@clapecho233/pi-smart-fold 0.1.0 → 0.2.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 +104 -27
- package/index.ts +1185 -61
- package/lib/config.ts +55 -7
- package/lib/fold.ts +291 -9
- package/lib/thinking.ts +194 -0
- package/package.json +11 -2
- package/scripts/link-pi.mjs +75 -0
- package/scripts/load-check.mjs +44 -0
- package/test/click-sim.mjs +414 -0
- package/test/fold.test.mjs +483 -6
- package/tsconfig.json +14 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Link the locally installed pi runtime into this project's node_modules so
|
|
4
|
+
* the extension typechecks and imports against the exact pi version that is
|
|
5
|
+
* installed on this machine (the same runtime that loads the extension).
|
|
6
|
+
*
|
|
7
|
+
* Creates:
|
|
8
|
+
* node_modules/@earendil-works/pi-coding-agent -> <pi>/libexec/lib/node_modules/@earendil-works/pi-coding-agent
|
|
9
|
+
* node_modules/@earendil-works/pi-tui -> <pi>/.../pi-coding-agent/node_modules/@earendil-works/pi-tui
|
|
10
|
+
*
|
|
11
|
+
* Re-run after every pi upgrade (also wired up as the npm `postinstall` and
|
|
12
|
+
* `link:pi` scripts; pass --required to fail hard when pi is not found).
|
|
13
|
+
*/
|
|
14
|
+
import { execFileSync } from "node:child_process";
|
|
15
|
+
import { mkdirSync, readlinkSync, rmSync, symlinkSync } from "node:fs";
|
|
16
|
+
import { dirname, resolve } from "node:path";
|
|
17
|
+
import { fileURLToPath } from "node:url";
|
|
18
|
+
|
|
19
|
+
const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
20
|
+
|
|
21
|
+
/** Resolve the pi installation root from the `pi` launcher on PATH. */
|
|
22
|
+
function piRoot() {
|
|
23
|
+
for (const finder of [
|
|
24
|
+
() => process.env.PI_BIN,
|
|
25
|
+
() => execFileSync("which", ["pi"], { encoding: "utf8" }).trim(),
|
|
26
|
+
]) {
|
|
27
|
+
try {
|
|
28
|
+
const bin = finder();
|
|
29
|
+
if (!bin) continue;
|
|
30
|
+
// /opt/homebrew/bin/pi -> ../Cellar/pi-coding-agent/<ver>/bin/pi
|
|
31
|
+
const real = execFileSync("realpath", [bin], { encoding: "utf8" }).trim();
|
|
32
|
+
return dirname(dirname(real)); // .../pi-coding-agent/<ver>
|
|
33
|
+
} catch {
|
|
34
|
+
// try next strategy
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const root = piRoot();
|
|
41
|
+
if (!root) {
|
|
42
|
+
const message = "link-pi: could not locate the pi installation (is `pi` on PATH?)";
|
|
43
|
+
if (process.argv.includes("--required")) {
|
|
44
|
+
console.error(message);
|
|
45
|
+
process.exit(1);
|
|
46
|
+
}
|
|
47
|
+
console.warn(`${message} — skipping (dev typecheck will not work until linked)`);
|
|
48
|
+
process.exit(0);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const agentPkg = resolve(root, "libexec/lib/node_modules/@earendil-works/pi-coding-agent");
|
|
52
|
+
const tuiPkg = resolve(agentPkg, "node_modules/@earendil-works/pi-tui");
|
|
53
|
+
|
|
54
|
+
const links = [
|
|
55
|
+
["@earendil-works/pi-coding-agent", agentPkg],
|
|
56
|
+
["@earendil-works/pi-tui", tuiPkg],
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
for (const [name, target] of links) {
|
|
60
|
+
const linkPath = resolve(projectRoot, "node_modules", name);
|
|
61
|
+
try {
|
|
62
|
+
const existing = readlinkSync(linkPath);
|
|
63
|
+
if (existing === target) {
|
|
64
|
+
console.log(`link-pi: ${name} already -> ${target}`);
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
rmSync(linkPath, { force: true, recursive: true });
|
|
68
|
+
} catch {
|
|
69
|
+
// no existing link (or a real directory — leave real dirs alone unless
|
|
70
|
+
// they are dangling symlinks, handled by rmSync above)
|
|
71
|
+
}
|
|
72
|
+
mkdirSync(dirname(linkPath), { recursive: true });
|
|
73
|
+
symlinkSync(target, linkPath);
|
|
74
|
+
console.log(`link-pi: linked ${name} -> ${target}`);
|
|
75
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Load-test the extension against the installed pi runtime (jiti), the same
|
|
3
|
+
* loader pi uses for filesystem extensions. Exercises the default export
|
|
4
|
+
* with a stub ExtensionAPI and reports what got registered.
|
|
5
|
+
*/
|
|
6
|
+
import { createRequire } from "node:module";
|
|
7
|
+
import { resolve } from "node:path";
|
|
8
|
+
|
|
9
|
+
const root = resolve(import.meta.dirname, "..");
|
|
10
|
+
// Resolve jiti from the linked pi package (extensions run inside pi's module
|
|
11
|
+
// root, so this mirrors the real resolution environment).
|
|
12
|
+
const require2 = createRequire(
|
|
13
|
+
resolve(root, "node_modules/@earendil-works/pi-coding-agent/package.json"),
|
|
14
|
+
);
|
|
15
|
+
const jitiPath = require2.resolve("jiti");
|
|
16
|
+
const { createJiti } = await import(
|
|
17
|
+
jitiPath.startsWith("/") ? `file://${jitiPath}` : jitiPath,
|
|
18
|
+
);
|
|
19
|
+
const jiti = createJiti(import.meta.url);
|
|
20
|
+
const mod = await jiti.import(resolve(root, "index.ts"), { default: true });
|
|
21
|
+
|
|
22
|
+
console.log("default export:", typeof mod);
|
|
23
|
+
if (typeof mod !== "function") process.exit(1);
|
|
24
|
+
|
|
25
|
+
const registered = { transformers: 0, tools: [], commands: [], handlers: [] };
|
|
26
|
+
const stub = {
|
|
27
|
+
registerMarkdownTransformer: () => registered.transformers++,
|
|
28
|
+
registerTool: (t) => registered.tools.push(t?.name),
|
|
29
|
+
registerCommand: (n) => registered.commands.push(n),
|
|
30
|
+
on: (e) => registered.handlers.push(e),
|
|
31
|
+
appendEntry: () => {},
|
|
32
|
+
};
|
|
33
|
+
mod(stub);
|
|
34
|
+
console.log(
|
|
35
|
+
"transformers:", registered.transformers,
|
|
36
|
+
"| tools:", registered.tools.join(","),
|
|
37
|
+
"| commands:", registered.commands.join(","),
|
|
38
|
+
"| handlers:", registered.handlers.join(","),
|
|
39
|
+
);
|
|
40
|
+
if (registered.transformers !== 1 || registered.tools.length !== 7 || registered.commands[0] !== "fold") {
|
|
41
|
+
console.error("registration shape unexpected");
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
44
|
+
console.log("OK");
|
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Click-cycle simulation against the REAL pi AssistantMessageComponent.
|
|
3
|
+
*
|
|
4
|
+
* Drives the patched updateContent + pi's exact MouseRegion click semantics
|
|
5
|
+
(copied from pi's assistant-message.js) and asserts the user-facing toggle:
|
|
6
|
+
*
|
|
7
|
+
* streaming: tail →(click)→ full →(click)→ tail →(click)→ full …
|
|
8
|
+
* full view keeps the ticking `Thinking… (Ns)` line at the
|
|
9
|
+
* BOTTOM of the block; the hidden-label middle state never
|
|
10
|
+
* appears. Same one-click toggle for a run that already
|
|
11
|
+
* finished while the message keeps streaming (folded duration
|
|
12
|
+
* line ↔ full text with footer).
|
|
13
|
+
* finalized: seeded hidden `Thought for …` label →(click)→ full+footer
|
|
14
|
+
* →(click)→ label …
|
|
15
|
+
*
|
|
16
|
+
* Requires ./node_modules/@earendil-works symlinks to the installed pi
|
|
17
|
+
* packages (see README dev section). Not part of `npm test`.
|
|
18
|
+
*/
|
|
19
|
+
import assert from "node:assert/strict";
|
|
20
|
+
import { AssistantMessageComponent, initTheme } from "@earendil-works/pi-coding-agent";
|
|
21
|
+
import smartFold from "../index.ts";
|
|
22
|
+
|
|
23
|
+
initTheme("default", false); // headless: the component's render path needs a theme
|
|
24
|
+
|
|
25
|
+
// ---- mock pi ExtensionAPI --------------------------------------------------
|
|
26
|
+
let transformer;
|
|
27
|
+
const handlers = {};
|
|
28
|
+
const mockPi = {
|
|
29
|
+
registerMarkdownTransformer: (fn) => {
|
|
30
|
+
transformer = fn;
|
|
31
|
+
},
|
|
32
|
+
on: (name, fn) => {
|
|
33
|
+
(handlers[name] ??= []).push(fn);
|
|
34
|
+
},
|
|
35
|
+
registerTool: () => {},
|
|
36
|
+
registerCommand: () => {},
|
|
37
|
+
appendEntry: () => {},
|
|
38
|
+
};
|
|
39
|
+
smartFold(mockPi);
|
|
40
|
+
assert.equal(typeof transformer, "function", "transformer registered");
|
|
41
|
+
|
|
42
|
+
const fire = (name, event) => {
|
|
43
|
+
let done = Promise.resolve();
|
|
44
|
+
for (const fn of handlers[name] ?? []) done = done.then(() => fn(event));
|
|
45
|
+
return done;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** Invoke the transformer the way pi's Markdown component would. */
|
|
49
|
+
const render = (markdown, isStreaming) =>
|
|
50
|
+
transformer(markdown, {
|
|
51
|
+
messageType: "assistant-thinking",
|
|
52
|
+
isStreaming,
|
|
53
|
+
availableWidth: 100,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
/** pi's MouseRegion click handler, verbatim semantics. */
|
|
57
|
+
const click = (comp, runIndex) => {
|
|
58
|
+
const hidden = comp.thinkingVisibilityOverrides.get(runIndex) ?? comp.hideThinkingBlock;
|
|
59
|
+
comp.thinkingVisibilityOverrides.set(runIndex, !hidden);
|
|
60
|
+
comp.updateContent(comp.lastMessage);
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const msg = (thinking) => ({
|
|
64
|
+
content: [{ type: "thinking", thinking }],
|
|
65
|
+
stopReason: undefined,
|
|
66
|
+
});
|
|
67
|
+
const update = (partial, delta) =>
|
|
68
|
+
fire("message_update", {
|
|
69
|
+
message: { role: "assistant" },
|
|
70
|
+
assistantMessageEvent: { type: "thinking_delta", contentIndex: 0, delta, partial },
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// ---- streaming: tail ↔ full with a single click -----------------------------
|
|
74
|
+
await fire("message_update", {
|
|
75
|
+
message: { role: "assistant" },
|
|
76
|
+
assistantMessageEvent: { type: "thinking_start", contentIndex: 0, partial: msg("") },
|
|
77
|
+
});
|
|
78
|
+
await update(msg("alpha thoughts"), "alpha thoughts");
|
|
79
|
+
|
|
80
|
+
const comp = new AssistantMessageComponent();
|
|
81
|
+
comp.updateContent(msg("alpha thoughts"), true);
|
|
82
|
+
assert.equal(comp.thinkingVisibilityOverrides.size, 0, "no overrides before any click");
|
|
83
|
+
|
|
84
|
+
// default: scrolling tail — label line on TOP, only the last text line
|
|
85
|
+
assert.match(
|
|
86
|
+
render("alpha thoughts", true),
|
|
87
|
+
/^\*\*Thinking… \(\d+[smh][0-9]*\)\*\*\n\nalpha thoughts$/,
|
|
88
|
+
"default streaming view is the live tail",
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
// click 1 → full text so far, timing line pinned at the BOTTOM
|
|
92
|
+
click(comp, 0);
|
|
93
|
+
assert.equal(comp.thinkingVisibilityOverrides.get(0), false, "click redirected to visible");
|
|
94
|
+
assert.match(
|
|
95
|
+
render("alpha thoughts", true),
|
|
96
|
+
/^alpha thoughts\n\n\*\*Thinking… \(\d+[smh][0-9]*\)\*\*$/,
|
|
97
|
+
"first click expands to full view with the timing line last",
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
// text keeps growing while open → full view follows, timing stays at the bottom
|
|
101
|
+
await update(msg("alpha thoughts\nbeta continues"), "beta continues");
|
|
102
|
+
comp.updateContent(msg("alpha thoughts\nbeta continues"), true);
|
|
103
|
+
assert.match(
|
|
104
|
+
render("alpha thoughts\nbeta continues", true),
|
|
105
|
+
/^alpha thoughts\nbeta continues\n\n\*\*Thinking… \(\d+[smh][0-9]*\)\*\*$/,
|
|
106
|
+
"full view follows growth",
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
// click 2 → back to the scrolling tail (no hidden-label middle state)
|
|
110
|
+
click(comp, 0);
|
|
111
|
+
assert.equal(comp.thinkingVisibilityOverrides.get(0), false, "still visible after collapse");
|
|
112
|
+
assert.match(
|
|
113
|
+
render("alpha thoughts\nbeta continues", true),
|
|
114
|
+
/^\*\*Thinking… \(\d+[smh][0-9]*\)\*\*\n\nbeta continues$/,
|
|
115
|
+
"second click returns to the live tail",
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
// click 3 → full again — a clean two-state toggle
|
|
119
|
+
click(comp, 0);
|
|
120
|
+
assert.equal(comp.thinkingVisibilityOverrides.get(0), false);
|
|
121
|
+
assert.match(
|
|
122
|
+
render("alpha thoughts\nbeta continues", true),
|
|
123
|
+
/^alpha thoughts\nbeta continues\n\n\*\*Thinking… \(\d+[smh][0-9]*\)\*\*$/,
|
|
124
|
+
"third click expands again",
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
// ---- message end: open run keeps full text, now with the final footer -------
|
|
128
|
+
await fire("message_end", {
|
|
129
|
+
message: { role: "assistant", content: msg("alpha thoughts\nbeta continues").content, stopReason: "stop" },
|
|
130
|
+
});
|
|
131
|
+
comp.updateContent(msg("alpha thoughts\nbeta continues"), false); // finalized render
|
|
132
|
+
assert.match(
|
|
133
|
+
render("alpha thoughts\nbeta continues", false),
|
|
134
|
+
/^alpha thoughts\nbeta continues\n\n\*\*Thought for [\d.]+s\*\*$/,
|
|
135
|
+
"run left open at message end shows full text + Thought-for footer",
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
// ---- fresh message: seeded hidden label, single click expands ---------------
|
|
139
|
+
const comp2 = new AssistantMessageComponent();
|
|
140
|
+
comp2.updateContent(msg("alpha thoughts\nbeta continues"), false);
|
|
141
|
+
assert.equal(comp2.thinkingVisibilityOverrides.get(0), true, "finalized runs seeded hidden");
|
|
142
|
+
assert.match(comp2.hiddenThinkingLabel, /Thought for /, "hidden label carries the duration");
|
|
143
|
+
|
|
144
|
+
click(comp2, 0);
|
|
145
|
+
assert.equal(comp2.thinkingVisibilityOverrides.get(0), false, "one click expands");
|
|
146
|
+
assert.match(
|
|
147
|
+
render("alpha thoughts\nbeta continues", false),
|
|
148
|
+
/\n\n\*\*Thought for [\d.]+s\*\*$/,
|
|
149
|
+
"expanded finalized run shows the footer",
|
|
150
|
+
);
|
|
151
|
+
click(comp2, 0);
|
|
152
|
+
assert.equal(comp2.thinkingVisibilityOverrides.get(0), true, "one more click collapses");
|
|
153
|
+
|
|
154
|
+
console.log("✓ scenario A: tail ↔ full while streaming; seeded toggle after");
|
|
155
|
+
|
|
156
|
+
// ---- scenario B: finished run + live run in one streaming message ----------
|
|
157
|
+
// Clicks on the finished run must not disturb the live run's open state.
|
|
158
|
+
const fireB = fire; // same handlers
|
|
159
|
+
const mk = (a, b) => ({
|
|
160
|
+
content: [
|
|
161
|
+
{ type: "thinking", thinking: a },
|
|
162
|
+
{ type: "toolCall", id: "t1", name: "bash", arguments: {} },
|
|
163
|
+
{ type: "thinking", thinking: b },
|
|
164
|
+
],
|
|
165
|
+
stopReason: undefined,
|
|
166
|
+
});
|
|
167
|
+
/** Partial as it exists at run A's thinking_end: just the thinking block. */
|
|
168
|
+
const endPartial = (a) => ({ content: [{ type: "thinking", thinking: a }], stopReason: undefined });
|
|
169
|
+
/** Partial at run B's thinking_start: run A's block followed by the tool call. */
|
|
170
|
+
const toolPartial = (a) => ({
|
|
171
|
+
content: [
|
|
172
|
+
{ type: "thinking", thinking: a },
|
|
173
|
+
{ type: "toolCall", id: "t1", name: "bash", arguments: {} },
|
|
174
|
+
],
|
|
175
|
+
stopReason: undefined,
|
|
176
|
+
});
|
|
177
|
+
await fireB("message_update", {
|
|
178
|
+
message: { role: "assistant" },
|
|
179
|
+
assistantMessageEvent: { type: "thinking_start", contentIndex: 0, partial: mk("", "") },
|
|
180
|
+
});
|
|
181
|
+
await fireB("message_update", {
|
|
182
|
+
message: { role: "assistant" },
|
|
183
|
+
assistantMessageEvent: { type: "thinking_end", contentIndex: 0, partial: endPartial("first run") },
|
|
184
|
+
});
|
|
185
|
+
await fireB("message_update", {
|
|
186
|
+
message: { role: "assistant" },
|
|
187
|
+
assistantMessageEvent: { type: "toolcall_start", contentIndex: 1, partial: toolPartial("first run") },
|
|
188
|
+
});
|
|
189
|
+
await fireB("message_update", {
|
|
190
|
+
message: { role: "assistant" },
|
|
191
|
+
assistantMessageEvent: { type: "thinking_start", contentIndex: 2, partial: toolPartial("first run") },
|
|
192
|
+
});
|
|
193
|
+
await fireB("message_update", {
|
|
194
|
+
message: { role: "assistant" },
|
|
195
|
+
assistantMessageEvent: { type: "thinking_delta", contentIndex: 2, delta: "second run", partial: mk("first run", "second run") },
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
const compB = new AssistantMessageComponent();
|
|
199
|
+
compB.updateContent(mk("first run", "second run"), true);
|
|
200
|
+
// run 0 finished → folded duration line; run 1 live → tail
|
|
201
|
+
assert.match(render("first run", true), /^\*\*Thought for /, "finished run folds while message streams");
|
|
202
|
+
assert.match(render("second run", true), /^\*\*Thinking… /, "live run shows the tail");
|
|
203
|
+
|
|
204
|
+
// open the live run (run 1) to full view
|
|
205
|
+
compB.thinkingVisibilityOverrides.set(1, true); // pi click handler for run 1
|
|
206
|
+
compB.updateContent(compB.lastMessage);
|
|
207
|
+
assert.equal(compB.thinkingVisibilityOverrides.get(1), false, "live-run click redirected");
|
|
208
|
+
assert.match(render("second run", true), /^second run\n\n\*\*Thinking… /, "live run expanded");
|
|
209
|
+
|
|
210
|
+
// clicking the finished run while the message still streams: ONE click
|
|
211
|
+
// expands it directly — the global `Thought…` hidden-label middle state
|
|
212
|
+
// must never appear (this was the reported bug)
|
|
213
|
+
compB.thinkingVisibilityOverrides.set(0, true); // pi click handler for run 0
|
|
214
|
+
compB.updateContent(compB.lastMessage);
|
|
215
|
+
assert.equal(compB.thinkingVisibilityOverrides.get(0), false, "finished-run click redirected to visible");
|
|
216
|
+
assert.match(
|
|
217
|
+
render("first run", true),
|
|
218
|
+
/^first run\n\n\*\*Thought for /,
|
|
219
|
+
"one click expands the finished run with the footer",
|
|
220
|
+
);
|
|
221
|
+
assert.match(
|
|
222
|
+
render("second run", true),
|
|
223
|
+
/^second run\n\n\*\*Thinking… /,
|
|
224
|
+
"finished-run click does not clobber the live run's open state",
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
// clicking it again folds the finished run back to the duration line
|
|
228
|
+
compB.thinkingVisibilityOverrides.set(0, true);
|
|
229
|
+
compB.updateContent(compB.lastMessage);
|
|
230
|
+
assert.equal(compB.thinkingVisibilityOverrides.get(0), false, "still visible after collapse");
|
|
231
|
+
assert.match(
|
|
232
|
+
render("first run", true),
|
|
233
|
+
/^\*\*Thought for /,
|
|
234
|
+
"second click re-folds the finished run (no label middle state)",
|
|
235
|
+
);
|
|
236
|
+
assert.match(
|
|
237
|
+
render("second run", true),
|
|
238
|
+
/^second run\n\n\*\*Thinking… /,
|
|
239
|
+
"live run still expanded",
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
// third click expands again — a clean two-state toggle
|
|
243
|
+
compB.thinkingVisibilityOverrides.set(0, true);
|
|
244
|
+
compB.updateContent(compB.lastMessage);
|
|
245
|
+
assert.match(
|
|
246
|
+
render("first run", true),
|
|
247
|
+
/^first run\n\n\*\*Thought for /,
|
|
248
|
+
"third click expands again",
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
console.log("✓ scenario B: finished-run clicks leave the live run's toggle intact");
|
|
252
|
+
|
|
253
|
+
// ---- scenario C: the reported regression — single run finished, answer
|
|
254
|
+
// text still streaming. `Thought for …` → click → full text, click → folded
|
|
255
|
+
// again. Never the global `Thought…` label. --------------------------------
|
|
256
|
+
const fireC = fire; // same handlers
|
|
257
|
+
const msgC = (thinking, text) => ({
|
|
258
|
+
content: [
|
|
259
|
+
{ type: "thinking", thinking },
|
|
260
|
+
{ type: "text", text },
|
|
261
|
+
],
|
|
262
|
+
stopReason: undefined,
|
|
263
|
+
});
|
|
264
|
+
const thinkOnly = (t) => ({ content: [{ type: "thinking", thinking: t }], stopReason: undefined });
|
|
265
|
+
await fireC("message_update", {
|
|
266
|
+
message: { role: "assistant" },
|
|
267
|
+
assistantMessageEvent: { type: "thinking_start", contentIndex: 0, partial: thinkOnly("") },
|
|
268
|
+
});
|
|
269
|
+
await fireC("message_update", {
|
|
270
|
+
message: { role: "assistant" },
|
|
271
|
+
assistantMessageEvent: {
|
|
272
|
+
type: "thinking_delta",
|
|
273
|
+
contentIndex: 0,
|
|
274
|
+
delta: "plan the answer",
|
|
275
|
+
partial: thinkOnly("plan the answer"),
|
|
276
|
+
},
|
|
277
|
+
});
|
|
278
|
+
await fireC("message_update", {
|
|
279
|
+
message: { role: "assistant" },
|
|
280
|
+
assistantMessageEvent: { type: "thinking_end", contentIndex: 0, partial: thinkOnly("plan the answer") },
|
|
281
|
+
});
|
|
282
|
+
// the following text block closes the thinking run: it now has a finalized
|
|
283
|
+
// duration while the message itself is still streaming
|
|
284
|
+
await fireC("message_update", {
|
|
285
|
+
message: { role: "assistant" },
|
|
286
|
+
assistantMessageEvent: {
|
|
287
|
+
type: "text_start",
|
|
288
|
+
contentIndex: 1,
|
|
289
|
+
partial: msgC("plan the answer", ""),
|
|
290
|
+
},
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
const compC = new AssistantMessageComponent();
|
|
294
|
+
compC.updateContent(msgC("plan the answer", "answering…"), true);
|
|
295
|
+
assert.match(
|
|
296
|
+
render("plan the answer", true),
|
|
297
|
+
/^\*\*Thought for /,
|
|
298
|
+
"finished run folds while the answer still streams",
|
|
299
|
+
);
|
|
300
|
+
|
|
301
|
+
// click 1 → full text + footer (NOT the global `Thought…` label)
|
|
302
|
+
compC.thinkingVisibilityOverrides.set(0, true);
|
|
303
|
+
compC.updateContent(compC.lastMessage);
|
|
304
|
+
assert.equal(compC.thinkingVisibilityOverrides.get(0), false, "click redirected to visible");
|
|
305
|
+
assert.match(
|
|
306
|
+
render("plan the answer", true),
|
|
307
|
+
/^plan the answer\n\n\*\*Thought for /,
|
|
308
|
+
"one click expands to full text with the footer",
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
// click 2 → folded duration line again
|
|
312
|
+
compC.thinkingVisibilityOverrides.set(0, true);
|
|
313
|
+
compC.updateContent(compC.lastMessage);
|
|
314
|
+
assert.equal(compC.thinkingVisibilityOverrides.get(0), false, "still visible after collapse");
|
|
315
|
+
assert.match(
|
|
316
|
+
render("plan the answer", true),
|
|
317
|
+
/^\*\*Thought for /,
|
|
318
|
+
"second click re-folds to the duration line",
|
|
319
|
+
);
|
|
320
|
+
|
|
321
|
+
// message ends: the run left folded normalizes into the seeded hidden state
|
|
322
|
+
// (label with duration) so the next click expands directly
|
|
323
|
+
await fireC("message_end", {
|
|
324
|
+
message: { role: "assistant", ...msgC("plan the answer", "answering…"), stopReason: "stop" },
|
|
325
|
+
});
|
|
326
|
+
compC.updateContent(compC.lastMessage, false);
|
|
327
|
+
assert.equal(compC.thinkingVisibilityOverrides.get(0), true, "collapsed run normalized to seeded hidden");
|
|
328
|
+
assert.match(compC.hiddenThinkingLabel, /Thought for /, "label carries the duration");
|
|
329
|
+
compC.thinkingVisibilityOverrides.set(0, false);
|
|
330
|
+
compC.updateContent(compC.lastMessage);
|
|
331
|
+
assert.match(
|
|
332
|
+
render("plan the answer", false),
|
|
333
|
+
/^plan the answer\n\n\*\*Thought for /,
|
|
334
|
+
"click after finalize expands in one step",
|
|
335
|
+
);
|
|
336
|
+
|
|
337
|
+
console.log("✓ scenario C: finished run in a streaming message toggles with one click");
|
|
338
|
+
|
|
339
|
+
// ---- scenario D: code blocks inside thinking -------------------------------
|
|
340
|
+
// Collapsed: when the last non-empty line is a bare code fence, the fold must
|
|
341
|
+
// NOT fall back to the full markdown (that flash-open was the flicker).
|
|
342
|
+
// Expanded: the footer must render OUTSIDE an unclosed fence — bold text, not
|
|
343
|
+
// literal `**` asterisks (verified through pi's real Markdown renderer).
|
|
344
|
+
import { Markdown } from "@earendil-works/pi-tui";
|
|
345
|
+
import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
|
|
346
|
+
|
|
347
|
+
const fireD = fire; // same handlers
|
|
348
|
+
const md = (t) => ({ content: [{ type: "thinking", thinking: t }], stopReason: undefined });
|
|
349
|
+
const streamD = (delta, partial) =>
|
|
350
|
+
fireD("message_update", {
|
|
351
|
+
message: { role: "assistant" },
|
|
352
|
+
assistantMessageEvent: { type: "thinking_delta", contentIndex: 0, delta, partial: md(partial) },
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
// stream up to the opening fence: last non-empty line is the bare-ish fence
|
|
356
|
+
await fireD("message_update", {
|
|
357
|
+
message: { role: "assistant" },
|
|
358
|
+
assistantMessageEvent: { type: "thinking_start", contentIndex: 0, partial: md("") },
|
|
359
|
+
});
|
|
360
|
+
await streamD("consider the snippet\n```ts", "consider the snippet\n```ts");
|
|
361
|
+
const compD = new AssistantMessageComponent();
|
|
362
|
+
compD.updateContent(md("consider the snippet\n```ts"), true);
|
|
363
|
+
assert.match(
|
|
364
|
+
render("consider the snippet\n```ts", true),
|
|
365
|
+
/^\*\*Thinking… \(\d+s\)\*\*\n\nconsider the snippet$/,
|
|
366
|
+
"bare fence as last line does not unfold the tail view",
|
|
367
|
+
);
|
|
368
|
+
|
|
369
|
+
// code content streams in: it becomes the tail line
|
|
370
|
+
await streamD("\nconst x = 1;", "consider the snippet\n```ts\nconst x = 1;");
|
|
371
|
+
compD.updateContent(md("consider the snippet\n```ts\nconst x = 1;"), true);
|
|
372
|
+
assert.match(
|
|
373
|
+
render("consider the snippet\n```ts\nconst x = 1;", true),
|
|
374
|
+
/^\*\*Thinking… \(\d+s\)\*\*\n\nconst x = 1;$/,
|
|
375
|
+
"code content line shows as the tail",
|
|
376
|
+
);
|
|
377
|
+
|
|
378
|
+
// expand while the fence is still unclosed → fence closed, footer outside it
|
|
379
|
+
click(compD, 0);
|
|
380
|
+
const expandedD = render("consider the snippet\n```ts\nconst x = 1;", true);
|
|
381
|
+
assert.match(
|
|
382
|
+
expandedD,
|
|
383
|
+
/```ts\nconst x = 1;\n```\n\n\*\*Thinking… \(\d+s\)\*\*$/,
|
|
384
|
+
"unclosed fence gets closed and the footer lands below it",
|
|
385
|
+
);
|
|
386
|
+
|
|
387
|
+
// render through pi's REAL Markdown component: the footer line must be bold
|
|
388
|
+
// "Thinking… (Ns)" — no literal asterisks from being swallowed by the fence
|
|
389
|
+
const stripAnsi = (s) => s.replace(/\u001b\[[0-9;?]*[A-Za-z]/g, "");
|
|
390
|
+
const renderedD = new Markdown(expandedD, 1, 0, getMarkdownTheme()).render(80).map(stripAnsi);
|
|
391
|
+
const lastLineD = [...renderedD].reverse().find((l) => l.trim() !== "");
|
|
392
|
+
assert.match(lastLineD.trim(), /^Thinking… \(\d+s\)$/, "footer renders bold without literal asterisks");
|
|
393
|
+
assert.equal(
|
|
394
|
+
renderedD.some((l) => l.includes("**Thinking")),
|
|
395
|
+
false,
|
|
396
|
+
"no raw ** markers leak into the render",
|
|
397
|
+
);
|
|
398
|
+
assert.equal(
|
|
399
|
+
renderedD.some((l) => l.includes("const x = 1;")),
|
|
400
|
+
true,
|
|
401
|
+
"code content still rendered",
|
|
402
|
+
);
|
|
403
|
+
|
|
404
|
+
// and the pre-fix behavior really did swallow the footer: sanity-check that
|
|
405
|
+
// an unclosed fence without our close would show literal asterisks
|
|
406
|
+
const unclosedView = "consider the snippet\n```ts\nconst x = 1;\n\n**Thinking… (8s)**";
|
|
407
|
+
const renderedU = new Markdown(unclosedView, 1, 0, getMarkdownTheme()).render(80).map(stripAnsi);
|
|
408
|
+
assert.equal(
|
|
409
|
+
renderedU.some((l) => l.includes("**Thinking…")),
|
|
410
|
+
true,
|
|
411
|
+
"sanity: unclosed fence would have swallowed the footer (regression guard)",
|
|
412
|
+
);
|
|
413
|
+
|
|
414
|
+
console.log("✓ scenario D: code blocks — no fold flicker, footer renders outside the fence");
|