decorated-pi 0.8.0 → 0.8.1
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/README.md +0 -2
- package/hooks/image-vision.ts +127 -5
- package/hooks/mcp.ts +10 -2
- package/hooks/pi-tool-filter.ts +2 -6
- package/index.ts +1 -3
- package/package.json +1 -1
- package/skills/pi-docs/SKILL.md +3 -4
- package/tools/patch/core.ts +41 -57
- package/tools/patch/index.ts +11 -7
- package/ui/ask.ts +71 -31
package/README.md
CHANGED
|
@@ -32,13 +32,11 @@ Multiple layers of token savings that compound across every session.
|
|
|
32
32
|
**Cache‑friendly Design** — stable system prompt prefix:
|
|
33
33
|
|
|
34
34
|
- tool definitions, guidelines, and skills are sorted alphabetically so the system prompt is identical across sessions
|
|
35
|
-
- volatile elements like `Current date: …` are stripped before prompt assembly
|
|
36
35
|
- MCP tool schemas are persisted to a local cache, so the tool list stays stable regardless of network conditions or server availability
|
|
37
36
|
|
|
38
37
|
**Pi Native Prompt Slimming**
|
|
39
38
|
|
|
40
39
|
- move the default Pi documentation block out of the system prompt and into a builtin `pi-docs` skill, so the docs reference loads on demand instead of sitting in every turn's prompt
|
|
41
|
-
- unregister the native `write` tool, since `bash` can handle it
|
|
42
40
|
|
|
43
41
|
**Large Result Externalization**
|
|
44
42
|
|
package/hooks/image-vision.ts
CHANGED
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { fileTypeFromFile } from "file-type";
|
|
7
|
+
import { Buffer } from "node:buffer";
|
|
7
8
|
import * as fs from "node:fs";
|
|
9
|
+
import * as os from "node:os";
|
|
8
10
|
import { extname, resolve } from "node:path";
|
|
9
11
|
import OpenAI from "openai";
|
|
10
12
|
import type { Model } from "@earendil-works/pi-ai";
|
|
@@ -13,6 +15,12 @@ import type { Module, Skeleton } from "./skeleton.js";
|
|
|
13
15
|
|
|
14
16
|
const SUPPORTED_IMAGE_TYPES = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"]);
|
|
15
17
|
|
|
18
|
+
function expandHome(filePath: string): string {
|
|
19
|
+
if (filePath.startsWith("~/")) return resolve(os.homedir(), filePath.slice(2));
|
|
20
|
+
if (filePath === "~") return os.homedir();
|
|
21
|
+
return filePath;
|
|
22
|
+
}
|
|
23
|
+
|
|
16
24
|
async function detectImageMimeType(filePath: string): Promise<string | null> {
|
|
17
25
|
try {
|
|
18
26
|
const type = await fileTypeFromFile(filePath);
|
|
@@ -32,6 +40,9 @@ export async function analyzeImage(
|
|
|
32
40
|
if (model.api === "anthropic-messages") {
|
|
33
41
|
return analyzeAnthropic(model, imageBase64, mediaType, apiKey, extraHeaders);
|
|
34
42
|
}
|
|
43
|
+
if (model.api === "openai-codex-responses") {
|
|
44
|
+
return analyzeCodex(model, imageBase64, mediaType, apiKey, extraHeaders);
|
|
45
|
+
}
|
|
35
46
|
return analyzeOpenAI(model, imageBase64, mediaType, apiKey, extraHeaders);
|
|
36
47
|
}
|
|
37
48
|
|
|
@@ -51,6 +62,99 @@ async function analyzeOpenAI(
|
|
|
51
62
|
return resp.choices[0]?.message?.content ?? "No analysis returned.";
|
|
52
63
|
}
|
|
53
64
|
|
|
65
|
+
function codexResponsesUrl(baseUrl?: string): string {
|
|
66
|
+
const base = (baseUrl || "https://chatgpt.com/backend-api").replace(/\/+$/, "");
|
|
67
|
+
return base.endsWith("/codex/responses") ? base
|
|
68
|
+
: base.endsWith("/codex") ? `${base}/responses`
|
|
69
|
+
: `${base}/codex/responses`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function extractCodexAccountId(apiKey: string): string {
|
|
73
|
+
try {
|
|
74
|
+
const parts = apiKey.split(".");
|
|
75
|
+
if (parts.length !== 3) return "";
|
|
76
|
+
const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
|
|
77
|
+
return payload?.["https://api.openai.com/auth"]?.chatgpt_account_id ?? "";
|
|
78
|
+
} catch {
|
|
79
|
+
return "";
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function appendCodexSseDelta(outputText: string, line: string): string {
|
|
84
|
+
if (!line.startsWith("data: ")) return outputText;
|
|
85
|
+
const data = line.slice(6).trim();
|
|
86
|
+
if (data === "[DONE]") return outputText;
|
|
87
|
+
try {
|
|
88
|
+
const parsed = JSON.parse(data);
|
|
89
|
+
if (parsed.type === "response.output_text.delta") {
|
|
90
|
+
return outputText + (parsed.delta ?? "");
|
|
91
|
+
}
|
|
92
|
+
} catch { /* skip malformed */ }
|
|
93
|
+
return outputText;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function analyzeCodex(
|
|
97
|
+
model: Model<any>, imageBase64: string, mediaType: string,
|
|
98
|
+
apiKey: string, extraHeaders: Record<string, string>,
|
|
99
|
+
): Promise<string> {
|
|
100
|
+
// Codex uses /codex/responses, not the standard /responses path.
|
|
101
|
+
const url = codexResponsesUrl(model.baseUrl);
|
|
102
|
+
|
|
103
|
+
// Codex auth: extract accountId from JWT, set special headers.
|
|
104
|
+
const accountId = extractCodexAccountId(apiKey);
|
|
105
|
+
|
|
106
|
+
const headers: Record<string, string> = {
|
|
107
|
+
"Content-Type": "application/json",
|
|
108
|
+
"Authorization": `Bearer ${apiKey}`,
|
|
109
|
+
"Originator": "pi",
|
|
110
|
+
"OpenAI-Beta": "responses=experimental",
|
|
111
|
+
"accept": "text/event-stream",
|
|
112
|
+
...extraHeaders,
|
|
113
|
+
};
|
|
114
|
+
if (accountId) headers["chatgpt-account-id"] = accountId;
|
|
115
|
+
|
|
116
|
+
const resp = await fetch(url, {
|
|
117
|
+
method: "POST",
|
|
118
|
+
headers,
|
|
119
|
+
body: JSON.stringify({
|
|
120
|
+
model: model.id,
|
|
121
|
+
store: false,
|
|
122
|
+
stream: true,
|
|
123
|
+
input: [{
|
|
124
|
+
role: "user",
|
|
125
|
+
content: [
|
|
126
|
+
{ type: "input_text", text: DEFAULT_PROMPT },
|
|
127
|
+
{ type: "input_image", image_url: `data:${mediaType};base64,${imageBase64}`, detail: "auto" },
|
|
128
|
+
],
|
|
129
|
+
}],
|
|
130
|
+
text: { format: { type: "text" } },
|
|
131
|
+
}),
|
|
132
|
+
signal: AbortSignal.timeout(60_000),
|
|
133
|
+
});
|
|
134
|
+
if (!resp.ok) {
|
|
135
|
+
const text = await resp.text().catch(() => "");
|
|
136
|
+
throw new Error(`Codex API error ${resp.status}: ${text.slice(0, 300)}`);
|
|
137
|
+
}
|
|
138
|
+
// Codex only supports SSE streaming; read the event stream.
|
|
139
|
+
const reader = resp.body?.getReader();
|
|
140
|
+
if (!reader) throw new Error("No response body");
|
|
141
|
+
const decoder = new TextDecoder();
|
|
142
|
+
let buffer = "";
|
|
143
|
+
let outputText = "";
|
|
144
|
+
while (true) {
|
|
145
|
+
const { done, value } = await reader.read();
|
|
146
|
+
if (done) break;
|
|
147
|
+
buffer += decoder.decode(value, { stream: true });
|
|
148
|
+
const lines = buffer.split(/\r?\n/);
|
|
149
|
+
buffer = lines.pop() ?? "";
|
|
150
|
+
for (const line of lines) outputText = appendCodexSseDelta(outputText, line);
|
|
151
|
+
}
|
|
152
|
+
buffer += decoder.decode();
|
|
153
|
+
for (const line of buffer.split(/\r?\n/)) outputText = appendCodexSseDelta(outputText, line);
|
|
154
|
+
return outputText || "No analysis returned.";
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
|
|
54
158
|
async function analyzeAnthropic(
|
|
55
159
|
model: Model<any>, imageBase64: string, mediaType: string,
|
|
56
160
|
apiKey: string, extraHeaders: Record<string, string>,
|
|
@@ -83,10 +187,20 @@ export const imageVisionModule: Module = {
|
|
|
83
187
|
if (event.toolName !== "read") return;
|
|
84
188
|
const filePath: string | undefined = (event.input as any)?.file ?? (event.input as any)?.path;
|
|
85
189
|
if (!filePath) return;
|
|
86
|
-
const mimeType = await detectImageMimeType(resolve(ctx.cwd, filePath));
|
|
190
|
+
const mimeType = await detectImageMimeType(resolve(ctx.cwd, expandHome(filePath)));
|
|
87
191
|
if (!mimeType) return;
|
|
88
|
-
|
|
192
|
+
const imageKey = getImageModelKey();
|
|
193
|
+
if (!imageKey) return;
|
|
89
194
|
pendingImageFallbacks.add(event.toolCallId);
|
|
195
|
+
if (ctx.hasUI) {
|
|
196
|
+
const p = parseModelKey(imageKey);
|
|
197
|
+
if (p) {
|
|
198
|
+
ctx.ui.notify(
|
|
199
|
+
`🖼️ Analyzing image with ${p.provider}/${p.modelId}...`,
|
|
200
|
+
"info",
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
90
204
|
},
|
|
91
205
|
],
|
|
92
206
|
tool_result: [
|
|
@@ -101,7 +215,7 @@ export const imageVisionModule: Module = {
|
|
|
101
215
|
const imageModel = ctx.modelRegistry.find(parsed.provider, parsed.modelId);
|
|
102
216
|
if (!imageModel) return;
|
|
103
217
|
try {
|
|
104
|
-
const absPath = resolve(ctx.cwd, filePath);
|
|
218
|
+
const absPath = resolve(ctx.cwd, expandHome(filePath));
|
|
105
219
|
const imageData = fs.readFileSync(absPath);
|
|
106
220
|
const imageBase64 = imageData.toString("base64");
|
|
107
221
|
const mimeType = (await detectImageMimeType(absPath)) ?? "image/png";
|
|
@@ -113,13 +227,19 @@ export const imageVisionModule: Module = {
|
|
|
113
227
|
);
|
|
114
228
|
return {
|
|
115
229
|
...event,
|
|
116
|
-
content: [{ type: "text", text:
|
|
230
|
+
content: [{ type: "text", text: analysis }],
|
|
117
231
|
details: { imageModel: imageKey, originalPath: filePath },
|
|
118
232
|
};
|
|
119
233
|
} catch (error) {
|
|
234
|
+
if (ctx.hasUI) {
|
|
235
|
+
ctx.ui.notify(
|
|
236
|
+
`Image analysis failed: ${error instanceof Error ? error.message : error}`,
|
|
237
|
+
"warning",
|
|
238
|
+
);
|
|
239
|
+
}
|
|
120
240
|
return {
|
|
121
241
|
...event,
|
|
122
|
-
content: [{ type: "text", text: `Image analysis failed
|
|
242
|
+
content: [{ type: "text", text: `Image analysis failed` }],
|
|
123
243
|
};
|
|
124
244
|
}
|
|
125
245
|
},
|
|
@@ -130,3 +250,5 @@ export const imageVisionModule: Module = {
|
|
|
130
250
|
export function setupImageVision(sk: Skeleton): void {
|
|
131
251
|
sk.register(imageVisionModule);
|
|
132
252
|
}
|
|
253
|
+
|
|
254
|
+
export const __imageVisionTest = { expandHome, analyzeCodex, appendCodexSseDelta, codexResponsesUrl, extractCodexAccountId };
|
package/hooks/mcp.ts
CHANGED
|
@@ -360,9 +360,17 @@ export const mcpModule: Module = {
|
|
|
360
360
|
|
|
361
361
|
// connectAll runs in the background so the watchdog tests
|
|
362
362
|
// that mock hung connections don't block session_start.
|
|
363
|
+
//
|
|
364
|
+
// Capture hasUI / notify synchronously: connectAll may resolve
|
|
365
|
+
// after the session is replaced or reloaded, at which point `ctx`
|
|
366
|
+
// is stale and its `hasUI` getter throws via assertActive.
|
|
367
|
+
// Using the captured plain values avoids touching the stale ctx
|
|
368
|
+
// in the .then callback.
|
|
369
|
+
const hasUI = ctx.hasUI;
|
|
370
|
+
const notify = ctx.ui?.notify?.bind(ctx.ui);
|
|
363
371
|
void connectAll(toConnect, ctx.modelRegistry, ctx).then(({ schemaChanges }) => {
|
|
364
|
-
if (schemaChanges.length > 0 &&
|
|
365
|
-
|
|
372
|
+
if (schemaChanges.length > 0 && hasUI && notify) {
|
|
373
|
+
notify(`mcp schema changed! please '/reload'`, "warning");
|
|
366
374
|
}
|
|
367
375
|
});
|
|
368
376
|
},
|
package/hooks/pi-tool-filter.ts
CHANGED
|
@@ -1,16 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* pi-tool-filter — unregister pi native tools that are replaced by our extensions.
|
|
3
3
|
*
|
|
4
|
-
* edit
|
|
5
|
-
* write → replaced by patch
|
|
6
|
-
* grep → replaced by bash
|
|
7
|
-
* find → replaced by bash
|
|
8
|
-
* ls → replaced by bash
|
|
4
|
+
* edit → replaced by patch
|
|
9
5
|
*/
|
|
10
6
|
|
|
11
7
|
import type { Module, Skeleton } from "./skeleton.js";
|
|
12
8
|
|
|
13
|
-
const TOOLS_TO_DROP = new Set(["edit"
|
|
9
|
+
const TOOLS_TO_DROP = new Set(["edit"]);
|
|
14
10
|
|
|
15
11
|
export const piToolFilterModule: Module = {
|
|
16
12
|
name: "pi-tool-filter",
|
package/index.ts
CHANGED
|
@@ -198,8 +198,7 @@ function installBuiltinSkills(pi: ExtensionAPI): void {
|
|
|
198
198
|
}
|
|
199
199
|
|
|
200
200
|
/** Install a single before_agent_start handler that appends every
|
|
201
|
-
* guideline in order
|
|
202
|
-
* for cache stability. Idempotent — re-injection is a no-op via marker. */
|
|
201
|
+
* guideline in order. Idempotent — re-injection is a no-op via marker. */
|
|
203
202
|
function installGuidelines(pi: ExtensionAPI): void {
|
|
204
203
|
const blocks = buildGuidelines();
|
|
205
204
|
const joined = blocks.join("\n\n");
|
|
@@ -209,7 +208,6 @@ function installGuidelines(pi: ExtensionAPI): void {
|
|
|
209
208
|
if (!event.systemPrompt) return undefined;
|
|
210
209
|
let prompt: string = stripPiDocsBlock(event.systemPrompt);
|
|
211
210
|
prompt = sortSkillsInSystemPrompt(prompt);
|
|
212
|
-
prompt = prompt.replace(/\nCurrent date: \d{4}-\d{2}-\d{2}/, "");
|
|
213
211
|
if (prompt.includes(marker)) return undefined; // already injected this turn
|
|
214
212
|
return { systemPrompt: `${prompt}\n\n${joined}` };
|
|
215
213
|
});
|
package/package.json
CHANGED
package/skills/pi-docs/SKILL.md
CHANGED
|
@@ -4,10 +4,9 @@ description: pi docs resources
|
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
Pi documentation (read only when the user asks about pi itself, its SDK, extensions, themes, skills, or TUI):
|
|
7
|
-
- Main documentation:
|
|
8
|
-
- Additional docs:
|
|
9
|
-
- Examples:
|
|
10
|
-
- When reading pi docs or examples, resolve docs/... under Additional docs and examples/... under Examples, not the current working directory
|
|
7
|
+
- Main documentation: `$(npm root -g)/@earendil-works/pi-coding-agent/README.md`
|
|
8
|
+
- Additional docs: `$(npm root -g)/@earendil-works/pi-coding-agent/docs`
|
|
9
|
+
- Examples: `$(npm root -g)/@earendil-works/pi-coding-agent/examples` (extensions, custom tools, SDK)
|
|
11
10
|
- When asked about: extensions (docs/extensions.md, examples/extensions/), themes (docs/themes.md), skills (docs/skills.md), prompt templates (docs/prompt-templates.md), TUI components (docs/tui.md), keybindings (docs/keybindings.md), SDK integrations (docs/sdk.md), custom providers (docs/custom-provider.md), adding models (docs/models.md), pi packages (docs/packages.md)
|
|
12
11
|
- When working on pi topics, read the docs and examples, and follow .md cross-references before implementing
|
|
13
12
|
- Always read pi .md files completely and follow links to related docs (e.g., tui.md for TUI API details)
|
package/tools/patch/core.ts
CHANGED
|
@@ -74,6 +74,8 @@ export interface ReplacementInfo {
|
|
|
74
74
|
normStart?: number;
|
|
75
75
|
/** Offset one past the matched region in normalized content (writeback). */
|
|
76
76
|
normEnd?: number;
|
|
77
|
+
/** Anchor was provided but appeared multiple times; matcher fell back to a global old_str search. */
|
|
78
|
+
anchorNotUnique?: boolean;
|
|
77
79
|
/** Optional anchor text (first line only, for hunk display) */
|
|
78
80
|
anchor?: string;
|
|
79
81
|
/** Anchor was provided but not found, and patch fell back to global old_str search */
|
|
@@ -202,6 +204,7 @@ type LocateEditResult =
|
|
|
202
204
|
matchIdx: number;
|
|
203
205
|
displayAnchor?: string;
|
|
204
206
|
anchorMissing: boolean;
|
|
207
|
+
anchorNotUnique: boolean;
|
|
205
208
|
}
|
|
206
209
|
| {
|
|
207
210
|
found: false;
|
|
@@ -225,6 +228,7 @@ function locateEdit(
|
|
|
225
228
|
let searchFrom = 0;
|
|
226
229
|
let displayAnchor: string | undefined;
|
|
227
230
|
let anchorMissing = false;
|
|
231
|
+
let anchorNotUnique = false;
|
|
228
232
|
let anchorState: "ok" | "missing" | "not_unique" = "ok";
|
|
229
233
|
let anchorMessage: string | undefined;
|
|
230
234
|
|
|
@@ -253,7 +257,11 @@ function locateEdit(
|
|
|
253
257
|
// ── Global exact match fallback (when anchor was missing/unusable) ──
|
|
254
258
|
if (matchIdx === -1 && anchorMessage) {
|
|
255
259
|
displayAnchor = edit.anchor;
|
|
256
|
-
|
|
260
|
+
// Distinguish the two degradation modes: a not-unique anchor still
|
|
261
|
+
// appeared in the file (just more than once), whereas a missing anchor
|
|
262
|
+
// did not appear at all. The diff label differs accordingly.
|
|
263
|
+
if (anchorState === "not_unique") anchorNotUnique = true;
|
|
264
|
+
else anchorMissing = true;
|
|
257
265
|
matchIdx = content.indexOf(oldNorm, 0);
|
|
258
266
|
if (matchIdx !== -1) {
|
|
259
267
|
const secondGlobalMatch = content.indexOf(oldNorm, matchIdx + 1);
|
|
@@ -288,7 +296,7 @@ function locateEdit(
|
|
|
288
296
|
}
|
|
289
297
|
}
|
|
290
298
|
|
|
291
|
-
return { found: true, oldNorm, newNorm, matchIdx, displayAnchor, anchorMissing };
|
|
299
|
+
return { found: true, oldNorm, newNorm, matchIdx, displayAnchor, anchorMissing, anchorNotUnique };
|
|
292
300
|
}
|
|
293
301
|
|
|
294
302
|
async function applyEdits(
|
|
@@ -363,7 +371,7 @@ async function applyEdits(
|
|
|
363
371
|
);
|
|
364
372
|
}
|
|
365
373
|
|
|
366
|
-
const { oldNorm, newNorm, matchIdx, displayAnchor, anchorMissing } = located;
|
|
374
|
+
const { oldNorm, newNorm, matchIdx, displayAnchor, anchorMissing, anchorNotUnique } = located;
|
|
367
375
|
|
|
368
376
|
const oldStartLine = lineAtOffset(lineOffsets, matchIdx - cumulativeOffset);
|
|
369
377
|
const oldEndLine = lineAtOffset(lineOffsets, matchIdx - cumulativeOffset + oldNorm.length - 1);
|
|
@@ -389,6 +397,7 @@ async function applyEdits(
|
|
|
389
397
|
normEnd,
|
|
390
398
|
anchor: displayAnchor ? displayAnchor.split("\n")[0] : undefined,
|
|
391
399
|
anchorMissing,
|
|
400
|
+
anchorNotUnique,
|
|
392
401
|
});
|
|
393
402
|
}
|
|
394
403
|
|
|
@@ -490,6 +499,7 @@ async function applyEdits(
|
|
|
490
499
|
newLines: p.newNorm.split("\n").filter((l, i, arr) => !(i === arr.length - 1 && l === "")),
|
|
491
500
|
anchor: p.displayAnchor ? p.displayAnchor.split("\n")[0] : undefined,
|
|
492
501
|
anchorMissing: p.anchorMissing,
|
|
502
|
+
anchorNotUnique: p.anchorNotUnique,
|
|
493
503
|
});
|
|
494
504
|
|
|
495
505
|
neededRanges.push({
|
|
@@ -588,60 +598,26 @@ export async function computePatchPreview(
|
|
|
588
598
|
|
|
589
599
|
for (const edit of patch.edits) {
|
|
590
600
|
if (!edit.old_str) continue;
|
|
591
|
-
let oldNorm = normalizeLineEndings(edit.old_str);
|
|
592
|
-
let newNorm = normalizeLineEndings(edit.new_str);
|
|
593
|
-
|
|
594
|
-
let searchFrom = 0;
|
|
595
|
-
let displayAnchor: string | undefined;
|
|
596
|
-
let anchorMissing = false;
|
|
597
|
-
let anchorNotFoundMessage: string | undefined;
|
|
598
|
-
if (edit.anchor) {
|
|
599
|
-
const anchorNorm = normalizeLineEndings(edit.anchor);
|
|
600
|
-
const idx = content.indexOf(anchorNorm);
|
|
601
|
-
if (idx === -1) {
|
|
602
|
-
anchorNotFoundMessage = `Anchor not found: "${truncate(edit.anchor)}"`;
|
|
603
|
-
} else {
|
|
604
|
-
const secondAnchor = content.indexOf(anchorNorm, idx + 1);
|
|
605
|
-
if (secondAnchor !== -1) {
|
|
606
|
-
anchorNotFoundMessage = `Anchor is not unique: "${truncate(edit.anchor)}"`;
|
|
607
|
-
} else {
|
|
608
|
-
searchFrom = Math.max(0, idx - (oldNorm.length - 1));
|
|
609
|
-
displayAnchor = edit.anchor;
|
|
610
|
-
anchorMissing = false;
|
|
611
|
-
}
|
|
612
|
-
}
|
|
613
|
-
}
|
|
614
601
|
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
const dupDiag = diagnoseOldStrNotUnique(oldNorm, content);
|
|
624
|
-
return { error: `${anchorNotFoundMessage}\n${dupDiag}` };
|
|
625
|
-
}
|
|
626
|
-
}
|
|
602
|
+
// Reuse the shared locator so preview and apply can never drift
|
|
603
|
+
// apart on anchor / fuzzy / uniqueness semantics. The only
|
|
604
|
+
// difference is error reporting: apply throws, preview returns.
|
|
605
|
+
let located: LocateEditResult;
|
|
606
|
+
try {
|
|
607
|
+
located = locateEdit(edit, content, patch.path);
|
|
608
|
+
} catch (e) {
|
|
609
|
+
return { error: e instanceof Error ? e.message : String(e) };
|
|
627
610
|
}
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
if (fuzzy) {
|
|
633
|
-
oldNorm = fuzzy.matched;
|
|
634
|
-
matchIdx = fuzzy.idx;
|
|
635
|
-
newNorm = normalizeIndentForFuzzy(fuzzy.matched.split("\n")[0] ?? "", newNorm);
|
|
636
|
-
} else if (anchorNotFoundMessage) {
|
|
637
|
-
const diag = diagnoseOldStrMismatch(oldNorm, content);
|
|
638
|
-
return { error: `${anchorNotFoundMessage}\nold_str not found: "${truncate(edit.old_str)}"\n${diag}` };
|
|
639
|
-
} else {
|
|
640
|
-
const diag = diagnoseOldStrMismatch(oldNorm, content);
|
|
641
|
-
return { error: `old_str not found: "${truncate(edit.old_str)}".\n${diag}` };
|
|
611
|
+
if (!located.found) {
|
|
612
|
+
const diag = diagnoseOldStrMismatch(located.oldNorm, content);
|
|
613
|
+
if (located.anchorState === "missing" || located.anchorState === "not_unique") {
|
|
614
|
+
return { error: `${located.anchorMessage}\nold_str not found: "${truncate(edit.old_str)}"\n${diag}` };
|
|
642
615
|
}
|
|
616
|
+
return { error: `old_str not found: "${truncate(edit.old_str)}".${edit.anchor ? ` after anchor "${truncate(edit.anchor)}"` : ""}\n${diag}` };
|
|
643
617
|
}
|
|
644
618
|
|
|
619
|
+
const { oldNorm, newNorm, matchIdx, displayAnchor, anchorMissing, anchorNotUnique } = located;
|
|
620
|
+
|
|
645
621
|
const origMatchIdx = matchIdx - cumulativeOffset;
|
|
646
622
|
const oldStartLine = lineAtOffset(lineOffsets, origMatchIdx);
|
|
647
623
|
const oldEndLine = lineAtOffset(lineOffsets, origMatchIdx + oldNorm.length - 1);
|
|
@@ -655,7 +631,7 @@ export async function computePatchPreview(
|
|
|
655
631
|
startLine: Math.max(1, oldStartLine - CONTEXT_LINES),
|
|
656
632
|
endLine: Math.min(totalLines, oldEndLine + CONTEXT_LINES),
|
|
657
633
|
});
|
|
658
|
-
allReplacements.push({ oldStartLine, oldEndLine, newStartLine, newEndLine, oldLines, newLines, anchor: displayAnchor ? displayAnchor.split("\n")[0] : undefined, anchorMissing });
|
|
634
|
+
allReplacements.push({ oldStartLine, oldEndLine, newStartLine, newEndLine, oldLines, newLines, anchor: displayAnchor ? displayAnchor.split("\n")[0] : undefined, anchorMissing, anchorNotUnique });
|
|
659
635
|
cumulativeOffset += newNorm.length - oldNorm.length;
|
|
660
636
|
}
|
|
661
637
|
|
|
@@ -744,6 +720,7 @@ function buildReplacementChunks(
|
|
|
744
720
|
interface ChunkAnchor {
|
|
745
721
|
text: string;
|
|
746
722
|
missing: boolean;
|
|
723
|
+
notUnique?: boolean;
|
|
747
724
|
}
|
|
748
725
|
|
|
749
726
|
function getChunkAnchors(chunk: ReplacementChunk): ChunkAnchor[] {
|
|
@@ -756,9 +733,13 @@ function getChunkAnchors(chunk: ReplacementChunk): ChunkAnchor[] {
|
|
|
756
733
|
for (const text of texts) {
|
|
757
734
|
const existing = byText.get(text);
|
|
758
735
|
if (!existing) {
|
|
759
|
-
byText.set(text, { text, missing: Boolean(rep.anchorMissing) });
|
|
760
|
-
} else
|
|
761
|
-
|
|
736
|
+
byText.set(text, { text, missing: Boolean(rep.anchorMissing), notUnique: Boolean(rep.anchorNotUnique) });
|
|
737
|
+
} else {
|
|
738
|
+
// A later rep with the same anchor text that did NOT degrade clears
|
|
739
|
+
// the stale flag — the anchor is usable in at least one rep, so
|
|
740
|
+
// don't keep a stale warning for the other.
|
|
741
|
+
if (!rep.anchorMissing) existing.missing = false;
|
|
742
|
+
if (!rep.anchorNotUnique) existing.notUnique = false;
|
|
762
743
|
}
|
|
763
744
|
}
|
|
764
745
|
}
|
|
@@ -766,7 +747,9 @@ function getChunkAnchors(chunk: ReplacementChunk): ChunkAnchor[] {
|
|
|
766
747
|
}
|
|
767
748
|
|
|
768
749
|
function formatAnchorLabel(anchor: ChunkAnchor): string {
|
|
769
|
-
|
|
750
|
+
if (anchor.notUnique) return anchor.text + " (not unique)";
|
|
751
|
+
if (anchor.missing) return anchor.text + " (missing)";
|
|
752
|
+
return anchor.text;
|
|
770
753
|
}
|
|
771
754
|
|
|
772
755
|
function formatChunkHeader(chunk: ReplacementChunk): string {
|
|
@@ -1292,6 +1275,7 @@ function collapseSequentialReplacements(
|
|
|
1292
1275
|
normEnd: merged.normEnd,
|
|
1293
1276
|
anchor: undefined,
|
|
1294
1277
|
anchorMissing: merged.anchorMissing || next.anchorMissing,
|
|
1278
|
+
anchorNotUnique: merged.anchorNotUnique || next.anchorNotUnique,
|
|
1295
1279
|
};
|
|
1296
1280
|
j++;
|
|
1297
1281
|
}
|
package/tools/patch/index.ts
CHANGED
|
@@ -162,13 +162,17 @@ function createSingleLineComponent(text: string) {
|
|
|
162
162
|
};
|
|
163
163
|
}
|
|
164
164
|
|
|
165
|
-
function formatPatchMetaLine(line: string, theme: any): string {
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
165
|
+
export function formatPatchMetaLine(line: string, theme: any): string {
|
|
166
|
+
// Anchor status suffixes share the same warning color so a degraded
|
|
167
|
+
// anchor (missing or not-unique) stands out from the diff body.
|
|
168
|
+
const suffixes = [" (missing)", " (not unique)"];
|
|
169
|
+
for (const suffix of suffixes) {
|
|
170
|
+
if (line.endsWith(suffix)) {
|
|
171
|
+
return (
|
|
172
|
+
theme.fg("accent", line.slice(0, -suffix.length)) +
|
|
173
|
+
theme.fg("warning", suffix)
|
|
174
|
+
);
|
|
175
|
+
}
|
|
172
176
|
}
|
|
173
177
|
return theme.fg("accent", line);
|
|
174
178
|
}
|
package/ui/ask.ts
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
21
|
-
import { Container, Spacer, Text, type TUI, type Component, getKeybindings } from "@earendil-works/pi-tui";
|
|
21
|
+
import { Container, Spacer, Text, matchesKey, Key, type TUI, type Component, getKeybindings } from "@earendil-works/pi-tui";
|
|
22
22
|
|
|
23
23
|
export type AskQuestionType = "text" | "single" | "multi";
|
|
24
24
|
|
|
@@ -37,25 +37,30 @@ export interface AskAnswer {
|
|
|
37
37
|
|
|
38
38
|
interface QuestionState {
|
|
39
39
|
value: string | string[];
|
|
40
|
+
/** Cursor position within value for text type (0 = before first char). */
|
|
41
|
+
textCursor: number;
|
|
40
42
|
cursor: number; // option cursor; opts.length maps to the "Other" row
|
|
41
43
|
/** Typed text when the cursor sits on the "Other" row (single or multi). */
|
|
42
44
|
customText: string;
|
|
45
|
+
/** Cursor position within customText (0 = before first char). */
|
|
46
|
+
customCursor: number;
|
|
43
47
|
/** Multi only: whether "Other" is currently toggled into the selection. */
|
|
44
48
|
customSelected: boolean;
|
|
45
49
|
}
|
|
46
50
|
|
|
47
51
|
function parseDefault(type: AskQuestionType, options: string[] | undefined, defaultValue: string | undefined): QuestionState {
|
|
48
52
|
if (type === "text") {
|
|
49
|
-
|
|
53
|
+
const v = defaultValue ?? "";
|
|
54
|
+
return { value: v, textCursor: v.length, cursor: 0, customText: "", customCursor: 0, customSelected: false };
|
|
50
55
|
}
|
|
51
56
|
const opts = options ?? [];
|
|
52
57
|
if (type === "single") {
|
|
53
58
|
const idx = defaultValue ? Math.max(0, opts.indexOf(defaultValue)) : 0;
|
|
54
|
-
return { value: opts[idx] ?? "", cursor: idx, customText: "", customSelected: false };
|
|
59
|
+
return { value: opts[idx] ?? "", textCursor: 0, cursor: idx, customText: "", customCursor: 0, customSelected: false };
|
|
55
60
|
}
|
|
56
61
|
// multi: comma-separated default, or empty
|
|
57
62
|
const selected = defaultValue ? defaultValue.split(",").map(s => s.trim()).filter(Boolean) : [];
|
|
58
|
-
return { value: selected, cursor: 0, customText: "", customSelected: false };
|
|
63
|
+
return { value: selected, textCursor: 0, cursor: 0, customText: "", customCursor: 0, customSelected: false };
|
|
59
64
|
}
|
|
60
65
|
|
|
61
66
|
/** Returns true for single/multi choice questions, which always get an
|
|
@@ -307,13 +312,23 @@ export class AskComponent extends Container {
|
|
|
307
312
|
const state = this.currentState();
|
|
308
313
|
|
|
309
314
|
if (q.type === "text") {
|
|
315
|
+
const tc = state.textCursor;
|
|
316
|
+
const val = state.value as string;
|
|
310
317
|
if (data === "\x7f" || data === "backspace") {
|
|
311
|
-
|
|
318
|
+
if (tc > 0) {
|
|
319
|
+
state.value = val.slice(0, tc - 1) + val.slice(tc);
|
|
320
|
+
state.textCursor--;
|
|
321
|
+
}
|
|
322
|
+
} else if (matchesKey(data, Key.left)) {
|
|
323
|
+
if (tc > 0) state.textCursor--;
|
|
324
|
+
} else if (matchesKey(data, Key.right)) {
|
|
325
|
+
if (tc < val.length) state.textCursor++;
|
|
312
326
|
} else if (data.length > 0 && data.charCodeAt(0) >= 0x20) {
|
|
313
327
|
// Accept any printable character: ASCII, Latin-1, CJK, emoji
|
|
314
328
|
// surrogate pairs. The earlier `>= " " && <= "~"` filter
|
|
315
329
|
// rejected every code point above U+007F (i.e. all Chinese).
|
|
316
|
-
state.value = (
|
|
330
|
+
state.value = val.slice(0, tc) + data + val.slice(tc);
|
|
331
|
+
state.textCursor += data.length;
|
|
317
332
|
}
|
|
318
333
|
} else if (q.type === "single" && q.options) {
|
|
319
334
|
const opts = q.options;
|
|
@@ -328,18 +343,26 @@ export class AskComponent extends Container {
|
|
|
328
343
|
const next = (state.cursor + 1) % totalLen;
|
|
329
344
|
state.cursor = next;
|
|
330
345
|
state.value = next < opts.length ? opts[next] : "";
|
|
346
|
+
} else if (onCustomRow) {
|
|
347
|
+
if (data === "\x7f" || data === "backspace") {
|
|
348
|
+
if (state.customCursor > 0) {
|
|
349
|
+
state.customText = state.customText.slice(0, state.customCursor - 1) + state.customText.slice(state.customCursor);
|
|
350
|
+
state.customCursor--;
|
|
351
|
+
}
|
|
352
|
+
} else if (matchesKey(data, Key.left)) {
|
|
353
|
+
if (state.customCursor > 0) state.customCursor--;
|
|
354
|
+
} else if (matchesKey(data, Key.right)) {
|
|
355
|
+
if (state.customCursor < state.customText.length) state.customCursor++;
|
|
356
|
+
} else if (data.length > 0 && data.charCodeAt(0) >= 0x20) {
|
|
357
|
+
state.customText = state.customText.slice(0, state.customCursor) + data + state.customText.slice(state.customCursor);
|
|
358
|
+
state.customCursor += data.length;
|
|
359
|
+
}
|
|
331
360
|
} else if (data >= "1" && data <= "9") {
|
|
332
361
|
const idx = parseInt(data, 10) - 1;
|
|
333
362
|
if (idx < totalLen) {
|
|
334
363
|
state.cursor = idx;
|
|
335
364
|
state.value = idx < opts.length ? opts[idx] : "";
|
|
336
365
|
}
|
|
337
|
-
} else if (onCustomRow) {
|
|
338
|
-
if (data === "\x7f" || data === "backspace") {
|
|
339
|
-
state.customText = state.customText.slice(0, -1);
|
|
340
|
-
} else if (data.length > 0 && data.charCodeAt(0) >= 0x20) {
|
|
341
|
-
state.customText += data;
|
|
342
|
-
}
|
|
343
366
|
}
|
|
344
367
|
} else if (q.type === "multi" && q.options) {
|
|
345
368
|
const opts = q.options;
|
|
@@ -360,6 +383,21 @@ export class AskComponent extends Container {
|
|
|
360
383
|
else selected.add(opt);
|
|
361
384
|
state.value = Array.from(selected);
|
|
362
385
|
}
|
|
386
|
+
} else if (onCustomRow) {
|
|
387
|
+
if (data === "\x7f" || data === "backspace") {
|
|
388
|
+
if (state.customCursor > 0) {
|
|
389
|
+
state.customText = state.customText.slice(0, state.customCursor - 1) + state.customText.slice(state.customCursor);
|
|
390
|
+
state.customCursor--;
|
|
391
|
+
}
|
|
392
|
+
} else if (matchesKey(data, Key.left)) {
|
|
393
|
+
if (state.customCursor > 0) state.customCursor--;
|
|
394
|
+
} else if (matchesKey(data, Key.right)) {
|
|
395
|
+
if (state.customCursor < state.customText.length) state.customCursor++;
|
|
396
|
+
} else if (data.length > 0 && data.charCodeAt(0) >= 0x20) {
|
|
397
|
+
state.customSelected = true;
|
|
398
|
+
state.customText = state.customText.slice(0, state.customCursor) + data + state.customText.slice(state.customCursor);
|
|
399
|
+
state.customCursor += data.length;
|
|
400
|
+
}
|
|
363
401
|
} else if (data >= "1" && data <= "9") {
|
|
364
402
|
const idx = parseInt(data, 10) - 1;
|
|
365
403
|
if (idx < totalLen) {
|
|
@@ -372,15 +410,6 @@ export class AskComponent extends Container {
|
|
|
372
410
|
state.value = Array.from(selected);
|
|
373
411
|
}
|
|
374
412
|
}
|
|
375
|
-
} else if (onCustomRow && data.length > 0 && data.charCodeAt(0) >= 0x20) {
|
|
376
|
-
// Typing on the "Other" row auto-selects it and feeds customText.
|
|
377
|
-
// Backspace never enters this branch (handled below).
|
|
378
|
-
if (data !== "\x7f" && data !== "backspace") {
|
|
379
|
-
state.customSelected = true;
|
|
380
|
-
state.customText += data;
|
|
381
|
-
} else {
|
|
382
|
-
state.customText = state.customText.slice(0, -1);
|
|
383
|
-
}
|
|
384
413
|
}
|
|
385
414
|
}
|
|
386
415
|
|
|
@@ -415,8 +444,11 @@ export class AskComponent extends Container {
|
|
|
415
444
|
|
|
416
445
|
if (q.type === "text") {
|
|
417
446
|
const value = state.value as string;
|
|
418
|
-
const
|
|
419
|
-
|
|
447
|
+
const tc = state.textCursor;
|
|
448
|
+
const before = value.slice(0, tc);
|
|
449
|
+
const after = value.slice(tc);
|
|
450
|
+
const cursor = this.theme.fg("accent", "|");
|
|
451
|
+
lines.push(`${before}${cursor}${after}`);
|
|
420
452
|
} else if (q.type === "single" && q.options) {
|
|
421
453
|
const opts = q.options;
|
|
422
454
|
const totalLen = effectiveOptionCount(q);
|
|
@@ -432,12 +464,16 @@ export class AskComponent extends Container {
|
|
|
432
464
|
let line: string;
|
|
433
465
|
if (isCustomRow) {
|
|
434
466
|
const text = state.customText;
|
|
435
|
-
const
|
|
436
|
-
|
|
467
|
+
const cc = state.customCursor;
|
|
468
|
+
const before = text.slice(0, cc);
|
|
469
|
+
const after = text.slice(cc);
|
|
470
|
+
const cmark = atCursor ? this.theme.fg("accent", "|") : "";
|
|
471
|
+
const hl = atCursor ? this.theme.fg("accent", ` ${icon} ${optLabel}: `) : ` ${icon} ${optLabel}: `;
|
|
472
|
+
line = `${hl}${before}${cmark}${after}`;
|
|
437
473
|
} else {
|
|
438
|
-
line = ` ${icon} ${optLabel}`;
|
|
474
|
+
line = atCursor ? this.theme.fg("accent", ` ${icon} ${optLabel}`) : ` ${icon} ${optLabel}`;
|
|
439
475
|
}
|
|
440
|
-
lines.push(
|
|
476
|
+
lines.push(line);
|
|
441
477
|
}
|
|
442
478
|
} else if (q.type === "multi" && q.options) {
|
|
443
479
|
const opts = q.options;
|
|
@@ -452,12 +488,16 @@ export class AskComponent extends Container {
|
|
|
452
488
|
let line: string;
|
|
453
489
|
if (isCustomRow) {
|
|
454
490
|
const text = state.customText;
|
|
455
|
-
const
|
|
456
|
-
|
|
491
|
+
const cc = state.customCursor;
|
|
492
|
+
const before = text.slice(0, cc);
|
|
493
|
+
const after = text.slice(cc);
|
|
494
|
+
const cmark = atCursor ? this.theme.fg("accent", "|") : "";
|
|
495
|
+
const hl = atCursor ? this.theme.fg("accent", ` ${icon} ${optLabel}: `) : ` ${icon} ${optLabel}: `;
|
|
496
|
+
line = `${hl}${before}${cmark}${after}`;
|
|
457
497
|
} else {
|
|
458
|
-
line = ` ${icon} ${optLabel}`;
|
|
498
|
+
line = atCursor ? this.theme.fg("accent", ` ${icon} ${optLabel}`) : ` ${icon} ${optLabel}`;
|
|
459
499
|
}
|
|
460
|
-
lines.push(
|
|
500
|
+
lines.push(line);
|
|
461
501
|
}
|
|
462
502
|
}
|
|
463
503
|
|