@oh-my-pi/pi-coding-agent 14.2.0 → 14.2.1

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.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-coding-agent",
4
- "version": "14.2.0",
4
+ "version": "14.2.1",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://github.com/can1357/oh-my-pi",
7
7
  "author": "Can Boluk",
@@ -46,12 +46,12 @@
46
46
  "dependencies": {
47
47
  "@agentclientprotocol/sdk": "0.16.1",
48
48
  "@mozilla/readability": "^0.6",
49
- "@oh-my-pi/omp-stats": "14.2.0",
50
- "@oh-my-pi/pi-agent-core": "14.2.0",
51
- "@oh-my-pi/pi-ai": "14.2.0",
52
- "@oh-my-pi/pi-natives": "14.2.0",
53
- "@oh-my-pi/pi-tui": "14.2.0",
54
- "@oh-my-pi/pi-utils": "14.2.0",
49
+ "@oh-my-pi/omp-stats": "14.2.1",
50
+ "@oh-my-pi/pi-agent-core": "14.2.1",
51
+ "@oh-my-pi/pi-ai": "14.2.1",
52
+ "@oh-my-pi/pi-natives": "14.2.1",
53
+ "@oh-my-pi/pi-tui": "14.2.1",
54
+ "@oh-my-pi/pi-utils": "14.2.1",
55
55
  "@sinclair/typebox": "^0.34",
56
56
  "@xterm/headless": "^6.0",
57
57
  "ajv": "^8.18",
@@ -1075,11 +1075,17 @@ export class DapSessionManager {
1075
1075
  * MUST be called before the command that triggers the event.
1076
1076
  */
1077
1077
  #prepareStopOutcome(session: DapSession, signal?: AbortSignal, timeoutMs: number = 30_000): Promise<unknown> {
1078
- return Promise.race([
1078
+ const promises = [
1079
1079
  session.client.waitForEvent("stopped", undefined, signal, timeoutMs),
1080
1080
  session.client.waitForEvent("terminated", undefined, signal, timeoutMs),
1081
1081
  session.client.waitForEvent("exited", undefined, signal, timeoutMs),
1082
- ]);
1082
+ ];
1083
+ // Promise.race leaves the losing waiters pending; their timeouts would
1084
+ // otherwise surface as unhandled rejections once they fire.
1085
+ for (const p of promises) {
1086
+ p.catch(() => {});
1087
+ }
1088
+ return Promise.race(promises);
1083
1089
  }
1084
1090
 
1085
1091
  /**
package/src/edit/index.ts CHANGED
@@ -322,7 +322,8 @@ export class EditTool implements AgentTool<TInput> {
322
322
  chunkAutoIndent: resolveChunkAutoIndent(),
323
323
  }),
324
324
  parameters: chunkEditParamsSchema,
325
- invalidParamsMessage: "Invalid edit parameters for chunk mode.",
325
+ invalidParamsMessage:
326
+ "Invalid edit parameters for chunk mode. Expected `{ edits: [{ path: 'file:selector', ...op }, ...] }` with at least one edit. Each edit needs a `path`; supply one of `write` (string content; pass an empty string or omit it together with `replace`/`insert` to delete the chunk), `replace: { old, new }`, or `insert: { loc, body }`.",
326
327
  validate: isChunkParams,
327
328
  execute: (
328
329
  tool: EditTool,
@@ -549,8 +549,12 @@ export function isChunkParams(params: unknown): params is ChunkParams {
549
549
  return false;
550
550
  }
551
551
  const first = params.edits[0];
552
- if (typeof first !== "object" || first === null || !("path" in first)) return false;
553
- return "write" in first || "replace" in first || "insert" in first;
552
+ // Accept a bare `{ path }` entry: it is interpreted downstream as a chunk
553
+ // delete. Some providers strip `null` values from tool-call JSON, so a
554
+ // documented `{ path, write: null }` delete can arrive here as just
555
+ // `{ path }`. Rejecting that surfaced as a misleading
556
+ // "Invalid edit parameters for chunk mode." error.
557
+ return typeof first === "object" && first !== null && "path" in first;
554
558
  }
555
559
 
556
560
  /** Auto-correct indentation for content targeting a body region (`~`) when autoIndent is on.
package/src/lsp/client.ts CHANGED
@@ -211,11 +211,19 @@ async function writeMessage(
211
211
  message: LspJsonRpcRequest | LspJsonRpcNotification | LspJsonRpcResponse,
212
212
  ): Promise<void> {
213
213
  const content = JSON.stringify(message);
214
- sink.write(`Content-Length: ${Buffer.byteLength(content, "utf-8")}\r\n\r\n`);
215
- sink.write(content);
214
+ sink.write(`Content-Length: ${Buffer.byteLength(content, "utf-8")}\r\n\r\n${content}`);
216
215
  await sink.flush();
217
216
  }
218
217
 
218
+ function queueWriteMessage(
219
+ client: LspClient,
220
+ message: LspJsonRpcRequest | LspJsonRpcNotification | LspJsonRpcResponse,
221
+ ): Promise<void> {
222
+ const write = client.writeQueue.catch(() => {}).then(() => writeMessage(client.proc.stdin, message));
223
+ client.writeQueue = write.catch(() => {});
224
+ return write;
225
+ }
226
+
219
227
  // =============================================================================
220
228
  // Message Reader
221
229
  // =============================================================================
@@ -382,7 +390,7 @@ async function sendResponse(
382
390
  };
383
391
 
384
392
  try {
385
- await writeMessage(client.proc.stdin, response);
393
+ await queueWriteMessage(client, response);
386
394
  } catch (err) {
387
395
  logger.error("LSP failed to respond.", { method, error: String(err) });
388
396
  }
@@ -461,6 +469,7 @@ export async function getOrCreateClient(config: ServerConfig, cwd: string, initT
461
469
  messageBuffer: new Uint8Array(0),
462
470
  isReading: false,
463
471
  lastActivity: Date.now(),
472
+ writeQueue: Promise.resolve(),
464
473
  activeProgressTokens: new Set(),
465
474
  projectLoaded,
466
475
  resolveProjectLoaded,
@@ -848,7 +857,7 @@ export async function sendRequest(
848
857
  });
849
858
 
850
859
  // Write request
851
- writeMessage(client.proc.stdin, request).catch(err => {
860
+ queueWriteMessage(client, request).catch(err => {
852
861
  if (timeout) clearTimeout(timeout);
853
862
  client.pendingRequests.delete(id);
854
863
  cleanup();
@@ -868,7 +877,7 @@ export async function sendNotification(client: LspClient, method: string, params
868
877
  };
869
878
 
870
879
  client.lastActivity = Date.now();
871
- await writeMessage(client.proc.stdin, notification);
880
+ await queueWriteMessage(client, notification);
872
881
  }
873
882
 
874
883
  /**
package/src/lsp/index.ts CHANGED
@@ -40,6 +40,7 @@ import {
40
40
  type LspParams,
41
41
  type LspToolDetails,
42
42
  lspSchema,
43
+ type Position,
43
44
  type PublishedDiagnostics,
44
45
  type ServerConfig,
45
46
  type SymbolInformation,
@@ -262,6 +263,10 @@ function getLspServerForFile(config: LspConfig, filePath: string): [string, Serv
262
263
  return servers.length > 0 ? servers[0] : null;
263
264
  }
264
265
 
266
+ function isProjectAwareLspServer(serverConfig: ServerConfig): boolean {
267
+ return !serverConfig.createClient && !serverConfig.isLinter;
268
+ }
269
+
265
270
  const DIAGNOSTIC_MESSAGE_LIMIT = 50;
266
271
  const SINGLE_DIAGNOSTICS_WAIT_TIMEOUT_MS = 3000;
267
272
  const BATCH_DIAGNOSTICS_WAIT_TIMEOUT_MS = 400;
@@ -278,6 +283,21 @@ function limitDiagnosticMessages(messages: string[]): string[] {
278
283
  const LOCATION_CONTEXT_LINES = 1;
279
284
  const REFERENCE_CONTEXT_LIMIT = 50;
280
285
 
286
+ const REFERENCES_RETRY_COUNT = 2;
287
+ const REFERENCES_RETRY_DELAY_MS = 250;
288
+
289
+ function comparePosition(a: Position, b: Position): number {
290
+ return a.line === b.line ? a.character - b.character : a.line - b.line;
291
+ }
292
+
293
+ function rangeContainsPosition(range: Location["range"], position: Position): boolean {
294
+ return comparePosition(range.start, position) <= 0 && comparePosition(position, range.end) <= 0;
295
+ }
296
+
297
+ function isOnlyQueriedDeclaration(locations: Location[], uri: string, position: Position): boolean {
298
+ return locations.length === 1 && locations[0]?.uri === uri && rangeContainsPosition(locations[0].range, position);
299
+ }
300
+
281
301
  function normalizeLocationResult(result: Location | Location[] | LocationLink | LocationLink[] | null): Location[] {
282
302
  if (!result) return [];
283
303
  const raw = Array.isArray(result) ? result : [result];
@@ -560,6 +580,10 @@ async function getDiagnosticsForFile(
560
580
  // Default: use LSP
561
581
  const client = await getOrCreateClient(serverConfig, cwd);
562
582
  throwIfAborted(signal);
583
+ if (isProjectAwareLspServer(serverConfig)) {
584
+ await waitForProjectLoaded(client, signal);
585
+ throwIfAborted(signal);
586
+ }
563
587
  // Content already synced + didSave sent, wait for fresh diagnostics
564
588
  const minVersion = minVersions?.get(serverName);
565
589
  const expectedDocumentVersion = expectedDocumentVersions?.get(serverName);
@@ -1220,6 +1244,10 @@ export class LspTool implements AgentTool<typeof lspSchema, LspToolDetails, Them
1220
1244
  continue;
1221
1245
  }
1222
1246
  const client = await getOrCreateClient(serverConfig, this.session.cwd);
1247
+ if (isProjectAwareLspServer(serverConfig)) {
1248
+ await waitForProjectLoaded(client, signal);
1249
+ throwIfAborted(signal);
1250
+ }
1223
1251
  const minVersion = client.diagnosticsVersion;
1224
1252
  await refreshFile(client, resolved, signal);
1225
1253
  const expectedDocumentVersion = client.openFiles.get(uri)?.version;
@@ -1512,16 +1540,31 @@ export class LspTool implements AgentTool<typeof lspSchema, LspToolDetails, Them
1512
1540
  break;
1513
1541
  }
1514
1542
  case "references": {
1515
- const result = (await sendRequest(
1516
- client,
1517
- "textDocument/references",
1518
- {
1519
- textDocument: { uri },
1520
- position,
1521
- context: { includeDeclaration: true },
1522
- },
1523
- signal,
1524
- )) as Location[] | null;
1543
+ let result: Location[] | null = null;
1544
+ for (let attempt = 0; attempt <= REFERENCES_RETRY_COUNT; attempt++) {
1545
+ result = (await sendRequest(
1546
+ client,
1547
+ "textDocument/references",
1548
+ {
1549
+ textDocument: { uri },
1550
+ position,
1551
+ context: { includeDeclaration: true },
1552
+ },
1553
+ signal,
1554
+ )) as Location[] | null;
1555
+
1556
+ const locations = result ?? [];
1557
+ if (!isProjectAwareLspServer(serverConfig) || attempt === REFERENCES_RETRY_COUNT) {
1558
+ break;
1559
+ }
1560
+ if (locations.length > 0 && !isOnlyQueriedDeclaration(locations, uri, position)) {
1561
+ break;
1562
+ }
1563
+
1564
+ await waitForProjectLoaded(client, signal);
1565
+ throwIfAborted(signal);
1566
+ await untilAborted(signal, () => Bun.sleep(REFERENCES_RETRY_DELAY_MS));
1567
+ }
1525
1568
 
1526
1569
  if (!result || result.length === 0) {
1527
1570
  output = "No references found";
package/src/lsp/types.ts CHANGED
@@ -411,6 +411,8 @@ export interface LspClient {
411
411
  isReading: boolean;
412
412
  serverCapabilities?: LspServerCapabilities;
413
413
  lastActivity: number;
414
+ /** Serializes outbound JSON-RPC writes to the server process. */
415
+ writeQueue: Promise<void>;
414
416
  /** Tracks active work-done progress tokens from the server */
415
417
  activeProgressTokens: Set<string | number>;
416
418
  /** Resolves when the server's initial project loading completes (or after timeout) */
@@ -10,6 +10,7 @@ Performs structural code search using AST matching via native ast-grep.
10
10
  - Metavariable names are UPPERCASE and must be the whole AST node — partial-text like `prefix$VAR`, `"hello $NAME"`, or `a $OP b` does NOT work; match the whole node instead
11
11
  - When the same metavariable appears twice, both occurrences **MUST** match identical code (`$A == $A` matches `x == x`, not `x == y`)
12
12
  - Patterns **MUST** parse as a single valid AST node for the target language. For method fragments or body snippets that don't parse standalone, wrap in valid context (e.g. `class $_ { … }`) and set `sel` to target the inner node — results return for the selected node, not the outer wrapper. If ast-grep reports `Multiple AST nodes are detected`, the pattern isn't a single parseable node — wrap and use `sel`
13
+ - C++ qualified calls used as expression statements need the statement semicolon in the pattern: use `ns::doThing($ARG);`, `$CALLEE($ARG)`, or wrap a statement snippet and select `call_expression`. Without `;`, tree-sitter-cpp may parse `ns::doThing($ARG)` as declaration-like syntax and return no matches
13
14
  - For TS declarations/methods, tolerate unknown annotations: `async function $NAME($$$ARGS): $_ { $$$BODY }` or `class $_ { method($ARG: $_): $_ { $$$BODY } }`
14
15
  - Declaration forms are structurally distinct — top-level `function foo`, class method `foo()`, and `const foo = () => {}` are different AST shapes; search the right form before concluding absence
15
16
  - Loosest existence check: `pat: ["executeBash"]` with `sel: "identifier"`
@@ -294,6 +294,23 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
294
294
  failOnParseError: false,
295
295
  });
296
296
  const dedupedApplyParseErrors = dedupeParseErrors(applyResult.parseErrors);
297
+ const { record: recordAppliedFile, list: appliedFileList } = createFileRecorder();
298
+ const appliedFileReplacementCounts = new Map<string, number>();
299
+ for (const fileChange of applyResult.fileChanges) {
300
+ const relativePath = formatPath(fileChange.path);
301
+ recordAppliedFile(relativePath);
302
+ appliedFileReplacementCounts.set(
303
+ relativePath,
304
+ (appliedFileReplacementCounts.get(relativePath) ?? 0) + fileChange.count,
305
+ );
306
+ }
307
+ for (const change of applyResult.changes) {
308
+ recordAppliedFile(formatPath(change.path));
309
+ }
310
+ const appliedFileReplacements = appliedFileList.map(filePath => ({
311
+ path: filePath,
312
+ count: appliedFileReplacementCounts.get(filePath) ?? 0,
313
+ }));
297
314
  const appliedDetails: AstEditToolDetails = {
298
315
  totalReplacements: applyResult.totalReplacements,
299
316
  filesTouched: applyResult.filesTouched,
@@ -302,9 +319,27 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
302
319
  limitReached: applyResult.limitReached,
303
320
  ...(dedupedApplyParseErrors.length > 0 ? { parseErrors: dedupedApplyParseErrors } : {}),
304
321
  scopePath,
305
- files: fileList,
306
- fileReplacements,
322
+ files: appliedFileList,
323
+ fileReplacements: appliedFileReplacements,
307
324
  };
325
+ const stalePreview =
326
+ applyResult.totalReplacements !== result.totalReplacements ||
327
+ applyResult.filesTouched !== result.filesTouched ||
328
+ fileList.some(
329
+ filePath => appliedFileReplacementCounts.get(filePath) !== fileReplacementCounts.get(filePath),
330
+ ) ||
331
+ appliedFileList.some(
332
+ filePath => fileReplacementCounts.get(filePath) !== appliedFileReplacementCounts.get(filePath),
333
+ );
334
+ if (stalePreview) {
335
+ const text =
336
+ applyResult.totalReplacements === 0
337
+ ? `Preview is stale / no longer matches; no replacements were applied. Preview expected ${result.totalReplacements} replacement${previewReplacementPlural} in ${result.filesTouched} file${previewFilePlural}.`
338
+ : applyResult.totalReplacements < result.totalReplacements
339
+ ? `Preview is stale / no longer matches; only ${applyResult.totalReplacements} of ${result.totalReplacements} replacements were applied in ${applyResult.filesTouched} of ${result.filesTouched} files.`
340
+ : `Preview is stale / no longer matches; applied ${applyResult.totalReplacements} replacements but preview expected ${result.totalReplacements}.`;
341
+ return { ...toolResult(appliedDetails).text(text).done(), isError: true };
342
+ }
308
343
  const appliedReplacementPlural = applyResult.totalReplacements !== 1 ? "s" : "";
309
344
  const appliedFilePlural = applyResult.filesTouched !== 1 ? "s" : "";
310
345
  const text = `Applied ${applyResult.totalReplacements} replacement${appliedReplacementPlural} in ${applyResult.filesTouched} file${appliedFilePlural}.`;
package/src/tools/bash.ts CHANGED
@@ -23,7 +23,7 @@ import { resolveToCwd } from "./path-utils";
23
23
  import { formatToolWorkingDirectory, replaceTabs } from "./render-utils";
24
24
  import { ToolAbortError, ToolError } from "./tool-errors";
25
25
  import { toolResult } from "./tool-result";
26
- import { clampTimeout } from "./tool-timeouts";
26
+ import { clampTimeout, TOOL_TIMEOUTS } from "./tool-timeouts";
27
27
 
28
28
  export const BASH_DEFAULT_PREVIEW_LINES = 10;
29
29
 
@@ -74,6 +74,7 @@ export interface BashToolInput {
74
74
  export interface BashToolDetails {
75
75
  meta?: OutputMeta;
76
76
  timeoutSeconds?: number;
77
+ requestedTimeoutSeconds?: number;
77
78
  async?: {
78
79
  state: "running" | "completed" | "failed";
79
80
  jobId: string;
@@ -219,6 +220,13 @@ function getBashEnvForDisplay(args: BashRenderArgs): Record<string, string> | un
219
220
  if (partialEnv && args.env) return { ...partialEnv, ...args.env };
220
221
  return args.env ?? partialEnv;
221
222
  }
223
+
224
+ function formatTimeoutClampNotice(requestedTimeoutSec: number, effectiveTimeoutSec: number): string | undefined {
225
+ return requestedTimeoutSec !== effectiveTimeoutSec
226
+ ? `Timeout clamped to ${effectiveTimeoutSec}s (requested ${requestedTimeoutSec}s; allowed range ${TOOL_TIMEOUTS.bash.min}-${TOOL_TIMEOUTS.bash.max}s).`
227
+ : undefined;
228
+ }
229
+
222
230
  /**
223
231
  * Bash tool implementation.
224
232
  *
@@ -289,9 +297,16 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
289
297
  timeoutSec: number,
290
298
  headLines?: number,
291
299
  tailLines?: number,
300
+ options: { requestedTimeoutSec?: number; notices?: string[] } = {},
292
301
  ): AgentToolResult<BashToolDetails> {
293
- const outputText = this.#formatResultOutput(result, headLines, tailLines);
302
+ const outputLines = [this.#formatResultOutput(result, headLines, tailLines)];
303
+ const notices = options.notices?.filter(Boolean) ?? [];
304
+ if (notices.length > 0) outputLines.push("", ...notices);
305
+ const outputText = outputLines.join("\n");
294
306
  const details: BashToolDetails = { timeoutSeconds: timeoutSec };
307
+ if (options.requestedTimeoutSec !== undefined && options.requestedTimeoutSec !== timeoutSec) {
308
+ details.requestedTimeoutSeconds = options.requestedTimeoutSec;
309
+ }
295
310
  const resultBuilder = toolResult(details).text(outputText).truncationFromSummary(result, { direction: "tail" });
296
311
  this.#buildResultText(result, timeoutSec, outputText);
297
312
  return resultBuilder.done();
@@ -302,16 +317,23 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
302
317
  label: string,
303
318
  previewText: string,
304
319
  timeoutSec: number,
320
+ options: { requestedTimeoutSec?: number; notices?: string[] } = {},
305
321
  ): AgentToolResult<BashToolDetails> {
306
322
  const details: BashToolDetails = {
307
323
  timeoutSeconds: timeoutSec,
308
324
  async: { state: "running", jobId, type: "bash" },
309
325
  };
326
+ if (options.requestedTimeoutSec !== undefined && options.requestedTimeoutSec !== timeoutSec) {
327
+ details.requestedTimeoutSeconds = options.requestedTimeoutSec;
328
+ }
310
329
  const lines: string[] = [];
311
330
  const trimmedPreview = previewText.trimEnd();
312
331
  if (trimmedPreview.length > 0) {
313
332
  lines.push(trimmedPreview, "");
314
333
  }
334
+ if (options.notices?.length) {
335
+ lines.push(...options.notices, "");
336
+ }
315
337
  lines.push(`Background job ${jobId} started: ${label}`);
316
338
  lines.push("Result will be delivered automatically when complete.");
317
339
  lines.push(`Use \`poll\`, \`read jobs://${jobId}\`, or \`cancel_job\` if needed.`);
@@ -330,6 +352,8 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
330
352
  commandCwd: string;
331
353
  timeoutMs: number;
332
354
  timeoutSec: number;
355
+ requestedTimeoutSec?: number;
356
+ timeoutClampNotice?: string;
333
357
  headLines?: number;
334
358
  tailLines?: number;
335
359
  resolvedEnv?: Record<string, string>;
@@ -372,6 +396,10 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
372
396
  options.timeoutSec,
373
397
  options.headLines,
374
398
  options.tailLines,
399
+ {
400
+ requestedTimeoutSec: options.requestedTimeoutSec,
401
+ notices: [options.timeoutClampNotice].filter((notice): notice is string => Boolean(notice)),
402
+ },
375
403
  );
376
404
  const finalText = this.#extractTextResult(finalResult);
377
405
  latestText = finalText;
@@ -531,8 +559,10 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
531
559
  }
532
560
 
533
561
  // Clamp to reasonable range: 1s - 3600s (1 hour)
534
- const timeoutSec = clampTimeout("bash", rawTimeout);
562
+ const requestedTimeoutSec = rawTimeout;
563
+ const timeoutSec = clampTimeout("bash", requestedTimeoutSec);
535
564
  const timeoutMs = timeoutSec * 1000;
565
+ const timeoutClampNotice = formatTimeoutClampNotice(requestedTimeoutSec, timeoutSec);
536
566
 
537
567
  if (asyncRequested) {
538
568
  if (!this.session.asyncJobManager) {
@@ -543,13 +573,18 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
543
573
  commandCwd,
544
574
  timeoutMs,
545
575
  timeoutSec,
576
+ requestedTimeoutSec,
577
+ timeoutClampNotice,
546
578
  headLines,
547
579
  tailLines,
548
580
  resolvedEnv,
549
581
  onUpdate,
550
582
  startBackgrounded: true,
551
583
  });
552
- return this.#buildBackgroundStartResult(job.jobId, job.label, "", timeoutSec);
584
+ return this.#buildBackgroundStartResult(job.jobId, job.label, "", timeoutSec, {
585
+ requestedTimeoutSec,
586
+ notices: [timeoutClampNotice].filter((notice): notice is string => Boolean(notice)),
587
+ });
553
588
  }
554
589
 
555
590
  if (this.#autoBackgroundEnabled && !pty && this.session.asyncJobManager) {
@@ -560,6 +595,8 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
560
595
  commandCwd,
561
596
  timeoutMs,
562
597
  timeoutSec,
598
+ requestedTimeoutSec,
599
+ timeoutClampNotice,
563
600
  headLines,
564
601
  tailLines,
565
602
  resolvedEnv,
@@ -567,7 +604,10 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
567
604
  startBackgrounded,
568
605
  });
569
606
  if (startBackgrounded) {
570
- return this.#buildBackgroundStartResult(job.jobId, job.label, "", timeoutSec);
607
+ return this.#buildBackgroundStartResult(job.jobId, job.label, "", timeoutSec, {
608
+ requestedTimeoutSec,
609
+ notices: [timeoutClampNotice].filter((notice): notice is string => Boolean(notice)),
610
+ });
571
611
  }
572
612
  const waitResult = await this.#waitForManagedBashJob(job, autoBackgroundWaitMs, signal);
573
613
  if (waitResult.kind === "completed") {
@@ -584,7 +624,10 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
584
624
  throw new ToolAbortError(job.getLatestText() || "Command aborted");
585
625
  }
586
626
  job.setBackgrounded(true);
587
- return this.#buildBackgroundStartResult(job.jobId, job.label, job.getLatestText(), timeoutSec);
627
+ return this.#buildBackgroundStartResult(job.jobId, job.label, job.getLatestText(), timeoutSec, {
628
+ requestedTimeoutSec,
629
+ notices: [timeoutClampNotice].filter((notice): notice is string => Boolean(notice)),
630
+ });
588
631
  }
589
632
 
590
633
  // Track output for streaming updates (tail only)
@@ -623,7 +666,10 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
623
666
  if (isInteractiveResult(result) && result.timedOut) {
624
667
  throw new ToolError(normalizeResultOutput(result) || `Command timed out after ${timeoutSec} seconds`);
625
668
  }
626
- return this.#buildCompletedResult(result, timeoutSec, headLines, tailLines);
669
+ return this.#buildCompletedResult(result, timeoutSec, headLines, tailLines, {
670
+ requestedTimeoutSec,
671
+ notices: [timeoutClampNotice].filter((notice): notice is string => Boolean(notice)),
672
+ });
627
673
  }
628
674
  }
629
675
 
@@ -700,12 +746,16 @@ export const bashToolRenderer = {
700
746
 
701
747
  // Build truncation warning
702
748
  const timeoutSeconds = details?.timeoutSeconds ?? renderContext?.timeout;
703
- const timeoutLine =
749
+ const requestedTimeoutSeconds = details?.requestedTimeoutSeconds;
750
+ const timeoutLabel =
704
751
  typeof timeoutSeconds === "number"
705
- ? uiTheme.fg(
706
- "dim",
707
- `${uiTheme.format.bracketLeft}Timeout: ${timeoutSeconds}s${uiTheme.format.bracketRight}`,
708
- )
752
+ ? requestedTimeoutSeconds !== undefined && requestedTimeoutSeconds !== timeoutSeconds
753
+ ? `Timeout: ${timeoutSeconds}s (requested ${requestedTimeoutSeconds}s clamped)`
754
+ : `Timeout: ${timeoutSeconds}s`
755
+ : undefined;
756
+ const timeoutLine =
757
+ timeoutLabel !== undefined
758
+ ? uiTheme.fg("dim", `${uiTheme.format.bracketLeft}${timeoutLabel}${uiTheme.format.bracketRight}`)
709
759
  : undefined;
710
760
  let warningLine: string | undefined;
711
761
  if (details?.meta?.truncation && !showingFullOutput) {
package/src/tools/find.ts CHANGED
@@ -129,6 +129,19 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
129
129
  const includeHidden = hidden ?? true;
130
130
  const timeoutSignal = AbortSignal.timeout(GLOB_TIMEOUT_MS);
131
131
  const combinedSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
132
+ const formatMatchPath = (matchPath: string, fileType?: natives.FileType): string => {
133
+ const hadTrailingSlash = matchPath.endsWith("/") || matchPath.endsWith("\\");
134
+ const absolutePath = path.isAbsolute(matchPath) ? matchPath : path.resolve(searchPath, matchPath);
135
+ let relativePath = path.relative(this.session.cwd, absolutePath).replace(/\\/g, "/");
136
+ if (relativePath.length === 0) {
137
+ relativePath = ".";
138
+ }
139
+ if ((fileType === natives.FileType.Dir || hadTrailingSlash) && !relativePath.endsWith("/")) {
140
+ relativePath += "/";
141
+ }
142
+ return relativePath;
143
+ };
144
+
132
145
  const buildResult = (files: string[]): AgentToolResult<FindToolDetails> => {
133
146
  if (files.length === 0) {
134
147
  const details: FindToolDetails = { scopePath, fileCount: 0, files: [], truncated: false };
@@ -176,12 +189,7 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
176
189
  ignore: ["**/node_modules/**", "**/.git/**"],
177
190
  limit: effectiveLimit,
178
191
  });
179
- const relativized = results.map(p => {
180
- if (p.startsWith(searchPath)) {
181
- return p.slice(searchPath.length + 1);
182
- }
183
- return path.relative(searchPath, p);
184
- });
192
+ const relativized = results.map(p => formatMatchPath(p));
185
193
 
186
194
  return buildResult(relativized);
187
195
  }
@@ -225,12 +233,8 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
225
233
  };
226
234
  const onMatch = onUpdate
227
235
  ? (err: Error | null, match: natives.GlobMatch | null) => {
228
- if (err || signal?.aborted || !match) return;
229
- let relativePath = match.path;
230
- if (!relativePath) return;
231
- if (match.fileType === natives.FileType.Dir && !relativePath.endsWith("/")) {
232
- relativePath += "/";
233
- }
236
+ if (err || signal?.aborted || !match?.path) return;
237
+ const relativePath = formatMatchPath(match.path, match.fileType);
234
238
  onUpdateMatches.push(relativePath);
235
239
  emitUpdate();
236
240
  }
@@ -254,10 +258,7 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
254
258
  );
255
259
 
256
260
  try {
257
- let result = await doGlob(true);
258
- if (result.matches.length === 0 && !timeoutSignal.aborted) {
259
- result = await doGlob(false);
260
- }
261
+ const result = await doGlob(true);
261
262
  // Sort by mtime descending (most recent first) in JS instead of native.
262
263
  // This allows native glob to early-terminate at maxResults.
263
264
  result.matches.sort((a, b) => (b.mtime ?? 0) - (a.mtime ?? 0));
@@ -276,19 +277,11 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
276
277
  const relativized: string[] = [];
277
278
  for (const match of matches) {
278
279
  throwIfAborted(signal);
279
- const line = match.path;
280
- if (!line) {
280
+ if (!match.path) {
281
281
  continue;
282
282
  }
283
283
 
284
- const hadTrailingSlash = line.endsWith("/") || line.endsWith("\\");
285
- let relativePath = line;
286
- const isDirectory = match.fileType === natives.FileType.Dir;
287
- if ((isDirectory || hadTrailingSlash) && !relativePath.endsWith("/")) {
288
- relativePath += "/";
289
- }
290
-
291
- relativized.push(relativePath);
284
+ relativized.push(formatMatchPath(match.path, match.fileType));
292
285
  }
293
286
 
294
287
  return buildResult(relativized);
package/src/tools/grep.ts CHANGED
@@ -124,6 +124,7 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
124
124
  };
125
125
  let searchPath: string;
126
126
  let scopePath: string;
127
+ let exactFilePaths: string[] | undefined;
127
128
  let globFilter = glob ? normalizePathLikeInput(glob) || undefined : undefined;
128
129
  const internalRouter = this.session.internalRouter;
129
130
  if (searchDir?.trim()) {
@@ -142,7 +143,8 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
142
143
  const multiSearchPath = await resolveMultiSearchPath(rawPath, this.session.cwd, globFilter);
143
144
  if (multiSearchPath) {
144
145
  searchPath = multiSearchPath.basePath;
145
- globFilter = multiSearchPath.glob;
146
+ globFilter = multiSearchPath.exactFilePaths ? undefined : multiSearchPath.glob;
147
+ exactFilePaths = multiSearchPath.exactFilePaths;
146
148
  scopePath = multiSearchPath.scopePath;
147
149
  } else {
148
150
  const parsedPath = parseSearchPath(rawPath);
@@ -174,26 +176,61 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
174
176
  // Run grep
175
177
  let result: GrepResult;
176
178
  try {
177
- result = await grep(
178
- {
179
- pattern: normalizedPattern,
180
- path: searchPath,
181
- glob: globFilter,
182
- type: type?.trim() || undefined,
183
- ignoreCase,
184
- multiline: effectiveMultiline,
185
- hidden: true,
186
- gitignore: useGitignore,
187
- cache: false,
188
- maxCount: internalLimit,
189
- offset: normalizedOffset > 0 ? normalizedOffset : undefined,
190
- contextBefore: normalizedContextBefore,
191
- contextAfter: normalizedContextAfter,
192
- maxColumns: DEFAULT_MAX_COLUMN,
193
- mode: effectiveOutputMode,
194
- },
195
- undefined,
196
- );
179
+ if (exactFilePaths) {
180
+ const matches: GrepMatch[] = [];
181
+ let limitReached = false;
182
+ for (const exactFilePath of exactFilePaths) {
183
+ const fileResult = await grep(
184
+ {
185
+ pattern: normalizedPattern,
186
+ path: exactFilePath,
187
+ type: type?.trim() || undefined,
188
+ ignoreCase,
189
+ multiline: effectiveMultiline,
190
+ hidden: true,
191
+ gitignore: useGitignore,
192
+ cache: false,
193
+ contextBefore: normalizedContextBefore,
194
+ contextAfter: normalizedContextAfter,
195
+ maxColumns: DEFAULT_MAX_COLUMN,
196
+ mode: effectiveOutputMode,
197
+ },
198
+ undefined,
199
+ );
200
+ limitReached = limitReached || Boolean(fileResult.limitReached);
201
+ const relativeFilePath = path.relative(searchPath, exactFilePath).replace(/\\/g, "/");
202
+ matches.push(...fileResult.matches.map(match => ({ ...match, path: relativeFilePath })));
203
+ }
204
+ const offsetMatches = matches.slice(normalizedOffset);
205
+ result = {
206
+ matches: offsetMatches,
207
+ totalMatches: offsetMatches.length,
208
+ filesWithMatches: new Set(offsetMatches.map(match => match.path)).size,
209
+ filesSearched: exactFilePaths.length,
210
+ limitReached,
211
+ };
212
+ } else {
213
+ result = await grep(
214
+ {
215
+ pattern: normalizedPattern,
216
+ path: searchPath,
217
+ glob: globFilter,
218
+ type: type?.trim() || undefined,
219
+ ignoreCase,
220
+ multiline: effectiveMultiline,
221
+ hidden: true,
222
+ gitignore: useGitignore,
223
+ cache: false,
224
+ maxCount: internalLimit,
225
+ offset: normalizedOffset > 0 ? normalizedOffset : undefined,
226
+ contextBefore: normalizedContextBefore,
227
+ contextAfter: normalizedContextAfter,
228
+ maxColumns: DEFAULT_MAX_COLUMN,
229
+ mode: effectiveOutputMode,
230
+ },
231
+ undefined,
232
+ );
233
+ }
197
234
  } catch (err) {
198
235
  if (err instanceof Error && err.message.startsWith("regex parse error")) {
199
236
  throw new ToolError(err.message);
@@ -258,6 +295,7 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
258
295
  }
259
296
  const outputLines: string[] = [];
260
297
  let linesTruncated = false;
298
+ const hasContextLines = normalizedContextBefore > 0 || normalizedContextAfter > 0;
261
299
  const matchesByFile = new Map<string, GrepMatch[]>();
262
300
  for (const match of selectedMatches) {
263
301
  const relativePath = formatPath(match.path);
@@ -289,10 +327,11 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
289
327
  }
290
328
  chunkMatchesByFile.get(match.displayPath)!.push(match);
291
329
  }
292
- const renderChunkedMatchesForFile = (relativePath: string) => {
330
+ const renderChunkedMatchesForFile = (relativePath: string): string[] => {
331
+ const renderedLines: string[] = [];
293
332
  const fileMatches = chunkMatchesByFile.get(relativePath) ?? [];
294
333
  if (fileMatches.length === 0) {
295
- return;
334
+ return renderedLines;
296
335
  }
297
336
  const lineWidth = fileMatches[0]?.fileLineCount.toString().length ?? 1;
298
337
  const matchesByChunk = new Map<string, ChunkedGrepMatch[]>();
@@ -310,13 +349,14 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
310
349
  const anchor = chunkChecksum
311
350
  ? `${dashes}@${chunkPath}#${chunkChecksum}`
312
351
  : `${dashes}@${chunkPath}`;
313
- outputLines.push(anchor);
352
+ renderedLines.push(anchor);
314
353
  }
315
354
  for (const match of chunkMatches) {
316
- outputLines.push(` ${match.lineNumber.toString().padStart(lineWidth, " ")} |${match.line}`);
355
+ renderedLines.push(` ${match.lineNumber.toString().padStart(lineWidth, " ")} |${match.line}`);
317
356
  fileMatchCounts.set(relativePath, (fileMatchCounts.get(relativePath) ?? 0) + 1);
318
357
  }
319
358
  }
359
+ return renderedLines;
320
360
  };
321
361
  if (isDirectory) {
322
362
  const filesByDirectory = new Map<string, string[]>();
@@ -330,26 +370,32 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
330
370
  for (const [directory, directoryFiles] of filesByDirectory) {
331
371
  if (directory === ".") {
332
372
  for (const relativePath of directoryFiles) {
373
+ const renderedLines = renderChunkedMatchesForFile(relativePath);
374
+ if (renderedLines.length === 0) continue;
333
375
  if (outputLines.length > 0) {
334
376
  outputLines.push("");
335
377
  }
336
378
  outputLines.push(`# ${path.basename(relativePath)}`);
337
- renderChunkedMatchesForFile(relativePath);
379
+ outputLines.push(...renderedLines);
338
380
  }
339
381
  continue;
340
382
  }
383
+ const renderedFiles = directoryFiles
384
+ .map(relativePath => ({ relativePath, lines: renderChunkedMatchesForFile(relativePath) }))
385
+ .filter(file => file.lines.length > 0);
386
+ if (renderedFiles.length === 0) continue;
341
387
  if (outputLines.length > 0) {
342
388
  outputLines.push("");
343
389
  }
344
390
  outputLines.push(`# ${directory}`);
345
- for (const relativePath of directoryFiles) {
391
+ for (const { relativePath, lines } of renderedFiles) {
346
392
  outputLines.push(`## └─ ${path.basename(relativePath)}`);
347
- renderChunkedMatchesForFile(relativePath);
393
+ outputLines.push(...lines);
348
394
  }
349
395
  }
350
396
  } else {
351
397
  for (const relativePath of fileList) {
352
- renderChunkedMatchesForFile(relativePath);
398
+ outputLines.push(...renderChunkedMatchesForFile(relativePath));
353
399
  }
354
400
  }
355
401
  const rawOutput = outputLines.join("\n");
@@ -380,7 +426,8 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
380
426
  }
381
427
  return resultBuilder.done();
382
428
  }
383
- const renderMatchesForFile = (relativePath: string) => {
429
+ const renderMatchesForFile = (relativePath: string): string[] => {
430
+ const renderedLines: string[] = [];
384
431
  const fileMatches = matchesByFile.get(relativePath) ?? [];
385
432
  for (const match of fileMatches) {
386
433
  const lineNumbers: number[] = [match.lineNumber];
@@ -399,20 +446,21 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
399
446
  formatMatchLine(lineNumber, line, isMatch, { useHashLines, lineWidth });
400
447
  if (match.contextBefore) {
401
448
  for (const ctx of match.contextBefore) {
402
- outputLines.push(formatLine(ctx.lineNumber, ctx.line, false));
449
+ renderedLines.push(formatLine(ctx.lineNumber, ctx.line, false));
403
450
  }
404
451
  }
405
- outputLines.push(formatLine(match.lineNumber, match.line, true));
452
+ renderedLines.push(formatLine(match.lineNumber, match.line, true));
406
453
  if (match.truncated) {
407
454
  linesTruncated = true;
408
455
  }
409
456
  if (match.contextAfter) {
410
457
  for (const ctx of match.contextAfter) {
411
- outputLines.push(formatLine(ctx.lineNumber, ctx.line, false));
458
+ renderedLines.push(formatLine(ctx.lineNumber, ctx.line, false));
412
459
  }
413
460
  }
414
461
  fileMatchCounts.set(relativePath, (fileMatchCounts.get(relativePath) ?? 0) + 1);
415
462
  }
463
+ return renderedLines;
416
464
  };
417
465
  if (isDirectory) {
418
466
  const filesByDirectory = new Map<string, string[]>();
@@ -426,28 +474,37 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
426
474
  for (const [directory, directoryFiles] of filesByDirectory) {
427
475
  if (directory === ".") {
428
476
  for (const relativePath of directoryFiles) {
477
+ const renderedLines = renderMatchesForFile(relativePath);
478
+ if (renderedLines.length === 0) continue;
429
479
  if (outputLines.length > 0) {
430
480
  outputLines.push("");
431
481
  }
432
482
  outputLines.push(`# ${path.basename(relativePath)}`);
433
- renderMatchesForFile(relativePath);
483
+ outputLines.push(...renderedLines);
434
484
  }
435
485
  continue;
436
486
  }
487
+ const renderedFiles = directoryFiles
488
+ .map(relativePath => ({ relativePath, lines: renderMatchesForFile(relativePath) }))
489
+ .filter(file => file.lines.length > 0);
490
+ if (renderedFiles.length === 0) continue;
437
491
  if (outputLines.length > 0) {
438
492
  outputLines.push("");
439
493
  }
440
494
  outputLines.push(`# ${directory}`);
441
- for (const relativePath of directoryFiles) {
495
+ for (const { relativePath, lines } of renderedFiles) {
442
496
  outputLines.push(`## └─ ${path.basename(relativePath)}`);
443
- renderMatchesForFile(relativePath);
497
+ outputLines.push(...lines);
444
498
  }
445
499
  }
446
500
  } else {
447
501
  for (const relativePath of fileList) {
448
- renderMatchesForFile(relativePath);
502
+ outputLines.push(...renderMatchesForFile(relativePath));
449
503
  }
450
504
  }
505
+ if (hasContextLines && outputLines.length > 0) {
506
+ outputLines.unshift("[grep] match lines use ':'; context lines use '-'.");
507
+ }
451
508
  const rawOutput = outputLines.join("\n");
452
509
  const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER });
453
510
  const output = truncation.content;
@@ -193,6 +193,7 @@ export interface ResolvedMultiSearchPath {
193
193
  basePath: string;
194
194
  glob?: string;
195
195
  scopePath: string;
196
+ exactFilePaths?: string[];
196
197
  }
197
198
 
198
199
  export interface ResolvedMultiFindPattern {
@@ -438,6 +439,28 @@ async function areDelimitedTokensResolvable(
438
439
  return true;
439
440
  }
440
441
 
442
+ async function filterResolvableTokens(
443
+ tokens: string[],
444
+ cwd: string,
445
+ parseBasePath: (value: string) => string,
446
+ ): Promise<string[]> {
447
+ const out: string[] = [];
448
+ for (const token of tokens) {
449
+ if (TOP_LEVEL_INTERNAL_URL_PREFIXES.some(prefix => token.startsWith(prefix))) continue;
450
+ const basePath = parseBasePath(token);
451
+ const resolvedBasePath = resolveToCwd(basePath, cwd);
452
+ if (await pathExists(resolvedBasePath)) {
453
+ out.push(token);
454
+ continue;
455
+ }
456
+ const resolvedExactPath = resolveToCwd(token, cwd);
457
+ if (await pathExists(resolvedExactPath)) {
458
+ out.push(token);
459
+ }
460
+ }
461
+ return out;
462
+ }
463
+
441
464
  async function splitDelimitedSearchInput(
442
465
  rawInput: string,
443
466
  cwd: string,
@@ -452,8 +475,11 @@ async function splitDelimitedSearchInput(
452
475
  }
453
476
 
454
477
  const commaSeparated = splitTopLevel(trimmed, "comma");
455
- if (commaSeparated.length > 1 && (await areDelimitedTokensResolvable(commaSeparated, cwd, parseBasePath, true))) {
456
- return [...new Set(commaSeparated)];
478
+ if (commaSeparated.length > 1) {
479
+ const resolvable = await filterResolvableTokens(commaSeparated, cwd, parseBasePath);
480
+ if (resolvable.length >= 1) {
481
+ return [...new Set(resolvable)];
482
+ }
457
483
  }
458
484
 
459
485
  const whitespaceSeparated = splitTopLevel(trimmed, "whitespace");
@@ -473,7 +499,7 @@ export async function resolveMultiSearchPath(
473
499
  suffixGlob?: string,
474
500
  ): Promise<ResolvedMultiSearchPath | undefined> {
475
501
  const pathItems = await splitDelimitedSearchInput(rawPath, cwd, value => parseSearchPath(value).basePath);
476
- if (!pathItems || pathItems.length <= 1) {
502
+ if (!pathItems || pathItems.length < 1) {
477
503
  return undefined;
478
504
  }
479
505
 
@@ -486,6 +512,7 @@ export async function resolveMultiSearchPath(
486
512
  }),
487
513
  );
488
514
 
515
+ const allExactFiles = !suffixGlob && parsedItems.every(item => !item.parsedPath.glob && item.stat.isFile());
489
516
  const commonBasePath = findCommonBasePath(parsedItems.map(item => item.absoluteBasePath));
490
517
  const combinedPatterns = parsedItems.map(item => {
491
518
  const relativeBasePath = normalizePosixPath(path.relative(commonBasePath, item.absoluteBasePath)) || ".";
@@ -507,6 +534,7 @@ export async function resolveMultiSearchPath(
507
534
  basePath: commonBasePath,
508
535
  glob: buildBraceUnion(combinedPatterns),
509
536
  scopePath: toScopeDisplay(pathItems),
537
+ exactFilePaths: allExactFiles ? parsedItems.map(item => item.absoluteBasePath) : undefined,
510
538
  };
511
539
  }
512
540
 
@@ -23,6 +23,7 @@ export interface ResolveToolDetails {
23
23
  reason: string;
24
24
  sourceToolName?: string;
25
25
  label?: string;
26
+ sourceResultDetails?: unknown;
26
27
  }
27
28
 
28
29
  function resolveReasonPreview(reason?: string): string | undefined {
@@ -67,14 +68,21 @@ export function queueResolveHandler(
67
68
  onRejected: () => "requeue",
68
69
  onInvoked: async (input: unknown) => {
69
70
  const params = input as ResolveParams;
71
+ const withResolveDetails = (result: AgentToolResult<unknown>): AgentToolResult<ResolveToolDetails> => ({
72
+ ...result,
73
+ details: {
74
+ ...detailsFor(params),
75
+ ...(result.details != null ? { sourceResultDetails: result.details } : {}),
76
+ },
77
+ });
70
78
  if (params.action === "apply") {
71
79
  const result = await options.apply(params.reason);
72
- return { ...result, details: detailsFor(params) };
80
+ return withResolveDetails(result);
73
81
  }
74
82
  if (params.action === "discard" && options.reject != null) {
75
83
  const result = await options.reject(params.reason);
76
84
  if (result != null) {
77
- return { ...result, details: detailsFor(params) };
85
+ return withResolveDetails(result);
78
86
  }
79
87
  }
80
88
  return {
@@ -154,9 +162,10 @@ export const resolveToolRenderer = {
154
162
  const reason = replaceTabs(details?.reason?.trim() || "No reason provided");
155
163
  const action = details?.action ?? "apply";
156
164
  const isApply = action === "apply" && !result.isError;
165
+ const isFailedApply = action === "apply" && result.isError;
157
166
  const bgColor = result.isError ? "error" : isApply ? "success" : "warning";
158
167
  const icon = isApply ? uiTheme.status.success : uiTheme.status.error;
159
- const verb = isApply ? "Accept" : "Discard";
168
+ const verb = isApply ? "Accept" : isFailedApply ? "Failed" : "Discard";
160
169
  const separator = ": ";
161
170
  const separatorIndex = label.indexOf(separator);
162
171
  const sourceLabel = separatorIndex > 0 ? label.slice(0, separatorIndex).trim() : undefined;
package/src/tools/vim.ts CHANGED
@@ -639,7 +639,7 @@ export class VimTool implements AgentTool<typeof vimSchema, VimToolDetails> {
639
639
 
640
640
  await executeVimSteps(engine, steps, {
641
641
  pauseLastStep: params.pause === true,
642
- onKbdStep: emitUpdate ? () => emitUpdate() : undefined,
642
+ onKbdStep: emitUpdate ? () => emitUpdate(true) : undefined,
643
643
  onInsertStep: emitUpdate ? () => emitUpdate(true) : undefined,
644
644
  });
645
645