@oh-my-pi/pi-coding-agent 15.1.9 → 15.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.
Files changed (61) hide show
  1. package/CHANGELOG.md +30 -1
  2. package/dist/types/config/settings-schema.d.ts +10 -0
  3. package/dist/types/eval/py/kernel.d.ts +6 -0
  4. package/dist/types/goals/state.d.ts +1 -1
  5. package/dist/types/goals/tools/goal-tool.d.ts +4 -0
  6. package/dist/types/hashline/parser.d.ts +6 -2
  7. package/dist/types/internal-urls/memory-protocol.d.ts +6 -0
  8. package/dist/types/modes/theme/shimmer.d.ts +27 -0
  9. package/dist/types/slash-commands/helpers/format.d.ts +4 -1
  10. package/dist/types/tools/ast-edit.d.ts +3 -0
  11. package/dist/types/tools/ast-grep.d.ts +3 -0
  12. package/dist/types/tools/find.d.ts +3 -0
  13. package/dist/types/tools/search.d.ts +3 -0
  14. package/dist/types/tui/file-list.d.ts +6 -0
  15. package/dist/types/tui/hyperlink.d.ts +42 -0
  16. package/dist/types/tui/index.d.ts +1 -0
  17. package/dist/types/web/search/providers/utils.d.ts +2 -1
  18. package/package.json +7 -7
  19. package/src/config/settings-schema.ts +12 -0
  20. package/src/config/settings.ts +28 -5
  21. package/src/edit/renderer.ts +5 -3
  22. package/src/eval/py/executor.ts +12 -1
  23. package/src/eval/py/kernel.ts +24 -8
  24. package/src/extensibility/plugins/legacy-pi-compat.ts +2 -2
  25. package/src/goals/runtime.ts +9 -3
  26. package/src/goals/state.ts +1 -1
  27. package/src/goals/tools/goal-tool.ts +12 -2
  28. package/src/hashline/diff.ts +1 -1
  29. package/src/hashline/execute.ts +2 -2
  30. package/src/hashline/parser.ts +87 -12
  31. package/src/internal-urls/memory-protocol.ts +1 -1
  32. package/src/modes/interactive-mode.ts +29 -1
  33. package/src/modes/theme/shimmer.ts +79 -0
  34. package/src/prompts/tools/goal.md +7 -2
  35. package/src/session/agent-session.ts +12 -75
  36. package/src/slash-commands/helpers/format.ts +23 -3
  37. package/src/task/executor.ts +115 -19
  38. package/src/tools/ast-edit.ts +39 -6
  39. package/src/tools/ast-grep.ts +38 -6
  40. package/src/tools/find.ts +13 -2
  41. package/src/tools/read.ts +46 -6
  42. package/src/tools/search.ts +447 -265
  43. package/src/tui/file-list.ts +10 -2
  44. package/src/tui/hyperlink.ts +126 -0
  45. package/src/tui/index.ts +1 -0
  46. package/src/web/search/index.ts +13 -9
  47. package/src/web/search/providers/anthropic.ts +3 -1
  48. package/src/web/search/providers/brave.ts +3 -1
  49. package/src/web/search/providers/codex.ts +3 -1
  50. package/src/web/search/providers/exa.ts +3 -1
  51. package/src/web/search/providers/gemini.ts +3 -1
  52. package/src/web/search/providers/jina.ts +3 -1
  53. package/src/web/search/providers/kagi.ts +5 -1
  54. package/src/web/search/providers/kimi.ts +3 -1
  55. package/src/web/search/providers/parallel.ts +5 -1
  56. package/src/web/search/providers/perplexity.ts +5 -1
  57. package/src/web/search/providers/searxng.ts +3 -1
  58. package/src/web/search/providers/synthetic.ts +3 -1
  59. package/src/web/search/providers/tavily.ts +3 -1
  60. package/src/web/search/providers/utils.ts +33 -1
  61. package/src/web/search/providers/zai.ts +3 -1
@@ -7,7 +7,7 @@
7
7
  import path from "node:path";
8
8
  import type { AgentEvent, AgentIdentity, AgentTelemetryConfig, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
9
9
  import { recordHandoff, resolveTelemetry } from "@oh-my-pi/pi-agent-core";
10
- import { isJsonSchemaValueValid } from "@oh-my-pi/pi-ai/utils/schema";
10
+ import { type JsonSchemaValidationIssue, validateJsonSchemaValue } from "@oh-my-pi/pi-ai/utils/schema";
11
11
  import { logger, prompt, untilAborted } from "@oh-my-pi/pi-utils";
12
12
  import { ModelRegistry } from "../config/model-registry";
13
13
  import { resolveModelOverrideWithAuthFallback } from "../config/model-resolver";
@@ -204,12 +204,59 @@ function parseStringifiedJson(value: unknown): unknown {
204
204
  }
205
205
  }
206
206
 
207
- function buildOutputValidator(schema: unknown): { validate?: (value: unknown) => boolean; error?: string } {
207
+ interface OutputValidator {
208
+ validate: (value: unknown) => { ok: true } | { ok: false; message: string; missingRequired: string[] };
209
+ requiredFields: string[];
210
+ }
211
+
212
+ function buildOutputValidator(schema: unknown): { validator?: OutputValidator; error?: string } {
208
213
  const { normalized, error } = normalizeSchema(schema);
209
214
  if (error) return { error };
210
215
  if (normalized === undefined) return {};
211
216
  const jsonSchema = jtdToJsonSchema(normalized);
212
- return { validate: value => isJsonSchemaValueValid(jsonSchema, value) };
217
+ const required = extractRequiredFields(jsonSchema);
218
+ return {
219
+ validator: {
220
+ requiredFields: required,
221
+ validate: value => {
222
+ const result = validateJsonSchemaValue(jsonSchema, value);
223
+ if (result.success) return { ok: true };
224
+ const missing = computeMissingRequired(required, value);
225
+ const message = formatValidationIssue(result.issues[0]) ?? "schema validation failed";
226
+ return { ok: false, message, missingRequired: missing };
227
+ },
228
+ },
229
+ };
230
+ }
231
+
232
+ function extractRequiredFields(jsonSchema: unknown): string[] {
233
+ if (!jsonSchema || typeof jsonSchema !== "object") return [];
234
+ const required = (jsonSchema as { required?: unknown }).required;
235
+ return Array.isArray(required) ? required.filter((k): k is string => typeof k === "string") : [];
236
+ }
237
+
238
+ function computeMissingRequired(required: readonly string[], value: unknown): string[] {
239
+ if (required.length === 0) return [];
240
+ if (value === null || value === undefined) return [...required];
241
+ if (typeof value !== "object" || Array.isArray(value)) return [];
242
+ const record = value as Record<string, unknown>;
243
+ return required.filter(key => !(key in record) || record[key] === undefined);
244
+ }
245
+
246
+ function formatValidationIssue(issue: JsonSchemaValidationIssue | undefined): string | undefined {
247
+ if (!issue) return undefined;
248
+ const path = issue.path.length > 0 ? issue.path.map(String).join(".") : "(root)";
249
+ return `${path}: ${issue.message}`;
250
+ }
251
+
252
+ function previewOffendingData(value: unknown, maxLength = 500): string {
253
+ let serialized: string;
254
+ try {
255
+ serialized = JSON.stringify(value) ?? "null";
256
+ } catch {
257
+ serialized = String(value);
258
+ }
259
+ return serialized.length > maxLength ? `${serialized.slice(0, maxLength)}…` : serialized;
213
260
  }
214
261
 
215
262
  function tryParseJsonOutput(text: string): unknown | undefined {
@@ -253,9 +300,9 @@ function resolveFallbackCompletion(rawOutput: string, outputSchema: unknown): {
253
300
  if (parsed === undefined) return null;
254
301
  const candidate = parseStringifiedJson(extractCompletionData(parsed));
255
302
  if (candidate === undefined) return null;
256
- const { validate, error } = buildOutputValidator(outputSchema);
303
+ const { validator, error } = buildOutputValidator(outputSchema);
257
304
  if (error) return null;
258
- if (validate && !validate(candidate)) return null;
305
+ if (validator && !validator.validate(candidate).ok) return null;
259
306
  return { data: candidate };
260
307
  }
261
308
 
@@ -288,6 +335,31 @@ export const SUBAGENT_WARNING_NULL_YIELD = "SYSTEM WARNING: Subagent called yiel
288
335
  export const SUBAGENT_WARNING_MISSING_YIELD =
289
336
  "SYSTEM WARNING: Subagent exited without calling yield tool after 3 reminders.";
290
337
 
338
+ /** Build a schema_violation outcome — surfaced as a non-zero exit so callers treat it as a failure. */
339
+ function buildSchemaViolationOutcome(
340
+ failure: { message: string; missingRequired: string[] },
341
+ data: unknown,
342
+ ): { rawOutput: string; stderr: string; exitCode: number } {
343
+ const missing = failure.missingRequired;
344
+ const headline =
345
+ missing.length > 0
346
+ ? `schema_violation: missing required fields: ${missing.join(", ")}`
347
+ : `schema_violation: ${failure.message}`;
348
+ const payload = {
349
+ error: "schema_violation",
350
+ message: failure.message,
351
+ missingRequired: missing,
352
+ data: previewOffendingData(data),
353
+ };
354
+ let rawOutput: string;
355
+ try {
356
+ rawOutput = JSON.stringify(payload, null, 2);
357
+ } catch {
358
+ rawOutput = `{"error":"schema_violation","message":${JSON.stringify(headline)}}`;
359
+ }
360
+ return { rawOutput, stderr: headline, exitCode: 1 };
361
+ }
362
+
291
363
  export function finalizeSubprocessOutput(args: FinalizeSubprocessOutputArgs): FinalizeSubprocessOutputResult {
292
364
  let { rawOutput, exitCode, stderr } = args;
293
365
  const { yieldItems, reportFindings, doneAborted, signalAborted, outputSchema } = args;
@@ -311,14 +383,29 @@ export function finalizeSubprocessOutput(args: FinalizeSubprocessOutputArgs): Fi
311
383
  rawOutput = rawOutput ? `${SUBAGENT_WARNING_NULL_YIELD}\n\n${rawOutput}` : SUBAGENT_WARNING_NULL_YIELD;
312
384
  } else {
313
385
  const completeData = normalizeCompleteData(submitData, reportFindings);
314
- try {
315
- rawOutput = JSON.stringify(completeData, null, 2) ?? "null";
316
- } catch (err) {
317
- const errorMessage = err instanceof Error ? err.message : String(err);
318
- rawOutput = `{"error":"Failed to serialize yield data: ${errorMessage}"}`;
386
+ const { validator, error: schemaError } = buildOutputValidator(outputSchema);
387
+ if (schemaError) {
388
+ rawOutput = `{"error":"schema_violation","message":"invalid output schema: ${schemaError.replace(/"/g, '\\"')}"}`;
389
+ stderr = `schema_violation: invalid output schema: ${schemaError}`;
390
+ exitCode = 1;
391
+ } else {
392
+ const verdict = validator ? validator.validate(completeData) : { ok: true as const };
393
+ if (!verdict.ok) {
394
+ const outcome = buildSchemaViolationOutcome(verdict, completeData);
395
+ rawOutput = outcome.rawOutput;
396
+ stderr = outcome.stderr;
397
+ exitCode = outcome.exitCode;
398
+ } else {
399
+ try {
400
+ rawOutput = JSON.stringify(completeData, null, 2) ?? "null";
401
+ } catch (err) {
402
+ const errorMessage = err instanceof Error ? err.message : String(err);
403
+ rawOutput = `{"error":"Failed to serialize yield data: ${errorMessage}"}`;
404
+ }
405
+ exitCode = 0;
406
+ stderr = "";
407
+ }
319
408
  }
320
- exitCode = 0;
321
- stderr = "";
322
409
  }
323
410
  }
324
411
  } else {
@@ -328,14 +415,23 @@ export function finalizeSubprocessOutput(args: FinalizeSubprocessOutputArgs): Fi
328
415
  const fallback = allowFallback ? resolveFallbackCompletion(rawOutput, outputSchema) : null;
329
416
  if (fallback) {
330
417
  const completeData = normalizeCompleteData(fallback.data, reportFindings);
331
- try {
332
- rawOutput = JSON.stringify(completeData, null, 2) ?? "null";
333
- } catch (err) {
334
- const errorMessage = err instanceof Error ? err.message : String(err);
335
- rawOutput = `{"error":"Failed to serialize fallback completion: ${errorMessage}"}`;
418
+ const { validator } = buildOutputValidator(outputSchema);
419
+ const verdict = validator ? validator.validate(completeData) : { ok: true as const };
420
+ if (!verdict.ok) {
421
+ const outcome = buildSchemaViolationOutcome(verdict, completeData);
422
+ rawOutput = outcome.rawOutput;
423
+ stderr = outcome.stderr;
424
+ exitCode = outcome.exitCode;
425
+ } else {
426
+ try {
427
+ rawOutput = JSON.stringify(completeData, null, 2) ?? "null";
428
+ } catch (err) {
429
+ const errorMessage = err instanceof Error ? err.message : String(err);
430
+ rawOutput = `{"error":"Failed to serialize fallback completion: ${errorMessage}"}`;
431
+ }
432
+ exitCode = 0;
433
+ stderr = "";
336
434
  }
337
- exitCode = 0;
338
- stderr = "";
339
435
  } else if (!hasOutputSchema && allowFallback && rawOutput.trim().length > 0) {
340
436
  exitCode = 0;
341
437
  stderr = "";
@@ -9,7 +9,7 @@ import type { RenderResultOptions } from "../extensibility/custom-tools/types";
9
9
  import { computeLineHash, HL_BODY_SEP } from "../hashline/hash";
10
10
  import type { Theme } from "../modes/theme/theme";
11
11
  import astEditDescription from "../prompts/tools/ast-edit.md" with { type: "text" };
12
- import { Ellipsis, renderStatusLine, renderTreeList, truncateToWidth } from "../tui";
12
+ import { Ellipsis, fileHyperlink, renderStatusLine, renderTreeList, truncateToWidth } from "../tui";
13
13
  import { resolveFileDisplayMode } from "../utils/file-display-mode";
14
14
  import type { ToolSession } from ".";
15
15
  import { createFileRecorder, formatResultPath } from "./file-recorder";
@@ -155,6 +155,9 @@ export interface AstEditToolDetails {
155
155
  /** Pre-formatted text for the user-visible TUI render. Mirrors `result.text` lines but uses
156
156
  * a `│` gutter (no model-only hashline anchors). The TUI uses this directly so it never parses model-facing text. */
157
157
  displayContent?: string;
158
+ /** Absolute base directory used during the edit. Used by the renderer to resolve
159
+ * display-relative paths to absolute paths for OSC 8 hyperlinks. */
160
+ searchPath?: string;
158
161
  }
159
162
 
160
163
  export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolDetails> {
@@ -241,6 +244,7 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
241
244
  limitReached: result.limitReached,
242
245
  ...(cappedParseErrors.length > 0 ? { parseErrors: cappedParseErrors, parseErrorsTotal } : {}),
243
246
  scopePath,
247
+ searchPath: resolvedSearchPath,
244
248
  files: fileList,
245
249
  fileReplacements: [],
246
250
  };
@@ -483,6 +487,7 @@ export const astEditToolRenderer = {
483
487
  return createCachedComponent(
484
488
  () => options.expanded,
485
489
  width => {
490
+ const searchBase = details?.searchPath;
486
491
  const changeLines = renderTreeList(
487
492
  {
488
493
  items: changeGroups,
@@ -490,14 +495,42 @@ export const astEditToolRenderer = {
490
495
  maxCollapsed: changeGroups.length,
491
496
  maxCollapsedLines: COLLAPSED_CHANGE_LIMIT,
492
497
  itemType: "change",
493
- renderItem: group =>
494
- group.map(line => {
495
- if (line.startsWith("## ")) return uiTheme.fg("dim", line);
496
- if (line.startsWith("# ")) return uiTheme.fg("accent", line);
498
+ renderItem: group => {
499
+ let contextDir = searchBase ?? "";
500
+ return group.map(line => {
501
+ if (line.startsWith("## ")) {
502
+ // Strip ` (3 replacements)` suffix attached by formatGroupedFiles.
503
+ const fileName = line
504
+ .slice(3)
505
+ .trimEnd()
506
+ .replace(/\s+\([^)]*\)\s*$/, "");
507
+ const absPath = contextDir && fileName ? path.join(contextDir, fileName) : undefined;
508
+ const styled = uiTheme.fg("dim", line);
509
+ return absPath ? fileHyperlink(absPath, styled) : styled;
510
+ }
511
+ if (line.startsWith("# ")) {
512
+ const raw = line
513
+ .slice(2)
514
+ .trimEnd()
515
+ .replace(/\s+\([^)]*\)\s*$/, "");
516
+ const isDirectory = raw.endsWith("/");
517
+ const name = raw.replace(/\/$/, "");
518
+ if (isDirectory) {
519
+ if (searchBase) {
520
+ contextDir = name === "." ? searchBase : path.join(searchBase, name);
521
+ }
522
+ return uiTheme.fg("accent", line);
523
+ }
524
+ // Root-level file with optional suffix, e.g. `# foo.ts (3 replacements)`.
525
+ const absPath = searchBase && name ? path.join(searchBase, name) : undefined;
526
+ const styled = uiTheme.fg("accent", line);
527
+ return absPath ? fileHyperlink(absPath, styled) : styled;
528
+ }
497
529
  if (line.startsWith("+")) return uiTheme.fg("toolDiffAdded", line);
498
530
  if (line.startsWith("-")) return uiTheme.fg("toolDiffRemoved", line);
499
531
  return uiTheme.fg("toolOutput", line);
500
- }),
532
+ });
533
+ },
501
534
  },
502
535
  uiTheme,
503
536
  );
@@ -8,7 +8,7 @@ import * as z from "zod/v4";
8
8
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
9
9
  import type { Theme } from "../modes/theme/theme";
10
10
  import astGrepDescription from "../prompts/tools/ast-grep.md" with { type: "text" };
11
- import { Ellipsis, renderStatusLine, renderTreeList, truncateToWidth } from "../tui";
11
+ import { Ellipsis, fileHyperlink, renderStatusLine, renderTreeList, truncateToWidth } from "../tui";
12
12
  import { resolveFileDisplayMode } from "../utils/file-display-mode";
13
13
  import type { ToolSession } from ".";
14
14
  import { createFileRecorder, formatResultPath } from "./file-recorder";
@@ -113,6 +113,9 @@ export interface AstGrepToolDetails {
113
113
  /** Pre-formatted text for the user-visible TUI render. Mirrors `result.text` lines but uses
114
114
  * a `│` gutter and `*` to mark match lines. The TUI uses this directly so it never parses model-facing text. */
115
115
  displayContent?: string;
116
+ /** Absolute base directory used during search. Used by the renderer to resolve
117
+ * display-relative paths to absolute paths for OSC 8 hyperlinks. */
118
+ searchPath?: string;
116
119
  }
117
120
 
118
121
  export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolDetails> {
@@ -197,6 +200,7 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
197
200
  limitReached: result.limitReached,
198
201
  ...(cappedParseErrors.length > 0 ? { parseErrors: cappedParseErrors, parseErrorsTotal } : {}),
199
202
  scopePath,
203
+ searchPath: resolvedSearchPath,
200
204
  files: fileList,
201
205
  fileMatches: [],
202
206
  };
@@ -366,6 +370,7 @@ export const astGrepToolRenderer = {
366
370
  return createCachedComponent(
367
371
  () => options.expanded,
368
372
  width => {
373
+ const searchBase = details?.searchPath;
369
374
  const matchLines = renderTreeList(
370
375
  {
371
376
  items: matchGroups,
@@ -373,13 +378,40 @@ export const astGrepToolRenderer = {
373
378
  maxCollapsed: matchGroups.length,
374
379
  maxCollapsedLines: COLLAPSED_MATCH_LIMIT,
375
380
  itemType: "match",
376
- renderItem: group =>
377
- group.map(line => {
378
- if (line.startsWith("## ")) return uiTheme.fg("dim", line);
379
- if (line.startsWith("# ")) return uiTheme.fg("accent", line);
381
+ renderItem: group => {
382
+ let contextDir = searchBase ?? "";
383
+ return group.map(line => {
384
+ if (line.startsWith("## ")) {
385
+ const fileName = line
386
+ .slice(3)
387
+ .trimEnd()
388
+ .replace(/\s+\([^)]*\)\s*$/, "");
389
+ const absPath = contextDir && fileName ? path.join(contextDir, fileName) : undefined;
390
+ const styled = uiTheme.fg("dim", line);
391
+ return absPath ? fileHyperlink(absPath, styled) : styled;
392
+ }
393
+ if (line.startsWith("# ")) {
394
+ const raw = line
395
+ .slice(2)
396
+ .trimEnd()
397
+ .replace(/\s+\([^)]*\)\s*$/, "");
398
+ const isDirectory = raw.endsWith("/");
399
+ const name = raw.replace(/\/$/, "");
400
+ if (isDirectory) {
401
+ if (searchBase) {
402
+ contextDir = name === "." ? searchBase : path.join(searchBase, name);
403
+ }
404
+ return uiTheme.fg("accent", line);
405
+ }
406
+ // Root-level file (single # without trailing slash) from formatGroupedFiles.
407
+ const absPath = searchBase && name ? path.join(searchBase, name) : undefined;
408
+ const styled = uiTheme.fg("accent", line);
409
+ return absPath ? fileHyperlink(absPath, styled) : styled;
410
+ }
380
411
  if (line.startsWith(" meta:")) return uiTheme.fg("dim", line);
381
412
  return uiTheme.fg("toolOutput", line);
382
- }),
413
+ });
414
+ },
383
415
  },
384
416
  uiTheme,
385
417
  );
package/src/tools/find.ts CHANGED
@@ -11,7 +11,7 @@ import { InternalUrlRouter } from "../internal-urls";
11
11
  import type { Theme } from "../modes/theme/theme";
12
12
  import findDescription from "../prompts/tools/find.md" with { type: "text" };
13
13
  import { type TruncationResult, truncateHead } from "../session/streaming-output";
14
- import { Ellipsis, renderFileList, renderStatusLine, renderTreeList, truncateToWidth } from "../tui";
14
+ import { Ellipsis, fileHyperlink, renderFileList, renderStatusLine, renderTreeList, truncateToWidth } from "../tui";
15
15
  import type { ToolSession } from ".";
16
16
  import { applyListLimit } from "./list-limit";
17
17
  import { formatFullOutputReference, type OutputMeta } from "./output-meta";
@@ -84,6 +84,9 @@ export interface FindToolDetails {
84
84
  files?: string[];
85
85
  truncated?: boolean;
86
86
  error?: string;
87
+ /** Working directory at search time. Used by the renderer to resolve relative
88
+ * file paths to absolute paths for OSC 8 hyperlinks. */
89
+ cwd?: string;
87
90
  /** User-supplied paths whose base directory was missing on disk. The tool
88
91
  * skipped these and continued with the surviving entries; surfaced as a
89
92
  * non-fatal warning in the renderer and in the model-facing text. */
@@ -221,6 +224,7 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
221
224
  fileCount: 0,
222
225
  files: [],
223
226
  truncated: forceTruncated,
227
+ cwd: this.session.cwd,
224
228
  missingPaths: missingPaths.length > 0 ? missingPaths : undefined,
225
229
  };
226
230
  const parts = ["No files found matching pattern"];
@@ -246,6 +250,7 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
246
250
  truncated: Boolean(forceTruncated || limitMeta.resultLimit || truncation.truncated),
247
251
  resultLimitReached: limitMeta.resultLimit?.reached,
248
252
  truncation: truncation.truncated ? truncation : undefined,
253
+ cwd: this.session.cwd,
249
254
  missingPaths: missingPaths.length > 0 ? missingPaths : undefined,
250
255
  };
251
256
 
@@ -513,11 +518,17 @@ export const findToolRenderer = {
513
518
  return createCachedComponent(
514
519
  () => options.expanded,
515
520
  width => {
521
+ const cwd = details?.cwd;
516
522
  const fileLines = renderFileList(
517
523
  {
518
- files: files.map(entry => ({ path: entry, isDirectory: entry.endsWith("/") })),
524
+ files: files.map(entry => ({
525
+ path: entry,
526
+ isDirectory: entry.endsWith("/"),
527
+ absPath: cwd && !entry.endsWith("/") ? path.resolve(cwd, entry) : undefined,
528
+ })),
519
529
  expanded: options.expanded,
520
530
  maxCollapsed: COLLAPSED_LIST_LIMIT,
531
+ hyperlinkFn: fileHyperlink,
521
532
  },
522
533
  uiTheme,
523
534
  );
package/src/tools/read.ts CHANGED
@@ -27,7 +27,7 @@ import {
27
27
  truncateHeadBytes,
28
28
  truncateLine,
29
29
  } from "../session/streaming-output";
30
- import { renderCodeCell, renderMarkdownCell, renderStatusLine } from "../tui";
30
+ import { fileHyperlink, renderCodeCell, renderMarkdownCell, renderStatusLine, tryResolveInternalUrlSync } from "../tui";
31
31
  import { CachedOutputBlock } from "../tui/output-block";
32
32
  import { resolveFileDisplayMode } from "../utils/file-display-mode";
33
33
  import { ImageInputTooLargeError, loadImageInput, MAX_IMAGE_INPUT_BYTES } from "../utils/image-loading";
@@ -118,8 +118,22 @@ function formatTextWithMode(
118
118
  startNum: number,
119
119
  shouldAddHashLines: boolean,
120
120
  shouldAddLineNumbers: boolean,
121
+ truncatedLines?: ReadonlySet<number>,
121
122
  ): string {
122
- if (shouldAddHashLines) return formatHashLines(text, startNum);
123
+ if (shouldAddHashLines) {
124
+ if (!truncatedLines || truncatedLines.size === 0) return formatHashLines(text, startNum);
125
+ // Column-truncated lines hash differently from the on-disk line that the
126
+ // edit verifier reads back. Drop the anchor (`LINE|TEXT` instead of
127
+ // `LINE+HASH|TEXT`) so the model treats the line as un-anchorable rather
128
+ // than copying a hash that will be rejected as stale.
129
+ const lines = text.split("\n");
130
+ return lines
131
+ .map((line, i) => {
132
+ const ln = startNum + i;
133
+ return truncatedLines.has(ln) ? `${ln}${HL_BODY_SEP}${line}` : formatHashLine(ln, line);
134
+ })
135
+ .join("\n");
136
+ }
123
137
  if (shouldAddLineNumbers) return prependLineNumbers(text, startNum);
124
138
  return text;
125
139
  }
@@ -1031,12 +1045,14 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1031
1045
  }
1032
1046
 
1033
1047
  const collectedLines = streamResult.lines;
1048
+ const truncatedLineNumbers = new Set<number>();
1034
1049
  if (!rawSelector && maxColumns > 0) {
1035
1050
  for (let i = 0; i < collectedLines.length; i++) {
1036
1051
  const { text, wasTruncated } = truncateLine(collectedLines[i], maxColumns);
1037
1052
  if (wasTruncated) {
1038
1053
  collectedLines[i] = text;
1039
1054
  columnTruncated = maxColumns;
1055
+ truncatedLineNumbers.add(range.startLine + i);
1040
1056
  }
1041
1057
  }
1042
1058
  }
@@ -1046,7 +1062,15 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1046
1062
  }
1047
1063
 
1048
1064
  const blockText = collectedLines.join("\n");
1049
- blocks.push(formatTextWithMode(blockText, range.startLine, shouldAddHashLines, shouldAddLineNumbers));
1065
+ blocks.push(
1066
+ formatTextWithMode(
1067
+ blockText,
1068
+ range.startLine,
1069
+ shouldAddHashLines,
1070
+ shouldAddLineNumbers,
1071
+ truncatedLineNumbers,
1072
+ ),
1073
+ );
1050
1074
  }
1051
1075
 
1052
1076
  let outputText = blocks.join("\n\n…\n\n");
@@ -1790,12 +1814,14 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1790
1814
  // view — column truncation surfaces separately via `.limits()`.
1791
1815
  const rawSelector = isRawSelector(parsed);
1792
1816
  const maxColumns = resolveOutputMaxColumns(this.session.settings);
1817
+ const truncatedLineNumbers = new Set<number>();
1793
1818
  if (!rawSelector && maxColumns > 0) {
1794
1819
  for (let i = 0; i < collectedLines.length; i++) {
1795
1820
  const { text, wasTruncated } = truncateLine(collectedLines[i], maxColumns);
1796
1821
  if (wasTruncated) {
1797
1822
  collectedLines[i] = text;
1798
1823
  columnTruncated = maxColumns;
1824
+ truncatedLineNumbers.add(startLineDisplay + i);
1799
1825
  }
1800
1826
  }
1801
1827
  }
@@ -1829,7 +1855,13 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1829
1855
  let capturedDisplayContent: { text: string; startLine: number } | undefined;
1830
1856
  const formatText = (text: string, startNum: number): string => {
1831
1857
  capturedDisplayContent = { text, startLine: startNum };
1832
- return formatTextWithMode(text, startNum, shouldAddHashLines, shouldAddLineNumbers);
1858
+ return formatTextWithMode(
1859
+ text,
1860
+ startNum,
1861
+ shouldAddHashLines,
1862
+ shouldAddLineNumbers,
1863
+ truncatedLineNumbers,
1864
+ );
1833
1865
  };
1834
1866
 
1835
1867
  let outputText: string;
@@ -2144,7 +2176,9 @@ export const readToolRenderer = {
2144
2176
  }
2145
2177
 
2146
2178
  const rawPath = args.file_path || args.path || "";
2147
- const filePath = shortenPath(rawPath);
2179
+ const shortPath = shortenPath(rawPath);
2180
+ const linkTarget = tryResolveInternalUrlSync(rawPath);
2181
+ const filePath = linkTarget ? fileHyperlink(linkTarget, shortPath) : shortPath;
2148
2182
  const offset = args.offset;
2149
2183
  const limit = args.limit;
2150
2184
 
@@ -2261,7 +2295,13 @@ export const readToolRenderer = {
2261
2295
  }
2262
2296
 
2263
2297
  const suffix = details?.suffixResolution;
2264
- const displayPath = suffix ? shortenPath(suffix.to) : filePath;
2298
+ const plainDisplayPath = suffix ? shortenPath(suffix.to) : filePath;
2299
+ // resolvedPath is the absolute fs path for fs-backed reads (regular files plus
2300
+ // local:// / memory:// / skill:// / artifact:// resources). Fall back to a sync
2301
+ // resolver for fs-backed internal URLs so the title is clickable even before the
2302
+ // result lands or if the handler didn't populate resolvedPath.
2303
+ const absForLink = details?.resolvedPath ?? tryResolveInternalUrlSync(rawPath);
2304
+ const displayPath = absForLink ? fileHyperlink(absForLink, plainDisplayPath) : plainDisplayPath;
2265
2305
  const correction = suffix ? ` ${uiTheme.fg("dim", `(corrected from ${shortenPath(suffix.from)})`)}` : "";
2266
2306
  let title = displayPath ? `Read ${displayPath}${correction}` : "Read";
2267
2307
  if (args?.offset !== undefined || args?.limit !== undefined) {