@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
@@ -195,6 +195,7 @@ export class DebugSelectorComponent extends Container {
195
195
  const result = await createReportBundle({
196
196
  sessionFile: this.ctx.sessionManager.getSessionFile(),
197
197
  settings: this.#getResolvedSettings(),
198
+ rawSseText: this.#getRawSseText(),
198
199
  cpuProfile,
199
200
  workProfile,
200
201
  });
@@ -253,6 +254,7 @@ export class DebugSelectorComponent extends Container {
253
254
  const result = await createReportBundle({
254
255
  sessionFile: this.ctx.sessionManager.getSessionFile(),
255
256
  settings: this.#getResolvedSettings(),
257
+ rawSseText: this.#getRawSseText(),
256
258
  });
257
259
 
258
260
  loader.stop();
@@ -288,6 +290,7 @@ export class DebugSelectorComponent extends Container {
288
290
  const result = await createReportBundle({
289
291
  sessionFile: this.ctx.sessionManager.getSessionFile(),
290
292
  settings: this.#getResolvedSettings(),
293
+ rawSseText: this.#getRawSseText(),
291
294
  heapSnapshot,
292
295
  });
293
296
 
@@ -490,6 +493,11 @@ export class DebugSelectorComponent extends Container {
490
493
  }
491
494
  }
492
495
 
496
+ #getRawSseText(): string | undefined {
497
+ const rawSseText = resolveRawSseDebugBuffer(this.ctx.session).toRawText();
498
+ return rawSseText.trim().length > 0 ? rawSseText : undefined;
499
+ }
500
+
493
501
  #getResolvedSettings(): Record<string, unknown> {
494
502
  // Extract key settings for the report
495
503
  return {
@@ -152,9 +152,9 @@ export class RawSseDebugBuffer {
152
152
  }
153
153
 
154
154
  // Ownership contract for `event.raw`:
155
- // The caller (either `notifyRawSseEvent` in `packages/ai/src/utils/sse-debug.ts`
156
- // or `SseTeeParser.#dispatch` directly) hands us a freshly-allocated
157
- // `string[]` per event and never retains, mutates, or re-dispatches it.
155
+ // The caller (`notifyRawSseEvent` in `packages/ai/src/utils/sse-debug.ts`)
156
+ // hands us a freshly-allocated `string[]` per event and never retains,
157
+ // mutates, or re-dispatches it.
158
158
  // That lets `trimRawLines` keep the array by reference instead of
159
159
  // cloning on every chunk — a measurable savings on the streaming hot
160
160
  // path. If a future observer-chain mutates the array, restore the
@@ -192,7 +192,10 @@ export class RawSseDebugBuffer {
192
192
  toRawText(): string {
193
193
  // Reads the live array directly: `rawRecordText` only computes a string
194
194
  // from each record, so no caller-visible mutation is possible.
195
- return this.#records.map(rawRecordText).join("\n");
195
+ const body = this.#records.map(rawRecordText).join("\n");
196
+ if (this.#droppedRecords === 0) return body;
197
+ const dropped = `: omp-debug-dropped records=${this.#droppedRecords} chars=${this.#droppedChars}\n\n`;
198
+ return body.length > 0 ? `${dropped}${body}` : dropped;
196
199
  }
197
200
 
198
201
  #append(record: RawSseDebugRecord, chars: number): void {
@@ -45,6 +45,8 @@ export interface ReportBundleOptions {
45
45
  heapSnapshot?: HeapSnapshot;
46
46
  /** Work profile (for work scheduling reports) */
47
47
  workProfile?: WorkProfile;
48
+ /** Raw provider SSE diagnostics captured by the session buffer */
49
+ rawSseText?: string;
48
50
  }
49
51
 
50
52
  export interface ReportBundleResult {
@@ -70,6 +72,7 @@ export interface DebugLogSource {
70
72
  * - env.json: Sanitized environment variables
71
73
  * - config.json: Resolved settings
72
74
  * - profile.cpuprofile: CPU profile (performance report only)
75
+ * - raw-sse.txt: Recent raw provider SSE diagnostics (when captured)
73
76
  * - profile.md: Markdown CPU profile (performance report only)
74
77
  * - heap.heapsnapshot: Heap snapshot (memory report only)
75
78
  * - work.folded: Work profile folded stacks (work report only)
@@ -109,6 +112,12 @@ export async function createReportBundle(options: ReportBundleOptions): Promise<
109
112
  files.push("logs.txt");
110
113
  }
111
114
 
115
+ // Recent raw provider SSE diagnostics
116
+ if (options.rawSseText && options.rawSseText.trim().length > 0) {
117
+ data["raw-sse.txt"] = options.rawSseText;
118
+ files.push("raw-sse.txt");
119
+ }
120
+
112
121
  // Session file
113
122
  if (options.sessionFile) {
114
123
  try {
@@ -8,6 +8,8 @@
8
8
  * from `@oh-my-pi/hashline`; the only coding-agent-specific concern here
9
9
  * is wiring it onto the per-session owner object.
10
10
  */
11
+ import * as fs from "node:fs";
12
+ import * as path from "node:path";
11
13
  import { InMemorySnapshotStore } from "@oh-my-pi/hashline";
12
14
  import { normalizeToLF } from "./normalize";
13
15
 
@@ -33,6 +35,36 @@ export function getFileSnapshotStore(session: FileSnapshotStoreOwner): InMemoryS
33
35
  return session.fileSnapshotStore;
34
36
  }
35
37
 
38
+ /**
39
+ * Canonicalize an absolute path into the stable key the snapshot store uses.
40
+ *
41
+ * Different code paths reach the snapshot store via different path forms:
42
+ * `read local://foo.md` records under the file's `fs.realpath` (the local
43
+ * protocol handler resolves symlinks); a subsequent `edit` may address the
44
+ * same artifact via `local://foo.md`, whose resolver does NOT realpath, or
45
+ * via the absolute path returned in the `[path#tag]` header. macOS adds the
46
+ * same hazard at the working-tree level (`/tmp/...` vs `/private/tmp/...`).
47
+ * Collapsing every key through `realpath` makes those forms fuse onto one
48
+ * snapshot entry, so a freshly-minted tag is never rejected as stale just
49
+ * because the lookup spelled the same file differently.
50
+ *
51
+ * Non-existent paths (new-file writes) fall back to a realpath of the parent
52
+ * directory + basename, then to the input. This keeps creates and updates on
53
+ * the same canonical key.
54
+ */
55
+ export function canonicalSnapshotKey(absolutePath: string): string {
56
+ try {
57
+ return fs.realpathSync.native(absolutePath);
58
+ } catch {
59
+ try {
60
+ const parent = fs.realpathSync.native(path.dirname(absolutePath));
61
+ return path.join(parent, path.basename(absolutePath));
62
+ } catch {
63
+ return absolutePath;
64
+ }
65
+ }
66
+ }
67
+
36
68
  /**
37
69
  * Read the full text of `absolutePath` (within {@link SNAPSHOT_MAX_BYTES}),
38
70
  * record it as a version snapshot, and return its content-hash tag. Returns
@@ -52,7 +84,7 @@ export async function recordFileSnapshot(
52
84
  const file = Bun.file(absolutePath);
53
85
  if (file.size > SNAPSHOT_MAX_BYTES) return undefined;
54
86
  const normalized = normalizeToLF(await file.text());
55
- return getFileSnapshotStore(session).record(absolutePath, normalized);
87
+ return getFileSnapshotStore(session).record(canonicalSnapshotKey(absolutePath), normalized);
56
88
  } catch {
57
89
  return undefined;
58
90
  }
@@ -12,6 +12,7 @@
12
12
  import {
13
13
  type ApplyResult,
14
14
  applyEdits,
15
+ type Cursor,
15
16
  computeFileHash,
16
17
  type Edit,
17
18
  Patch as HashlinePatch,
@@ -131,6 +132,86 @@ function applyPreviewEdits(args: {
131
132
  throw createMismatchError(section, absolutePath, normalized, snapshots, expected);
132
133
  }
133
134
 
135
+ /**
136
+ * Map an insert cursor to the 1-indexed line where its payload lands, used to
137
+ * number the `+` rows of a streaming preview. Deliberately approximate: it
138
+ * ignores line shifts introduced by sibling ops, because the args-complete
139
+ * pass renumbers everything through the real unified diff.
140
+ */
141
+ function insertCursorLine(cursor: Cursor, fileLineCount: number): number {
142
+ switch (cursor.kind) {
143
+ case "bof":
144
+ return 1;
145
+ case "eof":
146
+ return fileLineCount + 1;
147
+ case "before_anchor":
148
+ return cursor.anchor.line;
149
+ case "after_anchor":
150
+ return cursor.anchor.line + 1;
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Build a streaming diff preview by emitting, per op in patch order, the
156
+ * removed file lines followed by the op's `+` payload rows — never a whole-file
157
+ * Myers re-diff. {@link generateDiffString} re-aligns the in-flight payload
158
+ * against the removed block on every streamed chunk (it greedily matches shared
159
+ * `}`/blank/`return` rows), so additions jump between hunks and the tail window
160
+ * the renderer pins stutters tick to tick. Natural order keeps the removed
161
+ * block fixed and grows the payload monotonically at the bottom so the streamed
162
+ * cursor stays put. Mirrors the apply_patch streaming strategy; the
163
+ * args-complete pass still produces the real unified diff.
164
+ */
165
+ function buildStreamingSectionDiff(
166
+ section: PatchSection,
167
+ normalized: string,
168
+ ): { diff: string; firstChangedLine: number | undefined } | { error: string } {
169
+ const { edits } = parsePatchStreaming(section.diff);
170
+ const resolved = resolveBlockEdits(edits, normalized, section.path, nativeBlockResolver, { onUnresolved: "drop" });
171
+ if (resolved.length === 0) return { error: `No changes would be made to ${section.path}.` };
172
+
173
+ const fileLines = normalized.split("\n");
174
+ const rows: string[] = [];
175
+ let firstChangedLine: number | undefined;
176
+
177
+ // Every edit emitted from one op header carries that header's patch line
178
+ // number and the edits sit contiguously (a replace lays down its replacement
179
+ // inserts then its range deletes; block ops expand to the same shape). Group
180
+ // on that boundary so each op stays intact and ordered.
181
+ for (let i = 0; i < resolved.length; ) {
182
+ const opLine = resolved[i].lineNum;
183
+ const deletes: number[] = [];
184
+ const inserts: string[] = [];
185
+ let insertBase: number | undefined;
186
+ while (i < resolved.length && resolved[i].lineNum === opLine) {
187
+ const edit = resolved[i];
188
+ if (edit.kind === "delete") deletes.push(edit.anchor.line);
189
+ else if (edit.kind === "insert") {
190
+ insertBase ??= insertCursorLine(edit.cursor, fileLines.length);
191
+ inserts.push(edit.text);
192
+ }
193
+ i++;
194
+ }
195
+ // Removed lines first (a fixed block), payload second (grows at the
196
+ // bottom = the streamed cursor).
197
+ deletes.sort((a, b) => a - b);
198
+ for (const line of deletes) {
199
+ firstChangedLine ??= line;
200
+ const content = line >= 1 && line <= fileLines.length ? fileLines[line - 1] : "";
201
+ rows.push(`-${line}|${content}`);
202
+ }
203
+ let newLine = insertBase ?? deletes[0] ?? 1;
204
+ for (const text of inserts) {
205
+ firstChangedLine ??= newLine;
206
+ rows.push(`+${newLine}|${text}`);
207
+ newLine++;
208
+ }
209
+ }
210
+
211
+ if (rows.length === 0) return { error: `No changes would be made to ${section.path}.` };
212
+ return { diff: rows.join("\n"), firstChangedLine };
213
+ }
214
+
134
215
  export async function computeHashlineSectionDiff(
135
216
  section: PatchSection,
136
217
  cwd: string,
@@ -142,6 +223,11 @@ export async function computeHashlineSectionDiff(
142
223
  const rawContent = await readSectionText(absolutePath, section.path);
143
224
  const { text: content } = stripBom(rawContent);
144
225
  const normalized = normalizeToLF(content);
226
+ // Streaming favors a stable, monotonic preview over an exact unified
227
+ // diff: feed the in-flight ops through the natural-order builder so the
228
+ // streamed cursor stays pinned to the bottom. The args-complete pass
229
+ // (`streaming` unset) falls through to the real Myers diff below.
230
+ if (options.streaming) return buildStreamingSectionDiff(section, normalized);
145
231
  const result = applyPreviewEdits({ section, absolutePath, normalized, snapshots, options });
146
232
  if (normalized === result.text) return { error: `No changes would be made to ${section.path}.` };
147
233
  return generateDiffString(normalized, result.text);
@@ -11,6 +11,7 @@
11
11
  * round-trip once.
12
12
  */
13
13
  import {
14
+ type BlockResolution,
14
15
  buildCompactDiffPreview,
15
16
  MismatchError as HashlineMismatchError,
16
17
  Patch,
@@ -76,6 +77,14 @@ interface RenderedSection {
76
77
  perFileResult: EditToolPerFileResult;
77
78
  }
78
79
 
80
+ function formatBlockResolution(resolution: BlockResolution): string {
81
+ const op = resolution.isDelete ? "delete block" : "replace block";
82
+ const lines = resolution.end - resolution.start + 1;
83
+ const span =
84
+ resolution.start === resolution.end ? `line ${resolution.start}` : `lines ${resolution.start}-${resolution.end}`;
85
+ return `${op} ${resolution.anchorLine} → resolved ${span} (${lines} line${lines === 1 ? "" : "s"})`;
86
+ }
87
+
79
88
  function renderSection(result: PatchSectionResult, diagnostics: FileDiagnosticsResult | undefined): RenderedSection {
80
89
  if (result.op === "noop") {
81
90
  const toolResult: AgentToolResult<EditToolDetails, typeof hashlineEditParamsSchema> = {
@@ -96,10 +105,14 @@ function renderSection(result: PatchSectionResult, diagnostics: FileDiagnosticsR
96
105
 
97
106
  const warningsBlock = result.warnings.length > 0 ? `\n\nWarnings:\n${result.warnings.join("\n")}` : "";
98
107
  const previewBlock = preview.preview ? `\n${preview.preview}` : "";
108
+ const blockBlock =
109
+ result.blockResolutions && result.blockResolutions.length > 0
110
+ ? `\n${result.blockResolutions.map(formatBlockResolution).join("\n")}`
111
+ : "";
99
112
  const firstChangedLine = result.firstChangedLine ?? diff.firstChangedLine;
100
113
  return {
101
114
  toolResult: {
102
- content: [{ type: "text", text: `${result.header}${previewBlock}${warningsBlock}` }],
115
+ content: [{ type: "text", text: `${result.header}${blockBlock}${previewBlock}${warningsBlock}` }],
103
116
  details: {
104
117
  diff: diff.diff,
105
118
  firstChangedLine,
@@ -23,6 +23,7 @@ import type { ToolSession } from "../../tools";
23
23
  import { assertEditableFileContent } from "../../tools/auto-generated-guard";
24
24
  import { invalidateFsScanAfterWrite } from "../../tools/fs-cache-invalidation";
25
25
  import { enforcePlanModeWrite, resolvePlanPath } from "../../tools/plan-mode-guard";
26
+ import { canonicalSnapshotKey } from "../file-snapshot-store";
26
27
  import { readEditFileText, serializeEditFileText } from "../read-file";
27
28
  import type { LspBatchRequest } from "../renderer";
28
29
 
@@ -81,7 +82,7 @@ export class HashlineFilesystem extends Filesystem {
81
82
  }
82
83
 
83
84
  canonicalPath(relativePath: string): string {
84
- return this.resolveAbsolute(relativePath);
85
+ return canonicalSnapshotKey(this.resolveAbsolute(relativePath));
85
86
  }
86
87
 
87
88
  async readText(relativePath: string): Promise<string> {
package/src/edit/index.ts CHANGED
@@ -14,7 +14,7 @@ import { getDiagnosticsLedger } from "../lsp/diagnostics-ledger";
14
14
  import applyPatchDescription from "../prompts/tools/apply-patch.md" with { type: "text" };
15
15
  import patchDescription from "../prompts/tools/patch.md" with { type: "text" };
16
16
  import replaceDescription from "../prompts/tools/replace.md" with { type: "text" };
17
- import type { ToolSession } from "../tools";
17
+ import type { DeferredDiagnosticsEntry, ToolSession } from "../tools";
18
18
  import { truncateForPrompt } from "../tools/approval";
19
19
  import { isInternalUrlPath } from "../tools/path-utils";
20
20
  import { type EditMode, normalizeEditMode, resolveEditMode } from "../utils/edit-mode";
@@ -297,7 +297,6 @@ export class EditTool implements AgentTool<TInput> {
297
297
  readonly name = "edit";
298
298
  readonly label = "Edit";
299
299
  readonly loadMode = "essential";
300
- readonly nonAbortable = true;
301
300
  readonly concurrency = "exclusive";
302
301
  readonly strict = true;
303
302
 
@@ -307,6 +306,10 @@ export class EditTool implements AgentTool<TInput> {
307
306
  readonly #editMode?: EditMode;
308
307
  readonly #dedupDiagnostics: boolean;
309
308
  readonly #pendingDeferredFetches = new Map<string, AbortController>();
309
+ /** Fallback per-path mutation counter used only when the session does not expose
310
+ * a shared one. Prefer `session.bumpFileMutationVersion` so write (and any other
311
+ * tool) mutating the same file also invalidates pending late-diagnostics. */
312
+ readonly #editVersionByPath = new Map<string, number>();
310
313
 
311
314
  constructor(private readonly session: ToolSession) {
312
315
  const {
@@ -503,10 +506,11 @@ export class EditTool implements AgentTool<TInput> {
503
506
  }
504
507
 
505
508
  const deferredController = new AbortController();
509
+ const editVersion = this.#bumpFileVersion(path);
506
510
  return {
507
511
  onDeferredDiagnostics: (lateDiagnostics: FileDiagnosticsResult) => {
508
512
  this.#pendingDeferredFetches.delete(path);
509
- this.#injectLateDiagnostics(path, lateDiagnostics);
513
+ this.#injectLateDiagnostics(path, lateDiagnostics, editVersion);
510
514
  },
511
515
  signal: deferredController.signal,
512
516
  finalize: (diagnostics: FileDiagnosticsResult | undefined) => {
@@ -519,24 +523,34 @@ export class EditTool implements AgentTool<TInput> {
519
523
  };
520
524
  }
521
525
 
522
- #injectLateDiagnostics(path: string, diagnostics: FileDiagnosticsResult): void {
526
+ #injectLateDiagnostics(path: string, diagnostics: FileDiagnosticsResult, editVersion: number): void {
523
527
  const effective = this.#dedupDiagnostics
524
528
  ? getDiagnosticsLedger(this.session).reduce(path, diagnostics)
525
529
  : diagnostics;
526
530
  if (this.#dedupDiagnostics && effective.messages.length === 0) return;
527
531
 
528
- const summary = effective.summary ?? "";
529
- const lines = effective.messages ?? [];
530
- const body = [`Late LSP diagnostics for ${path} (arrived after the edit tool returned):`, summary, ...lines]
531
- .filter(Boolean)
532
- .join("\n");
533
-
534
- this.session.queueDeferredMessage?.({
535
- role: "custom",
536
- customType: "lsp-late-diagnostic",
537
- content: body,
538
- display: false,
539
- timestamp: Date.now(),
540
- });
532
+ const entry: DeferredDiagnosticsEntry = {
533
+ path,
534
+ summary: effective.summary ?? "",
535
+ messages: effective.messages ?? [],
536
+ errored: effective.errored,
537
+ // Drop at flush time if a later edit to the same file superseded this fetch.
538
+ isStale: () => this.#fileVersion(path) !== editVersion,
539
+ };
540
+ this.session.queueDeferredDiagnostics?.(entry);
541
+ }
542
+
543
+ /** Bump the file's mutation counter (session-global when available). */
544
+ #bumpFileVersion(path: string): number {
545
+ if (this.session.bumpFileMutationVersion) return this.session.bumpFileMutationVersion(path);
546
+ const next = (this.#editVersionByPath.get(path) ?? 0) + 1;
547
+ this.#editVersionByPath.set(path, next);
548
+ return next;
549
+ }
550
+
551
+ /** Read the file's current mutation counter (session-global when available). */
552
+ #fileVersion(path: string): number {
553
+ if (this.session.getFileMutationVersion) return this.session.getFileMutationVersion(path);
554
+ return this.#editVersionByPath.get(path) ?? 0;
541
555
  }
542
556
  }
@@ -4,7 +4,7 @@
4
4
 
5
5
  import { HL_FILE_PREFIX, HL_FILE_SUFFIX } from "@oh-my-pi/hashline";
6
6
  import type { Component } from "@oh-my-pi/pi-tui";
7
- import { visibleWidth, wrapTextWithAnsi } from "@oh-my-pi/pi-tui";
7
+ import { sliceWithWidth, visibleWidth, wrapTextWithAnsi } from "@oh-my-pi/pi-tui";
8
8
  import { sanitizeText } from "@oh-my-pi/pi-utils";
9
9
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
10
10
  import type { FileDiagnosticsResult } from "../lsp";
@@ -13,7 +13,6 @@ import { getLanguageFromPath, type Theme } from "../modes/theme/theme";
13
13
  import type { OutputMeta } from "../tools/output-meta";
14
14
  import {
15
15
  formatDiagnostics,
16
- formatDiffStats,
17
16
  formatExpandHint,
18
17
  formatStatusIcon,
19
18
  getDiffStats,
@@ -182,42 +181,118 @@ function getOperationTitle(op: Operation | undefined): string {
182
181
  return op === "create" ? "Create" : op === "delete" ? "Delete" : "Edit";
183
182
  }
184
183
 
184
+ interface EditPathDisplayOptions {
185
+ rename?: string;
186
+ firstChangedLine?: number;
187
+ linkPath?: string;
188
+ renameLinkPath?: string;
189
+ maxPathWidth?: number;
190
+ }
191
+
192
+ function truncateEditTitlePath(displayPath: string, maxWidth: number | undefined): string {
193
+ if (maxWidth === undefined) return displayPath;
194
+ const width = visibleWidth(displayPath);
195
+ const safeMaxWidth = Math.max(0, Math.floor(maxWidth));
196
+ if (width <= safeMaxWidth) return displayPath;
197
+
198
+ const contentWidth = safeMaxWidth - 1;
199
+ if (contentWidth <= 0) return "…";
200
+
201
+ const headWidth = Math.floor(contentWidth / 2);
202
+ const tailWidth = contentWidth - headWidth;
203
+ const head = sliceWithWidth(displayPath, 0, headWidth, true).text;
204
+ const tail = sliceWithWidth(displayPath, Math.max(0, width - tailWidth), tailWidth, true).text;
205
+ return `${head}…${tail}`;
206
+ }
207
+
208
+ function formatEditTitlePath(pathValue: string, maxWidth?: number): string {
209
+ return truncateEditTitlePath(replaceTabs(shortenPath(pathValue), pathValue), maxWidth);
210
+ }
211
+
185
212
  function formatEditPathDisplay(
186
213
  rawPath: string,
187
214
  uiTheme: Theme,
188
- options?: { rename?: string; firstChangedLine?: number; linkPath?: string; renameLinkPath?: string },
189
- ): string {
215
+ options?: EditPathDisplayOptions,
216
+ ): { text: string; pathWidth: number } {
190
217
  // `rawPath`/`rename` are shown (cwd-relative) but the OSC 8 link targets the
191
- // absolute path when known — a relative `rawPath` would yield a `file:///rel`
192
- // URI that resolves against filesystem root instead of cwd.
218
+ // absolute path when known — a relative `rawPath` would otherwise yield a
219
+ // `file:///rel` URI that resolves against filesystem root instead of cwd.
193
220
  const linkTarget = options?.linkPath || rawPath;
221
+ const lineLink = options?.firstChangedLine ? { line: options.firstChangedLine } : undefined;
222
+ const primaryDisplay = rawPath ? formatEditTitlePath(rawPath, options?.maxPathWidth) : "…";
194
223
  let pathDisplay = rawPath
195
- ? fileHyperlink(linkTarget, uiTheme.fg("accent", shortenPath(rawPath)))
196
- : uiTheme.fg("toolOutput", "…");
197
-
198
- if (options?.firstChangedLine) {
199
- pathDisplay += uiTheme.fg("warning", `:${options.firstChangedLine}`);
200
- }
224
+ ? fileHyperlink(linkTarget, uiTheme.fg("accent", primaryDisplay), lineLink)
225
+ : uiTheme.fg("toolOutput", primaryDisplay);
226
+ let pathWidth = visibleWidth(primaryDisplay);
201
227
 
202
228
  if (options?.rename) {
203
229
  const renameTarget = options.renameLinkPath || options.rename;
204
- pathDisplay += ` ${uiTheme.fg("dim", "→")} ${fileHyperlink(renameTarget, uiTheme.fg("accent", shortenPath(options.rename)))}`;
230
+ const renameDisplay = formatEditTitlePath(options.rename, options.maxPathWidth);
231
+ pathDisplay += ` ${uiTheme.fg("dim", "→")} ${fileHyperlink(renameTarget, uiTheme.fg("accent", renameDisplay))}`;
232
+ pathWidth += visibleWidth(renameDisplay);
205
233
  }
206
234
 
207
- return pathDisplay;
235
+ return { text: pathDisplay, pathWidth };
208
236
  }
209
237
 
210
238
  function formatEditDescription(
211
239
  rawPath: string,
212
240
  uiTheme: Theme,
213
- options?: { rename?: string; firstChangedLine?: number; linkPath?: string; renameLinkPath?: string },
214
- ): { language: string; description: string } {
241
+ options?: EditPathDisplayOptions,
242
+ ): { language: string; description: string; pathWidth: number } {
215
243
  const language = getLanguageFromPath(rawPath) ?? "text";
216
244
  const icon = uiTheme.fg("muted", uiTheme.getLangIcon(language));
245
+ const pathDisplay = formatEditPathDisplay(rawPath, uiTheme, options);
217
246
  return {
218
247
  language,
219
- description: `${icon} ${formatEditPathDisplay(rawPath, uiTheme, options)}`,
248
+ description: `${icon} ${pathDisplay.text}`,
249
+ pathWidth: pathDisplay.pathWidth,
250
+ };
251
+ }
252
+
253
+ function editHeaderLabelBudget(width: number, uiTheme: Theme): number {
254
+ const leftGlyphs = `${uiTheme.boxSharp.topLeft}${uiTheme.boxSharp.horizontal.repeat(3)}`;
255
+ return Math.max(0, width - visibleWidth(leftGlyphs) - visibleWidth(uiTheme.boxSharp.topRight) - 2);
256
+ }
257
+
258
+ function renderEditHeader(
259
+ width: number,
260
+ uiTheme: Theme,
261
+ options: {
262
+ icon: "pending" | "success" | "error";
263
+ spinnerFrame?: number;
264
+ op?: Operation;
265
+ rawPath: string;
266
+ rename?: string;
267
+ firstChangedLine?: number;
268
+ linkPath?: string;
269
+ statsSuffix?: string;
270
+ extraSuffix?: string;
271
+ },
272
+ ): string {
273
+ const title = getOperationTitle(options.op);
274
+ const descriptionOptions: EditPathDisplayOptions = {
275
+ rename: options.rename,
276
+ firstChangedLine: options.firstChangedLine,
277
+ linkPath: options.linkPath,
220
278
  };
279
+ const formatted = formatEditDescription(options.rawPath, uiTheme, descriptionOptions);
280
+ const suffix = `${options.statsSuffix ?? ""}${options.extraSuffix ?? ""}`;
281
+ const buildHeader = (description: string): string =>
282
+ renderStatusLine({ icon: options.icon, spinnerFrame: options.spinnerFrame, title, description }, uiTheme) +
283
+ suffix;
284
+
285
+ const header = buildHeader(formatted.description);
286
+ const overflow = visibleWidth(header) - editHeaderLabelBudget(width, uiTheme);
287
+ if (overflow <= 0 || formatted.pathWidth <= 1) return header;
288
+
289
+ const pathCount = Math.max(1, (options.rawPath ? 1 : 0) + (options.rename ? 1 : 0));
290
+ const fittedPathWidth = Math.max(1, Math.floor((formatted.pathWidth - overflow) / pathCount));
291
+ const fitted = formatEditDescription(options.rawPath, uiTheme, {
292
+ ...descriptionOptions,
293
+ maxPathWidth: fittedPathWidth,
294
+ });
295
+ return buildHeader(fitted.description);
221
296
  }
222
297
 
223
298
  function renderPlainTextPreview(text: string, uiTheme: Theme, filePath?: string): string {
@@ -379,10 +454,13 @@ function getApplyPatchRenderSummary(
379
454
  }
380
455
 
381
456
  function formatDiffStatsSuffix(diff: string, uiTheme: Theme): string {
382
- const { added, removed, hunks } = getDiffStats(diff);
383
- const stats = formatDiffStats(added, removed, hunks, uiTheme);
384
- if (!stats) return "";
385
- return ` ${uiTheme.fg("dim", uiTheme.format.bracketLeft)}${stats}${uiTheme.fg("dim", uiTheme.format.bracketRight)}`;
457
+ const { added, removed } = getDiffStats(diff);
458
+ if (added === 0 && removed === 0) return "";
459
+ const stats = [
460
+ added > 0 ? uiTheme.fg("toolDiffAdded", `+${added}`) : undefined,
461
+ removed > 0 ? uiTheme.fg("toolDiffRemoved", `-${removed}`) : undefined,
462
+ ].filter(value => value !== undefined);
463
+ return ` ${uiTheme.fg("dim", uiTheme.format.bracketLeft)}${stats.join(uiTheme.fg("dim", "/"))}${uiTheme.fg("dim", uiTheme.format.bracketRight)}`;
386
464
  }
387
465
 
388
466
  function renderDiffSection(
@@ -462,17 +540,19 @@ export const editToolRenderer = {
462
540
  "";
463
541
  const rename = editArgs.rename || firstEdit?.rename || firstEdit?.move || firstApplyPatchEntry?.rename;
464
542
  const op = editArgs.op || firstEdit?.op || firstApplyPatchEntry?.op;
465
- const { description } = formatEditDescription(rawPath, uiTheme, { rename });
466
543
  let fileCount = hashlineInputSummary?.entries.length ?? applyPatchSummary?.entries.length ?? 0;
467
544
  if (Array.isArray(editArgs.edits)) {
468
545
  fileCount = countEditFiles(editArgs.edits);
469
546
  }
470
547
  return framedBlock(uiTheme, width => {
471
- let header = renderStatusLine(
472
- { icon: "pending", spinnerFrame: options?.spinnerFrame, title: getOperationTitle(op), description },
473
- uiTheme,
474
- );
475
- if (fileCount > 1) header += uiTheme.fg("dim", ` (+${fileCount - 1} more)`);
548
+ const header = renderEditHeader(width, uiTheme, {
549
+ icon: "pending",
550
+ spinnerFrame: options?.spinnerFrame,
551
+ op,
552
+ rawPath,
553
+ rename,
554
+ extraSuffix: fileCount > 1 ? uiTheme.fg("dim", ` (+${fileCount - 1} more)`) : undefined,
555
+ });
476
556
  let body = getCallPreview(editArgs, rawPath, uiTheme, renderContext, options.expanded);
477
557
  if (applyPatchSummary?.error) {
478
558
  body += `\n${uiTheme.fg("error", truncateToWidth(replaceTabs(applyPatchSummary.error, rawPath), Math.max(1, width - 2)))}`;
@@ -546,15 +626,20 @@ function renderSingleFileResult(
546
626
  (editDiffPreview && "firstChangedLine" in editDiffPreview ? editDiffPreview.firstChangedLine : undefined) ||
547
627
  (details && !isError ? details.firstChangedLine : undefined);
548
628
  const linkPath = details && "path" in details ? details.path : undefined;
549
- const { description } = formatEditDescription(rawPath, uiTheme, { rename, firstChangedLine, linkPath });
550
629
 
551
630
  // Change stats ride inline on the header bar next to the path.
552
631
  const previewDiff = editDiffPreview && !("error" in editDiffPreview) ? editDiffPreview.diff : undefined;
553
632
  const headerDiff = isError ? undefined : details?.diff || previewDiff;
554
633
  const statsSuffix = headerDiff ? formatDiffStatsSuffix(headerDiff, uiTheme) : "";
555
- const header =
556
- renderStatusLine({ icon: isError ? "error" : "success", title: getOperationTitle(op), description }, uiTheme) +
557
- statsSuffix;
634
+ const header = renderEditHeader(width, uiTheme, {
635
+ icon: isError ? "error" : "success",
636
+ op,
637
+ rawPath,
638
+ rename,
639
+ firstChangedLine,
640
+ linkPath,
641
+ statsSuffix,
642
+ });
558
643
 
559
644
  let body = "";
560
645
  if (isError) {
@@ -206,6 +206,26 @@ describe("runEvalLlm", () => {
206
206
  expect(result.details).toEqual({ model: "p/smol", tier: "smol", structured: false });
207
207
  });
208
208
 
209
+ it("supplies a non-empty systemPrompt when system is omitted (codex 'Instructions are required' guard)", async () => {
210
+ // The openai-codex Responses transformer drops `instructions` when no
211
+ // system prompt is provided, and the remote endpoint then 400s with
212
+ // "Instructions are required". runEvalLlm must always carry a non-empty
213
+ // systemPrompt so `llm("…")` without a `system` argument works.
214
+ const spy = vi.spyOn(ai, "completeSimple").mockResolvedValue(assistant({ text: "ok" }));
215
+ await runEvalLlm({ prompt: "q", model: "smol" }, { session: makeSession() });
216
+ const ctx = spy.mock.calls[0]?.[1] as { systemPrompt?: string[] };
217
+ expect(ctx.systemPrompt).toBeDefined();
218
+ expect(ctx.systemPrompt?.length).toBeGreaterThan(0);
219
+ expect(ctx.systemPrompt?.[0]).toMatch(/.+/);
220
+ });
221
+
222
+ it("honors an explicit system prompt instead of overriding it", async () => {
223
+ const spy = vi.spyOn(ai, "completeSimple").mockResolvedValue(assistant({ text: "ok" }));
224
+ await runEvalLlm({ prompt: "q", model: "smol", system: "Be terse." }, { session: makeSession() });
225
+ const ctx = spy.mock.calls[0]?.[1] as { systemPrompt?: string[] };
226
+ expect(ctx.systemPrompt).toEqual(["Be terse."]);
227
+ });
228
+
209
229
  it("forces a respond tool call and returns its arguments in structured mode", async () => {
210
230
  const spy = vi
211
231
  .spyOn(ai, "completeSimple")