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 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
 
@@ -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
- if (!getImageModelKey()) return;
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: `[Image analysis via ${parsed.provider}/${parsed.modelId}]\n\n${analysis}` }],
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: ${error instanceof Error ? error.message : error}` }],
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 && ctx.hasUI) {
365
- ctx.ui.notify(`mcp schema changed! please '/reload'`, "warning");
372
+ if (schemaChanges.length > 0 && hasUI && notify) {
373
+ notify(`mcp schema changed! please '/reload'`, "warning");
366
374
  }
367
375
  });
368
376
  },
@@ -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 → replaced by patch
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", "write", "grep", "find", "ls"]);
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, stripping the volatile "Current date: …" line
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "decorated-pi",
3
- "version": "0.8.0",
3
+ "version": "0.8.1",
4
4
  "description": "decorated-pi is a practical enhancement pack for pi coding agent — token-efficient workflow, cache-friendly design, and smarter tools.",
5
5
  "keywords": [
6
6
  "pi",
@@ -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: /home/liuchang/.local/share/fnm/node-versions/v24.14.0/installation/lib/node_modules/@earendil-works/pi-coding-agent/README.md
8
- - Additional docs: /home/liuchang/.local/share/fnm/node-versions/v24.14.0/installation/lib/node_modules/@earendil-works/pi-coding-agent/docs
9
- - Examples: /home/liuchang/.local/share/fnm/node-versions/v24.14.0/installation/lib/node_modules/@earendil-works/pi-coding-agent/examples (extensions, custom tools, SDK)
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)
@@ -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
- anchorMissing = true;
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
- let matchIdx = anchorNotFoundMessage ? -1 : content.indexOf(oldNorm, searchFrom);
616
- if (matchIdx === -1 && anchorNotFoundMessage) {
617
- displayAnchor = edit.anchor;
618
- anchorMissing = true;
619
- matchIdx = content.indexOf(oldNorm, 0);
620
- if (matchIdx !== -1) {
621
- const secondGlobalMatch = content.indexOf(oldNorm, matchIdx + 1);
622
- if (secondGlobalMatch !== -1) {
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
- if (matchIdx === -1) {
630
- const searchLine = 0;
631
- const fuzzy = tryFuzzyLineMatch(oldNorm, content, searchLine);
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 if (!rep.anchorMissing) {
761
- existing.missing = false;
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
- return anchor.text + (anchor.missing ? " (missing)" : "");
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
  }
@@ -162,13 +162,17 @@ function createSingleLineComponent(text: string) {
162
162
  };
163
163
  }
164
164
 
165
- function formatPatchMetaLine(line: string, theme: any): string {
166
- const missingSuffix = " (missing)";
167
- if (line.endsWith(missingSuffix)) {
168
- return (
169
- theme.fg("accent", line.slice(0, -missingSuffix.length)) +
170
- theme.fg("warning", missingSuffix)
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
- return { value: defaultValue ?? "", cursor: 0, customText: "", customSelected: false };
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
- state.value = (state.value as string).slice(0, -1);
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 = (state.value as string) + data;
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 cursor = this.theme.fg("accent", "_");
419
- lines.push(value + cursor);
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 cmark = atCursor ? "_" : "";
436
- line = ` ${icon} ${optLabel}: ${text}${cmark}`;
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(atCursor ? this.theme.fg("accent", line) : line);
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 cmark = atCursor ? "_" : "";
456
- line = ` ${icon} ${optLabel}: ${text}${cmark}`;
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(atCursor ? this.theme.fg("accent", line) : line);
500
+ lines.push(line);
461
501
  }
462
502
  }
463
503