@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
@@ -3,7 +3,7 @@
3
3
  */
4
4
  import type { Component } from "@oh-my-pi/pi-tui";
5
5
  import { ImageProtocol, padding, TERMINAL, visibleWidth, wrapTextWithAnsi } from "@oh-my-pi/pi-tui";
6
- import type { Theme } from "../modes/theme/theme";
6
+ import type { Theme, ThemeColor } from "../modes/theme/theme";
7
7
  import { getSixelLineMask } from "../utils/sixel";
8
8
  import type { State } from "./types";
9
9
  import type { RenderCache } from "./utils";
@@ -13,11 +13,15 @@ export interface OutputBlockOptions {
13
13
  header?: string;
14
14
  headerMeta?: string;
15
15
  state?: State;
16
- sections?: Array<{ label?: string; lines: string[] }>;
16
+ sections?: Array<{ label?: string; lines: string[]; separator?: boolean }>;
17
17
  width: number;
18
18
  applyBg?: boolean;
19
+ contentPaddingLeft?: number;
19
20
  /** Animate the border with a sweeping dark segment (pending/running state). */
20
21
  animate?: boolean;
22
+ /** Override the state-derived border color. Used for muted "legacy" tool
23
+ * frames that should not visually compete with framed-output tools. */
24
+ borderColor?: ThemeColor;
21
25
  }
22
26
 
23
27
  const FRAMED_BLOCK_COMPONENT = Symbol("framedBlockComponent");
@@ -33,7 +37,7 @@ export function isFramedBlockComponent(component: Component): boolean {
33
37
  return (component as FramedBlockComponent)[FRAMED_BLOCK_COMPONENT] === true;
34
38
  }
35
39
 
36
- const BORDER_SHIMMER_TICK_MS = 16;
40
+ const BORDER_SHIMMER_TICK_MS = 1000 / 30;
37
41
  /** Duration of one full left↔right↔left bounce of the bottom-edge segment, in
38
42
  * ms. Position is derived from the wall clock against this fixed cycle so a
39
43
  * resize only nudges the segment proportionally instead of teleporting it. */
@@ -42,9 +46,9 @@ const BORDER_BOUNCE_MS = 3000;
42
46
  const BORDER_SEGMENT_LEN = 8;
43
47
 
44
48
  /**
45
- * Monotonic frame counter for animated borders, quantized to the TUI's ~16ms
46
- * render cap so the cache key advances once per ~60fps frame — fine enough for a
47
- * smooth segment sweep, coarse enough to coalesce multiple render passes that
49
+ * Monotonic frame counter for animated borders, quantized to the TUI's ~30fps
50
+ * render cap so the cache key advances once per animation frame — fine enough
51
+ * for a smooth segment sweep, coarse enough to coalesce multiple render passes
48
52
  * land inside the same frame.
49
53
  */
50
54
  export function borderShimmerTick(): number {
@@ -92,6 +96,11 @@ type BlockRow =
92
96
  | { kind: "content"; inner: string }
93
97
  | { kind: "sixel"; raw: string };
94
98
 
99
+ function normalizeContentPaddingLeft(value: number | undefined): number {
100
+ if (value === undefined || !Number.isFinite(value)) return 1;
101
+ return Math.max(0, Math.floor(value));
102
+ }
103
+
95
104
  export function renderOutputBlock(options: OutputBlockOptions, theme: Theme): string[] {
96
105
  const { header, headerMeta, state, sections = [], width, applyBg = true } = options;
97
106
  const h = theme.boxSharp.horizontal;
@@ -99,14 +108,15 @@ export function renderOutputBlock(options: OutputBlockOptions, theme: Theme): st
99
108
  const cap = h.repeat(3);
100
109
  const lineWidth = Math.max(0, width);
101
110
  // Border colors: running/pending use accent, success uses dim (gray), error/warning keep their colors
102
- const borderColor: "error" | "warning" | "accent" | "dim" =
103
- state === "error"
111
+ const borderColor: ThemeColor =
112
+ options.borderColor ??
113
+ (state === "error"
104
114
  ? "error"
105
115
  : state === "warning"
106
116
  ? "warning"
107
117
  : state === "running" || state === "pending"
108
118
  ? "accent"
109
- : "dim";
119
+ : "dim");
110
120
  const border = (text: string) => theme.fg(borderColor, text);
111
121
  const bgFn = (() => {
112
122
  if (!state || !applyBg) return undefined;
@@ -121,7 +131,9 @@ export function renderOutputBlock(options: OutputBlockOptions, theme: Theme): st
121
131
  };
122
132
  })();
123
133
 
124
- const contentWidth = Math.max(0, lineWidth - visibleWidth(`${v} `) - visibleWidth(v));
134
+ const contentPaddingLeft = normalizeContentPaddingLeft(options.contentPaddingLeft);
135
+ const contentWidth = Math.max(0, lineWidth - visibleWidth(v) - contentPaddingLeft - visibleWidth(v));
136
+ const contentLeftPadding = contentPaddingLeft > 0 ? padding(contentPaddingLeft) : "";
125
137
 
126
138
  // ── Layout pass: collect row descriptors so the border perimeter length is
127
139
  // known before the moving segment is positioned. ──
@@ -135,7 +147,11 @@ export function renderOutputBlock(options: OutputBlockOptions, theme: Theme): st
135
147
  });
136
148
 
137
149
  const normalizedSections = sections.length > 0 ? sections : [{ lines: [] as string[] }];
138
- for (const section of normalizedSections) {
150
+ for (let sectionIndex = 0; sectionIndex < normalizedSections.length; sectionIndex++) {
151
+ const section = normalizedSections[sectionIndex]!;
152
+ // A labeled section always draws its titled separator bar. A label-less
153
+ // section can still request a plain divider via `separator`, but only
154
+ // between sections — leading with one would just double the header bar.
139
155
  if (section.label) {
140
156
  rows.push({
141
157
  kind: "bar",
@@ -143,6 +159,12 @@ export function renderOutputBlock(options: OutputBlockOptions, theme: Theme): st
143
159
  rightChar: theme.boxSharp.teeLeft,
144
160
  label: section.label,
145
161
  });
162
+ } else if (section.separator && sectionIndex > 0) {
163
+ rows.push({
164
+ kind: "bar",
165
+ leftChar: theme.boxSharp.teeRight,
166
+ rightChar: theme.boxSharp.teeLeft,
167
+ });
146
168
  }
147
169
  const allLines = section.lines.flatMap(l => l.split("\n"));
148
170
  const sixelLineMask = TERMINAL.imageProtocol === ImageProtocol.Sixel ? getSixelLineMask(allLines) : undefined;
@@ -202,7 +224,12 @@ export function renderOutputBlock(options: OutputBlockOptions, theme: Theme): st
202
224
  const rightGlyph = row.rightChar;
203
225
  if (lineWidth <= 0) return border(leftGlyphs) + border(rightGlyph);
204
226
  const labelText = [row.label, row.meta].filter(Boolean).join(theme.sep.dot);
205
- const rawLabel = labelText ? ` ${labelText} ` : " ";
227
+ if (!labelText) {
228
+ // No header: draw a clean, continuous top/separator bar (no 1-col gap).
229
+ const fillCount = Math.max(0, lineWidth - visibleWidth(leftGlyphs) - visibleWidth(rightGlyph));
230
+ return `${border(leftGlyphs)}${border(h.repeat(fillCount))}${border(rightGlyph)}`;
231
+ }
232
+ const rawLabel = ` ${labelText} `;
206
233
  const leftWidth = visibleWidth(leftGlyphs);
207
234
  const rightWidth = visibleWidth(rightGlyph);
208
235
  const maxLabelWidth = Math.max(0, lineWidth - leftWidth - rightWidth);
@@ -225,7 +252,7 @@ export function renderOutputBlock(options: OutputBlockOptions, theme: Theme): st
225
252
  return `${leftStr}${fillStr}${rightStr}`;
226
253
  };
227
254
 
228
- const renderContent = (inner: string): string => `${border(`${v} `)}${inner}${border(v)}`;
255
+ const renderContent = (inner: string): string => `${border(v)}${contentLeftPadding}${inner}${border(v)}`;
229
256
 
230
257
  const lines: string[] = [];
231
258
  for (let r = 0; r < H; r++) {
@@ -269,15 +296,18 @@ export class CachedOutputBlock {
269
296
  #buildKey(options: OutputBlockOptions): bigint {
270
297
  const h = new Hasher();
271
298
  h.u32(options.width);
299
+ h.u32(normalizeContentPaddingLeft(options.contentPaddingLeft));
272
300
  h.optional(options.header);
273
301
  h.optional(options.headerMeta);
274
302
  h.optional(options.state);
303
+ h.optional(options.borderColor);
275
304
  h.bool(options.applyBg ?? true);
276
305
  h.bool(options.animate ?? false);
277
306
  if (options.animate) h.u32(borderShimmerTick());
278
307
  if (options.sections) {
279
308
  for (const s of options.sections) {
280
309
  h.optional(s.label);
310
+ h.bool(s.separator ?? false);
281
311
  for (const line of s.lines) {
282
312
  h.str(line);
283
313
  }
@@ -286,3 +316,20 @@ export class CachedOutputBlock {
286
316
  return h.digest();
287
317
  }
288
318
  }
319
+
320
+ /**
321
+ * Build a self-framing tool component backed by a cached output block. The
322
+ * `build` callback returns the block options for a given width; the cache
323
+ * dedupes re-renders. Pass `borderColor: "borderMuted"` for the dim "legacy"
324
+ * look that does not compete with the state-colored framed tools.
325
+ */
326
+ export function framedBlock(theme: Theme, build: (width: number) => OutputBlockOptions): Component {
327
+ const block = new CachedOutputBlock();
328
+ // Marked so the tool-execution container treats it as self-framing (renders
329
+ // flush, no extra padding/background) the same way `markFramedBlockComponent`
330
+ // blocks are treated.
331
+ return markFramedBlockComponent({
332
+ render: (width: number): string[] => block.render(build(width), theme),
333
+ invalidate: () => block.invalidate(),
334
+ });
335
+ }
@@ -7,6 +7,9 @@ import { formatStatusIcon } from "../tools/render-utils";
7
7
 
8
8
  export interface StatusLineOptions {
9
9
  icon?: ToolUIStatus;
10
+ /** Pre-rendered glyph that replaces the status icon (e.g. a magnifier for
11
+ * search-family tools). Takes precedence over `icon`. */
12
+ iconOverride?: string;
10
13
  spinnerFrame?: number;
11
14
  title: string;
12
15
  titleColor?: ThemeColor;
@@ -27,7 +30,8 @@ function flattenForHeader(text: string): string {
27
30
  }
28
31
 
29
32
  export function renderStatusLine(options: StatusLineOptions, theme: Theme): string {
30
- const icon = options.icon ? formatStatusIcon(options.icon, theme, options.spinnerFrame) : "";
33
+ const icon =
34
+ options.iconOverride ?? (options.icon ? formatStatusIcon(options.icon, theme, options.spinnerFrame) : "");
31
35
  const titleColor = options.titleColor ?? "accent";
32
36
  const title = theme.fg(titleColor, flattenForHeader(options.title));
33
37
  let line = icon ? `${icon} ${title}` : title;
@@ -6,8 +6,9 @@ import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
6
6
  import type { Api, Model } from "@oh-my-pi/pi-ai";
7
7
  import { completeSimple } from "@oh-my-pi/pi-ai";
8
8
  import { logger, prompt } from "@oh-my-pi/pi-utils";
9
+
9
10
  import type { ModelRegistry } from "../config/model-registry";
10
- import { resolveModelRoleValue } from "../config/model-resolver";
11
+ import { getModelMatchPreferences, resolveModelRoleValue } from "../config/model-resolver";
11
12
  import type { Settings } from "../config/settings";
12
13
  import MODEL_PRIO from "../priority.json" with { type: "json" };
13
14
  import commitSystemPrompt from "../prompts/system/commit-message-system.md" with { type: "text" };
@@ -50,7 +51,7 @@ function getSmolModelCandidates(
50
51
  candidates.push({ model, thinkingLevel });
51
52
  };
52
53
 
53
- const matchPreferences = { usageOrder: settings.getStorage()?.getModelUsageOrder() };
54
+ const matchPreferences = getModelMatchPreferences(settings);
54
55
  const configuredSmol = resolveModelRoleValue(settings.getModelRole("smol"), availableModels, {
55
56
  settings,
56
57
  matchPreferences,
@@ -110,7 +111,14 @@ export async function generateCommitMessage(
110
111
  systemPrompt: [COMMIT_SYSTEM_PROMPT],
111
112
  messages: [{ role: "user", content: userMessage, timestamp: Date.now() }],
112
113
  },
113
- { apiKey, maxTokens, reasoning: toReasoningEffort(candidate.thinkingLevel) },
114
+ {
115
+ apiKey: registry.resolver(candidate.model.provider, {
116
+ sessionId,
117
+ baseUrl: candidate.model.baseUrl,
118
+ }),
119
+ maxTokens,
120
+ reasoning: toReasoningEffort(candidate.thinkingLevel),
121
+ },
114
122
  );
115
123
 
116
124
  if (response.stopReason === "error") {
@@ -0,0 +1,230 @@
1
+ import type { ImageContent } from "@oh-my-pi/pi-ai";
2
+
3
+ const OSC5522_PREFIX = "\x1b]5522;";
4
+ const OSC_TERMINATOR_ST = "\x1b\\";
5
+ const OSC_TERMINATOR_BEL = "\x07";
6
+ const PASTE_EVENT_NAME_BASE64 = Buffer.from("Paste event", "utf8").toString("base64");
7
+
8
+ const IMAGE_MIME_PRIORITY = ["image/png", "image/jpeg", "image/webp", "image/gif"] as const;
9
+ const TEXT_MIME_TYPE = "text/plain";
10
+ /** Kitty's "give me the list of available MIME types" sentinel — see `TARGETS_MIME` in `kitty/clipboard.py`. */
11
+ const MIME_LISTING_TARGET = ".";
12
+
13
+ type PasteReadKind = "image" | "text";
14
+
15
+ export interface Osc5522Packet {
16
+ metadata: Map<string, string>;
17
+ payload: string;
18
+ }
19
+
20
+ interface PasteListingState {
21
+ phase: "listing";
22
+ mimes: string[];
23
+ kittyDotPayload?: true;
24
+ pw?: string;
25
+ loc?: string;
26
+ }
27
+
28
+ interface PasteReadState {
29
+ phase: "reading";
30
+ kind: PasteReadKind;
31
+ mimeType: string;
32
+ chunks: string[];
33
+ }
34
+
35
+ type PasteState = PasteListingState | PasteReadState;
36
+
37
+ export interface EnhancedPasteHandlers {
38
+ write(data: string): void;
39
+ pasteText(text: string): void;
40
+ pasteImage(image: ImageContent): void | Promise<void>;
41
+ showStatus(message: string): void;
42
+ }
43
+
44
+ export function isOsc5522Packet(data: string): boolean {
45
+ return data.startsWith(OSC5522_PREFIX) && (data.endsWith(OSC_TERMINATOR_ST) || data.endsWith(OSC_TERMINATOR_BEL));
46
+ }
47
+
48
+ function decodeBase64Utf8(value: string): string | undefined {
49
+ try {
50
+ return Buffer.from(value, "base64").toString("utf8");
51
+ } catch {
52
+ return undefined;
53
+ }
54
+ }
55
+
56
+ function parseMetadata(raw: string): Map<string, string> {
57
+ const metadata = new Map<string, string>();
58
+ for (const part of raw.split(":")) {
59
+ const eq = part.indexOf("=");
60
+ if (eq <= 0) continue;
61
+ metadata.set(part.slice(0, eq), part.slice(eq + 1));
62
+ }
63
+ return metadata;
64
+ }
65
+
66
+ export function parseOsc5522Packet(data: string): Osc5522Packet | undefined {
67
+ if (!isOsc5522Packet(data)) return undefined;
68
+ const bodyEnd = data.endsWith(OSC_TERMINATOR_BEL) ? data.length - 1 : data.length - OSC_TERMINATOR_ST.length;
69
+ const body = data.slice(OSC5522_PREFIX.length, bodyEnd);
70
+ const separator = body.indexOf(";");
71
+ const metadataRaw = separator === -1 ? body : body.slice(0, separator);
72
+ const payload = separator === -1 ? "" : body.slice(separator + 1);
73
+ return { metadata: parseMetadata(metadataRaw), payload };
74
+ }
75
+
76
+ function choosePasteMime(mimes: readonly string[]): { kind: PasteReadKind; mimeType: string } | undefined {
77
+ for (const mimeType of IMAGE_MIME_PRIORITY) {
78
+ if (mimes.includes(mimeType)) return { kind: "image", mimeType };
79
+ }
80
+ return mimes.includes(TEXT_MIME_TYPE) ? { kind: "text", mimeType: TEXT_MIME_TYPE } : undefined;
81
+ }
82
+
83
+ export class EnhancedPasteController {
84
+ #state: PasteState | undefined;
85
+ #handlers: EnhancedPasteHandlers;
86
+
87
+ constructor(handlers: EnhancedPasteHandlers) {
88
+ this.#handlers = handlers;
89
+ }
90
+
91
+ enable(): void {
92
+ this.#handlers.write("\x1b[?5522h");
93
+ }
94
+
95
+ disable(): void {
96
+ this.#handlers.write("\x1b[?5522l");
97
+ this.#state = undefined;
98
+ }
99
+
100
+ handleInput(data: string): boolean {
101
+ const packet = parseOsc5522Packet(data);
102
+ if (!packet) return false;
103
+ void this.#handlePacket(packet);
104
+ return true;
105
+ }
106
+
107
+ async #handlePacket(packet: Osc5522Packet): Promise<void> {
108
+ const type = packet.metadata.get("type");
109
+ if (type !== "read") return;
110
+
111
+ const status = packet.metadata.get("status");
112
+ if (status === "OK") {
113
+ this.#handleOk(packet);
114
+ return;
115
+ }
116
+ if (status === "DATA") {
117
+ this.#handleData(packet);
118
+ return;
119
+ }
120
+ if (status === "DONE") {
121
+ await this.#handleDone();
122
+ return;
123
+ }
124
+ if (status) {
125
+ this.#state = undefined;
126
+ this.#handlers.showStatus(`Enhanced paste failed: ${status}`);
127
+ }
128
+ }
129
+
130
+ #handleOk(packet: Osc5522Packet): void {
131
+ if (this.#state?.phase === "reading") return;
132
+ const loc = packet.metadata.get("loc");
133
+ this.#state = {
134
+ phase: "listing",
135
+ mimes: [],
136
+ pw: packet.metadata.get("pw"),
137
+ loc: loc === "primary" ? loc : undefined,
138
+ };
139
+ }
140
+
141
+ #handleData(packet: Osc5522Packet): void {
142
+ const state = this.#state;
143
+ if (!state) return;
144
+ const encodedMime = packet.metadata.get("mime");
145
+ if (!encodedMime) return;
146
+ const mimeType = decodeBase64Utf8(encodedMime);
147
+ if (!mimeType) return;
148
+
149
+ if (state.phase === "listing") {
150
+ // Kitty (as of writing) implements the "list available MIME types"
151
+ // response shape by sending a single DATA packet with `mime="."` and
152
+ // the available types packed into the payload as a whitespace-
153
+ // separated list (see `fulfill_read_request` in
154
+ // kovidgoyal/kitty:kitty/clipboard.py). The 5522-mode ancillary
155
+ // spec instead encodes each type as its own DATA packet with an
156
+ // empty payload. Support both — fall through to the per-packet
157
+ // form when the dot sentinel has no payload, or when the packet
158
+ // already names a concrete MIME type.
159
+ if (mimeType === MIME_LISTING_TARGET) {
160
+ if (!packet.payload) return;
161
+ const listing = decodeBase64Utf8(packet.payload);
162
+ if (!listing) return;
163
+ state.kittyDotPayload = true;
164
+ for (const candidate of listing.split(/\s+/)) {
165
+ if (candidate && candidate !== MIME_LISTING_TARGET) state.mimes.push(candidate);
166
+ }
167
+ return;
168
+ }
169
+ state.mimes.push(mimeType);
170
+ return;
171
+ }
172
+
173
+ if (state.mimeType === mimeType && packet.payload) {
174
+ state.chunks.push(packet.payload);
175
+ }
176
+ }
177
+
178
+ async #handleDone(): Promise<void> {
179
+ const state = this.#state;
180
+ if (!state) return;
181
+ if (state.phase === "listing") {
182
+ this.#finishListing(state);
183
+ return;
184
+ }
185
+ this.#state = undefined;
186
+ const bytes = Buffer.concat(state.chunks.map(chunk => Buffer.from(chunk, "base64")));
187
+ if (bytes.byteLength === 0) {
188
+ this.#handlers.showStatus("Clipboard paste was empty");
189
+ return;
190
+ }
191
+ if (state.kind === "text") {
192
+ this.#handlers.pasteText(bytes.toString("utf8"));
193
+ return;
194
+ }
195
+ await this.#handlers.pasteImage({
196
+ type: "image",
197
+ data: bytes.toString("base64"),
198
+ mimeType: state.mimeType,
199
+ });
200
+ }
201
+
202
+ #finishListing(state: PasteListingState): void {
203
+ const selected = choosePasteMime(state.mimes);
204
+ if (!selected) {
205
+ this.#state = undefined;
206
+ this.#handlers.showStatus("Clipboard paste has no supported text or image data");
207
+ return;
208
+ }
209
+
210
+ this.#state = {
211
+ phase: "reading",
212
+ kind: selected.kind,
213
+ mimeType: selected.mimeType,
214
+ chunks: [],
215
+ };
216
+
217
+ const encodedMime = Buffer.from(selected.mimeType, "utf8").toString("base64");
218
+ const metadata = ["type=read"];
219
+ if (state.loc) metadata.push(`loc=${state.loc}`);
220
+ if (state.pw) {
221
+ metadata.push(`pw=${state.pw}`, `name=${PASTE_EVENT_NAME_BASE64}`);
222
+ }
223
+ if (state.kittyDotPayload) {
224
+ this.#handlers.write(`${OSC5522_PREFIX}${metadata.join(":")};${encodedMime}${OSC_TERMINATOR_BEL}`);
225
+ return;
226
+ }
227
+ metadata.push(`mime=${encodedMime}`);
228
+ this.#handlers.write(`${OSC5522_PREFIX}${metadata.join(":")}${OSC_TERMINATOR_BEL}`);
229
+ }
230
+ }
@@ -6,6 +6,7 @@ import * as path from "node:path";
6
6
  import { type Api, type AssistantMessage, completeSimple, type Model, type Tool } from "@oh-my-pi/pi-ai";
7
7
  import { logger, prompt } from "@oh-my-pi/pi-utils";
8
8
  import type { ModelRegistry } from "../config/model-registry";
9
+
9
10
  import { resolveRoleSelection } from "../config/model-resolver";
10
11
  import type { Settings } from "../config/settings";
11
12
  import titleSystemPrompt from "../prompts/system/title-system.md" with { type: "text" };
@@ -238,7 +239,7 @@ export async function generateTitleOnline(
238
239
  tools: [setTitleTool],
239
240
  },
240
241
  {
241
- apiKey,
242
+ apiKey: registry.resolver(model.provider, { sessionId, baseUrl: model.baseUrl }),
242
243
  maxTokens,
243
244
  disableReasoning: true,
244
245
  toolChoice: { type: "tool", name: SET_TITLE_TOOL_NAME },
@@ -7,12 +7,14 @@
7
7
  import {
8
8
  type AnthropicAuthConfig,
9
9
  type AnthropicSystemBlock,
10
+ type ApiKey,
10
11
  type AuthStorage,
11
12
  buildAnthropicAuthConfig,
12
13
  buildAnthropicSearchHeaders,
13
14
  buildAnthropicSystemBlocks,
14
15
  buildAnthropicUrl,
15
16
  stripClaudeToolPrefix,
17
+ withAuth,
16
18
  } from "@oh-my-pi/pi-ai";
17
19
  import { $env } from "@oh-my-pi/pi-utils";
18
20
  import type {
@@ -247,18 +249,13 @@ export async function searchAnthropic(
247
249
  ): Promise<SearchResponse> {
248
250
  const searchApiKey = $env.ANTHROPIC_SEARCH_API_KEY;
249
251
  const searchBaseUrl = $env.ANTHROPIC_SEARCH_BASE_URL;
250
- let auth: AnthropicAuthConfig | undefined;
252
+ const keyOrResolver: ApiKey | undefined = searchApiKey
253
+ ? searchApiKey
254
+ : "authStorage" in params
255
+ ? params.authStorage.resolver("anthropic", { sessionId: params.sessionId })
256
+ : undefined;
251
257
 
252
- if (searchApiKey) {
253
- auth = buildAnthropicAuthConfig(searchApiKey, searchBaseUrl);
254
- } else if ("authStorage" in params) {
255
- const apiKey = await params.authStorage.getApiKey("anthropic", params.sessionId, {
256
- signal: params.signal,
257
- });
258
- if (apiKey) auth = buildAnthropicAuthConfig(apiKey, searchBaseUrl);
259
- }
260
-
261
- if (!auth) {
258
+ if (!keyOrResolver) {
262
259
  throw new Error(
263
260
  "No Anthropic credentials found. Set ANTHROPIC_SEARCH_API_KEY or ANTHROPIC_API_KEY, or configure Anthropic OAuth.",
264
261
  );
@@ -267,14 +264,23 @@ export async function searchAnthropic(
267
264
  const model = getModel();
268
265
  const systemPrompt = "authStorage" in params ? params.systemPrompt : params.system_prompt;
269
266
  const maxTokens = "authStorage" in params ? params.maxOutputTokens : params.max_tokens;
270
- const response = await callSearch(
271
- auth,
272
- model,
273
- params.query,
274
- systemPrompt,
275
- maxTokens,
276
- params.temperature,
277
- params.signal,
267
+ const response = await withAuth(
268
+ keyOrResolver,
269
+ key =>
270
+ callSearch(
271
+ buildAnthropicAuthConfig(key, searchBaseUrl),
272
+ model,
273
+ params.query,
274
+ systemPrompt,
275
+ maxTokens,
276
+ params.temperature,
277
+ params.signal,
278
+ ),
279
+ {
280
+ signal: params.signal,
281
+ missingKeyMessage:
282
+ "No Anthropic credentials found. Set ANTHROPIC_SEARCH_API_KEY or ANTHROPIC_API_KEY, or configure Anthropic OAuth.",
283
+ },
278
284
  );
279
285
 
280
286
  const result = parseResponse(response);
@@ -114,8 +114,34 @@ interface CodexResponse {
114
114
  usage?: CodexUsage;
115
115
  }
116
116
 
117
+ /**
118
+ * Known Codex "image placeholder" answers — short prose the assistant emits in
119
+ * place of a real answer when it produced a screenshot instead of text. These
120
+ * carry no information, so callers treat them as non-answers and advance the
121
+ * chain to a provider that returns text. Extend by adding the normalized
122
+ * literal below; no regex tuning required.
123
+ */
124
+ const IMAGE_PLACEHOLDER_ANSWERS: ReadonlySet<string> = new Set([
125
+ "see attached image",
126
+ "attached image",
127
+ "see the attached image",
128
+ "see image",
129
+ "see image above",
130
+ "image above",
131
+ "see image below",
132
+ "image below",
133
+ ]);
134
+
117
135
  function isImagePlaceholderAnswer(text: string): boolean {
118
- return text.trim().toLowerCase() === "(see attached image)";
136
+ // Strip surrounding brackets/quotes and trailing punctuation, lowercase,
137
+ // then match against the known-placeholder set.
138
+ const normalized = text
139
+ .trim()
140
+ .replace(/^[[("'`*_]+/, "")
141
+ .replace(/[\])"'`*_.!?]+$/, "")
142
+ .trim()
143
+ .toLowerCase();
144
+ return IMAGE_PLACEHOLDER_ANSWERS.has(normalized);
119
145
  }
120
146
 
121
147
  function addSource(sources: SearchSource[], source: SearchSource): void {
@@ -423,15 +449,18 @@ async function callCodexSearch(
423
449
 
424
450
  const finalAnswer = answerParts.join("\n\n").trim();
425
451
  const streamedAnswer = streamedAnswerParts.join("").trim();
426
- if (isImagePlaceholderAnswer(finalAnswer) && streamedAnswer.length === 0) {
452
+ // Throw to advance the chain whenever Codex emitted nothing but image
453
+ // placeholder prose — including the case where the streamed delta itself
454
+ // is the placeholder (the model occasionally streams the same text it
455
+ // publishes as the final output_text).
456
+ const finalIsPlaceholder = finalAnswer.length > 0 && isImagePlaceholderAnswer(finalAnswer);
457
+ const streamedIsPlaceholder = streamedAnswer.length > 0 && isImagePlaceholderAnswer(streamedAnswer);
458
+ const hasFinalText = finalAnswer.length > 0 && !finalIsPlaceholder;
459
+ const hasStreamedText = streamedAnswer.length > 0 && !streamedIsPlaceholder;
460
+ if (!hasFinalText && !hasStreamedText && sources.length === 0) {
427
461
  throw new SearchProviderError("codex", "Codex returned image-only response", 502);
428
462
  }
429
- const answer =
430
- finalAnswer.length > 0 && !isImagePlaceholderAnswer(finalAnswer)
431
- ? finalAnswer
432
- : streamedAnswer.length > 0
433
- ? streamedAnswer
434
- : finalAnswer;
463
+ const answer = hasFinalText ? finalAnswer : hasStreamedText ? streamedAnswer : "";
435
464
 
436
465
  // Fallback: when Codex omits url_citation annotations, scrape markdown links
437
466
  // and bare URLs from the synthesized answer so callers still receive sources.
@@ -6,7 +6,7 @@
6
6
  * Requests per-result summaries via `contents.summary` and synthesizes
7
7
  * them into a combined `answer` string on the SearchResponse.
8
8
  */
9
- import { type AuthStorage, getEnvApiKey } from "@oh-my-pi/pi-ai";
9
+ import { type ApiKey, type AuthStorage, getEnvApiKey, withAuth } from "@oh-my-pi/pi-ai";
10
10
  import { settings } from "../../../config/settings";
11
11
  import { callExaTool, findApiKey, isSearchResponse } from "../../../exa/mcp-client";
12
12
 
@@ -228,11 +228,19 @@ async function callExaMcpSearch(params: ExaSearchParams): Promise<ExaSearchRespo
228
228
 
229
229
  /** Execute Exa web search */
230
230
  export async function searchExa(params: ExaSearchParams): Promise<SearchResponse> {
231
+ // AuthStorage-backed key takes precedence (existing behavior); probe it once
232
+ // so the env-key and keyless-MCP fallbacks below stay intact, then drive the
233
+ // authStorage path through the central force-refresh/rotate retry policy.
231
234
  const storedKey = params.authStorage
232
235
  ? await params.authStorage.getApiKey("exa", params.sessionId, { signal: params.signal })
233
236
  : undefined;
234
- const apiKey = storedKey ?? getEnvApiKey("exa");
235
- const response = apiKey ? await callExaSearch(apiKey, params) : await callExaMcpSearch(params);
237
+ const keyOrResolver: ApiKey | undefined =
238
+ storedKey && params.authStorage
239
+ ? params.authStorage.resolver("exa", { sessionId: params.sessionId })
240
+ : getEnvApiKey("exa");
241
+ const response = keyOrResolver
242
+ ? await withAuth(keyOrResolver, key => callExaSearch(key, params), { signal: params.signal })
243
+ : await callExaMcpSearch(params);
236
244
 
237
245
  // Convert to unified SearchResponse
238
246
  const sources: SearchSource[] = [];