@oh-my-pi/pi-coding-agent 11.2.3 → 11.3.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 (92) hide show
  1. package/CHANGELOG.md +100 -0
  2. package/examples/extensions/plan-mode.ts +1 -1
  3. package/examples/hooks/qna.ts +1 -1
  4. package/examples/hooks/status-line.ts +1 -1
  5. package/examples/sdk/11-sessions.ts +1 -1
  6. package/package.json +8 -8
  7. package/src/cli/args.ts +9 -6
  8. package/src/cli/update-cli.ts +2 -2
  9. package/src/commands/index/index.ts +2 -5
  10. package/src/commit/agentic/agent.ts +1 -1
  11. package/src/commit/changelog/index.ts +2 -2
  12. package/src/config/keybindings.ts +16 -1
  13. package/src/config/model-registry.ts +25 -20
  14. package/src/config/model-resolver.ts +8 -8
  15. package/src/config/resolve-config-value.ts +92 -0
  16. package/src/config/settings-schema.ts +9 -0
  17. package/src/config.ts +14 -1
  18. package/src/export/html/template.css +7 -0
  19. package/src/export/html/template.generated.ts +1 -1
  20. package/src/export/html/template.js +33 -16
  21. package/src/extensibility/custom-commands/bundled/review/index.ts +1 -1
  22. package/src/extensibility/extensions/index.ts +18 -0
  23. package/src/extensibility/extensions/loader.ts +15 -0
  24. package/src/extensibility/extensions/runner.ts +78 -1
  25. package/src/extensibility/extensions/types.ts +131 -5
  26. package/src/extensibility/extensions/wrapper.ts +1 -1
  27. package/src/extensibility/plugins/git-url.ts +270 -0
  28. package/src/extensibility/plugins/index.ts +2 -0
  29. package/src/extensibility/slash-commands.ts +45 -0
  30. package/src/index.ts +7 -0
  31. package/src/lsp/render.ts +50 -43
  32. package/src/lsp/utils.ts +2 -2
  33. package/src/main.ts +11 -10
  34. package/src/mcp/transports/stdio.ts +3 -5
  35. package/src/modes/components/custom-message.ts +0 -8
  36. package/src/modes/components/diff.ts +1 -7
  37. package/src/modes/components/footer.ts +4 -4
  38. package/src/modes/components/model-selector.ts +4 -0
  39. package/src/modes/components/todo-display.ts +13 -3
  40. package/src/modes/components/tool-execution.ts +30 -16
  41. package/src/modes/components/tree-selector.ts +50 -19
  42. package/src/modes/controllers/event-controller.ts +1 -0
  43. package/src/modes/controllers/extension-ui-controller.ts +34 -2
  44. package/src/modes/controllers/input-controller.ts +47 -33
  45. package/src/modes/controllers/selector-controller.ts +10 -15
  46. package/src/modes/interactive-mode.ts +50 -38
  47. package/src/modes/print-mode.ts +6 -0
  48. package/src/modes/rpc/rpc-client.ts +4 -4
  49. package/src/modes/rpc/rpc-mode.ts +17 -2
  50. package/src/modes/rpc/rpc-types.ts +2 -2
  51. package/src/modes/types.ts +1 -0
  52. package/src/modes/utils/ui-helpers.ts +3 -1
  53. package/src/patch/applicator.ts +2 -3
  54. package/src/patch/fuzzy.ts +1 -1
  55. package/src/patch/shared.ts +74 -61
  56. package/src/prompts/system/system-prompt.md +1 -0
  57. package/src/prompts/tools/task.md +6 -0
  58. package/src/sdk.ts +15 -11
  59. package/src/session/agent-session.ts +72 -23
  60. package/src/session/auth-storage.ts +2 -1
  61. package/src/session/blob-store.ts +105 -0
  62. package/src/session/session-manager.ts +107 -44
  63. package/src/task/executor.ts +19 -9
  64. package/src/task/render.ts +80 -58
  65. package/src/tools/ask.ts +28 -5
  66. package/src/tools/bash.ts +47 -39
  67. package/src/tools/browser.ts +248 -26
  68. package/src/tools/calculator.ts +42 -23
  69. package/src/tools/fetch.ts +33 -16
  70. package/src/tools/find.ts +57 -22
  71. package/src/tools/grep.ts +54 -25
  72. package/src/tools/index.ts +5 -5
  73. package/src/tools/notebook.ts +19 -6
  74. package/src/tools/path-utils.ts +26 -1
  75. package/src/tools/python.ts +20 -14
  76. package/src/tools/read.ts +21 -8
  77. package/src/tools/render-utils.ts +5 -45
  78. package/src/tools/ssh.ts +59 -53
  79. package/src/tools/submit-result.ts +2 -2
  80. package/src/tools/todo-write.ts +32 -14
  81. package/src/tools/truncate.ts +1 -1
  82. package/src/tools/write.ts +39 -24
  83. package/src/tui/output-block.ts +61 -3
  84. package/src/tui/tree-list.ts +4 -4
  85. package/src/tui/utils.ts +71 -1
  86. package/src/utils/frontmatter.ts +1 -1
  87. package/src/utils/title-generator.ts +1 -1
  88. package/src/utils/tools-manager.ts +18 -2
  89. package/src/web/scrapers/osv.ts +4 -1
  90. package/src/web/scrapers/youtube.ts +1 -1
  91. package/src/web/search/index.ts +1 -1
  92. package/src/web/search/render.ts +96 -90
@@ -10,6 +10,7 @@ import puppeteer from "puppeteer";
10
10
  import { renderPromptTemplate } from "../config/prompt-templates";
11
11
  import browserDescription from "../prompts/tools/browser.md" with { type: "text" };
12
12
  import type { ToolSession } from "../sdk";
13
+ import { formatDimensionNote, resizeImage } from "../utils/image-resize";
13
14
  import { htmlToBasicMarkdown } from "../web/scrapers/types";
14
15
  import type { OutputMeta } from "./output-meta";
15
16
  import { resolveToCwd } from "./path-utils";
@@ -60,6 +61,172 @@ const INTERACTIVE_AX_ROLES = new Set([
60
61
  "treeitem",
61
62
  ]);
62
63
 
64
+ const LEGACY_SELECTOR_PREFIXES = ["p-aria/", "p-text/", "p-xpath/", "p-pierce/"] as const;
65
+
66
+ function normalizeSelector(selector: string): string {
67
+ if (!selector) return selector;
68
+ if (selector.startsWith("p-") && !LEGACY_SELECTOR_PREFIXES.some(prefix => selector.startsWith(prefix))) {
69
+ throw new ToolError(
70
+ `Unsupported selector prefix. Use CSS or puppeteer query handlers (aria/, text/, xpath/, pierce/). Got: ${selector}`,
71
+ );
72
+ }
73
+ if (selector.startsWith("p-text/")) {
74
+ return `text/${selector.slice("p-text/".length)}`;
75
+ }
76
+ if (selector.startsWith("p-xpath/")) {
77
+ return `xpath/${selector.slice("p-xpath/".length)}`;
78
+ }
79
+ if (selector.startsWith("p-pierce/")) {
80
+ return `pierce/${selector.slice("p-pierce/".length)}`;
81
+ }
82
+ if (selector.startsWith("p-aria/")) {
83
+ const rest = selector.slice("p-aria/".length);
84
+ // Playwright-style: p-aria/[name="Sign in"] → aria/Sign in
85
+ const nameMatch = rest.match(/\[\s*name\s*=\s*(?:"([^"]+)"|'([^']+)'|([^\]]+))\s*\]/);
86
+ const name = nameMatch?.[1] ?? nameMatch?.[2] ?? nameMatch?.[3];
87
+ if (name) return `aria/${name.trim()}`;
88
+ return `aria/${rest}`;
89
+ }
90
+ return selector;
91
+ }
92
+
93
+ type ActionabilityResult = { ok: true; x: number; y: number } | { ok: false; reason: string };
94
+
95
+ async function resolveActionableQueryHandlerClickTarget(handles: ElementHandle[]): Promise<ElementHandle | null> {
96
+ const candidates: Array<{ handle: ElementHandle; rect: { x: number; y: number; w: number; h: number } }> = [];
97
+
98
+ for (const handle of handles) {
99
+ let clickable: ElementHandle = handle;
100
+ let clickableProxy: ElementHandle | null = null;
101
+ try {
102
+ const proxy = await handle.evaluateHandle(el => {
103
+ const target =
104
+ (el as Element).closest(
105
+ 'a,button,[role="button"],[role="link"],input[type="button"],input[type="submit"]',
106
+ ) ?? el;
107
+ return target;
108
+ });
109
+ const nodeHandle = proxy.asElement();
110
+ clickableProxy = nodeHandle ? (nodeHandle as unknown as ElementHandle) : null;
111
+ if (clickableProxy) {
112
+ clickable = clickableProxy;
113
+ }
114
+ } catch {
115
+ // ignore
116
+ }
117
+
118
+ try {
119
+ const intersecting = await clickable.isIntersectingViewport();
120
+ if (!intersecting) continue;
121
+ const rect = (await clickable.evaluate(el => {
122
+ const r = (el as Element).getBoundingClientRect();
123
+ return { x: r.left, y: r.top, w: r.width, h: r.height };
124
+ })) as { x: number; y: number; w: number; h: number };
125
+ if (rect.w < 1 || rect.h < 1) continue;
126
+ candidates.push({ handle: clickable, rect });
127
+ } catch {
128
+ // ignore
129
+ } finally {
130
+ if (clickableProxy && clickableProxy !== handle) {
131
+ try {
132
+ await clickableProxy.dispose();
133
+ } catch {}
134
+ }
135
+ }
136
+ }
137
+
138
+ if (!candidates.length) return null;
139
+
140
+ // Prefer top-most visible element (nav/header usually wins), tie-break by left-most.
141
+ candidates.sort((a, b) => a.rect.y - b.rect.y || a.rect.x - b.rect.x);
142
+ return candidates[0]?.handle ?? null;
143
+ }
144
+
145
+ async function isClickActionable(handle: ElementHandle): Promise<ActionabilityResult> {
146
+ return (await handle.evaluate(el => {
147
+ const element = el as HTMLElement;
148
+ const style = globalThis.getComputedStyle(element);
149
+ if (style.display === "none") return { ok: false as const, reason: "display:none" };
150
+ if (style.visibility === "hidden") return { ok: false as const, reason: "visibility:hidden" };
151
+ if (style.pointerEvents === "none") return { ok: false as const, reason: "pointer-events:none" };
152
+ if (Number(style.opacity) === 0) return { ok: false as const, reason: "opacity:0" };
153
+
154
+ const r = element.getBoundingClientRect();
155
+ if (r.width < 1 || r.height < 1) return { ok: false as const, reason: "zero-size" };
156
+
157
+ const vw = globalThis.innerWidth;
158
+ const vh = globalThis.innerHeight;
159
+ const left = Math.max(0, Math.min(vw, r.left));
160
+ const right = Math.max(0, Math.min(vw, r.right));
161
+ const top = Math.max(0, Math.min(vh, r.top));
162
+ const bottom = Math.max(0, Math.min(vh, r.bottom));
163
+ if (right - left < 1 || bottom - top < 1) return { ok: false as const, reason: "off-viewport" };
164
+
165
+ const x = Math.floor((left + right) / 2);
166
+ const y = Math.floor((top + bottom) / 2);
167
+ const topEl = globalThis.document.elementFromPoint(x, y);
168
+ if (!topEl) return { ok: false as const, reason: "elementFromPoint-null" };
169
+ if (topEl === element || element.contains(topEl) || (topEl as Element).contains(element)) {
170
+ return { ok: true as const, x, y };
171
+ }
172
+ return { ok: false as const, reason: "obscured" };
173
+ })) as ActionabilityResult;
174
+ }
175
+
176
+ async function clickQueryHandlerText(
177
+ page: Page,
178
+ selector: string,
179
+ timeoutMs: number,
180
+ signal?: AbortSignal,
181
+ ): Promise<void> {
182
+ const timeoutSignal = AbortSignal.timeout(timeoutMs);
183
+ const clickSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
184
+ const start = Date.now();
185
+ let lastSeen = 0;
186
+ let lastReason: string | null = null;
187
+
188
+ while (Date.now() - start < timeoutMs) {
189
+ throwIfAborted(clickSignal);
190
+ const handles = (await untilAborted(clickSignal, () => page.$$(selector))) as ElementHandle[];
191
+ try {
192
+ lastSeen = handles.length;
193
+ const target = await resolveActionableQueryHandlerClickTarget(handles);
194
+ if (!target) {
195
+ lastReason = handles.length ? "no-visible-candidate" : "no-matches";
196
+ await Bun.sleep(100);
197
+ continue;
198
+ }
199
+ const actionability = await isClickActionable(target);
200
+ if (!actionability.ok) {
201
+ lastReason = actionability.reason;
202
+ await Bun.sleep(100);
203
+ continue;
204
+ }
205
+
206
+ try {
207
+ await untilAborted(clickSignal, () => target.click());
208
+ return;
209
+ } catch (err) {
210
+ lastReason = err instanceof Error ? err.message : String(err);
211
+ await Bun.sleep(100);
212
+ }
213
+ } finally {
214
+ await Promise.all(
215
+ handles.map(async h => {
216
+ try {
217
+ await h.dispose();
218
+ } catch {}
219
+ }),
220
+ );
221
+ }
222
+ }
223
+
224
+ throw new ToolError(
225
+ `Timed out clicking ${selector} (seen ${lastSeen} matches; last reason: ${lastReason ?? "unknown"}). ` +
226
+ "If there are multiple matching elements, use observe+click_id or a more specific selector.",
227
+ );
228
+ }
229
+
63
230
  /**
64
231
  * Stealth init scripts for Puppeteer.
65
232
  */
@@ -93,7 +260,10 @@ function resolvePageClient(page: Page): PuppeteerCdpClient | null {
93
260
 
94
261
  const puppeteerGetArgsSchema = Type.Array(
95
262
  Type.Object({
96
- selector: Type.String({ description: "CSS selector for the target element" }),
263
+ selector: Type.String({
264
+ description:
265
+ "Selector for the target element (CSS, or puppeteer query handler like aria/, text/, xpath/, pierce/; also accepts legacy p- prefixes)",
266
+ }),
97
267
  attribute: Type.Optional(Type.String({ description: "Attribute name (get_attribute)" })),
98
268
  }),
99
269
  { description: "Batch arguments for get_* actions", minItems: 1 },
@@ -126,7 +296,12 @@ const browserSchema = Type.Object({
126
296
  { description: "Action to perform" },
127
297
  ),
128
298
  url: Type.Optional(Type.String({ description: "URL to navigate to (goto)" })),
129
- selector: Type.Optional(Type.String({ description: "CSS selector for the target element" })),
299
+ selector: Type.Optional(
300
+ Type.String({
301
+ description:
302
+ "Selector for the target element (CSS, or puppeteer query handler like aria/, text/, xpath/, pierce/; also accepts legacy p- prefixes)",
303
+ }),
304
+ ),
130
305
  element_id: Type.Optional(Type.Number({ description: "Element ID from observe" })),
131
306
  include_all: Type.Optional(Type.Boolean({ description: "Include non-interactive nodes in observe" })),
132
307
  viewport_only: Type.Optional(Type.Boolean({ description: "Limit observe output to elements in the viewport" })),
@@ -159,8 +334,18 @@ const browserSchema = Type.Object({
159
334
  ),
160
335
  delta_x: Type.Optional(Type.Number({ description: "Scroll delta X (scroll)" })),
161
336
  delta_y: Type.Optional(Type.Number({ description: "Scroll delta Y (scroll)" })),
162
- from_selector: Type.Optional(Type.String({ description: "Drag start selector (drag)" })),
163
- to_selector: Type.Optional(Type.String({ description: "Drag end selector (drag)" })),
337
+ from_selector: Type.Optional(
338
+ Type.String({
339
+ description:
340
+ "Drag start selector (CSS, or puppeteer query handler like aria/, text/, xpath/, pierce/; also accepts legacy p- prefixes)",
341
+ }),
342
+ ),
343
+ to_selector: Type.Optional(
344
+ Type.String({
345
+ description:
346
+ "Drag end selector (CSS, or puppeteer query handler like aria/, text/, xpath/, pierce/; also accepts legacy p- prefixes)",
347
+ }),
348
+ ),
164
349
  });
165
350
 
166
351
  /** Input schema for the Puppeteer tool. */
@@ -287,20 +472,23 @@ export class BrowserTool implements AgentTool<typeof browserSchema, BrowserToolD
287
472
  private async resetBrowser(params?: BrowserParams): Promise<Page> {
288
473
  await this.closeBrowser();
289
474
  this.currentHeadless = this.session.settings.get("browser.headless");
475
+ const initialViewport = params?.viewport ?? DEFAULT_VIEWPORT;
290
476
  this.browser = await puppeteer.launch({
291
477
  headless: this.currentHeadless,
292
- defaultViewport: DEFAULT_VIEWPORT,
478
+ defaultViewport: this.currentHeadless ? initialViewport : null,
293
479
  args: [
294
480
  "--no-sandbox",
295
481
  "--disable-setuid-sandbox",
296
482
  "--disable-blink-features=AutomationControlled",
297
- `--window-size=${DEFAULT_VIEWPORT.width},${DEFAULT_VIEWPORT.height}`,
483
+ `--window-size=${initialViewport.width},${initialViewport.height}`,
298
484
  ],
299
485
  ignoreDefaultArgs: [...STEALTH_IGNORE_DEFAULT_ARGS],
300
486
  });
301
487
  this.page = await this.browser.newPage();
302
488
  await this.applyStealthPatches(this.page);
303
- await this.applyViewport(this.page, params?.viewport);
489
+ if (this.currentHeadless || params?.viewport) {
490
+ await this.applyViewport(this.page, params?.viewport);
491
+ }
304
492
  return this.page;
305
493
  }
306
494
 
@@ -317,7 +505,9 @@ export class BrowserTool implements AgentTool<typeof browserSchema, BrowserToolD
317
505
  }
318
506
  this.page = await this.browser.newPage();
319
507
  await this.applyStealthPatches(this.page);
320
- await this.applyViewport(this.page, params?.viewport);
508
+ if (this.currentHeadless || params?.viewport) {
509
+ await this.applyViewport(this.page, params?.viewport);
510
+ }
321
511
  return this.page;
322
512
  }
323
513
 
@@ -795,8 +985,13 @@ export class BrowserTool implements AgentTool<typeof browserSchema, BrowserToolD
795
985
  const selector = ensureParam(params.selector, "selector", params.action);
796
986
  details.selector = selector;
797
987
  const page = await this.ensurePage(params);
798
- const locator = page.locator(selector).setTimeout(timeoutMs);
799
- await untilAborted(signal, () => locator.click());
988
+ const resolvedSelector = normalizeSelector(selector);
989
+ if (resolvedSelector.startsWith("text/")) {
990
+ await clickQueryHandlerText(page, resolvedSelector, timeoutMs, signal);
991
+ } else {
992
+ const locator = page.locator(resolvedSelector).setTimeout(timeoutMs);
993
+ await untilAborted(signal, () => locator.click());
994
+ }
800
995
  return toolResult(details).text(`Clicked ${selector}`).done();
801
996
  }
802
997
  case "click_id": {
@@ -816,7 +1011,8 @@ export class BrowserTool implements AgentTool<typeof browserSchema, BrowserToolD
816
1011
  const text = ensureParam(params.text, "text", params.action);
817
1012
  details.selector = selector;
818
1013
  const page = await this.ensurePage(params);
819
- const locator = page.locator(selector).setTimeout(timeoutMs);
1014
+ const resolvedSelector = normalizeSelector(selector);
1015
+ const locator = page.locator(resolvedSelector).setTimeout(timeoutMs);
820
1016
  const handle = (await untilAborted(signal, () => locator.waitHandle())) as ElementHandle;
821
1017
  await untilAborted(signal, () => handle.type(text, { delay: 0 }));
822
1018
  await handle.dispose();
@@ -842,7 +1038,8 @@ export class BrowserTool implements AgentTool<typeof browserSchema, BrowserToolD
842
1038
  const value = ensureParam(params.value, "value", params.action);
843
1039
  details.selector = selector;
844
1040
  const page = await this.ensurePage(params);
845
- const locator = page.locator(selector).setTimeout(timeoutMs);
1041
+ const resolvedSelector = normalizeSelector(selector);
1042
+ const locator = page.locator(resolvedSelector).setTimeout(timeoutMs);
846
1043
  await untilAborted(signal, () => locator.fill(value));
847
1044
  return toolResult(details).text(`Filled ${selector}`).done();
848
1045
  }
@@ -873,7 +1070,8 @@ export class BrowserTool implements AgentTool<typeof browserSchema, BrowserToolD
873
1070
  const key = ensureParam(params.key, "key", params.action) as KeyInput;
874
1071
  const page = await this.ensurePage(params);
875
1072
  if (params.selector) {
876
- await untilAborted(signal, () => page.focus(params.selector as string));
1073
+ const resolvedSelector = normalizeSelector(params.selector as string);
1074
+ await untilAborted(signal, () => page.focus(resolvedSelector));
877
1075
  }
878
1076
  await untilAborted(signal, () => page.keyboard.press(key));
879
1077
  return toolResult(details).text(`Pressed ${key}`).done();
@@ -889,8 +1087,12 @@ export class BrowserTool implements AgentTool<typeof browserSchema, BrowserToolD
889
1087
  const fromSelector = ensureParam(params.from_selector, "from_selector", params.action);
890
1088
  const toSelector = ensureParam(params.to_selector, "to_selector", params.action);
891
1089
  const page = await this.ensurePage(params);
892
- const fromHandle = (await untilAborted(signal, () => page.$(fromSelector))) as ElementHandle | null;
893
- const toHandle = (await untilAborted(signal, () => page.$(toSelector))) as ElementHandle | null;
1090
+ const resolvedFromSelector = normalizeSelector(fromSelector);
1091
+ const resolvedToSelector = normalizeSelector(toSelector);
1092
+ const fromHandle = (await untilAborted(signal, () =>
1093
+ page.$(resolvedFromSelector),
1094
+ )) as ElementHandle | null;
1095
+ const toHandle = (await untilAborted(signal, () => page.$(resolvedToSelector))) as ElementHandle | null;
894
1096
  if (!fromHandle || !toHandle) {
895
1097
  throw new ToolError("Drag selectors did not resolve to elements");
896
1098
  }
@@ -925,7 +1127,8 @@ export class BrowserTool implements AgentTool<typeof browserSchema, BrowserToolD
925
1127
  const selector = ensureParam(params.selector, "selector", params.action);
926
1128
  details.selector = selector;
927
1129
  const page = await this.ensurePage(params);
928
- const locator = page.locator(selector).setTimeout(timeoutMs);
1130
+ const resolvedSelector = normalizeSelector(selector);
1131
+ const locator = page.locator(resolvedSelector).setTimeout(timeoutMs);
929
1132
  await untilAborted(signal, () => locator.wait());
930
1133
  return toolResult(details).text(`Selector ready: ${selector}`).done();
931
1134
  }
@@ -948,8 +1151,9 @@ export class BrowserTool implements AgentTool<typeof browserSchema, BrowserToolD
948
1151
  const values = (await Promise.all(
949
1152
  params.args.map((arg, index) => {
950
1153
  const selector = ensureParam(arg.selector, `args[${index}].selector`, params.action);
1154
+ const resolvedSelector = normalizeSelector(selector);
951
1155
  return untilAborted(signal, () =>
952
- page.$eval(selector, (el: Element) => (el as HTMLElement).innerText),
1156
+ page.$eval(resolvedSelector, (el: Element) => (el as HTMLElement).innerText),
953
1157
  );
954
1158
  }),
955
1159
  )) as string[];
@@ -960,8 +1164,9 @@ export class BrowserTool implements AgentTool<typeof browserSchema, BrowserToolD
960
1164
  }
961
1165
  const selector = ensureParam(params.selector, "selector", params.action);
962
1166
  details.selector = selector;
1167
+ const resolvedSelector = normalizeSelector(selector);
963
1168
  const value = (await untilAborted(signal, () =>
964
- page.$eval(selector, (el: Element) => (el as HTMLElement).innerText),
1169
+ page.$eval(resolvedSelector, (el: Element) => (el as HTMLElement).innerText),
965
1170
  )) as string;
966
1171
  details.result = value;
967
1172
  return toolResult(details).text(value).done();
@@ -972,8 +1177,9 @@ export class BrowserTool implements AgentTool<typeof browserSchema, BrowserToolD
972
1177
  const values = (await Promise.all(
973
1178
  params.args.map((arg, index) => {
974
1179
  const selector = ensureParam(arg.selector, `args[${index}].selector`, params.action);
1180
+ const resolvedSelector = normalizeSelector(selector);
975
1181
  return untilAborted(signal, () =>
976
- page.$eval(selector, (el: Element) => (el as HTMLElement).innerHTML),
1182
+ page.$eval(resolvedSelector, (el: Element) => (el as HTMLElement).innerHTML),
977
1183
  );
978
1184
  }),
979
1185
  )) as string[];
@@ -984,8 +1190,9 @@ export class BrowserTool implements AgentTool<typeof browserSchema, BrowserToolD
984
1190
  }
985
1191
  const selector = ensureParam(params.selector, "selector", params.action);
986
1192
  details.selector = selector;
1193
+ const resolvedSelector = normalizeSelector(selector);
987
1194
  const value = (await untilAborted(signal, () =>
988
- page.$eval(selector, (el: Element) => (el as HTMLElement).innerHTML),
1195
+ page.$eval(resolvedSelector, (el: Element) => (el as HTMLElement).innerHTML),
989
1196
  )) as string;
990
1197
  details.result = value;
991
1198
  return toolResult(details).text(value).done();
@@ -997,8 +1204,13 @@ export class BrowserTool implements AgentTool<typeof browserSchema, BrowserToolD
997
1204
  params.args.map((arg, index) => {
998
1205
  const selector = ensureParam(arg.selector, `args[${index}].selector`, params.action);
999
1206
  const attribute = ensureParam(arg.attribute, `args[${index}].attribute`, params.action);
1207
+ const resolvedSelector = normalizeSelector(selector);
1000
1208
  return untilAborted(signal, () =>
1001
- page.$eval(selector, (el: Element) => (el as HTMLElement).getAttribute(String(attribute))),
1209
+ page.$eval(
1210
+ resolvedSelector,
1211
+ (el: Element, attr: string) => (el as HTMLElement).getAttribute(String(attr)),
1212
+ attribute,
1213
+ ),
1002
1214
  );
1003
1215
  }),
1004
1216
  )) as string[];
@@ -1010,9 +1222,10 @@ export class BrowserTool implements AgentTool<typeof browserSchema, BrowserToolD
1010
1222
  const selector = ensureParam(params.selector, "selector", params.action);
1011
1223
  const attribute = ensureParam(params.attribute, "attribute", params.action);
1012
1224
  details.selector = selector;
1225
+ const resolvedSelector = normalizeSelector(selector);
1013
1226
  const value = (await untilAborted(signal, () =>
1014
1227
  page.$eval(
1015
- selector,
1228
+ resolvedSelector,
1016
1229
  (el: { getAttribute: (name: string) => string | null }, attr: string) =>
1017
1230
  el.getAttribute(String(attr)),
1018
1231
  attribute,
@@ -1074,9 +1287,8 @@ export class BrowserTool implements AgentTool<typeof browserSchema, BrowserToolD
1074
1287
  let buffer: Buffer;
1075
1288
 
1076
1289
  if (params.selector) {
1077
- const handle = (await untilAborted(signal, () =>
1078
- page.$(params.selector as string),
1079
- )) as ElementHandle | null;
1290
+ const resolvedSelector = normalizeSelector(params.selector as string);
1291
+ const handle = (await untilAborted(signal, () => page.$(resolvedSelector))) as ElementHandle | null;
1080
1292
  if (!handle) {
1081
1293
  throw new ToolError("Screenshot selector did not resolve to an element");
1082
1294
  }
@@ -1110,15 +1322,25 @@ export class BrowserTool implements AgentTool<typeof browserSchema, BrowserToolD
1110
1322
  details.mimeType = mimeType;
1111
1323
  details.bytes = buffer.length;
1112
1324
 
1325
+ // Compress for API content (same as pasted images)
1326
+ const resized = await resizeImage({ type: "image", data: base64, mimeType });
1327
+ const dimensionNote = formatDimensionNote(resized);
1328
+
1113
1329
  const lines = ["Screenshot captured", `Format: ${format}`, `Bytes: ${buffer.length}`];
1330
+ if (resized.wasResized) {
1331
+ lines.push(`Compressed: ${resized.width}x${resized.height} (${resized.mimeType})`);
1332
+ }
1114
1333
  if (savedPath) {
1115
1334
  lines.push(`Saved: ${savedPath}`);
1116
1335
  }
1336
+ if (dimensionNote) {
1337
+ lines.push(dimensionNote);
1338
+ }
1117
1339
 
1118
1340
  return toolResult(details)
1119
1341
  .content([
1120
1342
  { type: "text", text: lines.join("\n") },
1121
- { type: "image", data: base64, mimeType },
1343
+ { type: "image", data: resized.data, mimeType: resized.mimeType },
1122
1344
  ])
1123
1345
  .done();
1124
1346
  }
@@ -7,16 +7,9 @@ import { renderPromptTemplate } from "../config/prompt-templates";
7
7
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
8
8
  import type { Theme } from "../modes/theme/theme";
9
9
  import calculatorDescription from "../prompts/tools/calculator.md" with { type: "text" };
10
- import { renderStatusLine, renderTreeList } from "../tui";
10
+ import { Ellipsis, Hasher, type RenderCache, renderStatusLine, renderTreeList, truncateToWidth } from "../tui";
11
11
  import type { ToolSession } from ".";
12
- import {
13
- formatCount,
14
- formatEmptyMessage,
15
- formatErrorMessage,
16
- PREVIEW_LIMITS,
17
- TRUNCATE_LENGTHS,
18
- truncateToWidth,
19
- } from "./render-utils";
12
+ import { formatCount, formatEmptyMessage, formatErrorMessage, PREVIEW_LIMITS, TRUNCATE_LENGTHS } from "./render-utils";
20
13
 
21
14
  // =============================================================================
22
15
  // Token Types
@@ -466,7 +459,7 @@ export const calculatorToolRenderer = {
466
459
  */
467
460
  renderResult(
468
461
  result: { content: Array<{ type: string; text?: string }>; details?: CalculatorToolDetails; isError?: boolean },
469
- { expanded }: RenderResultOptions,
462
+ options: RenderResultOptions,
470
463
  uiTheme: Theme,
471
464
  args?: CalculatorRenderArgs,
472
465
  ): Component {
@@ -474,7 +467,13 @@ export const calculatorToolRenderer = {
474
467
  const textContent = result.content?.find(c => c.type === "text")?.text ?? "";
475
468
  if (result.isError) {
476
469
  const header = renderStatusLine({ icon: "error", title: "Calc" }, uiTheme);
477
- return new Text([header, formatErrorMessage(textContent, uiTheme)].join("\n"), 0, 0);
470
+ const renderedLines = [header, formatErrorMessage(textContent, uiTheme)];
471
+ return {
472
+ render() {
473
+ return renderedLines;
474
+ },
475
+ invalidate() {},
476
+ };
478
477
  }
479
478
 
480
479
  // Prefer structured details; fall back to parsing text content
@@ -491,7 +490,13 @@ export const calculatorToolRenderer = {
491
490
 
492
491
  if (outputs.length === 0) {
493
492
  const header = renderStatusLine({ icon: "warning", title: "Calc" }, uiTheme);
494
- return new Text([header, formatEmptyMessage("No results", uiTheme)].join("\n"), 0, 0);
493
+ const renderedLines = [header, formatEmptyMessage("No results", uiTheme)];
494
+ return {
495
+ render() {
496
+ return renderedLines;
497
+ },
498
+ invalidate() {},
499
+ };
495
500
  }
496
501
 
497
502
  const description = args?.calculations?.[0]?.expression
@@ -501,18 +506,32 @@ export const calculatorToolRenderer = {
501
506
  { icon: "success", title: "Calc", description, meta: [formatCount("result", outputs.length)] },
502
507
  uiTheme,
503
508
  );
504
- const lines = renderTreeList(
505
- {
506
- items: outputs,
507
- expanded,
508
- maxCollapsed: COLLAPSED_LIST_LIMIT,
509
- itemType: "result",
510
- renderItem: output => uiTheme.fg("toolOutput", output),
511
- },
512
- uiTheme,
513
- );
514
509
 
515
- return new Text([header, ...lines].join("\n"), 0, 0);
510
+ let cached: RenderCache | undefined;
511
+
512
+ return {
513
+ render(width) {
514
+ const { expanded } = options;
515
+ const key = new Hasher().bool(expanded).u32(width).digest();
516
+ if (cached?.key === key) return cached.lines;
517
+ const treeLines = renderTreeList(
518
+ {
519
+ items: outputs,
520
+ expanded,
521
+ maxCollapsed: COLLAPSED_LIST_LIMIT,
522
+ itemType: "result",
523
+ renderItem: output => uiTheme.fg("toolOutput", output),
524
+ },
525
+ uiTheme,
526
+ );
527
+ const lines = [header, ...treeLines].map(l => truncateToWidth(l, width, Ellipsis.Omit));
528
+ cached = { key, lines };
529
+ return lines;
530
+ },
531
+ invalidate() {
532
+ cached = undefined;
533
+ },
534
+ };
516
535
  },
517
536
  mergeCallAndResult: true,
518
537
  };
@@ -10,7 +10,8 @@ import { renderPromptTemplate } from "../config/prompt-templates";
10
10
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
11
11
  import { type Theme, theme } from "../modes/theme/theme";
12
12
  import fetchDescription from "../prompts/tools/fetch.md" with { type: "text" };
13
- import { renderOutputBlock, renderStatusLine } from "../tui";
13
+ import { renderStatusLine } from "../tui";
14
+ import { CachedOutputBlock } from "../tui/output-block";
14
15
  import { ensureTool } from "../utils/tools-manager";
15
16
  import { specialHandlers } from "../web/scrapers";
16
17
  import type { RenderResult } from "../web/scrapers/types";
@@ -980,7 +981,6 @@ export function renderFetchResult(
980
981
  options: RenderResultOptions,
981
982
  uiTheme: Theme = theme,
982
983
  ): Component {
983
- const { expanded } = options;
984
984
  const details = result.details;
985
985
 
986
986
  if (!details) {
@@ -1031,20 +1031,32 @@ export function renderFetchResult(
1031
1031
  metadataLines.push(`${uiTheme.fg("muted", "Notes:")} ${details.notes.join("; ")}`);
1032
1032
  }
1033
1033
 
1034
- const previewLimit = expanded ? 12 : 3;
1035
- const previewList = applyListLimit(contentLines, { headLimit: previewLimit });
1036
- const previewLines = previewList.items.map(line => truncate(line.trimEnd(), 120, "…"));
1037
- const remaining = Math.max(0, contentLines.length - previewLines.length);
1038
- const contentPreviewLines =
1039
- previewLines.length > 0 ? previewLines.map(line => uiTheme.fg("dim", line)) : [uiTheme.fg("dim", "(no content)")];
1040
- if (remaining > 0) {
1041
- const hint = formatExpandHint(uiTheme, expanded, true);
1042
- contentPreviewLines.push(uiTheme.fg("muted", `… ${remaining} more lines${hint ? ` ${hint}` : ""}`));
1043
- }
1034
+ const outputBlock = new CachedOutputBlock();
1035
+ let lastExpanded: boolean | undefined;
1036
+ let contentPreviewLines: string[] | undefined;
1044
1037
 
1045
1038
  return {
1046
- render: (width: number) =>
1047
- renderOutputBlock(
1039
+ render: (width: number) => {
1040
+ const { expanded } = options;
1041
+
1042
+ if (contentPreviewLines === undefined || lastExpanded !== expanded) {
1043
+ const previewLimit = expanded ? 12 : 3;
1044
+ const previewList = applyListLimit(contentLines, { headLimit: previewLimit });
1045
+ const previewLines = previewList.items.map(line => truncate(line.trimEnd(), 120, "…"));
1046
+ const remaining = Math.max(0, contentLines.length - previewLines.length);
1047
+ contentPreviewLines =
1048
+ previewLines.length > 0
1049
+ ? previewLines.map(line => uiTheme.fg("dim", line))
1050
+ : [uiTheme.fg("dim", "(no content)")];
1051
+ if (remaining > 0) {
1052
+ const hint = formatExpandHint(uiTheme, expanded, true);
1053
+ contentPreviewLines.push(uiTheme.fg("muted", `… ${remaining} more lines${hint ? ` ${hint}` : ""}`));
1054
+ }
1055
+ lastExpanded = expanded;
1056
+ outputBlock.invalidate();
1057
+ }
1058
+
1059
+ return outputBlock.render(
1048
1060
  {
1049
1061
  header,
1050
1062
  state: truncated ? "warning" : "success",
@@ -1056,8 +1068,13 @@ export function renderFetchResult(
1056
1068
  applyBg: false,
1057
1069
  },
1058
1070
  uiTheme,
1059
- ),
1060
- invalidate: () => {},
1071
+ );
1072
+ },
1073
+ invalidate: () => {
1074
+ outputBlock.invalidate();
1075
+ contentPreviewLines = undefined;
1076
+ lastExpanded = undefined;
1077
+ },
1061
1078
  };
1062
1079
  }
1063
1080