@xynogen/pix-pretty 1.0.0 → 1.1.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 +46 -2
- package/src/thinking.ts +64 -4
- 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.1.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
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
* Tests for thinking tag rendering
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import { describe,
|
|
6
|
-
import { renderThinking } from "./thinking";
|
|
5
|
+
import { describe, expect, it } from "bun:test";
|
|
6
|
+
import { renderThinking, stripPartialTailTag } from "./thinking";
|
|
7
7
|
|
|
8
8
|
describe("thinking tag rendering", () => {
|
|
9
9
|
describe("closed thinking blocks", () => {
|
|
@@ -184,6 +184,50 @@ describe("thinking tag rendering", () => {
|
|
|
184
184
|
});
|
|
185
185
|
});
|
|
186
186
|
|
|
187
|
+
describe("streaming (partial tail tags + live rendering)", () => {
|
|
188
|
+
it("strips a half-streamed opening tag", () => {
|
|
189
|
+
expect(stripPartialTailTag("Hello <thin")).toBe("Hello ");
|
|
190
|
+
expect(stripPartialTailTag("Hello <")).toBe("Hello ");
|
|
191
|
+
expect(stripPartialTailTag("Hello <thinking")).toBe("Hello ");
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("strips a half-streamed closing tag", () => {
|
|
195
|
+
expect(stripPartialTailTag("reasoning </thinkin")).toBe("reasoning ");
|
|
196
|
+
expect(stripPartialTailTag("reasoning </")).toBe("reasoning ");
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("keeps non-reasoning partial tags", () => {
|
|
200
|
+
expect(stripPartialTailTag("a generic <div")).toBe("a generic <div");
|
|
201
|
+
expect(stripPartialTailTag("math: 1 < 2")).toBe("math: 1 < 2");
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("keeps complete tags (only the trailing fragment is stripped)", () => {
|
|
205
|
+
expect(stripPartialTailTag("<thinking>body")).toBe("<thinking>body");
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("renders an open block as blockquote before close tag arrives", () => {
|
|
209
|
+
// Simulates mid-stream state: open tag + partial body, no close tag.
|
|
210
|
+
const midStream = "<thinking>I am reasoning about";
|
|
211
|
+
const output = renderThinking(stripPartialTailTag(midStream));
|
|
212
|
+
expect(output).toBe("> I am reasoning about\n\n");
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("renders progressively without flashing partial close tag", () => {
|
|
216
|
+
const step1 = renderThinking(stripPartialTailTag("<think>step one"));
|
|
217
|
+
const step2 = renderThinking(
|
|
218
|
+
stripPartialTailTag("<think>step one and two</thi"),
|
|
219
|
+
);
|
|
220
|
+
const step3 = renderThinking(
|
|
221
|
+
stripPartialTailTag("<think>step one and two</think>\n\nAnswer"),
|
|
222
|
+
);
|
|
223
|
+
expect(step1).toBe("> step one\n\n");
|
|
224
|
+
expect(step2).toBe("> step one and two\n\n");
|
|
225
|
+
expect(step3).toContain("> step one and two");
|
|
226
|
+
expect(step3).toContain("Answer");
|
|
227
|
+
expect(step3).not.toContain("<think>");
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
187
231
|
describe("edge cases", () => {
|
|
188
232
|
it("handles empty string", () => {
|
|
189
233
|
const input = "";
|
package/src/thinking.ts
CHANGED
|
@@ -8,10 +8,25 @@
|
|
|
8
8
|
* interfere with the actual response.
|
|
9
9
|
*
|
|
10
10
|
* Approach:
|
|
11
|
-
* -
|
|
11
|
+
* - During streaming (`message_update`), re-render the event's message so
|
|
12
|
+
* reasoning blocks appear as styled blockquotes the moment the open tag
|
|
13
|
+
* streams in — no waiting for the close tag. The dangling-open-block
|
|
14
|
+
* handling in renderThinking() covers the not-yet-closed case, and a
|
|
15
|
+
* trailing half-streamed tag (e.g. "<thin") is stripped so it never
|
|
16
|
+
* flashes as literal text.
|
|
17
|
+
*
|
|
18
|
+
* Safety: `event.message` is a per-event shallow copy, but its content
|
|
19
|
+
* blocks are the provider's LIVE accumulating objects (providers do
|
|
20
|
+
* `block.text += delta`). We therefore never mutate text blocks in
|
|
21
|
+
* place — we replace `message.content` with fresh block objects. The
|
|
22
|
+
* TUI receives the same event object after extensions run, so the
|
|
23
|
+
* restyled content is what gets rendered live.
|
|
24
|
+
*
|
|
12
25
|
* - On `message_end`, extract and reformat every reasoning block with
|
|
13
26
|
* visual markers, then return the styled message via the supported
|
|
14
|
-
* replacement channel.
|
|
27
|
+
* replacement channel. (The finalized message comes from
|
|
28
|
+
* `response.result()` — a fresh object that never saw the streaming
|
|
29
|
+
* restyling — so this step is still required for persistence.)
|
|
15
30
|
*
|
|
16
31
|
* `content[].text` is MARKDOWN rendered by pi's TUI Markdown component.
|
|
17
32
|
* The TUI does NOT parse HTML — <details>/<summary> would render as literal
|
|
@@ -44,6 +59,20 @@ interface Msg {
|
|
|
44
59
|
content?: Block[];
|
|
45
60
|
}
|
|
46
61
|
|
|
62
|
+
// Trailing half-streamed tag, e.g. "<", "</", "<thin", "</thinkin".
|
|
63
|
+
// Only used during streaming so an incomplete tag never flashes as text.
|
|
64
|
+
const PARTIAL_TAIL_RE = /<\/?([a-zA-Z]*)$/;
|
|
65
|
+
|
|
66
|
+
function stripPartialTailTag(text: string): string {
|
|
67
|
+
const match = text.match(PARTIAL_TAIL_RE);
|
|
68
|
+
if (!match) return text;
|
|
69
|
+
const fragment = match[1].toLowerCase();
|
|
70
|
+
if (TAG_NAMES.some((tag) => tag.startsWith(fragment))) {
|
|
71
|
+
return text.slice(0, match.index);
|
|
72
|
+
}
|
|
73
|
+
return text;
|
|
74
|
+
}
|
|
75
|
+
|
|
47
76
|
// Render a reasoning body as a markdown blockquote.
|
|
48
77
|
function asQuote(body: string, _label: string): string {
|
|
49
78
|
const lines = body.split("\n");
|
|
@@ -74,12 +103,43 @@ function renderThinking(text: string): string {
|
|
|
74
103
|
}
|
|
75
104
|
|
|
76
105
|
// Export for testing
|
|
77
|
-
export { renderThinking };
|
|
106
|
+
export { renderThinking, stripPartialTailTag };
|
|
78
107
|
|
|
79
108
|
export default function thinkingExtension(pi: ExtensionAPI) {
|
|
109
|
+
// Live styling during streaming: restyle the event's message so reasoning
|
|
110
|
+
// renders as soon as the open tag appears, token by token.
|
|
111
|
+
pi.on("message_update", (event) => {
|
|
112
|
+
const ev = event as {
|
|
113
|
+
message?: Msg;
|
|
114
|
+
assistantMessageEvent?: { type?: string };
|
|
115
|
+
};
|
|
116
|
+
const msg = ev.message;
|
|
117
|
+
if (msg?.role !== "assistant" || !Array.isArray(msg.content)) return;
|
|
118
|
+
|
|
119
|
+
// Only text stream events can change text blocks; skip toolcall/thinking
|
|
120
|
+
// channel deltas to avoid pointless re-renders.
|
|
121
|
+
const streamType = ev.assistantMessageEvent?.type;
|
|
122
|
+
if (streamType && !streamType.startsWith("text_")) return;
|
|
123
|
+
|
|
124
|
+
msg.content = msg.content.map((block) => {
|
|
125
|
+
if (block.type !== "text") return block;
|
|
126
|
+
const tb = block as TextBlock;
|
|
127
|
+
if (typeof tb.text !== "string" || !tb.text.includes("<")) return block;
|
|
128
|
+
const stripped = stripPartialTailTag(tb.text);
|
|
129
|
+
const lower = stripped.toLowerCase();
|
|
130
|
+
const hasTag = TAG_NAMES.some((t) => lower.includes(`<${t}`));
|
|
131
|
+
// Nothing reasoning-related: leave unrelated "<" text alone entirely.
|
|
132
|
+
if (!hasTag && stripped === tb.text) return block;
|
|
133
|
+
const rendered = hasTag ? renderThinking(stripped) : stripped;
|
|
134
|
+
if (rendered === tb.text) return block;
|
|
135
|
+
// New object — never mutate the provider's accumulating block.
|
|
136
|
+
return { ...block, text: rendered };
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
80
140
|
pi.on("message_end", (event) => {
|
|
81
141
|
const msg = (event as { message?: Msg }).message;
|
|
82
|
-
if (
|
|
142
|
+
if (msg?.role !== "assistant" || !Array.isArray(msg.content)) return;
|
|
83
143
|
|
|
84
144
|
let changed = false;
|
|
85
145
|
for (const block of msg.content) {
|
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).
|