@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 +1 -1
- package/src/thinking.test.ts +153 -169
- package/src/thinking.ts +115 -68
package/package.json
CHANGED
package/src/thinking.test.ts
CHANGED
|
@@ -1,190 +1,181 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Tests for
|
|
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 {
|
|
11
|
+
import { splitThinking, stripPartialTailTag } from "./thinking";
|
|
7
12
|
|
|
8
|
-
|
|
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("
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
|
|
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("
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
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("
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
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("
|
|
29
|
-
const
|
|
30
|
-
"<thinking>First block</thinking> Some text <thinking>Second block</thinking>"
|
|
31
|
-
|
|
32
|
-
expect(
|
|
33
|
-
expect(
|
|
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("
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
expect(
|
|
42
|
-
expect(
|
|
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("
|
|
47
|
-
const
|
|
48
|
-
|
|
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
|
|
56
|
-
|
|
57
|
-
|
|
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
|
|
62
|
-
"<THINKING>uppercase</THINKING> <ThInKiNg>mixedcase</ThInKiNg>"
|
|
63
|
-
|
|
64
|
-
expect(
|
|
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("
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
expect(
|
|
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("
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
expect(
|
|
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("
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
|
98
|
-
const
|
|
99
|
-
expect(
|
|
100
|
-
expect(
|
|
101
|
-
expect(
|
|
102
|
-
expect(
|
|
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("
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
expect(
|
|
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("
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
expect(
|
|
118
|
-
expect(
|
|
119
|
-
expect(
|
|
120
|
-
expect(
|
|
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
|
|
134
|
+
it("returns the original text block unchanged", () => {
|
|
126
135
|
const input = "This is regular text without any tags";
|
|
127
|
-
|
|
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
|
-
|
|
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("
|
|
167
|
-
it("
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
expect(
|
|
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("
|
|
175
|
-
const
|
|
176
|
-
|
|
177
|
-
|
|
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("
|
|
181
|
-
const
|
|
182
|
-
|
|
183
|
-
|
|
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
|
|
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("
|
|
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
|
|
212
|
-
expect(
|
|
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 =
|
|
217
|
-
const step2 =
|
|
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 =
|
|
212
|
+
const step3 = splitThinking(
|
|
221
213
|
stripPartialTailTag("<think>step one and two</think>\n\nAnswer"),
|
|
222
214
|
);
|
|
223
|
-
expect(step1).
|
|
224
|
-
expect(step2).
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
expect(step3).
|
|
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("
|
|
233
|
-
|
|
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("
|
|
245
|
-
|
|
246
|
-
"
|
|
247
|
-
|
|
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
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
expect(
|
|
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
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
expect(
|
|
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
|
-
*
|
|
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
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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`),
|
|
12
|
-
* reasoning
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
-
*
|
|
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`,
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
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
|
+
* rebuild — so this step is still required for persistence.)
|
|
30
30
|
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
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
|
-
|
|
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
|
-
//
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
//
|
|
99
|
-
|
|
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
|
-
//
|
|
102
|
-
|
|
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 {
|
|
150
|
+
export { splitThinking, stripPartialTailTag };
|
|
107
151
|
|
|
108
152
|
export default function thinkingExtension(pi: ExtensionAPI) {
|
|
109
|
-
// Live
|
|
110
|
-
//
|
|
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.
|
|
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 (!
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
146
|
-
if (block.type !== "text")
|
|
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")
|
|
149
|
-
if (!
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
|
158
|
-
|
|
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
|
}
|