@xynogen/pix-pretty 1.6.3 → 1.7.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.
@@ -1,251 +0,0 @@
1
- /**
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.
8
- */
9
-
10
- import { describe, expect, it } from "bun:test";
11
- import { splitThinking, stripPartialTailTag } from "./thinking";
12
-
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", () => {
25
- describe("closed thinking blocks", () => {
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
- ]);
31
- });
32
-
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
- ]);
38
- });
39
-
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
- ]);
45
- });
46
-
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"]);
53
- });
54
-
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");
60
- });
61
-
62
- it("drops whitespace-only thinking spans", () => {
63
- const out = splitThinking("Before <thinking> \n </thinking> After");
64
- expect(thinkings(out)).toEqual([]);
65
- });
66
-
67
- it("trims whitespace from thinking content", () => {
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
- ]);
74
- });
75
-
76
- it("handles mixed case tag names", () => {
77
- const out = splitThinking(
78
- "<THINKING>uppercase</THINKING> <ThInKiNg>mixedcase</ThInKiNg>",
79
- );
80
- expect(thinkings(out)).toEqual(["uppercase", "mixedcase"]);
81
- });
82
- });
83
-
84
- describe("dangling/unclosed blocks", () => {
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"]);
89
- });
90
-
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"]);
95
- });
96
-
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([]);
101
- });
102
- });
103
-
104
- describe("orphan tags", () => {
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");
130
- });
131
- });
132
-
133
- describe("text without thinking tags", () => {
134
- it("returns the original text block unchanged", () => {
135
- const input = "This is regular text without any tags";
136
- expect(splitThinking(input)).toEqual([{ type: "text", text: input }]);
137
- });
138
-
139
- it("preserves markdown formatting verbatim", () => {
140
- const input = "# Header\n\n**bold** and *italic*";
141
- expect(splitThinking(input)).toEqual([{ type: "text", text: input }]);
142
- });
143
- });
144
-
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
- ]);
175
- });
176
- });
177
-
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 ");
183
- });
184
-
185
- it("strips a half-streamed closing tag", () => {
186
- expect(stripPartialTailTag("reasoning </thinkin")).toBe("reasoning ");
187
- expect(stripPartialTailTag("reasoning </")).toBe("reasoning ");
188
- });
189
-
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");
193
- });
194
-
195
- it("keeps complete tags (only the trailing fragment is stripped)", () => {
196
- expect(stripPartialTailTag("<thinking>body")).toBe("<thinking>body");
197
- });
198
-
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
- ]);
205
- });
206
-
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
- ]);
223
- });
224
- });
225
-
226
- describe("edge cases", () => {
227
- it("returns a single text block for an empty string", () => {
228
- expect(splitThinking("")).toEqual([{ type: "text", text: "" }]);
229
- });
230
-
231
- it("collapses an all-empty reasoning message to one empty text block", () => {
232
- expect(splitThinking("<thinking></thinking>")).toEqual([
233
- { type: "text", text: "" },
234
- ]);
235
- });
236
-
237
- it("handles thinking content with special characters", () => {
238
- const out = splitThinking(
239
- "<thinking>Special chars: $@#%^&*()</thinking>",
240
- );
241
- expect(thinkings(out)).toEqual(["Special chars: $@#%^&*()"]);
242
- });
243
-
244
- it("handles thinking content with code-like syntax", () => {
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;"]);
249
- });
250
- });
251
- });
package/src/thinking.ts DELETED
@@ -1,207 +0,0 @@
1
- /**
2
- * Convert leaked reasoning tags into native `thinking` content blocks.
3
- *
4
- * Some openai-compatible providers leak raw <think>/<thinking> tags into the
5
- * visible assistant `content[].text` (the real reasoning travels the proper
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.
11
- *
12
- * Approach:
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.
18
- *
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.
36
- *
37
- * To add a new tag variant, append to TAG_NAMES below.
38
- */
39
-
40
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
41
-
42
- // Reasoning tag names to render. Add new variants here.
43
- const TAG_NAMES = ["think", "thinking"] as const;
44
- const TAG_ALT = TAG_NAMES.join("|");
45
-
46
- // Closed block: <think>...</think>
47
- const CLOSED_BLOCK_RE = new RegExp(`<(${TAG_ALT})>([\\s\\S]*?)<\\/\\1>`, "gi");
48
- // Dangling open block with no close (stream cut off, or close never emitted)
49
- const OPEN_TAIL_RE = new RegExp(`<(${TAG_ALT})>([\\s\\S]*)$`, "i");
50
- // Any orphan tags left over.
51
- const ORPHAN_TAG_RE = new RegExp(`<\\/?(${TAG_ALT})>`, "gi");
52
-
53
- interface TextBlock {
54
- type: "text";
55
- text: string;
56
- }
57
- interface ThinkingBlock {
58
- type: "thinking";
59
- thinking: string;
60
- }
61
- type Block = TextBlock | ThinkingBlock | { type: string; [k: string]: unknown };
62
- interface Msg {
63
- role?: string;
64
- content?: Block[];
65
- }
66
-
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;
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);
88
- }
89
-
90
- function pushText(blocks: Block[], text: string): void {
91
- const trimmed = text.trim();
92
- if (trimmed) blocks.push({ type: "text", text: trimmed });
93
- }
94
-
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
- }
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: "" }];
147
- }
148
-
149
- // Export for testing
150
- export { splitThinking, stripPartialTailTag };
151
-
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
-
181
- pi.on("message_end", (event) => {
182
- const msg = (event as { message?: Msg }).message;
183
- if (msg?.role !== "assistant" || !Array.isArray(msg.content)) return;
184
-
185
- let changed = false;
186
- const content = msg.content.flatMap((block): Block[] => {
187
- if (block.type !== "text") return [block];
188
- const tb = block as TextBlock;
189
- if (typeof tb.text !== "string") return [block];
190
- if (!hasReasoningTag(tb.text)) return [block];
191
- changed = true;
192
- return splitThinking(tb.text);
193
- });
194
-
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
- }
206
- });
207
- }