pi-cc-extensions 0.8.32 → 0.8.33

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.
@@ -1,5 +1,9 @@
1
+ import * as piAi from "@earendil-works/pi-ai";
2
+ import * as piCodingAgent from "@earendil-works/pi-coding-agent";
1
3
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
4
+ import * as piTui from "@earendil-works/pi-tui";
5
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
6
+ import { createRequire } from "node:module";
3
7
  import { homedir } from "node:os";
4
8
  import { join } from "node:path";
5
9
  import { createJiti } from "jiti";
@@ -14,41 +18,163 @@ export type CompactThinkingController = {
14
18
  updateConfig(next: CompactThinkingConfig): void;
15
19
  };
16
20
 
21
+ type CompactThinkingOwner = {
22
+ owner: object;
23
+ stop(event?: any, ctx?: any): void;
24
+ };
25
+
26
+ const COMPACT_THINKING_OWNER = Symbol.for("pi.ccstyle.compact-thinking-owner");
27
+
17
28
  export function installCompactThinking(
18
29
  pi: ExtensionAPI,
19
30
  initialConfig: CompactThinkingConfig,
20
31
  ): CompactThinkingController {
32
+ const owner = {};
33
+ const host = globalThis as typeof globalThis & {
34
+ [COMPACT_THINKING_OWNER]?: CompactThinkingOwner;
35
+ };
36
+ host[COMPACT_THINKING_OWNER]?.stop();
37
+
21
38
  const agentDir = process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent");
22
- const configPath = join(agentDir, "compact-thinking.json");
23
- let hasValidCompatibilityConfig = false;
24
- try {
25
- const value = JSON.parse(readFileSync(configPath, "utf8")) as Partial<CompactThinkingConfig>;
26
- hasValidCompatibilityConfig =
27
- typeof value.useSummaryTitlesAsThinkingTitle === "boolean" &&
28
- Number.isInteger(value.previewLines) &&
29
- (value.previewLines ?? -1) >= 0 &&
30
- Number.isFinite(value.animationIntervalMs) &&
31
- (value.animationIntervalMs ?? 0) >= 1;
32
- } catch {
33
- // The upstream loader needs a valid compatibility file on first install.
34
- }
35
- if (!hasValidCompatibilityConfig) {
36
- if (!existsSync(agentDir)) mkdirSync(agentDir, { recursive: true });
37
- writeFileSync(configPath, `${JSON.stringify(initialConfig, null, 2)}\n`, "utf8");
39
+ // Upstream requires this file while loading; mirror ccstyle into it and never read it back.
40
+ mkdirSync(agentDir, { recursive: true });
41
+ writeFileSync(
42
+ join(agentDir, "compact-thinking.json"),
43
+ `${JSON.stringify(initialConfig, null, 2)}\n`,
44
+ "utf8",
45
+ );
46
+
47
+ // Fork the upstream entry so a running subagent tool keeps the thinking
48
+ // loading animation until the model emits the next text/thinking boundary.
49
+ // Upstream finalizes on tool_execution_start, freezing the summary into a
50
+ // static "Thought for Xs" for the whole subagent run.
51
+ const upstreamRequire = createRequire(import.meta.url);
52
+ const upstreamIndexPath = upstreamRequire.resolve("pi-compact-thinking/index.ts");
53
+ const toolStartOriginal = ` pi.on("tool_execution_start", finishThinking);`;
54
+ const toolStartPatched = ` // Subagent tools can run for minutes: keep the thinking loading animation
55
+ // alive for the whole execution and only finalize once the model emits the
56
+ // next text/thinking boundary or the message ends.
57
+ function resumeAgentThinking(message: AssistantMessage | undefined) {
58
+ if (activeThinking) return;
59
+ const content = message?.content;
60
+ if (!Array.isArray(content)) return;
61
+ const index = content.findIndex((item) => item?.type === "thinking");
62
+ if (index < 0) return;
63
+ startThinking(message, index);
64
+ }
65
+ pi.on("tool_execution_start", (event: any) => {
66
+ if (event.toolName === "Agent" || event.toolName === "Agents") {
67
+ resumeAgentThinking(latestComponent?.lastMessage ?? undefined);
68
+ } else {
69
+ finishThinking();
70
+ }
71
+ });`;
72
+ const updateBoundaryOriginal = ` } else if (
73
+ update.type === "text_start" ||
74
+ update.type === "toolcall_start" ||
75
+ update.type === "toolcall_delta"
76
+ ) {
77
+ finishThinking();
78
+ }`;
79
+ const updateBoundaryPatched = ` } else if (update.type === "text_start") {
80
+ finishThinking();
81
+ } else if (
82
+ update.type === "toolcall_start" ||
83
+ update.type === "toolcall_delta"
84
+ ) {
85
+ if (
86
+ Array.isArray(event.message?.content) &&
87
+ event.message.content.some(
88
+ (content) =>
89
+ content?.type === "toolCall" &&
90
+ (content.name === "Agent" ||
91
+ content.name === "Agents" ||
92
+ content.args?.subagent_type != null),
93
+ )
94
+ ) {
95
+ resumeAgentThinking(event.message);
96
+ } else {
97
+ finishThinking();
98
+ }
99
+ }`;
100
+ let upstreamIndexSource = readFileSync(upstreamIndexPath, "utf8");
101
+ if (
102
+ upstreamIndexSource.includes(toolStartOriginal) &&
103
+ upstreamIndexSource.includes(updateBoundaryOriginal)
104
+ ) {
105
+ upstreamIndexSource = upstreamIndexSource
106
+ .replace(toolStartOriginal, toolStartPatched)
107
+ .replace(updateBoundaryOriginal, updateBoundaryPatched);
108
+ } else {
109
+ // Upstream changed shape: fall back to the untouched entry so the loader
110
+ // keeps working; only the subagent animation nicety is lost.
111
+ upstreamIndexSource = readFileSync(upstreamIndexPath, "utf8");
38
112
  }
39
113
 
40
- // The package publishes TypeScript source only, so use the loader already used by fixed-editor.
41
- const jiti = createJiti(import.meta.url);
114
+ // Reuse Pi's live modules so the upstream prototype patch reaches runtime components.
115
+ const jiti = createJiti(import.meta.url, {
116
+ virtualModules: {
117
+ "@earendil-works/pi-ai": piAi,
118
+ "@earendil-works/pi-coding-agent": piCodingAgent,
119
+ "@earendil-works/pi-tui": piTui,
120
+ },
121
+ });
42
122
  const upstreamConfig = (
43
123
  jiti("pi-compact-thinking/lib/config.ts") as { config: CompactThinkingConfig }
44
124
  ).config;
45
125
  const compactThinking = (
46
- jiti("pi-compact-thinking/index.ts") as {
126
+ jiti.evalModule(upstreamIndexSource, { filename: upstreamIndexPath }) as {
47
127
  default: (api: ExtensionAPI) => void;
48
128
  }
49
129
  ).default;
50
130
  Object.assign(upstreamConfig, initialConfig);
51
- compactThinking(pi);
131
+
132
+ // Upstream restores completedDurations from sessionManager.getBranch() (the
133
+ // current leaf path). After compaction or a branch switch, duration entries
134
+ // of older messages leave that path, so scrolling back renders a bare
135
+ // "Thinking..." instead of "Thought for Xs". Restore from every entry.
136
+ const restoreAllDurations = (ctx: any): any => {
137
+ const sessionManager = ctx?.sessionManager;
138
+ if (!sessionManager || typeof sessionManager.getEntries !== "function") return ctx;
139
+ return {
140
+ ...ctx,
141
+ sessionManager: {
142
+ ...sessionManager,
143
+ getBranch: () => sessionManager.getEntries(),
144
+ },
145
+ };
146
+ };
147
+
148
+ let session: { event: any; ctx: any } | undefined;
149
+ let shutdown: ((event: any, ctx: any) => void) | undefined;
150
+ let stopped = false;
151
+ const stop = (event?: any, ctx?: any) => {
152
+ if (stopped) return;
153
+ stopped = true;
154
+ shutdown?.(event ?? session?.event ?? {}, ctx ?? session?.ctx ?? { mode: "rpc", ui: {} });
155
+ if (host[COMPACT_THINKING_OWNER]?.owner === owner) delete host[COMPACT_THINKING_OWNER];
156
+ };
157
+ compactThinking({
158
+ on(event: string, handler: (event: any, ctx: any) => void) {
159
+ if (event === "session_start") {
160
+ pi.on(event, (startEvent, ctx) => {
161
+ session = { event: startEvent, ctx };
162
+ handler(startEvent, restoreAllDurations(ctx));
163
+ });
164
+ } else if (event === "session_shutdown") {
165
+ shutdown = handler;
166
+ pi.on(event, (shutdownEvent, ctx) => {
167
+ if (host[COMPACT_THINKING_OWNER]?.owner === owner) stop(shutdownEvent, ctx);
168
+ });
169
+ } else {
170
+ pi.on(event as any, (e: any, ctx: any) =>
171
+ handler(e, event === "session_tree" ? restoreAllDurations(ctx) : ctx),
172
+ );
173
+ }
174
+ },
175
+ appendEntry: (...args: any[]) => (pi.appendEntry as any)(...args),
176
+ } as unknown as ExtensionAPI);
177
+ host[COMPACT_THINKING_OWNER] = { owner, stop };
52
178
 
53
179
  return {
54
180
  updateConfig(next) {
@@ -12,21 +12,46 @@ const terminalSplit = jiti("@tifan/pi-fixed-editor/src/terminal-split.js") as {
12
12
  TerminalSplitCompositor?: { prototype: Record<PropertyKey, any> };
13
13
  };
14
14
  export type FixedEditorHitbox = { row: number; startCol: number; endCol: number };
15
+ export type FixedEditorViewportSnapshot = {
16
+ tui: object;
17
+ visibleRootStart: number;
18
+ visibleScrollableRows: number;
19
+ visibleLines: readonly string[];
20
+ generation: number;
21
+ };
15
22
 
16
- type FixedEditorSharedState = { scrollButtonHitbox: FixedEditorHitbox | null };
23
+ type FixedEditorSharedState = {
24
+ scrollButtonHitbox: FixedEditorHitbox | null;
25
+ viewports: WeakMap<object, FixedEditorViewportSnapshot>;
26
+ generation: number;
27
+ };
17
28
  const FIXED_EDITOR_STATE_KEY = Symbol.for("pi.ccstyle.fixed-editor.state");
18
29
  const fixedEditorState = (() => {
19
30
  const host = globalThis as typeof globalThis & {
20
31
  [FIXED_EDITOR_STATE_KEY]?: FixedEditorSharedState;
21
32
  };
22
- host[FIXED_EDITOR_STATE_KEY] ??= { scrollButtonHitbox: null };
23
- return host[FIXED_EDITOR_STATE_KEY];
33
+ const state = (host[FIXED_EDITOR_STATE_KEY] ??= {
34
+ scrollButtonHitbox: null,
35
+ viewports: new WeakMap(),
36
+ generation: 0,
37
+ });
38
+ state.viewports ??= new WeakMap();
39
+ state.generation ??= 0;
40
+ return state;
24
41
  })();
25
42
 
26
43
  export function getFixedEditorScrollButtonHitbox(): FixedEditorHitbox | null {
27
44
  return fixedEditorState.scrollButtonHitbox;
28
45
  }
29
46
 
47
+ export function getFixedEditorViewport(tui: object): FixedEditorViewportSnapshot | null {
48
+ return fixedEditorState.viewports.get(tui) ?? null;
49
+ }
50
+
51
+ function clearFixedEditorViewport(): void {
52
+ fixedEditorState.viewports = new WeakMap();
53
+ }
54
+
30
55
  const FIXED_EDITOR_SPACING_PATCH = Symbol.for("pi.ccstyle.fixed-editor-spacing-patch");
31
56
  const clusterModule = jiti("@tifan/pi-fixed-editor/src/cluster.js") as {
32
57
  renderFixedEditorCluster?: (input: any) => any;
@@ -70,6 +95,9 @@ const loaded = jiti("@tifan/pi-fixed-editor/src/index.ts") as {
70
95
  const fixedEditor = loaded.default ?? (loaded as unknown as FixedEditorExtension);
71
96
 
72
97
  const IME_CURSOR_PATCH = Symbol.for("pi.ccstyle.fixed-editor-ime-cursor-patch");
98
+ const VIEWPORT_PATCH = Symbol.for("pi.ccstyle.fixed-editor-viewport-patch");
99
+ const MOUSE_WRITE_PATCH = Symbol.for("pi.ccstyle.fixed-editor-mouse-write-patch");
100
+ const MOUSE_WRITE_RESTORE_PATCH = Symbol.for("pi.ccstyle.fixed-editor-mouse-write-restore-patch");
73
101
 
74
102
  function positionHiddenImeCursor(compositor: any): void {
75
103
  if (
@@ -93,17 +121,68 @@ export function installFixedEditorImePatch(compositorClass: {
93
121
  prototype: Record<PropertyKey, any>;
94
122
  }): void {
95
123
  const prototype = compositorClass.prototype;
96
- if (prototype[IME_CURSOR_PATCH]) return;
97
- for (const name of ["write", "requestRepaint"] as const) {
98
- const original = prototype[name];
99
- if (typeof original !== "function") continue;
100
- prototype[name] = function (this: any, ...args: any[]) {
101
- const result = Reflect.apply(original, this, args);
102
- positionHiddenImeCursor(this);
124
+ if (!prototype[IME_CURSOR_PATCH]) {
125
+ for (const name of ["write", "requestRepaint"] as const) {
126
+ const original = prototype[name];
127
+ if (typeof original !== "function") continue;
128
+ prototype[name] = function (this: any, ...args: any[]) {
129
+ const result = Reflect.apply(original, this, args);
130
+ positionHiddenImeCursor(this);
131
+ return result;
132
+ };
133
+ }
134
+ prototype[IME_CURSOR_PATCH] = true;
135
+ }
136
+ if (!prototype[VIEWPORT_PATCH] && typeof prototype.renderScrollableRoot === "function") {
137
+ const originalRenderScrollableRoot = prototype.renderScrollableRoot;
138
+ prototype.renderScrollableRoot = function (this: any, ...args: any[]) {
139
+ const lines = Reflect.apply(originalRenderScrollableRoot, this, args);
140
+ if (this.tui && !this.disposed && Array.isArray(lines)) {
141
+ fixedEditorState.viewports.set(this.tui, {
142
+ tui: this.tui,
143
+ visibleRootStart: Number(this.visibleRootStart) || 0,
144
+ visibleScrollableRows: Number(this.visibleScrollableRows) || lines.length,
145
+ visibleLines: [...lines],
146
+ generation: ++fixedEditorState.generation,
147
+ });
148
+ }
149
+ return lines;
150
+ };
151
+ prototype[VIEWPORT_PATCH] = true;
152
+ }
153
+ if (!prototype[MOUSE_WRITE_PATCH] && typeof prototype.install === "function") {
154
+ const originalInstall = prototype.install;
155
+ prototype.install = function (this: any, ...args: any[]) {
156
+ if (!Object.hasOwn(this, MOUSE_WRITE_PATCH) && typeof this.originalWrite === "function") {
157
+ const originalWrite = this.originalWrite;
158
+ this.originalWrite = (data: string) =>
159
+ Reflect.apply(originalWrite, this, [data.replaceAll("\x1b[?1002h", "\x1b[?1003h")]);
160
+ this[MOUSE_WRITE_PATCH] = originalWrite;
161
+ }
162
+ const wasInstalled = Boolean(this.installed);
163
+ const result = Reflect.apply(originalInstall, this, args);
164
+ // Dependents re-wrap doRender only after the compositor actually owns it.
165
+ if (!wasInstalled && this.installed && !this.disposed) compositorInstallNotify?.();
103
166
  return result;
104
167
  };
168
+ prototype[MOUSE_WRITE_PATCH] = true;
169
+ }
170
+ if (!prototype[MOUSE_WRITE_RESTORE_PATCH] && typeof prototype.dispose === "function") {
171
+ const originalDispose = prototype.dispose;
172
+ prototype.dispose = function (this: any, ...args: any[]) {
173
+ const originalWrite = Object.hasOwn(this, MOUSE_WRITE_PATCH)
174
+ ? this[MOUSE_WRITE_PATCH]
175
+ : undefined;
176
+ try {
177
+ return Reflect.apply(originalDispose, this, args);
178
+ } finally {
179
+ if (typeof originalWrite === "function" && this.terminal) {
180
+ this.terminal.write = originalWrite;
181
+ }
182
+ }
183
+ };
184
+ prototype[MOUSE_WRITE_RESTORE_PATCH] = true;
105
185
  }
106
- prototype[IME_CURSOR_PATCH] = true;
107
186
  }
108
187
 
109
188
  if (terminalSplit.TerminalSplitCompositor) {
@@ -112,6 +191,8 @@ if (terminalSplit.TerminalSplitCompositor) {
112
191
 
113
192
  export type FixedEditorController = {
114
193
  setEnabled(enabled: boolean): void;
194
+ /** Fires after compositor install/reinstall (session start, enable, footer rebuild). */
195
+ onRebuild(listener: () => void): () => void;
115
196
  };
116
197
 
117
198
  type FixedEditorOwner = {
@@ -120,6 +201,39 @@ type FixedEditorOwner = {
120
201
  };
121
202
 
122
203
  const FIXED_EDITOR_OWNER = Symbol.for("pi.ccstyle.fixed-editor-owner");
204
+ const FIXED_EDITOR_FOOTER_HOOK = Symbol.for("pi.ccstyle.fixed-editor-footer-hook");
205
+
206
+ /** Active owner callback; fired from compositor.install after a fresh install succeeds. */
207
+ let compositorInstallNotify: (() => void) | undefined;
208
+ let compositorInstallNotifyOwner: object | undefined;
209
+ /** Runs before fixed-editor start/probe so dependents can unwrap doRender before capture. */
210
+ let beforeFixedEditorStart: (() => void) | undefined;
211
+
212
+ /** Register a pre-start hook (e.g. unwrap mouse doRender before compositor construction). */
213
+ export function setBeforeFixedEditorStart(listener: (() => void) | undefined): void {
214
+ beforeFixedEditorStart = listener;
215
+ }
216
+
217
+ type FooterHookState = { listener?: () => void };
218
+
219
+ function observeFooterReplacement(ui: any, listener: () => void): () => void {
220
+ let state = ui[FIXED_EDITOR_FOOTER_HOOK] as FooterHookState | undefined;
221
+ if (!state) {
222
+ const original = ui.setFooter;
223
+ state = {};
224
+ const hookState = state;
225
+ ui.setFooter = function (this: unknown, ...args: unknown[]) {
226
+ const result = Reflect.apply(original, this, args);
227
+ hookState.listener?.();
228
+ return result;
229
+ };
230
+ ui[FIXED_EDITOR_FOOTER_HOOK] = state;
231
+ }
232
+ state.listener = listener;
233
+ return () => {
234
+ if (state.listener === listener) state.listener = undefined;
235
+ };
236
+ }
123
237
 
124
238
  /** Adds runtime on/off control around pi-fixed-editor's session lifecycle. */
125
239
  export function installFixedEditor(
@@ -139,24 +253,74 @@ export function installFixedEditor(
139
253
  let enabled = initiallyEnabled;
140
254
  let active = false;
141
255
  let session: { event: any; ctx: any } | undefined;
256
+ let unobserveFooter: (() => void) | undefined;
257
+ let footerRestartQueued = false;
258
+ const rebuildListeners = new Set<() => void>();
259
+ const notifyRebuild = () => {
260
+ for (const listener of rebuildListeners) listener();
261
+ };
142
262
 
143
- const deactivate = () => {
263
+ const unhookFooter = () => {
264
+ unobserveFooter?.();
265
+ unobserveFooter = undefined;
266
+ };
267
+ const hookFooter = () => {
268
+ const ui = session?.ctx?.ui;
269
+ if (typeof ui?.setFooter !== "function") return;
270
+ unobserveFooter = observeFooterReplacement(ui, () => {
271
+ if (!active || footerRestartQueued) return;
272
+ footerRestartQueued = true;
273
+ const currentSession = session;
274
+ queueMicrotask(() => {
275
+ footerRestartQueued = false;
276
+ if (!active || session !== currentSession) return;
277
+ // Dispose old compositor first, then reinstall and rebind dependents.
278
+ deactivate();
279
+ activate();
280
+ });
281
+ });
282
+ };
283
+ function clearInstallNotify() {
284
+ if (compositorInstallNotifyOwner === owner) {
285
+ compositorInstallNotify = undefined;
286
+ compositorInstallNotifyOwner = undefined;
287
+ }
288
+ }
289
+ function deactivate() {
144
290
  fixedEditorState.scrollButtonHitbox = null;
291
+ clearFixedEditorViewport();
145
292
  const current = (globalThis as any)[FIXED_EDITOR_OWNER] as FixedEditorOwner | undefined;
146
293
  if (current?.owner === owner) current.stop();
147
- else active = false;
148
- };
149
- const activate = () => {
294
+ else {
295
+ clearInstallNotify();
296
+ unhookFooter();
297
+ active = false;
298
+ }
299
+ }
300
+ function activate() {
150
301
  if (!session || session.ctx?.mode !== "tui" || active) return;
151
302
  const previous = (globalThis as any)[FIXED_EDITOR_OWNER] as FixedEditorOwner | undefined;
152
303
  if (previous?.owner !== owner) previous?.stop();
153
304
  const currentSession = session;
305
+ // Register before start/probe so the first real compositor.install notifies us.
306
+ compositorInstallNotify = () => {
307
+ if (!active) return;
308
+ if ((globalThis as any)[FIXED_EDITOR_OWNER]?.owner !== owner) return;
309
+ notifyRebuild();
310
+ };
311
+ compositorInstallNotifyOwner = owner;
312
+ // Unwrap outer doRender patches before compositor construction captures it.
313
+ beforeFixedEditorStart?.();
154
314
  start?.(currentSession.event, currentSession.ctx);
155
315
  active = true;
316
+ hookFooter();
156
317
  (globalThis as any)[FIXED_EDITOR_OWNER] = {
157
318
  owner,
158
319
  stop: () => {
159
320
  if (!active) return;
321
+ clearInstallNotify();
322
+ unhookFooter();
323
+ clearFixedEditorViewport();
160
324
  shutdown?.(currentSession.event, currentSession.ctx);
161
325
  active = false;
162
326
  if ((globalThis as any)[FIXED_EDITOR_OWNER]?.owner === owner) {
@@ -164,7 +328,7 @@ export function installFixedEditor(
164
328
  }
165
329
  },
166
330
  } satisfies FixedEditorOwner;
167
- };
331
+ }
168
332
 
169
333
  pi.on("session_start", (event, ctx) => {
170
334
  session = { event, ctx };
@@ -181,5 +345,11 @@ export function installFixedEditor(
181
345
  if (nextEnabled) activate();
182
346
  else deactivate();
183
347
  },
348
+ onRebuild(listener) {
349
+ rebuildListeners.add(listener);
350
+ return () => {
351
+ rebuildListeners.delete(listener);
352
+ };
353
+ },
184
354
  };
185
355
  }
@@ -154,6 +154,10 @@ function toolSummary(tool: any): { main: string; detail: string } {
154
154
  return `${titled} ${oneLine(found || fallback)}`;
155
155
  };
156
156
  if (lowerName === "agent" || lowerName === "agents") {
157
+ const displayName = args.subagent_type ?? args.agent_type ?? args.agent;
158
+ if (typeof displayName === "string" && displayName) {
159
+ return { main: `${titled} ${displayName}`, detail: "" };
160
+ }
157
161
  return {
158
162
  main: value(
159
163
  lowerName === "agent" ? "launch agent" : "launch agents",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-cc-extensions",
3
- "version": "0.8.32",
3
+ "version": "0.8.33",
4
4
  "description": "A Pi productivity suite with Claude Code-style UI, fixed editor, context inspection, and agent/session references",
5
5
  "type": "module",
6
6
  "keywords": [