pi-my-compact-ui 0.2.2 → 0.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-my-compact-ui",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
4
4
  "description": "Compact tree-shaped reasoning and tool-call UI for the Pi Coding Agent",
5
5
  "author": "cybershape",
6
6
  "license": "MIT",
@@ -21,7 +21,8 @@
21
21
  "files": [
22
22
  "LICENSE",
23
23
  "README.md",
24
- "index.ts"
24
+ "index.ts",
25
+ "src"
25
26
  ],
26
27
  "publishConfig": {
27
28
  "access": "public",
@@ -34,7 +35,8 @@
34
35
  },
35
36
  "scripts": {
36
37
  "typecheck": "tsc --noEmit",
37
- "check": "npm run typecheck",
38
+ "test": "tsc -p tsconfig.test.json && node --test .test-build/tests/*.test.js",
39
+ "check": "npm run typecheck && npm test",
38
40
  "prepublishOnly": "npm run check"
39
41
  },
40
42
  "peerDependencies": {
@@ -0,0 +1,37 @@
1
+ /** 动画调度与终端鼠标状态清理。 */
2
+ import { spinnerStart, THINKING_SPINNER, SPINNER_MS } from "./constants.js";
3
+ import { runtime } from "./state.js";
4
+
5
+ export function thinkingSpinnerFrame(now = Date.now()): { frame: string; color: string } {
6
+ const index =
7
+ Math.floor((now - spinnerStart) / THINKING_SPINNER.intervalMs) % THINKING_SPINNER.rawFrames.length;
8
+ return {
9
+ frame: THINKING_SPINNER.rawFrames[index]!,
10
+ color: THINKING_SPINNER.colorKeys[index]!,
11
+ };
12
+ }
13
+
14
+ export function disableMouseTracking(): void {
15
+ if (process.stdout.isTTY) {
16
+ process.stdout.write("\x1b[?1006l\x1b[?1004l\x1b[?1002l\x1b[?1000l");
17
+ }
18
+ }
19
+
20
+ process.on("exit", disableMouseTracking);
21
+
22
+ export function scheduleAnimation(): void {
23
+ if (runtime.animTimer) return;
24
+ runtime.animTimer = setTimeout(() => {
25
+ runtime.animTimer = null;
26
+ let any = false;
27
+ for (const g of runtime.groups) {
28
+ if (g.needsAnimation()) {
29
+ any = true;
30
+ break;
31
+ }
32
+ }
33
+ if (any && runtime.capturedTui) {
34
+ runtime.capturedTui.requestRender();
35
+ }
36
+ }, [...runtime.groups].some((group) => group.hasPending()) ? SPINNER_MS : THINKING_SPINNER.intervalMs);
37
+ }
@@ -0,0 +1,431 @@
1
+ /** 适配 Pi 原生 assistant 组件:历史思考、正文锚点与回合耗时分隔线。 */
2
+ import { runtime } from "./state.js";
3
+ import type { AssistantContentState, ThinkingEntry } from "./types.js";
4
+ import { ToolGroupComponent } from "./tool-group.js";
5
+ import { estimateTextTokens, formatWorkedTime } from "./helpers.js";
6
+ import { Spacer, truncateToWidth } from "@earendil-works/pi-tui";
7
+ import type { Container, Markdown } from "@earendil-works/pi-tui";
8
+ import { AssistantMessageComponent } from "@earendil-works/pi-coding-agent";
9
+ import { ASSISTANT_THINKING_PATCH_KEY, LIVE_ASSISTANT_KEY, IS_STREAMING_COMP, GROUP_MOUNT, PARENT_KEY } from "./constants.js";
10
+ import { isAssistantMessage, isContainer, isToolGroup, isMarkdown, isSpacer, isText } from "./guards.js";
11
+ import { installVisibleAssistantMarkdownRendering } from "./markdown.js";
12
+
13
+ export function thinkingAlreadyShown(text: string): boolean {
14
+ const norm = text.trim();
15
+ if (!norm) return true;
16
+ if (runtime.activeThinking && runtime.activeThinking.text.trim() === norm) return true;
17
+ for (const entry of runtime.unattachedThinking) {
18
+ if (entry.text.trim() === norm) return true;
19
+ }
20
+ for (const group of runtime.groups) {
21
+ for (const entry of group.entries) {
22
+ if (entry.kind === "thinking" && entry.text.trim() === norm) return true;
23
+ }
24
+ }
25
+ return false;
26
+ }
27
+
28
+ export function stageHistoricalThinking(
29
+ state: AssistantContentState,
30
+ ordinal: number,
31
+ texts: string[],
32
+ message: any,
33
+ ): void {
34
+ if (state.anchors.has(ordinal)) return;
35
+ const fresh = texts.filter((text, index) => text.trim() && texts.indexOf(text) === index && !thinkingAlreadyShown(text));
36
+ if (fresh.length === 0) return;
37
+ const group = new ToolGroupComponent();
38
+ group.anchored = true;
39
+ group.sealed = true;
40
+ const reasoningTokens = Number(message?.usage?.reasoning);
41
+ const tokensExact = Number.isFinite(reasoningTokens) && reasoningTokens > 0;
42
+ for (const text of fresh) {
43
+ const entry: ThinkingEntry = {
44
+ kind: "thinking",
45
+ id: runtime.thinkingEntrySeq++,
46
+ text,
47
+ tokens: tokensExact ? reasoningTokens : estimateTextTokens(text),
48
+ tokensExact,
49
+ active: false,
50
+ owner: group,
51
+ };
52
+ group.entries.push(entry);
53
+ }
54
+ if (!group.hasVisibleEntries()) return;
55
+ state.anchors.set(ordinal, group);
56
+ runtime.groups.add(group);
57
+ }
58
+
59
+ export function ensureHistoricalThinkingAnchors(
60
+ contentContainer: Container,
61
+ message: any,
62
+ ): void {
63
+ const content = Array.isArray(message?.content) ? message.content : undefined;
64
+ if (!content || content.length === 0) return;
65
+
66
+ const state = getAssistantContentState(contentContainer);
67
+ let currentThinkingBlocks: string[] = [];
68
+ let textOrdinal = 0;
69
+
70
+ for (const item of content) {
71
+ if (item?.type === "thinking") {
72
+ const text = String(item.thinking ?? "").trim();
73
+ if (text) currentThinkingBlocks.push(text);
74
+ } else if (item?.type === "text" && String(item.text ?? "").trim()) {
75
+ stageHistoricalThinking(state, textOrdinal, currentThinkingBlocks, message);
76
+ currentThinkingBlocks = [];
77
+ textOrdinal++;
78
+ }
79
+ }
80
+
81
+ const hasToolCalls = content.some((item: any) => item?.type === "toolCall");
82
+ if (!hasToolCalls && textOrdinal === 0 && currentThinkingBlocks.length > 0) {
83
+ stageHistoricalThinking(state, 0, currentThinkingBlocks, message);
84
+ }
85
+ }
86
+
87
+ export function installNativeThinkingSuppression(prototype: any = AssistantMessageComponent.prototype): void {
88
+ if (!prototype || typeof prototype.updateContent !== "function") return;
89
+ const previous = prototype[ASSISTANT_THINKING_PATCH_KEY] as
90
+ | {
91
+ originalUpdateContent: (this: any, message: any, isStreaming?: boolean) => void;
92
+ installedUpdateContent: (this: any, message: any, isStreaming?: boolean) => void;
93
+ }
94
+ | undefined;
95
+ const originalUpdateContent =
96
+ previous && prototype.updateContent === previous.installedUpdateContent
97
+ ? previous.originalUpdateContent
98
+ : (prototype.updateContent as (this: any, message: any, isStreaming?: boolean) => void);
99
+ const installedUpdateContent = function (this: any, message: any, isStreaming?: boolean): void {
100
+ const contentContainer = this.contentContainer;
101
+ if (contentContainer) {
102
+ runtime.assistantContentContainers.add(contentContainer);
103
+ }
104
+ if (isStreaming === true) {
105
+ this[LIVE_ASSISTANT_KEY] = true;
106
+ }
107
+ const isLive = Boolean(this[LIVE_ASSISTANT_KEY]) || isStreaming === true || (this as any)[IS_STREAMING_COMP] === true || this === runtime.lastStreamingComp;
108
+
109
+ const content = Array.isArray(message?.content) ? message.content : undefined;
110
+ const hasToolCalls = Boolean(content?.some((item: any) => item?.type === "toolCall"));
111
+ const hasText = Boolean(content?.some((item: any) => item?.type === "text" && String(item?.text ?? "").trim()));
112
+
113
+ // Historical anchor reconstruction is ONLY for non-live messages loaded from history.
114
+ // If the message is currently live or was streamed live in this session, its thinking is ALREADY in the compact tree.
115
+ if (!isLive && contentContainer && message?.content) {
116
+ ensureHistoricalThinkingAnchors(contentContainer, message);
117
+ }
118
+
119
+ if (!content?.some((item: any) => item?.type === "thinking")) {
120
+ originalUpdateContent.call(this, message, isStreaming);
121
+ return;
122
+ }
123
+ originalUpdateContent.call(
124
+ this,
125
+ {
126
+ ...message,
127
+ content: content.filter((item: any) => item?.type !== "thinking"),
128
+ },
129
+ isStreaming,
130
+ );
131
+
132
+ // If this was a true historical thinking-only message (NO text AND NO tool calls), attach the group.
133
+ // If it has tool calls, its thinking belongs to the tool group — NEVER attach it here.
134
+ if (!isLive && !hasToolCalls && !hasText && contentContainer) {
135
+ const state = getAssistantContentState(contentContainer);
136
+ const group = state.anchors.get(0);
137
+ if (group && group.hasVisibleEntries() && !contentContainer.children.includes(group)) {
138
+ detachGroup(group, contentContainer);
139
+ contentContainer.addChild(new Spacer(1));
140
+ contentContainer.addChild(group);
141
+ contentContainer.addChild(new Spacer(1));
142
+ noteGroupMount(contentContainer, group);
143
+ }
144
+ }
145
+ };
146
+ prototype.updateContent = installedUpdateContent;
147
+ if (typeof prototype.setHideThinkingBlock === "function") {
148
+ const originalSetHideThinkingBlock = prototype.setHideThinkingBlock as (this: any, hide: boolean) => void;
149
+ prototype.setHideThinkingBlock = function (this: any, _hide: boolean): void {
150
+ // Thinking stays in the compact tree. Letting Pi show it again paints a second copy.
151
+ this.hiddenThinkingLabel = "";
152
+ originalSetHideThinkingBlock.call(this, true);
153
+ };
154
+ }
155
+ if (typeof prototype.setHiddenThinkingLabel === "function") {
156
+ const originalSetHiddenThinkingLabel = prototype.setHiddenThinkingLabel as (this: any, label: string) => void;
157
+ prototype.setHiddenThinkingLabel = function (this: any, _label: string): void {
158
+ this.hiddenThinkingLabel = "";
159
+ originalSetHiddenThinkingLabel.call(this, "");
160
+ };
161
+ }
162
+ prototype[ASSISTANT_THINKING_PATCH_KEY] = {
163
+ originalUpdateContent,
164
+ installedUpdateContent,
165
+ };
166
+ }
167
+
168
+ export function ensureAssistantThinkingPatched(component: any): boolean {
169
+ const prototype = Object.getPrototypeOf(component);
170
+ const already = Boolean(prototype?.[ASSISTANT_THINKING_PATCH_KEY]);
171
+ installNativeThinkingSuppression(prototype);
172
+ component.hideThinkingBlock = true;
173
+ component.hiddenThinkingLabel = "";
174
+ return already;
175
+ }
176
+
177
+ export function getAssistantContentState(container: Container): AssistantContentState {
178
+ let state = runtime.assistantContentStates.get(container);
179
+ if (!state) {
180
+ state = { anchors: new Map(), nextTextOrdinal: 0 };
181
+ runtime.assistantContentStates.set(container, state);
182
+ }
183
+ return state;
184
+ }
185
+
186
+ export function removeGroupFromContainer(container: any, group: ToolGroupComponent): void {
187
+ const children = container?.children;
188
+ if (!Array.isArray(children)) return;
189
+ const index = children.indexOf(group);
190
+ if (index >= 0) children.splice(index, 1);
191
+ if ((group as any)[GROUP_MOUNT] === container) delete (group as any)[GROUP_MOUNT];
192
+ }
193
+
194
+ export function noteGroupMount(container: any, group: ToolGroupComponent): void {
195
+ (group as any)[GROUP_MOUNT] = container;
196
+ }
197
+
198
+ export function detachGroup(group: ToolGroupComponent, keep?: any): void {
199
+ const mounted = (group as any)[GROUP_MOUNT];
200
+ if (mounted && mounted !== keep) removeGroupFromContainer(mounted, group);
201
+ if (runtime.lastChatContainer && runtime.lastChatContainer !== keep) removeGroupFromContainer(runtime.lastChatContainer, group);
202
+ const anchor = runtime.groupAnchors.get(group);
203
+ if (anchor?.container && anchor.container !== keep) removeGroupFromContainer(anchor.container, group);
204
+ }
205
+
206
+ export function removeComponentFromContainer(container: any, component: any): void {
207
+ const children = container?.children;
208
+ if (!Array.isArray(children)) return;
209
+ const index = children.indexOf(component);
210
+ if (index >= 0) children.splice(index, 1);
211
+ }
212
+
213
+ export class TurnDividerComponent {
214
+ private readonly timeLabel: string;
215
+
216
+ constructor(timeLabel: string) {
217
+ this.timeLabel = timeLabel;
218
+ }
219
+
220
+ render(width: number): string[] {
221
+ const theme = runtime.currentTheme;
222
+ const fg = (color: string, text: string) => theme?.fg?.(color, text) ?? text;
223
+ const middle = `worked for ${this.timeLabel}`;
224
+ const avail = Math.max(6, width - middle.length - 2);
225
+ const left = Math.floor(avail / 2);
226
+ const right = avail - left;
227
+ const dash = (n: number) => "─".repeat(Math.max(0, n));
228
+ const line = `${fg("dim", dash(left))} ${fg("muted", middle)} ${fg("dim", dash(right))}`;
229
+ return [truncateToWidth(line, Math.max(1, width)), ""];
230
+ }
231
+
232
+ invalidate(): void {}
233
+ }
234
+
235
+ export function placeTurnDividerBeforeText(container: Container, target: Markdown, divider: any): void {
236
+ const targetIndex = container.children.indexOf(target);
237
+ if (targetIndex < 0) return;
238
+ removeComponentFromContainer(container, divider);
239
+ container.children.splice(targetIndex, 0, divider);
240
+ }
241
+
242
+ export function insertTurnDivider(elapsedMs: number): void {
243
+ if (elapsedMs < 1000) return;
244
+ let comp: any = runtime.lastStreamingComp;
245
+ if (!comp || !isAssistantMessage(comp)) {
246
+ if (!runtime.lastChatContainer) return;
247
+ const children = (runtime.lastChatContainer as any)?.children;
248
+ if (!Array.isArray(children)) return;
249
+ for (let i = children.length - 1; i >= 0; i--) {
250
+ if (isAssistantMessage(children[i])) {
251
+ comp = children[i];
252
+ break;
253
+ }
254
+ }
255
+ }
256
+ if (!comp) return;
257
+ const contentContainer = (comp as any).contentContainer;
258
+ if (!isContainer(contentContainer)) return;
259
+
260
+ const markdowns = contentContainer.children.filter(isVisibleTextMarkdown);
261
+ if (markdowns.length === 0) return;
262
+ const final = markdowns[markdowns.length - 1];
263
+ const finalIndex = contentContainer.children.indexOf(final);
264
+ // Only separate the final text from preceding work (an anchored tool/thinking
265
+ // group). A plain text-only answer gets no divider.
266
+ const hasPriorContent = contentContainer.children
267
+ .slice(0, finalIndex)
268
+ .some((child) => isToolGroup(child) && child.hasVisibleEntries());
269
+ if (!hasPriorContent) return;
270
+
271
+ const state = getAssistantContentState(contentContainer);
272
+ if (state.finalDivider && contentContainer.children.includes(state.finalDivider.component)) return;
273
+ const ordinal = markdowns.length - 1;
274
+ const divider = new TurnDividerComponent(formatWorkedTime(elapsedMs));
275
+ state.finalDivider = { ordinal, component: divider };
276
+ placeTurnDividerBeforeText(contentContainer, final, divider);
277
+ contentContainer.invalidate?.();
278
+ runtime.capturedTui?.requestRender?.();
279
+ }
280
+
281
+ export function isVisibleTextMarkdown(component: any): component is Markdown {
282
+ // Thinking Markdown receives a defaultTextStyle ({ color, italic }) from
283
+ // AssistantMessageComponent; normal assistant text does not. Count only
284
+ // normal text blocks so anchors remain correct if thinking visibility is
285
+ // toggled on.
286
+ return isMarkdown(component) && !(component as any).defaultTextStyle;
287
+ }
288
+
289
+ export function placeAnchoredGroupBeforeText(container: Container, target: Markdown, group: ToolGroupComponent): void {
290
+ detachGroup(group, container);
291
+ const targetIndex = container.children.indexOf(target);
292
+ if (targetIndex < 0) return;
293
+
294
+ // Pi may accumulate one Spacer for the message itself plus one Spacer for
295
+ // every hidden thinking run before this text. Tool loops can therefore leave
296
+ // an arbitrarily large run here. Replace the entire run with a deterministic
297
+ // boundary:
298
+ //
299
+ // previous text/content -> one blank -> compact group -> one blank -> text
300
+ //
301
+ // This also makes repeated cumulative AssistantMessageComponent rebuilds
302
+ // idempotent instead of accumulating more spacing around restored anchors.
303
+ let spacerStart = targetIndex;
304
+ while (spacerStart > 0 && isSpacer(container.children[spacerStart - 1])) spacerStart--;
305
+ if (targetIndex > spacerStart) {
306
+ container.children.splice(spacerStart, targetIndex - spacerStart);
307
+ }
308
+ group.anchored = true;
309
+ container.children.splice(spacerStart, 0, new Spacer(1), group, new Spacer(1));
310
+ noteGroupMount(container, group);
311
+ }
312
+
313
+ export function insertAnchoredGroup(container: Container, ordinal: number, group: ToolGroupComponent): void {
314
+ if (!group.hasVisibleEntries()) return;
315
+ const markdowns = container.children.filter(isVisibleTextMarkdown);
316
+ const target = markdowns[ordinal];
317
+ if (!target) return;
318
+ removeGroupFromContainer(container, group);
319
+ placeAnchoredGroupBeforeText(container, target, group);
320
+ }
321
+
322
+ export function installAssistantExpansion(component: AssistantMessageComponent, contentContainer: Container): void {
323
+ // Ctrl+O only visits top-level chat children. Once compact groups are
324
+ // anchored inside an AssistantMessageComponent, make that top-level
325
+ // component expandable and delegate the state to its nested groups.
326
+ (component as any).setExpanded = (expanded: boolean) => {
327
+ const state = runtime.assistantContentStates.get(contentContainer);
328
+ if (!state) return;
329
+ for (const group of state.anchors.values()) group.setExpanded(expanded);
330
+ contentContainer.invalidate();
331
+ };
332
+ }
333
+
334
+ export function anchorGroupBeforeCurrentText(group: ToolGroupComponent, ordinal: number): void {
335
+ if (!runtime.lastStreamingComp || !runtime.lastChatContainer) return;
336
+ const contentContainer = (runtime.lastStreamingComp as any).contentContainer;
337
+ if (!isContainer(contentContainer)) return;
338
+
339
+ // An open group normally lives directly in the chat container. Remove it
340
+ // there before nesting it at the exact text boundary.
341
+ removeGroupFromContainer(runtime.lastChatContainer, group);
342
+
343
+ const previousAnchor = runtime.groupAnchors.get(group);
344
+ if (previousAnchor) {
345
+ const previousState = runtime.assistantContentStates.get(previousAnchor.container);
346
+ if (previousState?.anchors.get(previousAnchor.ordinal) === group) {
347
+ previousState.anchors.delete(previousAnchor.ordinal);
348
+ }
349
+ removeGroupFromContainer(previousAnchor.container, group);
350
+ }
351
+
352
+ if (!group.hasVisibleEntries()) {
353
+ runtime.groups.delete(group);
354
+ return;
355
+ }
356
+
357
+ const state = getAssistantContentState(contentContainer);
358
+ const replaced = state.anchors.get(ordinal);
359
+ if (replaced && replaced !== group) {
360
+ removeGroupFromContainer(contentContainer, replaced);
361
+ replaced.anchored = false;
362
+ runtime.groupAnchors.delete(replaced);
363
+ }
364
+ state.anchors.set(ordinal, group);
365
+ runtime.groupAnchors.set(group, { container: contentContainer, ordinal });
366
+ insertAnchoredGroup(contentContainer, ordinal, group);
367
+ runtime.lastChatContainer.invalidate?.();
368
+ runtime.capturedTui?.requestRender?.();
369
+ }
370
+
371
+ export function restoreAssistantAnchor(parent: any, component: any): void {
372
+ if (!runtime.assistantContentContainers.has(parent) || !isVisibleTextMarkdown(component)) return;
373
+ installVisibleAssistantMarkdownRendering(component);
374
+ const state = getAssistantContentState(parent);
375
+ const ordinal = state.nextTextOrdinal++;
376
+ const group = state.anchors.get(ordinal);
377
+ if (group && group.hasVisibleEntries()) {
378
+ removeGroupFromContainer(parent, group);
379
+ placeAnchoredGroupBeforeText(parent, component, group);
380
+ }
381
+ const divider = state.finalDivider;
382
+ if (divider && divider.ordinal === ordinal) {
383
+ placeTurnDividerBeforeText(parent, component, divider.component);
384
+ }
385
+ }
386
+
387
+ export function releaseAssistantAnchors(component: any): void {
388
+ if (!isAssistantMessage(component)) return;
389
+ const contentContainer = (component as any).contentContainer;
390
+ if (!isContainer(contentContainer)) return;
391
+ const state = runtime.assistantContentStates.get(contentContainer);
392
+ if (!state) return;
393
+ for (const group of state.anchors.values()) {
394
+ for (const tool of [...group.children]) delete (tool as any)[PARENT_KEY];
395
+ runtime.groupAnchors.delete(group);
396
+ runtime.groups.delete(group);
397
+ }
398
+ state.anchors.clear();
399
+ state.finalDivider = undefined;
400
+ }
401
+
402
+ export function stripAssistantPhantomPadding(parent: any, component: any): void {
403
+ // Mark the plain Container that an AssistantMessageComponent owns as its
404
+ // content container so we can trim its children later.
405
+ if (isAssistantMessage(parent) && isContainer(component) && !isAssistantMessage(component)) {
406
+ runtime.assistantContentContainers.add(component);
407
+ getAssistantContentState(component);
408
+ installAssistantExpansion(parent, component);
409
+ return;
410
+ }
411
+ if (!runtime.assistantContentContainers.has(parent)) return;
412
+ // Pi still builds a native thinking widget (full Markdown, or the hidden
413
+ // "Thinking..." label) beside the compact tree. Drop that widget so the run
414
+ // is visible only inside the tree.
415
+ if (!isNativeThinkingWidget(component)) return;
416
+ const index = parent.children.indexOf(component);
417
+ if (index >= 0) parent.children.splice(index, 1);
418
+ if (isSpacer(parent.children[index])) parent.children.splice(index, 1);
419
+ while (parent.children.length > 0 && parent.children.every((child: any) => isSpacer(child))) parent.children.pop();
420
+ }
421
+
422
+ export function isNativeThinkingWidget(component: any): boolean {
423
+ if (!component || isToolGroup(component)) return false;
424
+ if (component.constructor?.name === "MouseRegion" && component.child) {
425
+ return isNativeThinkingWidget(component.child);
426
+ }
427
+ if (isMarkdown(component) && (component as any).defaultTextStyle) return true;
428
+ if (!isText(component)) return false;
429
+ const visible = String((component as any).text ?? "").replace(/\x1b\[[0-9;]*m/g, "").trim();
430
+ return visible === "" || visible === "Thinking..." || visible === "thinking" || visible === "thinking...";
431
+ }
@@ -0,0 +1,75 @@
1
+ /** 压缩摘要的紧凑呈现与可热重载的原型补丁。 */
2
+ import { visibleWidth, truncateToWidth } from "@earendil-works/pi-tui";
3
+ import type { Component } from "@earendil-works/pi-tui";
4
+ import { runtime } from "./state.js";
5
+ import { formatTokenK } from "./helpers.js";
6
+ import { CompactionSummaryMessageComponent } from "@earendil-works/pi-coding-agent";
7
+ import { COMPACTION_STYLE_PATCH_KEY, GROUP_PADDING_X } from "./constants.js";
8
+
9
+ export class CompactionHeaderComponent implements Component {
10
+ constructor(private readonly tokensBefore: number) {}
11
+
12
+ render(width: number): string[] {
13
+ const theme = runtime.currentTheme;
14
+ const fg = (color: string, text: string) => theme?.fg?.(color, text) ?? text;
15
+ const exactTokens = Math.max(0, this.tokensBefore).toLocaleString();
16
+ const icon = fg("success", "›‹");
17
+ const title = fg("success", "Context compacted");
18
+ const detail = fg("muted", ` • ${exactTokens} tokens → summary`);
19
+ const full = `${icon} ${title}${detail}`;
20
+ if (visibleWidth(full) <= width) return [full];
21
+
22
+ const compactTitle = fg("success", "Compacted");
23
+ const compactDetail = fg("muted", ` • ${formatTokenK(this.tokensBefore)} tok`);
24
+ return [truncateToWidth(`${icon} ${compactTitle}${compactDetail}`, Math.max(1, width), "...")];
25
+ }
26
+
27
+ invalidate(): void {}
28
+ }
29
+
30
+ export function installCompactionSummaryRendering(): void {
31
+ const prototype = CompactionSummaryMessageComponent.prototype as any;
32
+ const previous = prototype[COMPACTION_STYLE_PATCH_KEY] as
33
+ | {
34
+ original?: (this: any) => void;
35
+ installed?: (this: any) => void;
36
+ originalUpdateDisplay?: (this: any) => void;
37
+ originalSetExpanded?: (this: any, expanded: boolean) => void;
38
+ installedUpdateDisplay?: (this: any) => void;
39
+ installedSetExpanded?: (this: any, expanded: boolean) => void;
40
+ }
41
+ | undefined;
42
+ // Replace the previous compact-ui closure on hot reload while retaining
43
+ // pi's original renderer for a future replacement.
44
+ const previousInstalledUpdate = previous?.installedUpdateDisplay ?? previous?.installed;
45
+ const originalUpdateDisplay =
46
+ previous && previousInstalledUpdate && prototype.updateDisplay === previousInstalledUpdate
47
+ ? (previous.originalUpdateDisplay ?? previous.original)!
48
+ : (prototype.updateDisplay as (this: any) => void);
49
+ const originalSetExpanded =
50
+ previous?.installedSetExpanded && prototype.setExpanded === previous.installedSetExpanded
51
+ ? previous.originalSetExpanded!
52
+ : (prototype.setExpanded as (this: any, expanded: boolean) => void);
53
+ const installedUpdateDisplay = function (this: any): void {
54
+ this.paddingX = GROUP_PADDING_X;
55
+ this.paddingY = 0;
56
+ this.setBgFn(undefined);
57
+ this.clear();
58
+
59
+ const tokensBefore = Number(this.message?.tokensBefore);
60
+ const safeTokens = Number.isFinite(tokensBefore) && tokensBefore > 0 ? tokensBefore : 0;
61
+ this.addChild(new CompactionHeaderComponent(safeTokens));
62
+ };
63
+ const installedSetExpanded = function (this: any, _expanded: boolean): void {
64
+ // Context compaction is a static transcript event. Global Ctrl+O remains
65
+ // available for compact thinking/tool groups but does not alter this row.
66
+ };
67
+ prototype.updateDisplay = installedUpdateDisplay;
68
+ prototype.setExpanded = installedSetExpanded;
69
+ prototype[COMPACTION_STYLE_PATCH_KEY] = {
70
+ originalUpdateDisplay,
71
+ originalSetExpanded,
72
+ installedUpdateDisplay,
73
+ installedSetExpanded,
74
+ };
75
+ }
package/src/config.ts ADDED
@@ -0,0 +1,31 @@
1
+ /** 配置持久化;通过可注入的读写器隔离文件系统。 */
2
+ import { join } from "path";
3
+ import { homedir } from "os";
4
+ import { readFileSync, writeFileSync } from "fs";
5
+
6
+ export const CONFIG_PATH = join(homedir(), ".pi", "agent", "compact-ui.json");
7
+
8
+ export const DEFAULT_CONFIG = { expandedToolLines: 5, expandedThinkingLines: 10, maxGroupEntries: 5 };
9
+
10
+ export const config = loadConfig(CONFIG_PATH);
11
+
12
+ export function saveConfig(
13
+ values = config,
14
+ path = CONFIG_PATH,
15
+ write: (path: string, data: string) => void = writeFileSync,
16
+ ): void {
17
+ try {
18
+ write(path, JSON.stringify(values, null, 2) + "\n");
19
+ } catch {
20
+ // ignore
21
+ }
22
+ }
23
+
24
+ /** Read configuration without coupling tests to the user's filesystem. */
25
+ export function loadConfig(path: string, read: (path: string, encoding: 'utf-8') => string = readFileSync): typeof DEFAULT_CONFIG {
26
+ try {
27
+ return { ...DEFAULT_CONFIG, ...JSON.parse(read(path, 'utf-8')) };
28
+ } catch {
29
+ return { ...DEFAULT_CONFIG };
30
+ }
31
+ }
@@ -0,0 +1,36 @@
1
+ /** 渲染常量与跨热重载稳定的补丁标记。 */
2
+ export const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
3
+
4
+ export const SPINNER_MS = 100;
5
+
6
+ export const THINKING_SPINNER = {
7
+ rawFrames: ["·", "•", "●", "•"],
8
+ intervalMs: 140,
9
+ colorKeys: ["dim", "muted", "accent", "muted"],
10
+ } as const;
11
+
12
+ export const GROUP_PADDING_X = 1;
13
+
14
+ export const GROUP_PADDING_RIGHT = 2;
15
+
16
+ export const spinnerStart = Date.now();
17
+
18
+ export const PARENT_KEY = Symbol.for("compact-ui.group-parent");
19
+
20
+ export const PATCH_KEY = Symbol.for("compact-ui.group-patch");
21
+
22
+ export const MARKDOWN_RENDER_PATCH_KEY = Symbol.for("compact-ui.markdown-render-patch");
23
+
24
+ export const COMPACTION_STYLE_PATCH_KEY = Symbol.for("compact-ui.compaction-style-patch");
25
+
26
+ export const ASSISTANT_THINKING_PATCH_KEY = Symbol.for("compact-ui.assistant-thinking-patch");
27
+
28
+ export const IS_STREAMING_COMP = Symbol.for("compact-ui.is-streaming-comp");
29
+
30
+ export const GROUP_MOUNT = Symbol.for("compact-ui.group-mount");
31
+
32
+ export const TOOL_RENDER_PATCH_KEY = Symbol.for("compact-ui.tool-render-patch");
33
+
34
+ export const LIVE_ASSISTANT_KEY = Symbol.for("compact-ui.live-assistant");
35
+
36
+ export const CODE_BLOCK_PADDING_X = 1;