@oh-my-pi/pi-coding-agent 14.2.1 → 14.4.0
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 +143 -1
- package/package.json +19 -19
- package/src/autoresearch/prompt.md +1 -1
- package/src/cli/args.ts +10 -1
- package/src/cli/shell-cli.ts +15 -3
- package/src/commit/agentic/prompts/analyze-file.md +1 -1
- package/src/config/model-registry.ts +67 -15
- package/src/config/prompt-templates.ts +5 -5
- package/src/config/settings-schema.ts +63 -4
- package/src/cursor.ts +3 -8
- package/src/debug/system-info.ts +6 -2
- package/src/discovery/claude.ts +58 -36
- package/src/discovery/helpers.ts +3 -3
- package/src/discovery/opencode.ts +20 -2
- package/src/edit/diff.ts +50 -47
- package/src/edit/index.ts +87 -57
- package/src/edit/line-hash.ts +735 -19
- package/src/edit/modes/apply-patch.ts +0 -9
- package/src/edit/modes/atom.ts +658 -0
- package/src/edit/modes/chunk.ts +144 -78
- package/src/edit/modes/hashline.ts +223 -146
- package/src/edit/modes/patch.ts +5 -9
- package/src/edit/modes/replace.ts +6 -11
- package/src/edit/renderer.ts +112 -143
- package/src/edit/streaming.ts +385 -0
- package/src/exec/bash-executor.ts +58 -5
- package/src/export/html/template.generated.ts +1 -1
- package/src/export/html/template.js +4 -12
- package/src/extensibility/custom-tools/types.ts +2 -0
- package/src/extensibility/custom-tools/wrapper.ts +2 -1
- package/src/internal-urls/docs-index.generated.ts +7 -7
- package/src/internal-urls/pi-protocol.ts +0 -2
- package/src/lsp/client.ts +8 -1
- package/src/lsp/defaults.json +2 -1
- package/src/lsp/index.ts +1 -1
- package/src/mcp/render.ts +1 -8
- package/src/modes/acp/acp-agent.ts +76 -2
- package/src/modes/components/assistant-message.ts +5 -34
- package/src/modes/components/diff.ts +23 -14
- package/src/modes/components/footer.ts +21 -16
- package/src/modes/components/hook-editor.ts +1 -1
- package/src/modes/components/settings-defs.ts +6 -1
- package/src/modes/components/todo-reminder.ts +1 -8
- package/src/modes/components/tool-execution.ts +112 -105
- package/src/modes/controllers/input-controller.ts +1 -1
- package/src/modes/controllers/selector-controller.ts +1 -1
- package/src/modes/interactive-mode.ts +0 -2
- package/src/modes/print-mode.ts +8 -0
- package/src/modes/theme/mermaid-cache.ts +13 -52
- package/src/modes/theme/theme.ts +2 -2
- package/src/prompts/agents/librarian.md +1 -1
- package/src/prompts/agents/reviewer.md +4 -4
- package/src/prompts/ci-green-request.md +1 -1
- package/src/prompts/review-request.md +1 -1
- package/src/prompts/system/subagent-system-prompt.md +3 -3
- package/src/prompts/system/subagent-yield-reminder.md +11 -0
- package/src/prompts/system/system-prompt.md +4 -1
- package/src/prompts/tools/ask.md +3 -2
- package/src/prompts/tools/ast-edit.md +15 -19
- package/src/prompts/tools/ast-grep.md +18 -24
- package/src/prompts/tools/atom.md +96 -0
- package/src/prompts/tools/browser.md +1 -0
- package/src/prompts/tools/chunk-edit.md +58 -179
- package/src/prompts/tools/debug.md +4 -5
- package/src/prompts/tools/exit-plan-mode.md +4 -5
- package/src/prompts/tools/find.md +4 -8
- package/src/prompts/tools/github.md +18 -0
- package/src/prompts/tools/grep.md +8 -8
- package/src/prompts/tools/hashline.md +22 -89
- package/src/prompts/tools/{gemini-image.md → image-gen.md} +1 -1
- package/src/prompts/tools/inspect-image.md +6 -6
- package/src/prompts/tools/lsp.md +6 -0
- package/src/prompts/tools/patch.md +12 -19
- package/src/prompts/tools/python.md +3 -2
- package/src/prompts/tools/read-chunk.md +46 -8
- package/src/prompts/tools/read.md +9 -6
- package/src/prompts/tools/ssh.md +8 -17
- package/src/prompts/tools/todo-write.md +54 -41
- package/src/sdk.ts +22 -14
- package/src/session/agent-session.ts +61 -22
- package/src/session/session-manager.ts +228 -57
- package/src/session/streaming-output.ts +11 -0
- package/src/system-prompt.ts +7 -2
- package/src/task/executor.ts +44 -48
- package/src/task/render.ts +11 -13
- package/src/tools/ask.ts +7 -7
- package/src/tools/ast-edit.ts +45 -41
- package/src/tools/ast-grep.ts +77 -85
- package/src/tools/bash.ts +21 -9
- package/src/tools/browser.ts +32 -30
- package/src/tools/calculator.ts +4 -4
- package/src/tools/cancel-job.ts +1 -1
- package/src/tools/checkpoint.ts +2 -2
- package/src/tools/debug.ts +41 -37
- package/src/tools/exit-plan-mode.ts +1 -1
- package/src/tools/find.ts +4 -4
- package/src/tools/gh-renderer.ts +12 -4
- package/src/tools/gh.ts +514 -712
- package/src/tools/grep.ts +115 -130
- package/src/tools/{gemini-image.ts → image-gen.ts} +459 -60
- package/src/tools/index.ts +14 -32
- package/src/tools/inspect-image.ts +3 -3
- package/src/tools/json-tree.ts +114 -114
- package/src/tools/match-line-format.ts +9 -8
- package/src/tools/notebook.ts +8 -7
- package/src/tools/poll-tool.ts +2 -1
- package/src/tools/python.ts +9 -23
- package/src/tools/read.ts +32 -21
- package/src/tools/render-mermaid.ts +1 -1
- package/src/tools/render-utils.ts +18 -0
- package/src/tools/renderers.ts +2 -2
- package/src/tools/report-tool-issue.ts +3 -2
- package/src/tools/resolve.ts +1 -1
- package/src/tools/review.ts +12 -10
- package/src/tools/search-tool-bm25.ts +2 -4
- package/src/tools/sqlite-reader.ts +116 -3
- package/src/tools/ssh.ts +4 -4
- package/src/tools/todo-write.ts +172 -147
- package/src/tools/vim.ts +14 -15
- package/src/tools/write.ts +4 -4
- package/src/tools/{submit-result.ts → yield.ts} +11 -13
- package/src/utils/edit-mode.ts +2 -1
- package/src/utils/file-display-mode.ts +10 -5
- package/src/utils/git.ts +9 -5
- package/src/utils/shell-snapshot.ts +2 -3
- package/src/vim/render.ts +4 -4
- package/src/web/search/providers/codex.ts +129 -6
- package/src/prompts/system/subagent-submit-reminder.md +0 -11
- package/src/prompts/tools/gh-issue-view.md +0 -11
- package/src/prompts/tools/gh-pr-checkout.md +0 -12
- package/src/prompts/tools/gh-pr-diff.md +0 -12
- package/src/prompts/tools/gh-pr-push.md +0 -11
- package/src/prompts/tools/gh-pr-view.md +0 -11
- package/src/prompts/tools/gh-repo-view.md +0 -11
- package/src/prompts/tools/gh-run-watch.md +0 -12
- package/src/prompts/tools/gh-search-issues.md +0 -11
- package/src/prompts/tools/gh-search-prs.md +0 -11
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Streaming edit preview strategies.
|
|
3
|
+
*
|
|
4
|
+
* Each edit mode owns a strategy that knows how to:
|
|
5
|
+
* - collapse partial-JSON args to the subset safe to preview
|
|
6
|
+
* (`extractCompleteEdits`),
|
|
7
|
+
* - compute unified diff previews for the in-flight args
|
|
8
|
+
* (`computeDiffPreview`), and
|
|
9
|
+
* - render a text placeholder while no diff exists yet
|
|
10
|
+
* (`renderStreamingFallback`).
|
|
11
|
+
*
|
|
12
|
+
* The shared renderer / `ToolExecutionComponent` consult the strategy via
|
|
13
|
+
* the injected `editMode` rather than probing argument shape.
|
|
14
|
+
*/
|
|
15
|
+
import type { Theme } from "../modes/theme/theme";
|
|
16
|
+
import { type EditMode, resolveEditMode } from "../utils/edit-mode";
|
|
17
|
+
import { computeEditDiff, type DiffError, type DiffResult } from "./diff";
|
|
18
|
+
import { expandApplyPatchToEntries, expandApplyPatchToPreviewEntries } from "./modes/apply-patch";
|
|
19
|
+
import { type ChunkToolEdit, computeChunkDiff, parseChunkEditPath } from "./modes/chunk";
|
|
20
|
+
import { computeHashlineDiff, type HashlineToolEdit } from "./modes/hashline";
|
|
21
|
+
import { computePatchDiff, type PatchEditEntry } from "./modes/patch";
|
|
22
|
+
import type { ReplaceEditEntry } from "./modes/replace";
|
|
23
|
+
|
|
24
|
+
export interface PerFileDiffPreview {
|
|
25
|
+
path: string;
|
|
26
|
+
diff?: string;
|
|
27
|
+
firstChangedLine?: number;
|
|
28
|
+
error?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface StreamingDiffContext {
|
|
32
|
+
cwd: string;
|
|
33
|
+
signal: AbortSignal;
|
|
34
|
+
fuzzyThreshold?: number;
|
|
35
|
+
allowFuzzy?: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface EditStreamingStrategy<Args = unknown> {
|
|
39
|
+
/**
|
|
40
|
+
* Return the args restricted to edits that are "complete enough" to
|
|
41
|
+
* compute a diff against. Strategies drop the trailing incomplete entry
|
|
42
|
+
* when `partialJson` indicates its closing `}` hasn't arrived yet.
|
|
43
|
+
*/
|
|
44
|
+
extractCompleteEdits(args: Args, partialJson: string | undefined): Args;
|
|
45
|
+
/**
|
|
46
|
+
* Compute diff(s) for the given partial args. Returns `null` when args
|
|
47
|
+
* do not yet carry enough structure to compute anything.
|
|
48
|
+
*/
|
|
49
|
+
computeDiffPreview(args: Args, ctx: StreamingDiffContext): Promise<PerFileDiffPreview[] | null>;
|
|
50
|
+
/**
|
|
51
|
+
* Rendered inline while the diff hasn't been computed yet (or when the
|
|
52
|
+
* compute returned `null` because args are still too partial).
|
|
53
|
+
*/
|
|
54
|
+
renderStreamingFallback(args: Args, uiTheme: Theme): string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// -----------------------------------------------------------------------------
|
|
58
|
+
// Partial-JSON handling
|
|
59
|
+
// -----------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Given an edits array parsed from partial JSON, drop the last entry when the
|
|
63
|
+
* corresponding object in `partialJson` does not yet end with a closed `}`.
|
|
64
|
+
*
|
|
65
|
+
* This guards against `partial-json` silently coercing truncated tails like
|
|
66
|
+
* `"write":nu` / `"write":nul` into `{ write: null }`, which would make the
|
|
67
|
+
* last entry render a spurious null-write error until the value finishes
|
|
68
|
+
* streaming.
|
|
69
|
+
*/
|
|
70
|
+
export function dropIncompleteLastEdit<T>(edits: readonly T[], partialJson: string | undefined, listKey: string): T[] {
|
|
71
|
+
if (!Array.isArray(edits) || edits.length === 0) return [...(edits ?? [])];
|
|
72
|
+
if (!partialJson) return [...edits];
|
|
73
|
+
|
|
74
|
+
const keyMarker = `"${listKey}"`;
|
|
75
|
+
const keyIdx = partialJson.indexOf(keyMarker);
|
|
76
|
+
if (keyIdx === -1) return [...edits];
|
|
77
|
+
|
|
78
|
+
// Find the `[` that opens the list value.
|
|
79
|
+
let i = partialJson.indexOf("[", keyIdx + keyMarker.length);
|
|
80
|
+
if (i === -1) return [...edits];
|
|
81
|
+
i++;
|
|
82
|
+
|
|
83
|
+
let depth = 0;
|
|
84
|
+
let inString = false;
|
|
85
|
+
let escaped = false;
|
|
86
|
+
let lastClose = -1;
|
|
87
|
+
for (; i < partialJson.length; i++) {
|
|
88
|
+
const ch = partialJson[i];
|
|
89
|
+
if (escaped) {
|
|
90
|
+
escaped = false;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (ch === "\\") {
|
|
94
|
+
if (inString) escaped = true;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (ch === '"') {
|
|
98
|
+
inString = !inString;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (inString) continue;
|
|
102
|
+
if (ch === "{" || ch === "[") {
|
|
103
|
+
depth++;
|
|
104
|
+
} else if (ch === "}" || ch === "]") {
|
|
105
|
+
depth--;
|
|
106
|
+
if (ch === "}" && depth === 0) {
|
|
107
|
+
lastClose = i;
|
|
108
|
+
}
|
|
109
|
+
if (ch === "]" && depth === -1) {
|
|
110
|
+
// End of list reached.
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// If we're still inside the list and saw no closing `}` for the last entry,
|
|
117
|
+
// or there is trailing non-whitespace after the last `}` before the list
|
|
118
|
+
// ended (i.e. a new object has opened), drop the trailing entry.
|
|
119
|
+
const tail = lastClose === -1 ? partialJson.slice(i) : partialJson.slice(lastClose + 1);
|
|
120
|
+
const sawNewObjectAfterLastClose = /\{/.test(tail);
|
|
121
|
+
const listIsStillOpen = depth >= 0;
|
|
122
|
+
|
|
123
|
+
if (lastClose === -1 || (listIsStillOpen && sawNewObjectAfterLastClose)) {
|
|
124
|
+
return edits.slice(0, -1);
|
|
125
|
+
}
|
|
126
|
+
return [...edits];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// -----------------------------------------------------------------------------
|
|
130
|
+
// Strategies
|
|
131
|
+
// -----------------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
interface ReplaceArgs {
|
|
134
|
+
path?: string;
|
|
135
|
+
edits?: ReplaceEditEntry[];
|
|
136
|
+
__partialJson?: string;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const replaceStrategy: EditStreamingStrategy<ReplaceArgs> = {
|
|
140
|
+
extractCompleteEdits(args, partialJson) {
|
|
141
|
+
if (!args?.edits) return args;
|
|
142
|
+
return { ...args, edits: dropIncompleteLastEdit(args.edits, partialJson, "edits") };
|
|
143
|
+
},
|
|
144
|
+
async computeDiffPreview(args, ctx) {
|
|
145
|
+
const first = args.edits?.[0];
|
|
146
|
+
if (!first) return null;
|
|
147
|
+
const path = first.path ?? args.path;
|
|
148
|
+
if (!path || first.old_text === undefined || first.new_text === undefined) return null;
|
|
149
|
+
ctx.signal.throwIfAborted();
|
|
150
|
+
const result = await computeEditDiff(
|
|
151
|
+
path,
|
|
152
|
+
first.old_text,
|
|
153
|
+
first.new_text,
|
|
154
|
+
ctx.cwd,
|
|
155
|
+
ctx.allowFuzzy ?? true,
|
|
156
|
+
first.all,
|
|
157
|
+
ctx.fuzzyThreshold,
|
|
158
|
+
);
|
|
159
|
+
ctx.signal.throwIfAborted();
|
|
160
|
+
return [toPerFilePreview(path, result)];
|
|
161
|
+
},
|
|
162
|
+
renderStreamingFallback() {
|
|
163
|
+
return "";
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
interface PatchArgs {
|
|
168
|
+
path?: string;
|
|
169
|
+
edits?: PatchEditEntry[];
|
|
170
|
+
__partialJson?: string;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const patchStrategy: EditStreamingStrategy<PatchArgs> = {
|
|
174
|
+
extractCompleteEdits(args, partialJson) {
|
|
175
|
+
if (!args?.edits) return args;
|
|
176
|
+
return { ...args, edits: dropIncompleteLastEdit(args.edits, partialJson, "edits") };
|
|
177
|
+
},
|
|
178
|
+
async computeDiffPreview(args, ctx) {
|
|
179
|
+
const first = args.edits?.[0];
|
|
180
|
+
const path = first?.path ?? args.path;
|
|
181
|
+
if (!path) return null;
|
|
182
|
+
ctx.signal.throwIfAborted();
|
|
183
|
+
const result = await computePatchDiff(
|
|
184
|
+
{ path, op: first?.op ?? "update", rename: first?.rename, diff: first?.diff },
|
|
185
|
+
ctx.cwd,
|
|
186
|
+
{ fuzzyThreshold: ctx.fuzzyThreshold, allowFuzzy: ctx.allowFuzzy },
|
|
187
|
+
);
|
|
188
|
+
ctx.signal.throwIfAborted();
|
|
189
|
+
return [toPerFilePreview(path, result)];
|
|
190
|
+
},
|
|
191
|
+
renderStreamingFallback() {
|
|
192
|
+
return "";
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
interface HashlineArgs {
|
|
197
|
+
path?: string;
|
|
198
|
+
edits?: HashlineToolEdit[];
|
|
199
|
+
__partialJson?: string;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const hashlineStrategy: EditStreamingStrategy<HashlineArgs> = {
|
|
203
|
+
extractCompleteEdits(args, partialJson) {
|
|
204
|
+
if (!args?.edits) return args;
|
|
205
|
+
return { ...args, edits: dropIncompleteLastEdit(args.edits, partialJson, "edits") };
|
|
206
|
+
},
|
|
207
|
+
async computeDiffPreview(args, ctx) {
|
|
208
|
+
const first = args.edits?.[0] as (HashlineToolEdit & { path?: string }) | undefined;
|
|
209
|
+
const path = first?.path ?? args.path;
|
|
210
|
+
if (!path) return null;
|
|
211
|
+
const fileEdits = (args.edits ?? [])
|
|
212
|
+
.map(e => {
|
|
213
|
+
if (!e || typeof e !== "object") return undefined;
|
|
214
|
+
const entryPath = (e as { path?: string }).path ?? args.path;
|
|
215
|
+
if (!entryPath || entryPath !== path) return undefined;
|
|
216
|
+
return { ...(e as HashlineToolEdit), path } as HashlineToolEdit & { path: string };
|
|
217
|
+
})
|
|
218
|
+
.filter((e): e is HashlineToolEdit & { path: string } => e !== undefined);
|
|
219
|
+
ctx.signal.throwIfAborted();
|
|
220
|
+
const result = await computeHashlineDiff({ path, edits: fileEdits }, ctx.cwd);
|
|
221
|
+
ctx.signal.throwIfAborted();
|
|
222
|
+
return [toPerFilePreview(path, result)];
|
|
223
|
+
},
|
|
224
|
+
renderStreamingFallback() {
|
|
225
|
+
return "";
|
|
226
|
+
},
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
interface ChunkArgs {
|
|
230
|
+
path?: string;
|
|
231
|
+
edits?: ChunkToolEdit[];
|
|
232
|
+
__partialJson?: string;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const chunkStrategy: EditStreamingStrategy<ChunkArgs> = {
|
|
236
|
+
extractCompleteEdits(args, partialJson) {
|
|
237
|
+
if (!args?.edits) return args;
|
|
238
|
+
let edits = dropIncompleteLastEdit(args.edits, partialJson, "edits");
|
|
239
|
+
// Extra guard: if partial JSON still contains `":nu` / `":nul` (partial
|
|
240
|
+
// `null` literals), `partial-json` may have already surfaced the last
|
|
241
|
+
// entry with `write === null`. When that entry's `}` hasn't closed
|
|
242
|
+
// yet, it has already been dropped above. But if dropping was not
|
|
243
|
+
// triggered (e.g. list still open and no new `{` after), also drop the
|
|
244
|
+
// trailing null-write entry so the preview does not flicker with an
|
|
245
|
+
// error for an incomplete string/null literal.
|
|
246
|
+
if (partialJson && edits.length > 0) {
|
|
247
|
+
const last = edits[edits.length - 1] as Partial<ChunkToolEdit> | undefined;
|
|
248
|
+
const endsInPartialNull = /:\s*nu?l?\s*$/.test(partialJson.trimEnd());
|
|
249
|
+
if (last && endsInPartialNull && last.write === null) {
|
|
250
|
+
edits = edits.slice(0, -1);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return { ...args, edits };
|
|
254
|
+
},
|
|
255
|
+
async computeDiffPreview(args, ctx) {
|
|
256
|
+
const edits = args.edits ?? [];
|
|
257
|
+
if (edits.length === 0) return null;
|
|
258
|
+
// Group edits by file path
|
|
259
|
+
const groups = new Map<string, ChunkToolEdit[]>();
|
|
260
|
+
const fileOrder: string[] = [];
|
|
261
|
+
for (const edit of edits) {
|
|
262
|
+
if (!edit) continue;
|
|
263
|
+
const editPath = edit.path ?? args.path;
|
|
264
|
+
if (!editPath) continue;
|
|
265
|
+
const { filePath } = parseChunkEditPath(editPath);
|
|
266
|
+
if (!filePath) continue;
|
|
267
|
+
let bucket = groups.get(filePath);
|
|
268
|
+
if (!bucket) {
|
|
269
|
+
bucket = [];
|
|
270
|
+
groups.set(filePath, bucket);
|
|
271
|
+
fileOrder.push(filePath);
|
|
272
|
+
}
|
|
273
|
+
bucket.push({ ...edit, path: editPath });
|
|
274
|
+
}
|
|
275
|
+
if (fileOrder.length === 0) return null;
|
|
276
|
+
|
|
277
|
+
const MAX_FILES = 5;
|
|
278
|
+
const selected = fileOrder.slice(0, MAX_FILES);
|
|
279
|
+
const previews: PerFileDiffPreview[] = [];
|
|
280
|
+
for (const filePath of selected) {
|
|
281
|
+
ctx.signal.throwIfAborted();
|
|
282
|
+
const fileEdits = groups.get(filePath) ?? [];
|
|
283
|
+
const result = await computeChunkDiff({ path: filePath, edits: fileEdits }, ctx.cwd, { signal: ctx.signal });
|
|
284
|
+
previews.push(toPerFilePreview(filePath, result));
|
|
285
|
+
}
|
|
286
|
+
return previews;
|
|
287
|
+
},
|
|
288
|
+
renderStreamingFallback() {
|
|
289
|
+
return "";
|
|
290
|
+
},
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
interface ApplyPatchArgs {
|
|
294
|
+
input?: string;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const applyPatchStrategy: EditStreamingStrategy<ApplyPatchArgs> = {
|
|
298
|
+
extractCompleteEdits(args) {
|
|
299
|
+
// Apply_patch payload is plain text, not an edits array. Nothing to trim.
|
|
300
|
+
return args;
|
|
301
|
+
},
|
|
302
|
+
async computeDiffPreview(args, ctx) {
|
|
303
|
+
if (typeof args.input !== "string" || args.input.length === 0) return null;
|
|
304
|
+
let entries: PatchEditEntry[];
|
|
305
|
+
try {
|
|
306
|
+
entries = expandApplyPatchToEntries({ input: args.input });
|
|
307
|
+
} catch {
|
|
308
|
+
try {
|
|
309
|
+
entries = expandApplyPatchToPreviewEntries({ input: args.input });
|
|
310
|
+
} catch (err) {
|
|
311
|
+
return [{ path: "", error: err instanceof Error ? err.message : String(err) }];
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
const first = entries[0];
|
|
315
|
+
if (!first?.path) return null;
|
|
316
|
+
ctx.signal.throwIfAborted();
|
|
317
|
+
const result = await computePatchDiff(
|
|
318
|
+
{ path: first.path, op: first.op ?? "update", rename: first.rename, diff: first.diff },
|
|
319
|
+
ctx.cwd,
|
|
320
|
+
{ fuzzyThreshold: ctx.fuzzyThreshold, allowFuzzy: ctx.allowFuzzy },
|
|
321
|
+
);
|
|
322
|
+
ctx.signal.throwIfAborted();
|
|
323
|
+
return [toPerFilePreview(first.path, result)];
|
|
324
|
+
},
|
|
325
|
+
renderStreamingFallback() {
|
|
326
|
+
return "";
|
|
327
|
+
},
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
// Vim streaming preview is handled by the existing vimToolRenderer inside
|
|
331
|
+
// edit/renderer.ts. The strategy here is a no-op so the registry is total.
|
|
332
|
+
const vimStrategy: EditStreamingStrategy<unknown> = {
|
|
333
|
+
extractCompleteEdits(args) {
|
|
334
|
+
return args;
|
|
335
|
+
},
|
|
336
|
+
async computeDiffPreview() {
|
|
337
|
+
return null;
|
|
338
|
+
},
|
|
339
|
+
renderStreamingFallback() {
|
|
340
|
+
return "";
|
|
341
|
+
},
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
interface AtomArgs {
|
|
345
|
+
path?: string;
|
|
346
|
+
edits?: unknown[];
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const atomStrategy: EditStreamingStrategy<AtomArgs> = {
|
|
350
|
+
extractCompleteEdits(args, partialJson) {
|
|
351
|
+
if (!args.edits) return args;
|
|
352
|
+
return { ...args, edits: dropIncompleteLastEdit(args.edits, partialJson, "edits") };
|
|
353
|
+
},
|
|
354
|
+
async computeDiffPreview() {
|
|
355
|
+
// Atom edits are line-anchored and validated against live file hashes; a
|
|
356
|
+
// streaming preview without that validation could mislead. Skip for now.
|
|
357
|
+
return null;
|
|
358
|
+
},
|
|
359
|
+
renderStreamingFallback() {
|
|
360
|
+
return "";
|
|
361
|
+
},
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
export const EDIT_MODE_STRATEGIES: Record<EditMode, EditStreamingStrategy<unknown>> = {
|
|
365
|
+
replace: replaceStrategy as EditStreamingStrategy<unknown>,
|
|
366
|
+
patch: patchStrategy as EditStreamingStrategy<unknown>,
|
|
367
|
+
hashline: hashlineStrategy as EditStreamingStrategy<unknown>,
|
|
368
|
+
chunk: chunkStrategy as EditStreamingStrategy<unknown>,
|
|
369
|
+
apply_patch: applyPatchStrategy as EditStreamingStrategy<unknown>,
|
|
370
|
+
vim: vimStrategy,
|
|
371
|
+
atom: atomStrategy as EditStreamingStrategy<unknown>,
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
export { resolveEditMode };
|
|
375
|
+
|
|
376
|
+
// -----------------------------------------------------------------------------
|
|
377
|
+
// Helpers
|
|
378
|
+
// -----------------------------------------------------------------------------
|
|
379
|
+
|
|
380
|
+
function toPerFilePreview(path: string, result: DiffResult | DiffError): PerFileDiffPreview {
|
|
381
|
+
if ("error" in result) {
|
|
382
|
+
return { path, error: result.error };
|
|
383
|
+
}
|
|
384
|
+
return { path, diff: result.diff, firstChangedLine: result.firstChangedLine };
|
|
385
|
+
}
|
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
* Uses brush-core via native bindings for shell execution.
|
|
5
5
|
*/
|
|
6
6
|
import * as fs from "node:fs/promises";
|
|
7
|
-
import { executeShell, Shell } from "@oh-my-pi/pi-natives";
|
|
8
|
-
import { Settings } from "../config/settings";
|
|
7
|
+
import { executeShell, type MinimizerOptions, Shell } from "@oh-my-pi/pi-natives";
|
|
8
|
+
import { Settings, type ShellMinimizerSettings } from "../config/settings";
|
|
9
9
|
import { OutputSink } from "../session/streaming-output";
|
|
10
10
|
import { getOrCreateSnapshot } from "../utils/shell-snapshot";
|
|
11
11
|
import { NON_INTERACTIVE_ENV } from "./non-interactive-env";
|
|
@@ -22,6 +22,17 @@ export interface BashExecutorOptions {
|
|
|
22
22
|
/** Artifact path/id for full output storage */
|
|
23
23
|
artifactPath?: string;
|
|
24
24
|
artifactId?: string;
|
|
25
|
+
/**
|
|
26
|
+
* Invoked when the native minimizer rewrote the command's output, giving
|
|
27
|
+
* the caller a chance to persist the lossless original capture (typically
|
|
28
|
+
* via the session's `ArtifactManager`). The returned id is spliced into
|
|
29
|
+
* the sink output as `artifact://<id>` so the agent can retrieve the raw
|
|
30
|
+
* bytes. Return `undefined` to skip the footer.
|
|
31
|
+
*/
|
|
32
|
+
onMinimizedSave?: (
|
|
33
|
+
originalText: string,
|
|
34
|
+
info: { filter: string; inputBytes: number; outputBytes: number },
|
|
35
|
+
) => Promise<string | undefined>;
|
|
25
36
|
}
|
|
26
37
|
|
|
27
38
|
export interface BashResult {
|
|
@@ -53,10 +64,24 @@ async function resolveShellCwd(cwd: string | undefined): Promise<string | undefi
|
|
|
53
64
|
}
|
|
54
65
|
}
|
|
55
66
|
|
|
67
|
+
function buildMinimizerOptions(group: ShellMinimizerSettings): MinimizerOptions | undefined {
|
|
68
|
+
if (!group.enabled) return undefined;
|
|
69
|
+
return {
|
|
70
|
+
enabled: true,
|
|
71
|
+
settingsPath: group.settingsPath || undefined,
|
|
72
|
+
only: group.only.length > 0 ? group.only : undefined,
|
|
73
|
+
except: group.except.length > 0 ? group.except : undefined,
|
|
74
|
+
maxCaptureBytes: group.maxCaptureBytes,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
56
78
|
export async function executeBash(command: string, options?: BashExecutorOptions): Promise<BashResult> {
|
|
57
79
|
const settings = await Settings.init();
|
|
58
80
|
const { shell, env: shellEnv, prefix } = settings.getShellConfig();
|
|
59
81
|
const snapshotPath = shell.includes("bash") ? await getOrCreateSnapshot(shell, shellEnv) : null;
|
|
82
|
+
|
|
83
|
+
const minimizer = buildMinimizerOptions(settings.getGroup("shellMinimizer"));
|
|
84
|
+
|
|
60
85
|
const commandCwd = await resolveShellCwd(options?.cwd);
|
|
61
86
|
const commandEnv = options?.env ? { ...NON_INTERACTIVE_ENV, ...options.env } : NON_INTERACTIVE_ENV;
|
|
62
87
|
|
|
@@ -89,7 +114,7 @@ export async function executeBash(command: string, options?: BashExecutorOptions
|
|
|
89
114
|
};
|
|
90
115
|
}
|
|
91
116
|
|
|
92
|
-
const sessionKey = buildSessionKey(shell, prefix, snapshotPath, shellEnv, options?.sessionKey);
|
|
117
|
+
const sessionKey = buildSessionKey(shell, prefix, snapshotPath, shellEnv, options?.sessionKey, minimizer);
|
|
93
118
|
const persistentSessionBroken = brokenShellSessions.has(sessionKey);
|
|
94
119
|
if (persistentSessionBroken) {
|
|
95
120
|
shellSessions.delete(sessionKey);
|
|
@@ -97,7 +122,11 @@ export async function executeBash(command: string, options?: BashExecutorOptions
|
|
|
97
122
|
|
|
98
123
|
let shellSession = persistentSessionBroken ? undefined : shellSessions.get(sessionKey);
|
|
99
124
|
if (!shellSession && !persistentSessionBroken) {
|
|
100
|
-
shellSession = new Shell({
|
|
125
|
+
shellSession = new Shell({
|
|
126
|
+
sessionEnv: shellEnv,
|
|
127
|
+
snapshotPath: snapshotPath ?? undefined,
|
|
128
|
+
minimizer,
|
|
129
|
+
});
|
|
101
130
|
shellSessions.set(sessionKey, shellSession);
|
|
102
131
|
}
|
|
103
132
|
const userSignal = options?.signal;
|
|
@@ -152,6 +181,7 @@ export async function executeBash(command: string, options?: BashExecutorOptions
|
|
|
152
181
|
env: commandEnv,
|
|
153
182
|
sessionEnv: shellEnv,
|
|
154
183
|
snapshotPath: snapshotPath ?? undefined,
|
|
184
|
+
minimizer,
|
|
155
185
|
timeoutMs: options?.timeout,
|
|
156
186
|
signal: runAbortController.signal,
|
|
157
187
|
},
|
|
@@ -204,6 +234,25 @@ export async function executeBash(command: string, options?: BashExecutorOptions
|
|
|
204
234
|
};
|
|
205
235
|
}
|
|
206
236
|
|
|
237
|
+
// When the native minimizer rewrote the output, swap the sink's accumulated
|
|
238
|
+
// raw stream for the minimized text, persist the original as a session
|
|
239
|
+
// artifact, and splice an `artifact://<id>` footer into the visible text so
|
|
240
|
+
// the agent can retrieve the raw bytes losslessly.
|
|
241
|
+
const minimized = winner.result.minimized;
|
|
242
|
+
if (minimized && minimized.text !== minimized.originalText) {
|
|
243
|
+
sink.replace(minimized.text);
|
|
244
|
+
if (options?.onMinimizedSave) {
|
|
245
|
+
const artifactId = await options.onMinimizedSave(minimized.originalText, {
|
|
246
|
+
filter: minimized.filter,
|
|
247
|
+
inputBytes: minimized.inputBytes,
|
|
248
|
+
outputBytes: minimized.outputBytes,
|
|
249
|
+
});
|
|
250
|
+
if (artifactId) {
|
|
251
|
+
sink.push(`\n[raw output: artifact://${artifactId}]\n`);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
207
256
|
// Normal completion
|
|
208
257
|
return {
|
|
209
258
|
exitCode: winner.result.exitCode,
|
|
@@ -232,9 +281,13 @@ function buildSessionKey(
|
|
|
232
281
|
snapshotPath: string | null,
|
|
233
282
|
env: Record<string, string>,
|
|
234
283
|
agentSessionKey?: string,
|
|
284
|
+
minimizer?: MinimizerOptions,
|
|
235
285
|
): string {
|
|
236
286
|
const entries = Object.entries(env);
|
|
237
287
|
entries.sort(([a], [b]) => a.localeCompare(b));
|
|
238
288
|
const envSerialized = entries.map(([key, value]) => `${key}=${value}`).join("\n");
|
|
239
|
-
|
|
289
|
+
const minimizerSerialized = minimizer ? JSON.stringify(minimizer) : "";
|
|
290
|
+
return [agentSessionKey ?? "", shell, prefix ?? "", snapshotPath ?? "", envSerialized, minimizerSerialized].join(
|
|
291
|
+
"\n",
|
|
292
|
+
);
|
|
240
293
|
}
|