@oh-my-pi/pi-coding-agent 16.3.2 → 16.3.3

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 (93) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/dist/cli.js +3221 -3208
  3. package/dist/types/cli/update-cli.d.ts +1 -0
  4. package/dist/types/cli/update-cli.test.d.ts +1 -0
  5. package/dist/types/commands/update.d.ts +5 -0
  6. package/dist/types/config/config-file.d.ts +1 -1
  7. package/dist/types/config/settings-schema.d.ts +10 -0
  8. package/dist/types/dap/client.d.ts +9 -1
  9. package/dist/types/edit/file-snapshot-store.d.ts +9 -1
  10. package/dist/types/edit/hashline/execute.d.ts +1 -1
  11. package/dist/types/edit/hashline/params.d.ts +3 -6
  12. package/dist/types/edit/renderer.d.ts +1 -0
  13. package/dist/types/extensibility/plugins/parser.d.ts +2 -1
  14. package/dist/types/hindsight/client.d.ts +15 -11
  15. package/dist/types/hindsight/client.test.d.ts +1 -0
  16. package/dist/types/mcp/smithery-registry.d.ts +1 -0
  17. package/dist/types/mcp/smithery-registry.test.d.ts +1 -0
  18. package/dist/types/modes/components/advisor-message.d.ts +4 -2
  19. package/dist/types/modes/components/tool-execution.d.ts +9 -6
  20. package/dist/types/modes/components/transcript-container.d.ts +1 -0
  21. package/dist/types/modes/theme/theme.d.ts +1 -1
  22. package/dist/types/session/indexed-session-storage.d.ts +2 -2
  23. package/dist/types/session/session-storage.d.ts +31 -3
  24. package/dist/types/ssh/__tests__/connection-manager-timeout.test.d.ts +1 -0
  25. package/dist/types/ssh/__tests__/sshfs-mount.test.d.ts +1 -0
  26. package/dist/types/ssh/connection-manager.d.ts +19 -0
  27. package/dist/types/ssh/sshfs-mount.d.ts +10 -1
  28. package/dist/types/subprocess/worker-client.d.ts +20 -6
  29. package/dist/types/tools/bash.d.ts +27 -0
  30. package/dist/types/tools/renderers.d.ts +13 -0
  31. package/dist/types/tools/ssh.d.ts +2 -0
  32. package/dist/types/utils/fetch-timeout.d.ts +4 -0
  33. package/dist/types/web/search/providers/base.d.ts +1 -0
  34. package/dist/types/web/search/providers/gemini.d.ts +1 -0
  35. package/package.json +12 -12
  36. package/scripts/generate-legacy-pi-bundled-registry.ts +8 -2
  37. package/src/cli/models-cli.ts +19 -0
  38. package/src/cli/update-cli.test.ts +28 -0
  39. package/src/cli/update-cli.ts +35 -8
  40. package/src/commands/update.ts +8 -2
  41. package/src/config/config-file.ts +6 -6
  42. package/src/config/settings-schema.ts +10 -0
  43. package/src/dap/client.ts +134 -36
  44. package/src/edit/file-snapshot-store.ts +12 -1
  45. package/src/edit/hashline/diff.ts +4 -13
  46. package/src/edit/hashline/execute.ts +1 -1
  47. package/src/edit/hashline/params.ts +5 -12
  48. package/src/edit/renderer.ts +4 -2
  49. package/src/edit/streaming.ts +15 -5
  50. package/src/export/html/tool-views.generated.js +2 -2
  51. package/src/extensibility/plugins/installer.ts +12 -3
  52. package/src/extensibility/plugins/manager.ts +32 -8
  53. package/src/extensibility/plugins/parser.ts +7 -5
  54. package/src/extensibility/tool-event-input.ts +1 -1
  55. package/src/hindsight/client.test.ts +33 -0
  56. package/src/hindsight/client.ts +42 -22
  57. package/src/internal-urls/docs-index.generated.txt +1 -1
  58. package/src/main.ts +7 -1
  59. package/src/mcp/oauth-flow.ts +93 -4
  60. package/src/mcp/smithery-auth.ts +3 -0
  61. package/src/mcp/smithery-connect.ts +9 -0
  62. package/src/mcp/smithery-registry.test.ts +51 -0
  63. package/src/mcp/smithery-registry.ts +27 -4
  64. package/src/modes/components/advisor-message.ts +13 -10
  65. package/src/modes/components/status-line/component.ts +11 -4
  66. package/src/modes/components/tool-execution.ts +74 -8
  67. package/src/modes/components/transcript-container.ts +26 -0
  68. package/src/modes/controllers/event-controller.ts +7 -3
  69. package/src/modes/controllers/tool-args-reveal.ts +1 -1
  70. package/src/modes/interactive-mode.ts +1 -0
  71. package/src/modes/rpc/rpc-client.ts +29 -16
  72. package/src/modes/theme/shimmer.ts +49 -15
  73. package/src/modes/theme/theme.ts +7 -0
  74. package/src/session/agent-session.ts +166 -30
  75. package/src/session/indexed-session-storage.ts +40 -3
  76. package/src/session/session-manager.ts +83 -14
  77. package/src/session/session-storage.ts +112 -26
  78. package/src/slash-commands/helpers/usage-report.ts +6 -1
  79. package/src/ssh/__tests__/connection-manager-timeout.test.ts +61 -0
  80. package/src/ssh/__tests__/sshfs-mount.test.ts +13 -0
  81. package/src/ssh/connection-manager.ts +44 -11
  82. package/src/ssh/sshfs-mount.ts +27 -4
  83. package/src/subprocess/worker-client.ts +161 -10
  84. package/src/tools/bash.ts +30 -1
  85. package/src/tools/grep.ts +21 -1
  86. package/src/tools/read.ts +14 -4
  87. package/src/tools/renderers.ts +13 -0
  88. package/src/tools/ssh.ts +8 -0
  89. package/src/utils/clipboard.ts +49 -12
  90. package/src/utils/fetch-timeout.ts +10 -0
  91. package/src/web/search/index.ts +8 -0
  92. package/src/web/search/providers/base.ts +1 -0
  93. package/src/web/search/providers/gemini.ts +23 -6
@@ -1,3 +1,5 @@
1
+ import * as fs from "node:fs";
2
+ import * as os from "node:os";
1
3
  import * as path from "node:path";
2
4
  import {
3
5
  $env,
@@ -64,7 +66,7 @@ export interface RefCountedWorkerHandle<Inbound, Outbound> extends WorkerHandle<
64
66
 
65
67
  /** The raw spawned subprocess plus the parent-side fan-out sets. */
66
68
  export interface SpawnedSubprocess<Outbound> {
67
- proc: Subprocess<"ignore", "ignore", "ignore">;
69
+ proc: Subprocess<"ignore", "ignore", number | "ignore">;
68
70
  inbound: Set<(message: Outbound) => void>;
69
71
  errors: Set<(error: Error) => void>;
70
72
  /**
@@ -74,8 +76,23 @@ export interface SpawnedSubprocess<Outbound> {
74
76
  * worker error so callers don't await forever.
75
77
  */
76
78
  intentionalExit: { value: boolean };
79
+ /**
80
+ * Resolves when the file-backed stderr capture has drained after worker
81
+ * exit. `onExit` waits on this before surfacing the crash so the exit-error
82
+ * carries the *whole* tail, not whatever happened to be flushed before the
83
+ * exit event fired. Tests can await it deterministically instead of racing
84
+ * wall-clock timers.
85
+ */
86
+ stderrDrained: Promise<void>;
77
87
  }
78
88
 
89
+ /**
90
+ * Bound on the tail of worker stderr surfaced with a crash. Sized to comfortably
91
+ * hold a full ONNX Runtime/glibc traceback (a few KiB) without letting a chatty
92
+ * native runtime OOM the parent on repeated warnings.
93
+ */
94
+ const STDERR_TAIL_LIMIT_BYTES = 16 * 1024;
95
+
79
96
  export interface WorkerSpawnCommand {
80
97
  cmd: string[];
81
98
  cwd?: string;
@@ -126,11 +143,17 @@ export function workerEnvFromParent(overlay?: Record<string, string>): Record<st
126
143
  }
127
144
 
128
145
  /**
129
- * Spawn an inference worker subprocess and wire its IPC fan-out. The child
130
- * inherits no stdio (native model runtimes may otherwise print progress or
131
- * decoded text and corrupt the chat scrollback) and is `unref`'d outside `bun
132
- * test` so an idle worker never blocks process exit. `exitLabel` prefixes the
133
- * worker-error message surfaced for an unexpected (non-intentional) exit.
146
+ * Spawn an inference worker subprocess and wire its IPC fan-out. Stdio is
147
+ * captured (stderr redirected to a temp file, stdout ignored) so native
148
+ * runtimes can't corrupt the chat scrollback while the crash reason still
149
+ * reaches the parent. The file-backed capture deliberately avoids Bun
150
+ * `ReadableStream` pipes: even an unref'd child with a piped stderr stream can
151
+ * keep the parent event loop alive. After the worker exits, the last
152
+ * {@link STDERR_TAIL_LIMIT_BYTES} are appended to the `onExit` error so
153
+ * `tts/mnemopi/…: worker error` lines carry the actual stack instead of a bare
154
+ * exit code (issue #4324). The child is `unref`'d outside `bun test` so an idle
155
+ * worker never blocks process exit. `exitLabel` prefixes the worker-error
156
+ * message surfaced for an unexpected (non-intentional) exit.
134
157
  */
135
158
  export function createWorkerSubprocess<Outbound>(options: {
136
159
  spawnCommand: WorkerSpawnCommand;
@@ -140,19 +163,29 @@ export function createWorkerSubprocess<Outbound>(options: {
140
163
  const inbound = new Set<(message: Outbound) => void>();
141
164
  const errors = new Set<(error: Error) => void>();
142
165
  const intentionalExit = { value: false };
166
+ const stderrTail = new StderrTail(STDERR_TAIL_LIMIT_BYTES);
167
+ const stderrDrained = Promise.withResolvers<void>();
168
+ const stderrCapture = createStderrCapture(options.exitLabel);
169
+ let stderrDrainStarted = false;
170
+ const startStderrDrain = (): void => {
171
+ if (stderrDrainStarted) return;
172
+ stderrDrainStarted = true;
173
+ void drainStderrCapture(stderrCapture, options.exitLabel, stderrTail).finally(() => stderrDrained.resolve());
174
+ };
143
175
  const proc = Bun.spawn({
144
176
  cmd: options.spawnCommand.cmd,
145
177
  cwd: options.spawnCommand.cwd,
146
178
  env: options.env,
147
179
  stdin: "ignore",
148
180
  stdout: "ignore",
149
- stderr: "ignore",
181
+ stderr: stderrCapture.target,
150
182
  serialization: "advanced",
151
183
  windowsHide: true,
152
184
  ipc(message) {
153
185
  for (const handler of inbound) handler(message as Outbound);
154
186
  },
155
187
  onExit(_proc, exitCode, signalCode) {
188
+ startStderrDrain();
156
189
  if (exitCode === 0) return;
157
190
  // Swallow only the expected SIGKILL from `terminate()`; every other
158
191
  // signal exit (SIGSEGV from a native fault, OOM SIGKILL, operator
@@ -160,15 +193,133 @@ export function createWorkerSubprocess<Outbound>(options: {
160
193
  // requests so callers don't await forever.
161
194
  if (exitCode === null && intentionalExit.value) return;
162
195
  const reason = exitCode !== null ? `code ${exitCode}` : `signal ${signalCode ?? "unknown"}`;
163
- const err = new Error(`${options.exitLabel} exited with ${reason}`);
164
- for (const handler of errors) handler(err);
196
+ // The stderr target is drained only after exit so idle unref'd
197
+ // workers do not keep the parent alive; wait for that drain before
198
+ // surfacing the error so the tail is complete.
199
+ void stderrDrained.promise.finally(() => {
200
+ const suffix = stderrTail.suffix();
201
+ const err = new Error(`${options.exitLabel} exited with ${reason}${suffix}`);
202
+ for (const handler of errors) handler(err);
203
+ });
165
204
  },
166
205
  });
167
206
  // Don't keep the parent event loop alive on an idle worker; the dispose
168
207
  // path calls `terminate()` explicitly. Bun's test runner starves IPC for
169
208
  // unref'd subprocesses, so keep it referenced only under tests.
170
209
  if (!isBunTestRuntime()) proc.unref();
171
- return { proc, inbound, errors, intentionalExit };
210
+ return { proc, inbound, errors, intentionalExit, stderrDrained: stderrDrained.promise };
211
+ }
212
+
213
+ /**
214
+ * Bounded buffer of the *tail* of a stderr stream. Appended chunks are
215
+ * concatenated and truncated from the front once they exceed `limit`, so the
216
+ * final `suffix()` always reflects the most recent output — where native
217
+ * crash tracebacks land.
218
+ */
219
+ class StderrTail {
220
+ #chunks: Uint8Array[] = [];
221
+ #bytes = 0;
222
+ constructor(readonly limit: number) {}
223
+
224
+ append(chunk: Uint8Array): void {
225
+ if (chunk.length === 0) return;
226
+ this.#chunks.push(chunk);
227
+ this.#bytes += chunk.length;
228
+ while (this.#bytes > this.limit && this.#chunks.length > 1) {
229
+ const head = this.#chunks.shift();
230
+ if (head) this.#bytes -= head.length;
231
+ }
232
+ if (this.#bytes > this.limit && this.#chunks.length === 1) {
233
+ const only = this.#chunks[0];
234
+ const start = only.length - this.limit;
235
+ this.#chunks[0] = only.subarray(start);
236
+ this.#bytes = this.limit;
237
+ }
238
+ }
239
+
240
+ /** Human-readable trailer for an exit error, or `""` when nothing was captured. */
241
+ suffix(): string {
242
+ if (this.#bytes === 0) return "";
243
+ const merged = new Uint8Array(this.#bytes);
244
+ let offset = 0;
245
+ for (const chunk of this.#chunks) {
246
+ merged.set(chunk, offset);
247
+ offset += chunk.length;
248
+ }
249
+ const text = new TextDecoder().decode(merged).replace(/\s+$/u, "");
250
+ if (text.length === 0) return "";
251
+ return `: ${text}`;
252
+ }
253
+ }
254
+
255
+ interface StderrCapture {
256
+ target: number | "ignore";
257
+ fd: number | null;
258
+ dir: string | null;
259
+ cleanupOnExit: (() => void) | null;
260
+ }
261
+
262
+ /** Create a file-backed stderr target that does not pin Bun's event loop. */
263
+ function createStderrCapture(exitLabel: string): StderrCapture {
264
+ try {
265
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "omp-worker-stderr-"));
266
+ const fd = fs.openSync(path.join(dir, "stderr.log"), "w+");
267
+ const cleanupOnExit = (): void => cleanupStderrCapture({ target: fd, fd, dir, cleanupOnExit: null });
268
+ process.once("exit", cleanupOnExit);
269
+ return { target: fd, fd, dir, cleanupOnExit };
270
+ } catch (error) {
271
+ logger.debug(`${exitLabel} stderr capture unavailable`, {
272
+ error: error instanceof Error ? error.message : String(error),
273
+ });
274
+ return { target: "ignore", fd: null, dir: null, cleanupOnExit: null };
275
+ }
276
+ }
277
+
278
+ function cleanupStderrCapture(capture: StderrCapture): void {
279
+ if (capture.cleanupOnExit) process.off("exit", capture.cleanupOnExit);
280
+ if (capture.fd !== null) {
281
+ try {
282
+ fs.closeSync(capture.fd);
283
+ } catch {
284
+ // Already closed.
285
+ }
286
+ capture.fd = null;
287
+ }
288
+ if (capture.dir) {
289
+ try {
290
+ fs.rmSync(capture.dir, { recursive: true, force: true });
291
+ } catch {
292
+ // Best-effort temp cleanup.
293
+ }
294
+ capture.dir = null;
295
+ }
296
+ }
297
+
298
+ /**
299
+ * Drain a worker's file-backed stderr target after it exits: forward each
300
+ * decoded tail line to `logger.debug`, and record the bytes in `tail` so the
301
+ * eventual exit error can carry the most recent output. Never rejects — cleanup
302
+ * failures must not fault the parent.
303
+ */
304
+ async function drainStderrCapture(capture: StderrCapture, exitLabel: string, tail: StderrTail): Promise<void> {
305
+ try {
306
+ if (capture.fd === null) return;
307
+ const size = fs.fstatSync(capture.fd).size;
308
+ if (size <= 0) return;
309
+ const length = Math.min(size, tail.limit);
310
+ const buffer = new Uint8Array(length);
311
+ fs.readSync(capture.fd, buffer, 0, length, size - length);
312
+ tail.append(buffer);
313
+ for (const rawLine of new TextDecoder().decode(buffer).split("\n")) {
314
+ const line = rawLine.replace(/\r$/u, "");
315
+ if (line.length > 0) logger.debug(`${exitLabel} stderr`, { line });
316
+ }
317
+ } catch {
318
+ // The worker may have exited while the parent is already tearing down,
319
+ // or the temp file may have been removed by process-exit cleanup.
320
+ } finally {
321
+ cleanupStderrCapture(capture);
322
+ }
172
323
  }
173
324
 
174
325
  /**
package/src/tools/bash.ts CHANGED
@@ -46,6 +46,33 @@ export const BASH_DEFAULT_PREVIEW_LINES = 10;
46
46
  const BASH_ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
47
47
  const DEFAULT_AUTO_BACKGROUND_THRESHOLD_MS = 60_000;
48
48
 
49
+ /**
50
+ * Shape a shell command line for an ACP-conformant `terminal/create` request.
51
+ *
52
+ * ACP's `command` field is documented as the executable and `args` as its
53
+ * argv tail (see https://agentclientprotocol.com/protocol/v1/terminals), so a
54
+ * spec-conformant client `spawn(command, args)`s them directly — no implicit
55
+ * shell. A raw `bash` tool line ("git status && echo x | head") therefore has
56
+ * to be wrapped in an explicit shell invocation, otherwise the client tries
57
+ * to spawn the whole line as argv[0] and fails with `ENOENT` for anything
58
+ * containing a space, pipe, `&&`, redirect, or `$(...)`.
59
+ *
60
+ * The wrap reuses the same shell binary + args the local `bash-executor` would
61
+ * pick via `settings.getShellConfig()` — Git Bash / `bash.exe` on Windows,
62
+ * `$SHELL` (bash/zsh) with the `sh` fallback on POSIX — so the ACP path
63
+ * preserves `bash` tool semantics (`$VAR`, `$(...)`, `source`, POSIX quoting,
64
+ * `-l`) instead of dropping to `cmd.exe` on Windows. The agent host's shell
65
+ * path is used as a proxy for the client's, matching the near-universal
66
+ * ACP deployment shape of an editor spawning omp as a co-hosted subprocess.
67
+ */
68
+ export function wrapShellLineForClientTerminal(
69
+ line: string,
70
+ shellConfig: { shell: string; args: string[]; prefix?: string | undefined },
71
+ ): { command: string; args: string[] } {
72
+ const finalLine = shellConfig.prefix ? `${shellConfig.prefix} ${line}` : line;
73
+ return { command: shellConfig.shell, args: [...shellConfig.args, finalLine] };
74
+ }
75
+
49
76
  /**
50
77
  * Bash patterns flagged as safety critical for approval policy.
51
78
  *
@@ -842,8 +869,10 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
842
869
  // Skip when pty=true (PTY needs the local terminal UI).
843
870
  if (clientBridge?.capabilities.terminal && clientBridge.createTerminal && !pty) {
844
871
  const bridgeWallTimeStart = performance.now();
872
+ const shellSpawn = wrapShellLineForClientTerminal(command, this.session.settings.getShellConfig());
845
873
  const handle = await clientBridge.createTerminal({
846
- command,
874
+ command: shellSpawn.command,
875
+ args: shellSpawn.args,
847
876
  cwd: commandCwd,
848
877
  env: resolvedEnv
849
878
  ? Object.entries(resolvedEnv).map(([name, value]) => ({ name, value: value as string }))
package/src/tools/grep.ts CHANGED
@@ -1371,6 +1371,17 @@ export class GrepTool implements AgentTool<typeof searchSchema, GrepToolDetails>
1371
1371
  }, 0);
1372
1372
  let lastEmittedLine: number | undefined;
1373
1373
  const gutterPad = " ".repeat(lineNumberWidth + 1);
1374
+ // Track match/context lines whose displayed text was
1375
+ // column-truncated by the native (see `crates/pi-natives/src/grep.rs`
1376
+ // `truncate_line`, marker `...` at max_columns). Excluded from
1377
+ // seenLines so a follow-up edit anchored at that line still
1378
+ // requires a full-width re-read — the model saw only the
1379
+ // prefix. The native currently propagates `truncated` only on
1380
+ // the match line; context lines fall back to a length check
1381
+ // against `DEFAULT_MAX_COLUMN` as a conservative heuristic.
1382
+ const clippedLines = new Set<number>();
1383
+ const isNativeTruncated = (line: string): boolean =>
1384
+ line.length >= DEFAULT_MAX_COLUMN && line.endsWith("...");
1374
1385
  for (const match of fileMatches) {
1375
1386
  const pushLine = (lineNumber: number, line: string, isMatch: boolean) => {
1376
1387
  if (lastEmittedLine !== undefined && lineNumber > lastEmittedLine + 1) {
@@ -1384,22 +1395,31 @@ export class GrepTool implements AgentTool<typeof searchSchema, GrepToolDetails>
1384
1395
  if (match.contextBefore) {
1385
1396
  for (const ctx of match.contextBefore) {
1386
1397
  pushLine(ctx.lineNumber, ctx.line, false);
1398
+ if (isNativeTruncated(ctx.line)) clippedLines.add(ctx.lineNumber);
1387
1399
  }
1388
1400
  }
1389
1401
  pushLine(match.lineNumber, match.line, true);
1390
1402
  if (match.truncated) {
1391
1403
  linesTruncated = true;
1404
+ clippedLines.add(match.lineNumber);
1392
1405
  }
1393
1406
  if (match.contextAfter) {
1394
1407
  for (const ctx of match.contextAfter) {
1395
1408
  pushLine(ctx.lineNumber, ctx.line, false);
1409
+ if (isNativeTruncated(ctx.line)) clippedLines.add(ctx.lineNumber);
1396
1410
  }
1397
1411
  }
1398
1412
  fileMatchCounts.set(relativePath, (fileMatchCounts.get(relativePath) ?? 0) + 1);
1399
1413
  }
1400
1414
  if (hashContext?.tag) {
1401
1415
  const absoluteFilePath = path.resolve(this.session.cwd, relativePath);
1402
- recordSeenLinesFromBody(this.session, absoluteFilePath, hashContext.tag, modelOut.join("\n"));
1416
+ recordSeenLinesFromBody(
1417
+ this.session,
1418
+ absoluteFilePath,
1419
+ hashContext.tag,
1420
+ modelOut.join("\n"),
1421
+ clippedLines,
1422
+ );
1403
1423
  }
1404
1424
  return { model: modelOut, display: displayOut };
1405
1425
  };
package/src/tools/read.ts CHANGED
@@ -1559,6 +1559,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1559
1559
  const displayLineByNumber = new Map<number, string>();
1560
1560
  const fullLines = rawSelector ? undefined : await readBracketContextFullLines(absolutePath, fileSize);
1561
1561
  let columnTruncated = 0;
1562
+ const clippedLines = new Set<number>();
1562
1563
  let displayContent: { text: string; startLine: number; lineNumbers?: Array<number | null> } | undefined;
1563
1564
 
1564
1565
  for (const range of ranges) {
@@ -1606,6 +1607,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1606
1607
  if (!cloned) cloned = collectedLines.slice();
1607
1608
  cloned[i] = text;
1608
1609
  columnTruncated = maxColumns;
1610
+ clippedLines.add(range.startLine + i);
1609
1611
  }
1610
1612
  }
1611
1613
  if (cloned) displayLines = cloned;
@@ -1633,7 +1635,10 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1633
1635
  if (visibleText !== undefined) return visibleText;
1634
1636
  if (maxColumns <= 0) return sourceText;
1635
1637
  const truncated = truncateLine(sourceText, maxColumns);
1636
- if (truncated.wasTruncated) columnTruncated = maxColumns;
1638
+ if (truncated.wasTruncated) {
1639
+ columnTruncated = maxColumns;
1640
+ clippedLines.add(lineNumber);
1641
+ }
1637
1642
  return truncated.text;
1638
1643
  },
1639
1644
  },
@@ -1651,7 +1656,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1651
1656
  if (shouldAddHashLines && outputText) {
1652
1657
  const tag = await recordFileSnapshot(this.session, absolutePath);
1653
1658
  if (tag) {
1654
- recordSeenLinesFromBody(this.session, absolutePath, tag, outputText);
1659
+ recordSeenLinesFromBody(this.session, absolutePath, tag, outputText, clippedLines);
1655
1660
  outputText = `${formatReadHashlineHeader(formatPathRelativeToCwd(absolutePath, this.session.cwd), tag)}\n${outputText}`;
1656
1661
  }
1657
1662
  }
@@ -2490,6 +2495,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2490
2495
  // ellipsis-truncated text made every long-line file uneditable on
2491
2496
  // the next edit attempt.
2492
2497
  let displayLines: string[] = collectedLines;
2498
+ const clippedLines = new Set<number>();
2493
2499
  if (!rawSelector && maxColumns > 0) {
2494
2500
  let cloned: string[] | undefined;
2495
2501
  for (let i = 0; i < collectedLines.length; i++) {
@@ -2498,6 +2504,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2498
2504
  if (!cloned) cloned = collectedLines.slice();
2499
2505
  cloned[i] = text;
2500
2506
  columnTruncated = maxColumns;
2507
+ clippedLines.add(startLineDisplay + i);
2501
2508
  }
2502
2509
  }
2503
2510
  if (cloned) displayLines = cloned;
@@ -2580,7 +2587,10 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2580
2587
  if (visibleText !== undefined) return visibleText;
2581
2588
  if (maxColumns <= 0) return sourceText;
2582
2589
  const truncated = truncateLine(sourceText, maxColumns);
2583
- if (truncated.wasTruncated) columnTruncated = maxColumns;
2590
+ if (truncated.wasTruncated) {
2591
+ columnTruncated = maxColumns;
2592
+ clippedLines.add(lineNumber);
2593
+ }
2584
2594
  return truncated.text;
2585
2595
  },
2586
2596
  },
@@ -2654,7 +2664,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2654
2664
  }
2655
2665
 
2656
2666
  if (hashContext?.tag) {
2657
- recordSeenLinesFromBody(this.session, absolutePath, hashContext.tag, outputText);
2667
+ recordSeenLinesFromBody(this.session, absolutePath, hashContext.tag, outputText, clippedLines);
2658
2668
  }
2659
2669
 
2660
2670
  if (capturedDisplayContent) {
@@ -74,6 +74,19 @@ export type ToolRenderer = {
74
74
  * `options.spinnerFrame`.
75
75
  */
76
76
  animatedPartialResult?: boolean | ((args: unknown) => boolean);
77
+ /**
78
+ * Whether replacing a streamed pending placeholder with the first result
79
+ * requires a full viewport repaint. Use for merged renderers whose pending
80
+ * streamed args may have committed placeholder rows that the result render
81
+ * re-anchors instead of preserving.
82
+ */
83
+ forceFirstResultViewportRepaint?: boolean;
84
+ /**
85
+ * Whether settling a provisional partial result into the final render requires
86
+ * a full viewport repaint. Use when the result renderer changes chrome or
87
+ * frame topology at `options.isPartial: true -> false`.
88
+ */
89
+ forceResultViewportRepaintOnSettle?: boolean;
77
90
  };
78
91
 
79
92
  export const toolRenderers: Record<string, ToolRenderer> = {
package/src/tools/ssh.ts CHANGED
@@ -402,4 +402,12 @@ export const sshToolRenderer = {
402
402
  // land below and strand a duplicate pending header above the final frame
403
403
  // ([#3177](https://github.com/can1357/oh-my-pi/issues/3177)).
404
404
  provisionalPartialResult: true,
405
+ // Streamed args can initially render the SSH placeholder (`⏳ SSH: […]` /
406
+ // `$ …`), then the first partial result inserts the `Output` section and
407
+ // re-anchors the frame. Force a full repaint at that seam so placeholder rows
408
+ // do not survive in viewport/native scrollback.
409
+ forceFirstResultViewportRepaint: true,
410
+ // The provisional pending-result frame settles into the final `⇄ SSH: [host]`
411
+ // frame, so clear/replay the viewport at that topology flip too.
412
+ forceResultViewportRepaintOnSettle: true,
405
413
  };
@@ -1,8 +1,49 @@
1
- import { execSync } from "node:child_process";
2
1
  import type { ClipboardImage } from "@oh-my-pi/pi-natives";
3
2
  import * as native from "@oh-my-pi/pi-natives";
4
3
  import { logger } from "@oh-my-pi/pi-utils";
5
4
 
5
+ /**
6
+ * Run a subprocess and capture its stdout without blocking the event loop.
7
+ *
8
+ * `readTextFromClipboard`, `readMacFileUrlsFromClipboard`, and the Termux copy
9
+ * path all shell out to CLI clipboard tools. The synchronous `execSync` API
10
+ * parks the render loop until the child exits or the timeout fires, so a hung
11
+ * clipboard daemon freezes the TUI for the full 2000ms budget (#4235). This
12
+ * helper mirrors the previous semantics — read stdout as UTF-8, throw on
13
+ * non-zero exit or timeout, forward optional stdin — but yields to the event
14
+ * loop while the child runs.
15
+ *
16
+ * @throws Error when the child fails to spawn, is killed by the timeout, or
17
+ * exits with a non-zero status. Callers rely on this to fall through to the
18
+ * outer catch and return an empty string / empty list.
19
+ */
20
+ async function spawnCapture(cmd: string[], options: { input?: string; timeoutMs?: number } = {}): Promise<string> {
21
+ const timeoutMs = options.timeoutMs ?? 2000;
22
+ const proc = Bun.spawn(cmd, {
23
+ stdout: "pipe",
24
+ stderr: "ignore",
25
+ stdin: options.input !== undefined ? Buffer.from(options.input) : "ignore",
26
+ });
27
+ let timedOut = false;
28
+ const timer = setTimeout(() => {
29
+ timedOut = true;
30
+ proc.kill();
31
+ }, timeoutMs);
32
+ try {
33
+ const stdout = await new Response(proc.stdout).text();
34
+ await proc.exited;
35
+ if (timedOut) {
36
+ throw new Error(`${cmd[0]} timed out after ${timeoutMs}ms`);
37
+ }
38
+ if (proc.exitCode !== 0) {
39
+ throw new Error(`${cmd[0]} exited with code ${proc.exitCode}`);
40
+ }
41
+ return stdout;
42
+ } finally {
43
+ clearTimeout(timer);
44
+ }
45
+ }
46
+
6
47
  function hasDisplay(): boolean {
7
48
  return process.platform !== "linux" || Boolean(process.env.DISPLAY || process.env.WAYLAND_DISPLAY);
8
49
  }
@@ -53,11 +94,7 @@ const MAC_FILE_URL_SCRIPT = [
53
94
  export async function readMacFileUrlsFromClipboard(): Promise<string[]> {
54
95
  if (process.platform !== "darwin") return [];
55
96
  try {
56
- const stdout = execSync("osascript -", {
57
- input: MAC_FILE_URL_SCRIPT,
58
- encoding: "utf8",
59
- timeout: 2000,
60
- }).toString();
97
+ const stdout = await spawnCapture(["osascript", "-"], { input: MAC_FILE_URL_SCRIPT });
61
98
  return stdout
62
99
  .split(/\r?\n/)
63
100
  .map(line => line.trim())
@@ -110,7 +147,7 @@ export async function copyToClipboard(text: string): Promise<void> {
110
147
  try {
111
148
  if (process.env.TERMUX_VERSION) {
112
149
  try {
113
- execSync("termux-clipboard-set", { input: text, timeout: 5000 });
150
+ await spawnCapture(["termux-clipboard-set"], { input: text, timeoutMs: 5000 });
114
151
  return;
115
152
  } catch {
116
153
  // Fall through to native
@@ -286,13 +323,13 @@ export async function readTextFromClipboard(): Promise<string> {
286
323
  try {
287
324
  const p = process.platform;
288
325
  if (p === "darwin") {
289
- return execSync("pbpaste", { encoding: "utf8", timeout: 2000 }).toString();
326
+ return await spawnCapture(["pbpaste"]);
290
327
  }
291
328
  if (p === "win32") {
292
329
  return (await readTextViaPowerShell()) ?? "";
293
330
  }
294
331
  if (process.env.TERMUX_VERSION) {
295
- return execSync("termux-clipboard-get", { encoding: "utf8", timeout: 2000 }).toString();
332
+ return await spawnCapture(["termux-clipboard-get"]);
296
333
  }
297
334
  if (isWsl()) {
298
335
  const text = await readTextViaPowerShell();
@@ -303,14 +340,14 @@ export async function readTextFromClipboard(): Promise<string> {
303
340
  const hasX11Display = Boolean(process.env.DISPLAY);
304
341
  if (hasWaylandDisplay) {
305
342
  try {
306
- return execSync("wl-paste --type text/plain --no-newline", { encoding: "utf8", timeout: 2000 }).toString();
343
+ return await spawnCapture(["wl-paste", "--type", "text/plain", "--no-newline"]);
307
344
  } catch {
308
345
  if (hasX11Display) {
309
- return execSync("xclip -selection clipboard -o", { encoding: "utf8", timeout: 2000 }).toString();
346
+ return await spawnCapture(["xclip", "-selection", "clipboard", "-o"]);
310
347
  }
311
348
  }
312
349
  } else if (hasX11Display) {
313
- return execSync("xclip -selection clipboard -o", { encoding: "utf8", timeout: 2000 }).toString();
350
+ return await spawnCapture(["xclip", "-selection", "clipboard", "-o"]);
314
351
  }
315
352
  } catch (error) {
316
353
  logger.warn("clipboard: failed to read clipboard text", { error: String(error) });
@@ -0,0 +1,10 @@
1
+ /** Create an abort signal that fires after a timeout and preserves caller cancellation. */
2
+ export function withTimeoutSignal(timeoutMs: number, signal?: AbortSignal): AbortSignal {
3
+ const timeoutSignal = AbortSignal.timeout(timeoutMs);
4
+ return signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
5
+ }
6
+
7
+ /** Detect a timeout raised by an abortable fetch. */
8
+ export function isTimeoutError(error: unknown): boolean {
9
+ return error instanceof Error && error.name === "TimeoutError";
10
+ }
@@ -159,6 +159,13 @@ async function executeSearch(
159
159
  antigravityEndpointMode = undefined;
160
160
  }
161
161
 
162
+ let geminiModel: string | undefined;
163
+ try {
164
+ geminiModel = settings.get("providers.webSearchGeminiModel");
165
+ } catch {
166
+ geminiModel = undefined;
167
+ }
168
+
162
169
  const failures: Array<{ provider: SearchProvider; error: unknown }> = [];
163
170
  let lastProvider = providers[0];
164
171
  for (const provider of providers) {
@@ -176,6 +183,7 @@ async function executeSearch(
176
183
  authStorage,
177
184
  sessionId,
178
185
  antigravityEndpointMode,
186
+ geminiModel,
179
187
  });
180
188
 
181
189
  if (!hasRenderableSearchContent(response)) {
@@ -52,6 +52,7 @@ export interface SearchParams {
52
52
  */
53
53
  sessionId?: string;
54
54
  antigravityEndpointMode?: "auto" | "production" | "sandbox";
55
+ geminiModel?: string;
55
56
  }
56
57
 
57
58
  /** Base class for web search providers. */