@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.
- package/CHANGELOG.md +46 -1
- package/dist/types/cli/gallery-fixtures/types.d.ts +7 -1
- package/dist/types/edit/index.d.ts +0 -1
- package/dist/types/lsp/index.d.ts +0 -5
- package/dist/types/main.d.ts +11 -0
- package/dist/types/modes/components/assistant-message.d.ts +0 -9
- package/dist/types/modes/components/late-diagnostics-message.d.ts +20 -0
- package/dist/types/modes/components/read-tool-group.d.ts +6 -0
- package/dist/types/modes/components/session-selector.d.ts +16 -7
- package/dist/types/modes/components/tool-execution.d.ts +0 -18
- package/dist/types/modes/types.d.ts +4 -0
- package/dist/types/session/messages.d.ts +11 -8
- package/dist/types/session/yield-queue.d.ts +10 -1
- package/dist/types/tools/eval-render.d.ts +0 -1
- package/dist/types/tools/index.d.ts +31 -0
- package/dist/types/tools/path-utils.d.ts +5 -1
- package/dist/types/tools/read.d.ts +2 -1
- package/dist/types/tools/render-utils.d.ts +3 -1
- package/dist/types/tools/renderers.d.ts +0 -15
- package/dist/types/tools/write.d.ts +0 -2
- package/dist/types/tui/code-cell.d.ts +0 -2
- package/dist/types/tui/hyperlink.d.ts +5 -7
- package/dist/types/tui/output-block.d.ts +0 -18
- package/package.json +9 -9
- package/src/cli/gallery-cli.ts +4 -0
- package/src/cli/gallery-fixtures/codeintel.ts +0 -1
- package/src/cli/gallery-fixtures/fs.ts +68 -1
- package/src/cli/gallery-fixtures/types.ts +8 -1
- package/src/commit/agentic/agent.ts +1 -0
- package/src/edit/hashline/diff.ts +86 -0
- package/src/edit/hashline/execute.ts +14 -1
- package/src/edit/index.ts +31 -17
- package/src/edit/renderer.ts +116 -31
- package/src/eval/js/shared/prelude.txt +26 -10
- package/src/internal-urls/docs-index.generated.ts +4 -4
- package/src/lsp/index.ts +128 -52
- package/src/main.ts +54 -14
- package/src/modes/components/assistant-message.ts +3 -15
- package/src/modes/components/late-diagnostics-message.ts +60 -0
- package/src/modes/components/plan-review-overlay.ts +26 -5
- package/src/modes/components/read-tool-group.ts +415 -35
- package/src/modes/components/session-selector.ts +89 -35
- package/src/modes/components/tool-execution.ts +7 -49
- package/src/modes/components/transcript-container.ts +108 -32
- package/src/modes/controllers/event-controller.ts +6 -1
- package/src/modes/controllers/input-controller.ts +10 -2
- package/src/modes/types.ts +4 -0
- package/src/modes/utils/ui-helpers.ts +26 -5
- package/src/prompts/system/manual-continue.md +7 -0
- package/src/prompts/system/plan-mode-active.md +56 -72
- package/src/prompts/tools/eval.md +3 -1
- package/src/prompts/tools/lsp-late-diagnostic.md +8 -0
- package/src/sdk.ts +59 -1
- package/src/session/agent-session.ts +5 -3
- package/src/session/messages.ts +21 -14
- package/src/session/session-manager.ts +2 -2
- package/src/session/yield-queue.ts +20 -2
- package/src/task/executor.ts +1 -0
- package/src/tiny/title-client.ts +6 -1
- package/src/tools/bash.ts +0 -7
- package/src/tools/eval-render.ts +4 -23
- package/src/tools/find.ts +148 -106
- package/src/tools/index.ts +32 -0
- package/src/tools/path-utils.ts +19 -22
- package/src/tools/read.ts +16 -8
- package/src/tools/render-utils.ts +3 -1
- package/src/tools/renderers.ts +0 -15
- package/src/tools/ssh.ts +0 -1
- package/src/tools/todo.ts +1 -0
- package/src/tools/write.ts +3 -12
- package/src/tui/code-cell.ts +1 -6
- package/src/tui/hyperlink.ts +13 -23
- 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
|
|
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. `
|
|
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
|
|
@@ -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
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
.
|
|
532
|
-
.
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
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
|
}
|
package/src/edit/renderer.ts
CHANGED
|
@@ -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?:
|
|
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
|
|
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",
|
|
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
|
-
|
|
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?:
|
|
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} ${
|
|
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
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
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
|
-
|
|
557
|
-
|
|
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
|
|
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
|
|
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
|
|
11
|
-
const uniq = (text, opts
|
|
12
|
-
const counter = (items, 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
|
|
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 =
|
|
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 =
|
|
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;
|