@oh-my-pi/pi-coding-agent 17.1.2 → 17.1.4

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 (110) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/dist/cli.js +3806 -3798
  3. package/dist/types/cli/__tests__/auth-gateway-catalog.test.d.ts +1 -0
  4. package/dist/types/cli/auth-gateway-cli.d.ts +8 -0
  5. package/dist/types/cli/update-cli.d.ts +41 -0
  6. package/dist/types/cli/usage-cli.d.ts +4 -2
  7. package/dist/types/commands/update.d.ts +1 -0
  8. package/dist/types/config/model-registry.d.ts +19 -0
  9. package/dist/types/config/settings-schema.d.ts +30 -7
  10. package/dist/types/cursor.d.ts +47 -1
  11. package/dist/types/eval/js/process-entry.d.ts +2 -1
  12. package/dist/types/eval/js/worker-core.d.ts +4 -1
  13. package/dist/types/extensibility/extensions/runner.d.ts +1 -0
  14. package/dist/types/extensibility/legacy-pi-ai-shim.d.ts +4 -0
  15. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +4 -0
  16. package/dist/types/extensibility/legacy-pi-tui-shim.d.ts +15 -7
  17. package/dist/types/extensibility/shared-events.d.ts +2 -0
  18. package/dist/types/modes/components/assistant-message.d.ts +9 -0
  19. package/dist/types/modes/components/custom-editor.d.ts +12 -8
  20. package/dist/types/plan-mode/approved-plan.d.ts +3 -2
  21. package/dist/types/secrets/obfuscator.d.ts +7 -31
  22. package/dist/types/session/agent-session.d.ts +19 -4
  23. package/dist/types/session/prewalk.d.ts +2 -1
  24. package/dist/types/session/session-advisors.d.ts +8 -0
  25. package/dist/types/session/session-metadata.d.ts +29 -0
  26. package/dist/types/session/turn-recovery.d.ts +5 -5
  27. package/dist/types/stt/sherpa-runtime.d.ts +38 -0
  28. package/dist/types/thinking.d.ts +24 -0
  29. package/dist/types/tools/auto-generated-guard.d.ts +5 -2
  30. package/dist/types/tools/bash.d.ts +5 -4
  31. package/dist/types/tools/computer/exposure.d.ts +8 -0
  32. package/dist/types/tools/computer/supervisor.d.ts +1 -1
  33. package/dist/types/tools/computer/worker-entry.d.ts +1 -1
  34. package/dist/types/tools/computer/worker.d.ts +1 -1
  35. package/dist/types/tools/computer.d.ts +6 -0
  36. package/dist/types/tools/memory-render.d.ts +1 -4
  37. package/dist/types/tools/shell-tokenize.d.ts +14 -0
  38. package/dist/types/tools/todo.d.ts +7 -1
  39. package/package.json +12 -12
  40. package/src/advisor/__tests__/advisor.test.ts +80 -10
  41. package/src/advisor/runtime.ts +27 -1
  42. package/src/cli/__tests__/auth-gateway-catalog.test.ts +111 -0
  43. package/src/cli/auth-gateway-cli.ts +63 -16
  44. package/src/cli/config-cli.ts +25 -7
  45. package/src/cli/update-cli.ts +229 -29
  46. package/src/cli/usage-cli.ts +144 -15
  47. package/src/cli.ts +21 -15
  48. package/src/commands/update.ts +6 -0
  49. package/src/config/__tests__/model-registry.test.ts +42 -7
  50. package/src/config/model-registry.ts +67 -4
  51. package/src/config/settings-schema.ts +39 -8
  52. package/src/config/settings.ts +24 -2
  53. package/src/cursor.ts +153 -0
  54. package/src/dap/session.ts +70 -11
  55. package/src/edit/hashline/filesystem.ts +1 -1
  56. package/src/edit/modes/patch.ts +1 -1
  57. package/src/eval/__tests__/js-context-manager.test.ts +9 -1
  58. package/src/eval/__tests__/process-entry-import.test.ts +111 -1
  59. package/src/eval/js/process-entry.ts +9 -5
  60. package/src/eval/js/worker-core.ts +6 -2
  61. package/src/exec/bash-executor.ts +4 -2
  62. package/src/extensibility/extensions/runner.ts +23 -8
  63. package/src/extensibility/legacy-pi-ai-shim.ts +9 -0
  64. package/src/extensibility/legacy-pi-coding-agent-shim.ts +14 -2
  65. package/src/extensibility/legacy-pi-tui-shim.ts +33 -0
  66. package/src/extensibility/shared-events.ts +2 -0
  67. package/src/main.ts +22 -1
  68. package/src/modes/acp/acp-agent.ts +6 -0
  69. package/src/modes/components/assistant-message.ts +88 -11
  70. package/src/modes/components/chat-transcript-builder.ts +2 -0
  71. package/src/modes/components/custom-editor.test.ts +170 -0
  72. package/src/modes/components/custom-editor.ts +79 -29
  73. package/src/modes/components/settings-defs.ts +5 -1
  74. package/src/modes/controllers/event-controller.ts +96 -4
  75. package/src/modes/controllers/selector-controller.ts +14 -0
  76. package/src/modes/interactive-mode.ts +34 -8
  77. package/src/modes/utils/context-usage.ts +19 -2
  78. package/src/modes/utils/interactive-context-helpers.ts +1 -0
  79. package/src/plan-mode/approved-plan.ts +18 -10
  80. package/src/prompts/system/custom-system-prompt.md +1 -1
  81. package/src/prompts/system/system-prompt.md +10 -1
  82. package/src/prompts/tools/ast-edit.md +1 -1
  83. package/src/sdk.ts +4 -0
  84. package/src/secrets/obfuscator.ts +36 -126
  85. package/src/session/agent-session.ts +69 -60
  86. package/src/session/prewalk.ts +8 -3
  87. package/src/session/session-advisors.ts +67 -3
  88. package/src/session/session-metadata.ts +53 -0
  89. package/src/session/session-provider-boundary.ts +0 -1
  90. package/src/session/session-tools.ts +35 -1
  91. package/src/session/stream-guards.ts +1 -1
  92. package/src/session/turn-recovery.ts +36 -19
  93. package/src/slash-commands/builtin-registry.ts +49 -7
  94. package/src/stt/asr-worker.ts +2 -37
  95. package/src/stt/sherpa-runtime.ts +71 -0
  96. package/src/task/executor.ts +6 -5
  97. package/src/thinking.ts +39 -0
  98. package/src/tools/auto-generated-guard.ts +18 -5
  99. package/src/tools/bash.ts +43 -15
  100. package/src/tools/computer/exposure.ts +38 -0
  101. package/src/tools/computer/supervisor.ts +61 -13
  102. package/src/tools/computer/worker-entry.ts +28 -19
  103. package/src/tools/computer/worker.ts +3 -7
  104. package/src/tools/computer.ts +65 -10
  105. package/src/tools/gh-cache-invalidation.ts +2 -82
  106. package/src/tools/memory-render.ts +11 -2
  107. package/src/tools/render-utils.ts +8 -3
  108. package/src/tools/shell-tokenize.ts +83 -0
  109. package/src/tools/todo.ts +44 -17
  110. package/src/tools/write.ts +1 -1
@@ -1,25 +1,34 @@
1
1
  import { parentPort } from "node:worker_threads";
2
- import { consumeWorkerInbox } from "@oh-my-pi/pi-utils/worker-host";
2
+ import { consumeWorkerInbox, isWorkerHostSelector } from "@oh-my-pi/pi-utils/worker-host";
3
3
  import type { ComputerWorkerInbound, ComputerWorkerTransport } from "./protocol";
4
4
  import { ComputerWorkerCore } from "./worker";
5
5
 
6
- if (!parentPort) throw new Error("computer-worker-entry: missing parentPort");
6
+ export function startComputerWorker(): void {
7
+ if (!parentPort) throw new Error("computer-worker-entry: missing parentPort");
7
8
 
8
- const port = parentPort;
9
- const inbox = consumeWorkerInbox();
10
- const transport: ComputerWorkerTransport = {
11
- send(message, transfer) {
12
- port.postMessage(message, transfer ?? []);
13
- },
14
- onMessage(handler) {
15
- if (inbox) return inbox.bind(message => handler(message as ComputerWorkerInbound));
16
- const listener = (message: unknown): void => handler(message as ComputerWorkerInbound);
17
- port.on("message", listener);
18
- return () => port.off("message", listener);
19
- },
20
- close() {
21
- port.close();
22
- },
23
- };
9
+ const port = parentPort;
10
+ const inbox = consumeWorkerInbox();
11
+ const transport: ComputerWorkerTransport = {
12
+ send(message, transfer) {
13
+ port.postMessage(message, transfer ?? []);
14
+ },
15
+ onMessage(handler) {
16
+ if (inbox) return inbox.bind(message => handler(message as ComputerWorkerInbound));
17
+ const listener = (message: unknown): void => handler(message as ComputerWorkerInbound);
18
+ port.on("message", listener);
19
+ return () => port.off("message", listener);
20
+ },
21
+ close() {
22
+ port.close();
23
+ },
24
+ };
24
25
 
25
- new ComputerWorkerCore(transport);
26
+ new ComputerWorkerCore(transport);
27
+ }
28
+
29
+ // Bun workers report `import.meta.main === false`. The source fallback still
30
+ // enters this file directly, while packaged CLI workers carry the selector and
31
+ // start the named entry only after installing its inbox.
32
+ if (!Bun.isMainThread && !process.argv.some(isWorkerHostSelector) && import.meta.path === Bun.main) {
33
+ startComputerWorker();
34
+ }
@@ -1,9 +1,5 @@
1
- import {
2
- type DesktopAction,
3
- type DesktopCapture,
4
- DesktopSession,
5
- type DesktopSessionOptions,
6
- } from "@oh-my-pi/pi-natives";
1
+ import type { DesktopAction, DesktopCapture, DesktopSession, DesktopSessionOptions } from "@oh-my-pi/pi-natives";
2
+ import { createDesktopSession } from "@oh-my-pi/pi-natives/desktop";
7
3
  import type { ComputerWorkerError, ComputerWorkerInbound, ComputerWorkerTransport } from "./protocol";
8
4
 
9
5
  export interface NativeDesktopSession {
@@ -44,7 +40,7 @@ export class ComputerWorkerCore {
44
40
 
45
41
  constructor(
46
42
  private readonly transport: ComputerWorkerTransport,
47
- private readonly createSession: NativeDesktopSessionFactory = options => new DesktopSession(options),
43
+ private readonly createSession: NativeDesktopSessionFactory = createDesktopSession,
48
44
  ) {
49
45
  this.#unsubscribe = transport.onMessage(message => this.#onMessage(message));
50
46
  }
@@ -5,7 +5,8 @@ import type {
5
5
  AgentToolUpdateCallback,
6
6
  ToolApprovalDecision,
7
7
  } from "@oh-my-pi/pi-agent-core";
8
- import type { ComputerAction, ComputerSafetyCheck, ComputerToolCallMetadata } from "@oh-my-pi/pi-ai";
8
+ import type { ComputerAction, ComputerSafetyCheck, ComputerToolCallMetadata, Model } from "@oh-my-pi/pi-ai";
9
+ import { isClaudeModelId } from "@oh-my-pi/pi-catalog/identity";
9
10
  import type {
10
11
  DesktopAction,
11
12
  DesktopCapabilities,
@@ -21,6 +22,36 @@ import { type ComputerController, ComputerSupervisor, registerComputerController
21
22
  import type { ToolSession } from "./index";
22
23
  import { ToolError, throwIfAborted } from "./tool-errors";
23
24
 
25
+ // Image transports that cannot preserve native screenshot detail resize frames
26
+ // without returning transformed dimensions. Keep their native coordinate frames
27
+ // below the empirically verified threshold so pointer actions match what the
28
+ // model sees. Claude paths predate the resolved transport capability and retain
29
+ // their established model-family fallback.
30
+ const COORDINATE_SAFE_MAX_CAPTURE_WIDTH = 1280;
31
+ const COORDINATE_SAFE_MAX_CAPTURE_HEIGHT = 896;
32
+
33
+ function usesCoordinateSafeImageSizing(model: Model | undefined): boolean {
34
+ if (!model) return false;
35
+ const compat = model.compat;
36
+ return (
37
+ (!!compat && "supportsImageDetailOriginal" in compat && compat.supportsImageDetailOriginal === false) ||
38
+ isClaudeModelId(model.id) ||
39
+ (model.requestModelId !== undefined && isClaudeModelId(model.requestModelId)) ||
40
+ (typeof model.name === "string" && /^claude(?:\s|$)/i.test(model.name))
41
+ );
42
+ }
43
+
44
+ function captureOptions(session: ToolSession, coordinateSafeImageSizing: boolean): DesktopSessionOptions {
45
+ const maxWidth = session.settings.get("computer.maxWidth");
46
+ const maxHeight = session.settings.get("computer.maxHeight");
47
+ return {
48
+ backend: session.settings.get("computer.backend"),
49
+ display: session.settings.get("computer.display"),
50
+ maxWidth: coordinateSafeImageSizing ? Math.min(maxWidth, COORDINATE_SAFE_MAX_CAPTURE_WIDTH) : maxWidth,
51
+ maxHeight: coordinateSafeImageSizing ? Math.min(maxHeight, COORDINATE_SAFE_MAX_CAPTURE_HEIGHT) : maxHeight,
52
+ };
53
+ }
54
+
24
55
  // Desktop actions cross the N-API boundary as i32; out-of-range JS numbers
25
56
  // must fail closed here instead of truncating in the napi conversion.
26
57
  const INT32_MIN = -2_147_483_648;
@@ -230,7 +261,7 @@ function isComputerAction(value: unknown): value is ComputerAction {
230
261
  function parseActions(value: unknown): ComputerAction[] {
231
262
  // Missing or empty action batches degrade to a plain screenshot so a
232
263
  // function-calling model can observe the screen before acting.
233
- if (value === undefined) return [{ type: "screenshot" }];
264
+ if (value == null) return [{ type: "screenshot" }];
234
265
  if (!Array.isArray(value)) throw new ToolError("Computer call requires an array of actions");
235
266
  if (value.length === 0) return [{ type: "screenshot" }];
236
267
  if (!value.every(isComputerAction)) throw new ToolError("Computer call contains an invalid action");
@@ -286,6 +317,7 @@ function callMetadata(context: AgentToolContext | undefined): ComputerToolCallMe
286
317
  export function computerApproval(args: unknown): ToolApprovalDecision {
287
318
  const actions =
288
319
  args && typeof args === "object" && "actions" in args ? (args as { actions?: unknown }).actions : undefined;
320
+ if (actions == null) return "read";
289
321
  if (!Array.isArray(actions)) return "exec";
290
322
  return actions.every(action => {
291
323
  if (!action || typeof action !== "object") return false;
@@ -356,8 +388,16 @@ export class ComputerTool implements AgentTool<typeof computerSchema, ComputerTo
356
388
  const actions = args && typeof args === "object" ? (args as { actions?: unknown }).actions : undefined;
357
389
  return approvalActionSummary(actions);
358
390
  };
359
- readonly #controller: ComputerController;
360
- readonly #unregisterOwner: () => void;
391
+ /**
392
+ * Settings snapshot used to create the tool's current controller; refreshed
393
+ * when a model switch crosses the coordinate-safe sizing boundary. Surfaced
394
+ * by `/computer status`.
395
+ */
396
+ effectiveConfiguration: Readonly<DesktopSessionOptions>;
397
+ readonly #createController: ComputerControllerFactory;
398
+ #controller: ComputerController;
399
+ #unregisterOwner: () => void;
400
+ #usesCoordinateSafeImageSizing: boolean;
361
401
  #closed = false;
362
402
  #description?: string;
363
403
 
@@ -365,12 +405,10 @@ export class ComputerTool implements AgentTool<typeof computerSchema, ComputerTo
365
405
  readonly session: ToolSession,
366
406
  createController: ComputerControllerFactory = options => new ComputerSupervisor(options),
367
407
  ) {
368
- this.#controller = createController({
369
- backend: session.settings.get("computer.backend"),
370
- display: session.settings.get("computer.display"),
371
- maxWidth: session.settings.get("computer.maxWidth"),
372
- maxHeight: session.settings.get("computer.maxHeight"),
373
- });
408
+ this.#createController = createController;
409
+ this.#usesCoordinateSafeImageSizing = usesCoordinateSafeImageSizing(session.getActiveModel?.());
410
+ this.effectiveConfiguration = Object.freeze(captureOptions(session, this.#usesCoordinateSafeImageSizing));
411
+ this.#controller = createController(this.effectiveConfiguration);
374
412
  this.#unregisterOwner = registerComputerController(
375
413
  session.getEvalKernelOwnerId?.() ?? undefined,
376
414
  this.#controller,
@@ -381,6 +419,21 @@ export class ComputerTool implements AgentTool<typeof computerSchema, ComputerTo
381
419
  return this.#description;
382
420
  }
383
421
 
422
+ async #refreshControllerForModel(): Promise<void> {
423
+ const nextUsesCoordinateSafeImageSizing = usesCoordinateSafeImageSizing(this.session.getActiveModel?.());
424
+ if (nextUsesCoordinateSafeImageSizing === this.#usesCoordinateSafeImageSizing) return;
425
+
426
+ const previous = this.#controller;
427
+ const nextOptions = Object.freeze(captureOptions(this.session, nextUsesCoordinateSafeImageSizing));
428
+ const next = this.#createController(nextOptions);
429
+ this.#unregisterOwner();
430
+ this.#controller = next;
431
+ this.#unregisterOwner = registerComputerController(this.session.getEvalKernelOwnerId?.() ?? undefined, next);
432
+ this.#usesCoordinateSafeImageSizing = nextUsesCoordinateSafeImageSizing;
433
+ this.effectiveConfiguration = nextOptions;
434
+ await previous.close();
435
+ }
436
+
384
437
  async execute(
385
438
  _toolCallId: string,
386
439
  params: ComputerParams,
@@ -396,6 +449,8 @@ export class ComputerTool implements AgentTool<typeof computerSchema, ComputerTo
396
449
  if (pendingSafetyChecks.length > 0 && context?.providerSafetyApproved !== true) {
397
450
  throw new ToolError("Provider safety checks require interactive approval before computer input");
398
451
  }
452
+ await this.#refreshControllerForModel();
453
+ throwIfAborted(signal);
399
454
  const capture = await this.#controller.execute(actions.map(toDesktopAction), signal);
400
455
  throwIfAborted(signal);
401
456
  const data = Buffer.from(capture.data).toBase64();
@@ -18,6 +18,7 @@
18
18
  * dwarfs the cost of one cache miss.
19
19
  */
20
20
  import { invalidateAllForNumber, invalidateAllForRepo } from "./github-cache";
21
+ import { tokenizeShellSegments } from "./shell-tokenize";
21
22
 
22
23
  const PR_URL_PATTERN = /^https:\/\/github\.com\/([^/\s]+\/[^/\s]+)\/pull\/(\d+)(?:[/?#].*)?$/i;
23
24
  const ISSUE_URL_PATTERN = /^https:\/\/github\.com\/([^/\s]+\/[^/\s]+)\/issues\/(\d+)(?:[/?#].*)?$/i;
@@ -154,87 +155,6 @@ function detectGhMutation(tokens: readonly string[]): { number?: number; repo?:
154
155
  return repo !== undefined ? { repo } : {};
155
156
  }
156
157
 
157
- /**
158
- * Conservative tokenizer that splits a bash command into individual word
159
- * tokens. Handles single/double-quoted strings, backslash escapes, and
160
- * standard operators (`;`, `&&`, `||`, `|`, `&`, newlines) as token
161
- * boundaries that emit a sentinel `";"` so the caller treats the segments
162
- * as independent command sequences. We do not attempt full POSIX shell
163
- * parsing — heredocs, command substitution, and arithmetic expansion are
164
- * out of scope; the detector simply falls through when it cannot find a
165
- * clean `gh issue|pr <subcmd>` triple.
166
- */
167
- function tokenize(command: string): string[][] {
168
- const segments: string[][] = [];
169
- let current: string[] = [];
170
- let buffer = "";
171
- let inSingle = false;
172
- let inDouble = false;
173
- const pushBuffer = () => {
174
- if (buffer.length > 0) {
175
- current.push(buffer);
176
- buffer = "";
177
- }
178
- };
179
- const pushSegment = () => {
180
- pushBuffer();
181
- if (current.length > 0) segments.push(current);
182
- current = [];
183
- };
184
- for (let i = 0; i < command.length; i++) {
185
- const ch = command[i];
186
- if (inSingle) {
187
- if (ch === "'") {
188
- inSingle = false;
189
- continue;
190
- }
191
- buffer += ch;
192
- continue;
193
- }
194
- if (inDouble) {
195
- if (ch === "\\" && i + 1 < command.length) {
196
- const next = command[i + 1];
197
- if (next === '"' || next === "\\" || next === "$" || next === "`") {
198
- buffer += next;
199
- i++;
200
- continue;
201
- }
202
- }
203
- if (ch === '"') {
204
- inDouble = false;
205
- continue;
206
- }
207
- buffer += ch;
208
- continue;
209
- }
210
- if (ch === "'") {
211
- inSingle = true;
212
- continue;
213
- }
214
- if (ch === '"') {
215
- inDouble = true;
216
- continue;
217
- }
218
- if (ch === "\\" && i + 1 < command.length) {
219
- buffer += command[i + 1];
220
- i++;
221
- continue;
222
- }
223
- if (ch === " " || ch === "\t") {
224
- pushBuffer();
225
- continue;
226
- }
227
- if (ch === "\n" || ch === ";" || ch === "&" || ch === "|" || ch === "(" || ch === ")") {
228
- pushSegment();
229
- // `&&`, `||` already collapsed by the segment break above.
230
- continue;
231
- }
232
- buffer += ch;
233
- }
234
- pushSegment();
235
- return segments;
236
- }
237
-
238
158
  /**
239
159
  * Drop `github-cache` rows for any `gh issue|pr <mutating-subcmd>` call
240
160
  * embedded in `command`. Safe to invoke unconditionally; no-op when the
@@ -242,7 +162,7 @@ function tokenize(command: string): string[][] {
242
162
  */
243
163
  export function invalidateGithubCacheForBashCommand(command: string): void {
244
164
  if (!command?.includes("gh")) return;
245
- const segments = tokenize(command);
165
+ const segments = tokenizeShellSegments(command);
246
166
  for (const segment of segments) {
247
167
  const hit = detectGhMutation(segment);
248
168
  if (!hit) continue;
@@ -27,7 +27,7 @@ import {
27
27
  // from the active theme (`•` by default, a nerd-font dot under nerd themes).
28
28
 
29
29
  interface RetainRenderArgs {
30
- items?: Array<{ content?: string; context?: string }>;
30
+ items?: unknown;
31
31
  }
32
32
 
33
33
  interface QueryRenderArgs {
@@ -35,7 +35,16 @@ interface QueryRenderArgs {
35
35
  }
36
36
 
37
37
  function retainContents(args: RetainRenderArgs | undefined): string[] {
38
- return (args?.items ?? []).map(item => replaceTabs((item?.content ?? "").trim())).filter(line => line.length > 0);
38
+ const items = args?.items;
39
+ if (!Array.isArray(items)) return [];
40
+
41
+ const contents: string[] = [];
42
+ for (const item of items) {
43
+ if (!item || typeof item !== "object" || !("content" in item) || typeof item.content !== "string") continue;
44
+ const content = replaceTabs(item.content.trim());
45
+ if (content.length > 0) contents.push(content);
46
+ }
47
+ return contents;
39
48
  }
40
49
 
41
50
  function resultText(result: { content?: Array<{ type: string; text?: string }> }): string {
@@ -13,7 +13,8 @@ import type { Component } from "@oh-my-pi/pi-tui";
13
13
  import { getKeybindings, replaceTabs, truncateToWidth } from "@oh-my-pi/pi-tui";
14
14
  import { pluralize } from "@oh-my-pi/pi-utils";
15
15
  import { formatKeyHints, type KeyId } from "../config/keybindings";
16
- import { settings } from "../config/settings";
16
+ import { isSettingsInitialized, settings } from "../config/settings";
17
+ import { getDefault } from "../config/settings-schema";
17
18
  import type { Theme } from "../modes/theme/theme";
18
19
  import { Hasher } from "../tui/utils";
19
20
  import { formatDimensionNote, type ResizedImage } from "../utils/image-resize";
@@ -27,8 +28,12 @@ export { replaceTabs, truncateToWidth, wrapTextWithAnsi } from "@oh-my-pi/pi-tui
27
28
 
28
29
  /** Resolve inline image dimension caps from settings and viewport. */
29
30
  export function resolveImageOptions(): { maxWidthCells: number; maxHeightCells?: number } {
30
- const maxWidthCells = settings.get("tui.maxInlineImageColumns");
31
- const rowSetting = Math.max(0, settings.get("tui.maxInlineImageRows"));
31
+ const activeSettings = isSettingsInitialized() ? settings : undefined;
32
+ const maxWidthCells = activeSettings?.get("tui.maxInlineImageColumns") ?? getDefault("tui.maxInlineImageColumns");
33
+ const rowSetting = Math.max(
34
+ 0,
35
+ activeSettings?.get("tui.maxInlineImageRows") ?? getDefault("tui.maxInlineImageRows"),
36
+ );
32
37
  const viewportRows = process.stdout.rows;
33
38
  const viewportFraction = viewportRows ? Math.floor(viewportRows * 0.6) : 0;
34
39
  let maxHeightCells: number | undefined;
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Conservative shell command tokenizer shared by the bash approval-pattern
3
+ * matcher and the gh-cache invalidator.
4
+ *
5
+ * Splits a bash command into independent command segments, each a list of word
6
+ * tokens. Handles single/double-quoted strings, backslash escapes, and the
7
+ * standard operators (`;`, `&&`, `||`, `|`, `&`, `(`, `)`, newlines) as segment
8
+ * boundaries so callers treat the pieces as independent command sequences.
9
+ *
10
+ * It is deliberately not a full POSIX parser — heredocs, command substitution,
11
+ * and arithmetic expansion are out of scope; callers fall through when they
12
+ * cannot find the structure they need.
13
+ */
14
+ export function tokenizeShellSegments(command: string): string[][] {
15
+ const segments: string[][] = [];
16
+ let current: string[] = [];
17
+ let buffer = "";
18
+ let inSingle = false;
19
+ let inDouble = false;
20
+ const pushBuffer = () => {
21
+ if (buffer.length > 0) {
22
+ current.push(buffer);
23
+ buffer = "";
24
+ }
25
+ };
26
+ const pushSegment = () => {
27
+ pushBuffer();
28
+ if (current.length > 0) segments.push(current);
29
+ current = [];
30
+ };
31
+ for (let i = 0; i < command.length; i++) {
32
+ const ch = command[i];
33
+ if (inSingle) {
34
+ if (ch === "'") {
35
+ inSingle = false;
36
+ continue;
37
+ }
38
+ buffer += ch;
39
+ continue;
40
+ }
41
+ if (inDouble) {
42
+ if (ch === "\\" && i + 1 < command.length) {
43
+ const next = command[i + 1];
44
+ if (next === '"' || next === "\\" || next === "$" || next === "`") {
45
+ buffer += next;
46
+ i++;
47
+ continue;
48
+ }
49
+ }
50
+ if (ch === '"') {
51
+ inDouble = false;
52
+ continue;
53
+ }
54
+ buffer += ch;
55
+ continue;
56
+ }
57
+ if (ch === "'") {
58
+ inSingle = true;
59
+ continue;
60
+ }
61
+ if (ch === '"') {
62
+ inDouble = true;
63
+ continue;
64
+ }
65
+ if (ch === "\\" && i + 1 < command.length) {
66
+ buffer += command[i + 1];
67
+ i++;
68
+ continue;
69
+ }
70
+ if (ch === " " || ch === "\t") {
71
+ pushBuffer();
72
+ continue;
73
+ }
74
+ if (ch === "\n" || ch === ";" || ch === "&" || ch === "|" || ch === "(" || ch === ")") {
75
+ pushSegment();
76
+ // `&&`, `||` already collapsed by the segment break above.
77
+ continue;
78
+ }
79
+ buffer += ch;
80
+ }
81
+ pushSegment();
82
+ return segments;
83
+ }
package/src/tools/todo.ts CHANGED
@@ -2,7 +2,7 @@ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallb
2
2
  import type { ToolExample } from "@oh-my-pi/pi-ai";
3
3
  import type { Component } from "@oh-my-pi/pi-tui";
4
4
  import { Text } from "@oh-my-pi/pi-tui";
5
- import { isRecord, prompt } from "@oh-my-pi/pi-utils";
5
+ import { isRecord, prompt, sanitizeText } from "@oh-my-pi/pi-utils";
6
6
  import { type } from "arktype";
7
7
  import chalk from "chalk";
8
8
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
@@ -12,7 +12,7 @@ import type { ToolSession } from "../sdk";
12
12
  import type { SessionEntry } from "../session/session-entries";
13
13
  import { framedBlock, renderStatusLine, renderTreeList } from "../tui";
14
14
  import { normalizePathLikeInput, resolveToCwd } from "./path-utils";
15
- import { formatErrorDetail, formatMoreItems, PREVIEW_LIMITS, pluralize } from "./render-utils";
15
+ import { formatErrorDetail, formatMoreItems, PREVIEW_LIMITS, pluralize, replaceTabs } from "./render-utils";
16
16
 
17
17
  // =============================================================================
18
18
  // Types
@@ -929,9 +929,27 @@ export function phaseRomanNumeral(oneBasedIndex: number): string {
929
929
  return out;
930
930
  }
931
931
 
932
- /** Display-only phase header: `I. Foundation`. State and prompts never see this. */
932
+ /**
933
+ * Every render boundary in this file funnels display text through here.
934
+ *
935
+ * `sanitizeText` strips ANSI/C0 sequences but deliberately preserves tabs, and
936
+ * a raw tab punches holes in bordered TUI output, so both are needed. The raw
937
+ * value stays untouched everywhere else: task content and phase names are the
938
+ * identity keys the local list is looked up by, and what gets persisted.
939
+ */
940
+ function forDisplay(text: string): string {
941
+ return replaceTabs(sanitizeText(text));
942
+ }
943
+
944
+ /**
945
+ * Display-only phase header: `I. Foundation`. State and prompts never see this.
946
+ *
947
+ * Sanitized for the same reason task labels are: this is a render boundary and
948
+ * the name may carry provider or session text holding control sequences. The
949
+ * raw `phase.name` stays the lookup key everywhere else.
950
+ */
933
951
  export function formatPhaseDisplayName(name: string, oneBasedIndex: number): string {
934
- return `${phaseRomanNumeral(oneBasedIndex)}. ${name}`;
952
+ return `${phaseRomanNumeral(oneBasedIndex)}. ${forDisplay(name)}`;
935
953
  }
936
954
 
937
955
  export const TODO_STRIKE_HOLD_FRAMES = 2;
@@ -970,27 +988,31 @@ function formatTodoLine(
970
988
  matched = false,
971
989
  ): string {
972
990
  const checkbox = uiTheme.checkbox;
991
+ // Sanitize only for display. A mirrored Cursor snapshot carries provider text
992
+ // verbatim, and a label holding ANSI/C0 sequences would otherwise rewrite the
993
+ // terminal every time the list renders or replays. `item.content` stays raw
994
+ // everywhere else: it is the identity key the local list is looked up by
995
+ // (`findTaskByContent`) and what gets persisted.
996
+ const label = forDisplay(item.content);
973
997
  switch (item.status) {
974
998
  case "completed": {
975
- const revealCount = completionKeys.has(item.content) ? strikeRevealCount(item.content, frame) : undefined;
999
+ const revealCount = completionKeys.has(item.content) ? strikeRevealCount(label, frame) : undefined;
976
1000
  const content =
977
- revealCount === undefined
978
- ? strikethroughText(item.content)
979
- : partialStrikethrough(item.content, revealCount);
1001
+ revealCount === undefined ? strikethroughText(label) : partialStrikethrough(label, revealCount);
980
1002
  return uiTheme.fg("success", `${prefix}${checkbox.checked} ${content}`);
981
1003
  }
982
1004
  case "in_progress":
983
- return uiTheme.fg("accent", `${prefix}${checkbox.unchecked} ${item.content}`);
1005
+ return uiTheme.fg("accent", `${prefix}${checkbox.unchecked} ${label}`);
984
1006
  case "abandoned":
985
- return uiTheme.fg("error", `${prefix}${checkbox.unchecked} ${strikethroughText(item.content)}`);
1007
+ return uiTheme.fg("error", `${prefix}${checkbox.unchecked} ${strikethroughText(label)}`);
986
1008
  case "blocked": {
987
- const note = item.blocker ? `blocked: ${item.blocker}` : "blocked";
988
- return uiTheme.fg("warning", `${prefix}${checkbox.unchecked} ${item.content} (${note})`);
1009
+ const note = item.blocker ? `blocked: ${forDisplay(item.blocker)}` : "blocked";
1010
+ return uiTheme.fg("warning", `${prefix}${checkbox.unchecked} ${label} (${note})`);
989
1011
  }
990
1012
  default:
991
1013
  // A pending todo lit by a live subagent match renders accent, matching
992
1014
  // the sticky HUD's convention (#5873).
993
- return uiTheme.fg(matched ? "accent" : "dim", `${prefix}${checkbox.unchecked} ${item.content}`);
1015
+ return uiTheme.fg(matched ? "accent" : "dim", `${prefix}${checkbox.unchecked} ${label}`);
994
1016
  }
995
1017
  }
996
1018
 
@@ -1065,13 +1087,15 @@ export const todoToolRenderer = {
1065
1087
  // both the new single-op and legacy batch shapes so a malformed delta
1066
1088
  // never breaks the TUI render loop (#2005).
1067
1089
  const opsList = normalizeTodoArg(args);
1090
+ // Model-authored, partially-streamed strings going straight into a header:
1091
+ // `renderStatusLine` only flattens CR/LF and leaves the rest to the caller.
1068
1092
  const ops =
1069
1093
  opsList.length === 0
1070
1094
  ? ["update"]
1071
1095
  : opsList.map(e => {
1072
- const parts = [e.op ?? "update"];
1073
- if (e.task) parts.push(e.task);
1074
- if (e.phase) parts.push(e.phase);
1096
+ const parts = [forDisplay(e.op ?? "update")];
1097
+ if (e.task) parts.push(forDisplay(e.task));
1098
+ if (e.phase) parts.push(forDisplay(e.phase));
1075
1099
  if (Array.isArray(e.items) && e.items.length) {
1076
1100
  parts.push(`${e.items.length} item${e.items.length === 1 ? "" : "s"}`);
1077
1101
  }
@@ -1125,7 +1149,10 @@ export const todoToolRenderer = {
1125
1149
  uiTheme,
1126
1150
  );
1127
1151
  if (allTasks.length === 0) {
1128
- const fallback = result.content?.find(content => content.type === "text")?.text ?? "No todos";
1152
+ // Provider text on the Cursor path (the todo summary or a refusal note),
1153
+ // so sanitize like every other label. The error branch above already
1154
+ // goes through `formatErrorDetail`.
1155
+ const fallback = forDisplay(result.content?.find(content => content.type === "text")?.text ?? "No todos");
1129
1156
  return new Text(`${header}\n ${uiTheme.fg("dim", fallback)}`, 0, 0);
1130
1157
  }
1131
1158
 
@@ -1215,7 +1215,7 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
1215
1215
 
1216
1216
  // Check if file exists and is auto-generated before overwriting
1217
1217
  if (await fs.exists(absolutePath)) {
1218
- await assertEditableFile(absolutePath, path);
1218
+ await assertEditableFile(absolutePath, path, this.session.settings);
1219
1219
  }
1220
1220
 
1221
1221
  const displayPath = formatPathRelativeToCwd(absolutePath, this.session.cwd);