@oh-my-pi/pi-coding-agent 15.10.2 → 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 (73) hide show
  1. package/CHANGELOG.md +46 -1
  2. package/dist/types/cli/gallery-fixtures/types.d.ts +7 -1
  3. package/dist/types/edit/index.d.ts +0 -1
  4. package/dist/types/lsp/index.d.ts +0 -5
  5. package/dist/types/main.d.ts +11 -0
  6. package/dist/types/modes/components/assistant-message.d.ts +0 -9
  7. package/dist/types/modes/components/late-diagnostics-message.d.ts +20 -0
  8. package/dist/types/modes/components/read-tool-group.d.ts +6 -0
  9. package/dist/types/modes/components/session-selector.d.ts +16 -7
  10. package/dist/types/modes/components/tool-execution.d.ts +0 -18
  11. package/dist/types/modes/types.d.ts +4 -0
  12. package/dist/types/session/messages.d.ts +11 -8
  13. package/dist/types/session/yield-queue.d.ts +10 -1
  14. package/dist/types/tools/eval-render.d.ts +0 -1
  15. package/dist/types/tools/index.d.ts +31 -0
  16. package/dist/types/tools/path-utils.d.ts +5 -1
  17. package/dist/types/tools/read.d.ts +2 -1
  18. package/dist/types/tools/render-utils.d.ts +3 -1
  19. package/dist/types/tools/renderers.d.ts +0 -15
  20. package/dist/types/tools/write.d.ts +0 -2
  21. package/dist/types/tui/code-cell.d.ts +0 -2
  22. package/dist/types/tui/hyperlink.d.ts +5 -7
  23. package/dist/types/tui/output-block.d.ts +0 -18
  24. package/package.json +9 -9
  25. package/src/cli/gallery-cli.ts +4 -0
  26. package/src/cli/gallery-fixtures/codeintel.ts +0 -1
  27. package/src/cli/gallery-fixtures/fs.ts +68 -1
  28. package/src/cli/gallery-fixtures/types.ts +8 -1
  29. package/src/commit/agentic/agent.ts +1 -0
  30. package/src/edit/hashline/diff.ts +86 -0
  31. package/src/edit/hashline/execute.ts +14 -1
  32. package/src/edit/index.ts +31 -17
  33. package/src/edit/renderer.ts +116 -31
  34. package/src/eval/js/shared/prelude.txt +26 -10
  35. package/src/internal-urls/docs-index.generated.ts +4 -4
  36. package/src/lsp/index.ts +128 -52
  37. package/src/main.ts +54 -14
  38. package/src/modes/components/assistant-message.ts +3 -15
  39. package/src/modes/components/late-diagnostics-message.ts +60 -0
  40. package/src/modes/components/plan-review-overlay.ts +26 -5
  41. package/src/modes/components/read-tool-group.ts +415 -35
  42. package/src/modes/components/session-selector.ts +89 -35
  43. package/src/modes/components/tool-execution.ts +7 -49
  44. package/src/modes/components/transcript-container.ts +108 -32
  45. package/src/modes/controllers/event-controller.ts +6 -1
  46. package/src/modes/controllers/input-controller.ts +10 -2
  47. package/src/modes/types.ts +4 -0
  48. package/src/modes/utils/ui-helpers.ts +26 -5
  49. package/src/prompts/system/manual-continue.md +7 -0
  50. package/src/prompts/system/plan-mode-active.md +56 -72
  51. package/src/prompts/tools/eval.md +3 -1
  52. package/src/prompts/tools/lsp-late-diagnostic.md +8 -0
  53. package/src/sdk.ts +59 -1
  54. package/src/session/agent-session.ts +5 -3
  55. package/src/session/messages.ts +21 -14
  56. package/src/session/session-manager.ts +2 -2
  57. package/src/session/yield-queue.ts +20 -2
  58. package/src/task/executor.ts +1 -0
  59. package/src/tiny/title-client.ts +6 -1
  60. package/src/tools/bash.ts +0 -7
  61. package/src/tools/eval-render.ts +4 -23
  62. package/src/tools/find.ts +148 -106
  63. package/src/tools/index.ts +32 -0
  64. package/src/tools/path-utils.ts +19 -22
  65. package/src/tools/read.ts +16 -8
  66. package/src/tools/render-utils.ts +3 -1
  67. package/src/tools/renderers.ts +0 -15
  68. package/src/tools/ssh.ts +0 -1
  69. package/src/tools/todo.ts +1 -0
  70. package/src/tools/write.ts +3 -12
  71. package/src/tui/code-cell.ts +1 -6
  72. package/src/tui/hyperlink.ts +13 -23
  73. package/src/tui/output-block.ts +2 -97
@@ -1,6 +1,7 @@
1
1
  // biome-ignore-all lint/suspicious/noTemplateCurlyInString: sample source-code strings (read fixtures) intentionally contain literal ${...}.
2
2
  // Gallery fixtures for the filesystem tools (read, write, find).
3
- import type { GalleryFixture } from "./types";
3
+ import { ReadToolGroupComponent } from "../../modes/components/read-tool-group";
4
+ import type { GalleryFixture, GalleryFixtureState, GalleryResult } from "./types";
4
5
 
5
6
  const readSnippet = [
6
7
  "export const findToolRenderer = {",
@@ -36,6 +37,64 @@ const writtenContent = [
36
37
  "",
37
38
  ].join("\n");
38
39
 
40
+ const groupedReadTargets = [
41
+ "packages/coding-agent/test/streaming-preview-height.test.ts:301-409",
42
+ "packages/coding-agent/test/tool-live-region-scrollback.test.ts:143-310",
43
+ "packages/tui/test/streaming-scrollback-defer.test.ts:89-464",
44
+ ];
45
+
46
+ const groupedReadDelimitedPath = groupedReadTargets.join(",");
47
+ const groupedReadRepeatedFile = "packages/coding-agent/src/task/render.ts";
48
+ const groupedReadRepeatedRanges = `${groupedReadRepeatedFile}:507-605,1070-1194,1210-1240,1270-1274`;
49
+
50
+ function textResult(text: string, details?: unknown, isError?: boolean): GalleryResult {
51
+ return { content: [{ type: "text", text }], details, isError };
52
+ }
53
+
54
+ function addGroupedReadArgs(component: ReadToolGroupComponent): void {
55
+ component.updateArgs({ path: groupedReadDelimitedPath }, "read-delimited");
56
+ component.updateArgs({ path: groupedReadRepeatedRanges }, "read-ranges");
57
+ }
58
+
59
+ function renderReadGroupFixtureState(state: GalleryFixtureState, width: number, expanded: boolean): string[] {
60
+ const component = new ReadToolGroupComponent();
61
+ component.setExpanded(expanded);
62
+
63
+ if (state === "streaming") {
64
+ component.updateArgs(
65
+ {
66
+ path: [
67
+ "packages/coding-agent/test/streaming-preview-height.test.ts:301-409",
68
+ "packages/coding-agent/test/tool-live-region-scrollback.test.ts:143-",
69
+ ].join(","),
70
+ },
71
+ "read-delimited",
72
+ );
73
+ return component.render(width);
74
+ }
75
+
76
+ addGroupedReadArgs(component);
77
+ if (state === "progress") return component.render(width);
78
+
79
+ component.updateResult(
80
+ textResult("Read three focused test ranges.", { displayReadTargets: groupedReadTargets }),
81
+ false,
82
+ "read-delimited",
83
+ );
84
+
85
+ if (state === "error") {
86
+ component.updateResult(
87
+ textResult("Error: selector 1270-1274 is outside the file", undefined, true),
88
+ false,
89
+ "read-ranges",
90
+ );
91
+ return component.render(width);
92
+ }
93
+
94
+ component.updateResult(textResult("Read four render.ts ranges."), false, "read-ranges");
95
+ return component.render(width);
96
+ }
97
+
39
98
  export const fsFixtures: Record<string, GalleryFixture> = {
40
99
  read: {
41
100
  label: "Read",
@@ -81,6 +140,14 @@ export const fsFixtures: Record<string, GalleryFixture> = {
81
140
  },
82
141
  },
83
142
 
143
+ read_group: {
144
+ label: "Read Groups",
145
+ args: {},
146
+ result: textResult("Rendered grouped read calls."),
147
+ errorResult: textResult("Rendered grouped read errors.", undefined, true),
148
+ renderState: renderReadGroupFixtureState,
149
+ },
150
+
84
151
  write: {
85
152
  label: "Write",
86
153
  // Streaming: path known, content still arriving (only the imports so far).
@@ -11,14 +11,21 @@ export interface GalleryResult {
11
11
  isError?: boolean;
12
12
  }
13
13
 
14
+ export type GalleryFixtureState = "streaming" | "progress" | "success" | "error";
15
+
14
16
  export interface GalleryFixture {
15
17
  /** Display label for the tool header (defaults to the tool name). */
16
18
  label?: string;
17
19
  /** Edit mode for edit-like tools so the streaming preview dispatches correctly. */
18
20
  editMode?: EditMode;
21
+ /**
22
+ * Custom gallery-only renderer for fixtures that are not one ToolExecutionComponent
23
+ * (for example the read-group transcript component).
24
+ */
25
+ renderState?: (state: GalleryFixtureState, width: number, expanded: boolean) => string[] | Promise<string[]>;
19
26
  /**
20
27
  * Set for tools whose real `AgentTool` attaches `renderCall`/`renderResult`
21
- * directly on the instance (e.g. `lsp`, `task`). The harness then attaches
28
+ * directly on the instance (e.g. `task`). The harness then attaches
22
29
  * the registry renderer onto the fake tool so the component routes through
23
30
  * the custom-tool branch — the same path production takes — instead of the
24
31
  * built-in registry branch. The two branches can diverge, so exercising the
@@ -170,6 +170,7 @@ export async function runCommitAgentSession(input: CommitAgentInput): Promise<Co
170
170
  await session.prompt(reminder, {
171
171
  attribution: "agent",
172
172
  expandPromptTemplates: false,
173
+ synthetic: true,
173
174
  });
174
175
  }
175
176
 
@@ -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,
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) {
@@ -1,17 +1,33 @@
1
1
  if (!globalThis.__omp_js_prelude_loaded__) {
2
2
  globalThis.__omp_js_prelude_loaded__ = true;
3
3
 
4
- const toOptions = value => (value && typeof value === "object" && !Array.isArray(value) ? value : {});
4
+ const isPlainObject = value => value !== null && typeof value === "object" && !Array.isArray(value);
5
+ const optionsArg = (name, value, rest, example) => {
6
+ if (rest.length > 0) {
7
+ throw new TypeError(
8
+ `${name}() takes options as a single trailing object literal, not positional arguments (got ${rest.length + 1} extra args). Pass them as ${name}(..., ${example}).`,
9
+ );
10
+ }
11
+ if (value === undefined || value === null) return {};
12
+ if (!isPlainObject(value)) {
13
+ const kind = Array.isArray(value) ? "an array" : typeof value;
14
+ throw new TypeError(
15
+ `${name}() options must be a trailing object literal like ${example}, not ${kind}. JS helpers never take positional options.`,
16
+ );
17
+ }
18
+ return value;
19
+ };
5
20
  const callHelper = (name, ...args) => globalThis.__omp_helpers__[name](...args);
6
21
 
7
- const read = (path, opts = {}) => callHelper("read", path, toOptions(opts));
22
+ const read = (path, opts, ...rest) => callHelper("read", path, optionsArg("read", opts, rest, "{ offset, limit }"));
8
23
  const write = async (path, data) => callHelper("writeFile", path, data);
9
24
  const append = (path, content) => callHelper("append", path, content);
10
- const sort = (text, opts = {}) => callHelper("sortText", text, toOptions(opts));
11
- const uniq = (text, opts = {}) => callHelper("uniqText", text, toOptions(opts));
12
- const counter = (items, opts = {}) => callHelper("counter", items, toOptions(opts));
25
+ const sort = (text, opts, ...rest) => callHelper("sortText", text, optionsArg("sort", opts, rest, "{ reverse, unique }"));
26
+ const uniq = (text, opts, ...rest) => callHelper("uniqText", text, optionsArg("uniq", opts, rest, "{ count }"));
27
+ const counter = (items, opts, ...rest) =>
28
+ callHelper("counter", items, optionsArg("counter", opts, rest, "{ limit, reverse }"));
13
29
  const diff = (a, b) => callHelper("diff", a, b);
14
- const tree = (path = ".", opts = {}) => callHelper("tree", path, toOptions(opts));
30
+ const tree = (path = ".", opts, ...rest) => callHelper("tree", path, optionsArg("tree", opts, rest, "{ maxDepth, showHidden }"));
15
31
  const env = (key, value) => callHelper("env", key, value);
16
32
 
17
33
  const tool = new Proxy(
@@ -41,15 +57,15 @@ if (!globalThis.__omp_js_prelude_loaded__) {
41
57
 
42
58
  const hasOwn = (object, key) => Object.prototype.hasOwnProperty.call(object, key);
43
59
 
44
- const llm = async (prompt, opts = {}) => {
45
- const o = toOptions(opts);
60
+ const llm = async (prompt, opts, ...rest) => {
61
+ const o = optionsArg("llm", opts, rest, "{ model, system, schema }");
46
62
  const res = await globalThis.__omp_call_tool__("__llm__", { prompt, ...o });
47
63
  const text = res && typeof res === "object" ? res.text : res;
48
64
  return hasOwn(o, "schema") ? JSON.parse(text) : text;
49
65
  };
50
66
 
51
- const agent = async (prompt, opts = {}) => {
52
- const o = toOptions(opts);
67
+ const agent = async (prompt, opts, ...rest) => {
68
+ const o = optionsArg("agent", opts, rest, "{ agentType, model, context, label, schema }");
53
69
  const res = await globalThis.__omp_call_tool__("__agent__", { prompt, ...o });
54
70
  const text = res && typeof res === "object" ? res.text : res;
55
71
  return hasOwn(o, "schema") ? JSON.parse(text) : text;