@oh-my-pi/pi-coding-agent 15.10.1 → 15.10.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 (154) hide show
  1. package/CHANGELOG.md +113 -1
  2. package/dist/types/cli/gallery-fixtures/types.d.ts +7 -1
  3. package/dist/types/cli/startup-cwd.d.ts +2 -0
  4. package/dist/types/commands/launch.d.ts +3 -0
  5. package/dist/types/config/keybindings.d.ts +2 -2
  6. package/dist/types/config/model-provider-priority.d.ts +1 -0
  7. package/dist/types/config/model-resolver.d.ts +4 -1
  8. package/dist/types/config/settings.d.ts +7 -2
  9. package/dist/types/debug/report-bundle.d.ts +3 -0
  10. package/dist/types/edit/file-snapshot-store.d.ts +18 -10
  11. package/dist/types/edit/index.d.ts +0 -1
  12. package/dist/types/eval/py/__tests__/prelude.test.d.ts +1 -0
  13. package/dist/types/extensibility/extensions/types.d.ts +4 -1
  14. package/dist/types/lsp/client.d.ts +10 -0
  15. package/dist/types/lsp/index.d.ts +0 -5
  16. package/dist/types/main.d.ts +14 -9
  17. package/dist/types/mcp/tool-bridge.d.ts +2 -0
  18. package/dist/types/modes/components/assistant-message.d.ts +0 -9
  19. package/dist/types/modes/components/custom-editor.d.ts +1 -1
  20. package/dist/types/modes/components/late-diagnostics-message.d.ts +20 -0
  21. package/dist/types/modes/components/read-tool-group.d.ts +6 -0
  22. package/dist/types/modes/components/session-selector.d.ts +16 -7
  23. package/dist/types/modes/components/status-line.d.ts +2 -0
  24. package/dist/types/modes/components/tool-execution.d.ts +0 -18
  25. package/dist/types/modes/controllers/event-controller.d.ts +17 -0
  26. package/dist/types/modes/interactive-mode.d.ts +1 -0
  27. package/dist/types/modes/magic-keywords.d.ts +1 -1
  28. package/dist/types/modes/markdown-prose.d.ts +1 -1
  29. package/dist/types/modes/types.d.ts +7 -0
  30. package/dist/types/modes/workflow.d.ts +3 -3
  31. package/dist/types/session/auth-storage.d.ts +1 -1
  32. package/dist/types/session/messages.d.ts +11 -8
  33. package/dist/types/session/session-manager.d.ts +5 -2
  34. package/dist/types/session/yield-queue.d.ts +10 -1
  35. package/dist/types/task/executor.d.ts +10 -0
  36. package/dist/types/tools/eval-render.d.ts +0 -1
  37. package/dist/types/tools/eval.d.ts +8 -0
  38. package/dist/types/tools/gh-cache-invalidation.d.ts +6 -0
  39. package/dist/types/tools/github-cache.d.ts +12 -0
  40. package/dist/types/tools/index.d.ts +31 -0
  41. package/dist/types/tools/path-utils.d.ts +13 -1
  42. package/dist/types/tools/read.d.ts +2 -1
  43. package/dist/types/tools/render-utils.d.ts +3 -1
  44. package/dist/types/tools/renderers.d.ts +0 -15
  45. package/dist/types/tools/search.d.ts +2 -2
  46. package/dist/types/tools/write.d.ts +0 -2
  47. package/dist/types/tools/yield.d.ts +8 -0
  48. package/dist/types/tui/code-cell.d.ts +0 -2
  49. package/dist/types/tui/hyperlink.d.ts +5 -7
  50. package/dist/types/tui/output-block.d.ts +0 -18
  51. package/package.json +9 -9
  52. package/src/cli/args.ts +3 -1
  53. package/src/cli/dry-balance-cli.ts +2 -4
  54. package/src/cli/gallery-cli.ts +4 -0
  55. package/src/cli/gallery-fixtures/codeintel.ts +0 -1
  56. package/src/cli/gallery-fixtures/fs.ts +68 -1
  57. package/src/cli/gallery-fixtures/types.ts +8 -1
  58. package/src/cli/startup-cwd.ts +68 -0
  59. package/src/commands/launch.ts +3 -0
  60. package/src/commit/agentic/agent.ts +1 -0
  61. package/src/commit/model-selection.ts +3 -2
  62. package/src/config/model-provider-priority.ts +55 -0
  63. package/src/config/model-registry.ts +4 -22
  64. package/src/config/model-resolver.ts +39 -7
  65. package/src/config/settings.ts +86 -41
  66. package/src/debug/index.ts +8 -0
  67. package/src/debug/raw-sse-buffer.ts +7 -4
  68. package/src/debug/report-bundle.ts +9 -0
  69. package/src/edit/file-snapshot-store.ts +33 -1
  70. package/src/edit/hashline/diff.ts +86 -0
  71. package/src/edit/hashline/execute.ts +14 -1
  72. package/src/edit/hashline/filesystem.ts +2 -1
  73. package/src/edit/index.ts +31 -17
  74. package/src/edit/renderer.ts +116 -31
  75. package/src/eval/__tests__/llm-bridge.test.ts +20 -0
  76. package/src/eval/js/context-manager.ts +32 -15
  77. package/src/eval/js/shared/prelude.txt +26 -10
  78. package/src/eval/llm-bridge.ts +14 -3
  79. package/src/eval/py/__tests__/prelude.test.ts +19 -0
  80. package/src/eval/py/executor.ts +23 -11
  81. package/src/eval/py/prelude.py +1 -1
  82. package/src/extensibility/extensions/types.ts +10 -1
  83. package/src/internal-urls/docs-index.generated.ts +7 -7
  84. package/src/lsp/client.ts +23 -11
  85. package/src/lsp/config.ts +11 -1
  86. package/src/lsp/index.ts +189 -61
  87. package/src/main.ts +144 -78
  88. package/src/mcp/tool-bridge.ts +2 -0
  89. package/src/memories/index.ts +2 -2
  90. package/src/modes/components/assistant-message.ts +3 -15
  91. package/src/modes/components/custom-editor.ts +143 -111
  92. package/src/modes/components/late-diagnostics-message.ts +60 -0
  93. package/src/modes/components/model-selector.ts +59 -13
  94. package/src/modes/components/oauth-selector.ts +33 -7
  95. package/src/modes/components/plan-review-overlay.ts +26 -5
  96. package/src/modes/components/read-tool-group.ts +415 -35
  97. package/src/modes/components/session-selector.ts +89 -35
  98. package/src/modes/components/status-line.ts +19 -4
  99. package/src/modes/components/tips.txt +1 -1
  100. package/src/modes/components/tool-execution.ts +7 -49
  101. package/src/modes/components/transcript-container.ts +108 -32
  102. package/src/modes/components/user-message.ts +1 -1
  103. package/src/modes/controllers/event-controller.ts +32 -1
  104. package/src/modes/controllers/input-controller.ts +56 -9
  105. package/src/modes/interactive-mode.ts +107 -20
  106. package/src/modes/magic-keywords.ts +1 -1
  107. package/src/modes/markdown-prose.ts +1 -1
  108. package/src/modes/theme/shimmer.ts +20 -9
  109. package/src/modes/types.ts +7 -0
  110. package/src/modes/utils/ui-helpers.ts +26 -5
  111. package/src/modes/workflow.ts +10 -10
  112. package/src/prompts/system/manual-continue.md +7 -0
  113. package/src/prompts/system/plan-mode-active.md +56 -72
  114. package/src/prompts/system/workflow-notice.md +1 -1
  115. package/src/prompts/tools/bash.md +9 -0
  116. package/src/prompts/tools/browser.md +1 -1
  117. package/src/prompts/tools/eval.md +5 -2
  118. package/src/prompts/tools/lsp-late-diagnostic.md +8 -0
  119. package/src/prompts/tools/read.md +2 -2
  120. package/src/sdk.ts +85 -10
  121. package/src/session/agent-session.ts +42 -15
  122. package/src/session/auth-storage.ts +2 -0
  123. package/src/session/messages.ts +21 -14
  124. package/src/session/session-manager.ts +98 -25
  125. package/src/session/yield-queue.ts +20 -2
  126. package/src/task/executor.ts +72 -36
  127. package/src/task/render.ts +3 -4
  128. package/src/tiny/title-client.ts +6 -1
  129. package/src/tools/bash.ts +7 -7
  130. package/src/tools/browser/tab-supervisor.ts +13 -1
  131. package/src/tools/browser/tab-worker.ts +33 -4
  132. package/src/tools/eval-render.ts +4 -23
  133. package/src/tools/eval.ts +13 -2
  134. package/src/tools/find.ts +148 -99
  135. package/src/tools/gh-cache-invalidation.ts +200 -0
  136. package/src/tools/github-cache.ts +25 -0
  137. package/src/tools/index.ts +32 -0
  138. package/src/tools/inspect-image.ts +2 -2
  139. package/src/tools/path-utils.ts +47 -24
  140. package/src/tools/plan-mode-guard.ts +52 -7
  141. package/src/tools/read.ts +41 -20
  142. package/src/tools/render-utils.ts +3 -1
  143. package/src/tools/renderers.ts +0 -15
  144. package/src/tools/search.ts +38 -3
  145. package/src/tools/ssh.ts +0 -1
  146. package/src/tools/todo.ts +1 -0
  147. package/src/tools/write.ts +5 -14
  148. package/src/tools/yield.ts +10 -1
  149. package/src/tui/code-cell.ts +1 -6
  150. package/src/tui/hyperlink.ts +13 -23
  151. package/src/tui/output-block.ts +2 -97
  152. package/src/utils/commit-message-generator.ts +2 -2
  153. package/src/utils/enhanced-paste.ts +30 -2
  154. package/src/web/search/providers/codex.ts +37 -8
@@ -117,6 +117,29 @@ export type {
117
117
  DiscoverableToolSource,
118
118
  } from "../tool-discovery/tool-index";
119
119
 
120
+ /**
121
+ * A late LSP diagnostics result that arrived after the edit/write tool already
122
+ * returned. Surfaced to the model and the transcript via
123
+ * {@link ToolSession.queueDeferredDiagnostics}, batched through the session
124
+ * yield queue like background-job results.
125
+ */
126
+ export interface DeferredDiagnosticsEntry {
127
+ /** Absolute path the diagnostics belong to (the renderer shortens it). */
128
+ path: string;
129
+ /** One-line severity summary, e.g. "2 errors". */
130
+ summary: string;
131
+ /** Formatted, ready-to-display diagnostic lines. */
132
+ messages: string[];
133
+ /** True when any message is error severity. */
134
+ errored: boolean;
135
+ /**
136
+ * Evaluated at injection time (in the dispatcher's stale check): drop the entry
137
+ * when a newer mutation to the same file has superseded it, so the model never
138
+ * sees diagnostics for stale content.
139
+ */
140
+ isStale(): boolean;
141
+ }
142
+
120
143
  /** Session context for tool factories */
121
144
  export interface ToolSession {
122
145
  /** Current working directory */
@@ -284,6 +307,15 @@ export interface ToolSession {
284
307
 
285
308
  /** Queue a hidden message to be injected at the next agent turn. */
286
309
  queueDeferredMessage?(message: CustomMessage): void;
310
+ /** Queue late LSP diagnostics (arrived after an edit/write returned) to be shown
311
+ * in the transcript and delivered to the model at the next yield, like background
312
+ * job results. */
313
+ queueDeferredDiagnostics?(entry: DeferredDiagnosticsEntry): void;
314
+ /** Bump and return the session-global mutation counter for `path`. Edit/write
315
+ * tools call this on every file mutation so stale late-diagnostics can be dropped. */
316
+ bumpFileMutationVersion?(path: string): number;
317
+ /** Read the current session-global mutation counter for `path` (0 if never mutated). */
318
+ getFileMutationVersion?(path: string): number;
287
319
  /** Get the active OpenTelemetry config so subagent dispatch can forward
288
320
  * the parent's tracer/hooks with the subagent's own identity stamped. */
289
321
  getTelemetry?: () => AgentTelemetryConfig | undefined;
@@ -5,7 +5,7 @@ import { prompt } from "@oh-my-pi/pi-utils";
5
5
  import * as z from "zod/v4";
6
6
  import { extractTextContent } from "../commit/utils";
7
7
 
8
- import { expandRoleAlias, resolveModelFromString } from "../config/model-resolver";
8
+ import { expandRoleAlias, getModelMatchPreferences, resolveModelFromString } from "../config/model-resolver";
9
9
  import inspectImageDescription from "../prompts/tools/inspect-image.md" with { type: "text" };
10
10
  import inspectImageSystemPromptTemplate from "../prompts/tools/inspect-image-system.md" with { type: "text" };
11
11
  import {
@@ -72,7 +72,7 @@ export class InspectImageTool implements AgentTool<typeof inspectImageSchema, In
72
72
  throw new ToolError("No models available for inspect_image.");
73
73
  }
74
74
 
75
- const matchPreferences = { usageOrder: this.session.settings.getStorage()?.getModelUsageOrder() };
75
+ const matchPreferences = getModelMatchPreferences(this.session.settings);
76
76
  const resolvePattern = (pattern: string | undefined): Model<Api> | undefined => {
77
77
  if (!pattern) return undefined;
78
78
  const expanded = expandRoleAlias(pattern, this.session.settings);
@@ -572,9 +572,14 @@ export interface ResolvedMultiSearchPath {
572
572
  targets?: ResolvedSearchTarget[];
573
573
  }
574
574
 
575
- export interface ResolvedMultiFindPattern {
575
+ export interface ResolvedFindTarget {
576
576
  basePath: string;
577
577
  globPattern: string;
578
+ hasGlob: boolean;
579
+ }
580
+
581
+ export interface ResolvedMultiFindPattern {
582
+ targets: ResolvedFindTarget[];
578
583
  scopePath: string;
579
584
  }
580
585
 
@@ -601,6 +606,23 @@ export function parseSearchPath(filePath: string): ParsedSearchPath {
601
606
  };
602
607
  }
603
608
 
609
+ /**
610
+ * Async sibling of {@link parseSearchPath} that prefers literal interpretation
611
+ * when a path containing glob metacharacters resolves to an existing entry on
612
+ * disk. Disambiguates Next.js/SvelteKit routes like `apps/[id]/page.tsx` —
613
+ * without this, `[id]` is parsed as a glob character class and silently
614
+ * matches nothing.
615
+ */
616
+ export async function parseSearchPathPreferringLiteral(filePath: string, cwd: string): Promise<ParsedSearchPath> {
617
+ if (!hasGlobPathChars(filePath) || isInternalUrlPath(filePath)) return parseSearchPath(filePath);
618
+ try {
619
+ await fs.promises.stat(resolveToCwd(filePath, cwd));
620
+ return { basePath: filePath };
621
+ } catch {
622
+ return parseSearchPath(filePath);
623
+ }
624
+ }
625
+
604
626
  // Parse a find pattern into a base directory path and a glob pattern.
605
627
  // Examples:
606
628
  // src/app/**/\*.tsx -> { basePath: "src/app", globPattern: "**/*.tsx", hasGlob: true }
@@ -707,7 +729,7 @@ async function resolveSearchPathItems(
707
729
 
708
730
  const parsedItems = await Promise.all(
709
731
  pathItems.map(async item => {
710
- const parsedPath = parseSearchPath(item);
732
+ const parsedPath = await parseSearchPathPreferringLiteral(item, cwd);
711
733
  const absoluteBasePath = resolveToCwd(parsedPath.basePath, cwd);
712
734
  const stat = await fs.promises.stat(absoluteBasePath);
713
735
  return { raw: item, parsedPath, absoluteBasePath, stat };
@@ -765,30 +787,22 @@ async function resolveFindPatternItems(
765
787
  return undefined;
766
788
  }
767
789
 
768
- const parsedItems = await Promise.all(
769
- patternItems.map(async item => {
770
- const parsedPattern = parseFindPattern(item);
771
- const absoluteBasePath = resolveToCwd(parsedPattern.basePath, cwd);
772
- const stat = await fs.promises.stat(absoluteBasePath);
773
- return { raw: item, parsedPattern, absoluteBasePath, stat };
774
- }),
775
- );
776
-
777
- const commonBasePath = findCommonBasePath(parsedItems.map(item => item.absoluteBasePath));
778
- const combinedPatterns = parsedItems.map(item => {
779
- const relativeBasePath = normalizePosixPath(path.relative(commonBasePath, item.absoluteBasePath)) || ".";
780
- if (item.parsedPattern.hasGlob) {
781
- return joinRelativeGlob(relativeBasePath, item.parsedPattern.globPattern);
782
- }
783
- if (item.stat.isDirectory()) {
784
- return joinRelativeGlob(relativeBasePath, "**/*");
785
- }
786
- return relativeBasePath === "." ? path.basename(item.absoluteBasePath) : relativeBasePath;
790
+ // Each path becomes its own walk root. Collapsing to a shared common ancestor
791
+ // (and filtering with a brace-union glob) would force the walker to traverse
792
+ // and stat every unrelated sibling under that ancestor — two paths under
793
+ // $HOME would scan all of $HOME. The find tool fans these targets out in
794
+ // parallel instead, so every scan stays bounded to exactly one requested path.
795
+ const targets = patternItems.map(item => {
796
+ const parsedPattern = parseFindPattern(item);
797
+ return {
798
+ basePath: resolveToCwd(parsedPattern.basePath, cwd),
799
+ globPattern: parsedPattern.globPattern,
800
+ hasGlob: parsedPattern.hasGlob,
801
+ };
787
802
  });
788
803
 
789
804
  return {
790
- basePath: commonBasePath,
791
- globPattern: buildBraceUnion(combinedPatterns) ?? "**/*",
805
+ targets,
792
806
  scopePath: toScopeDisplay(patternItems, cwd),
793
807
  };
794
808
  }
@@ -946,6 +960,15 @@ export async function resolveToolSearchScope(opts: ToolScopeOptions): Promise<To
946
960
  if (rawPaths.some(rawPath => rawPath.length === 0)) {
947
961
  throw new ToolError("`paths` must contain non-empty paths or globs");
948
962
  }
963
+ // External (http/https/ftp/file) URLs are not searchable; route the caller
964
+ // to `read` instead of letting the path-resolver surface a confusing
965
+ // "Path not found" for a slash-stripped URL.
966
+ const externalUrl = rawPaths.find(rawPath => /^(?:https?|ftp|file|ws|wss):\/\//i.test(rawPath));
967
+ if (externalUrl) {
968
+ throw new ToolError(
969
+ `Cannot ${internalUrlAction} external URL: ${externalUrl}. Use \`read\` to fetch web content, then search the returned text.`,
970
+ );
971
+ }
949
972
  const internalRouter = InternalUrlRouter.instance();
950
973
  const resolvedPathInputs: string[] = [];
951
974
  const immutableSourcePaths = new Set<string>();
@@ -989,7 +1012,7 @@ export async function resolveToolSearchScope(opts: ToolScopeOptions): Promise<To
989
1012
  let multiTargets: ResolvedSearchTarget[] | undefined;
990
1013
  let exactFilePaths: string[] | undefined;
991
1014
  if (effectivePaths.length === 1) {
992
- const parsedPath = parseSearchPath(effectivePaths[0] ?? ".");
1015
+ const parsedPath = await parseSearchPathPreferringLiteral(effectivePaths[0] ?? ".", cwd);
993
1016
  searchPath = resolveToCwd(parsedPath.basePath, cwd);
994
1017
  globFilter = parsedPath.glob;
995
1018
  scopePath = formatPathRelativeToCwd(searchPath, cwd);
@@ -1,4 +1,6 @@
1
- import { resolveLocalUrlToPath, resolveVaultUrlToPath } from "../internal-urls";
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { resolveLocalRoot, resolveLocalUrlToPath, resolveVaultUrlToPath } from "../internal-urls";
2
4
  import type { ToolSession } from ".";
3
5
  import { normalizeLocalScheme, resolveToCwd } from "./path-utils";
4
6
  import { ToolError } from "./tool-errors";
@@ -6,11 +8,54 @@ import { ToolError } from "./tool-errors";
6
8
  const VAULT_SCHEME_PREFIX = "vault:";
7
9
  const LOCAL_SCHEME_PREFIX = "local:";
8
10
 
9
- /** True when `targetPath` addresses the session-local artifact sandbox
10
- * (`local://…`). Those files are not part of the working tree, so plan mode
11
- * treats them as freely writable scratch/plan space. */
12
- function targetsLocalSandbox(targetPath: string): boolean {
13
- return normalizeLocalScheme(targetPath).startsWith(LOCAL_SCHEME_PREFIX);
11
+ /** Resolve the absolute path of the session's `local://` artifact sandbox.
12
+ * Returns `null` when the session has no artifact wiring (e.g. tests). */
13
+ function localSandboxRoot(session: ToolSession): string | null {
14
+ try {
15
+ return path.resolve(
16
+ resolveLocalRoot({
17
+ getArtifactsDir: session.getArtifactsDir,
18
+ getSessionId: session.getSessionId,
19
+ }),
20
+ );
21
+ } catch {
22
+ return null;
23
+ }
24
+ }
25
+
26
+ /** True when `absolutePath` resolves inside `root` (== root or under it). */
27
+ function isWithinRoot(absolutePath: string, root: string): boolean {
28
+ if (absolutePath === root) return true;
29
+ const sep = `${root}${path.sep}`;
30
+ return absolutePath.startsWith(sep);
31
+ }
32
+
33
+ /** True when `targetPath` addresses the session-local artifact sandbox.
34
+ * Accepts both `local://…` URLs and absolute paths pointing inside the
35
+ * resolved sandbox root — the latter is what `read local://…` echoes back
36
+ * in the `[path#tag]` header. Those files are not part of the working tree,
37
+ * so plan mode treats them as freely writable scratch/plan space. */
38
+ function targetsLocalSandbox(session: ToolSession, targetPath: string): boolean {
39
+ const normalized = normalizeLocalScheme(targetPath);
40
+ if (normalized.startsWith(LOCAL_SCHEME_PREFIX)) return true;
41
+ if (!path.isAbsolute(normalized)) return false;
42
+ const root = localSandboxRoot(session);
43
+ if (!root) return false;
44
+ // Compare both raw and realpath-normalized forms so that
45
+ // `/tmp/…` vs `/private/tmp/…` (macOS) and other symlink-collapsed
46
+ // roots both resolve to the same sandbox identity.
47
+ const resolved = path.resolve(normalized);
48
+ if (isWithinRoot(resolved, root)) return true;
49
+ try {
50
+ const realRoot = fs.realpathSync.native(root);
51
+ if (isWithinRoot(resolved, realRoot)) return true;
52
+ // `resolved` itself may live in `/tmp/...` while `realRoot` is `/private/tmp/...`;
53
+ // realpath the parent dir of `resolved` so we catch that direction too.
54
+ const realParent = fs.realpathSync.native(path.dirname(resolved));
55
+ return isWithinRoot(path.join(realParent, path.basename(resolved)), realRoot);
56
+ } catch {
57
+ return false;
58
+ }
14
59
  }
15
60
 
16
61
  /**
@@ -55,7 +100,7 @@ export function enforcePlanModeWrite(
55
100
  throw new ToolError("Plan mode: deleting files is not allowed.");
56
101
  }
57
102
 
58
- if (targetsLocalSandbox(targetPath)) return;
103
+ if (targetsLocalSandbox(session, targetPath)) return;
59
104
 
60
105
  throw new ToolError(
61
106
  "Plan mode: the working tree is read-only. Write your plan to a local://<slug>-plan.md file instead.",
package/src/tools/read.ts CHANGED
@@ -9,7 +9,7 @@ import type { Component } from "@oh-my-pi/pi-tui";
9
9
  import { Text } from "@oh-my-pi/pi-tui";
10
10
  import { getRemoteDir, logger, prompt, readImageMetadata, untilAborted } from "@oh-my-pi/pi-utils";
11
11
  import * as z from "zod/v4";
12
- import { getFileSnapshotStore, recordFileSnapshot } from "../edit/file-snapshot-store";
12
+ import { canonicalSnapshotKey, getFileSnapshotStore, recordFileSnapshot } from "../edit/file-snapshot-store";
13
13
  import { normalizeToLF } from "../edit/normalize";
14
14
  import { isNotebookPath, readEditableNotebookText } from "../edit/notebook";
15
15
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
@@ -131,7 +131,7 @@ function recordFullHashlineContext(
131
131
  ): HashlineHeaderContext | undefined {
132
132
  if (!absolutePath || !path.isAbsolute(absolutePath)) return undefined;
133
133
  const normalized = normalizeToLF(fullText);
134
- const tag = getFileSnapshotStore(session).record(absolutePath, normalized);
134
+ const tag = getFileSnapshotStore(session).record(canonicalSnapshotKey(absolutePath), normalized);
135
135
  return {
136
136
  header: formatHashlineHeader(displayPath, tag),
137
137
  tag,
@@ -575,6 +575,8 @@ export interface ReadToolDetails {
575
575
  summary?: { lines: number; elidedSpans: number; elidedLines: number };
576
576
  /** Number of unresolved git conflicts surfaced by this read (TUI uses for inline `⚠ N` badge). */
577
577
  conflictCount?: number;
578
+ /** Paths recovered from a delimited read argument; used only by the TUI to render one call as multiple read rows. */
579
+ displayReadTargets?: string[];
578
580
  }
579
581
 
580
582
  type ReadParams = ReadToolInput;
@@ -670,7 +672,6 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
670
672
  readonly loadMode = "essential";
671
673
  readonly description: string;
672
674
  readonly parameters = readSchema;
673
- readonly nonAbortable = true;
674
675
  readonly strict = true;
675
676
 
676
677
  readonly #autoResizeImages: boolean;
@@ -704,6 +705,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
704
705
  const notice = `Note: interpreted as ${parts.length} paths: ${parts.join(", ")}`;
705
706
  const notes = [notice];
706
707
  const content: Array<TextContent | ImageContent> = [];
708
+ const displayReadTargets: string[] = [];
707
709
  let pendingText = notice;
708
710
  const flushText = () => {
709
711
  if (pendingText.length === 0) return;
@@ -717,6 +719,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
717
719
  for (const part of parts) {
718
720
  try {
719
721
  const result = await this.execute("read-delimited-part", { path: part }, signal);
722
+ displayReadTargets.push(result.details?.suffixResolution?.to ?? part);
720
723
  for (const block of result.content) {
721
724
  if (block.type === "text") {
722
725
  appendText(block.text);
@@ -730,12 +733,13 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
730
733
  const message = error instanceof Error ? error.message : String(error);
731
734
  const errorNote = `Could not read ${part}: ${message}`;
732
735
  notes.push(errorNote);
736
+ displayReadTargets.push(part);
733
737
  appendText(`[${errorNote}]`);
734
738
  }
735
739
  }
736
740
  flushText();
737
741
 
738
- return toolResult<ReadToolDetails>({ notes }).content(content).done();
742
+ return toolResult<ReadToolDetails>({ notes, displayReadTargets }).content(content).done();
739
743
  }
740
744
 
741
745
  async #resolveArchiveReadPath(readPath: string, signal?: AbortSignal): Promise<ResolvedArchiveReadPath | null> {
@@ -1648,7 +1652,9 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1648
1652
  throw new ToolError("Multi-range line selectors are not supported for directory listings.");
1649
1653
  }
1650
1654
  const { offset, limit } = selToOffsetLimit(parsed);
1651
- const dirResult = await this.#readDirectory(absolutePath, offset, limit, signal);
1655
+ // Directory listings are deterministic and fast; never abort them mid-scan
1656
+ // (an interrupt would otherwise surface a misleading "Operation aborted").
1657
+ const dirResult = await this.#readDirectory(absolutePath, offset, limit, undefined);
1652
1658
  if (suffixResolution) {
1653
1659
  dirResult.details ??= {};
1654
1660
  dirResult.details.suffixResolution = suffixResolution;
@@ -1750,15 +1756,25 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1750
1756
  // Convert document via markit.
1751
1757
  const result = await convertFileWithMarkit(absolutePath, signal);
1752
1758
  if (result.ok) {
1753
- // Apply truncation to converted content
1754
- const truncation = truncateHead(result.content);
1755
- const outputText = truncation.content;
1756
-
1757
- details = { truncation };
1758
- sourcePath = absolutePath;
1759
- truncationInfo = { result: truncation, options: { direction: "head", startLine: 1 } };
1760
-
1761
- content = [{ type: "text", text: outputText }];
1759
+ // Route the converted markdown through the in-memory text builder
1760
+ // so line-range selectors (`file.pdf:50-100`, `:5-16,40-80`) and
1761
+ // raw mode apply against the converted output. Without this,
1762
+ // `file.pdf:50-100` silently returned the head of the document
1763
+ // because only `truncateHead` was being applied.
1764
+ if (isMultiRange(parsed) && parsed.kind === "lines") {
1765
+ return this.#buildInMemoryMultiRangeResult(result.content, parsed.ranges, {
1766
+ details: { resolvedPath: absolutePath },
1767
+ sourcePath: absolutePath,
1768
+ entityLabel: "document",
1769
+ });
1770
+ }
1771
+ const { offset, limit } = selToOffsetLimit(parsed);
1772
+ return this.#buildInMemoryTextResult(result.content, offset, limit, {
1773
+ details: { resolvedPath: absolutePath },
1774
+ sourcePath: absolutePath,
1775
+ entityLabel: "document",
1776
+ raw: isRawSelector(parsed),
1777
+ });
1762
1778
  } else if (result.error) {
1763
1779
  content = [{ type: "text", text: `[Cannot read ${ext} file: ${result.error || "conversion failed"}]` }];
1764
1780
  } else {
@@ -1805,7 +1821,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1805
1821
  parsed,
1806
1822
  displayMode,
1807
1823
  suffixResolution,
1808
- signal,
1824
+ undefined, // plain-file read: deterministic and fast, never abort mid-read
1809
1825
  );
1810
1826
  if (multiResult.bridgeResult) return multiResult.bridgeResult;
1811
1827
  content = [{ type: "text", text: multiResult.outputText }];
@@ -1864,7 +1880,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1864
1880
  maxLinesToCollect,
1865
1881
  maxBytesForRead,
1866
1882
  selectedLineLimit,
1867
- signal,
1883
+ undefined, // plain-file read: deterministic and fast, never abort mid-read
1868
1884
  );
1869
1885
 
1870
1886
  const {
@@ -1944,7 +1960,10 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1944
1960
  // full file and any anchor validates while the file is unchanged.
1945
1961
  const isWholeFile = offset === undefined && limit === undefined && !wasTruncated;
1946
1962
  const tag = isWholeFile
1947
- ? getFileSnapshotStore(this.session).record(absolutePath, normalizeToLF(collectedLines.join("\n")))
1963
+ ? getFileSnapshotStore(this.session).record(
1964
+ canonicalSnapshotKey(absolutePath),
1965
+ normalizeToLF(collectedLines.join("\n")),
1966
+ )
1948
1967
  : await recordFileSnapshot(this.session, absolutePath);
1949
1968
  if (tag) {
1950
1969
  hashContext = hashlineHeaderContext(formatPathRelativeToCwd(absolutePath, this.session.cwd), tag);
@@ -2355,11 +2374,13 @@ function formatReadPathLink(
2355
2374
  const plainDisplayPath = options.suffixResolution
2356
2375
  ? shortenPath(options.suffixResolution.to)
2357
2376
  : shortenPath(basePath || options.resolvedPath || options.fallbackLabel || rawPath);
2358
- const target = options.resolvedPath ?? options.sourcePath ?? tryResolveInternalUrlSync(basePath);
2377
+ const absoluteInputPath = path.isAbsolute(basePath) ? basePath : undefined;
2378
+ const target =
2379
+ options.resolvedPath ?? options.sourcePath ?? tryResolveInternalUrlSync(basePath) ?? absoluteInputPath;
2359
2380
  const line = firstReadSelectorLine(split.sel) ?? options.offset;
2360
2381
  const linkOptions = line !== undefined ? { line } : undefined;
2361
- const displayPath = target ? fileHyperlink(target, plainDisplayPath, linkOptions) : plainDisplayPath;
2362
- return `${displayPath}${selectorSuffix}`;
2382
+ const linkedPath = target ? fileHyperlink(target, plainDisplayPath, linkOptions) : plainDisplayPath;
2383
+ return `${linkedPath}${selectorSuffix}`;
2363
2384
  }
2364
2385
 
2365
2386
  export const readToolRenderer = {
@@ -338,6 +338,7 @@ export function formatDiagnostics(
338
338
  expanded: boolean,
339
339
  theme: Theme,
340
340
  getLangIcon: (filePath: string) => string,
341
+ options?: { title?: string },
341
342
  ): string {
342
343
  if (diag.messages.length === 0) return "";
343
344
 
@@ -369,7 +370,8 @@ export function formatDiagnostics(
369
370
  ? theme.styledSymbol("status.error", "error")
370
371
  : theme.styledSymbol("status.warning", "warning");
371
372
  const summary = sanitizeDiagnosticDisplayText(diag.summary);
372
- let output = `\n\n${headerIcon} ${theme.fg("toolTitle", "Diagnostics")} ${theme.fg("dim", `(${summary})`)}`;
373
+ const summaryTag = summary ? ` ${theme.fg("dim", `(${summary})`)}` : "";
374
+ let output = `\n\n${headerIcon} ${theme.fg("toolTitle", options?.title ?? "Diagnostics")}${summaryTag}`;
373
375
 
374
376
  const maxDiags = expanded ? diag.messages.length : 5;
375
377
  let diagsShown = 0;
@@ -40,21 +40,6 @@ export type ToolRenderer = {
40
40
  args?: unknown,
41
41
  ) => Component;
42
42
  mergeCallAndResult?: boolean;
43
- /**
44
- * While a tool's preview is still streaming, report whether the
45
- * currently-rendered preview is append-only: its rows only grow at the bottom
46
- * and never re-layout above the bottom live region (a full, top-anchored
47
- * content/code preview). The transcript reports this up to the TUI so a
48
- * streaming preview taller than the viewport commits its scrolled-off head to
49
- * native scrollback instead of dropping it (see
50
- * `ToolExecutionComponent.isTranscriptBlockAppendOnly`). `result` is the
51
- * latest (possibly partial) tool result, or `undefined` before one exists —
52
- * `eval`/`bash` use its presence to defer committing until the streamed input
53
- * (code) has finalized. Omit (or return `false`) for previews that slide a
54
- * tail window or later collapse to a compact result — committing their head
55
- * would strand stale rows.
56
- */
57
- isStreamingPreviewAppendOnly?: (args: unknown, options: RenderResultOptions, result?: unknown) => boolean;
58
43
  /** Render without background box, inline in the response flow */
59
44
  inline?: boolean;
60
45
  };
@@ -83,6 +83,7 @@ const searchSchema = z
83
83
  gitignore: z.boolean().optional().describe("respect gitignore"),
84
84
  skip: z
85
85
  .number()
86
+ .nullable()
86
87
  .optional()
87
88
  .describe("files to skip before collecting results — use to paginate when the prior call hit the file limit"),
88
89
  })
@@ -107,6 +108,10 @@ export const SINGLE_FILE_MATCHES = 200;
107
108
  * (DEFAULT_FILE_LIMIT files × MULTI_FILE_PER_FILE_MATCHES matches) plus
108
109
  * pagination headroom so the caller can see total file count. */
109
110
  const INTERNAL_TOTAL_CAP = 2000;
111
+ /** Mirrors `MAX_FILE_BYTES` in `crates/pi-natives/src/grep.rs`. Native grep
112
+ * silently returns no matches for files larger than this; surface a warning
113
+ * when the caller explicitly targeted such a file so they know to chunk it. */
114
+ const NATIVE_GREP_MAX_FILE_BYTES = 4 * 1024 * 1024;
110
115
 
111
116
  /**
112
117
  * Parsed `paths` entry — a path (possibly archive-shaped) plus an optional
@@ -666,7 +671,8 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
666
671
  throw new ToolError("Pattern must not be empty");
667
672
  }
668
673
 
669
- const normalizedSkip = skip === undefined ? 0 : Number.isFinite(skip) ? Math.floor(skip) : Number.NaN;
674
+ const normalizedSkip =
675
+ skip === undefined || skip === null ? 0 : Number.isFinite(skip) ? Math.floor(skip) : Number.NaN;
670
676
  if (normalizedSkip < 0 || !Number.isFinite(normalizedSkip)) {
671
677
  throw new ToolError("Skip must be a non-negative number");
672
678
  }
@@ -728,7 +734,7 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
728
734
  // reason instead of a downstream "path not found" from the scope resolver.
729
735
  throw new ToolError(
730
736
  `Cannot search archive member(s): ${archiveUnreadable.join(", ")}. ` +
731
- `Read the file directly with \`read <archive>:<member>\` and grep the returned content, ` +
737
+ `Read the member with \`read <archive>:<member>\` and inspect the returned text, ` +
732
738
  `or pass a UTF-8 text member.`,
733
739
  );
734
740
  }
@@ -991,6 +997,34 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
991
997
  : "";
992
998
  const { record: recordFile, list: fileList } = createFileRecorder();
993
999
  const fileMatchCounts = new Map<string, number>();
1000
+ // Detect explicit file targets that exceed the native grep size cap.
1001
+ // Native silently returns no matches above the cap; without this note the
1002
+ // caller sees "no matches" for a literal pattern that visibly exists.
1003
+ const oversizedNote = await (async (): Promise<string | undefined> => {
1004
+ const explicitFileTargets: string[] = [];
1005
+ if (exactFilePaths) {
1006
+ explicitFileTargets.push(...exactFilePaths);
1007
+ } else if (searchablePaths.length > 0 && !isDirectory && !multiTargets) {
1008
+ explicitFileTargets.push(searchPath);
1009
+ }
1010
+ if (explicitFileTargets.length === 0) return undefined;
1011
+ const oversized: string[] = [];
1012
+ await Promise.all(
1013
+ explicitFileTargets.map(async target => {
1014
+ try {
1015
+ const st = await stat(target);
1016
+ if (st.isFile() && st.size > NATIVE_GREP_MAX_FILE_BYTES) {
1017
+ oversized.push(path.relative(this.session.cwd, target) || target);
1018
+ }
1019
+ } catch {
1020
+ // Stat failures here are surfaced by other code paths.
1021
+ }
1022
+ }),
1023
+ );
1024
+ if (oversized.length === 0) return undefined;
1025
+ const limitMb = Math.floor(NATIVE_GREP_MAX_FILE_BYTES / (1024 * 1024));
1026
+ return `Skipped oversized files (>${limitMb}MB grep limit; split the file or narrow with \`read\`): ${oversized.join(", ")}`;
1027
+ })();
994
1028
  const archiveNote =
995
1029
  archiveUnreadable.length > 0
996
1030
  ? `Skipped archive entries (search supports text members only): ${archiveUnreadable.join(", ")}`
@@ -1002,7 +1036,8 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
1002
1036
  const missingPathsNote =
1003
1037
  missingPathsForNote.length > 0 ? `Skipped missing paths: ${missingPathsForNote.join(", ")}` : undefined;
1004
1038
  const warningNote =
1005
- [missingPathsNote, archiveNote].filter((s): s is string => Boolean(s)).join("\n") || undefined;
1039
+ [missingPathsNote, archiveNote, oversizedNote].filter((s): s is string => Boolean(s)).join("\n") ||
1040
+ undefined;
1006
1041
  if (selectedMatches.length === 0) {
1007
1042
  const details: SearchToolDetails = {
1008
1043
  scopePath,
package/src/tools/ssh.ts CHANGED
@@ -252,7 +252,6 @@ export const sshToolRenderer = {
252
252
  state: "pending",
253
253
  sections: [{ lines: capPreviewLines(cmdLines, uiTheme, { expanded: _options.expanded }) }],
254
254
  width,
255
- animate: true,
256
255
  },
257
256
  uiTheme,
258
257
  ),
package/src/tools/todo.ts CHANGED
@@ -927,6 +927,7 @@ export const todoToolRenderer = {
927
927
  sections: bodyLines.length > 0 ? [{ lines: bodyLines }] : [],
928
928
  state: options.isPartial ? "pending" : "success",
929
929
  borderColor: "borderMuted",
930
+ applyBg: false,
930
931
  width,
931
932
  };
932
933
  });
@@ -8,7 +8,7 @@ import type { Component } from "@oh-my-pi/pi-tui";
8
8
  import { isEnoent, isRecord, prompt, untilAborted } from "@oh-my-pi/pi-utils";
9
9
  import * as z from "zod/v4";
10
10
 
11
- import { getFileSnapshotStore } from "../edit/file-snapshot-store";
11
+ import { canonicalSnapshotKey, getFileSnapshotStore } from "../edit/file-snapshot-store";
12
12
  import { normalizeToLF } from "../edit/normalize";
13
13
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
14
14
  import { InternalUrlRouter } from "../internal-urls";
@@ -132,7 +132,7 @@ function stripWriteContent(session: ToolSession, content: string): { text: strin
132
132
  function maybeWriteSnapshotHeader(session: ToolSession, absolutePath: string, content: string): string | undefined {
133
133
  if (!resolveFileDisplayMode(session).hashLines) return undefined;
134
134
  const normalized = normalizeToLF(content);
135
- const tag = getFileSnapshotStore(session).record(absolutePath, normalized);
135
+ const tag = getFileSnapshotStore(session).record(canonicalSnapshotKey(absolutePath), normalized);
136
136
  return formatHashlineHeader(formatPathRelativeToCwd(absolutePath, session.cwd), tag);
137
137
  }
138
138
 
@@ -277,7 +277,6 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
277
277
  readonly label = "Write";
278
278
  readonly description: string;
279
279
  readonly parameters = writeSchema;
280
- readonly nonAbortable = true;
281
280
  readonly strict = true;
282
281
  readonly concurrency = "exclusive";
283
282
  readonly loadMode = "discoverable";
@@ -582,6 +581,7 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
582
581
  const batchRequest = getLspBatchRequest(context?.toolCall);
583
582
  const diagnostics = await this.#writethrough(absolutePath, newContent, signal, undefined, batchRequest);
584
583
  invalidateFsScanAfterWrite(absolutePath);
584
+ this.session.bumpFileMutationVersion?.(absolutePath);
585
585
  this.session.fileSnapshotStore?.invalidate(absolutePath);
586
586
  this.session.conflictHistory?.invalidate(entry.id);
587
587
 
@@ -707,6 +707,7 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
707
707
 
708
708
  const diagnostics = await this.#writethrough(absolutePath, text, signal, undefined, batchRequest);
709
709
  invalidateFsScanAfterWrite(absolutePath);
710
+ this.session.bumpFileMutationVersion?.(absolutePath);
710
711
  this.session.fileSnapshotStore?.invalidate(absolutePath);
711
712
  for (const entry of fileEntries) history.invalidate(entry.id);
712
713
  const header = maybeWriteSnapshotHeader(this.session, absolutePath, text);
@@ -886,6 +887,7 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
886
887
 
887
888
  const diagnostics = await this.#writethrough(absolutePath, cleanContent, signal, undefined, batchRequest);
888
889
  invalidateFsScanAfterWrite(absolutePath);
890
+ this.session.bumpFileMutationVersion?.(absolutePath);
889
891
  const madeExecutable = await maybeMarkExecutableForShebang(absolutePath, cleanContent);
890
892
 
891
893
  const displayPath = formatPathRelativeToCwd(absolutePath, this.session.cwd);
@@ -1039,17 +1041,6 @@ export const writeToolRenderer = {
1039
1041
  });
1040
1042
  },
1041
1043
 
1042
- // Only the expanded (Ctrl+O) preview is append-only: it renders the whole
1043
- // content top-anchored, so streamed chunks only append rows at the bottom.
1044
- // The collapsed preview slides a bounded tail window (`formatStreamingContent`
1045
- // with `WRITE_STREAMING_PREVIEW_LINES`) whose visible rows re-layout as the
1046
- // window moves — not append-only, but it never overflows the viewport, so its
1047
- // head is never at risk of being dropped regardless. `write` has no partial
1048
- // result (content streams as args), so `result` is ignored here.
1049
- isStreamingPreviewAppendOnly(args: WriteRenderArgs, options: RenderResultOptions, _result?: unknown): boolean {
1050
- return Boolean(options?.expanded && args.content);
1051
- },
1052
-
1053
1044
  renderResult(
1054
1045
  result: { content: Array<{ type: string; text?: string }>; details?: WriteToolDetails; isError?: boolean },
1055
1046
  options: RenderResultOptions,
@@ -20,6 +20,14 @@ export interface YieldDetails {
20
20
  data: unknown;
21
21
  status: "success" | "aborted";
22
22
  error?: string;
23
+ /**
24
+ * Set when the yield tool exhausted its in-tool schema-retry budget
25
+ * (MAX_SCHEMA_RETRIES) and accepted the data anyway. Surfaced so the
26
+ * executor's post-mortem finalizer can honor the override instead of
27
+ * re-rejecting the same payload with `schema_violation` — keeping the
28
+ * subagent's acceptance and the parent's view of the result in lockstep.
29
+ */
30
+ schemaOverridden?: boolean;
23
31
  }
24
32
 
25
33
  function formatSchema(schema: unknown): string {
@@ -237,7 +245,7 @@ export class YieldTool implements AgentTool<TSchema, YieldDetails> {
237
245
  : "Result submitted.";
238
246
  return {
239
247
  content: [{ type: "text", text: responseText }],
240
- details: { data, status, error: errorMessage },
248
+ details: { data, status, error: errorMessage, schemaOverridden: schemaValidationOverridden || undefined },
241
249
  };
242
250
  }
243
251
  }
@@ -254,6 +262,7 @@ subprocessToolRegistry.register<YieldDetails>("yield", {
254
262
  data: record.data,
255
263
  status,
256
264
  error: typeof record.error === "string" ? record.error : undefined,
265
+ schemaOverridden: record.schemaOverridden === true ? true : undefined,
257
266
  };
258
267
  },
259
268
  shouldTerminate: event => !event.isError,