@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/README.md +9 -4
- package/package.json +2 -3
- package/src/ansi.ts +0 -10
- package/src/commands/fff.ts +60 -0
- package/src/diff-render.ts +92 -16
- package/src/image.ts +0 -3
- package/src/index.ts +88 -1452
- package/src/thinking.test.ts +153 -169
- package/src/thinking.ts +115 -68
- package/src/tools/bash.ts +154 -0
- package/src/tools/context.ts +19 -0
- package/src/tools/edit.ts +291 -0
- package/src/tools/find.ts +158 -0
- package/src/tools/grep.ts +202 -0
- package/src/tools/ls.ts +111 -0
- package/src/tools/multi-grep.ts +328 -0
- package/src/tools/read.ts +177 -0
- package/src/tools/write.ts +231 -0
- package/src/tsconfig.json +1 -1
- package/src/types.ts +30 -1
- package/src/utils.ts +45 -2
package/src/thinking.ts
CHANGED
|
@@ -1,37 +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
|
-
* - During streaming (`message_update`),
|
|
12
|
-
* reasoning
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
-
*
|
|
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`,
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
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
|
+
* rebuild — so this step is still required for persistence.)
|
|
30
30
|
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
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
|
-
|
|
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
|
-
//
|
|
77
|
-
|
|
78
|
-
|
|
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);
|
|
81
88
|
}
|
|
82
89
|
|
|
83
|
-
function
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
//
|
|
99
|
-
|
|
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
|
-
//
|
|
102
|
-
|
|
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 {
|
|
150
|
+
export { splitThinking, stripPartialTailTag };
|
|
107
151
|
|
|
108
152
|
export default function thinkingExtension(pi: ExtensionAPI) {
|
|
109
|
-
// Live
|
|
110
|
-
//
|
|
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.
|
|
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 (!
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
146
|
-
if (block.type !== "text")
|
|
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")
|
|
149
|
-
if (!
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
|
158
|
-
|
|
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
|
+
}
|