@mariozechner/pi-coding-agent 0.61.1 → 0.63.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 (214) hide show
  1. package/CHANGELOG.md +107 -0
  2. package/README.md +2 -2
  3. package/dist/cli/file-processor.d.ts.map +1 -1
  4. package/dist/cli/file-processor.js +4 -0
  5. package/dist/cli/file-processor.js.map +1 -1
  6. package/dist/core/agent-session.d.ts +15 -6
  7. package/dist/core/agent-session.d.ts.map +1 -1
  8. package/dist/core/agent-session.js +94 -90
  9. package/dist/core/agent-session.js.map +1 -1
  10. package/dist/core/auth-storage.d.ts +3 -1
  11. package/dist/core/auth-storage.d.ts.map +1 -1
  12. package/dist/core/auth-storage.js +5 -2
  13. package/dist/core/auth-storage.js.map +1 -1
  14. package/dist/core/compaction/branch-summarization.d.ts +2 -0
  15. package/dist/core/compaction/branch-summarization.d.ts.map +1 -1
  16. package/dist/core/compaction/branch-summarization.js +2 -2
  17. package/dist/core/compaction/branch-summarization.js.map +1 -1
  18. package/dist/core/compaction/compaction.d.ts +2 -2
  19. package/dist/core/compaction/compaction.d.ts.map +1 -1
  20. package/dist/core/compaction/compaction.js +9 -9
  21. package/dist/core/compaction/compaction.js.map +1 -1
  22. package/dist/core/export-html/index.d.ts +2 -2
  23. package/dist/core/export-html/index.d.ts.map +1 -1
  24. package/dist/core/export-html/index.js +7 -6
  25. package/dist/core/export-html/index.js.map +1 -1
  26. package/dist/core/export-html/tool-renderer.d.ts +2 -2
  27. package/dist/core/export-html/tool-renderer.d.ts.map +1 -1
  28. package/dist/core/export-html/tool-renderer.js +41 -16
  29. package/dist/core/export-html/tool-renderer.js.map +1 -1
  30. package/dist/core/extensions/index.d.ts +3 -2
  31. package/dist/core/extensions/index.d.ts.map +1 -1
  32. package/dist/core/extensions/index.js.map +1 -1
  33. package/dist/core/extensions/loader.d.ts.map +1 -1
  34. package/dist/core/extensions/loader.js +12 -2
  35. package/dist/core/extensions/loader.js.map +1 -1
  36. package/dist/core/extensions/runner.d.ts +4 -7
  37. package/dist/core/extensions/runner.d.ts.map +1 -1
  38. package/dist/core/extensions/runner.js +27 -38
  39. package/dist/core/extensions/runner.js.map +1 -1
  40. package/dist/core/extensions/types.d.ts +44 -9
  41. package/dist/core/extensions/types.d.ts.map +1 -1
  42. package/dist/core/extensions/types.js.map +1 -1
  43. package/dist/core/extensions/wrapper.d.ts.map +1 -1
  44. package/dist/core/extensions/wrapper.js +2 -8
  45. package/dist/core/extensions/wrapper.js.map +1 -1
  46. package/dist/core/index.d.ts +1 -0
  47. package/dist/core/index.d.ts.map +1 -1
  48. package/dist/core/index.js +1 -0
  49. package/dist/core/index.js.map +1 -1
  50. package/dist/core/model-registry.d.ts +18 -2
  51. package/dist/core/model-registry.d.ts.map +1 -1
  52. package/dist/core/model-registry.js +83 -69
  53. package/dist/core/model-registry.js.map +1 -1
  54. package/dist/core/model-resolver.d.ts.map +1 -1
  55. package/dist/core/model-resolver.js +4 -4
  56. package/dist/core/model-resolver.js.map +1 -1
  57. package/dist/core/output-guard.d.ts +6 -0
  58. package/dist/core/output-guard.d.ts.map +1 -0
  59. package/dist/core/output-guard.js +59 -0
  60. package/dist/core/output-guard.js.map +1 -0
  61. package/dist/core/package-manager.d.ts +1 -0
  62. package/dist/core/package-manager.d.ts.map +1 -1
  63. package/dist/core/package-manager.js +77 -10
  64. package/dist/core/package-manager.js.map +1 -1
  65. package/dist/core/prompt-templates.d.ts +2 -1
  66. package/dist/core/prompt-templates.d.ts.map +1 -1
  67. package/dist/core/prompt-templates.js +30 -32
  68. package/dist/core/prompt-templates.js.map +1 -1
  69. package/dist/core/resolve-config-value.d.ts +6 -0
  70. package/dist/core/resolve-config-value.d.ts.map +1 -1
  71. package/dist/core/resolve-config-value.js +37 -5
  72. package/dist/core/resolve-config-value.js.map +1 -1
  73. package/dist/core/resource-loader.d.ts +6 -5
  74. package/dist/core/resource-loader.d.ts.map +1 -1
  75. package/dist/core/resource-loader.js +136 -108
  76. package/dist/core/resource-loader.js.map +1 -1
  77. package/dist/core/sdk.d.ts +2 -2
  78. package/dist/core/sdk.d.ts.map +1 -1
  79. package/dist/core/sdk.js +13 -22
  80. package/dist/core/sdk.js.map +1 -1
  81. package/dist/core/settings-manager.d.ts +2 -0
  82. package/dist/core/settings-manager.d.ts.map +1 -1
  83. package/dist/core/settings-manager.js +3 -0
  84. package/dist/core/settings-manager.js.map +1 -1
  85. package/dist/core/skills.d.ts +2 -1
  86. package/dist/core/skills.d.ts.map +1 -1
  87. package/dist/core/skills.js +25 -1
  88. package/dist/core/skills.js.map +1 -1
  89. package/dist/core/slash-commands.d.ts +2 -3
  90. package/dist/core/slash-commands.d.ts.map +1 -1
  91. package/dist/core/slash-commands.js.map +1 -1
  92. package/dist/core/source-info.d.ts +18 -0
  93. package/dist/core/source-info.d.ts.map +1 -0
  94. package/dist/core/source-info.js +19 -0
  95. package/dist/core/source-info.js.map +1 -0
  96. package/dist/core/system-prompt.d.ts.map +1 -1
  97. package/dist/core/system-prompt.js +3 -38
  98. package/dist/core/system-prompt.js.map +1 -1
  99. package/dist/core/timings.d.ts +1 -0
  100. package/dist/core/timings.d.ts.map +1 -1
  101. package/dist/core/timings.js +6 -0
  102. package/dist/core/timings.js.map +1 -1
  103. package/dist/core/tools/bash.d.ts +19 -9
  104. package/dist/core/tools/bash.d.ts.map +1 -1
  105. package/dist/core/tools/bash.js +151 -59
  106. package/dist/core/tools/bash.js.map +1 -1
  107. package/dist/core/tools/edit-diff.d.ts +23 -1
  108. package/dist/core/tools/edit-diff.d.ts.map +1 -1
  109. package/dist/core/tools/edit-diff.js +100 -32
  110. package/dist/core/tools/edit-diff.js.map +1 -1
  111. package/dist/core/tools/edit.d.ts +30 -6
  112. package/dist/core/tools/edit.d.ts.map +1 -1
  113. package/dist/core/tools/edit.js +172 -59
  114. package/dist/core/tools/edit.js.map +1 -1
  115. package/dist/core/tools/file-mutation-queue.d.ts.map +1 -1
  116. package/dist/core/tools/file-mutation-queue.js +4 -4
  117. package/dist/core/tools/file-mutation-queue.js.map +1 -1
  118. package/dist/core/tools/find.d.ts +11 -4
  119. package/dist/core/tools/find.d.ts.map +1 -1
  120. package/dist/core/tools/find.js +76 -27
  121. package/dist/core/tools/find.js.map +1 -1
  122. package/dist/core/tools/grep.d.ts +15 -4
  123. package/dist/core/tools/grep.d.ts.map +1 -1
  124. package/dist/core/tools/grep.js +83 -29
  125. package/dist/core/tools/grep.js.map +1 -1
  126. package/dist/core/tools/index.d.ts +67 -21
  127. package/dist/core/tools/index.d.ts.map +1 -1
  128. package/dist/core/tools/index.js +50 -26
  129. package/dist/core/tools/index.js.map +1 -1
  130. package/dist/core/tools/ls.d.ts +9 -3
  131. package/dist/core/tools/ls.d.ts.map +1 -1
  132. package/dist/core/tools/ls.js +67 -13
  133. package/dist/core/tools/ls.js.map +1 -1
  134. package/dist/core/tools/read.d.ts +10 -3
  135. package/dist/core/tools/read.d.ts.map +1 -1
  136. package/dist/core/tools/read.js +110 -51
  137. package/dist/core/tools/read.js.map +1 -1
  138. package/dist/core/tools/render-utils.d.ts +21 -0
  139. package/dist/core/tools/render-utils.d.ts.map +1 -0
  140. package/dist/core/tools/render-utils.js +49 -0
  141. package/dist/core/tools/render-utils.js.map +1 -0
  142. package/dist/core/tools/tool-definition-wrapper.d.ts +14 -0
  143. package/dist/core/tools/tool-definition-wrapper.d.ts.map +1 -0
  144. package/dist/core/tools/tool-definition-wrapper.js +30 -0
  145. package/dist/core/tools/tool-definition-wrapper.js.map +1 -0
  146. package/dist/core/tools/write.d.ts +9 -3
  147. package/dist/core/tools/write.d.ts.map +1 -1
  148. package/dist/core/tools/write.js +162 -27
  149. package/dist/core/tools/write.js.map +1 -1
  150. package/dist/index.d.ts +3 -2
  151. package/dist/index.d.ts.map +1 -1
  152. package/dist/index.js +2 -1
  153. package/dist/index.js.map +1 -1
  154. package/dist/main.d.ts.map +1 -1
  155. package/dist/main.js +56 -18
  156. package/dist/main.js.map +1 -1
  157. package/dist/modes/interactive/components/bash-execution.d.ts +0 -1
  158. package/dist/modes/interactive/components/bash-execution.d.ts.map +1 -1
  159. package/dist/modes/interactive/components/bash-execution.js +18 -5
  160. package/dist/modes/interactive/components/bash-execution.js.map +1 -1
  161. package/dist/modes/interactive/components/tool-execution.d.ts +15 -40
  162. package/dist/modes/interactive/components/tool-execution.d.ts.map +1 -1
  163. package/dist/modes/interactive/components/tool-execution.js +126 -679
  164. package/dist/modes/interactive/components/tool-execution.js.map +1 -1
  165. package/dist/modes/interactive/interactive-mode.d.ts +4 -11
  166. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  167. package/dist/modes/interactive/interactive-mode.js +146 -93
  168. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  169. package/dist/modes/interactive/theme/theme.d.ts +3 -0
  170. package/dist/modes/interactive/theme/theme.d.ts.map +1 -1
  171. package/dist/modes/interactive/theme/theme.js +14 -0
  172. package/dist/modes/interactive/theme/theme.js.map +1 -1
  173. package/dist/modes/print-mode.d.ts +1 -1
  174. package/dist/modes/print-mode.d.ts.map +1 -1
  175. package/dist/modes/print-mode.js +84 -78
  176. package/dist/modes/print-mode.js.map +1 -1
  177. package/dist/modes/rpc/rpc-mode.d.ts.map +1 -1
  178. package/dist/modes/rpc/rpc-mode.js +27 -20
  179. package/dist/modes/rpc/rpc-mode.js.map +1 -1
  180. package/dist/modes/rpc/rpc-types.d.ts +3 -4
  181. package/dist/modes/rpc/rpc-types.d.ts.map +1 -1
  182. package/dist/modes/rpc/rpc-types.js.map +1 -1
  183. package/dist/utils/image-resize.d.ts +5 -5
  184. package/dist/utils/image-resize.d.ts.map +1 -1
  185. package/dist/utils/image-resize.js +45 -94
  186. package/dist/utils/image-resize.js.map +1 -1
  187. package/docs/development.md +3 -1
  188. package/docs/extensions.md +74 -33
  189. package/docs/models.md +6 -0
  190. package/docs/rpc.md +11 -2
  191. package/docs/settings.md +12 -0
  192. package/docs/tui.md +2 -2
  193. package/examples/extensions/built-in-tool-renderer.ts +8 -8
  194. package/examples/extensions/commands.ts +3 -3
  195. package/examples/extensions/custom-compaction.ts +17 -4
  196. package/examples/extensions/custom-provider-anthropic/package-lock.json +2 -2
  197. package/examples/extensions/custom-provider-anthropic/package.json +1 -1
  198. package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
  199. package/examples/extensions/custom-provider-qwen-cli/package.json +1 -1
  200. package/examples/extensions/handoff.ts +5 -2
  201. package/examples/extensions/minimal-mode.ts +14 -14
  202. package/examples/extensions/qna.ts +5 -2
  203. package/examples/extensions/question.ts +2 -2
  204. package/examples/extensions/questionnaire.ts +2 -2
  205. package/examples/extensions/subagent/index.ts +2 -2
  206. package/examples/extensions/summarize.ts +15 -4
  207. package/examples/extensions/todo.ts +2 -2
  208. package/examples/extensions/truncated-tool.ts +2 -2
  209. package/examples/extensions/with-deps/package-lock.json +2 -2
  210. package/examples/extensions/with-deps/package.json +1 -1
  211. package/examples/sdk/04-skills.ts +8 -2
  212. package/examples/sdk/08-prompt-templates.ts +2 -1
  213. package/examples/sdk/12-full-control.ts +0 -1
  214. package/package.json +5 -4
@@ -15,19 +15,19 @@ export interface ResizedImage {
15
15
  wasResized: boolean;
16
16
  }
17
17
  /**
18
- * Resize an image to fit within the specified max dimensions and file size.
19
- * Returns the original image if it already fits within the limits.
18
+ * Resize an image to fit within the specified max dimensions and encoded file size.
19
+ * Returns null if the image cannot be resized below maxBytes.
20
20
  *
21
21
  * Uses Photon (Rust/WASM) for image processing. If Photon is not available,
22
- * returns the original image unchanged.
22
+ * returns null.
23
23
  *
24
24
  * Strategy for staying under maxBytes:
25
25
  * 1. First resize to maxWidth/maxHeight
26
26
  * 2. Try both PNG and JPEG formats, pick the smaller one
27
27
  * 3. If still too large, try JPEG with decreasing quality
28
- * 4. If still too large, progressively reduce dimensions
28
+ * 4. If still too large, progressively reduce dimensions until 1x1
29
29
  */
30
- export declare function resizeImage(img: ImageContent, options?: ImageResizeOptions): Promise<ResizedImage>;
30
+ export declare function resizeImage(img: ImageContent, options?: ImageResizeOptions): Promise<ResizedImage | null>;
31
31
  /**
32
32
  * Format a dimension note for resized images.
33
33
  * This helps the model understand the coordinate mapping.
@@ -1 +1 @@
1
- {"version":3,"file":"image-resize.d.ts","sourceRoot":"","sources":["../../src/utils/image-resize.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AAIxD,MAAM,WAAW,kBAAkB;IAClC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,WAAW,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,YAAY;IAC5B,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,aAAa,EAAE,MAAM,CAAC;IACtB,cAAc,EAAE,MAAM,CAAC;IACvB,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,OAAO,CAAC;CACpB;AAoBD;;;;;;;;;;;;GAYG;AACH,wBAAsB,WAAW,CAAC,GAAG,EAAE,YAAY,EAAE,OAAO,CAAC,EAAE,kBAAkB,GAAG,OAAO,CAAC,YAAY,CAAC,CAyKxG;AAED;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,YAAY,GAAG,MAAM,GAAG,SAAS,CAO5E","sourcesContent":["import type { ImageContent } from \"@mariozechner/pi-ai\";\nimport { applyExifOrientation } from \"./exif-orientation.js\";\nimport { loadPhoton } from \"./photon.js\";\n\nexport interface ImageResizeOptions {\n\tmaxWidth?: number; // Default: 2000\n\tmaxHeight?: number; // Default: 2000\n\tmaxBytes?: number; // Default: 4.5MB (below Anthropic's 5MB limit)\n\tjpegQuality?: number; // Default: 80\n}\n\nexport interface ResizedImage {\n\tdata: string; // base64\n\tmimeType: string;\n\toriginalWidth: number;\n\toriginalHeight: number;\n\twidth: number;\n\theight: number;\n\twasResized: boolean;\n}\n\n// 4.5MB - provides headroom below Anthropic's 5MB limit\nconst DEFAULT_MAX_BYTES = 4.5 * 1024 * 1024;\n\nconst DEFAULT_OPTIONS: Required<ImageResizeOptions> = {\n\tmaxWidth: 2000,\n\tmaxHeight: 2000,\n\tmaxBytes: DEFAULT_MAX_BYTES,\n\tjpegQuality: 80,\n};\n\n/** Helper to pick the smaller of two buffers */\nfunction pickSmaller(\n\ta: { buffer: Uint8Array; mimeType: string },\n\tb: { buffer: Uint8Array; mimeType: string },\n): { buffer: Uint8Array; mimeType: string } {\n\treturn a.buffer.length <= b.buffer.length ? a : b;\n}\n\n/**\n * Resize an image to fit within the specified max dimensions and file size.\n * Returns the original image if it already fits within the limits.\n *\n * Uses Photon (Rust/WASM) for image processing. If Photon is not available,\n * returns the original image unchanged.\n *\n * Strategy for staying under maxBytes:\n * 1. First resize to maxWidth/maxHeight\n * 2. Try both PNG and JPEG formats, pick the smaller one\n * 3. If still too large, try JPEG with decreasing quality\n * 4. If still too large, progressively reduce dimensions\n */\nexport async function resizeImage(img: ImageContent, options?: ImageResizeOptions): Promise<ResizedImage> {\n\tconst opts = { ...DEFAULT_OPTIONS, ...options };\n\tconst inputBuffer = Buffer.from(img.data, \"base64\");\n\n\tconst photon = await loadPhoton();\n\tif (!photon) {\n\t\t// Photon not available, return original image\n\t\treturn {\n\t\t\tdata: img.data,\n\t\t\tmimeType: img.mimeType,\n\t\t\toriginalWidth: 0,\n\t\t\toriginalHeight: 0,\n\t\t\twidth: 0,\n\t\t\theight: 0,\n\t\t\twasResized: false,\n\t\t};\n\t}\n\n\tlet image: ReturnType<typeof photon.PhotonImage.new_from_byteslice> | undefined;\n\ttry {\n\t\tconst inputBytes = new Uint8Array(inputBuffer);\n\t\tconst rawImage = photon.PhotonImage.new_from_byteslice(inputBytes);\n\t\timage = applyExifOrientation(photon, rawImage, inputBytes);\n\t\tif (image !== rawImage) rawImage.free();\n\n\t\tconst originalWidth = image.get_width();\n\t\tconst originalHeight = image.get_height();\n\t\tconst format = img.mimeType?.split(\"/\")[1] ?? \"png\";\n\n\t\t// Check if already within all limits (dimensions AND size)\n\t\tconst originalSize = inputBuffer.length;\n\t\tif (originalWidth <= opts.maxWidth && originalHeight <= opts.maxHeight && originalSize <= opts.maxBytes) {\n\t\t\treturn {\n\t\t\t\tdata: img.data,\n\t\t\t\tmimeType: img.mimeType ?? `image/${format}`,\n\t\t\t\toriginalWidth,\n\t\t\t\toriginalHeight,\n\t\t\t\twidth: originalWidth,\n\t\t\t\theight: originalHeight,\n\t\t\t\twasResized: false,\n\t\t\t};\n\t\t}\n\n\t\t// Calculate initial dimensions respecting max limits\n\t\tlet targetWidth = originalWidth;\n\t\tlet targetHeight = originalHeight;\n\n\t\tif (targetWidth > opts.maxWidth) {\n\t\t\ttargetHeight = Math.round((targetHeight * opts.maxWidth) / targetWidth);\n\t\t\ttargetWidth = opts.maxWidth;\n\t\t}\n\t\tif (targetHeight > opts.maxHeight) {\n\t\t\ttargetWidth = Math.round((targetWidth * opts.maxHeight) / targetHeight);\n\t\t\ttargetHeight = opts.maxHeight;\n\t\t}\n\n\t\t// Helper to resize and encode in both formats, returning the smaller one\n\t\tfunction tryBothFormats(\n\t\t\twidth: number,\n\t\t\theight: number,\n\t\t\tjpegQuality: number,\n\t\t): { buffer: Uint8Array; mimeType: string } {\n\t\t\tconst resized = photon!.resize(image!, width, height, photon!.SamplingFilter.Lanczos3);\n\n\t\t\ttry {\n\t\t\t\tconst pngBuffer = resized.get_bytes();\n\t\t\t\tconst jpegBuffer = resized.get_bytes_jpeg(jpegQuality);\n\n\t\t\t\treturn pickSmaller(\n\t\t\t\t\t{ buffer: pngBuffer, mimeType: \"image/png\" },\n\t\t\t\t\t{ buffer: jpegBuffer, mimeType: \"image/jpeg\" },\n\t\t\t\t);\n\t\t\t} finally {\n\t\t\t\tresized.free();\n\t\t\t}\n\t\t}\n\n\t\t// Try to produce an image under maxBytes\n\t\tconst qualitySteps = [85, 70, 55, 40];\n\t\tconst scaleSteps = [1.0, 0.75, 0.5, 0.35, 0.25];\n\n\t\tlet best: { buffer: Uint8Array; mimeType: string };\n\t\tlet finalWidth = targetWidth;\n\t\tlet finalHeight = targetHeight;\n\n\t\t// First attempt: resize to target dimensions, try both formats\n\t\tbest = tryBothFormats(targetWidth, targetHeight, opts.jpegQuality);\n\n\t\tif (best.buffer.length <= opts.maxBytes) {\n\t\t\treturn {\n\t\t\t\tdata: Buffer.from(best.buffer).toString(\"base64\"),\n\t\t\t\tmimeType: best.mimeType,\n\t\t\t\toriginalWidth,\n\t\t\t\toriginalHeight,\n\t\t\t\twidth: finalWidth,\n\t\t\t\theight: finalHeight,\n\t\t\t\twasResized: true,\n\t\t\t};\n\t\t}\n\n\t\t// Still too large - try JPEG with decreasing quality\n\t\tfor (const quality of qualitySteps) {\n\t\t\tbest = tryBothFormats(targetWidth, targetHeight, quality);\n\n\t\t\tif (best.buffer.length <= opts.maxBytes) {\n\t\t\t\treturn {\n\t\t\t\t\tdata: Buffer.from(best.buffer).toString(\"base64\"),\n\t\t\t\t\tmimeType: best.mimeType,\n\t\t\t\t\toriginalWidth,\n\t\t\t\t\toriginalHeight,\n\t\t\t\t\twidth: finalWidth,\n\t\t\t\t\theight: finalHeight,\n\t\t\t\t\twasResized: true,\n\t\t\t\t};\n\t\t\t}\n\t\t}\n\n\t\t// Still too large - reduce dimensions progressively\n\t\tfor (const scale of scaleSteps) {\n\t\t\tfinalWidth = Math.round(targetWidth * scale);\n\t\t\tfinalHeight = Math.round(targetHeight * scale);\n\n\t\t\tif (finalWidth < 100 || finalHeight < 100) {\n\t\t\t\tbreak;\n\t\t\t}\n\n\t\t\tfor (const quality of qualitySteps) {\n\t\t\t\tbest = tryBothFormats(finalWidth, finalHeight, quality);\n\n\t\t\t\tif (best.buffer.length <= opts.maxBytes) {\n\t\t\t\t\treturn {\n\t\t\t\t\t\tdata: Buffer.from(best.buffer).toString(\"base64\"),\n\t\t\t\t\t\tmimeType: best.mimeType,\n\t\t\t\t\t\toriginalWidth,\n\t\t\t\t\t\toriginalHeight,\n\t\t\t\t\t\twidth: finalWidth,\n\t\t\t\t\t\theight: finalHeight,\n\t\t\t\t\t\twasResized: true,\n\t\t\t\t\t};\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// Last resort: return smallest version we produced\n\t\treturn {\n\t\t\tdata: Buffer.from(best.buffer).toString(\"base64\"),\n\t\t\tmimeType: best.mimeType,\n\t\t\toriginalWidth,\n\t\t\toriginalHeight,\n\t\t\twidth: finalWidth,\n\t\t\theight: finalHeight,\n\t\t\twasResized: true,\n\t\t};\n\t} catch {\n\t\t// Failed to load image\n\t\treturn {\n\t\t\tdata: img.data,\n\t\t\tmimeType: img.mimeType,\n\t\t\toriginalWidth: 0,\n\t\t\toriginalHeight: 0,\n\t\t\twidth: 0,\n\t\t\theight: 0,\n\t\t\twasResized: false,\n\t\t};\n\t} finally {\n\t\tif (image) {\n\t\t\timage.free();\n\t\t}\n\t}\n}\n\n/**\n * Format a dimension note for resized images.\n * This helps the model understand the coordinate mapping.\n */\nexport function formatDimensionNote(result: ResizedImage): string | undefined {\n\tif (!result.wasResized) {\n\t\treturn undefined;\n\t}\n\n\tconst scale = result.originalWidth / result.width;\n\treturn `[Image: original ${result.originalWidth}x${result.originalHeight}, displayed at ${result.width}x${result.height}. Multiply coordinates by ${scale.toFixed(2)} to map to original image.]`;\n}\n"]}
1
+ {"version":3,"file":"image-resize.d.ts","sourceRoot":"","sources":["../../src/utils/image-resize.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AAIxD,MAAM,WAAW,kBAAkB;IAClC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,WAAW,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,YAAY;IAC5B,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,aAAa,EAAE,MAAM,CAAC;IACtB,cAAc,EAAE,MAAM,CAAC;IACvB,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,OAAO,CAAC;CACpB;AA2BD;;;;;;;;;;;;GAYG;AACH,wBAAsB,WAAW,CAAC,GAAG,EAAE,YAAY,EAAE,OAAO,CAAC,EAAE,kBAAkB,GAAG,OAAO,CAAC,YAAY,GAAG,IAAI,CAAC,CAuG/G;AAED;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,YAAY,GAAG,MAAM,GAAG,SAAS,CAO5E","sourcesContent":["import type { ImageContent } from \"@mariozechner/pi-ai\";\nimport { applyExifOrientation } from \"./exif-orientation.js\";\nimport { loadPhoton } from \"./photon.js\";\n\nexport interface ImageResizeOptions {\n\tmaxWidth?: number; // Default: 2000\n\tmaxHeight?: number; // Default: 2000\n\tmaxBytes?: number; // Default: 4.5MB of base64 payload (below Anthropic's 5MB limit)\n\tjpegQuality?: number; // Default: 80\n}\n\nexport interface ResizedImage {\n\tdata: string; // base64\n\tmimeType: string;\n\toriginalWidth: number;\n\toriginalHeight: number;\n\twidth: number;\n\theight: number;\n\twasResized: boolean;\n}\n\n// 4.5MB of base64 payload. Provides headroom below Anthropic's 5MB limit.\nconst DEFAULT_MAX_BYTES = 4.5 * 1024 * 1024;\n\nconst DEFAULT_OPTIONS: Required<ImageResizeOptions> = {\n\tmaxWidth: 2000,\n\tmaxHeight: 2000,\n\tmaxBytes: DEFAULT_MAX_BYTES,\n\tjpegQuality: 80,\n};\n\ninterface EncodedCandidate {\n\tdata: string;\n\tencodedSize: number;\n\tmimeType: string;\n}\n\nfunction encodeCandidate(buffer: Uint8Array, mimeType: string): EncodedCandidate {\n\tconst data = Buffer.from(buffer).toString(\"base64\");\n\treturn {\n\t\tdata,\n\t\tencodedSize: Buffer.byteLength(data, \"utf-8\"),\n\t\tmimeType,\n\t};\n}\n\n/**\n * Resize an image to fit within the specified max dimensions and encoded file size.\n * Returns null if the image cannot be resized below maxBytes.\n *\n * Uses Photon (Rust/WASM) for image processing. If Photon is not available,\n * returns null.\n *\n * Strategy for staying under maxBytes:\n * 1. First resize to maxWidth/maxHeight\n * 2. Try both PNG and JPEG formats, pick the smaller one\n * 3. If still too large, try JPEG with decreasing quality\n * 4. If still too large, progressively reduce dimensions until 1x1\n */\nexport async function resizeImage(img: ImageContent, options?: ImageResizeOptions): Promise<ResizedImage | null> {\n\tconst opts = { ...DEFAULT_OPTIONS, ...options };\n\tconst inputBuffer = Buffer.from(img.data, \"base64\");\n\tconst inputBase64Size = Buffer.byteLength(img.data, \"utf-8\");\n\n\tconst photon = await loadPhoton();\n\tif (!photon) {\n\t\treturn null;\n\t}\n\n\tlet image: ReturnType<typeof photon.PhotonImage.new_from_byteslice> | undefined;\n\ttry {\n\t\tconst inputBytes = new Uint8Array(inputBuffer);\n\t\tconst rawImage = photon.PhotonImage.new_from_byteslice(inputBytes);\n\t\timage = applyExifOrientation(photon, rawImage, inputBytes);\n\t\tif (image !== rawImage) rawImage.free();\n\n\t\tconst originalWidth = image.get_width();\n\t\tconst originalHeight = image.get_height();\n\t\tconst format = img.mimeType?.split(\"/\")[1] ?? \"png\";\n\n\t\t// Check if already within all limits (dimensions AND encoded size)\n\t\tif (originalWidth <= opts.maxWidth && originalHeight <= opts.maxHeight && inputBase64Size < opts.maxBytes) {\n\t\t\treturn {\n\t\t\t\tdata: img.data,\n\t\t\t\tmimeType: img.mimeType ?? `image/${format}`,\n\t\t\t\toriginalWidth,\n\t\t\t\toriginalHeight,\n\t\t\t\twidth: originalWidth,\n\t\t\t\theight: originalHeight,\n\t\t\t\twasResized: false,\n\t\t\t};\n\t\t}\n\n\t\t// Calculate initial dimensions respecting max limits\n\t\tlet targetWidth = originalWidth;\n\t\tlet targetHeight = originalHeight;\n\n\t\tif (targetWidth > opts.maxWidth) {\n\t\t\ttargetHeight = Math.round((targetHeight * opts.maxWidth) / targetWidth);\n\t\t\ttargetWidth = opts.maxWidth;\n\t\t}\n\t\tif (targetHeight > opts.maxHeight) {\n\t\t\ttargetWidth = Math.round((targetWidth * opts.maxHeight) / targetHeight);\n\t\t\ttargetHeight = opts.maxHeight;\n\t\t}\n\n\t\tfunction tryEncodings(width: number, height: number, jpegQualities: number[]): EncodedCandidate[] {\n\t\t\tconst resized = photon!.resize(image!, width, height, photon!.SamplingFilter.Lanczos3);\n\n\t\t\ttry {\n\t\t\t\tconst candidates: EncodedCandidate[] = [encodeCandidate(resized.get_bytes(), \"image/png\")];\n\t\t\t\tfor (const quality of jpegQualities) {\n\t\t\t\t\tcandidates.push(encodeCandidate(resized.get_bytes_jpeg(quality), \"image/jpeg\"));\n\t\t\t\t}\n\t\t\t\treturn candidates;\n\t\t\t} finally {\n\t\t\t\tresized.free();\n\t\t\t}\n\t\t}\n\n\t\tconst qualitySteps = Array.from(new Set([opts.jpegQuality, 85, 70, 55, 40]));\n\t\tlet currentWidth = targetWidth;\n\t\tlet currentHeight = targetHeight;\n\n\t\twhile (true) {\n\t\t\tconst candidates = tryEncodings(currentWidth, currentHeight, qualitySteps);\n\t\t\tfor (const candidate of candidates) {\n\t\t\t\tif (candidate.encodedSize < opts.maxBytes) {\n\t\t\t\t\treturn {\n\t\t\t\t\t\tdata: candidate.data,\n\t\t\t\t\t\tmimeType: candidate.mimeType,\n\t\t\t\t\t\toriginalWidth,\n\t\t\t\t\t\toriginalHeight,\n\t\t\t\t\t\twidth: currentWidth,\n\t\t\t\t\t\theight: currentHeight,\n\t\t\t\t\t\twasResized: true,\n\t\t\t\t\t};\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif (currentWidth === 1 && currentHeight === 1) {\n\t\t\t\tbreak;\n\t\t\t}\n\n\t\t\tconst nextWidth = currentWidth === 1 ? 1 : Math.max(1, Math.floor(currentWidth * 0.75));\n\t\t\tconst nextHeight = currentHeight === 1 ? 1 : Math.max(1, Math.floor(currentHeight * 0.75));\n\t\t\tif (nextWidth === currentWidth && nextHeight === currentHeight) {\n\t\t\t\tbreak;\n\t\t\t}\n\n\t\t\tcurrentWidth = nextWidth;\n\t\t\tcurrentHeight = nextHeight;\n\t\t}\n\n\t\treturn null;\n\t} catch {\n\t\treturn null;\n\t} finally {\n\t\tif (image) {\n\t\t\timage.free();\n\t\t}\n\t}\n}\n\n/**\n * Format a dimension note for resized images.\n * This helps the model understand the coordinate mapping.\n */\nexport function formatDimensionNote(result: ResizedImage): string | undefined {\n\tif (!result.wasResized) {\n\t\treturn undefined;\n\t}\n\n\tconst scale = result.originalWidth / result.width;\n\treturn `[Image: original ${result.originalWidth}x${result.originalHeight}, displayed at ${result.width}x${result.height}. Multiply coordinates by ${scale.toFixed(2)} to map to original image.]`;\n}\n"]}
@@ -1,6 +1,6 @@
1
1
  import { applyExifOrientation } from "./exif-orientation.js";
2
2
  import { loadPhoton } from "./photon.js";
3
- // 4.5MB - provides headroom below Anthropic's 5MB limit
3
+ // 4.5MB of base64 payload. Provides headroom below Anthropic's 5MB limit.
4
4
  const DEFAULT_MAX_BYTES = 4.5 * 1024 * 1024;
5
5
  const DEFAULT_OPTIONS = {
6
6
  maxWidth: 2000,
@@ -8,38 +8,34 @@ const DEFAULT_OPTIONS = {
8
8
  maxBytes: DEFAULT_MAX_BYTES,
9
9
  jpegQuality: 80,
10
10
  };
11
- /** Helper to pick the smaller of two buffers */
12
- function pickSmaller(a, b) {
13
- return a.buffer.length <= b.buffer.length ? a : b;
11
+ function encodeCandidate(buffer, mimeType) {
12
+ const data = Buffer.from(buffer).toString("base64");
13
+ return {
14
+ data,
15
+ encodedSize: Buffer.byteLength(data, "utf-8"),
16
+ mimeType,
17
+ };
14
18
  }
15
19
  /**
16
- * Resize an image to fit within the specified max dimensions and file size.
17
- * Returns the original image if it already fits within the limits.
20
+ * Resize an image to fit within the specified max dimensions and encoded file size.
21
+ * Returns null if the image cannot be resized below maxBytes.
18
22
  *
19
23
  * Uses Photon (Rust/WASM) for image processing. If Photon is not available,
20
- * returns the original image unchanged.
24
+ * returns null.
21
25
  *
22
26
  * Strategy for staying under maxBytes:
23
27
  * 1. First resize to maxWidth/maxHeight
24
28
  * 2. Try both PNG and JPEG formats, pick the smaller one
25
29
  * 3. If still too large, try JPEG with decreasing quality
26
- * 4. If still too large, progressively reduce dimensions
30
+ * 4. If still too large, progressively reduce dimensions until 1x1
27
31
  */
28
32
  export async function resizeImage(img, options) {
29
33
  const opts = { ...DEFAULT_OPTIONS, ...options };
30
34
  const inputBuffer = Buffer.from(img.data, "base64");
35
+ const inputBase64Size = Buffer.byteLength(img.data, "utf-8");
31
36
  const photon = await loadPhoton();
32
37
  if (!photon) {
33
- // Photon not available, return original image
34
- return {
35
- data: img.data,
36
- mimeType: img.mimeType,
37
- originalWidth: 0,
38
- originalHeight: 0,
39
- width: 0,
40
- height: 0,
41
- wasResized: false,
42
- };
38
+ return null;
43
39
  }
44
40
  let image;
45
41
  try {
@@ -51,9 +47,8 @@ export async function resizeImage(img, options) {
51
47
  const originalWidth = image.get_width();
52
48
  const originalHeight = image.get_height();
53
49
  const format = img.mimeType?.split("/")[1] ?? "png";
54
- // Check if already within all limits (dimensions AND size)
55
- const originalSize = inputBuffer.length;
56
- if (originalWidth <= opts.maxWidth && originalHeight <= opts.maxHeight && originalSize <= opts.maxBytes) {
50
+ // Check if already within all limits (dimensions AND encoded size)
51
+ if (originalWidth <= opts.maxWidth && originalHeight <= opts.maxHeight && inputBase64Size < opts.maxBytes) {
57
52
  return {
58
53
  data: img.data,
59
54
  mimeType: img.mimeType ?? `image/${format}`,
@@ -75,96 +70,52 @@ export async function resizeImage(img, options) {
75
70
  targetWidth = Math.round((targetWidth * opts.maxHeight) / targetHeight);
76
71
  targetHeight = opts.maxHeight;
77
72
  }
78
- // Helper to resize and encode in both formats, returning the smaller one
79
- function tryBothFormats(width, height, jpegQuality) {
73
+ function tryEncodings(width, height, jpegQualities) {
80
74
  const resized = photon.resize(image, width, height, photon.SamplingFilter.Lanczos3);
81
75
  try {
82
- const pngBuffer = resized.get_bytes();
83
- const jpegBuffer = resized.get_bytes_jpeg(jpegQuality);
84
- return pickSmaller({ buffer: pngBuffer, mimeType: "image/png" }, { buffer: jpegBuffer, mimeType: "image/jpeg" });
76
+ const candidates = [encodeCandidate(resized.get_bytes(), "image/png")];
77
+ for (const quality of jpegQualities) {
78
+ candidates.push(encodeCandidate(resized.get_bytes_jpeg(quality), "image/jpeg"));
79
+ }
80
+ return candidates;
85
81
  }
86
82
  finally {
87
83
  resized.free();
88
84
  }
89
85
  }
90
- // Try to produce an image under maxBytes
91
- const qualitySteps = [85, 70, 55, 40];
92
- const scaleSteps = [1.0, 0.75, 0.5, 0.35, 0.25];
93
- let best;
94
- let finalWidth = targetWidth;
95
- let finalHeight = targetHeight;
96
- // First attempt: resize to target dimensions, try both formats
97
- best = tryBothFormats(targetWidth, targetHeight, opts.jpegQuality);
98
- if (best.buffer.length <= opts.maxBytes) {
99
- return {
100
- data: Buffer.from(best.buffer).toString("base64"),
101
- mimeType: best.mimeType,
102
- originalWidth,
103
- originalHeight,
104
- width: finalWidth,
105
- height: finalHeight,
106
- wasResized: true,
107
- };
108
- }
109
- // Still too large - try JPEG with decreasing quality
110
- for (const quality of qualitySteps) {
111
- best = tryBothFormats(targetWidth, targetHeight, quality);
112
- if (best.buffer.length <= opts.maxBytes) {
113
- return {
114
- data: Buffer.from(best.buffer).toString("base64"),
115
- mimeType: best.mimeType,
116
- originalWidth,
117
- originalHeight,
118
- width: finalWidth,
119
- height: finalHeight,
120
- wasResized: true,
121
- };
122
- }
123
- }
124
- // Still too large - reduce dimensions progressively
125
- for (const scale of scaleSteps) {
126
- finalWidth = Math.round(targetWidth * scale);
127
- finalHeight = Math.round(targetHeight * scale);
128
- if (finalWidth < 100 || finalHeight < 100) {
129
- break;
130
- }
131
- for (const quality of qualitySteps) {
132
- best = tryBothFormats(finalWidth, finalHeight, quality);
133
- if (best.buffer.length <= opts.maxBytes) {
86
+ const qualitySteps = Array.from(new Set([opts.jpegQuality, 85, 70, 55, 40]));
87
+ let currentWidth = targetWidth;
88
+ let currentHeight = targetHeight;
89
+ while (true) {
90
+ const candidates = tryEncodings(currentWidth, currentHeight, qualitySteps);
91
+ for (const candidate of candidates) {
92
+ if (candidate.encodedSize < opts.maxBytes) {
134
93
  return {
135
- data: Buffer.from(best.buffer).toString("base64"),
136
- mimeType: best.mimeType,
94
+ data: candidate.data,
95
+ mimeType: candidate.mimeType,
137
96
  originalWidth,
138
97
  originalHeight,
139
- width: finalWidth,
140
- height: finalHeight,
98
+ width: currentWidth,
99
+ height: currentHeight,
141
100
  wasResized: true,
142
101
  };
143
102
  }
144
103
  }
104
+ if (currentWidth === 1 && currentHeight === 1) {
105
+ break;
106
+ }
107
+ const nextWidth = currentWidth === 1 ? 1 : Math.max(1, Math.floor(currentWidth * 0.75));
108
+ const nextHeight = currentHeight === 1 ? 1 : Math.max(1, Math.floor(currentHeight * 0.75));
109
+ if (nextWidth === currentWidth && nextHeight === currentHeight) {
110
+ break;
111
+ }
112
+ currentWidth = nextWidth;
113
+ currentHeight = nextHeight;
145
114
  }
146
- // Last resort: return smallest version we produced
147
- return {
148
- data: Buffer.from(best.buffer).toString("base64"),
149
- mimeType: best.mimeType,
150
- originalWidth,
151
- originalHeight,
152
- width: finalWidth,
153
- height: finalHeight,
154
- wasResized: true,
155
- };
115
+ return null;
156
116
  }
157
117
  catch {
158
- // Failed to load image
159
- return {
160
- data: img.data,
161
- mimeType: img.mimeType,
162
- originalWidth: 0,
163
- originalHeight: 0,
164
- width: 0,
165
- height: 0,
166
- wasResized: false,
167
- };
118
+ return null;
168
119
  }
169
120
  finally {
170
121
  if (image) {
@@ -1 +1 @@
1
- {"version":3,"file":"image-resize.js","sourceRoot":"","sources":["../../src/utils/image-resize.ts"],"names":[],"mappings":"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","sourcesContent":["import type { ImageContent } from \"@mariozechner/pi-ai\";\nimport { applyExifOrientation } from \"./exif-orientation.js\";\nimport { loadPhoton } from \"./photon.js\";\n\nexport interface ImageResizeOptions {\n\tmaxWidth?: number; // Default: 2000\n\tmaxHeight?: number; // Default: 2000\n\tmaxBytes?: number; // Default: 4.5MB (below Anthropic's 5MB limit)\n\tjpegQuality?: number; // Default: 80\n}\n\nexport interface ResizedImage {\n\tdata: string; // base64\n\tmimeType: string;\n\toriginalWidth: number;\n\toriginalHeight: number;\n\twidth: number;\n\theight: number;\n\twasResized: boolean;\n}\n\n// 4.5MB - provides headroom below Anthropic's 5MB limit\nconst DEFAULT_MAX_BYTES = 4.5 * 1024 * 1024;\n\nconst DEFAULT_OPTIONS: Required<ImageResizeOptions> = {\n\tmaxWidth: 2000,\n\tmaxHeight: 2000,\n\tmaxBytes: DEFAULT_MAX_BYTES,\n\tjpegQuality: 80,\n};\n\n/** Helper to pick the smaller of two buffers */\nfunction pickSmaller(\n\ta: { buffer: Uint8Array; mimeType: string },\n\tb: { buffer: Uint8Array; mimeType: string },\n): { buffer: Uint8Array; mimeType: string } {\n\treturn a.buffer.length <= b.buffer.length ? a : b;\n}\n\n/**\n * Resize an image to fit within the specified max dimensions and file size.\n * Returns the original image if it already fits within the limits.\n *\n * Uses Photon (Rust/WASM) for image processing. If Photon is not available,\n * returns the original image unchanged.\n *\n * Strategy for staying under maxBytes:\n * 1. First resize to maxWidth/maxHeight\n * 2. Try both PNG and JPEG formats, pick the smaller one\n * 3. If still too large, try JPEG with decreasing quality\n * 4. If still too large, progressively reduce dimensions\n */\nexport async function resizeImage(img: ImageContent, options?: ImageResizeOptions): Promise<ResizedImage> {\n\tconst opts = { ...DEFAULT_OPTIONS, ...options };\n\tconst inputBuffer = Buffer.from(img.data, \"base64\");\n\n\tconst photon = await loadPhoton();\n\tif (!photon) {\n\t\t// Photon not available, return original image\n\t\treturn {\n\t\t\tdata: img.data,\n\t\t\tmimeType: img.mimeType,\n\t\t\toriginalWidth: 0,\n\t\t\toriginalHeight: 0,\n\t\t\twidth: 0,\n\t\t\theight: 0,\n\t\t\twasResized: false,\n\t\t};\n\t}\n\n\tlet image: ReturnType<typeof photon.PhotonImage.new_from_byteslice> | undefined;\n\ttry {\n\t\tconst inputBytes = new Uint8Array(inputBuffer);\n\t\tconst rawImage = photon.PhotonImage.new_from_byteslice(inputBytes);\n\t\timage = applyExifOrientation(photon, rawImage, inputBytes);\n\t\tif (image !== rawImage) rawImage.free();\n\n\t\tconst originalWidth = image.get_width();\n\t\tconst originalHeight = image.get_height();\n\t\tconst format = img.mimeType?.split(\"/\")[1] ?? \"png\";\n\n\t\t// Check if already within all limits (dimensions AND size)\n\t\tconst originalSize = inputBuffer.length;\n\t\tif (originalWidth <= opts.maxWidth && originalHeight <= opts.maxHeight && originalSize <= opts.maxBytes) {\n\t\t\treturn {\n\t\t\t\tdata: img.data,\n\t\t\t\tmimeType: img.mimeType ?? `image/${format}`,\n\t\t\t\toriginalWidth,\n\t\t\t\toriginalHeight,\n\t\t\t\twidth: originalWidth,\n\t\t\t\theight: originalHeight,\n\t\t\t\twasResized: false,\n\t\t\t};\n\t\t}\n\n\t\t// Calculate initial dimensions respecting max limits\n\t\tlet targetWidth = originalWidth;\n\t\tlet targetHeight = originalHeight;\n\n\t\tif (targetWidth > opts.maxWidth) {\n\t\t\ttargetHeight = Math.round((targetHeight * opts.maxWidth) / targetWidth);\n\t\t\ttargetWidth = opts.maxWidth;\n\t\t}\n\t\tif (targetHeight > opts.maxHeight) {\n\t\t\ttargetWidth = Math.round((targetWidth * opts.maxHeight) / targetHeight);\n\t\t\ttargetHeight = opts.maxHeight;\n\t\t}\n\n\t\t// Helper to resize and encode in both formats, returning the smaller one\n\t\tfunction tryBothFormats(\n\t\t\twidth: number,\n\t\t\theight: number,\n\t\t\tjpegQuality: number,\n\t\t): { buffer: Uint8Array; mimeType: string } {\n\t\t\tconst resized = photon!.resize(image!, width, height, photon!.SamplingFilter.Lanczos3);\n\n\t\t\ttry {\n\t\t\t\tconst pngBuffer = resized.get_bytes();\n\t\t\t\tconst jpegBuffer = resized.get_bytes_jpeg(jpegQuality);\n\n\t\t\t\treturn pickSmaller(\n\t\t\t\t\t{ buffer: pngBuffer, mimeType: \"image/png\" },\n\t\t\t\t\t{ buffer: jpegBuffer, mimeType: \"image/jpeg\" },\n\t\t\t\t);\n\t\t\t} finally {\n\t\t\t\tresized.free();\n\t\t\t}\n\t\t}\n\n\t\t// Try to produce an image under maxBytes\n\t\tconst qualitySteps = [85, 70, 55, 40];\n\t\tconst scaleSteps = [1.0, 0.75, 0.5, 0.35, 0.25];\n\n\t\tlet best: { buffer: Uint8Array; mimeType: string };\n\t\tlet finalWidth = targetWidth;\n\t\tlet finalHeight = targetHeight;\n\n\t\t// First attempt: resize to target dimensions, try both formats\n\t\tbest = tryBothFormats(targetWidth, targetHeight, opts.jpegQuality);\n\n\t\tif (best.buffer.length <= opts.maxBytes) {\n\t\t\treturn {\n\t\t\t\tdata: Buffer.from(best.buffer).toString(\"base64\"),\n\t\t\t\tmimeType: best.mimeType,\n\t\t\t\toriginalWidth,\n\t\t\t\toriginalHeight,\n\t\t\t\twidth: finalWidth,\n\t\t\t\theight: finalHeight,\n\t\t\t\twasResized: true,\n\t\t\t};\n\t\t}\n\n\t\t// Still too large - try JPEG with decreasing quality\n\t\tfor (const quality of qualitySteps) {\n\t\t\tbest = tryBothFormats(targetWidth, targetHeight, quality);\n\n\t\t\tif (best.buffer.length <= opts.maxBytes) {\n\t\t\t\treturn {\n\t\t\t\t\tdata: Buffer.from(best.buffer).toString(\"base64\"),\n\t\t\t\t\tmimeType: best.mimeType,\n\t\t\t\t\toriginalWidth,\n\t\t\t\t\toriginalHeight,\n\t\t\t\t\twidth: finalWidth,\n\t\t\t\t\theight: finalHeight,\n\t\t\t\t\twasResized: true,\n\t\t\t\t};\n\t\t\t}\n\t\t}\n\n\t\t// Still too large - reduce dimensions progressively\n\t\tfor (const scale of scaleSteps) {\n\t\t\tfinalWidth = Math.round(targetWidth * scale);\n\t\t\tfinalHeight = Math.round(targetHeight * scale);\n\n\t\t\tif (finalWidth < 100 || finalHeight < 100) {\n\t\t\t\tbreak;\n\t\t\t}\n\n\t\t\tfor (const quality of qualitySteps) {\n\t\t\t\tbest = tryBothFormats(finalWidth, finalHeight, quality);\n\n\t\t\t\tif (best.buffer.length <= opts.maxBytes) {\n\t\t\t\t\treturn {\n\t\t\t\t\t\tdata: Buffer.from(best.buffer).toString(\"base64\"),\n\t\t\t\t\t\tmimeType: best.mimeType,\n\t\t\t\t\t\toriginalWidth,\n\t\t\t\t\t\toriginalHeight,\n\t\t\t\t\t\twidth: finalWidth,\n\t\t\t\t\t\theight: finalHeight,\n\t\t\t\t\t\twasResized: true,\n\t\t\t\t\t};\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// Last resort: return smallest version we produced\n\t\treturn {\n\t\t\tdata: Buffer.from(best.buffer).toString(\"base64\"),\n\t\t\tmimeType: best.mimeType,\n\t\t\toriginalWidth,\n\t\t\toriginalHeight,\n\t\t\twidth: finalWidth,\n\t\t\theight: finalHeight,\n\t\t\twasResized: true,\n\t\t};\n\t} catch {\n\t\t// Failed to load image\n\t\treturn {\n\t\t\tdata: img.data,\n\t\t\tmimeType: img.mimeType,\n\t\t\toriginalWidth: 0,\n\t\t\toriginalHeight: 0,\n\t\t\twidth: 0,\n\t\t\theight: 0,\n\t\t\twasResized: false,\n\t\t};\n\t} finally {\n\t\tif (image) {\n\t\t\timage.free();\n\t\t}\n\t}\n}\n\n/**\n * Format a dimension note for resized images.\n * This helps the model understand the coordinate mapping.\n */\nexport function formatDimensionNote(result: ResizedImage): string | undefined {\n\tif (!result.wasResized) {\n\t\treturn undefined;\n\t}\n\n\tconst scale = result.originalWidth / result.width;\n\treturn `[Image: original ${result.originalWidth}x${result.originalHeight}, displayed at ${result.width}x${result.height}. Multiply coordinates by ${scale.toFixed(2)} to map to original image.]`;\n}\n"]}
1
+ {"version":3,"file":"image-resize.js","sourceRoot":"","sources":["../../src/utils/image-resize.ts"],"names":[],"mappings":"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","sourcesContent":["import type { ImageContent } from \"@mariozechner/pi-ai\";\nimport { applyExifOrientation } from \"./exif-orientation.js\";\nimport { loadPhoton } from \"./photon.js\";\n\nexport interface ImageResizeOptions {\n\tmaxWidth?: number; // Default: 2000\n\tmaxHeight?: number; // Default: 2000\n\tmaxBytes?: number; // Default: 4.5MB of base64 payload (below Anthropic's 5MB limit)\n\tjpegQuality?: number; // Default: 80\n}\n\nexport interface ResizedImage {\n\tdata: string; // base64\n\tmimeType: string;\n\toriginalWidth: number;\n\toriginalHeight: number;\n\twidth: number;\n\theight: number;\n\twasResized: boolean;\n}\n\n// 4.5MB of base64 payload. Provides headroom below Anthropic's 5MB limit.\nconst DEFAULT_MAX_BYTES = 4.5 * 1024 * 1024;\n\nconst DEFAULT_OPTIONS: Required<ImageResizeOptions> = {\n\tmaxWidth: 2000,\n\tmaxHeight: 2000,\n\tmaxBytes: DEFAULT_MAX_BYTES,\n\tjpegQuality: 80,\n};\n\ninterface EncodedCandidate {\n\tdata: string;\n\tencodedSize: number;\n\tmimeType: string;\n}\n\nfunction encodeCandidate(buffer: Uint8Array, mimeType: string): EncodedCandidate {\n\tconst data = Buffer.from(buffer).toString(\"base64\");\n\treturn {\n\t\tdata,\n\t\tencodedSize: Buffer.byteLength(data, \"utf-8\"),\n\t\tmimeType,\n\t};\n}\n\n/**\n * Resize an image to fit within the specified max dimensions and encoded file size.\n * Returns null if the image cannot be resized below maxBytes.\n *\n * Uses Photon (Rust/WASM) for image processing. If Photon is not available,\n * returns null.\n *\n * Strategy for staying under maxBytes:\n * 1. First resize to maxWidth/maxHeight\n * 2. Try both PNG and JPEG formats, pick the smaller one\n * 3. If still too large, try JPEG with decreasing quality\n * 4. If still too large, progressively reduce dimensions until 1x1\n */\nexport async function resizeImage(img: ImageContent, options?: ImageResizeOptions): Promise<ResizedImage | null> {\n\tconst opts = { ...DEFAULT_OPTIONS, ...options };\n\tconst inputBuffer = Buffer.from(img.data, \"base64\");\n\tconst inputBase64Size = Buffer.byteLength(img.data, \"utf-8\");\n\n\tconst photon = await loadPhoton();\n\tif (!photon) {\n\t\treturn null;\n\t}\n\n\tlet image: ReturnType<typeof photon.PhotonImage.new_from_byteslice> | undefined;\n\ttry {\n\t\tconst inputBytes = new Uint8Array(inputBuffer);\n\t\tconst rawImage = photon.PhotonImage.new_from_byteslice(inputBytes);\n\t\timage = applyExifOrientation(photon, rawImage, inputBytes);\n\t\tif (image !== rawImage) rawImage.free();\n\n\t\tconst originalWidth = image.get_width();\n\t\tconst originalHeight = image.get_height();\n\t\tconst format = img.mimeType?.split(\"/\")[1] ?? \"png\";\n\n\t\t// Check if already within all limits (dimensions AND encoded size)\n\t\tif (originalWidth <= opts.maxWidth && originalHeight <= opts.maxHeight && inputBase64Size < opts.maxBytes) {\n\t\t\treturn {\n\t\t\t\tdata: img.data,\n\t\t\t\tmimeType: img.mimeType ?? `image/${format}`,\n\t\t\t\toriginalWidth,\n\t\t\t\toriginalHeight,\n\t\t\t\twidth: originalWidth,\n\t\t\t\theight: originalHeight,\n\t\t\t\twasResized: false,\n\t\t\t};\n\t\t}\n\n\t\t// Calculate initial dimensions respecting max limits\n\t\tlet targetWidth = originalWidth;\n\t\tlet targetHeight = originalHeight;\n\n\t\tif (targetWidth > opts.maxWidth) {\n\t\t\ttargetHeight = Math.round((targetHeight * opts.maxWidth) / targetWidth);\n\t\t\ttargetWidth = opts.maxWidth;\n\t\t}\n\t\tif (targetHeight > opts.maxHeight) {\n\t\t\ttargetWidth = Math.round((targetWidth * opts.maxHeight) / targetHeight);\n\t\t\ttargetHeight = opts.maxHeight;\n\t\t}\n\n\t\tfunction tryEncodings(width: number, height: number, jpegQualities: number[]): EncodedCandidate[] {\n\t\t\tconst resized = photon!.resize(image!, width, height, photon!.SamplingFilter.Lanczos3);\n\n\t\t\ttry {\n\t\t\t\tconst candidates: EncodedCandidate[] = [encodeCandidate(resized.get_bytes(), \"image/png\")];\n\t\t\t\tfor (const quality of jpegQualities) {\n\t\t\t\t\tcandidates.push(encodeCandidate(resized.get_bytes_jpeg(quality), \"image/jpeg\"));\n\t\t\t\t}\n\t\t\t\treturn candidates;\n\t\t\t} finally {\n\t\t\t\tresized.free();\n\t\t\t}\n\t\t}\n\n\t\tconst qualitySteps = Array.from(new Set([opts.jpegQuality, 85, 70, 55, 40]));\n\t\tlet currentWidth = targetWidth;\n\t\tlet currentHeight = targetHeight;\n\n\t\twhile (true) {\n\t\t\tconst candidates = tryEncodings(currentWidth, currentHeight, qualitySteps);\n\t\t\tfor (const candidate of candidates) {\n\t\t\t\tif (candidate.encodedSize < opts.maxBytes) {\n\t\t\t\t\treturn {\n\t\t\t\t\t\tdata: candidate.data,\n\t\t\t\t\t\tmimeType: candidate.mimeType,\n\t\t\t\t\t\toriginalWidth,\n\t\t\t\t\t\toriginalHeight,\n\t\t\t\t\t\twidth: currentWidth,\n\t\t\t\t\t\theight: currentHeight,\n\t\t\t\t\t\twasResized: true,\n\t\t\t\t\t};\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif (currentWidth === 1 && currentHeight === 1) {\n\t\t\t\tbreak;\n\t\t\t}\n\n\t\t\tconst nextWidth = currentWidth === 1 ? 1 : Math.max(1, Math.floor(currentWidth * 0.75));\n\t\t\tconst nextHeight = currentHeight === 1 ? 1 : Math.max(1, Math.floor(currentHeight * 0.75));\n\t\t\tif (nextWidth === currentWidth && nextHeight === currentHeight) {\n\t\t\t\tbreak;\n\t\t\t}\n\n\t\t\tcurrentWidth = nextWidth;\n\t\t\tcurrentHeight = nextHeight;\n\t\t}\n\n\t\treturn null;\n\t} catch {\n\t\treturn null;\n\t} finally {\n\t\tif (image) {\n\t\t\timage.free();\n\t\t}\n\t}\n}\n\n/**\n * Format a dimension note for resized images.\n * This helps the model understand the coordinate mapping.\n */\nexport function formatDimensionNote(result: ResizedImage): string | undefined {\n\tif (!result.wasResized) {\n\t\treturn undefined;\n\t}\n\n\tconst scale = result.originalWidth / result.width;\n\treturn `[Image: original ${result.originalWidth}x${result.originalHeight}, displayed at ${result.width}x${result.height}. Multiply coordinates by ${scale.toFixed(2)} to map to original image.]`;\n}\n"]}
@@ -14,9 +14,11 @@ npm run build
14
14
  Run from source:
15
15
 
16
16
  ```bash
17
- ./pi-test.sh
17
+ /path/to/pi-mono/pi-test.sh
18
18
  ```
19
19
 
20
+ The script can be run from any directory. Pi keeps the caller's current working directory.
21
+
20
22
  ## Forking / Rebranding
21
23
 
22
24
  Configure via `package.json`:
@@ -293,10 +293,11 @@ Fired by the `pi` CLI during startup session resolution, before the initial sess
293
293
  This event is:
294
294
  - CLI-only. It is not emitted in SDK mode.
295
295
  - Startup-only. It is not emitted for later interactive `/new` or `/resume` actions.
296
- - Bypassed when `--session-dir` is provided.
296
+ - Lower priority than `--session-dir` and `sessionDir` in `settings.json`.
297
297
  - Special-cased to receive no `ctx` argument.
298
298
 
299
299
  If multiple extensions return `sessionDir`, the last one wins.
300
+ Combined precedence is: `--session-dir` CLI flag, then `sessionDir` in settings, then extension `session_directory` hooks.
300
301
 
301
302
  ```typescript
302
303
  pi.on("session_directory", async (event) => {
@@ -976,8 +977,8 @@ pi.registerTool({
976
977
  },
977
978
 
978
979
  // Optional: Custom rendering
979
- renderCall(args, theme) { ... },
980
- renderResult(result, options, theme) { ... },
980
+ renderCall(args, theme, context) { ... },
981
+ renderResult(result, options, theme, context) { ... },
981
982
  });
982
983
  ```
983
984
 
@@ -1089,6 +1090,8 @@ Labels persist in the session and survive restarts. Use them to mark important p
1089
1090
 
1090
1091
  Register a command.
1091
1092
 
1093
+ If multiple extensions register the same command name, pi keeps them all and assigns numeric invocation suffixes in load order, for example `/review:1` and `/review:2`.
1094
+
1092
1095
  ```typescript
1093
1096
  pi.registerCommand("stats", {
1094
1097
  description: "Show session statistics",
@@ -1126,20 +1129,28 @@ The list matches the RPC `get_commands` ordering: extensions first, then templat
1126
1129
  ```typescript
1127
1130
  const commands = pi.getCommands();
1128
1131
  const bySource = commands.filter((command) => command.source === "extension");
1132
+ const userScoped = commands.filter((command) => command.sourceInfo.scope === "user");
1129
1133
  ```
1130
1134
 
1131
1135
  Each entry has this shape:
1132
1136
 
1133
1137
  ```typescript
1134
1138
  {
1135
- name: string; // Command name without the leading slash
1139
+ name: string; // Invokable command name without the leading slash. May be suffixed like "review:1"
1136
1140
  description?: string;
1137
1141
  source: "extension" | "prompt" | "skill";
1138
- location?: "user" | "project" | "path"; // For templates and skills
1139
- path?: string; // Files backing templates, skills, and extensions
1142
+ sourceInfo: {
1143
+ path: string;
1144
+ source: string;
1145
+ scope: "user" | "project" | "temporary";
1146
+ origin: "package" | "top-level";
1147
+ baseDir?: string;
1148
+ };
1140
1149
  }
1141
1150
  ```
1142
1151
 
1152
+ Use `sourceInfo` as the canonical provenance field. Do not infer ownership from command names or from ad hoc path parsing.
1153
+
1143
1154
  Built-in interactive commands (like `/model` and `/settings`) are not included here. They are handled only in interactive
1144
1155
  mode and would not execute if sent via `prompt`.
1145
1156
 
@@ -1191,12 +1202,27 @@ const result = await pi.exec("git", ["status"], { signal, timeout: 5000 });
1191
1202
  Manage active tools. This works for both built-in tools and dynamically registered tools.
1192
1203
 
1193
1204
  ```typescript
1194
- const active = pi.getActiveTools(); // ["read", "bash", "edit", "write"]
1195
- const all = pi.getAllTools(); // [{ name: "read", description: "Read file contents..." }, ...]
1196
- const names = all.map(t => t.name); // Just names if needed
1205
+ const active = pi.getActiveTools();
1206
+ const all = pi.getAllTools();
1207
+ // [{
1208
+ // name: "read",
1209
+ // description: "Read file contents...",
1210
+ // parameters: ...,
1211
+ // sourceInfo: { path: "<builtin:read>", source: "builtin", scope: "temporary", origin: "top-level" }
1212
+ // }, ...]
1213
+ const names = all.map(t => t.name);
1214
+ const builtinTools = all.filter((t) => t.sourceInfo.source === "builtin");
1215
+ const extensionTools = all.filter((t) => t.sourceInfo.source !== "builtin" && t.sourceInfo.source !== "sdk");
1197
1216
  pi.setActiveTools(["read", "bash"]); // Switch to read-only
1198
1217
  ```
1199
1218
 
1219
+ `pi.getAllTools()` returns `name`, `description`, `parameters`, and `sourceInfo`.
1220
+
1221
+ Typical `sourceInfo.source` values:
1222
+ - `builtin` for built-in tools
1223
+ - `sdk` for tools passed via `createAgentSession({ customTools })`
1224
+ - extension source metadata for tools registered by extensions
1225
+
1200
1226
  ### pi.setModel(model)
1201
1227
 
1202
1228
  Set the current model. Returns `false` if no API key is available for the model. See [models.md](models.md) for configuring custom models.
@@ -1427,8 +1453,8 @@ pi.registerTool({
1427
1453
  },
1428
1454
 
1429
1455
  // Optional: Custom rendering
1430
- renderCall(args, theme) { ... },
1431
- renderResult(result, options, theme) { ... },
1456
+ renderCall(args, theme, context) { ... },
1457
+ renderResult(result, options, theme, context) { ... },
1432
1458
  });
1433
1459
  ```
1434
1460
 
@@ -1463,7 +1489,9 @@ pi --no-tools -e ./my-extension.ts
1463
1489
 
1464
1490
  See [examples/extensions/tool-override.ts](../examples/extensions/tool-override.ts) for a complete example that overrides `read` with logging and access control.
1465
1491
 
1466
- **Rendering:** If your override doesn't provide custom `renderCall`/`renderResult` functions, the built-in renderer is used automatically (syntax highlighting, diffs, etc.). This lets you wrap built-in tools for logging or access control without reimplementing the UI.
1492
+ **Rendering:** Built-in renderer inheritance is resolved per slot. Execution override and rendering override are independent. If your override omits `renderCall`, the built-in `renderCall` is used. If your override omits `renderResult`, the built-in `renderResult` is used. If your override omits both, the built-in renderer is used automatically (syntax highlighting, diffs, etc.). This lets you wrap built-in tools for logging or access control without reimplementing the UI.
1493
+
1494
+ **Prompt metadata:** `promptSnippet` and `promptGuidelines` are not inherited from the built-in tool. If your override should keep those prompt instructions, define them on the override explicitly.
1467
1495
 
1468
1496
  **Your implementation must match the exact result shape**, including the `details` type. The UI and session logic depend on these shapes for rendering and state tracking.
1469
1497
 
@@ -1597,44 +1625,52 @@ export default function (pi: ExtensionAPI) {
1597
1625
 
1598
1626
  ### Custom Rendering
1599
1627
 
1600
- Tools can provide `renderCall` and `renderResult` for custom TUI display. See [tui.md](tui.md) for the full component API and [tool-execution.ts](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/src/modes/interactive/components/tool-execution.ts) for how built-in tools render.
1628
+ Tools can provide `renderCall` and `renderResult` for custom TUI display. See [tui.md](tui.md) for the full component API and [tool-execution.ts](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/src/modes/interactive/components/tool-execution.ts) for how tool rows are composed.
1629
+
1630
+ Tool output is wrapped in a `Box` that handles padding and background. A defined `renderCall` or `renderResult` must return a `Component`. If a slot renderer is not defined, `tool-execution.ts` uses fallback rendering for that slot.
1631
+
1632
+ `renderCall` and `renderResult` each receive a `context` object with:
1633
+ - `args` - the current tool call arguments
1634
+ - `state` - shared row-local state across `renderCall` and `renderResult`
1635
+ - `lastComponent` - the previously returned component for that slot, if any
1636
+ - `invalidate()` - request a rerender of this tool row
1637
+ - `toolCallId`, `cwd`, `executionStarted`, `argsComplete`, `isPartial`, `expanded`, `showImages`, `isError`
1601
1638
 
1602
- Tool output is wrapped in a `Box` that handles padding and background. Your render methods return `Component` instances (typically `Text`).
1639
+ Use `context.state` for cross-slot shared state. Keep slot-local caches on the returned component instance when you want to reuse and mutate the same component across renders.
1603
1640
 
1604
1641
  #### renderCall
1605
1642
 
1606
- Renders the tool call (before/during execution):
1643
+ Renders the tool call or header:
1607
1644
 
1608
1645
  ```typescript
1609
1646
  import { Text } from "@mariozechner/pi-tui";
1610
1647
 
1611
- renderCall(args, theme) {
1612
- let text = theme.fg("toolTitle", theme.bold("my_tool "));
1613
- text += theme.fg("muted", args.action);
1648
+ renderCall(args, theme, context) {
1649
+ const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
1650
+ let content = theme.fg("toolTitle", theme.bold("my_tool "));
1651
+ content += theme.fg("muted", args.action);
1614
1652
  if (args.text) {
1615
- text += " " + theme.fg("dim", `"${args.text}"`);
1653
+ content += " " + theme.fg("dim", `"${args.text}"`);
1616
1654
  }
1617
- return new Text(text, 0, 0); // 0,0 padding - Box handles it
1655
+ text.setText(content);
1656
+ return text;
1618
1657
  }
1619
1658
  ```
1620
1659
 
1621
1660
  #### renderResult
1622
1661
 
1623
- Renders the tool result:
1662
+ Renders the tool result or output:
1624
1663
 
1625
1664
  ```typescript
1626
- renderResult(result, { expanded, isPartial }, theme) {
1627
- // Handle streaming
1665
+ renderResult(result, { expanded, isPartial }, theme, context) {
1628
1666
  if (isPartial) {
1629
1667
  return new Text(theme.fg("warning", "Processing..."), 0, 0);
1630
1668
  }
1631
1669
 
1632
- // Handle errors
1633
1670
  if (result.details?.error) {
1634
1671
  return new Text(theme.fg("error", `Error: ${result.details.error}`), 0, 0);
1635
1672
  }
1636
1673
 
1637
- // Normal result - support expanded view (Ctrl+O)
1638
1674
  let text = theme.fg("success", "✓ Done");
1639
1675
  if (expanded && result.details?.items) {
1640
1676
  for (const item of result.details.items) {
@@ -1645,6 +1681,8 @@ renderResult(result, { expanded, isPartial }, theme) {
1645
1681
  }
1646
1682
  ```
1647
1683
 
1684
+ If a slot intentionally has no visible content, return an empty `Component` such as an empty `Container`.
1685
+
1648
1686
  #### Keybinding Hints
1649
1687
 
1650
1688
  Use `keyHint()` to display keybinding hints that respect the active keybinding configuration:
@@ -1652,7 +1690,7 @@ Use `keyHint()` to display keybinding hints that respect the active keybinding c
1652
1690
  ```typescript
1653
1691
  import { keyHint } from "@mariozechner/pi-coding-agent";
1654
1692
 
1655
- renderResult(result, { expanded }, theme) {
1693
+ renderResult(result, { expanded }, theme, context) {
1656
1694
  let text = theme.fg("success", "✓ Done");
1657
1695
  if (!expanded) {
1658
1696
  text += ` (${keyHint("app.tools.expand", "to expand")})`;
@@ -1676,16 +1714,19 @@ Custom editors and `ctx.ui.custom()` components receive `keybindings: Keybinding
1676
1714
 
1677
1715
  #### Best Practices
1678
1716
 
1679
- - Use `Text` with padding `(0, 0)` - the Box handles padding
1680
- - Use `\n` for multi-line content
1681
- - Handle `isPartial` for streaming progress
1682
- - Support `expanded` for detail on demand
1683
- - Keep default view compact
1717
+ - Use `Text` with padding `(0, 0)`. The Box handles padding.
1718
+ - Use `\n` for multi-line content.
1719
+ - Handle `isPartial` for streaming progress.
1720
+ - Support `expanded` for detail on demand.
1721
+ - Keep default view compact.
1722
+ - Read `context.args` in `renderResult` instead of copying args into `context.state`.
1723
+ - Use `context.state` only for data that must be shared across call and result slots.
1724
+ - Reuse `context.lastComponent` when the same component instance can be updated in place.
1684
1725
 
1685
1726
  #### Fallback
1686
1727
 
1687
- If `renderCall`/`renderResult` is not defined or throws:
1688
- - `renderCall`: Shows tool name
1728
+ If a slot renderer is not defined or throws:
1729
+ - `renderCall`: Shows the tool name
1689
1730
  - `renderResult`: Shows raw text from `content`
1690
1731
 
1691
1732
  ## Custom UI
package/docs/models.md CHANGED
@@ -131,6 +131,12 @@ The `apiKey` and `headers` fields support three formats:
131
131
  "apiKey": "sk-..."
132
132
  ```
133
133
 
134
+ For `models.json`, shell commands are resolved at request time. pi intentionally does not apply built-in TTL, stale reuse, or recovery logic for arbitrary commands. Different commands need different caching and failure strategies, and pi cannot infer the right one.
135
+
136
+ If your command is slow, expensive, rate-limited, or should keep using a previous value on transient failures, wrap it in your own script or command that implements the caching or TTL behavior you want.
137
+
138
+ `/model` availability checks use configured auth presence and do not execute shell commands.
139
+
134
140
  ### Custom Headers
135
141
 
136
142
  ```json
package/docs/rpc.md CHANGED
@@ -494,7 +494,7 @@ Response:
494
494
 
495
495
  #### get_session_stats
496
496
 
497
- Get token usage and cost statistics.
497
+ Get token usage, cost statistics, and current context window usage.
498
498
 
499
499
  ```json
500
500
  {"type": "get_session_stats"}
@@ -521,11 +521,20 @@ Response:
521
521
  "cacheWrite": 5000,
522
522
  "total": 105000
523
523
  },
524
- "cost": 0.45
524
+ "cost": 0.45,
525
+ "contextUsage": {
526
+ "tokens": 60000,
527
+ "contextWindow": 200000,
528
+ "percent": 30
529
+ }
525
530
  }
526
531
  }
527
532
  ```
528
533
 
534
+ `tokens` contains assistant usage totals for the current session state. `contextUsage` contains the actual current context-window estimate used for compaction and footer display.
535
+
536
+ `contextUsage` is omitted when no model or context window is available. `contextUsage.tokens` and `contextUsage.percent` are `null` immediately after compaction until a fresh post-compaction assistant response provides valid usage data.
537
+
529
538
  #### export_html
530
539
 
531
540
  Export session to an HTML file.
package/docs/settings.md CHANGED
@@ -127,6 +127,18 @@ When a provider requests a retry delay longer than `maxDelayMs` (e.g., Google's
127
127
 
128
128
  `npmCommand` is used for all npm package-manager operations, including `npm root -g`, installs, uninstalls, and `npm install` inside git packages. Use argv-style entries exactly as the process should be launched.
129
129
 
130
+ ### Sessions
131
+
132
+ | Setting | Type | Default | Description |
133
+ |---------|------|---------|-------------|
134
+ | `sessionDir` | string | - | Directory where session files are stored. Accepts absolute or relative paths. |
135
+
136
+ ```json
137
+ { "sessionDir": ".pi/sessions" }
138
+ ```
139
+
140
+ When multiple sources specify a session directory, `--session-dir` CLI flag takes precedence, then `sessionDir` in settings.json, then extension hooks.
141
+
130
142
  ### Model Cycling
131
143
 
132
144
  | Setting | Type | Default | Description |