@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 CHANGED
@@ -1,54 +1,55 @@
1
1
  {
2
- "name": "@xynogen/pix-pretty",
3
- "version": "1.0.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
- },
45
- "dependencies": {
46
- "cli-highlight": "^2.1.11",
47
- "@ff-labs/fff-node": "^0.5.2",
48
- "diff": "^7.0.0"
49
- },
50
- "peerDependencies": {
51
- "@earendil-works/pi-coding-agent": "*",
52
- "@earendil-works/pi-tui": "*"
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
@@ -2,7 +2,7 @@
2
2
  * Basic smoke tests for pix-pretty extensions
3
3
  */
4
4
 
5
- import { describe, it, expect } from "bun:test";
5
+ import { describe, expect, it } from "bun:test";
6
6
 
7
7
  describe("pix-pretty", () => {
8
8
  it("exports are valid TypeScript modules", () => {
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 ((fffState.module || createGrepTool)) {
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,
@@ -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, it, expect } from "bun:test";
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
 
@@ -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 { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
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));
@@ -2,8 +2,8 @@
2
2
  * Tests for thinking tag rendering
3
3
  */
4
4
 
5
- import { describe, it, expect } from "bun:test";
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
- * - Do nothing during streaming (no live mutation, no polling, no races).
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 (!msg || msg.role !== "assistant" || !Array.isArray(msg.content)) return;
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
- export type EditParams = EditToolInput;
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
- export type WriteParams = WriteToolInput;
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).