pi-zentui 0.15.0 → 0.15.1

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.
@@ -0,0 +1,73 @@
1
+ export type EditorComponentFactory = (...args: never[]) => unknown;
2
+
3
+ export type EditorTransferUi<Factory = EditorComponentFactory> = {
4
+ getEditorText?: () => unknown;
5
+ setEditorText?: (text: string) => void;
6
+ setEditorComponent?: (factory: Factory | undefined) => void;
7
+ getEditorComponent?: () => Factory | undefined;
8
+ };
9
+
10
+ export type EditorTransferFailureReason =
11
+ | "unsupported-transfer-api"
12
+ | "editor-factory-snapshot-failed"
13
+ | "editor-text-snapshot-failed"
14
+ | "editor-text-preparation-failed"
15
+ | "editor-replacement-failed-with-rollback"
16
+ | "editor-replacement-rollback-failed";
17
+
18
+ export type EditorTransferResult =
19
+ | { ok: true }
20
+ | { ok: false; reason: EditorTransferFailureReason };
21
+
22
+ /**
23
+ * Prepare the active editor's public expanded text before Pi replaces its factory.
24
+ * This intentionally transfers prompt contents only; editor-private state is never inspected.
25
+ */
26
+ export function replaceEditorComponentWithExpandedText<Factory>(
27
+ ui: EditorTransferUi<Factory>,
28
+ factory: Factory | undefined,
29
+ ): EditorTransferResult {
30
+ if (
31
+ typeof ui.getEditorText !== "function" ||
32
+ typeof ui.setEditorText !== "function" ||
33
+ typeof ui.setEditorComponent !== "function" ||
34
+ typeof ui.getEditorComponent !== "function"
35
+ ) {
36
+ return { ok: false, reason: "unsupported-transfer-api" };
37
+ }
38
+
39
+ let previousFactory: Factory | undefined;
40
+ try {
41
+ previousFactory = ui.getEditorComponent();
42
+ } catch {
43
+ return { ok: false, reason: "editor-factory-snapshot-failed" };
44
+ }
45
+
46
+ let expandedText: unknown;
47
+ try {
48
+ expandedText = ui.getEditorText();
49
+ } catch {
50
+ return { ok: false, reason: "editor-text-snapshot-failed" };
51
+ }
52
+ if (typeof expandedText !== "string") {
53
+ return { ok: false, reason: "editor-text-snapshot-failed" };
54
+ }
55
+
56
+ try {
57
+ ui.setEditorText(expandedText);
58
+ } catch {
59
+ return { ok: false, reason: "editor-text-preparation-failed" };
60
+ }
61
+
62
+ try {
63
+ ui.setEditorComponent(factory);
64
+ } catch {
65
+ try {
66
+ ui.setEditorComponent(previousFactory);
67
+ return { ok: false, reason: "editor-replacement-failed-with-rollback" };
68
+ } catch {
69
+ return { ok: false, reason: "editor-replacement-rollback-failed" };
70
+ }
71
+ }
72
+ return { ok: true };
73
+ }
@@ -1,5 +1,4 @@
1
1
  import { homedir, hostname, userInfo } from "node:os";
2
- import type { AssistantMessage } from "@earendil-works/pi-ai";
3
2
  import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
4
3
  import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
5
4
  import type {
@@ -81,23 +80,40 @@ export type UsageTotals = {
81
80
 
82
81
  export type ContextColorTier = "normal" | "warning" | "error";
83
82
 
83
+ type SessionUsage = {
84
+ input?: unknown;
85
+ output?: unknown;
86
+ cacheRead?: unknown;
87
+ cacheWrite?: unknown;
88
+ cost?: unknown;
89
+ };
90
+
84
91
  type SessionEntry = {
85
92
  type?: string;
86
93
  id?: string | number;
87
94
  timestamp?: string | number;
95
+ usage?: SessionUsage;
88
96
  message?: {
89
97
  role?: string;
90
- usage?: AssistantMessage["usage"];
98
+ usage?: SessionUsage;
91
99
  };
92
100
  };
93
101
 
102
+ type SelectedUsage = {
103
+ usage: SessionUsage | undefined;
104
+ location: "message" | "entry";
105
+ isAssistant: boolean;
106
+ };
107
+
94
108
  type UsageCacheEntry = {
95
109
  key: string;
96
110
  totals: UsageTotals;
97
111
  };
98
112
 
113
+ const MAX_USAGE_TOTAL = Number.MAX_VALUE;
114
+
99
115
  let usageTotalsCache: UsageCacheEntry | undefined;
100
- let usageTotalsComputeCount = 0;
116
+ let usageTotalsAggregationPassCount = 0;
101
117
 
102
118
  export function formatCount(value: number): string {
103
119
  if (value < 1000) return value.toString();
@@ -130,26 +146,78 @@ function calculateCacheHitRate(
130
146
  cacheWrite: number,
131
147
  ): number | undefined {
132
148
  const promptTokens = input + cacheRead + cacheWrite;
133
- return promptTokens > 0 ? (cacheRead / promptTokens) * 100 : undefined;
149
+ if (promptTokens === 0) return undefined;
150
+ if (Number.isFinite(promptTokens)) return (cacheRead / promptTokens) * 100;
151
+
152
+ const scale = Math.max(input, cacheRead, cacheWrite);
153
+ const scaledPromptTokens = input / scale + cacheRead / scale + cacheWrite / scale;
154
+ return (cacheRead / scale / scaledPromptTokens) * 100;
155
+ }
156
+
157
+ function normalizeUsageNumber(value: unknown): number {
158
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : 0;
159
+ }
160
+
161
+ function usageCostTotal(usage: SessionUsage | undefined): number {
162
+ if (typeof usage?.cost !== "object" || usage.cost === null) return 0;
163
+ return normalizeUsageNumber((usage.cost as { total?: unknown }).total);
164
+ }
165
+
166
+ function addUsageTotal(total: number, value: number): number {
167
+ const sum = total + value;
168
+ return Number.isFinite(sum) ? sum : MAX_USAGE_TOTAL;
169
+ }
170
+
171
+ function usageForEntry(entry: SessionEntry): SelectedUsage | undefined {
172
+ if (entry.type === "message") {
173
+ const role = entry.message?.role;
174
+ if (role !== "assistant" && role !== "toolResult") return undefined;
175
+ return {
176
+ usage: entry.message?.usage,
177
+ location: "message",
178
+ isAssistant: role === "assistant",
179
+ };
180
+ }
181
+ if (entry.type === "compaction" || entry.type === "branch_summary") {
182
+ return { usage: entry.usage, location: "entry", isAssistant: false };
183
+ }
184
+ return undefined;
185
+ }
186
+
187
+ function normalizedUsage(usage: SessionUsage | undefined) {
188
+ return {
189
+ input: normalizeUsageNumber(usage?.input),
190
+ output: normalizeUsageNumber(usage?.output),
191
+ cacheRead: normalizeUsageNumber(usage?.cacheRead),
192
+ cacheWrite: normalizeUsageNumber(usage?.cacheWrite),
193
+ cost: usageCostTotal(usage),
194
+ };
134
195
  }
135
196
 
136
- function entryIdentity(entry: SessionEntry | undefined): string {
137
- if (!entry) return "";
138
- const usage = entry.message?.usage;
139
- const usageKey = usage
140
- ? `${usage.input ?? 0}:${usage.output ?? 0}:${usage.cacheRead ?? 0}:${usage.cacheWrite ?? 0}:${usage.cost?.total ?? 0}`
141
- : "";
142
- return `${entry.id ?? ""}|${entry.timestamp ?? ""}|${entry.type ?? ""}|${entry.message?.role ?? ""}|${usageKey}`;
197
+ function entryIdentity(entry: SessionEntry): string {
198
+ const selected = usageForEntry(entry);
199
+ if (!selected) return "unsupported";
200
+ const usage = normalizedUsage(selected.usage);
201
+ return JSON.stringify([
202
+ entry.id ?? null,
203
+ entry.timestamp ?? null,
204
+ entry.type ?? null,
205
+ entry.message?.role ?? null,
206
+ selected.location,
207
+ usage.input,
208
+ usage.output,
209
+ usage.cacheRead,
210
+ usage.cacheWrite,
211
+ usage.cost,
212
+ ]);
143
213
  }
144
214
 
145
215
  function buildUsageFingerprint(entries: readonly SessionEntry[]): string {
146
- const first = entries[0];
147
- const last = entries[entries.length - 1];
148
- return `${entries.length}\0${entryIdentity(first)}\0${entryIdentity(last)}`;
216
+ return entries.map(entryIdentity).join("\0");
149
217
  }
150
218
 
151
219
  function computeUsageTotals(entries: readonly SessionEntry[]): UsageTotals {
152
- usageTotalsComputeCount += 1;
220
+ usageTotalsAggregationPassCount += 1;
153
221
  let input = 0;
154
222
  let output = 0;
155
223
  let cacheRead = 0;
@@ -158,18 +226,18 @@ function computeUsageTotals(entries: readonly SessionEntry[]): UsageTotals {
158
226
  let cost = 0;
159
227
 
160
228
  for (const entry of entries) {
161
- if (entry.type !== "message" || entry.message?.role !== "assistant") continue;
162
- const usage = entry.message.usage;
163
- const entryInput = usage?.input ?? 0;
164
- const entryCacheRead = usage?.cacheRead ?? 0;
165
- const entryCacheWrite = usage?.cacheWrite ?? 0;
166
-
167
- input += entryInput;
168
- output += usage?.output ?? 0;
169
- cacheRead += entryCacheRead;
170
- cacheWrite += entryCacheWrite;
171
- cost += usage?.cost?.total ?? 0;
172
- latestCacheHitRate = calculateCacheHitRate(entryInput, entryCacheRead, entryCacheWrite);
229
+ const selected = usageForEntry(entry);
230
+ if (!selected) continue;
231
+ const usage = normalizedUsage(selected.usage);
232
+
233
+ input = addUsageTotal(input, usage.input);
234
+ output = addUsageTotal(output, usage.output);
235
+ cacheRead = addUsageTotal(cacheRead, usage.cacheRead);
236
+ cacheWrite = addUsageTotal(cacheWrite, usage.cacheWrite);
237
+ cost = addUsageTotal(cost, usage.cost);
238
+ if (selected.isAssistant) {
239
+ latestCacheHitRate = calculateCacheHitRate(usage.input, usage.cacheRead, usage.cacheWrite);
240
+ }
173
241
  }
174
242
 
175
243
  return Object.freeze({ input, output, cacheRead, cacheWrite, latestCacheHitRate, cost });
@@ -179,23 +247,26 @@ export function invalidateUsageTotalsCache(): void {
179
247
  usageTotalsCache = undefined;
180
248
  }
181
249
 
182
- /** Test helper: number of full usage scans performed since process start / last reset. */
183
- export function __usageTotalsComputeCount(): number {
184
- return usageTotalsComputeCount;
250
+ /** Test helper: counts aggregation passes only; every cache lookup still fingerprints all entries. */
251
+ export function __usageTotalsAggregationPassCount(): number {
252
+ return usageTotalsAggregationPassCount;
185
253
  }
186
254
 
187
- /** Test helper: reset memoization counters/cache. */
255
+ /** Test helper: reset the aggregation-pass counter and cached totals. */
188
256
  export function __resetUsageTotalsCacheForTests(): void {
189
257
  usageTotalsCache = undefined;
190
- usageTotalsComputeCount = 0;
258
+ usageTotalsAggregationPassCount = 0;
191
259
  }
192
260
 
193
261
  export function getUsageTotals(ctx: ExtensionContext): UsageTotals {
194
262
  const sessionManager = ctx.sessionManager as {
195
- getEntries?: () => SessionEntry[];
196
- getBranch: () => SessionEntry[];
263
+ getEntries?: () => readonly SessionEntry[];
264
+ getBranch: () => readonly SessionEntry[];
197
265
  };
198
- const entries = sessionManager.getEntries?.() ?? sessionManager.getBranch();
266
+ const entries =
267
+ typeof sessionManager.getEntries === "function"
268
+ ? sessionManager.getEntries()
269
+ : sessionManager.getBranch();
199
270
  const key = buildUsageFingerprint(entries);
200
271
  if (usageTotalsCache?.key === key) return usageTotalsCache.totals;
201
272
 
@@ -42,6 +42,10 @@ import {
42
42
  saveUiFeaturesPatch,
43
43
  type UiFeaturesConfig,
44
44
  } from "./config";
45
+ import {
46
+ type EditorTransferFailureReason,
47
+ replaceEditorComponentWithExpandedText,
48
+ } from "./editor-transfer";
45
49
  import {
46
50
  disposeFixedEditor,
47
51
  installFixedEditorProbe,
@@ -80,10 +84,30 @@ type ZentuiEditorFactory = EditorFactory & {
80
84
 
81
85
  type ApplyUiResult = {
82
86
  editorBlocked: boolean;
87
+ editorReason?: string;
83
88
  };
84
89
 
90
+ type EditorChangeResult = { ok: true } | { ok: false; reason: string };
91
+
85
92
  type EditorInstallMode = "none" | "standalone" | "wrapper";
86
93
 
94
+ function editorTransferFailureMessage(reason: EditorTransferFailureReason): string {
95
+ switch (reason) {
96
+ case "unsupported-transfer-api":
97
+ return "this Pi version cannot safely transfer expanded editor text; reload Pi to apply this change";
98
+ case "editor-factory-snapshot-failed":
99
+ return "the current editor factory could not be read safely; reload Pi to apply this change";
100
+ case "editor-text-snapshot-failed":
101
+ return "expanded editor text could not be read safely; reload Pi to apply this change";
102
+ case "editor-text-preparation-failed":
103
+ return "expanded editor text could not be prepared safely; reload Pi to apply this change";
104
+ case "editor-replacement-failed-with-rollback":
105
+ return "the editor replacement failed; the previous factory was reapplied, but editor instance identity is not guaranteed";
106
+ case "editor-replacement-rollback-failed":
107
+ return "the editor replacement and previous-factory rollback both failed; reload Pi before editing";
108
+ }
109
+ }
110
+
87
111
  function isZentuiEditorFactory(factory: EditorFactory | undefined): boolean {
88
112
  return Boolean((factory as ZentuiEditorFactory | undefined)?.[ZENTUI_EDITOR_FACTORY]);
89
113
  }
@@ -266,15 +290,28 @@ export default function (pi: ExtensionAPI) {
266
290
  if (footerInstalled) scheduleProjectRefresh(ctx, { force: true });
267
291
  };
268
292
 
269
- const installPrototypePatches = () => {
270
- if (prototypePatchesInstalled) return;
271
- const cleanupSelectorBorderStyle = installSelectorBorderStyle(getActiveTheme, getCurrentConfig);
272
- const cleanupUserMessageStyle = installUserMessageStyle(getActiveTheme, getCurrentConfig);
273
- cleanupPrototypePatches = () => {
274
- cleanupSelectorBorderStyle();
275
- cleanupUserMessageStyle();
276
- };
277
- prototypePatchesInstalled = true;
293
+ const installPrototypePatches = (): boolean => {
294
+ if (prototypePatchesInstalled) return true;
295
+ let cleanupSelectorBorderStyle: (() => void) | undefined;
296
+ try {
297
+ cleanupSelectorBorderStyle = installSelectorBorderStyle(getActiveTheme, getCurrentConfig);
298
+ const cleanupUserMessageStyle = installUserMessageStyle(getActiveTheme, getCurrentConfig);
299
+ cleanupPrototypePatches = () => {
300
+ cleanupSelectorBorderStyle?.();
301
+ cleanupUserMessageStyle();
302
+ };
303
+ prototypePatchesInstalled = true;
304
+ return true;
305
+ } catch {
306
+ try {
307
+ cleanupSelectorBorderStyle?.();
308
+ } catch {
309
+ // Best effort: each installer is locally transactional as well.
310
+ }
311
+ cleanupPrototypePatches = () => {};
312
+ prototypePatchesInstalled = false;
313
+ return false;
314
+ }
278
315
  };
279
316
 
280
317
  const uninstallPrototypePatches = () => {
@@ -283,6 +320,43 @@ export default function (pi: ExtensionAPI) {
283
320
  prototypePatchesInstalled = false;
284
321
  };
285
322
 
323
+ const clearEditorOwnership = () => {
324
+ wrappedEditorFactory = undefined;
325
+ installedEditorFactory = undefined;
326
+ editorInstallMode = "none";
327
+ editorInstalled = false;
328
+ };
329
+
330
+ const trackZentuiEditorFactory = (factory: EditorFactory) => {
331
+ const baseFactory = getZentuiEditorBaseFactory(factory);
332
+ wrappedEditorFactory = baseFactory;
333
+ installedEditorFactory = factory;
334
+ editorInstallMode = baseFactory ? "wrapper" : "standalone";
335
+ editorInstalled = true;
336
+ };
337
+
338
+ const observeEditorFactory = (
339
+ ctx: ExtensionContext,
340
+ ): { known: true; factory: EditorFactory | undefined } | { known: false } => {
341
+ try {
342
+ return { known: true, factory: ctx.ui.getEditorComponent() };
343
+ } catch {
344
+ return { known: false };
345
+ }
346
+ };
347
+
348
+ const reconcileObservedEditorOwnership = (ctx: ExtensionContext) => {
349
+ const observed = observeEditorFactory(ctx);
350
+ if (!observed.known) return observed;
351
+ if (observed.factory && isZentuiEditorFactory(observed.factory)) {
352
+ trackZentuiEditorFactory(observed.factory);
353
+ } else {
354
+ uninstallPrototypePatches();
355
+ clearEditorOwnership();
356
+ }
357
+ return observed;
358
+ };
359
+
286
360
  const makeEditorFactory = (ctx: ExtensionContext): ZentuiEditorFactory => {
287
361
  const sessionTheme = ctx.ui.theme;
288
362
  const factory = ((tui: TUI, theme: EditorTheme, keybindings: KeybindingsManager) =>
@@ -329,53 +403,73 @@ export default function (pi: ExtensionAPI) {
329
403
  return factory;
330
404
  };
331
405
 
332
- const installEditor = (ctx: ExtensionContext): boolean => {
406
+ const replaceEditor = (
407
+ ctx: ExtensionContext,
408
+ factory: EditorFactory | undefined,
409
+ ): EditorChangeResult => {
410
+ const result = replaceEditorComponentWithExpandedText(ctx.ui, factory);
411
+ return result.ok ? result : { ok: false, reason: editorTransferFailureMessage(result.reason) };
412
+ };
413
+
414
+ const installEditor = (ctx: ExtensionContext): EditorChangeResult => {
333
415
  const currentFactory = ctx.ui.getEditorComponent();
334
416
  if (currentFactory && currentFactory === installedEditorFactory) {
335
417
  editorInstalled = true;
336
- return true;
418
+ return { ok: true };
337
419
  }
338
420
 
339
- installPrototypePatches();
340
421
  const currentZentuiBaseFactory = getZentuiEditorBaseFactory(currentFactory);
341
- if (currentFactory && isZentuiEditorFactory(currentFactory)) {
342
- wrappedEditorFactory = currentZentuiBaseFactory;
343
- const nextFactory = currentZentuiBaseFactory
344
- ? makeWrappedEditorFactory(ctx, currentZentuiBaseFactory)
345
- : makeEditorFactory(ctx);
346
- ctx.ui.setEditorComponent(nextFactory);
347
- installedEditorFactory = nextFactory;
348
- editorInstallMode = currentZentuiBaseFactory ? "wrapper" : "standalone";
349
- } else if (currentFactory) {
350
- wrappedEditorFactory = currentFactory;
351
- const nextFactory = makeWrappedEditorFactory(ctx, currentFactory);
352
- ctx.ui.setEditorComponent(nextFactory);
353
- installedEditorFactory = nextFactory;
354
- editorInstallMode = "wrapper";
355
- } else {
356
- wrappedEditorFactory = undefined;
357
- const nextFactory = makeEditorFactory(ctx);
358
- ctx.ui.setEditorComponent(nextFactory);
359
- installedEditorFactory = nextFactory;
360
- editorInstallMode = "standalone";
422
+ const baseFactory =
423
+ currentZentuiBaseFactory ??
424
+ (currentFactory && !isZentuiEditorFactory(currentFactory) ? currentFactory : undefined);
425
+ const nextFactory = baseFactory
426
+ ? makeWrappedEditorFactory(ctx, baseFactory)
427
+ : makeEditorFactory(ctx);
428
+ const replacement = replaceEditor(ctx, nextFactory);
429
+ if (!replacement.ok) return replacement;
430
+ if (!installPrototypePatches()) {
431
+ const rollback = replaceEditor(ctx, currentFactory);
432
+ reconcileObservedEditorOwnership(ctx);
433
+ return {
434
+ ok: false,
435
+ reason: rollback.ok
436
+ ? "editor chrome patch installation failed; the previous factory was reapplied, but editor instance identity is not guaranteed"
437
+ : "editor chrome patch installation failed and the previous factory could not be restored; reload Pi before editing",
438
+ };
361
439
  }
362
- editorInstalled = true;
363
- return true;
440
+
441
+ trackZentuiEditorFactory(nextFactory);
442
+ return { ok: true };
364
443
  };
365
444
 
366
- const uninstallEditor = (ctx: ExtensionContext): boolean => {
367
- const currentFactory = ctx.ui.getEditorComponent();
368
- if (currentFactory && !isZentuiEditorFactory(currentFactory)) return false;
445
+ const uninstallEditor = (ctx: ExtensionContext): EditorChangeResult => {
446
+ const observed = observeEditorFactory(ctx);
447
+ if (!observed.known) {
448
+ return {
449
+ ok: false,
450
+ reason:
451
+ "the current editor factory could not be observed safely; reload Pi to apply this change",
452
+ };
453
+ }
454
+ const currentFactory = observed.factory;
455
+ if (!currentFactory || !isZentuiEditorFactory(currentFactory)) {
456
+ uninstallPrototypePatches();
457
+ clearEditorOwnership();
458
+ return { ok: true };
459
+ }
369
460
 
370
- uninstallPrototypePatches();
371
- ctx.ui.setEditorComponent(
372
- editorInstallMode === "wrapper" && wrappedEditorFactory ? wrappedEditorFactory : undefined,
461
+ const replacement = replaceEditor(
462
+ ctx,
463
+ getZentuiEditorBaseFactory(currentFactory) ??
464
+ (editorInstallMode === "wrapper" && wrappedEditorFactory
465
+ ? wrappedEditorFactory
466
+ : undefined),
373
467
  );
374
- wrappedEditorFactory = undefined;
375
- installedEditorFactory = undefined;
376
- editorInstallMode = "none";
377
- editorInstalled = false;
378
- return true;
468
+ if (!replacement.ok) return replacement;
469
+
470
+ uninstallPrototypePatches();
471
+ clearEditorOwnership();
472
+ return { ok: true };
379
473
  };
380
474
 
381
475
  const installStatusLine = (ctx: ExtensionContext) => {
@@ -413,12 +507,17 @@ export default function (pi: ExtensionAPI) {
413
507
  const result: ApplyUiResult = { editorBlocked: false };
414
508
  if (!isTuiContext(ctx)) return result;
415
509
  activeTheme = ctx.ui.theme;
510
+ let editorChange: EditorChangeResult | undefined;
416
511
  if (currentConfig.features.editor) {
417
512
  const currentFactory = ctx.ui.getEditorComponent();
418
513
  const editorMissingOrReplaced = !editorInstalled || !isZentuiEditorFactory(currentFactory);
419
- if (editorMissingOrReplaced) result.editorBlocked = !installEditor(ctx);
514
+ if (editorMissingOrReplaced) editorChange = installEditor(ctx);
420
515
  } else if (editorInstalled || prototypePatchesInstalled) {
421
- result.editorBlocked = !uninstallEditor(ctx);
516
+ editorChange = uninstallEditor(ctx);
517
+ }
518
+ if (editorChange && !editorChange.ok) {
519
+ result.editorBlocked = true;
520
+ result.editorReason = editorChange.reason;
422
521
  }
423
522
 
424
523
  if (currentConfig.features.statusLine) {
@@ -450,11 +549,16 @@ export default function (pi: ExtensionAPI) {
450
549
  const scheduleEditorReconciliation = (ctx: ExtensionContext) => {
451
550
  sessionLifecycle.defer(() => {
452
551
  if (!isTuiContext(ctx) || !currentConfig.features.editor) return;
453
- const currentFactory = ctx.ui.getEditorComponent();
454
- if (currentFactory && currentFactory !== installedEditorFactory) {
455
- applyConfiguredUi(ctx);
552
+ const observed = observeEditorFactory(ctx);
553
+ if (!observed.known || observed.factory === installedEditorFactory) return;
554
+ if (!observed.factory || !isZentuiEditorFactory(observed.factory)) {
555
+ uninstallPrototypePatches();
556
+ clearEditorOwnership();
456
557
  refresh();
558
+ return;
457
559
  }
560
+ trackZentuiEditorFactory(observed.factory);
561
+ refresh();
458
562
  });
459
563
  };
460
564
 
@@ -464,28 +568,29 @@ export default function (pi: ExtensionAPI) {
464
568
  try {
465
569
  disposeFixedEditor(ctx);
466
570
  if (isTuiContext(ctx)) removeFixedEditorProbe(ctx);
467
- uninstallPrototypePatches();
468
571
  stopSessionTimer();
469
572
  stopProjectRefresh();
470
573
  requestFooterRender = undefined;
471
574
  getActiveExtensionStatuses = () => new Map();
472
575
  if (isTuiContext(ctx)) {
473
576
  ctx.ui.setFooter(undefined);
474
- const currentFactory = ctx.ui.getEditorComponent();
475
- if (!currentFactory || isZentuiEditorFactory(currentFactory)) {
476
- ctx.ui.setEditorComponent(
477
- getZentuiEditorBaseFactory(currentFactory) ??
478
- (editorInstallMode === "wrapper" && wrappedEditorFactory
479
- ? wrappedEditorFactory
480
- : undefined),
481
- );
577
+ const before = observeEditorFactory(ctx);
578
+ if (before.known) {
579
+ const currentFactory = before.factory;
580
+ if (currentFactory && isZentuiEditorFactory(currentFactory)) {
581
+ replaceEditor(
582
+ ctx,
583
+ getZentuiEditorBaseFactory(currentFactory) ??
584
+ (editorInstallMode === "wrapper" && wrappedEditorFactory
585
+ ? wrappedEditorFactory
586
+ : undefined),
587
+ );
588
+ }
589
+ reconcileObservedEditorOwnership(ctx);
482
590
  }
483
591
  }
484
- wrappedEditorFactory = undefined;
485
- installedEditorFactory = undefined;
486
- editorInstallMode = "none";
592
+ uninstallPrototypePatches();
487
593
  footerInstalled = false;
488
- editorInstalled = false;
489
594
  activeTheme = undefined;
490
595
  } finally {
491
596
  requestFooterRender = undefined;
@@ -520,9 +625,7 @@ export default function (pi: ExtensionAPI) {
520
625
  const result = applyConfiguredUi(ctx);
521
626
  return {
522
627
  applied: !(patch.editor !== undefined && result.editorBlocked),
523
- reason: result.editorBlocked
524
- ? "another extension is currently managing the editor; reload Pi to apply this change"
525
- : undefined,
628
+ reason: result.editorBlocked ? result.editorReason : undefined,
526
629
  };
527
630
  },
528
631
  setFooterSegments(patch: Partial<FooterSegmentsConfig>, ctx: ExtensionContext) {
@@ -79,6 +79,7 @@ export function installPrototypePatch(
79
79
  if (!(record && record.method === method && target[method] === record.wrapper)) {
80
80
  const predecessor = target[method];
81
81
  if (typeof predecessor !== "function") {
82
+ if (registry.size === 0) delete target[ZENTUI_PROTOTYPE_PATCH_REGISTRY];
82
83
  throw new TypeError(`Cannot patch ${method}: predecessor is not a function`);
83
84
  }
84
85
  const nextRecord: PatchRecord = {
@@ -98,7 +99,13 @@ export function installPrototypePatch(
98
99
  nextRecord.wrapper = wrapper;
99
100
  record = nextRecord;
100
101
  registry.set(adapter, record);
101
- target[method] = wrapper;
102
+ try {
103
+ target[method] = wrapper;
104
+ } catch (error) {
105
+ registry.delete(adapter);
106
+ if (registry.size === 0) delete target[ZENTUI_PROTOTYPE_PATCH_REGISTRY];
107
+ throw error;
108
+ }
102
109
  }
103
110
 
104
111
  const token = Symbol(adapter);
@@ -65,11 +65,17 @@ export function installSelectorBorderStyle(
65
65
  getTheme,
66
66
  getConfig,
67
67
  );
68
- const cleanupSettings = patchSelectorBorderStyle(
69
- SettingsSelectorComponent.prototype as unknown as PatchableSelectorPrototype,
70
- getTheme,
71
- getConfig,
72
- );
68
+ let cleanupSettings: Cleanup;
69
+ try {
70
+ cleanupSettings = patchSelectorBorderStyle(
71
+ SettingsSelectorComponent.prototype as unknown as PatchableSelectorPrototype,
72
+ getTheme,
73
+ getConfig,
74
+ );
75
+ } catch (error) {
76
+ cleanupModel();
77
+ throw error;
78
+ }
73
79
  return () => {
74
80
  cleanupModel();
75
81
  cleanupSettings();
@@ -17,7 +17,7 @@ import {
17
17
  safeThemeFg,
18
18
  } from "./style";
19
19
 
20
- const SPLIT_POLISHED_FRAME: unique symbol = Symbol.for("pi-zentui.polished-frame");
20
+ const LEGACY_SPLIT_POLISHED_FRAME = Symbol.for("pi-zentui.polished-frame");
21
21
 
22
22
  type ViewportCounts = {
23
23
  above?: string;
@@ -30,7 +30,12 @@ type PolishedFrameSplit = {
30
30
  viewport: ViewportCounts;
31
31
  };
32
32
 
33
- const POLISHED_FRAME_SPLITS = new WeakMap<string[], PolishedFrameSplit>();
33
+ type PolishedFrameProvenance = {
34
+ rows: readonly string[];
35
+ split: PolishedFrameSplit;
36
+ };
37
+
38
+ const POLISHED_FRAME_SPLITS = new WeakMap<string[], PolishedFrameProvenance>();
34
39
 
35
40
  type AutocompleteEditorInternals = {
36
41
  autocompleteList?: Pick<Component, "render">;
@@ -58,7 +63,6 @@ type WrappedEditor = EditorComponent &
58
63
  setAutocompleteProvider?: (provider: AutocompleteProvider) => void;
59
64
  setPaddingX?: (padding: number) => void;
60
65
  setAutocompleteMaxVisible?: (maxVisible: number) => void;
61
- [SPLIT_POLISHED_FRAME]?: (lines: string[]) => PolishedFrameSplit | undefined;
62
66
  };
63
67
 
64
68
  type EditorMeta = {
@@ -78,9 +82,58 @@ type PolishedFrameOptions = {
78
82
  modelMeta: EditorMeta;
79
83
  thinkingLevel: string | undefined;
80
84
  rightStatus?: string;
81
- splitBaseFrame?: (lines: string[]) => PolishedFrameSplit | undefined;
85
+ ownedFrame?: PolishedFrameSplit;
86
+ trustedBaseFrame?: boolean;
82
87
  };
83
88
 
89
+ type PolishedFrameResult = { lines: string[]; decorated: boolean };
90
+
91
+ type AutocompleteCount = { known: true; count: number } | { known: false };
92
+
93
+ function isStringArray(value: unknown): value is string[] {
94
+ return Array.isArray(value) && value.every((line) => typeof line === "string");
95
+ }
96
+
97
+ function isViewportCounts(value: unknown): value is ViewportCounts {
98
+ if (!value || typeof value !== "object") return false;
99
+ const counts = value as Record<string, unknown>;
100
+ return [counts.above, counts.below].every(
101
+ (count) => count === undefined || (typeof count === "string" && /^[1-9]\d*$/.test(count)),
102
+ );
103
+ }
104
+
105
+ function isPolishedFrameSplit(value: unknown, baseLineCount: number): value is PolishedFrameSplit {
106
+ if (!value || typeof value !== "object") return false;
107
+ const split = value as Record<string, unknown>;
108
+ return (
109
+ isStringArray(split.editorLines) &&
110
+ isStringArray(split.trailingLines) &&
111
+ split.trailingLines.length <= baseLineCount &&
112
+ isViewportCounts(split.viewport)
113
+ );
114
+ }
115
+
116
+ function readAutocompleteCount(
117
+ source: AutocompleteEditorInternals,
118
+ width: number,
119
+ baseLineCount: number,
120
+ ): AutocompleteCount {
121
+ try {
122
+ const showing = source.isShowingAutocomplete;
123
+ if (typeof showing !== "function") return { known: true, count: 0 };
124
+ if (!showing.call(source)) return { known: true, count: 0 };
125
+ const list = source.autocompleteList;
126
+ if (!list || typeof list.render !== "function") return { known: false };
127
+ const rendered = list.render(width);
128
+ if (!isStringArray(rendered) || rendered.length <= 0 || rendered.length >= baseLineCount) {
129
+ return { known: false };
130
+ }
131
+ return { known: true, count: rendered.length };
132
+ } catch {
133
+ return { known: false };
134
+ }
135
+ }
136
+
84
137
  function clampRenderedLines(lines: string[], width: number): string[] {
85
138
  const maxWidth = Math.max(0, width);
86
139
  return lines.map((line) => truncateToWidth(line, maxWidth, ""));
@@ -264,42 +317,49 @@ function renderPolishedFrame({
264
317
  modelMeta,
265
318
  thinkingLevel,
266
319
  rightStatus,
267
- splitBaseFrame,
268
- }: PolishedFrameOptions): string[] {
269
- if (width <= 2) return clampRenderedLines(baseRendered, width);
320
+ ownedFrame,
321
+ trustedBaseFrame = false,
322
+ }: PolishedFrameOptions): PolishedFrameResult {
323
+ if (width <= 2) return { lines: clampRenderedLines(baseRendered, width), decorated: false };
270
324
 
271
325
  const reset = "\x1b[0m";
272
326
  const colorSource = config.colorSources.editor;
273
327
  const { prompt, promptWidth, rail, railWidth } = getEditorChromeWidths(config, uiTheme, reset);
274
328
  const innerWidth = Math.max(0, width - railWidth);
275
329
  const copyFriendlyContinuation = " ".repeat(promptWidth);
276
- const isShowingAutocomplete =
277
- typeof autocompleteSource.isShowingAutocomplete === "function"
278
- ? Boolean(autocompleteSource.isShowingAutocomplete())
279
- : false;
280
-
281
- if (baseRendered.length < 2) return clampRenderedLines(baseRendered, width);
282
-
283
- const ownedFrame = splitBaseFrame?.(baseRendered);
284
- const { autocompleteList } = autocompleteSource;
285
- const autocompleteCount =
286
- !ownedFrame && isShowingAutocomplete && typeof autocompleteList?.render === "function"
287
- ? autocompleteList.render(innerWidth).length
288
- : 0;
330
+
331
+ if (baseRendered.length < 2) {
332
+ return { lines: clampRenderedLines(baseRendered, width), decorated: false };
333
+ }
334
+ if (ownedFrame && !isPolishedFrameSplit(ownedFrame, baseRendered.length)) {
335
+ return { lines: clampRenderedLines(baseRendered, width), decorated: false };
336
+ }
337
+
338
+ const autocomplete = ownedFrame
339
+ ? { known: true, count: ownedFrame.trailingLines.length }
340
+ : readAutocompleteCount(autocompleteSource, innerWidth, baseRendered.length);
341
+ if (!autocomplete.known) {
342
+ return { lines: clampRenderedLines(baseRendered, width), decorated: false };
343
+ }
289
344
  const editorFrame =
290
- !ownedFrame && autocompleteCount > 0 && autocompleteCount < baseRendered.length
291
- ? baseRendered.slice(0, -autocompleteCount)
345
+ !ownedFrame && autocomplete.count > 0
346
+ ? baseRendered.slice(0, -autocomplete.count)
292
347
  : baseRendered;
293
348
  const autocompleteLines = ownedFrame
294
349
  ? ownedFrame.trailingLines
295
- : autocompleteCount > 0 && autocompleteCount < baseRendered.length
296
- ? baseRendered.slice(-autocompleteCount)
350
+ : autocomplete.count > 0
351
+ ? baseRendered.slice(-autocomplete.count)
297
352
  : [];
298
- if (editorFrame.length < 2) return clampRenderedLines(baseRendered, width);
353
+ if (editorFrame.length < 2) {
354
+ return { lines: clampRenderedLines(baseRendered, width), decorated: false };
355
+ }
299
356
 
300
- const editorLines = ownedFrame?.editorLines ?? editorFrame.slice(1, -1);
301
357
  const parsedTop = parseEditorBorder(editorFrame[0] ?? "", "above");
302
358
  const parsedBottom = parseEditorBorder(editorFrame.at(-1) ?? "", "below");
359
+ if (!ownedFrame && !trustedBaseFrame && (!parsedTop || !parsedBottom)) {
360
+ return { lines: clampRenderedLines(baseRendered, width), decorated: false };
361
+ }
362
+ const editorLines = ownedFrame?.editorLines ?? editorFrame.slice(1, -1);
303
363
  const viewport = ownedFrame?.viewport ?? {
304
364
  above: parsedTop?.count,
305
365
  below: parsedBottom?.count,
@@ -365,11 +425,14 @@ function renderPolishedFrame({
365
425
 
366
426
  const clamped = clampRenderedLines(renderedLines, width);
367
427
  POLISHED_FRAME_SPLITS.set(clamped, {
368
- editorLines,
369
- trailingLines: autocompleteLines.length > 0 ? clamped.slice(-autocompleteLines.length) : [],
370
- viewport,
428
+ rows: Object.freeze([...clamped]),
429
+ split: {
430
+ editorLines,
431
+ trailingLines: autocompleteLines.length > 0 ? clamped.slice(-autocompleteLines.length) : [],
432
+ viewport,
433
+ },
371
434
  });
372
- return clamped;
435
+ return { lines: clamped, decorated: true };
373
436
  }
374
437
 
375
438
  export class PolishedEditor extends CustomEditor {
@@ -404,34 +467,53 @@ export class PolishedEditor extends CustomEditor {
404
467
  const { railWidth } = getEditorChromeWidths(config, this.uiTheme, "\x1b[0m");
405
468
  const innerWidth = Math.max(0, width - railWidth);
406
469
  const rendered = super.render(innerWidth);
407
- const modelMeta = this.getModelMeta();
408
- const result = renderPolishedFrame({
409
- width,
410
- baseRendered: rendered,
411
- autocompleteSource: this as unknown as AutocompleteEditorInternals,
412
- uiTheme: this.uiTheme,
413
- config,
414
- modelMeta,
415
- thinkingLevel: this.getThinkingLevel(),
416
- });
417
- return result;
418
- }
419
-
420
- [SPLIT_POLISHED_FRAME](lines: string[]): PolishedFrameSplit | undefined {
421
- return (
422
- POLISHED_FRAME_SPLITS.get(lines) ?? splitPolishedFrame(lines, this.getConfig(), this.uiTheme)
423
- );
470
+ try {
471
+ return renderPolishedFrame({
472
+ width,
473
+ baseRendered: rendered,
474
+ autocompleteSource: this as unknown as AutocompleteEditorInternals,
475
+ uiTheme: this.uiTheme,
476
+ config,
477
+ modelMeta: this.getModelMeta(),
478
+ thinkingLevel: this.getThinkingLevel(),
479
+ trustedBaseFrame: true,
480
+ }).lines;
481
+ } catch {
482
+ return clampRenderedLines(rendered, width);
483
+ }
424
484
  }
425
485
  }
426
486
 
427
487
  export class WrappedPolishedEditor implements EditorComponent {
488
+ declare readonly addToHistory?: (text: string) => void;
489
+ declare readonly insertTextAtCursor?: (text: string) => void;
490
+ declare readonly setAutocompleteProvider?: (provider: AutocompleteProvider) => void;
491
+ declare readonly setPaddingX?: (padding: number) => void;
492
+ declare readonly setAutocompleteMaxVisible?: (maxVisible: number) => void;
493
+
428
494
  constructor(
429
495
  private readonly base: WrappedEditor,
430
496
  private readonly uiTheme: Theme,
431
497
  private readonly getConfig: () => PolishedTuiConfig,
432
498
  private readonly getModelMeta: () => EditorMeta,
433
499
  private readonly getThinkingLevel: () => string | undefined,
434
- ) {}
500
+ ) {
501
+ if (typeof base.addToHistory === "function") {
502
+ this.addToHistory = (text) => base.addToHistory?.(text);
503
+ }
504
+ if (typeof base.insertTextAtCursor === "function") {
505
+ this.insertTextAtCursor = (text) => base.insertTextAtCursor?.(text);
506
+ }
507
+ if (typeof base.setAutocompleteProvider === "function") {
508
+ this.setAutocompleteProvider = (provider) => base.setAutocompleteProvider?.(provider);
509
+ }
510
+ if (typeof base.setPaddingX === "function") {
511
+ this.setPaddingX = (padding) => base.setPaddingX?.(padding);
512
+ }
513
+ if (typeof base.setAutocompleteMaxVisible === "function") {
514
+ this.setAutocompleteMaxVisible = (maxVisible) => base.setAutocompleteMaxVisible?.(maxVisible);
515
+ }
516
+ }
435
517
 
436
518
  get focused(): boolean {
437
519
  return Boolean(this.base.focused);
@@ -516,26 +598,42 @@ export class WrappedPolishedEditor implements EditorComponent {
516
598
  const config = this.getConfig();
517
599
  const { railWidth } = getEditorChromeWidths(config, this.uiTheme, "\x1b[0m");
518
600
  const innerWidth = Math.max(0, width - railWidth);
519
- const rendered = this.base.render(innerWidth);
520
- const vimStatus = readVimStatus(this.base, this.uiTheme);
521
- const modelMeta = this.getModelMeta();
522
- return renderPolishedFrame({
523
- width,
524
- baseRendered: rendered,
525
- autocompleteSource: this.base,
526
- uiTheme: this.uiTheme,
527
- config,
528
- modelMeta,
529
- thinkingLevel: this.getThinkingLevel(),
530
- rightStatus: vimStatus,
531
- splitBaseFrame: this.base[SPLIT_POLISHED_FRAME]?.bind(this.base),
532
- });
533
- }
534
-
535
- [SPLIT_POLISHED_FRAME](lines: string[]): PolishedFrameSplit | undefined {
536
- return (
537
- POLISHED_FRAME_SPLITS.get(lines) ?? splitPolishedFrame(lines, this.getConfig(), this.uiTheme)
538
- );
601
+ let rendered: string[];
602
+ try {
603
+ rendered = this.base.render(innerWidth);
604
+ } catch {
605
+ return clampRenderedLines(this.base.render(width), width);
606
+ }
607
+ let result: PolishedFrameResult | undefined;
608
+ try {
609
+ const provenance = POLISHED_FRAME_SPLITS.get(rendered);
610
+ const provenanceMatches = Boolean(
611
+ provenance &&
612
+ provenance.rows.length === rendered.length &&
613
+ provenance.rows.every((line, index) => line === rendered[index]),
614
+ );
615
+ const ownedFrame = provenanceMatches ? provenance?.split : undefined;
616
+ const hasMutatedProvenance = Boolean(provenance && !provenanceMatches);
617
+ const hasUntrustedLegacySplitter = LEGACY_SPLIT_POLISHED_FRAME in this.base;
618
+ const hasUnprovenPolishedFrame =
619
+ !ownedFrame && Boolean(splitPolishedFrame(rendered, config, this.uiTheme));
620
+ if (!hasMutatedProvenance && !hasUntrustedLegacySplitter && !hasUnprovenPolishedFrame) {
621
+ result = renderPolishedFrame({
622
+ width,
623
+ baseRendered: rendered,
624
+ autocompleteSource: this.base,
625
+ uiTheme: this.uiTheme,
626
+ config,
627
+ modelMeta: this.getModelMeta(),
628
+ thinkingLevel: this.getThinkingLevel(),
629
+ rightStatus: readVimStatus(this.base, this.uiTheme),
630
+ ownedFrame,
631
+ });
632
+ }
633
+ } catch {
634
+ // Decoration is optional; re-render the base at the caller's width below.
635
+ }
636
+ return result?.decorated ? result.lines : clampRenderedLines(this.base.render(width), width);
539
637
  }
540
638
 
541
639
  invalidate(): void {
@@ -554,30 +652,10 @@ export class WrappedPolishedEditor implements EditorComponent {
554
652
  this.base.setText(text);
555
653
  }
556
654
 
557
- addToHistory(text: string): void {
558
- this.base.addToHistory?.(text);
559
- }
560
-
561
- insertTextAtCursor(text: string): void {
562
- this.base.insertTextAtCursor?.(text);
563
- }
564
-
565
655
  getExpandedText(): string {
566
656
  return this.base.getExpandedText?.() ?? this.base.getText();
567
657
  }
568
658
 
569
- setAutocompleteProvider(provider: AutocompleteProvider): void {
570
- this.base.setAutocompleteProvider?.(provider);
571
- }
572
-
573
- setPaddingX(padding: number): void {
574
- this.base.setPaddingX?.(padding);
575
- }
576
-
577
- setAutocompleteMaxVisible(maxVisible: number): void {
578
- this.base.setAutocompleteMaxVisible?.(maxVisible);
579
- }
580
-
581
659
  getLines(): string[] {
582
660
  return this.base.getLines?.() ?? this.base.getText().split("\n");
583
661
  }
@@ -225,24 +225,30 @@ export function installUserMessageStyle(
225
225
  return Reflect.apply(predecessor, receiver, args);
226
226
  },
227
227
  );
228
- const cleanupRender = installPrototypePatch(
229
- prototype,
230
- "render",
231
- "user-message-render",
232
- ({ predecessor, receiver, args }) => {
233
- const width = args[0];
234
- if (typeof width !== "number") return Reflect.apply(predecessor, receiver, args);
235
- const lines = renderZentuiUserMessage(
236
- receiver as PatchableUserMessagePrototype,
237
- width,
238
- getTheme(),
239
- getConfig(),
240
- );
241
- if (!lines) return Reflect.apply(predecessor, receiver, args);
242
- if (lines.length === 0) return lines;
243
- return withPromptZoneMarkers(lines);
244
- },
245
- );
228
+ let cleanupRender: Cleanup;
229
+ try {
230
+ cleanupRender = installPrototypePatch(
231
+ prototype,
232
+ "render",
233
+ "user-message-render",
234
+ ({ predecessor, receiver, args }) => {
235
+ const width = args[0];
236
+ if (typeof width !== "number") return Reflect.apply(predecessor, receiver, args);
237
+ const lines = renderZentuiUserMessage(
238
+ receiver as PatchableUserMessagePrototype,
239
+ width,
240
+ getTheme(),
241
+ getConfig(),
242
+ );
243
+ if (!lines) return Reflect.apply(predecessor, receiver, args);
244
+ if (lines.length === 0) return lines;
245
+ return withPromptZoneMarkers(lines);
246
+ },
247
+ );
248
+ } catch (error) {
249
+ cleanupInvalidate();
250
+ throw error;
251
+ }
246
252
  let cleaned = false;
247
253
  return () => {
248
254
  if (cleaned) return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-zentui",
3
- "version": "0.15.0",
3
+ "version": "0.15.1",
4
4
  "description": "A Starship-inspired statusline and Opencode-style TUI for Pi.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -50,9 +50,9 @@
50
50
  },
51
51
  "devDependencies": {
52
52
  "@biomejs/biome": "^2.5.0",
53
- "@earendil-works/pi-ai": "^0.80.6",
54
- "@earendil-works/pi-coding-agent": "^0.80.6",
55
- "@earendil-works/pi-tui": "^0.80.6",
53
+ "@earendil-works/pi-ai": "^0.82.1",
54
+ "@earendil-works/pi-coding-agent": "^0.82.1",
55
+ "@earendil-works/pi-tui": "^0.82.1",
56
56
  "@types/node": "^26.0.0",
57
57
  "typescript": "^7.0.2",
58
58
  "vitest": "^4.1.8"