@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 +53 -52
- package/src/index.test.ts +1 -1
- package/src/index.ts +6 -3
- package/src/paste-chips.test.ts +1 -1
- package/src/paste-chips.ts +11 -7
- package/src/thinking.test.ts +182 -154
- package/src/thinking.ts +156 -49
- package/src/types.ts +28 -2
package/package.json
CHANGED
|
@@ -1,54 +1,55 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
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 (
|
|
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,
|
package/src/paste-chips.test.ts
CHANGED
|
@@ -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,
|
|
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
|
|
package/src/paste-chips.ts
CHANGED
|
@@ -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 {
|
|
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));
|
package/src/thinking.test.ts
CHANGED
|
@@ -1,223 +1,251 @@
|
|
|
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
|
-
import { describe,
|
|
6
|
-
import {
|
|
10
|
+
import { describe, expect, it } from "bun:test";
|
|
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(
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
it("
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
expect(
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
expect(
|
|
117
|
-
expect(
|
|
118
|
-
expect(
|
|
119
|
-
expect(
|
|
120
|
-
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");
|
|
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
|
|
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);
|
|
141
|
+
expect(splitThinking(input)).toEqual([{ type: "text", text: input }]);
|
|
135
142
|
});
|
|
136
143
|
});
|
|
137
144
|
|
|
138
|
-
describe("mixed content", () => {
|
|
139
|
-
it("
|
|
140
|
-
const
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
expect(
|
|
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
|
+
]);
|
|
145
175
|
});
|
|
176
|
+
});
|
|
146
177
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
expect(
|
|
151
|
-
expect(
|
|
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("
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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("
|
|
175
|
-
|
|
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("
|
|
181
|
-
const
|
|
182
|
-
const
|
|
183
|
-
expect(
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
const
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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("
|
|
201
|
-
|
|
202
|
-
"
|
|
203
|
-
|
|
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
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
expect(
|
|
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
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
expect(
|
|
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
|
-
*
|
|
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
|
-
* -
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
-
* `
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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
|
-
|
|
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
|
-
//
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
70
|
-
|
|
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
|
-
|
|
73
|
-
|
|
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 {
|
|
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 (
|
|
183
|
+
if (msg?.role !== "assistant" || !Array.isArray(msg.content)) return;
|
|
83
184
|
|
|
84
185
|
let changed = false;
|
|
85
|
-
|
|
86
|
-
if (block.type !== "text")
|
|
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")
|
|
89
|
-
if (!
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
|
98
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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).
|