@xynogen/pix-pretty 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,54 +1,55 @@
1
1
  {
2
- "name": "@xynogen/pix-pretty",
3
- "version": "1.0.0",
4
- "description": "Enhanced tool output rendering with syntax highlighting, file icons, tree views, FFF search, and paste chip formatting",
5
- "type": "module",
6
- "main": "src/index.ts",
7
- "scripts": {
8
- "test": "bun test"
9
- },
10
- "files": [
11
- "src",
12
- "README.md",
13
- "LICENSE"
14
- ],
15
- "pi": {
16
- "extensions": [
17
- "./src/index.ts",
18
- "./src/paste-chips.ts",
19
- "./src/thinking.ts"
20
- ]
21
- },
22
- "keywords": [
23
- "pi",
24
- "pi-package",
25
- "pi-extension",
26
- "syntax-highlighting",
27
- "fff",
28
- "paste-chips",
29
- "tool-rendering"
30
- ],
31
- "author": "xynogen",
32
- "license": "MIT",
33
- "repository": {
34
- "type": "git",
35
- "url": "git+https://github.com/xynogen/pix-mono.git",
36
- "directory": "packages/pix-pretty"
37
- },
38
- "homepage": "https://github.com/xynogen/pix-mono/tree/main/packages/pix-pretty#readme",
39
- "bugs": {
40
- "url": "https://github.com/xynogen/pix-mono/issues"
41
- },
42
- "publishConfig": {
43
- "access": "public"
44
- },
45
- "dependencies": {
46
- "cli-highlight": "^2.1.11",
47
- "@ff-labs/fff-node": "^0.5.2",
48
- "diff": "^7.0.0"
49
- },
50
- "peerDependencies": {
51
- "@earendil-works/pi-coding-agent": "*",
52
- "@earendil-works/pi-tui": "*"
53
- }
2
+ "name": "@xynogen/pix-pretty",
3
+ "version": "1.2.0",
4
+ "description": "Enhanced tool output rendering with syntax highlighting, file icons, tree views, FFF search, and paste chip formatting",
5
+ "type": "module",
6
+ "main": "src/index.ts",
7
+ "scripts": {
8
+ "test": "bun test"
9
+ },
10
+ "files": [
11
+ "src",
12
+ "README.md",
13
+ "LICENSE"
14
+ ],
15
+ "pi": {
16
+ "extensions": [
17
+ "./src/index.ts",
18
+ "./src/paste-chips.ts",
19
+ "./src/thinking.ts"
20
+ ]
21
+ },
22
+ "keywords": [
23
+ "pi",
24
+ "pi-package",
25
+ "pi-extension",
26
+ "syntax-highlighting",
27
+ "fff",
28
+ "paste-chips",
29
+ "tool-rendering"
30
+ ],
31
+ "author": "xynogen",
32
+ "license": "MIT",
33
+ "repository": {
34
+ "type": "git",
35
+ "url": "git+https://github.com/xynogen/pix-mono.git",
36
+ "directory": "packages/pix-pretty"
37
+ },
38
+ "homepage": "https://github.com/xynogen/pix-mono/tree/main/packages/pix-pretty#readme",
39
+ "bugs": {
40
+ "url": "https://github.com/xynogen/pix-mono/issues"
41
+ },
42
+ "publishConfig": {
43
+ "access": "public",
44
+ "provenance": true
45
+ },
46
+ "dependencies": {
47
+ "cli-highlight": "^2.1.11",
48
+ "@ff-labs/fff-node": "^0.5.2",
49
+ "diff": "^7.0.0"
50
+ },
51
+ "peerDependencies": {
52
+ "@earendil-works/pi-coding-agent": "*",
53
+ "@earendil-works/pi-tui": "*"
54
+ }
54
55
  }
package/src/index.test.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * Basic smoke tests for pix-pretty extensions
3
3
  */
4
4
 
5
- import { describe, it, expect } from "bun:test";
5
+ import { describe, expect, it } from "bun:test";
6
6
 
7
7
  describe("pix-pretty", () => {
8
8
  it("exports are valid TypeScript modules", () => {
package/src/index.ts CHANGED
@@ -920,7 +920,7 @@ export default function piPrettyExtension(
920
920
  // SDK grep fallback otherwise)
921
921
  // ===================================================================
922
922
 
923
- if ((fffState.module || createGrepTool)) {
923
+ if (fffState.module || createGrepTool) {
924
924
  const multiGrepFallback = createGrepTool ? createGrepTool(cwd) : null;
925
925
 
926
926
  pi.registerTool({
@@ -1269,9 +1269,12 @@ export default function piPrettyExtension(
1269
1269
  ) {
1270
1270
  const fp = params.path ?? params.file_path ?? "";
1271
1271
  const operations = getEditOperations(params);
1272
+ // params is the live tool input (upstream EditToolInput shape); we
1273
+ // type it loosely as EditParams for defensive legacy-field reads, so
1274
+ // cast back to the upstream input when delegating to the real tool.
1272
1275
  const result = (await origEdit.execute(
1273
1276
  tid,
1274
- params,
1277
+ params as unknown as Parameters<typeof origEdit.execute>[1],
1275
1278
  sig,
1276
1279
  upd,
1277
1280
  ctx,
@@ -1409,7 +1412,7 @@ export default function piPrettyExtension(
1409
1412
 
1410
1413
  const result = (await origWrite.execute(
1411
1414
  tid,
1412
- params,
1415
+ params as unknown as Parameters<typeof origWrite.execute>[1],
1413
1416
  sig,
1414
1417
  upd,
1415
1418
  ctx,
@@ -6,7 +6,7 @@
6
6
  * See: pi-crash.log — "Rendered line 38 exceeds terminal width (285 > 283)"
7
7
  */
8
8
 
9
- import { describe, it, expect } from "bun:test";
9
+ import { describe, expect, it } from "bun:test";
10
10
  import { visibleWidth } from "@earendil-works/pi-tui";
11
11
  import { restyleMarkers } from "./paste-chips";
12
12
 
@@ -13,14 +13,22 @@
13
13
  * The display rewrite is purely visual (render layer); buffer is untouched.
14
14
  */
15
15
 
16
- import { CustomEditor } from "@earendil-works/pi-coding-agent";
17
16
  import type {
18
- EditorFactory,
19
17
  ExtensionAPI,
20
18
  KeybindingsManager,
21
19
  } from "@earendil-works/pi-coding-agent";
22
- import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
20
+ import { CustomEditor } from "@earendil-works/pi-coding-agent";
23
21
  import type { EditorTheme, TUI } from "@earendil-works/pi-tui";
22
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
23
+
24
+ // Upstream stopped re-exporting `EditorFactory` from the package entry point,
25
+ // so we reconstruct its signature locally from the still-exported primitives.
26
+ // This matches ctx.ui.setEditorComponent's expected factory shape.
27
+ type EditorFactory = (
28
+ tui: TUI,
29
+ theme: EditorTheme,
30
+ keybindings: KeybindingsManager,
31
+ ) => CustomEditor;
24
32
 
25
33
  // ─── Constants ────────────────────────────────────────────────────────────────
26
34
 
@@ -121,10 +129,6 @@ export function restyleMarkers(line: string, imageIds: Set<number>): string {
121
129
  class ChipEditor extends CustomEditor {
122
130
  private readonly imageIds = new Set<number>();
123
131
 
124
- constructor(tui: TUI, theme: EditorTheme, keybindings: KeybindingsManager) {
125
- super(tui, theme, keybindings);
126
- }
127
-
128
132
  override insertTextAtCursor(text: string): void {
129
133
  const internals = this as unknown as EditorInternals;
130
134
  super.insertTextAtCursor(replaceImagePaths(text, internals, this.imageIds));
@@ -1,223 +1,251 @@
1
1
  /**
2
- * Tests for thinking tag rendering
2
+ * Tests for leaked-reasoning splitting into native content blocks.
3
+ *
4
+ * splitThinking() turns leaked `<think>`/`<thinking>` spans into real
5
+ * `thinking` content blocks (rendered dim + italic by pi's native
6
+ * `thinkingText` styling) while keeping surrounding answer text as `text`
7
+ * blocks.
3
8
  */
4
9
 
5
- import { describe, it, expect } from "bun:test";
6
- import { renderThinking } from "./thinking";
10
+ import { describe, expect, it } from "bun:test";
11
+ import { splitThinking, stripPartialTailTag } from "./thinking";
7
12
 
8
- describe("thinking tag rendering", () => {
13
+ type Block = { type: string; text?: string; thinking?: string };
14
+
15
+ function texts(blocks: Block[]): string[] {
16
+ return blocks.filter((b) => b.type === "text").map((b) => b.text ?? "");
17
+ }
18
+ function thinkings(blocks: Block[]): string[] {
19
+ return blocks
20
+ .filter((b) => b.type === "thinking")
21
+ .map((b) => b.thinking ?? "");
22
+ }
23
+
24
+ describe("splitThinking", () => {
9
25
  describe("closed thinking blocks", () => {
10
- it("renders basic <thinking> block as blockquote", () => {
11
- const input = "<thinking>This is reasoning</thinking>";
12
- const output = renderThinking(input);
13
- expect(output).toBe(`> This is reasoning\n\n`);
26
+ it("turns a <thinking> span into a thinking block", () => {
27
+ const out = splitThinking("<thinking>This is reasoning</thinking>");
28
+ expect(out).toEqual([
29
+ { type: "thinking", thinking: "This is reasoning" },
30
+ ]);
14
31
  });
15
32
 
16
- it("renders basic <think> block as blockquote", () => {
17
- const input = "<think>This is reasoning</think>";
18
- const output = renderThinking(input);
19
- expect(output).toBe(`> This is reasoning\n\n`);
33
+ it("turns a <think> span into a thinking block", () => {
34
+ const out = splitThinking("<think>This is reasoning</think>");
35
+ expect(out).toEqual([
36
+ { type: "thinking", thinking: "This is reasoning" },
37
+ ]);
20
38
  });
21
39
 
22
- it("renders multi-line thinking block as blockquote", () => {
23
- const input = "<thinking>Line 1\nLine 2\nLine 3</thinking>";
24
- const output = renderThinking(input);
25
- expect(output).toBe(`> Line 1\n> Line 2\n> Line 3\n\n`);
40
+ it("preserves multi-line reasoning inside one thinking block", () => {
41
+ const out = splitThinking("<thinking>Line 1\nLine 2\nLine 3</thinking>");
42
+ expect(out).toEqual([
43
+ { type: "thinking", thinking: "Line 1\nLine 2\nLine 3" },
44
+ ]);
26
45
  });
27
46
 
28
- it("renders multiple thinking blocks", () => {
29
- const input =
30
- "<thinking>First block</thinking> Some text <thinking>Second block</thinking>";
31
- const output = renderThinking(input);
32
- expect(output).toContain("First block");
33
- expect(output).toContain("Second block");
34
- expect(output).toContain("Some text");
35
- expect(output).toContain(">");
47
+ it("emits one thinking block per closed span in order", () => {
48
+ const out = splitThinking(
49
+ "<thinking>First block</thinking> Some text <thinking>Second block</thinking>",
50
+ );
51
+ expect(thinkings(out)).toEqual(["First block", "Second block"]);
52
+ expect(texts(out)).toEqual(["Some text"]);
36
53
  });
37
54
 
38
- it("removes empty thinking blocks", () => {
39
- const input = "Before <thinking></thinking> After";
40
- const output = renderThinking(input);
41
- expect(output).not.toContain("thinking");
42
- expect(output).toContain("Before");
43
- expect(output).toContain("After");
55
+ it("drops empty thinking spans", () => {
56
+ const out = splitThinking("Before <thinking></thinking> After");
57
+ expect(thinkings(out)).toEqual([]);
58
+ expect(texts(out).join(" ")).toContain("Before");
59
+ expect(texts(out).join(" ")).toContain("After");
44
60
  });
45
61
 
46
- it("removes thinking blocks with only whitespace", () => {
47
- const input = "Before <thinking> \n </thinking> After";
48
- const output = renderThinking(input);
49
- expect(output).not.toContain(">");
50
- expect(output).toContain("Before");
51
- expect(output).toContain("After");
62
+ it("drops whitespace-only thinking spans", () => {
63
+ const out = splitThinking("Before <thinking> \n </thinking> After");
64
+ expect(thinkings(out)).toEqual([]);
52
65
  });
53
66
 
54
67
  it("trims whitespace from thinking content", () => {
55
- const input = "<thinking>\n This is reasoning \n</thinking>";
56
- const output = renderThinking(input);
57
- expect(output).toBe(`> This is reasoning\n\n`);
68
+ const out = splitThinking(
69
+ "<thinking>\n This is reasoning \n</thinking>",
70
+ );
71
+ expect(out).toEqual([
72
+ { type: "thinking", thinking: "This is reasoning" },
73
+ ]);
58
74
  });
59
75
 
60
76
  it("handles mixed case tag names", () => {
61
- const input =
62
- "<THINKING>uppercase</THINKING> <ThInKiNg>mixedcase</ThInKiNg>";
63
- const output = renderThinking(input);
64
- expect(output).toContain("uppercase");
65
- expect(output).toContain("mixedcase");
66
- expect(output).toContain(">");
77
+ const out = splitThinking(
78
+ "<THINKING>uppercase</THINKING> <ThInKiNg>mixedcase</ThInKiNg>",
79
+ );
80
+ expect(thinkings(out)).toEqual(["uppercase", "mixedcase"]);
67
81
  });
68
82
  });
69
83
 
70
84
  describe("dangling/unclosed blocks", () => {
71
- it("renders dangling <thinking> block at end of text", () => {
72
- const input = "Some text <thinking>Reasoning without close tag";
73
- const output = renderThinking(input);
74
- expect(output).toContain("Reasoning without close tag");
75
- expect(output).toContain("Some text");
76
- expect(output).toContain(">");
85
+ it("treats a trailing <thinking> as a thinking block", () => {
86
+ const out = splitThinking("Some text <thinking>Reasoning without close");
87
+ expect(texts(out)).toEqual(["Some text"]);
88
+ expect(thinkings(out)).toEqual(["Reasoning without close"]);
77
89
  });
78
90
 
79
- it("renders dangling <think> block at end of text", () => {
80
- const input = "Some text <think>Reasoning without close tag";
81
- const output = renderThinking(input);
82
- expect(output).toContain("Reasoning without close tag");
83
- expect(output).toContain("Some text");
84
- expect(output).toContain(">");
91
+ it("treats a trailing <think> as a thinking block", () => {
92
+ const out = splitThinking("Some text <think>Reasoning without close");
93
+ expect(texts(out)).toEqual(["Some text"]);
94
+ expect(thinkings(out)).toEqual(["Reasoning without close"]);
85
95
  });
86
96
 
87
- it("does not treat mid-text unclosed tag as dangling", () => {
88
- // Only dangling blocks at the END of text are processed by OPEN_TAIL_RE
89
- const input = "<thinking>Unclosed\nMore text after";
90
- const output = renderThinking(input);
91
- expect(output).toContain(">");
97
+ it("captures the remainder of a leading unclosed tag as reasoning", () => {
98
+ const out = splitThinking("<thinking>Unclosed\nMore text after");
99
+ expect(thinkings(out)).toEqual(["Unclosed\nMore text after"]);
100
+ expect(texts(out)).toEqual([]);
92
101
  });
93
102
  });
94
103
 
95
104
  describe("orphan tags", () => {
96
- it("removes orphan closing tags", () => {
97
- const input = "Some text </thinking> more text";
98
- const output = renderThinking(input);
99
- expect(output).not.toContain("</thinking>");
100
- expect(output).not.toContain("<thinking>");
101
- expect(output).toContain("Some text");
102
- expect(output).toContain("more text");
103
- });
104
-
105
- it("removes orphan opening tags after processing blocks", () => {
106
- const input = "Text <think> orphan tag";
107
- const output = renderThinking(input);
108
- expect(output).not.toContain("<think>");
109
- expect(output).toContain("Text");
110
- });
111
-
112
- it("removes multiple orphan tags", () => {
113
- const input = "</thinking> text </think> more <thinking> stuff </think>";
114
- const output = renderThinking(input);
115
- // Note: <thinking> at the end is treated as dangling block, creating a blockquote
116
- expect(output).not.toContain("<thinking>");
117
- expect(output).not.toContain("</thinking>");
118
- expect(output).not.toContain("</think>");
119
- expect(output).toContain("text");
120
- expect(output).toContain("stuff");
105
+ it("removes orphan closing tags from text", () => {
106
+ const out = splitThinking("Some text </thinking> more text");
107
+ const joined = texts(out).join(" ");
108
+ expect(joined).not.toContain("</thinking>");
109
+ expect(joined).not.toContain("<thinking>");
110
+ expect(joined).toContain("Some text");
111
+ expect(joined).toContain("more text");
112
+ });
113
+
114
+ it("treats a trailing open tag as a (possibly empty) reasoning span", () => {
115
+ const out = splitThinking("Text <think> orphan tag");
116
+ expect(texts(out)).toEqual(["Text"]);
117
+ expect(thinkings(out)).toEqual(["orphan tag"]);
118
+ });
119
+
120
+ it("handles multiple orphan tags", () => {
121
+ const out = splitThinking(
122
+ "</thinking> text </think> more <thinking> stuff </think>",
123
+ );
124
+ const joined = texts(out).join(" ");
125
+ expect(joined).not.toContain("<thinking>");
126
+ expect(joined).not.toContain("</thinking>");
127
+ expect(joined).not.toContain("</think>");
128
+ expect(joined).toContain("text");
129
+ expect(thinkings(out).join(" ")).toContain("stuff");
121
130
  });
122
131
  });
123
132
 
124
133
  describe("text without thinking tags", () => {
125
- it("returns text unchanged when no thinking tags present", () => {
134
+ it("returns the original text block unchanged", () => {
126
135
  const input = "This is regular text without any tags";
127
- const output = renderThinking(input);
128
- expect(output).toBe(input);
136
+ expect(splitThinking(input)).toEqual([{ type: "text", text: input }]);
129
137
  });
130
138
 
131
- it("preserves markdown formatting", () => {
139
+ it("preserves markdown formatting verbatim", () => {
132
140
  const input = "# Header\n\n**bold** and *italic*";
133
- const output = renderThinking(input);
134
- expect(output).toBe(input);
141
+ expect(splitThinking(input)).toEqual([{ type: "text", text: input }]);
135
142
  });
136
143
  });
137
144
 
138
- describe("mixed content", () => {
139
- it("preserves text before thinking block", () => {
140
- const input = "Response text here\n\n<thinking>reasoning</thinking>";
141
- const output = renderThinking(input);
142
- expect(output).toContain("Response text here");
143
- expect(output).toContain("reasoning");
144
- expect(output).toContain(">");
145
+ describe("mixed content order", () => {
146
+ it("keeps text before a thinking block", () => {
147
+ const out = splitThinking(
148
+ "Response text here\n\n<thinking>reasoning</thinking>",
149
+ );
150
+ expect(out).toEqual([
151
+ { type: "text", text: "Response text here" },
152
+ { type: "thinking", thinking: "reasoning" },
153
+ ]);
154
+ });
155
+
156
+ it("keeps text after a thinking block", () => {
157
+ const out = splitThinking(
158
+ "<thinking>reasoning</thinking>\n\nMore response text",
159
+ );
160
+ expect(out).toEqual([
161
+ { type: "thinking", thinking: "reasoning" },
162
+ { type: "text", text: "More response text" },
163
+ ]);
164
+ });
165
+
166
+ it("keeps text between multiple thinking blocks in order", () => {
167
+ const out = splitThinking(
168
+ "<thinking>first</thinking>\n\nMiddle text\n\n<thinking>second</thinking>",
169
+ );
170
+ expect(out).toEqual([
171
+ { type: "thinking", thinking: "first" },
172
+ { type: "text", text: "Middle text" },
173
+ { type: "thinking", thinking: "second" },
174
+ ]);
145
175
  });
176
+ });
146
177
 
147
- it("preserves text after thinking block", () => {
148
- const input = "<thinking>reasoning</thinking>\n\nMore response text";
149
- const output = renderThinking(input);
150
- expect(output).toContain("reasoning");
151
- expect(output).toContain("More response text");
152
- expect(output).toContain(">");
178
+ describe("streaming (partial tail tags)", () => {
179
+ it("strips a half-streamed opening tag", () => {
180
+ expect(stripPartialTailTag("Hello <thin")).toBe("Hello ");
181
+ expect(stripPartialTailTag("Hello <")).toBe("Hello ");
182
+ expect(stripPartialTailTag("Hello <thinking")).toBe("Hello ");
153
183
  });
154
184
 
155
- it("preserves text between multiple thinking blocks", () => {
156
- const input =
157
- "<thinking>first</thinking>\n\nMiddle text\n\n<thinking>second</thinking>";
158
- const output = renderThinking(input);
159
- expect(output).toContain("first");
160
- expect(output).toContain("Middle text");
161
- expect(output).toContain("second");
162
- expect(output).toContain(">");
185
+ it("strips a half-streamed closing tag", () => {
186
+ expect(stripPartialTailTag("reasoning </thinkin")).toBe("reasoning ");
187
+ expect(stripPartialTailTag("reasoning </")).toBe("reasoning ");
163
188
  });
164
- });
165
189
 
166
- describe("newline cleanup", () => {
167
- it("reduces excessive newlines to maximum of 3", () => {
168
- const input = "Text\n\n\n\n\n\nMore text";
169
- const output = renderThinking(input);
170
- expect(output).not.toContain("\n\n\n\n");
171
- expect(output).toBe("Text\n\n\nMore text");
190
+ it("keeps non-reasoning partial tags", () => {
191
+ expect(stripPartialTailTag("a generic <div")).toBe("a generic <div");
192
+ expect(stripPartialTailTag("math: 1 < 2")).toBe("math: 1 < 2");
172
193
  });
173
194
 
174
- it("removes leading whitespace", () => {
175
- const input = " \n \n Text";
176
- const output = renderThinking(input);
177
- expect(output).toBe("Text");
195
+ it("keeps complete tags (only the trailing fragment is stripped)", () => {
196
+ expect(stripPartialTailTag("<thinking>body")).toBe("<thinking>body");
178
197
  });
179
198
 
180
- it("preserves necessary newlines", () => {
181
- const input = "Line 1\n\nLine 2";
182
- const output = renderThinking(input);
183
- expect(output).toBe(input);
199
+ it("emits a thinking block for an open span before the close arrives", () => {
200
+ const midStream = "<thinking>I am reasoning about";
201
+ const out = splitThinking(stripPartialTailTag(midStream));
202
+ expect(out).toEqual([
203
+ { type: "thinking", thinking: "I am reasoning about" },
204
+ ]);
184
205
  });
185
- });
186
206
 
187
- describe("edge cases", () => {
188
- it("handles empty string", () => {
189
- const input = "";
190
- const output = renderThinking(input);
191
- expect(output).toBe("");
207
+ it("renders progressively without flashing a partial close tag", () => {
208
+ const step1 = splitThinking(stripPartialTailTag("<think>step one"));
209
+ const step2 = splitThinking(
210
+ stripPartialTailTag("<think>step one and two</thi"),
211
+ );
212
+ const step3 = splitThinking(
213
+ stripPartialTailTag("<think>step one and two</think>\n\nAnswer"),
214
+ );
215
+ expect(step1).toEqual([{ type: "thinking", thinking: "step one" }]);
216
+ expect(step2).toEqual([
217
+ { type: "thinking", thinking: "step one and two" },
218
+ ]);
219
+ expect(step3).toEqual([
220
+ { type: "thinking", thinking: "step one and two" },
221
+ { type: "text", text: "Answer" },
222
+ ]);
192
223
  });
224
+ });
193
225
 
194
- it("handles string with only whitespace", () => {
195
- const input = " \n \n ";
196
- const output = renderThinking(input);
197
- expect(output).toBe("");
226
+ describe("edge cases", () => {
227
+ it("returns a single text block for an empty string", () => {
228
+ expect(splitThinking("")).toEqual([{ type: "text", text: "" }]);
198
229
  });
199
230
 
200
- it("handles nested-looking tags (not actually nested in HTML sense)", () => {
201
- const input =
202
- "<thinking>outer <thinking>inner</thinking> outer</thinking>";
203
- const output = renderThinking(input);
204
- // Regex will match first <thinking>...</thinking> pair
205
- expect(output).toContain(">");
231
+ it("collapses an all-empty reasoning message to one empty text block", () => {
232
+ expect(splitThinking("<thinking></thinking>")).toEqual([
233
+ { type: "text", text: "" },
234
+ ]);
206
235
  });
207
236
 
208
237
  it("handles thinking content with special characters", () => {
209
- const input = "<thinking>Special chars: $@#%^&*()</thinking>";
210
- const output = renderThinking(input);
211
- expect(output).toContain("Special chars: $@#%^&*()");
212
- expect(output).toContain(">");
238
+ const out = splitThinking(
239
+ "<thinking>Special chars: $@#%^&*()</thinking>",
240
+ );
241
+ expect(thinkings(out)).toEqual(["Special chars: $@#%^&*()"]);
213
242
  });
214
243
 
215
244
  it("handles thinking content with code-like syntax", () => {
216
- const input = "<thinking>const x = 5;\nreturn x + 1;</thinking>";
217
- const output = renderThinking(input);
218
- expect(output).toContain("const x = 5;");
219
- expect(output).toContain("return x + 1;");
220
- expect(output).toContain(">");
245
+ const out = splitThinking(
246
+ "<thinking>const x = 5;\nreturn x + 1;</thinking>",
247
+ );
248
+ expect(thinkings(out)).toEqual(["const x = 5;\nreturn x + 1;"]);
221
249
  });
222
250
  });
223
251
  });
package/src/thinking.ts CHANGED
@@ -1,22 +1,38 @@
1
1
  /**
2
- * Render leaked reasoning tags as styled, visually distinct blocks.
2
+ * Convert leaked reasoning tags into native `thinking` content blocks.
3
3
  *
4
4
  * Some openai-compatible providers leak raw <think>/<thinking> tags into the
5
5
  * visible assistant `content[].text` (the real reasoning travels the proper
6
- * `reasoning_content` channel). Instead of stripping them, we render them
7
- * with clear visual styling so they're useful for debugging but don't
8
- * interfere with the actual response.
6
+ * `reasoning_content` channel). Instead of stripping or restyling them, we
7
+ * split each affected text block into ordered `text` + `thinking` content
8
+ * blocks. Pi renders `thinking` blocks dim + italic via the `thinkingText`
9
+ * theme token natively (see assistant-message.ts) — no ANSI injection, no
10
+ * markdown blockquote shim.
9
11
  *
10
12
  * Approach:
11
- * - Do nothing during streaming (no live mutation, no polling, no races).
12
- * - On `message_end`, extract and reformat every reasoning block with
13
- * visual markers, then return the styled message via the supported
14
- * replacement channel.
13
+ * - During streaming (`message_update`), rebuild the event's message so a
14
+ * reasoning block appears the moment the open tag streams in — no waiting
15
+ * for the close tag. splitThinking() captures the dangling-open case, and
16
+ * a trailing half-streamed tag (e.g. "<thin") is stripped so it never
17
+ * flashes as literal text.
15
18
  *
16
- * `content[].text` is MARKDOWN rendered by pi's TUI Markdown component.
17
- * The TUI does NOT parse HTML <details>/<summary> would render as literal
18
- * junk text. We use a Markdown BLOCKQUOTE instead, which the TUI renders
19
- * natively via the `mdQuote`/`mdQuoteBorder` theme tokens.
19
+ * Safety: `event.message` is a per-event shallow copy, but its content
20
+ * blocks are the provider's LIVE accumulating objects (providers do
21
+ * `block.text += delta`). We therefore never mutate text blocks in
22
+ * place we replace `message.content` with fresh block objects. The
23
+ * TUI receives the same event object after extensions run, so the rebuilt
24
+ * content is what gets rendered live.
25
+ *
26
+ * - On `message_end`, split every affected text block and return the
27
+ * replacement via the supported channel. (The finalized message comes
28
+ * from `response.result()` — a fresh object that never saw the streaming
29
+ * rebuild — so this step is still required for persistence.)
30
+ *
31
+ * Persistence trade-off: the replacement is persisted and round-trips to the
32
+ * provider next turn. The synthesized `thinking` blocks carry no
33
+ * thinkingSignature (none was received — the reasoning leaked into the text
34
+ * channel), so signature-validating APIs (e.g. Anthropic) may reject or drop
35
+ * them on multi-turn. Accepted in exchange for native dim+italic rendering.
20
36
  *
21
37
  * To add a new tag variant, append to TAG_NAMES below.
22
38
  */
@@ -38,63 +54,154 @@ interface TextBlock {
38
54
  type: "text";
39
55
  text: string;
40
56
  }
41
- type Block = TextBlock | { type: string; [k: string]: unknown };
57
+ interface ThinkingBlock {
58
+ type: "thinking";
59
+ thinking: string;
60
+ }
61
+ type Block = TextBlock | ThinkingBlock | { type: string; [k: string]: unknown };
42
62
  interface Msg {
43
63
  role?: string;
44
64
  content?: Block[];
45
65
  }
46
66
 
47
- // Render a reasoning body as a markdown blockquote.
48
- function asQuote(body: string, _label: string): string {
49
- const lines = body.split("\n");
50
- const quoted = lines.map((line) => `> ${line}`).join("\n");
51
- return `\n\n${quoted}\n\n`;
67
+ // Trailing half-streamed tag, e.g. "<", "</", "<thin", "</thinkin".
68
+ // Only used during streaming so an incomplete tag never flashes as text.
69
+ const PARTIAL_TAIL_RE = /<\/?([a-zA-Z]*)$/;
70
+
71
+ function stripPartialTailTag(text: string): string {
72
+ const match = text.match(PARTIAL_TAIL_RE);
73
+ if (!match) return text;
74
+ const fragment = match[1].toLowerCase();
75
+ if (TAG_NAMES.some((tag) => tag.startsWith(fragment))) {
76
+ return text.slice(0, match.index);
77
+ }
78
+ return text;
52
79
  }
53
80
 
54
- function renderThinking(text: string): string {
55
- // Replace closed blocks with a clearly-marked blockquote
56
- text = text.replace(CLOSED_BLOCK_RE, (_match, _tag, content) => {
57
- const trimmed = content.trim();
58
- if (!trimmed) return "";
59
- return asQuote(trimmed, "⚙ Reasoning");
60
- });
81
+ // Push a text block only when it has visible content. Surrounding whitespace
82
+ // between reasoning and answer text is dropped so the native renderer doesn't
83
+ // emit stray blank paragraphs.
84
+ // True when the text contains any reasoning tag (open, close, or orphan).
85
+ function hasReasoningTag(text: string): boolean {
86
+ ORPHAN_TAG_RE.lastIndex = 0;
87
+ return ORPHAN_TAG_RE.test(text);
88
+ }
61
89
 
62
- // Replace dangling open blocks (stream cut off before close tag)
63
- text = text.replace(OPEN_TAIL_RE, (_match, _tag, content) => {
64
- const trimmed = content.trim();
65
- if (!trimmed) return "";
66
- return asQuote(trimmed, "⚙ Reasoning (incomplete)");
67
- });
90
+ function pushText(blocks: Block[], text: string): void {
91
+ const trimmed = text.trim();
92
+ if (trimmed) blocks.push({ type: "text", text: trimmed });
93
+ }
68
94
 
69
- // Clean up any orphan tags
70
- text = text.replace(ORPHAN_TAG_RE, "");
95
+ function pushThinking(blocks: Block[], thinking: string): void {
96
+ const trimmed = thinking.trim();
97
+ if (trimmed) blocks.push({ type: "thinking", thinking: trimmed });
98
+ }
71
99
 
72
- // Clean up excessive newlines
73
- return text.replace(/\n{4,}/g, "\n\n\n").replace(/^\s+/, "");
100
+ /**
101
+ * Split leaked reasoning text into ordered native content blocks.
102
+ *
103
+ * Reasoning spans (`<think>…</think>`, plus a trailing unclosed `<think>…`)
104
+ * become real `thinking` blocks, which pi renders dim + italic via the
105
+ * `thinkingText` theme token — no ANSI injection, no markdown blockquote.
106
+ * Everything else stays a `text` block. Returns the original single text
107
+ * block unchanged when no reasoning tags are present.
108
+ */
109
+ function splitThinking(text: string): Block[] {
110
+ if (!hasReasoningTag(text)) {
111
+ return [{ type: "text", text }];
112
+ }
113
+
114
+ const blocks: Block[] = [];
115
+ let rest = text;
116
+
117
+ // Consume closed reasoning blocks left-to-right, preserving order with the
118
+ // surrounding answer text.
119
+ CLOSED_BLOCK_RE.lastIndex = 0;
120
+ let match = CLOSED_BLOCK_RE.exec(rest);
121
+ while (match) {
122
+ pushText(blocks, rest.slice(0, match.index));
123
+ pushThinking(blocks, match[2]);
124
+ rest = rest.slice(match.index + match[0].length);
125
+ CLOSED_BLOCK_RE.lastIndex = 0;
126
+ match = CLOSED_BLOCK_RE.exec(rest);
127
+ }
128
+
129
+ // A dangling open block (close tag not yet streamed / never emitted): the
130
+ // remainder after the open tag is reasoning.
131
+ const openMatch = OPEN_TAIL_RE.exec(rest);
132
+ if (openMatch) {
133
+ // Leading text may still carry orphan tags (e.g. a stray `</think>`).
134
+ pushText(
135
+ blocks,
136
+ openMatch.input.slice(0, openMatch.index).replace(ORPHAN_TAG_RE, ""),
137
+ );
138
+ pushThinking(blocks, openMatch[2].replace(ORPHAN_TAG_RE, ""));
139
+ } else {
140
+ // Strip any orphan tags from the trailing text.
141
+ pushText(blocks, rest.replace(ORPHAN_TAG_RE, ""));
142
+ }
143
+
144
+ // All-empty (e.g. `<think></think>`) collapses to a single empty text block
145
+ // so the message never becomes contentless.
146
+ return blocks.length > 0 ? blocks : [{ type: "text", text: "" }];
74
147
  }
75
148
 
76
149
  // Export for testing
77
- export { renderThinking };
150
+ export { splitThinking, stripPartialTailTag };
78
151
 
79
152
  export default function thinkingExtension(pi: ExtensionAPI) {
153
+ // Live conversion during streaming: rebuild the event's message so a native
154
+ // thinking block appears as soon as the open tag streams in, token by token.
155
+ pi.on("message_update", (event) => {
156
+ const ev = event as {
157
+ message?: Msg;
158
+ assistantMessageEvent?: { type?: string };
159
+ };
160
+ const msg = ev.message;
161
+ if (msg?.role !== "assistant" || !Array.isArray(msg.content)) return;
162
+
163
+ // Only text stream events can change text blocks; skip toolcall/thinking
164
+ // channel deltas to avoid pointless re-renders.
165
+ const streamType = ev.assistantMessageEvent?.type;
166
+ if (streamType && !streamType.startsWith("text_")) return;
167
+
168
+ msg.content = msg.content.flatMap((block): Block[] => {
169
+ if (block.type !== "text") return [block];
170
+ const tb = block as TextBlock;
171
+ if (typeof tb.text !== "string" || !tb.text.includes("<")) return [block];
172
+ // Strip a half-streamed tag so it never flashes as literal text.
173
+ const stripped = stripPartialTailTag(tb.text);
174
+ // Nothing reasoning-related: leave unrelated "<" text alone entirely.
175
+ if (!hasReasoningTag(stripped) && stripped === tb.text) return [block];
176
+ // New objects — never mutate the provider's accumulating block.
177
+ return splitThinking(stripped);
178
+ });
179
+ });
180
+
80
181
  pi.on("message_end", (event) => {
81
182
  const msg = (event as { message?: Msg }).message;
82
- if (!msg || msg.role !== "assistant" || !Array.isArray(msg.content)) return;
183
+ if (msg?.role !== "assistant" || !Array.isArray(msg.content)) return;
83
184
 
84
185
  let changed = false;
85
- for (const block of msg.content) {
86
- if (block.type !== "text") continue;
186
+ const content = msg.content.flatMap((block): Block[] => {
187
+ if (block.type !== "text") return [block];
87
188
  const tb = block as TextBlock;
88
- if (typeof tb.text !== "string") continue;
89
- if (!TAG_NAMES.some((t) => tb.text.includes(`<${t}`))) continue;
90
- const rendered = renderThinking(tb.text);
91
- if (rendered !== tb.text) {
92
- tb.text = rendered;
93
- changed = true;
94
- }
95
- }
189
+ if (typeof tb.text !== "string") return [block];
190
+ if (!hasReasoningTag(tb.text)) return [block];
191
+ changed = true;
192
+ return splitThinking(tb.text);
193
+ });
96
194
 
97
- // Return the replacement so the styled message is what gets persisted.
98
- if (changed) return { message: msg as unknown as never };
195
+ // Return the replacement so the native thinking blocks are persisted.
196
+ // Persistence note: this rewrites leaked reasoning from `text` into real
197
+ // `thinking` content blocks, which round-trip to the provider next turn.
198
+ // The blocks carry no thinkingSignature (we never received one — the
199
+ // reasoning leaked into the text channel), so signature-validating APIs
200
+ // may reject or drop them on multi-turn. Accepted trade-off for native
201
+ // dim+italic rendering via the `thinkingText` theme token.
202
+ if (changed) {
203
+ msg.content = content;
204
+ return { message: msg as unknown as never };
205
+ }
99
206
  });
100
207
  }
package/src/types.ts CHANGED
@@ -161,9 +161,35 @@ export type ReadParams = ReadToolInput;
161
161
 
162
162
  export type BashParams = BashToolInput;
163
163
 
164
- export type EditParams = EditToolInput;
164
+ // The defensive renderers below accept several legacy / alternate field names
165
+ // (snake_case + singular shapes) that upstream's strict tool-input types no
166
+ // longer declare. We model the full accepted superset here so the runtime
167
+ // fallbacks stay type-safe. These are intentionally standalone (not an
168
+ // intersection with EditToolInput/WriteToolInput) because the upstream `edits`
169
+ // element type is narrower and would conflict with the legacy item shape.
170
+ export type EditOperationInput = {
171
+ oldText?: string;
172
+ newText?: string;
173
+ old_text?: string;
174
+ new_text?: string;
175
+ };
176
+
177
+ export type EditParams = {
178
+ path?: string;
179
+ file_path?: string;
180
+ edits?: EditOperationInput[];
181
+ } & EditOperationInput;
182
+
183
+ export type WriteParams = {
184
+ path?: string;
185
+ file_path?: string;
186
+ content?: string;
187
+ };
165
188
 
166
- export type WriteParams = WriteToolInput;
189
+ // Keep a reference to the upstream input types so the import stays meaningful
190
+ // and future drift is visible at this seam.
191
+ export type UpstreamEditToolInput = EditToolInput;
192
+ export type UpstreamWriteToolInput = WriteToolInput;
167
193
 
168
194
  // A single old→new replacement extracted from an edit tool call (supports both
169
195
  // the single oldText/newText shape and the batched `edits[]` shape).