@oh-my-pi/pi-coding-agent 11.2.3 → 11.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/CHANGELOG.md +119 -4
  2. package/examples/extensions/plan-mode.ts +1 -1
  3. package/examples/hooks/qna.ts +1 -1
  4. package/examples/hooks/status-line.ts +1 -1
  5. package/examples/sdk/11-sessions.ts +1 -1
  6. package/package.json +8 -8
  7. package/src/cli/args.ts +9 -6
  8. package/src/cli/update-cli.ts +2 -2
  9. package/src/commands/index/index.ts +2 -5
  10. package/src/commit/agentic/agent.ts +1 -1
  11. package/src/commit/changelog/index.ts +2 -2
  12. package/src/config/keybindings.ts +16 -1
  13. package/src/config/model-registry.ts +25 -20
  14. package/src/config/model-resolver.ts +8 -8
  15. package/src/config/resolve-config-value.ts +92 -0
  16. package/src/config/settings-schema.ts +9 -0
  17. package/src/config.ts +14 -1
  18. package/src/export/html/template.css +7 -0
  19. package/src/export/html/template.generated.ts +1 -1
  20. package/src/export/html/template.js +33 -16
  21. package/src/extensibility/custom-commands/bundled/review/index.ts +1 -1
  22. package/src/extensibility/extensions/index.ts +18 -0
  23. package/src/extensibility/extensions/loader.ts +15 -0
  24. package/src/extensibility/extensions/runner.ts +78 -1
  25. package/src/extensibility/extensions/types.ts +131 -5
  26. package/src/extensibility/extensions/wrapper.ts +1 -1
  27. package/src/extensibility/plugins/git-url.ts +270 -0
  28. package/src/extensibility/plugins/index.ts +2 -0
  29. package/src/extensibility/slash-commands.ts +45 -0
  30. package/src/index.ts +7 -0
  31. package/src/lsp/render.ts +50 -43
  32. package/src/lsp/utils.ts +2 -2
  33. package/src/main.ts +11 -10
  34. package/src/mcp/transports/stdio.ts +3 -5
  35. package/src/modes/components/custom-message.ts +0 -8
  36. package/src/modes/components/diff.ts +41 -13
  37. package/src/modes/components/footer.ts +4 -4
  38. package/src/modes/components/model-selector.ts +4 -0
  39. package/src/modes/components/todo-display.ts +13 -3
  40. package/src/modes/components/tool-execution.ts +30 -16
  41. package/src/modes/components/tree-selector.ts +50 -19
  42. package/src/modes/controllers/event-controller.ts +1 -0
  43. package/src/modes/controllers/extension-ui-controller.ts +34 -2
  44. package/src/modes/controllers/input-controller.ts +47 -33
  45. package/src/modes/controllers/selector-controller.ts +10 -15
  46. package/src/modes/interactive-mode.ts +50 -38
  47. package/src/modes/print-mode.ts +6 -0
  48. package/src/modes/rpc/rpc-client.ts +4 -4
  49. package/src/modes/rpc/rpc-mode.ts +17 -2
  50. package/src/modes/rpc/rpc-types.ts +2 -2
  51. package/src/modes/types.ts +1 -0
  52. package/src/modes/utils/ui-helpers.ts +3 -1
  53. package/src/patch/applicator.ts +106 -4
  54. package/src/patch/fuzzy.ts +1 -1
  55. package/src/patch/shared.ts +77 -63
  56. package/src/prompts/system/plan-mode-active.md +6 -6
  57. package/src/prompts/system/system-prompt.md +2 -1
  58. package/src/prompts/tools/ask.md +2 -2
  59. package/src/prompts/tools/gemini-image.md +2 -2
  60. package/src/prompts/tools/lsp.md +2 -2
  61. package/src/prompts/tools/patch.md +1 -1
  62. package/src/prompts/tools/python.md +3 -3
  63. package/src/prompts/tools/task.md +7 -1
  64. package/src/prompts/tools/todo-write.md +2 -2
  65. package/src/prompts/tools/web-search.md +2 -2
  66. package/src/prompts/tools/write.md +2 -5
  67. package/src/sdk.ts +15 -11
  68. package/src/session/agent-session.ts +92 -34
  69. package/src/session/auth-storage.ts +2 -1
  70. package/src/session/blob-store.ts +105 -0
  71. package/src/session/session-manager.ts +107 -44
  72. package/src/task/executor.ts +19 -9
  73. package/src/task/render.ts +80 -58
  74. package/src/tools/ask.ts +28 -5
  75. package/src/tools/bash.ts +47 -39
  76. package/src/tools/browser.ts +248 -26
  77. package/src/tools/calculator.ts +42 -23
  78. package/src/tools/fetch.ts +33 -16
  79. package/src/tools/find.ts +57 -22
  80. package/src/tools/grep.ts +54 -25
  81. package/src/tools/index.ts +5 -5
  82. package/src/tools/notebook.ts +19 -6
  83. package/src/tools/path-utils.ts +26 -1
  84. package/src/tools/python.ts +20 -14
  85. package/src/tools/read.ts +21 -8
  86. package/src/tools/render-utils.ts +5 -45
  87. package/src/tools/ssh.ts +59 -53
  88. package/src/tools/submit-result.ts +2 -2
  89. package/src/tools/todo-write.ts +32 -14
  90. package/src/tools/truncate.ts +1 -1
  91. package/src/tools/write.ts +42 -26
  92. package/src/tui/code-cell.ts +1 -1
  93. package/src/tui/output-block.ts +61 -3
  94. package/src/tui/tree-list.ts +4 -4
  95. package/src/tui/utils.ts +71 -1
  96. package/src/utils/frontmatter.ts +1 -1
  97. package/src/utils/title-generator.ts +1 -1
  98. package/src/utils/tools-manager.ts +18 -2
  99. package/src/web/scrapers/osv.ts +4 -1
  100. package/src/web/scrapers/youtube.ts +1 -1
  101. package/src/web/search/index.ts +1 -1
  102. package/src/web/search/render.ts +96 -90
@@ -269,6 +269,12 @@ async function installPythonPackage(pkg: string, signal?: AbortSignal): Promise<
269
269
  return false;
270
270
  }
271
271
 
272
+ // Termux package names for tools
273
+ const TERMUX_PACKAGES: Partial<Record<ToolName, string>> = {
274
+ sd: "sd",
275
+ sg: "ast-grep",
276
+ };
277
+
272
278
  // Ensure a tool is available, downloading if necessary
273
279
  // Returns the path to the tool, or null if unavailable
274
280
  type EnsureToolOptions = {
@@ -284,13 +290,23 @@ export async function ensureTool(tool: ToolName, silentOrOptions?: EnsureToolOpt
284
290
  return existingPath;
285
291
  }
286
292
 
293
+ // On Android/Termux, Linux binaries don't work due to Bionic libc incompatibility.
294
+ // Users must install via pkg.
295
+ if (os.platform() === "android") {
296
+ const pkgName = TERMUX_PACKAGES[tool] ?? tool;
297
+ if (!silent) {
298
+ logger.warn(`${TOOLS[tool]?.name ?? tool} not found. Install with: pkg install ${pkgName}`);
299
+ }
300
+ return undefined;
301
+ }
302
+
287
303
  // Handle Python tools
288
304
  const pythonConfig = PYTHON_TOOLS[tool];
289
305
  if (pythonConfig) {
290
306
  if (!silent) {
291
307
  logger.debug(`${pythonConfig.name} not found. Installing via uv/pip...`);
292
308
  }
293
- notify?.(`Installing ${pythonConfig.name}...`);
309
+ notify?.(`Installing ${pythonConfig.name}…`);
294
310
  const success = await installPythonPackage(pythonConfig.package, signal);
295
311
  if (success) {
296
312
  // Re-check for the command after installation
@@ -315,7 +331,7 @@ export async function ensureTool(tool: ToolName, silentOrOptions?: EnsureToolOpt
315
331
  if (!silent) {
316
332
  logger.debug(`${config.name} not found. Downloading...`);
317
333
  }
318
- notify?.(`Downloading ${config.name}...`);
334
+ notify?.(`Downloading ${config.name}…`);
319
335
 
320
336
  try {
321
337
  const path = await downloadTool(tool, signal);
@@ -145,8 +145,11 @@ export const handleOsv: SpecialHandler = async (
145
145
  if (affected.versions?.length) {
146
146
  const versions =
147
147
  affected.versions.length > 10
148
- ? `${affected.versions.slice(0, 10).join(", ")}... (${affected.versions.length} total)`
148
+ ? `${affected.versions.slice(0, 10).join(", ")} (${affected.versions.length} total)`
149
149
  : affected.versions.join(", ");
150
+ affected.versions.length > 10
151
+ ? `${affected.versions.slice(0, 10).join(", ")}… (${affected.versions.length} total)`
152
+ : affected.versions.join(", ");
150
153
  md += `- **Versions:** ${versions}\n`;
151
154
  }
152
155
 
@@ -287,7 +287,7 @@ export const handleYouTube: SpecialHandler = async (
287
287
 
288
288
  if (description) {
289
289
  // Truncate long descriptions
290
- const descPreview = description.length > 1000 ? `${description.slice(0, 1000)}...` : description;
290
+ const descPreview = description.length > 1000 ? `${description.slice(0, 1000)}…` : description;
291
291
  md += `---\n\n## Description\n\n${descPreview}\n\n`;
292
292
  }
293
293
 
@@ -79,7 +79,7 @@ function formatProviderError(error: unknown, provider: SearchProvider): string {
79
79
  /** Truncate text for tool output */
80
80
  function truncateText(text: string, maxLen: number): string {
81
81
  if (text.length <= maxLen) return text;
82
- return `${text.slice(0, Math.max(0, maxLen - 3))}...`;
82
+ return `${text.slice(0, Math.max(0, maxLen - 1))}…`;
83
83
  }
84
84
 
85
85
  function formatCount(label: string, count: number): string {
@@ -21,7 +21,8 @@ import {
21
21
  TRUNCATE_LENGTHS,
22
22
  truncateToWidth,
23
23
  } from "../../tools/render-utils";
24
- import { renderOutputBlock, renderStatusLine, renderTreeList } from "../../tui";
24
+ import { renderStatusLine, renderTreeList } from "../../tui";
25
+ import { CachedOutputBlock } from "../../tui/output-block";
25
26
  import type { SearchResponse } from "./types";
26
27
 
27
28
  const MAX_COLLAPSED_ANSWER_LINES = PREVIEW_LIMITS.COLLAPSED_LINES;
@@ -79,7 +80,6 @@ export function renderSearchResult(
79
80
  maxAnswerLines?: number;
80
81
  },
81
82
  ): Component {
82
- const { expanded } = options;
83
83
  const details = result.details;
84
84
 
85
85
  // Handle error case
@@ -90,7 +90,7 @@ export function renderSearchResult(
90
90
  const rawText = result.content?.find(block => block.type === "text")?.text?.trim() ?? "";
91
91
  const response = details?.response;
92
92
  if (!response) {
93
- return renderFallbackText(rawText, expanded, theme);
93
+ return renderFallbackText(rawText, options.expanded, theme);
94
94
  }
95
95
 
96
96
  const sources = Array.isArray(response.sources) ? response.sources : [];
@@ -112,12 +112,6 @@ export function renderSearchResult(
112
112
  .map(l => l.trim())
113
113
  : [];
114
114
  const totalAnswerLines = answerLines.length;
115
- const answerLimit = expanded ? MAX_EXPANDED_ANSWER_LINES : MAX_COLLAPSED_ANSWER_LINES;
116
- const answerPreview = contentText
117
- ? args?.allowLongAnswer
118
- ? answerLines.slice(0, args.maxAnswerLines ?? answerLines.length)
119
- : getPreviewLines(contentText, answerLimit, MAX_ANSWER_LINE_LEN)
120
- : [];
121
115
 
122
116
  const providerLabel = provider !== "none" ? getSearchProvider(provider).label : "None";
123
117
  const queryPreview = args?.query
@@ -135,46 +129,6 @@ export function renderSearchResult(
135
129
  theme,
136
130
  );
137
131
 
138
- const remainingAnswer = totalAnswerLines - answerPreview.length;
139
-
140
- const sourceTree = renderTreeList(
141
- {
142
- items: sources,
143
- expanded,
144
- maxCollapsed: MAX_COLLAPSED_ITEMS,
145
- itemType: "source",
146
- renderItem: src => {
147
- const titleText =
148
- typeof src.title === "string" && src.title.trim()
149
- ? src.title
150
- : typeof src.url === "string" && src.url.trim()
151
- ? src.url
152
- : "Untitled";
153
- const title = truncateToWidth(titleText, 70);
154
- const url = typeof src.url === "string" ? src.url : "";
155
- const domain = url ? getDomain(url) : "";
156
- const age = formatAge(src.ageSeconds) || (typeof src.publishedDate === "string" ? src.publishedDate : "");
157
- const metaParts: string[] = [];
158
- if (domain) metaParts.push(theme.fg("dim", `(${domain})`));
159
- if (typeof src.author === "string" && src.author.trim()) metaParts.push(theme.fg("muted", src.author));
160
- if (age) metaParts.push(theme.fg("muted", age));
161
- const metaSep = theme.fg("dim", theme.sep.dot);
162
- const metaSuffix = metaParts.length > 0 ? ` ${metaParts.join(metaSep)}` : "";
163
- const lines: string[] = [`${theme.fg("accent", title)}${metaSuffix}`];
164
- const snippetText = typeof src.snippet === "string" ? src.snippet : "";
165
- if (snippetText.trim()) {
166
- const snippetLines = getPreviewLines(snippetText, MAX_SNIPPET_LINES, MAX_SNIPPET_LINE_LEN);
167
- for (const snippetLine of snippetLines) {
168
- lines.push(theme.fg("muted", `${theme.format.dash} ${snippetLine}`));
169
- }
170
- }
171
- if (url) lines.push(theme.fg("mdLinkUrl", url));
172
- return lines;
173
- },
174
- },
175
- theme,
176
- );
177
-
178
132
  const metaLines: string[] = [];
179
133
  metaLines.push(`${theme.fg("muted", "Provider:")} ${theme.fg("text", providerLabel)}`);
180
134
  if (response.model) metaLines.push(`${theme.fg("muted", "Model:")} ${theme.fg("text", response.model)}`);
@@ -202,9 +156,94 @@ export function renderSearchResult(
202
156
  metaLines.push(`${theme.fg("muted", "Queries:")} ${theme.fg("text", queryList.join("; "))}${suffix}`);
203
157
  }
204
158
 
159
+ const outputBlock = new CachedOutputBlock();
160
+
205
161
  return {
206
- render: (width: number) =>
207
- renderOutputBlock(
162
+ render(width: number): string[] {
163
+ // Read mutable state at render time
164
+ const { expanded } = options;
165
+
166
+ // Expanded-dependent computations
167
+ const answerLimit = expanded ? MAX_EXPANDED_ANSWER_LINES : MAX_COLLAPSED_ANSWER_LINES;
168
+ const answerPreview = contentText
169
+ ? args?.allowLongAnswer
170
+ ? answerLines.slice(0, args.maxAnswerLines ?? answerLines.length)
171
+ : getPreviewLines(contentText, answerLimit, MAX_ANSWER_LINE_LEN)
172
+ : [];
173
+ const remainingAnswer = totalAnswerLines - answerPreview.length;
174
+
175
+ const sourceTree = renderTreeList(
176
+ {
177
+ items: sources,
178
+ expanded,
179
+ maxCollapsed: MAX_COLLAPSED_ITEMS,
180
+ itemType: "source",
181
+ renderItem: src => {
182
+ const titleText =
183
+ typeof src.title === "string" && src.title.trim()
184
+ ? src.title
185
+ : typeof src.url === "string" && src.url.trim()
186
+ ? src.url
187
+ : "Untitled";
188
+ const title = truncateToWidth(titleText, 70);
189
+ const url = typeof src.url === "string" ? src.url : "";
190
+ const domain = url ? getDomain(url) : "";
191
+ const age =
192
+ formatAge(src.ageSeconds) || (typeof src.publishedDate === "string" ? src.publishedDate : "");
193
+ const metaParts: string[] = [];
194
+ if (domain) metaParts.push(theme.fg("dim", `(${domain})`));
195
+ if (typeof src.author === "string" && src.author.trim())
196
+ metaParts.push(theme.fg("muted", src.author));
197
+ if (age) metaParts.push(theme.fg("muted", age));
198
+ const metaSep = theme.fg("dim", theme.sep.dot);
199
+ const metaSuffix = metaParts.length > 0 ? ` ${metaParts.join(metaSep)}` : "";
200
+ const srcLines: string[] = [`${theme.fg("accent", title)}${metaSuffix}`];
201
+ const snippetText = typeof src.snippet === "string" ? src.snippet : "";
202
+ if (snippetText.trim()) {
203
+ const snippetLines = getPreviewLines(snippetText, MAX_SNIPPET_LINES, MAX_SNIPPET_LINE_LEN);
204
+ for (const snippetLine of snippetLines) {
205
+ srcLines.push(theme.fg("muted", `${theme.format.dash} ${snippetLine}`));
206
+ }
207
+ }
208
+ if (url) srcLines.push(theme.fg("mdLinkUrl", url));
209
+ return srcLines;
210
+ },
211
+ },
212
+ theme,
213
+ );
214
+
215
+ // Build answer section
216
+ const answerState = sourceCount > 0 ? "success" : "warning";
217
+ const borderColor: "warning" | "dim" = answerState === "warning" ? "warning" : "dim";
218
+ const border = (t: string) => theme.fg(borderColor, t);
219
+ const contentPrefix = border(`${theme.boxSharp.vertical} `);
220
+ const contentSuffix = border(theme.boxSharp.vertical);
221
+ const contentWidth = Math.max(0, width - visibleWidth(contentPrefix) - visibleWidth(contentSuffix));
222
+ const answerTreeLines = answerPreview.length > 0 ? answerPreview : ["No answer text returned"];
223
+ const answerTree = renderTreeList(
224
+ {
225
+ items: answerTreeLines,
226
+ expanded: true,
227
+ maxCollapsed: answerTreeLines.length,
228
+ itemType: "line",
229
+ renderItem: (line, context) => {
230
+ const coloredLine =
231
+ line === "No answer text returned" ? theme.fg("muted", line) : theme.fg("dim", line);
232
+ if (!args?.allowLongAnswer) {
233
+ return coloredLine;
234
+ }
235
+ const prefixWidth = visibleWidth(context.continuePrefix);
236
+ const wrapWidth = Math.max(10, contentWidth - prefixWidth);
237
+ return wrapTextWithAnsi(coloredLine, wrapWidth);
238
+ },
239
+ },
240
+ theme,
241
+ );
242
+ if (remainingAnswer > 0) {
243
+ answerTree.push(theme.fg("muted", formatMoreItems(remainingAnswer, "line")));
244
+ }
245
+
246
+ return outputBlock.render(
208
247
  {
209
248
  header,
210
249
  state: sourceCount > 0 ? "success" : "warning",
@@ -218,43 +257,7 @@ export function renderSearchResult(
218
257
  : []),
219
258
  {
220
259
  label: theme.fg("toolTitle", "Answer"),
221
- lines: (() => {
222
- const state = sourceCount > 0 ? "success" : "warning";
223
- const borderColor: "warning" | "dim" = state === "warning" ? "warning" : "dim";
224
- const border = (text: string) => theme.fg(borderColor, text);
225
- const contentPrefix = border(`${theme.boxSharp.vertical} `);
226
- const contentSuffix = border(theme.boxSharp.vertical);
227
- const contentWidth = Math.max(
228
- 0,
229
- width - visibleWidth(contentPrefix) - visibleWidth(contentSuffix),
230
- );
231
- const answerTreeLines = answerPreview.length > 0 ? answerPreview : ["No answer text returned"];
232
- const answerTree = renderTreeList(
233
- {
234
- items: answerTreeLines,
235
- expanded: true,
236
- maxCollapsed: answerTreeLines.length,
237
- itemType: "line",
238
- renderItem: (line, context) => {
239
- const coloredLine =
240
- line === "No answer text returned"
241
- ? theme.fg("muted", line)
242
- : theme.fg("dim", line);
243
- if (!args?.allowLongAnswer) {
244
- return coloredLine;
245
- }
246
- const prefixWidth = visibleWidth(context.continuePrefix);
247
- const wrapWidth = Math.max(10, contentWidth - prefixWidth);
248
- return wrapTextWithAnsi(coloredLine, wrapWidth);
249
- },
250
- },
251
- theme,
252
- );
253
- if (remainingAnswer > 0) {
254
- answerTree.push(theme.fg("muted", formatMoreItems(remainingAnswer, "line")));
255
- }
256
- return answerTree;
257
- })(),
260
+ lines: answerTree,
258
261
  },
259
262
  {
260
263
  label: theme.fg("toolTitle", "Sources"),
@@ -265,8 +268,11 @@ export function renderSearchResult(
265
268
  width,
266
269
  },
267
270
  theme,
268
- ),
269
- invalidate: () => {},
271
+ );
272
+ },
273
+ invalidate() {
274
+ outputBlock.invalidate();
275
+ },
270
276
  };
271
277
  }
272
278