@xynogen/pix-pretty 1.0.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.
@@ -0,0 +1,223 @@
1
+ /**
2
+ * Tests for thinking tag rendering
3
+ */
4
+
5
+ import { describe, it, expect } from "bun:test";
6
+ import { renderThinking } from "./thinking";
7
+
8
+ describe("thinking tag rendering", () => {
9
+ 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`);
14
+ });
15
+
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`);
20
+ });
21
+
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`);
26
+ });
27
+
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(">");
36
+ });
37
+
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");
44
+ });
45
+
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");
52
+ });
53
+
54
+ 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`);
58
+ });
59
+
60
+ 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(">");
67
+ });
68
+ });
69
+
70
+ 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(">");
77
+ });
78
+
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(">");
85
+ });
86
+
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(">");
92
+ });
93
+ });
94
+
95
+ 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");
121
+ });
122
+ });
123
+
124
+ describe("text without thinking tags", () => {
125
+ it("returns text unchanged when no thinking tags present", () => {
126
+ const input = "This is regular text without any tags";
127
+ const output = renderThinking(input);
128
+ expect(output).toBe(input);
129
+ });
130
+
131
+ it("preserves markdown formatting", () => {
132
+ 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(">");
163
+ });
164
+ });
165
+
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");
172
+ });
173
+
174
+ it("removes leading whitespace", () => {
175
+ const input = " \n \n Text";
176
+ const output = renderThinking(input);
177
+ expect(output).toBe("Text");
178
+ });
179
+
180
+ it("preserves necessary newlines", () => {
181
+ const input = "Line 1\n\nLine 2";
182
+ const output = renderThinking(input);
183
+ expect(output).toBe(input);
184
+ });
185
+ });
186
+
187
+ describe("edge cases", () => {
188
+ it("handles empty string", () => {
189
+ const input = "";
190
+ const output = renderThinking(input);
191
+ expect(output).toBe("");
192
+ });
193
+
194
+ it("handles string with only whitespace", () => {
195
+ const input = " \n \n ";
196
+ const output = renderThinking(input);
197
+ expect(output).toBe("");
198
+ });
199
+
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(">");
206
+ });
207
+
208
+ 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(">");
213
+ });
214
+
215
+ 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(">");
221
+ });
222
+ });
223
+ });
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Render leaked reasoning tags as styled, visually distinct 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 them, we render them
7
+ * with clear visual styling so they're useful for debugging but don't
8
+ * interfere with the actual response.
9
+ *
10
+ * 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.
15
+ *
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.
20
+ *
21
+ * To add a new tag variant, append to TAG_NAMES below.
22
+ */
23
+
24
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
25
+
26
+ // Reasoning tag names to render. Add new variants here.
27
+ const TAG_NAMES = ["think", "thinking"] as const;
28
+ const TAG_ALT = TAG_NAMES.join("|");
29
+
30
+ // Closed block: <think>...</think>
31
+ const CLOSED_BLOCK_RE = new RegExp(`<(${TAG_ALT})>([\\s\\S]*?)<\\/\\1>`, "gi");
32
+ // Dangling open block with no close (stream cut off, or close never emitted)
33
+ const OPEN_TAIL_RE = new RegExp(`<(${TAG_ALT})>([\\s\\S]*)$`, "i");
34
+ // Any orphan tags left over.
35
+ const ORPHAN_TAG_RE = new RegExp(`<\\/?(${TAG_ALT})>`, "gi");
36
+
37
+ interface TextBlock {
38
+ type: "text";
39
+ text: string;
40
+ }
41
+ type Block = TextBlock | { type: string; [k: string]: unknown };
42
+ interface Msg {
43
+ role?: string;
44
+ content?: Block[];
45
+ }
46
+
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`;
52
+ }
53
+
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
+ });
61
+
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
+ });
68
+
69
+ // Clean up any orphan tags
70
+ text = text.replace(ORPHAN_TAG_RE, "");
71
+
72
+ // Clean up excessive newlines
73
+ return text.replace(/\n{4,}/g, "\n\n\n").replace(/^\s+/, "");
74
+ }
75
+
76
+ // Export for testing
77
+ export { renderThinking };
78
+
79
+ export default function thinkingExtension(pi: ExtensionAPI) {
80
+ pi.on("message_end", (event) => {
81
+ const msg = (event as { message?: Msg }).message;
82
+ if (!msg || msg.role !== "assistant" || !Array.isArray(msg.content)) return;
83
+
84
+ let changed = false;
85
+ for (const block of msg.content) {
86
+ if (block.type !== "text") continue;
87
+ 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
+ }
96
+
97
+ // Return the replacement so the styled message is what gets persisted.
98
+ if (changed) return { message: msg as unknown as never };
99
+ });
100
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "Bundler",
6
+ "strict": true,
7
+ "esModuleInterop": true,
8
+ "skipLibCheck": true,
9
+ "noEmit": true,
10
+ "types": ["node"],
11
+ "allowImportingTsExtensions": true
12
+ },
13
+ "include": ["*.ts", "*.d.ts", "../_types/**/*.d.ts"]
14
+ }
@@ -0,0 +1,41 @@
1
+ // Minimal ambient shim for the `diff` npm package (v7) — only the surface the
2
+ // vendored pretty extension's diff renderer uses. diff@7 ships no bundled types
3
+ // and @types/diff is not installed into pi's npm root; this keeps the local
4
+ // editor/LSP quiet without vendoring node_modules in-repo.
5
+
6
+ declare module "diff" {
7
+ export interface StructuredPatchHunk {
8
+ oldStart: number;
9
+ oldLines: number;
10
+ newStart: number;
11
+ newLines: number;
12
+ lines: string[];
13
+ }
14
+
15
+ export interface StructuredPatch {
16
+ hunks: StructuredPatchHunk[];
17
+ }
18
+
19
+ export function structuredPatch(
20
+ oldFileName: string,
21
+ newFileName: string,
22
+ oldStr: string,
23
+ newStr: string,
24
+ oldHeader?: string,
25
+ newHeader?: string,
26
+ options?: { context?: number },
27
+ ): StructuredPatch;
28
+
29
+ export interface Change {
30
+ value: string;
31
+ added?: boolean;
32
+ removed?: boolean;
33
+ count?: number;
34
+ }
35
+
36
+ export function diffWords(
37
+ oldStr: string,
38
+ newStr: string,
39
+ options?: unknown,
40
+ ): Change[];
41
+ }
@@ -0,0 +1,80 @@
1
+ // Minimal ambient shim for @ff-labs/fff-node — only the surface the vendored
2
+ // pretty extension uses. The real types ship with the runtime package; this
3
+ // keeps the local editor/LSP quiet without installing node_modules in-repo.
4
+
5
+ declare module "@ff-labs/fff-node" {
6
+ export interface FileItem {
7
+ relativePath: string;
8
+ [k: string]: unknown;
9
+ }
10
+ export interface SearchResult {
11
+ items: FileItem[];
12
+ totalMatched: number;
13
+ [k: string]: unknown;
14
+ }
15
+ export interface GrepMatch {
16
+ relativePath: string;
17
+ lineNumber: number;
18
+ lineContent: string;
19
+ contextBefore?: string[];
20
+ contextAfter?: string[];
21
+ [k: string]: unknown;
22
+ }
23
+ export interface GrepCursor {
24
+ [k: string]: unknown;
25
+ }
26
+ export interface GrepResult {
27
+ items: GrepMatch[];
28
+ nextCursor?: GrepCursor | null;
29
+ regexFallbackError?: string | null;
30
+ [k: string]: unknown;
31
+ }
32
+
33
+ type Ok<T> = { ok: true; value: T };
34
+ type Err = { ok: false; error: string };
35
+ type Result<T> = Ok<T> | Err;
36
+
37
+ export class FileFinder {
38
+ static create(opts: {
39
+ basePath: string;
40
+ frecencyDbPath: string;
41
+ historyDbPath: string;
42
+ aiMode?: boolean;
43
+ }): Result<FileFinder>;
44
+ readonly isDestroyed: boolean;
45
+ waitForScan(timeoutMs: number): Promise<Result<boolean>>;
46
+ fileSearch(query: string, opts: { pageSize: number }): Result<SearchResult>;
47
+ grep(
48
+ query: string,
49
+ opts: {
50
+ mode: "plain" | "regex";
51
+ smartCase: boolean;
52
+ maxMatchesPerFile: number;
53
+ cursor: GrepCursor | null;
54
+ beforeContext: number;
55
+ afterContext: number;
56
+ },
57
+ ): Result<GrepResult>;
58
+ multiGrep(opts: {
59
+ patterns: string[];
60
+ maxMatchesPerFile: number;
61
+ smartCase: boolean;
62
+ cursor: GrepCursor | null;
63
+ beforeContext: number;
64
+ afterContext: number;
65
+ }): Result<GrepResult>;
66
+ healthCheck(): Result<{
67
+ version: string;
68
+ git: { repositoryFound: boolean; workdir?: string };
69
+ filePicker: { initialized: boolean; indexedFiles?: number };
70
+ frecency: { initialized: boolean };
71
+ queryTracker: { initialized: boolean };
72
+ }>;
73
+ getScanProgress(): Result<{
74
+ isScanning: boolean;
75
+ scannedFilesCount: number;
76
+ }>;
77
+ scanFiles(): Result<unknown>;
78
+ destroy(): void;
79
+ }
80
+ }