@xynogen/pix-pretty 1.6.3 → 1.6.4
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 -8
- package/src/index.test.ts +5 -25
- package/src/index.ts +8 -20
- package/src/paste-chips.test.ts +0 -162
- package/src/paste-chips.ts +0 -217
- package/src/thinking.test.ts +0 -251
- package/src/thinking.ts +0 -207
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xynogen/pix-pretty",
|
|
3
|
-
"version": "1.6.
|
|
3
|
+
"version": "1.6.4",
|
|
4
4
|
"description": "Enhanced tool output rendering with syntax highlighting, file icons, tree views, FFF search, and paste chip formatting",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -28,13 +28,6 @@
|
|
|
28
28
|
"README.md",
|
|
29
29
|
"LICENSE"
|
|
30
30
|
],
|
|
31
|
-
"pi": {
|
|
32
|
-
"extensions": [
|
|
33
|
-
"./src/index.ts",
|
|
34
|
-
"./src/paste-chips.ts",
|
|
35
|
-
"./src/thinking.ts"
|
|
36
|
-
]
|
|
37
|
-
},
|
|
38
31
|
"keywords": [
|
|
39
32
|
"pi",
|
|
40
33
|
"pi-package",
|
package/src/index.test.ts
CHANGED
|
@@ -1,33 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Smoke tests for pix-pretty (pure lib).
|
|
3
|
+
* UI extension tests moved to pix-display.
|
|
3
4
|
*/
|
|
4
5
|
|
|
5
6
|
import { describe, expect, it } from "bun:test";
|
|
6
7
|
|
|
7
8
|
describe("pix-pretty", () => {
|
|
8
|
-
it("
|
|
9
|
-
|
|
10
|
-
expect(
|
|
11
|
-
});
|
|
12
|
-
|
|
13
|
-
describe("tool rendering extension", () => {
|
|
14
|
-
it("main extension exports a function", async () => {
|
|
15
|
-
const mainModule = await import("./index");
|
|
16
|
-
expect(mainModule.default).toBeFunction();
|
|
17
|
-
});
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
describe("paste-chips extension", () => {
|
|
21
|
-
it("paste-chips extension exports a function", async () => {
|
|
22
|
-
const pasteChipsModule = await import("./paste-chips");
|
|
23
|
-
expect(pasteChipsModule.default).toBeFunction();
|
|
24
|
-
});
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
describe("thinking extension", () => {
|
|
28
|
-
it("thinking extension exports a function", async () => {
|
|
29
|
-
const thinkingModule = await import("./thinking");
|
|
30
|
-
expect(thinkingModule.default).toBeFunction();
|
|
31
|
-
});
|
|
9
|
+
it("main module exports a function", async () => {
|
|
10
|
+
const mod = await import("./index");
|
|
11
|
+
expect(mod.default).toBeFunction();
|
|
32
12
|
});
|
|
33
13
|
});
|
package/src/index.ts
CHANGED
|
@@ -1,26 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* pi-pretty — Pretty terminal output for pi built-in tools.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* standalone pix-{read,bash,ls,find,grep,edit,write} packages — each
|
|
7
|
-
* self-registers via its own pi extension entry point.
|
|
8
|
-
*
|
|
9
|
-
* Modules:
|
|
10
|
-
* types.ts shared interfaces/types
|
|
11
|
-
* config.ts theme + thresholds
|
|
12
|
-
* ansi.ts ANSI codes, low-contrast fix
|
|
13
|
-
* utils.ts helpers + renderToolError
|
|
14
|
-
* lang.ts language detection
|
|
15
|
-
* icons.ts Nerd Font file-type icons
|
|
16
|
-
* highlight.ts cli-highlight engine + ANSI cache
|
|
17
|
-
* renderers.ts renderFileContent/Bash/Tree/Find/Grep
|
|
18
|
-
* fff.ts Fast File Finder + cursor store + module singleton
|
|
19
|
-
* diff.ts unified diff parser
|
|
20
|
-
* diff-render.ts split/word-level diff renderer
|
|
21
|
-
* resize.ts terminal resize invalidation registry
|
|
22
|
-
* tools/ per-tool registrar helpers (context type)
|
|
23
|
-
* commands/ slash command registrars (fff)
|
|
4
|
+
* Pure rendering library — no Pi lifecycle hooks or extensions.
|
|
5
|
+
* UI features (paste chips, thinking blocks) live in pix-display.
|
|
24
6
|
*/
|
|
25
7
|
|
|
26
8
|
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
@@ -48,3 +30,9 @@ export default function piPrettyExtension(pi: PiPrettyApi): void {
|
|
|
48
30
|
// Commands become available once pix-grep initialises the finder.
|
|
49
31
|
registerFffCommands(pi, fffState);
|
|
50
32
|
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* piPrettyExtension still exports a default function for packages that
|
|
36
|
+
* import it as an extension (pix-core activates it for theme + FFF).
|
|
37
|
+
* UI extensions (paste-chips, thinking) moved to pix-display.
|
|
38
|
+
*/
|
package/src/paste-chips.test.ts
DELETED
|
@@ -1,162 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tests for paste-chips marker restyling and width safety.
|
|
3
|
-
*
|
|
4
|
-
* Regression: restyling markers can widen lines (e.g. "#1" → "text"),
|
|
5
|
-
* which caused a TUI crash when the restyled line exceeded terminal width.
|
|
6
|
-
* See: pi-crash.log — "Rendered line 38 exceeds terminal width (285 > 283)"
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import { describe, expect, it } from "bun:test";
|
|
10
|
-
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
11
|
-
import { endsWithMarker, restyleMarkers } from "./paste-chips";
|
|
12
|
-
|
|
13
|
-
const stripAnsi = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, "");
|
|
14
|
-
|
|
15
|
-
// ─── restyleMarkers ──────────────────────────────────────────────────────────
|
|
16
|
-
|
|
17
|
-
describe("paste-chips restyleMarkers", () => {
|
|
18
|
-
describe("text markers", () => {
|
|
19
|
-
it("restyles chars marker to colored icon text chip", () => {
|
|
20
|
-
const result = restyleMarkers("[paste #1 2232 chars]", new Set());
|
|
21
|
-
expect(result).toContain("\x1b[");
|
|
22
|
-
expect(stripAnsi(result)).toBe(" text 2.2k chars");
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
it("restyles lines marker to colored icon text chip", () => {
|
|
26
|
-
const result = restyleMarkers("[paste #2 +42 lines]", new Set());
|
|
27
|
-
expect(stripAnsi(result)).toBe(" text 42 lines");
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
it("restyles bare marker (no size info) to text chip with id", () => {
|
|
31
|
-
const result = restyleMarkers("[paste #3]", new Set());
|
|
32
|
-
expect(stripAnsi(result)).toBe(" text #3");
|
|
33
|
-
});
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
describe("image markers", () => {
|
|
37
|
-
it("restyles marker to colored icon image chip when ID is in imageIds", () => {
|
|
38
|
-
const imageIds = new Set([1]);
|
|
39
|
-
const result = restyleMarkers("[paste #1 58 chars]", imageIds);
|
|
40
|
-
expect(result).toContain("\x1b[");
|
|
41
|
-
expect(stripAnsi(result)).toBe(" image #1");
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
it("does not restyle non-image ID as image", () => {
|
|
45
|
-
const imageIds = new Set([1]);
|
|
46
|
-
const result = restyleMarkers("[paste #2 100 chars]", imageIds);
|
|
47
|
-
expect(stripAnsi(result)).toBe(" text 100 chars");
|
|
48
|
-
});
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
describe("multiple markers in one line", () => {
|
|
52
|
-
it("restyles all markers in a single line", () => {
|
|
53
|
-
const imageIds = new Set([1]);
|
|
54
|
-
const line =
|
|
55
|
-
"before [paste #1 58 chars] middle [paste #2 +10 lines] after";
|
|
56
|
-
const result = restyleMarkers(line, imageIds);
|
|
57
|
-
expect(stripAnsi(result)).toBe(
|
|
58
|
-
"before image #1 middle text 10 lines after",
|
|
59
|
-
);
|
|
60
|
-
});
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
describe("no markers", () => {
|
|
64
|
-
it("returns line unchanged when no markers present", () => {
|
|
65
|
-
const line = "just regular text with no paste markers";
|
|
66
|
-
expect(restyleMarkers(line, new Set())).toBe(line);
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
it("returns empty string unchanged", () => {
|
|
70
|
-
expect(restyleMarkers("", new Set())).toBe("");
|
|
71
|
-
});
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
describe("ANSI-styled markers", () => {
|
|
75
|
-
it("restyles markers embedded in ANSI sequences", () => {
|
|
76
|
-
const line = "\x1b[38;2;84;92;126m[paste #1 500 chars]\x1b[0m";
|
|
77
|
-
const result = restyleMarkers(line, new Set());
|
|
78
|
-
expect(stripAnsi(result)).toBe(" text 500 chars");
|
|
79
|
-
});
|
|
80
|
-
});
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
// ─── Width safety (regression for crash) ─────────────────────────────────────
|
|
84
|
-
|
|
85
|
-
describe("paste-chips width safety", () => {
|
|
86
|
-
it("restyling a chars marker can decrease visible width", () => {
|
|
87
|
-
const original = "[paste #1 2232 chars]";
|
|
88
|
-
const restyled = restyleMarkers(original, new Set());
|
|
89
|
-
expect(visibleWidth(restyled)).toBeLessThan(visibleWidth(original));
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
it("restyling a lines marker can decrease visible width", () => {
|
|
93
|
-
// "[paste #2 +42 lines]" (20 chars) → "[paste text +42]" (16 chars) = -4 width
|
|
94
|
-
const original = "[paste #2 +42 lines]";
|
|
95
|
-
const restyled = restyleMarkers(original, new Set());
|
|
96
|
-
expect(visibleWidth(restyled)).toBeLessThan(visibleWidth(original));
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
it("restyling to image label changes width", () => {
|
|
100
|
-
// "[paste #1 58 chars]" (19 chars) → "[paste image #1]" (16 chars)
|
|
101
|
-
const imageIds = new Set([1]);
|
|
102
|
-
const original = "[paste #1 58 chars]";
|
|
103
|
-
const restyled = restyleMarkers(original, imageIds);
|
|
104
|
-
expect(visibleWidth(restyled)).not.toBe(visibleWidth(original));
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
it("chars marker with large char count compacts metadata", () => {
|
|
108
|
-
const original = "[paste #1 9999 chars]";
|
|
109
|
-
const restyled = restyleMarkers(original, new Set());
|
|
110
|
-
expect(stripAnsi(restyled)).toBe(" text 10k chars");
|
|
111
|
-
expect(visibleWidth(restyled)).toBeLessThan(visibleWidth(original));
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
it("chars marker with multi-digit ID omits id from text chip", () => {
|
|
115
|
-
const original = "[paste #10 9999 chars]";
|
|
116
|
-
const restyled = restyleMarkers(original, new Set());
|
|
117
|
-
expect(stripAnsi(restyled)).toBe(" text 10k chars");
|
|
118
|
-
expect(visibleWidth(restyled)).toBeLessThan(visibleWidth(original));
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
describe("width behavior", () => {
|
|
122
|
-
it("icon chip stays within terminal width when marker line fits", () => {
|
|
123
|
-
const terminalWidth = 283;
|
|
124
|
-
const marker = "[paste #1 2232 chars]";
|
|
125
|
-
const padding = " ".repeat(terminalWidth - visibleWidth(marker));
|
|
126
|
-
const line = marker + padding;
|
|
127
|
-
|
|
128
|
-
expect(visibleWidth(line)).toBe(terminalWidth);
|
|
129
|
-
|
|
130
|
-
const restyled = restyleMarkers(line, new Set());
|
|
131
|
-
expect(visibleWidth(restyled)).toBeLessThanOrEqual(terminalWidth);
|
|
132
|
-
});
|
|
133
|
-
});
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
// ─── endsWithMarker (trailing-space gate) ──────────────────────────────
|
|
137
|
-
|
|
138
|
-
describe("paste-chips endsWithMarker", () => {
|
|
139
|
-
it("matches a chars marker at end of string", () => {
|
|
140
|
-
expect(endsWithMarker("[paste #1 2232 chars]")).toBe(true);
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
it("matches a lines marker at end of string", () => {
|
|
144
|
-
expect(endsWithMarker("hello [paste #2 +42 lines]")).toBe(true);
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
it("matches a bare marker at end of string", () => {
|
|
148
|
-
expect(endsWithMarker("[paste #3]")).toBe(true);
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
it("does not match when marker is not at the end", () => {
|
|
152
|
-
expect(endsWithMarker("[paste #1 58 chars] trailing")).toBe(false);
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
it("does not match plain text", () => {
|
|
156
|
-
expect(endsWithMarker("just some text")).toBe(false);
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
it("does not match a lone trailing space", () => {
|
|
160
|
-
expect(endsWithMarker(" ")).toBe(false);
|
|
161
|
-
});
|
|
162
|
-
});
|
package/src/paste-chips.ts
DELETED
|
@@ -1,217 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* paste-chips — collapse pasted image paths into Pi paste markers, and
|
|
3
|
-
* re-render all paste markers with type-aware labels.
|
|
4
|
-
*
|
|
5
|
-
* /tmp/pi-clipboard-abc.png → buffer: [paste #1 58 chars]
|
|
6
|
-
* display: [paste image #1]
|
|
7
|
-
*
|
|
8
|
-
* long pasted text → buffer: [paste #2 +42 lines]
|
|
9
|
-
* display: [paste text +42]
|
|
10
|
-
*
|
|
11
|
-
* Atomic deletion + cursor handling come from Pi's marker grammar.
|
|
12
|
-
* On submit, getExpandedText() restores the real path/text for the model.
|
|
13
|
-
* The display rewrite is purely visual (render layer); buffer is untouched.
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
import type {
|
|
17
|
-
ExtensionAPI,
|
|
18
|
-
KeybindingsManager,
|
|
19
|
-
} from "@earendil-works/pi-coding-agent";
|
|
20
|
-
import { CustomEditor } from "@earendil-works/pi-coding-agent";
|
|
21
|
-
import type { EditorTheme, TUI } from "@earendil-works/pi-tui";
|
|
22
|
-
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
23
|
-
import { BOLD, FG_BLUE, FG_DIM, FG_GREEN, RST } from "./ansi.js";
|
|
24
|
-
|
|
25
|
-
// Upstream stopped re-exporting `EditorFactory` from the package entry point,
|
|
26
|
-
// so we reconstruct its signature locally from the still-exported primitives.
|
|
27
|
-
// This matches ctx.ui.setEditorComponent's expected factory shape.
|
|
28
|
-
type EditorFactory = (
|
|
29
|
-
tui: TUI,
|
|
30
|
-
theme: EditorTheme,
|
|
31
|
-
keybindings: KeybindingsManager,
|
|
32
|
-
) => CustomEditor;
|
|
33
|
-
|
|
34
|
-
// ─── Constants ────────────────────────────────────────────────────────────────
|
|
35
|
-
|
|
36
|
-
const IMAGE_EXTS = new Set([
|
|
37
|
-
".png",
|
|
38
|
-
".jpg",
|
|
39
|
-
".jpeg",
|
|
40
|
-
".gif",
|
|
41
|
-
".webp",
|
|
42
|
-
".bmp",
|
|
43
|
-
".tif",
|
|
44
|
-
".tiff",
|
|
45
|
-
".heic",
|
|
46
|
-
".heif",
|
|
47
|
-
]);
|
|
48
|
-
|
|
49
|
-
// Group 1 = prefix char (or empty at start), Group 2 = path
|
|
50
|
-
const PATH_RE = /(^|[^\w/])((?:~|\/)[^\s,;'"(){}[\]]+)/g;
|
|
51
|
-
|
|
52
|
-
// Pi's marker grammar — must match exactly for atomic segmentation.
|
|
53
|
-
// e.g. `[paste #1 58 chars]` or `[paste #2 +42 lines]`
|
|
54
|
-
const MARKER_RE = /\[paste #(\d+)( (\+(\d+) lines|(\d+) chars))?\]/g;
|
|
55
|
-
|
|
56
|
-
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
57
|
-
|
|
58
|
-
function extOf(p: string): string {
|
|
59
|
-
const dot = p.lastIndexOf(".");
|
|
60
|
-
return dot >= 0 ? p.slice(dot).toLowerCase() : "";
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
function isImagePath(p: string): boolean {
|
|
64
|
-
return IMAGE_EXTS.has(extOf(p));
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
function makeMarker(id: number, charCount: number): string {
|
|
68
|
-
return `[paste #${id} ${charCount} chars]`;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
// ─── Editor internals (Pi's TS-private fields are runtime-public JS) ─────────
|
|
72
|
-
|
|
73
|
-
type EditorInternals = {
|
|
74
|
-
pastes: Map<number, string>;
|
|
75
|
-
pasteCounter: number;
|
|
76
|
-
};
|
|
77
|
-
|
|
78
|
-
// ─── Path → marker rewriter ──────────────────────────────────────────────────
|
|
79
|
-
|
|
80
|
-
/**
|
|
81
|
-
* Walk `text`, for each image path: allocate a new paste ID, register the
|
|
82
|
-
* real path in editor.pastes, remember the ID as an image, and emit a
|
|
83
|
-
* Pi-format marker so deletion is atomic.
|
|
84
|
-
*/
|
|
85
|
-
function replaceImagePaths(
|
|
86
|
-
text: string,
|
|
87
|
-
internals: EditorInternals,
|
|
88
|
-
imageIds: Set<number>,
|
|
89
|
-
): string {
|
|
90
|
-
return text.replace(PATH_RE, (_, prefix: string, rawPath: string) => {
|
|
91
|
-
if (!isImagePath(rawPath)) return prefix + rawPath;
|
|
92
|
-
internals.pasteCounter += 1;
|
|
93
|
-
const id = internals.pasteCounter;
|
|
94
|
-
internals.pastes.set(id, rawPath);
|
|
95
|
-
imageIds.add(id);
|
|
96
|
-
return prefix + makeMarker(id, rawPath.length);
|
|
97
|
-
});
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
// ─── Display rewriter ────────────────────────────────────────────────────────
|
|
101
|
-
|
|
102
|
-
/**
|
|
103
|
-
* Re-style every paste marker in a rendered line:
|
|
104
|
-
* image → ` image #N` with blue icon/label
|
|
105
|
-
* text → ` text Lines lines` or ` text Chars chars` with green icon/label
|
|
106
|
-
*
|
|
107
|
-
* Width-preserving is not required — Pi re-wraps each render call.
|
|
108
|
-
*/
|
|
109
|
-
export function restyleMarkers(line: string, imageIds: Set<number>): string {
|
|
110
|
-
return line.replace(
|
|
111
|
-
MARKER_RE,
|
|
112
|
-
(_full, idStr, _g2, _g3, linesStr, charsStr) => {
|
|
113
|
-
const id = Number.parseInt(idStr, 10);
|
|
114
|
-
if (imageIds.has(id)) {
|
|
115
|
-
return chip(FG_BLUE, "", "image", `#${id}`);
|
|
116
|
-
}
|
|
117
|
-
if (linesStr) {
|
|
118
|
-
return chip(FG_GREEN, "", "text", `${linesStr} lines`);
|
|
119
|
-
}
|
|
120
|
-
if (charsStr) {
|
|
121
|
-
return chip(FG_GREEN, "", "text", `${compactNumber(charsStr)} chars`);
|
|
122
|
-
}
|
|
123
|
-
return chip(FG_GREEN, "", "text", `#${id}`);
|
|
124
|
-
},
|
|
125
|
-
);
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
function chip(
|
|
129
|
-
color: string,
|
|
130
|
-
icon: string,
|
|
131
|
-
label: string,
|
|
132
|
-
meta: string,
|
|
133
|
-
): string {
|
|
134
|
-
return `${color}${BOLD}${icon} ${label}${RST}${FG_DIM} ${meta}${RST}`;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
/** True when `text` ends with a Pi paste marker (chip), e.g. `[paste #1 58 chars]`. */
|
|
138
|
-
export function endsWithMarker(text: string): boolean {
|
|
139
|
-
return /\[paste #\d+[^\]]*\]$/.test(text);
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
function compactNumber(raw: string): string {
|
|
143
|
-
const n = Number.parseInt(raw, 10);
|
|
144
|
-
if (!Number.isFinite(n)) return raw;
|
|
145
|
-
if (n < 1_000) return `${n}`;
|
|
146
|
-
if (n < 1_000_000) return `${(n / 1_000).toFixed(1).replace(/\.0$/, "")}k`;
|
|
147
|
-
return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, "")}m`;
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
// ─── Custom editor ────────────────────────────────────────────────────────────
|
|
151
|
-
|
|
152
|
-
// handlePaste is TS-private in CustomEditor but runtime-public JS. We patch it
|
|
153
|
-
// per-instance, capturing the base implementation, so large text pastes get the
|
|
154
|
-
// same trailing-space treatment as image chips.
|
|
155
|
-
type PasteHandler = { handlePaste(text: string): void };
|
|
156
|
-
|
|
157
|
-
class ChipEditor extends CustomEditor {
|
|
158
|
-
private readonly imageIds = new Set<number>();
|
|
159
|
-
|
|
160
|
-
constructor(...args: ConstructorParameters<typeof CustomEditor>) {
|
|
161
|
-
super(...args);
|
|
162
|
-
this.patchHandlePaste();
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
override insertTextAtCursor(text: string): void {
|
|
166
|
-
const internals = this as unknown as EditorInternals;
|
|
167
|
-
const replaced = replaceImagePaths(text, internals, this.imageIds);
|
|
168
|
-
// Append a trailing space when the insertion ends with a paste marker so
|
|
169
|
-
// the cursor lands after the chip rather than inside it.
|
|
170
|
-
super.insertTextAtCursor(
|
|
171
|
-
endsWithMarker(replaced) ? `${replaced} ` : replaced,
|
|
172
|
-
);
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
private patchHandlePaste(): void {
|
|
176
|
-
const self = this as unknown as PasteHandler;
|
|
177
|
-
const base = self.handlePaste.bind(self);
|
|
178
|
-
self.handlePaste = (pastedText: string) => {
|
|
179
|
-
const before = this.getText();
|
|
180
|
-
base(pastedText);
|
|
181
|
-
const after = this.getText();
|
|
182
|
-
// Only nudge a space when the paste collapsed into a marker chip;
|
|
183
|
-
// inline small pastes (no marker) are left untouched.
|
|
184
|
-
if (endsWithMarker(after) && after !== before) {
|
|
185
|
-
super.insertTextAtCursor(" ");
|
|
186
|
-
}
|
|
187
|
-
};
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
override render(width: number): string[] {
|
|
191
|
-
const raw = super.render(width);
|
|
192
|
-
return raw.map((line) => {
|
|
193
|
-
const restyled = restyleMarkers(line, this.imageIds);
|
|
194
|
-
// Restyling may widen lines (e.g. "#1" → "text"), so clamp to width.
|
|
195
|
-
if (visibleWidth(restyled) > width) {
|
|
196
|
-
return truncateToWidth(restyled, width, "");
|
|
197
|
-
}
|
|
198
|
-
return restyled;
|
|
199
|
-
});
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
// ─── Extension ────────────────────────────────────────────────────────────────
|
|
204
|
-
|
|
205
|
-
export default function (pi: ExtensionAPI) {
|
|
206
|
-
pi.on("session_start", (_event, ctx) => {
|
|
207
|
-
if (!ctx.hasUI) return;
|
|
208
|
-
const factory: EditorFactory = (tui, theme, keybindings) =>
|
|
209
|
-
new ChipEditor(tui, theme, keybindings);
|
|
210
|
-
ctx.ui.setEditorComponent(factory);
|
|
211
|
-
});
|
|
212
|
-
|
|
213
|
-
pi.on("session_shutdown", (_event, ctx) => {
|
|
214
|
-
if (!ctx.hasUI) return;
|
|
215
|
-
ctx.ui.setEditorComponent(undefined);
|
|
216
|
-
});
|
|
217
|
-
}
|
package/src/thinking.test.ts
DELETED
|
@@ -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
|
-
}
|