@xynogen/pix-pretty 1.1.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,6 +1,6 @@
1
1
  {
2
2
  "name": "@xynogen/pix-pretty",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Enhanced tool output rendering with syntax highlighting, file icons, tree views, FFF search, and paste chip formatting",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -1,190 +1,181 @@
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
10
  import { describe, expect, it } from "bun:test";
6
- import { renderThinking, stripPartialTailTag } from "./thinking";
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");
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");
103
112
  });
104
113
 
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");
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"]);
110
118
  });
111
119
 
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");
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);
135
- });
136
- });
137
-
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
- });
146
-
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(">");
153
- });
154
-
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(">");
141
+ expect(splitThinking(input)).toEqual([{ type: "text", text: input }]);
163
142
  });
164
143
  });
165
144
 
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");
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
+ ]);
172
154
  });
173
155
 
174
- it("removes leading whitespace", () => {
175
- const input = " \n \n Text";
176
- const output = renderThinking(input);
177
- expect(output).toBe("Text");
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
+ ]);
178
164
  });
179
165
 
180
- it("preserves necessary newlines", () => {
181
- const input = "Line 1\n\nLine 2";
182
- const output = renderThinking(input);
183
- expect(output).toBe(input);
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
+ ]);
184
175
  });
185
176
  });
186
177
 
187
- describe("streaming (partial tail tags + live rendering)", () => {
178
+ describe("streaming (partial tail tags)", () => {
188
179
  it("strips a half-streamed opening tag", () => {
189
180
  expect(stripPartialTailTag("Hello <thin")).toBe("Hello ");
190
181
  expect(stripPartialTailTag("Hello <")).toBe("Hello ");
@@ -205,63 +196,56 @@ describe("thinking tag rendering", () => {
205
196
  expect(stripPartialTailTag("<thinking>body")).toBe("<thinking>body");
206
197
  });
207
198
 
208
- it("renders an open block as blockquote before close tag arrives", () => {
209
- // Simulates mid-stream state: open tag + partial body, no close tag.
199
+ it("emits a thinking block for an open span before the close arrives", () => {
210
200
  const midStream = "<thinking>I am reasoning about";
211
- const output = renderThinking(stripPartialTailTag(midStream));
212
- expect(output).toBe("> I am reasoning about\n\n");
201
+ const out = splitThinking(stripPartialTailTag(midStream));
202
+ expect(out).toEqual([
203
+ { type: "thinking", thinking: "I am reasoning about" },
204
+ ]);
213
205
  });
214
206
 
215
- it("renders progressively without flashing partial close tag", () => {
216
- const step1 = renderThinking(stripPartialTailTag("<think>step one"));
217
- const step2 = renderThinking(
207
+ it("renders progressively without flashing a partial close tag", () => {
208
+ const step1 = splitThinking(stripPartialTailTag("<think>step one"));
209
+ const step2 = splitThinking(
218
210
  stripPartialTailTag("<think>step one and two</thi"),
219
211
  );
220
- const step3 = renderThinking(
212
+ const step3 = splitThinking(
221
213
  stripPartialTailTag("<think>step one and two</think>\n\nAnswer"),
222
214
  );
223
- expect(step1).toBe("> step one\n\n");
224
- expect(step2).toBe("> step one and two\n\n");
225
- expect(step3).toContain("> step one and two");
226
- expect(step3).toContain("Answer");
227
- expect(step3).not.toContain("<think>");
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
+ ]);
228
223
  });
229
224
  });
230
225
 
231
226
  describe("edge cases", () => {
232
- it("handles empty string", () => {
233
- const input = "";
234
- const output = renderThinking(input);
235
- expect(output).toBe("");
236
- });
237
-
238
- it("handles string with only whitespace", () => {
239
- const input = " \n \n ";
240
- const output = renderThinking(input);
241
- expect(output).toBe("");
227
+ it("returns a single text block for an empty string", () => {
228
+ expect(splitThinking("")).toEqual([{ type: "text", text: "" }]);
242
229
  });
243
230
 
244
- it("handles nested-looking tags (not actually nested in HTML sense)", () => {
245
- const input =
246
- "<thinking>outer <thinking>inner</thinking> outer</thinking>";
247
- const output = renderThinking(input);
248
- // Regex will match first <thinking>...</thinking> pair
249
- 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
+ ]);
250
235
  });
251
236
 
252
237
  it("handles thinking content with special characters", () => {
253
- const input = "<thinking>Special chars: $@#%^&*()</thinking>";
254
- const output = renderThinking(input);
255
- expect(output).toContain("Special chars: $@#%^&*()");
256
- expect(output).toContain(">");
238
+ const out = splitThinking(
239
+ "<thinking>Special chars: $@#%^&*()</thinking>",
240
+ );
241
+ expect(thinkings(out)).toEqual(["Special chars: $@#%^&*()"]);
257
242
  });
258
243
 
259
244
  it("handles thinking content with code-like syntax", () => {
260
- const input = "<thinking>const x = 5;\nreturn x + 1;</thinking>";
261
- const output = renderThinking(input);
262
- expect(output).toContain("const x = 5;");
263
- expect(output).toContain("return x + 1;");
264
- 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;"]);
265
249
  });
266
250
  });
267
251
  });
package/src/thinking.ts CHANGED
@@ -1,37 +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
- * - During streaming (`message_update`), re-render the event's message so
12
- * reasoning blocks appear as styled blockquotes the moment the open tag
13
- * streams in — no waiting for the close tag. The dangling-open-block
14
- * handling in renderThinking() covers the not-yet-closed case, and a
15
- * trailing half-streamed tag (e.g. "<thin") is stripped so it never
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
16
17
  * flashes as literal text.
17
18
  *
18
19
  * Safety: `event.message` is a per-event shallow copy, but its content
19
20
  * blocks are the provider's LIVE accumulating objects (providers do
20
21
  * `block.text += delta`). We therefore never mutate text blocks in
21
22
  * place — we replace `message.content` with fresh block objects. The
22
- * TUI receives the same event object after extensions run, so the
23
- * restyled content is what gets rendered live.
23
+ * TUI receives the same event object after extensions run, so the rebuilt
24
+ * content is what gets rendered live.
24
25
  *
25
- * - On `message_end`, extract and reformat every reasoning block with
26
- * visual markers, then return the styled message via the supported
27
- * replacement channel. (The finalized message comes from
28
- * `response.result()`a fresh object that never saw the streaming
29
- * restyling — so this step is still required for persistence.)
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
+ * rebuildso this step is still required for persistence.)
30
30
  *
31
- * `content[].text` is MARKDOWN rendered by pi's TUI Markdown component.
32
- * The TUI does NOT parse HTML <details>/<summary> would render as literal
33
- * junk text. We use a Markdown BLOCKQUOTE instead, which the TUI renders
34
- * natively via the `mdQuote`/`mdQuoteBorder` theme tokens.
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.
35
36
  *
36
37
  * To add a new tag variant, append to TAG_NAMES below.
37
38
  */
@@ -53,7 +54,11 @@ interface TextBlock {
53
54
  type: "text";
54
55
  text: string;
55
56
  }
56
- 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 };
57
62
  interface Msg {
58
63
  role?: string;
59
64
  content?: Block[];
@@ -73,41 +78,80 @@ function stripPartialTailTag(text: string): string {
73
78
  return text;
74
79
  }
75
80
 
76
- // Render a reasoning body as a markdown blockquote.
77
- function asQuote(body: string, _label: string): string {
78
- const lines = body.split("\n");
79
- const quoted = lines.map((line) => `> ${line}`).join("\n");
80
- return `\n\n${quoted}\n\n`;
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);
81
88
  }
82
89
 
83
- function renderThinking(text: string): string {
84
- // Replace closed blocks with a clearly-marked blockquote
85
- text = text.replace(CLOSED_BLOCK_RE, (_match, _tag, content) => {
86
- const trimmed = content.trim();
87
- if (!trimmed) return "";
88
- return asQuote(trimmed, "⚙ Reasoning");
89
- });
90
+ function pushText(blocks: Block[], text: string): void {
91
+ const trimmed = text.trim();
92
+ if (trimmed) blocks.push({ type: "text", text: trimmed });
93
+ }
90
94
 
91
- // Replace dangling open blocks (stream cut off before close tag)
92
- text = text.replace(OPEN_TAIL_RE, (_match, _tag, content) => {
93
- const trimmed = content.trim();
94
- if (!trimmed) return "";
95
- return asQuote(trimmed, "⚙ Reasoning (incomplete)");
96
- });
95
+ function pushThinking(blocks: Block[], thinking: string): void {
96
+ const trimmed = thinking.trim();
97
+ if (trimmed) blocks.push({ type: "thinking", thinking: trimmed });
98
+ }
99
+
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
+ }
97
128
 
98
- // Clean up any orphan tags
99
- text = text.replace(ORPHAN_TAG_RE, "");
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
+ }
100
143
 
101
- // Clean up excessive newlines
102
- return text.replace(/\n{4,}/g, "\n\n\n").replace(/^\s+/, "");
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: "" }];
103
147
  }
104
148
 
105
149
  // Export for testing
106
- export { renderThinking, stripPartialTailTag };
150
+ export { splitThinking, stripPartialTailTag };
107
151
 
108
152
  export default function thinkingExtension(pi: ExtensionAPI) {
109
- // Live styling during streaming: restyle the event's message so reasoning
110
- // renders as soon as the open tag appears, token by token.
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.
111
155
  pi.on("message_update", (event) => {
112
156
  const ev = event as {
113
157
  message?: Msg;
@@ -121,19 +165,16 @@ export default function thinkingExtension(pi: ExtensionAPI) {
121
165
  const streamType = ev.assistantMessageEvent?.type;
122
166
  if (streamType && !streamType.startsWith("text_")) return;
123
167
 
124
- msg.content = msg.content.map((block) => {
125
- if (block.type !== "text") return block;
168
+ msg.content = msg.content.flatMap((block): Block[] => {
169
+ if (block.type !== "text") return [block];
126
170
  const tb = block as TextBlock;
127
- if (typeof tb.text !== "string" || !tb.text.includes("<")) return block;
171
+ if (typeof tb.text !== "string" || !tb.text.includes("<")) return [block];
172
+ // Strip a half-streamed tag so it never flashes as literal text.
128
173
  const stripped = stripPartialTailTag(tb.text);
129
- const lower = stripped.toLowerCase();
130
- const hasTag = TAG_NAMES.some((t) => lower.includes(`<${t}`));
131
174
  // Nothing reasoning-related: leave unrelated "<" text alone entirely.
132
- if (!hasTag && stripped === tb.text) return block;
133
- const rendered = hasTag ? renderThinking(stripped) : stripped;
134
- if (rendered === tb.text) return block;
135
- // New object — never mutate the provider's accumulating block.
136
- return { ...block, text: rendered };
175
+ if (!hasReasoningTag(stripped) && stripped === tb.text) return [block];
176
+ // New objects never mutate the provider's accumulating block.
177
+ return splitThinking(stripped);
137
178
  });
138
179
  });
139
180
 
@@ -142,19 +183,25 @@ export default function thinkingExtension(pi: ExtensionAPI) {
142
183
  if (msg?.role !== "assistant" || !Array.isArray(msg.content)) return;
143
184
 
144
185
  let changed = false;
145
- for (const block of msg.content) {
146
- if (block.type !== "text") continue;
186
+ const content = msg.content.flatMap((block): Block[] => {
187
+ if (block.type !== "text") return [block];
147
188
  const tb = block as TextBlock;
148
- if (typeof tb.text !== "string") continue;
149
- if (!TAG_NAMES.some((t) => tb.text.includes(`<${t}`))) continue;
150
- const rendered = renderThinking(tb.text);
151
- if (rendered !== tb.text) {
152
- tb.text = rendered;
153
- changed = true;
154
- }
155
- }
189
+ if (typeof tb.text !== "string") return [block];
190
+ if (!hasReasoningTag(tb.text)) return [block];
191
+ changed = true;
192
+ return splitThinking(tb.text);
193
+ });
156
194
 
157
- // Return the replacement so the styled message is what gets persisted.
158
- 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
+ }
159
206
  });
160
207
  }