@xynogen/pix-pretty 1.1.0 → 1.3.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/src/thinking.ts CHANGED
@@ -1,37 +1,38 @@
1
1
  /**
2
- * Render leaked reasoning tags as styled, visually distinct blocks.
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 render them
7
- * with clear visual styling so they're useful for debugging but don't
8
- * interfere with the actual response.
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
- * - 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
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
16
17
  * flashes as literal text.
17
18
  *
18
19
  * Safety: `event.message` is a per-event shallow copy, but its content
19
20
  * blocks are the provider's LIVE accumulating objects (providers do
20
21
  * `block.text += delta`). We therefore never mutate text blocks in
21
22
  * 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.
23
+ * TUI receives the same event object after extensions run, so the rebuilt
24
+ * content is what gets rendered live.
24
25
  *
25
- * - On `message_end`, extract and reformat every reasoning block with
26
- * visual markers, then return the styled message via the supported
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.)
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
+ * rebuildso this step is still required for persistence.)
30
30
  *
31
- * `content[].text` is MARKDOWN rendered by pi's TUI Markdown component.
32
- * The TUI does NOT parse HTML <details>/<summary> would render as literal
33
- * junk text. We use a Markdown BLOCKQUOTE instead, which the TUI renders
34
- * natively via the `mdQuote`/`mdQuoteBorder` theme tokens.
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.
35
36
  *
36
37
  * To add a new tag variant, append to TAG_NAMES below.
37
38
  */
@@ -53,7 +54,11 @@ interface TextBlock {
53
54
  type: "text";
54
55
  text: string;
55
56
  }
56
- type Block = TextBlock | { type: string; [k: string]: unknown };
57
+ interface ThinkingBlock {
58
+ type: "thinking";
59
+ thinking: string;
60
+ }
61
+ type Block = TextBlock | ThinkingBlock | { type: string; [k: string]: unknown };
57
62
  interface Msg {
58
63
  role?: string;
59
64
  content?: Block[];
@@ -73,41 +78,80 @@ function stripPartialTailTag(text: string): string {
73
78
  return text;
74
79
  }
75
80
 
76
- // Render a reasoning body as a markdown blockquote.
77
- function asQuote(body: string, _label: string): string {
78
- const lines = body.split("\n");
79
- const quoted = lines.map((line) => `> ${line}`).join("\n");
80
- return `\n\n${quoted}\n\n`;
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);
81
88
  }
82
89
 
83
- function renderThinking(text: string): string {
84
- // Replace closed blocks with a clearly-marked blockquote
85
- text = text.replace(CLOSED_BLOCK_RE, (_match, _tag, content) => {
86
- const trimmed = content.trim();
87
- if (!trimmed) return "";
88
- return asQuote(trimmed, "⚙ Reasoning");
89
- });
90
+ function pushText(blocks: Block[], text: string): void {
91
+ const trimmed = text.trim();
92
+ if (trimmed) blocks.push({ type: "text", text: trimmed });
93
+ }
90
94
 
91
- // Replace dangling open blocks (stream cut off before close tag)
92
- text = text.replace(OPEN_TAIL_RE, (_match, _tag, content) => {
93
- const trimmed = content.trim();
94
- if (!trimmed) return "";
95
- return asQuote(trimmed, "⚙ Reasoning (incomplete)");
96
- });
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
+ }
97
128
 
98
- // Clean up any orphan tags
99
- text = text.replace(ORPHAN_TAG_RE, "");
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
+ }
100
143
 
101
- // Clean up excessive newlines
102
- return text.replace(/\n{4,}/g, "\n\n\n").replace(/^\s+/, "");
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: "" }];
103
147
  }
104
148
 
105
149
  // Export for testing
106
- export { renderThinking, stripPartialTailTag };
150
+ export { splitThinking, stripPartialTailTag };
107
151
 
108
152
  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.
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.
111
155
  pi.on("message_update", (event) => {
112
156
  const ev = event as {
113
157
  message?: Msg;
@@ -121,19 +165,16 @@ export default function thinkingExtension(pi: ExtensionAPI) {
121
165
  const streamType = ev.assistantMessageEvent?.type;
122
166
  if (streamType && !streamType.startsWith("text_")) return;
123
167
 
124
- msg.content = msg.content.map((block) => {
125
- if (block.type !== "text") return block;
168
+ msg.content = msg.content.flatMap((block): Block[] => {
169
+ if (block.type !== "text") return [block];
126
170
  const tb = block as TextBlock;
127
- if (typeof tb.text !== "string" || !tb.text.includes("<")) return block;
171
+ if (typeof tb.text !== "string" || !tb.text.includes("<")) return [block];
172
+ // Strip a half-streamed tag so it never flashes as literal text.
128
173
  const stripped = stripPartialTailTag(tb.text);
129
- const lower = stripped.toLowerCase();
130
- const hasTag = TAG_NAMES.some((t) => lower.includes(`<${t}`));
131
174
  // 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 };
175
+ if (!hasReasoningTag(stripped) && stripped === tb.text) return [block];
176
+ // New objects never mutate the provider's accumulating block.
177
+ return splitThinking(stripped);
137
178
  });
138
179
  });
139
180
 
@@ -142,19 +183,25 @@ export default function thinkingExtension(pi: ExtensionAPI) {
142
183
  if (msg?.role !== "assistant" || !Array.isArray(msg.content)) return;
143
184
 
144
185
  let changed = false;
145
- for (const block of msg.content) {
146
- if (block.type !== "text") continue;
186
+ const content = msg.content.flatMap((block): Block[] => {
187
+ if (block.type !== "text") return [block];
147
188
  const tb = block as TextBlock;
148
- if (typeof tb.text !== "string") continue;
149
- if (!TAG_NAMES.some((t) => tb.text.includes(`<${t}`))) continue;
150
- const rendered = renderThinking(tb.text);
151
- if (rendered !== tb.text) {
152
- tb.text = rendered;
153
- changed = true;
154
- }
155
- }
189
+ if (typeof tb.text !== "string") return [block];
190
+ if (!hasReasoningTag(tb.text)) return [block];
191
+ changed = true;
192
+ return splitThinking(tb.text);
193
+ });
156
194
 
157
- // Return the replacement so the styled message is what gets persisted.
158
- if (changed) return { message: msg as unknown as never };
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
+ }
159
206
  });
160
207
  }
@@ -0,0 +1,154 @@
1
+ import type {
2
+ AgentToolUpdateCallback,
3
+ BashToolInput,
4
+ ExtensionContext,
5
+ } from "@earendil-works/pi-coding-agent";
6
+
7
+ import { FG_DIM, RST } from "../ansi.js";
8
+ import { MAX_PREVIEW_LINES } from "../config.js";
9
+ import { renderBashOutput } from "../renderers.js";
10
+ import type {
11
+ BashParams,
12
+ PiPrettyApi,
13
+ RenderContextLike,
14
+ ThemeLike,
15
+ ToolFactory,
16
+ ToolResultLike,
17
+ } from "../types.js";
18
+ import {
19
+ fillToolBackground,
20
+ getTextContent,
21
+ isTextContent,
22
+ renderToolError,
23
+ rule,
24
+ setResultDetails,
25
+ termW,
26
+ } from "../utils.js";
27
+ import type { ToolContext } from "./context.js";
28
+
29
+ export function registerBashTool(
30
+ pi: PiPrettyApi,
31
+ createBashTool: ToolFactory<BashToolInput>,
32
+ ctx: ToolContext,
33
+ ): void {
34
+ const { cwd, TextComponent } = ctx;
35
+ const origBash = createBashTool(cwd);
36
+
37
+ pi.registerTool({
38
+ ...origBash,
39
+ name: "bash",
40
+
41
+ async execute(
42
+ tid: string,
43
+ params: BashParams,
44
+ sig: AbortSignal | undefined,
45
+ upd: AgentToolUpdateCallback<unknown> | undefined,
46
+ toolCtx: ExtensionContext,
47
+ ) {
48
+ const result = (await origBash.execute(
49
+ tid,
50
+ params,
51
+ sig,
52
+ upd,
53
+ toolCtx,
54
+ )) as ToolResultLike;
55
+ const textContent = getTextContent(result);
56
+
57
+ let exitCode: number | null = 0;
58
+ if (textContent) {
59
+ const exitMatch = textContent.match(
60
+ /(?:exit code|exited with|exit status)[:\s]*(\d+)/i,
61
+ );
62
+ if (exitMatch) exitCode = Number(exitMatch[1]);
63
+ if (
64
+ textContent.includes("command not found") ||
65
+ textContent.includes("No such file")
66
+ ) {
67
+ exitCode = 1;
68
+ }
69
+ }
70
+
71
+ setResultDetails(result, {
72
+ _type: "bashResult",
73
+ text: textContent ?? "",
74
+ exitCode,
75
+ command: params.command ?? "",
76
+ });
77
+
78
+ return result;
79
+ },
80
+
81
+ renderCall(
82
+ args: BashParams,
83
+ theme: ThemeLike,
84
+ renderCtx: RenderContextLike,
85
+ ) {
86
+ const cmd = args.command ?? "";
87
+ const text = renderCtx.lastComponent ?? new TextComponent("", 0, 0);
88
+ const timeout = args.timeout
89
+ ? ` ${theme.fg("muted", `(${args.timeout}s timeout)`)}`
90
+ : "";
91
+ const displayCmd =
92
+ renderCtx.expanded || cmd.length <= 80 ? cmd : `${cmd.slice(0, 77)}…`;
93
+ text.setText(
94
+ fillToolBackground(
95
+ `${theme.fg("toolTitle", theme.bold("bash"))} ${theme.fg("accent", displayCmd)}${timeout}`,
96
+ ),
97
+ );
98
+ return text;
99
+ },
100
+
101
+ renderResult(
102
+ result: ToolResultLike,
103
+ _opt: unknown,
104
+ theme: ThemeLike,
105
+ renderCtx: RenderContextLike,
106
+ ) {
107
+ const text = renderCtx.lastComponent ?? new TextComponent("", 0, 0);
108
+
109
+ if (renderCtx.isError) {
110
+ text.setText(renderToolError(getTextContent(result) || "Error", theme));
111
+ return text;
112
+ }
113
+
114
+ const d = result.details as Record<string, unknown> | undefined;
115
+ if (d?._type === "bashResult") {
116
+ const { summary } = renderBashOutput(
117
+ d.text as string,
118
+ d.exitCode as number | null,
119
+ );
120
+ const lines = (d.text as string).split("\n");
121
+ const lineCount = lines.length;
122
+ const lineInfo =
123
+ lineCount > 1 ? ` ${FG_DIM}(${lineCount} lines)${RST}` : "";
124
+ const header = ` ${summary}${lineInfo}`;
125
+
126
+ if ((d.text as string).trim()) {
127
+ const maxShow = renderCtx.expanded ? lineCount : MAX_PREVIEW_LINES;
128
+ const show = lines.slice(0, maxShow);
129
+ const tw = termW();
130
+ const out: string[] = [header, rule(tw)];
131
+ for (const line of show) out.push(` ${line}`);
132
+ out.push(rule(tw));
133
+ if (lineCount > maxShow) {
134
+ out.push(`${FG_DIM} … ${lineCount - maxShow} more lines${RST}`);
135
+ }
136
+ text.setText(fillToolBackground(out.join("\n")));
137
+ } else {
138
+ text.setText(fillToolBackground(header));
139
+ }
140
+ return text;
141
+ }
142
+
143
+ const fallback = result.content?.[0];
144
+ const fallbackText =
145
+ fallback && isTextContent(fallback) ? fallback.text : "done";
146
+ text.setText(
147
+ fillToolBackground(
148
+ ` ${theme.fg("dim", String(fallbackText).slice(0, 120))}`,
149
+ ),
150
+ );
151
+ return text;
152
+ },
153
+ });
154
+ }
@@ -0,0 +1,19 @@
1
+ import type { CursorStore, FffState } from "../fff.js";
2
+ import type { MultiGrepRipgrepFallback, TextComponentCtor } from "../types.js";
3
+
4
+ // ── Shared context passed to each tool registrar ───────────────────────
5
+
6
+ export interface ToolContext {
7
+ /** Current working directory */
8
+ cwd: string;
9
+ /** Shorten a path for display */
10
+ sp: (p: string) => string;
11
+ /** Text component constructor */
12
+ TextComponent: TextComponentCtor;
13
+ /** FFF state (shared across tools) */
14
+ fffState: FffState;
15
+ /** FFF cursor store */
16
+ cursorStore: CursorStore;
17
+ /** Ripgrep fallback for multi_grep */
18
+ multiGrepRipgrepFallback: MultiGrepRipgrepFallback;
19
+ }