@oh-my-pi/pi-coding-agent 13.7.1 → 13.7.4

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.
@@ -412,10 +412,14 @@ export function validateLineRef(ref: { line: number; hash: string }, fileLines:
412
412
  }
413
413
 
414
414
  function isEscapedTabAutocorrectEnabled(): boolean {
415
- const value = Bun.env.PI_HASHLINE_AUTOCORRECT_ESCAPED_TABS;
416
- if (value === "0") return false;
417
- if (value === "1") return true;
418
- return true;
415
+ switch (Bun.env.PI_HASHLINE_AUTOCORRECT_ESCAPED_TABS) {
416
+ case "0":
417
+ return false;
418
+ case "1":
419
+ return true;
420
+ default:
421
+ return true;
422
+ }
419
423
  }
420
424
 
421
425
  function maybeAutocorrectEscapedTabIndentation(edits: HashlineEdit[], warnings: string[]): void {
@@ -623,20 +627,19 @@ export function applyHashlineEdits(
623
627
  } else {
624
628
  const count = edit.end.line - edit.pos.line + 1;
625
629
  const newLines = [...edit.lines];
626
- const trailingReplacementLine = newLines[newLines.length - 1];
627
- const nextSurvivingLine = fileLines[edit.end.line];
630
+ const trailingReplacementLine = newLines[newLines.length - 1]?.trimEnd();
631
+ const nextSurvivingLine = fileLines[edit.end.line]?.trimEnd();
628
632
  if (
629
- trailingReplacementLine !== undefined &&
630
- trailingReplacementLine.trim().length > 0 &&
631
- nextSurvivingLine !== undefined &&
632
- trailingReplacementLine.trim() === nextSurvivingLine.trim() &&
633
+ trailingReplacementLine &&
634
+ nextSurvivingLine &&
635
+ trailingReplacementLine === nextSurvivingLine &&
633
636
  // Safety: only correct when end-line content differs from the duplicate.
634
637
  // If end already points to the boundary, matching next line is coincidence.
635
- fileLines[edit.end.line - 1].trim() !== trailingReplacementLine.trim()
638
+ fileLines[edit.end.line - 1]?.trimEnd() !== trailingReplacementLine
636
639
  ) {
637
640
  newLines.pop();
638
641
  warnings.push(
639
- `Auto-corrected range replace ${edit.pos.line}#${edit.pos.hash}-${edit.end.line}#${edit.end.hash}: removed trailing replacement line "${trailingReplacementLine.trim()}" that duplicated next surviving line`,
642
+ `Auto-corrected range replace ${edit.pos.line}#${edit.pos.hash}-${edit.end.line}#${edit.end.hash}: removed trailing replacement line "${trailingReplacementLine}" that duplicated next surviving line`,
640
643
  );
641
644
  }
642
645
  fileLines.splice(edit.pos.line - 1, count, ...newLines);
@@ -129,10 +129,11 @@ export function stripNewLinePrefixes(lines: string[]): string[] {
129
129
 
130
130
  export function hashlineParseText(edit: string[] | string | null): string[] {
131
131
  if (edit === null) return [];
132
- const lines = stripNewLinePrefixes(Array.isArray(edit) ? edit : edit.split("\n"));
133
- if (lines.length === 0) return [];
134
- if (lines[lines.length - 1].trim() === "") return lines.slice(0, -1);
135
- return lines;
132
+ if (typeof edit === "string") {
133
+ const normalizedEdit = edit.endsWith("\n") ? edit.slice(0, -1) : edit;
134
+ edit = normalizedEdit.replaceAll("\r", "").split("\n");
135
+ }
136
+ return stripNewLinePrefixes(edit);
136
137
  }
137
138
 
138
139
  const hashlineEditSchema = Type.Object(
@@ -6,49 +6,31 @@ Applies precise file edits using `LINE#ID` tags from `read` output.
6
6
  3. You **MUST** submit one `edit` call per file with all operations, think your changes through before submitting.
7
7
  </workflow>
8
8
 
9
- <prohibited>
10
- You **MUST NOT** use this tool for formatting-only edits: reindenting, realigning, brace-style changes, whitespace normalization, or line-length wrapping. Any edit whose diff is purely whitespace is a formatting operation — run the appropriate formatter for the project instead.
11
- </prohibited>
12
-
13
9
  <operations>
14
- Every edit has `op`, `pos`, and `lines`. Range replaces also have `end`. Both `pos` and `end` use `"N#ID"` format (e.g. `"23#XY"`).
15
- **`pos`** — the anchor line. Meaning depends on `op`:
10
+ **`path`** the path to the file to edit.
11
+ **`move`** — if set, move the file to the given path.
12
+ **`delete`** — if true, delete the file.
13
+ **`edits.[n].pos`** — the anchor line. Meaning depends on `op`:
16
14
  - `replace`: start of range (or the single line to replace)
17
15
  - `prepend`: insert new lines **before** this line; omit for beginning of file
18
16
  - `append`: insert new lines **after** this line; omit for end of file
19
- **`end`** — range replace only. The last line of the range (inclusive). Omit for single-line replace.
20
- **`lines`** — the replacement content:
17
+ **`edits.[n].end`** — range replace only. The last line of the range (inclusive). Omit for single-line replace.
18
+ **`edits.[n].lines`** — the replacement content:
21
19
  - `["line1", "line2"]` — replace with these lines (array of strings)
22
20
  - `"line1"` — shorthand for `["line1"]` (single-line replace)
23
21
  - `[""]` — replace content with a blank line (line preserved, content cleared)
24
22
  - `null` or `[]` — **delete** the line(s) entirely
25
23
 
26
- ### Line or range replace/delete
27
- - `{ path: "…", edits: [{ op: "replace", pos: "N#ID", lines: null }] }` delete one line
28
- - `{ path: "…", edits: [{ op: "replace", pos: "N#ID", end: "M#ID", lines: null }] }` — delete a range
29
- - `{ path: "…", edits: [{ op: "replace", pos: "N#ID", lines: […] }] }` — replace one line
30
- - `{ path: "…", edits: [{ op: "replace", pos: "N#ID", end: "M#ID", lines: […] }] }` — replace a range
31
-
32
- ### Insert new lines
33
- - `{ path: "…", edits: [{ op: "prepend", pos: "N#ID", lines: […] }] }` — insert before tagged line
34
- - `{ path: "…", edits: [{ op: "prepend", lines: […] }] }` — insert at beginning of file (no tag)
35
- - `{ path: "…", edits: [{ op: "append", pos: "N#ID", lines: […] }] }` — insert after tagged line
36
- - `{ path: "…", edits: [{ op: "append", lines: […] }] }` — insert at end of file (no tag)
37
-
38
- ### File-level controls
39
- - `{ path: "…", delete: true, edits: [] }` — delete the file
40
- - `{ path: "…", move: "new/path.ts", edits: […] }` — move file to new path (edits applied first)
41
- **Atomicity:** all ops in one call validate against the same pre-edit snapshot; tags reference the last `read`. Edits are applied bottom-up, so earlier tags stay valid even when later ops add or remove lines.
24
+ Tags should be referenced from the last `read` output.
25
+ Edits are applied bottom-up, so earlier tags stay valid even when later ops add or remove lines.
42
26
  </operations>
43
27
 
44
28
  <rules>
45
29
  1. **Minimize scope:** You **MUST** use one logical mutation per operation.
46
- 2. **`end` is inclusive:** If `lines` includes a closing token (`}`, `]`, `)`, `);`, `},`), `end` **MUST** include the original boundary line. To delete a line while keeping neighbors, use `lines: null` — do not replace it with an adjacent line's content.
47
- 3. **Copy indentation from `read` output:** Leading whitespace in `lines` **MUST** follow adjacent lines exactly. Do not reconstruct from memory.
48
- 4. **Verify the splice before submitting:** For each edit op, mentally read the result:
49
- - Does the last `lines` entry duplicate the line surviving after `end`? extend `end` or remove the duplicate.
50
- - Does the first `lines` entry duplicate the line before `pos`? → the edit is wrong.
51
- - For `prepend`/`append`: does new code land inside or outside the enclosing block? Trace the braces.
30
+ 2. **Prefer insertion over neighbor rewrites:** You **SHOULD** anchor on structural boundaries (`}`, `]`, `},`), not interior lines.
31
+ 3. **Range end tag (inclusive):** `end` is inclusive and **MUST** point to the final line being replaced.
32
+ - If `lines` includes a closing boundary token (`}`, `]`, `)`, `);`, `},`), `end` **MUST** include the original boundary line.
33
+ - You **MUST NOT** set `end` to an interior line and then re-add the boundary token in `lines`; that duplicates the next surviving line.
52
34
  </rules>
53
35
 
54
36
  <recovery>
@@ -61,18 +43,56 @@ Every edit has `op`, `pos`, and `lines`. Range replaces also have `end`. Both `p
61
43
  {{hlinefull 23 " const timeout: number = 5000;"}}
62
44
  ```
63
45
  ```
64
- { op: "replace", pos: {{hlinejsonref 23 " const timeout: number = 5000;"}}, lines: [" const timeout: number = 30_000;"] }
46
+ {
47
+ path: "…",
48
+ edits: [{
49
+ op: "replace",
50
+ pos: {{hlineref 23 " const timeout: number = 5000;"}},
51
+ lines: [" const timeout: number = 30_000;"]
52
+ }]
53
+ }
65
54
  ```
66
55
  </example>
67
56
 
68
57
  <example name="delete lines">
69
58
  Single line — `lines: null` deletes entirely:
70
59
  ```
71
- { op: "replace", pos: {{hlinejsonref 7 "// @ts-ignore"}}, lines: null }
60
+ {
61
+ path: "…",
62
+ edits: [{
63
+ op: "replace",
64
+ pos: {{hlineref 7 "// @ts-ignore"}},
65
+ lines: null
66
+ }]
67
+ }
72
68
  ```
73
69
  Range — add `end`:
74
70
  ```
75
- { op: "replace", pos: {{hlinejsonref 80 " // TODO: remove after migration"}}, end: {{hlinejsonref 83 " }"}}, lines: null }
71
+ {
72
+ path: "…",
73
+ edits: [{
74
+ op: "replace",
75
+ pos: {{hlineref 80 " // TODO: remove after migration"}},
76
+ end: {{hlineref 83 " }"}},
77
+ lines: null
78
+ }]
79
+ }
80
+ ```
81
+ </example>
82
+
83
+ <example name="clear text but keep the line break">
84
+ ```ts
85
+ {{hlinefull 14 " placeholder: \"DO NOT SHIP\","}}
86
+ ```
87
+ ```
88
+ {
89
+ path: "…",
90
+ edits: [{
91
+ op: "replace",
92
+ pos: {{hlineref 14 " placeholder: \"DO NOT SHIP\","}},
93
+ lines: [""]
94
+ }]
95
+ }
76
96
  ```
77
97
  </example>
78
98
 
@@ -83,37 +103,63 @@ Range — add `end`:
83
103
  {{hlinefull 62 " return null;"}}
84
104
  {{hlinefull 63 " }"}}
85
105
  ```
86
- Include the closing `}` in the replaced range — stopping one line short orphans the brace or duplicates it.
87
106
  ```
88
- { op: "replace", pos: {{hlinejsonref 61 " console.error(err);"}}, end: {{hlinejsonref 63 " }"}}, lines: [" if (isEnoent(err)) return null;", " throw err;", " }"] }
107
+ {
108
+ path: "…",
109
+ edits: [{
110
+ op: "replace",
111
+ pos: {{hlineref 61 " console.error(err);"}},
112
+ end: {{hlineref 63 " }"}},
113
+ lines: [
114
+ " if (isEnoent(err)) return null;",
115
+ " throw err;",
116
+ " }"
117
+ ]
118
+ }]
119
+ }
89
120
  ```
90
121
  </example>
91
122
 
92
- <example name="insert inside a block (good vs bad)">
93
- Adding a method inside a class — anchor on the **closing brace**, not after it.
123
+ <example name="inclusive end avoids duplicate boundary">
94
124
  ```ts
95
- {{hlinefull 20 " greet() {"}}
96
- {{hlinefull 21 " return \"hi\";"}}
97
- {{hlinefull 22 " }"}}
98
- {{hlinefull 23 "}"}}
99
- {{hlinefull 24 ""}}
100
- {{hlinefull 25 "function other() {"}}
101
- ```
102
- Bad — appends **after** closing `}` (method lands outside the class):
103
- ```
104
- { op: "append", pos: {{hlinejsonref 23 "}"}}, lines: [" newMethod() {", " return 1;", " }"] }
105
- ```
106
- Result `newMethod` is a **top-level function**, not a class method:
107
- ```
108
- } ← class closes here
109
- newMethod() {
110
- return 1;
111
- }
112
- ```
113
- Good — prepends **before** closing `}` (method stays inside the class):
114
- ```
115
- { op: "prepend", pos: {{hlinejsonref 23 "}"}}, lines: [" newMethod() {", " return 1;", " }"] }
116
- ```
125
+ {{hlinefull 70 "if (ok) {"}}
126
+ {{hlinefull 71 " run();"}}
127
+ {{hlinefull 72 "}"}}
128
+ {{hlinefull 73 "after();"}}
129
+ ```
130
+ Bad `end` stops before `}` while `lines` already includes `}`:
131
+ ```
132
+ {
133
+ path: "…",
134
+ edits: [{
135
+ op: "replace",
136
+ pos: {{hlineref 70 "if (ok) {"}},
137
+ end: {{hlineref 71 " run();"}},
138
+ lines: [
139
+ "if (ok) {",
140
+ " runSafe();",
141
+ "}"
142
+ ]
143
+ }]
144
+ }
145
+ ```
146
+ Good — include original `}` in the replaced range when replacement keeps `}`:
147
+ ```
148
+ {
149
+ path: "…",
150
+ edits: [{
151
+ op: "replace",
152
+ pos: {{hlineref 70 "if (ok) {"}},
153
+ end: {{hlineref 72 "}"}},
154
+ lines: [
155
+ "if (ok) {",
156
+ " runSafe();",
157
+ "}"
158
+ ]
159
+ }]
160
+ }
161
+ ```
162
+ Also apply the same rule to `);`, `],`, and `},` closers: if replacement includes the closer token, `end` must include the original closer line.
117
163
  </example>
118
164
 
119
165
  <example name="insert between sibling declarations">
@@ -126,26 +172,62 @@ Good — prepends **before** closing `}` (method stays inside the class):
126
172
  {{hlinefull 49 " runY();"}}
127
173
  {{hlinefull 50 "}"}}
128
174
  ```
129
- Use a trailing `""` to preserve the blank line between top-level sibling declarations.
130
175
  ```
131
- { op: "prepend", pos: {{hlinejsonref 48 "function y() {"}}, lines: ["function z() {", " runZ();", "}", ""] }
176
+ {
177
+ path: "…",
178
+ edits: [{
179
+ op: "prepend",
180
+ pos: {{hlineref 48 "function y() {"}},
181
+ lines: [
182
+ "function z() {",
183
+ " runZ();",
184
+ "}",
185
+ ""
186
+ ]
187
+ }]
188
+ }
132
189
  ```
190
+ Use a trailing `""` to preserve the blank line between top-level sibling declarations.
133
191
  </example>
134
192
 
135
193
  <example name="disambiguate anchors">
136
194
  Blank lines and repeated patterns (`}`, `return null;`) appear many times — never anchor on them when a unique line exists nearby.
137
195
  ```ts
138
- {{hlinefull 46 "}"}}
139
- {{hlinefull 47 ""}}
140
- {{hlinefull 48 "function processItem(item: Item) {"}}
196
+ {{hlinefull 101 "}"}}
197
+ {{hlinefull 102 ""}}
198
+ {{hlinefull 103 "export function serialize(data: unknown): string {"}}
141
199
  ```
142
200
  Bad — anchoring on the blank line (ambiguous, may shift):
143
201
  ```
144
- { op: "append", pos: {{hlinejsonref 47 ""}}, lines: ["function helper() { }"] }
202
+ {
203
+ path: "…",
204
+ edits: [{
205
+ op: "append",
206
+ pos: {{hlineref 102 ""}},
207
+ lines: [
208
+ "function validate(data: unknown): boolean {",
209
+ " return data != null && typeof data === \"object\";",
210
+ "}",
211
+ ""
212
+ ]
213
+ }]
214
+ }
145
215
  ```
146
216
  Good — anchor on the unique declaration line:
147
217
  ```
148
- { op: "prepend", pos: {{hlinejsonref 48 "function processItem(item: Item) {"}}, lines: ["function helper() { }", ""] }
218
+ {
219
+ path: "…",
220
+ edits: [{
221
+ op: "prepend",
222
+ pos: {{hlineref 103 "export function serialize(data: unknown): string {"}},
223
+ lines: [
224
+ "function validate(data: unknown): boolean {",
225
+ " return data != null && typeof data === \"object\";",
226
+ "}",
227
+ ""
228
+ ]
229
+ }]
230
+ }
149
231
  ```
150
232
  </example>
151
233
 
@@ -1,3 +1,4 @@
1
+ import { getOAuthProviders } from "@oh-my-pi/pi-ai";
1
2
  import type { SettingPath, SettingValue } from "../config/settings";
2
3
  import { settings } from "../config/settings";
3
4
  import type { InteractiveModeContext } from "../modes/types";
@@ -261,12 +262,27 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<BuiltinSlashCommandSpec> = [
261
262
  {
262
263
  name: "login",
263
264
  description: "Login with OAuth provider",
264
- inlineHint: "[redirect URL]",
265
+ inlineHint: "[provider|redirect URL]",
265
266
  allowArgs: true,
266
267
  handle: (command, runtime) => {
267
268
  const manualInput = runtime.ctx.oauthManualInput;
268
269
  const args = command.args.trim();
269
270
  if (args.length > 0) {
271
+ const matchedProvider = getOAuthProviders().find(provider => provider.id === args);
272
+ if (matchedProvider) {
273
+ if (manualInput.hasPending()) {
274
+ const pendingProvider = manualInput.pendingProviderId;
275
+ const message = pendingProvider
276
+ ? `OAuth login already in progress for ${pendingProvider}. Paste the redirect URL with /login <url>.`
277
+ : "OAuth login already in progress. Paste the redirect URL with /login <url>.";
278
+ runtime.ctx.showWarning(message);
279
+ runtime.ctx.editor.setText("");
280
+ return;
281
+ }
282
+ void runtime.ctx.showOAuthSelector("login", matchedProvider.id);
283
+ runtime.ctx.editor.setText("");
284
+ return;
285
+ }
270
286
  const submitted = manualInput.submit(args);
271
287
  if (submitted) {
272
288
  runtime.ctx.showStatus("OAuth callback received; completing login…");
@@ -41,7 +41,7 @@ import {
41
41
  } from "./types";
42
42
 
43
43
  const MCP_CALL_TIMEOUT_MS = 60_000;
44
- const ajv = new Ajv({ allErrors: true, strict: false });
44
+ const ajv = new Ajv({ allErrors: true, strict: false, logger: false });
45
45
 
46
46
  /** Agent event types to forward for progress tracking. */
47
47
  const agentEventTypes = new Set<AgentEvent["type"]>([
@@ -879,7 +879,9 @@ function renderAgentResult(result: SingleResult, isLast: boolean, expanded: bool
879
879
 
880
880
  // Error message
881
881
  if (result.error && (!success || mergeFailed) && (!aborted || result.error !== result.abortReason)) {
882
- lines.push(`${continuePrefix}${theme.fg(mergeFailed ? "warning" : "error", truncateToWidth(result.error, 70))}`);
882
+ lines.push(
883
+ `${continuePrefix}${theme.fg(mergeFailed ? "warning" : "error", truncateToWidth(replaceTabs(result.error), 70))}`,
884
+ );
883
885
  }
884
886
 
885
887
  return lines;
@@ -1,8 +1,7 @@
1
1
  import * as path from "node:path";
2
2
  import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
3
3
  import { htmlToMarkdown } from "@oh-my-pi/pi-natives";
4
- import type { Component } from "@oh-my-pi/pi-tui";
5
- import { Text } from "@oh-my-pi/pi-tui";
4
+ import { type Component, Text } from "@oh-my-pi/pi-tui";
6
5
  import { ptree, truncate } from "@oh-my-pi/pi-utils";
7
6
  import { type Static, Type } from "@sinclair/typebox";
8
7
  import { parseHTML } from "linkedom";
@@ -14,6 +13,7 @@ import { DEFAULT_MAX_BYTES, truncateHead } from "../session/streaming-output";
14
13
  import { renderStatusLine } from "../tui";
15
14
  import { CachedOutputBlock } from "../tui/output-block";
16
15
  import { ensureTool } from "../utils/tools-manager";
16
+ import { summarizeUrlWithKagi } from "../web/kagi";
17
17
  import { specialHandlers } from "../web/scrapers";
18
18
  import type { RenderResult } from "../web/scrapers/types";
19
19
  import { finalizeOutput, loadPage, MAX_OUTPUT_CHARS } from "../web/scrapers/types";
@@ -423,12 +423,13 @@ function parseFeedToMarkdown(content: string, maxItems = 10): string {
423
423
  }
424
424
 
425
425
  /**
426
- * Render HTML to markdown using native, jina, trafilatura, lynx (in order of preference)
426
+ * Render HTML to markdown using kagi, jina, trafilatura, lynx (in order of preference)
427
427
  */
428
428
  async function renderHtmlToText(
429
429
  url: string,
430
430
  html: string,
431
431
  timeout: number,
432
+ useKagiSummarizer: boolean,
432
433
  userSignal?: AbortSignal,
433
434
  ): Promise<{ content: string; ok: boolean; method: string }> {
434
435
  const signal = ptree.combineSignals(userSignal, timeout * 1000);
@@ -440,7 +441,20 @@ async function renderHtmlToText(
440
441
  signal,
441
442
  };
442
443
 
443
- // Try jina first (reader API)
444
+ // Try Kagi Universal Summarizer first (if enabled and KAGI_API_KEY is configured)
445
+ if (useKagiSummarizer) {
446
+ try {
447
+ const kagiSummary = await summarizeUrlWithKagi(url, { signal });
448
+ if (kagiSummary && kagiSummary.length > 100 && !isLowQualityOutput(kagiSummary)) {
449
+ return { content: kagiSummary, ok: true, method: "kagi" };
450
+ }
451
+ } catch {
452
+ // Kagi failed, continue to next method
453
+ signal?.throwIfAborted();
454
+ }
455
+ }
456
+
457
+ // Try jina next (reader API)
444
458
  try {
445
459
  const jinaUrl = `https://r.jina.ai/${url}`;
446
460
  const response = await fetch(jinaUrl, {
@@ -553,7 +567,13 @@ async function handleSpecialUrls(url: string, timeout: number, signal?: AbortSig
553
567
  /**
554
568
  * Main render function implementing the full pipeline
555
569
  */
556
- async function renderUrl(url: string, timeout: number, raw: boolean, signal?: AbortSignal): Promise<RenderResult> {
570
+ async function renderUrl(
571
+ url: string,
572
+ timeout: number,
573
+ raw: boolean,
574
+ useKagiSummarizer: boolean,
575
+ signal?: AbortSignal,
576
+ ): Promise<RenderResult> {
557
577
  const notes: string[] = [];
558
578
  const fetchedAt = new Date().toISOString();
559
579
  if (signal?.aborted) {
@@ -792,7 +812,7 @@ async function renderUrl(url: string, timeout: number, raw: boolean, signal?: Ab
792
812
  }
793
813
 
794
814
  // Step 6: Render HTML with lynx or html2text
795
- const htmlResult = await renderHtmlToText(finalUrl, rawContent, timeout, signal);
815
+ const htmlResult = await renderHtmlToText(finalUrl, rawContent, timeout, useKagiSummarizer, signal);
796
816
  if (!htmlResult.ok) {
797
817
  notes.push("html rendering failed (lynx/html2text unavailable)");
798
818
  const output = finalizeOutput(rawContent);
@@ -915,7 +935,8 @@ export class FetchTool implements AgentTool<typeof fetchSchema, FetchToolDetails
915
935
  throw new ToolAbortError();
916
936
  }
917
937
 
918
- const result = await renderUrl(url, effectiveTimeout, raw, signal);
938
+ const useKagiSummarizer = this.session.settings.get("fetch.useKagiSummarizer");
939
+ const result = await renderUrl(url, effectiveTimeout, raw, useKagiSummarizer, signal);
919
940
  const truncation = truncateHead(result.content, {
920
941
  maxBytes: DEFAULT_MAX_BYTES,
921
942
  maxLines: FETCH_DEFAULT_MAX_LINES,
@@ -1066,8 +1087,8 @@ export function renderFetchResult(
1066
1087
  if (contentPreviewLines === undefined || lastExpanded !== expanded) {
1067
1088
  const previewLimit = expanded ? 12 : 3;
1068
1089
  const previewList = applyListLimit(contentLines, { headLimit: previewLimit });
1069
- const previewLines = previewList.items.map(line => truncate(line.trimEnd(), 120, "…"));
1070
- const remaining = Math.max(0, contentLines.length - previewLines.length);
1090
+ const previewLines = previewList.items.map(line => line.trimEnd());
1091
+ const remaining = Math.max(0, contentLines.length - previewList.items.length);
1071
1092
  contentPreviewLines =
1072
1093
  previewLines.length > 0
1073
1094
  ? previewLines.map(line => uiTheme.fg("dim", line))
@@ -4,7 +4,7 @@
4
4
  * Subagents must call this tool to finish and return structured JSON output.
5
5
  */
6
6
  import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
7
- import { sanitizeSchemaForStrictMode } from "@oh-my-pi/pi-ai/utils/schema";
7
+ import { dereferenceJsonSchema, sanitizeSchemaForStrictMode } from "@oh-my-pi/pi-ai/utils/schema";
8
8
  import type { Static, TSchema } from "@sinclair/typebox";
9
9
  import { Type } from "@sinclair/typebox";
10
10
  import Ajv, { type ErrorObject, type ValidateFunction } from "ajv";
@@ -18,7 +18,7 @@ export interface SubmitResultDetails {
18
18
  error?: string;
19
19
  }
20
20
 
21
- const ajv = new Ajv({ allErrors: true, strict: false });
21
+ const ajv = new Ajv({ allErrors: true, strict: false, logger: false });
22
22
 
23
23
  function normalizeSchema(schema: unknown): { normalized?: unknown; error?: string } {
24
24
  if (schema === undefined || schema === null) return {};
@@ -52,53 +52,6 @@ function formatAjvErrors(errors: ErrorObject[] | null | undefined): string {
52
52
  .join("; ");
53
53
  }
54
54
 
55
- /**
56
- * Resolve all $ref references in a JSON Schema by inlining definitions.
57
- * Handles $defs and definitions at any nesting level.
58
- * Removes $defs/definitions from the output since all refs are inlined.
59
- */
60
- function resolveSchemaRefs(schema: Record<string, unknown>): Record<string, unknown> {
61
- const defs: Record<string, Record<string, unknown>> = {};
62
- const defsObj = schema.$defs ?? schema.definitions;
63
- if (defsObj && typeof defsObj === "object" && !Array.isArray(defsObj)) {
64
- for (const [name, def] of Object.entries(defsObj as Record<string, unknown>)) {
65
- if (def && typeof def === "object" && !Array.isArray(def)) {
66
- defs[name] = def as Record<string, unknown>;
67
- }
68
- }
69
- }
70
- if (Object.keys(defs).length === 0) return schema;
71
-
72
- const inlining = new Set<string>();
73
- function inline(node: unknown): unknown {
74
- if (node === null || typeof node !== "object") return node;
75
- if (Array.isArray(node)) return node.map(inline);
76
- const obj = node as Record<string, unknown>;
77
- const ref = obj.$ref;
78
- if (typeof ref === "string") {
79
- const match = ref.match(/^#\/(?:\$defs|definitions)\/(.+)$/);
80
- if (match) {
81
- const name = match[1];
82
- const def = defs[name];
83
- if (def) {
84
- if (inlining.has(name)) return {};
85
- inlining.add(name);
86
- const resolved = inline(def);
87
- inlining.delete(name);
88
- return resolved;
89
- }
90
- }
91
- }
92
- const result: Record<string, unknown> = {};
93
- for (const [key, value] of Object.entries(obj)) {
94
- if (key === "$defs" || key === "definitions") continue;
95
- result[key] = inline(value);
96
- }
97
- return result;
98
- }
99
- return inline(schema) as Record<string, unknown>;
100
- }
101
-
102
55
  export class SubmitResultTool implements AgentTool<TSchema, SubmitResultDetails> {
103
56
  readonly name = "submit_result";
104
57
  readonly label = "Submit Result";
@@ -168,11 +121,11 @@ export class SubmitResultTool implements AgentTool<TSchema, SubmitResultDetails>
168
121
  : undefined;
169
122
 
170
123
  if (sanitizedSchema !== undefined) {
171
- const resolved = resolveSchemaRefs({
124
+ const resolved = dereferenceJsonSchema({
172
125
  ...sanitizedSchema,
173
126
  description: schemaDescription,
174
127
  });
175
- dataSchema = Type.Unsafe(resolved);
128
+ dataSchema = Type.Unsafe(resolved as Record<string, unknown>);
176
129
  } else {
177
130
  dataSchema = Type.Record(Type.String(), Type.Any(), {
178
131
  description: schemaError ? schemaDescription : "Structured JSON output (no schema specified)",
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Bordered output container with optional header and sections.
3
3
  */
4
- import { ImageProtocol, padding, TERMINAL, visibleWidth } from "@oh-my-pi/pi-tui";
4
+ import { ImageProtocol, padding, TERMINAL, visibleWidth, wrapTextWithAnsi } from "@oh-my-pi/pi-tui";
5
5
  import type { Theme } from "../modes/theme/theme";
6
6
  import { getSixelLineMask } from "../utils/sixel";
7
7
  import type { State } from "./types";
@@ -88,13 +88,12 @@ export function renderOutputBlock(options: OutputBlockOptions, theme: Theme): st
88
88
  lines.push(line);
89
89
  continue;
90
90
  }
91
- // Sections may receive content that was already padded to terminal width
92
- // (e.g. from Text.render()). Trailing spaces would trigger truncateToWidth()
93
- // to append an ellipsis even when the *semantic* content fits.
94
- const text = truncateToWidth(line.trimEnd(), contentWidth);
95
- const innerPadding = padding(Math.max(0, contentWidth - visibleWidth(text)));
96
- const fullLine = `${contentPrefix}${text}${innerPadding}${contentSuffix}`;
97
- lines.push(padToWidth(fullLine, lineWidth, bgFn));
91
+ const wrappedLines = wrapTextWithAnsi(line.trimEnd(), contentWidth);
92
+ for (const wrappedLine of wrappedLines) {
93
+ const innerPadding = padding(Math.max(0, contentWidth - visibleWidth(wrappedLine)));
94
+ const fullLine = `${contentPrefix}${wrappedLine}${innerPadding}${contentSuffix}`;
95
+ lines.push(padToWidth(fullLine, lineWidth, bgFn));
96
+ }
98
97
  }
99
98
  }
100
99
 
@@ -296,7 +296,7 @@ type EnsureToolOptions = {
296
296
 
297
297
  export async function ensureTool(tool: ToolName, silentOrOptions?: EnsureToolOptions): Promise<string | undefined> {
298
298
  const { signal, silent = false, notify } = silentOrOptions ?? {};
299
- const existingPath = await getToolPath(tool);
299
+ const existingPath = getToolPath(tool);
300
300
  if (existingPath) {
301
301
  return existingPath;
302
302
  }