pi-one-ui 0.4.0 → 0.5.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.
@@ -0,0 +1,333 @@
1
+ /**
2
+ * 工具卡 updateDisplay 跨 toggle 重建缓存(on/compact 共用,off 关闭)。
3
+ *
4
+ * 原生 ToolExecutionComponent.updateDisplay 每次调用都会 clear 后重新调用
5
+ * call/result renderer 生成全新组件,新组件内部行缓存为空,导致折叠/展开
6
+ * 切换(Ctrl+O、group.setExpanded、鼠标点击)每次都要重新 wrap 输出全文,
7
+ * 大输出下可达数百毫秒同步阻塞。
8
+ *
9
+ * 这里按工具实例缓存(collapsed/expanded 两槽)的渲染指纹:内容指纹
10
+ * (args/result/isPartial/executionStarted/argsComplete/showImages/mode/theme)
11
+ * 未变化时跳过原生全量重建;容器内当前槽位与目标槽位不同时,把目标槽构建
12
+ * 时缓存的 call/result 组件重新装入对应壳容器(selfRenderContainer /
13
+ * contentBox / contentText)并恢复派生字段,实现零重建的来回切换。
14
+ *
15
+ * 失效面:result/args 等输入变化(指纹比较)、模式切换(mode 入指纹)、
16
+ * 主题或配置变化(setTheme / clear 由装配层在既有回调中驱动)。
17
+ * fallback 分支(contentText,无 renderer 定义的工具)不做组件级切换,
18
+ * 切槽时退化为原生重建,与现状一致。
19
+ */
20
+ import { ToolExecutionComponent } from "@earendil-works/pi-coding-agent";
21
+ import { config } from "../../../../app/config/renderer.ts";
22
+ import {
23
+ patchRegistry,
24
+ TOGGLE_RENDER_CACHE_PATCH,
25
+ } from "../../../../tools/patch-keys.ts";
26
+ import { scheduleAnimation } from "./result.ts";
27
+
28
+ type RenderSlot = {
29
+ /** 输入指纹(args/result/isPartial 等,不含 expanded)。 */
30
+ inputFingerprint: unknown[];
31
+ /** 构建时的派生组件引用(call/result),用于同槽外部污染检测。 */
32
+ components: [unknown, unknown];
33
+ hideComponent: boolean;
34
+ /** 构建时实际挂载的壳载体:box / self / text / null(未挂候选容器)。 */
35
+ shell: "box" | "self" | "text" | null;
36
+ callComponent: unknown;
37
+ resultComponent: unknown;
38
+ /** rendererState 中 ccstyle 渲染层的指针快照(切槽重装时恢复)。 */
39
+ stateSnapshot: [unknown, unknown, unknown];
40
+ };
41
+
42
+ type ComponentCache = {
43
+ expanded?: RenderSlot;
44
+ collapsed?: RenderSlot;
45
+ /** 容器当前装着的槽位(重建/装载后更新)。 */
46
+ current?: "expanded" | "collapsed";
47
+ };
48
+
49
+ let componentCaches = new WeakMap<object, ComponentCache>();
50
+
51
+ type ToggleRenderCachePatch = {
52
+ active: boolean;
53
+ prototype: any;
54
+ installed: () => void;
55
+ original: () => void;
56
+ theme: unknown;
57
+ };
58
+
59
+ export type ToggleRenderCacheHooks = {
60
+ setTheme(theme: unknown): void;
61
+ /** 配置/主题等外部状态变化后丢弃全部缓存(下次 updateDisplay 重建)。 */
62
+ clear(): void;
63
+ shutdown(): void;
64
+ };
65
+
66
+ /** 渲染输入指纹:不含 expanded(由槽位区分),不含组件引用。 */
67
+ function fingerprintOf(
68
+ component: any,
69
+ patch: ToggleRenderCachePatch,
70
+ ): unknown[] {
71
+ return [
72
+ component.args,
73
+ component.result,
74
+ component.isPartial === true,
75
+ component.executionStarted === true,
76
+ component.argsComplete === true,
77
+ component.showImages === true,
78
+ config.mode,
79
+ patch.theme,
80
+ ];
81
+ }
82
+
83
+ function componentsOf(component: any): [unknown, unknown] {
84
+ return [component.callRendererComponent, component.resultRendererComponent];
85
+ }
86
+
87
+ function sameComponents(
88
+ left: [unknown, unknown],
89
+ right: [unknown, unknown],
90
+ ): boolean {
91
+ return left[0] === right[0] && left[1] === right[1];
92
+ }
93
+
94
+ function sameFingerprint(left: unknown[], right: unknown[]): boolean {
95
+ if (left.length !== right.length) return false;
96
+ for (let i = 0; i < left.length; i++) {
97
+ if (left[i] !== right[i]) return false;
98
+ }
99
+ return true;
100
+ }
101
+
102
+ /** 从 children 中识别当前挂载的候选壳(无副作用,不调用 getRenderShell)。 */
103
+ function mountedShell(component: any): "box" | "self" | "text" | null {
104
+ const children = Array.isArray(component?.children) ? component.children : [];
105
+ for (const child of children) {
106
+ if (child === component.contentBox) return "box";
107
+ if (child === component.selfRenderContainer) return "self";
108
+ if (child === component.contentText) return "text";
109
+ }
110
+ return null;
111
+ }
112
+
113
+ function shellContainer(
114
+ component: any,
115
+ shell: "box" | "self" | "text" | null,
116
+ ): any {
117
+ if (shell === "box") return component.contentBox;
118
+ if (shell === "self") return component.selfRenderContainer;
119
+ if (shell === "text") return component.contentText;
120
+ return null;
121
+ }
122
+
123
+ /** 与 default-mode 的 syncToolShell 同款:把目标壳换入 children,排除其余候选。 */
124
+ function syncShell(component: any, shell: "box" | "self" | "text"): void {
125
+ const target = shellContainer(component, shell);
126
+ if (!target || !Array.isArray(component.children)) return;
127
+ const candidates = new Set(
128
+ [
129
+ component.contentText,
130
+ component.contentBox,
131
+ component.selfRenderContainer,
132
+ ].filter(Boolean),
133
+ );
134
+ const indexes = component.children
135
+ .map((child: any, index: number) => (candidates.has(child) ? index : -1))
136
+ .filter((index: number) => index >= 0);
137
+ const targetIndex = indexes[0];
138
+ if (targetIndex === undefined) return;
139
+ component.children[targetIndex] = target;
140
+ for (const index of indexes.sort(
141
+ (left: number, right: number) => right - left,
142
+ )) {
143
+ if (index !== targetIndex) component.children.splice(index, 1);
144
+ }
145
+ }
146
+
147
+ function stateSnapshotOf(component: any): [unknown, unknown, unknown] {
148
+ const state = component?.rendererState;
149
+ return [
150
+ state?.ccstyleIoView,
151
+ state?.ccstyleExpandedIoView,
152
+ state?.ccstyleToolVisualState,
153
+ ];
154
+ }
155
+
156
+ function restoreStateSnapshot(
157
+ component: any,
158
+ slotKey: "expanded" | "collapsed",
159
+ snapshot: [unknown, unknown, unknown],
160
+ ): void {
161
+ if (
162
+ component?.rendererState === undefined &&
163
+ snapshot.every((v) => v === undefined)
164
+ ) {
165
+ return;
166
+ }
167
+ const state = (component.rendererState ??= {});
168
+ // ioView/visualState 每槽构建时都会写入,按槽恢复;
169
+ // expandedIoView 是跨槽保留指针(折叠 renderer 不清),只在展开槽恢复。
170
+ state.ccstyleIoView = snapshot[0];
171
+ state.ccstyleToolVisualState = snapshot[1];
172
+ if (slotKey === "expanded") state.ccstyleExpandedIoView = snapshot[2];
173
+ }
174
+
175
+ /**
176
+ * 把缓存槽的 call/result 组件装回目标壳容器并恢复派生字段。
177
+ * 同时恢复 ccstyle 渲染层的 rendererState 指针快照(ioView / expandedIoView /
178
+ * visualState),补齐切槽跳过 renderer 调用导致的状态缺失;pending 展开时
179
+ * 重新调度加载动画。text fallback 槽(无容器装载语义)返回 false,调用方
180
+ * 退化为重建。
181
+ */
182
+ function mountSlot(
183
+ component: any,
184
+ slotKey: "expanded" | "collapsed",
185
+ slot: RenderSlot,
186
+ ): boolean {
187
+ if (!slot.shell || slot.shell === "text") return false;
188
+ const container = shellContainer(component, slot.shell);
189
+ if (!container || typeof container.clear !== "function") return false;
190
+ syncShell(component, slot.shell);
191
+ container.clear();
192
+ if (slot.callComponent !== undefined)
193
+ container.addChild(slot.callComponent as any);
194
+ if (slot.resultComponent !== undefined)
195
+ container.addChild(slot.resultComponent as any);
196
+ component.callRendererComponent = slot.callComponent;
197
+ component.resultRendererComponent = slot.resultComponent;
198
+ component.hideComponent = slot.hideComponent;
199
+ restoreStateSnapshot(component, slotKey, slot.stateSnapshot);
200
+ if (slot.resultComponent !== undefined) {
201
+ // pending(流式)且已展开:恢复加载动画调度(默认 renderResult 的副作用)。
202
+ if (component.isPartial === true || component.executionStarted === true) {
203
+ try {
204
+ scheduleAnimation(component);
205
+ } catch {
206
+ // 动画调度失败不影响装载结果。
207
+ }
208
+ }
209
+ }
210
+ return true;
211
+ }
212
+
213
+ export function installToggleRenderCache(): ToggleRenderCacheHooks {
214
+ const previous = patchRegistry.get<ToggleRenderCachePatch>(
215
+ TOGGLE_RENDER_CACHE_PATCH,
216
+ );
217
+ // /reload 残留的旧补丁先停用,链上以本次安装为准。
218
+ if (previous) previous.active = false;
219
+ const prototype = ToolExecutionComponent.prototype as any;
220
+ const original = prototype.updateDisplay;
221
+ const patch: ToggleRenderCachePatch = {
222
+ active: true,
223
+ prototype,
224
+ installed: undefined as any,
225
+ original,
226
+ theme: undefined,
227
+ };
228
+
229
+ patch.installed = function (this: any) {
230
+ if (!patch.active || config.mode === "off") {
231
+ return patch.original.call(this);
232
+ }
233
+ const self = this;
234
+ if (
235
+ !self ||
236
+ typeof self.expanded !== "boolean" ||
237
+ typeof self.hideComponent !== "boolean"
238
+ ) {
239
+ return patch.original.call(this);
240
+ }
241
+ const slotKey: "expanded" | "collapsed" = self.expanded
242
+ ? "expanded"
243
+ : "collapsed";
244
+ const fingerprint = fingerprintOf(self, patch);
245
+ const componentsNow = componentsOf(self);
246
+ let entry = componentCaches.get(self);
247
+ const slot = entry?.[slotKey];
248
+
249
+ const rebuild = () => {
250
+ entry ??= {};
251
+ componentCaches.set(self, entry);
252
+ // 防止 renderer 复用另一槽的组件实例:pi 的 renderResult 通过
253
+ // context.lastComponent(= resultRendererComponent 字段)复用组件,
254
+ // 跨槽复用会把另一槽缓存的内容覆盖污染(如 bash 的 rebuildln 重建
255
+ // children)。重置字段强制新建,保证两槽各自持有独立实例。
256
+ const otherKey: "expanded" | "collapsed" =
257
+ slotKey === "expanded" ? "collapsed" : "expanded";
258
+ const other = entry[otherKey];
259
+ if (other && other.shell !== "text") {
260
+ if (self.callRendererComponent === other.callComponent) {
261
+ self.callRendererComponent = undefined;
262
+ }
263
+ if (self.resultRendererComponent === other.resultComponent) {
264
+ self.resultRendererComponent = undefined;
265
+ }
266
+ }
267
+ patch.original.call(this);
268
+ // 输入指纹取重建后的状态快照;组件引用由重建产物决定。
269
+ const shell = mountedShell(self);
270
+ const built: RenderSlot = {
271
+ inputFingerprint: fingerprintOf(self, patch),
272
+ components: componentsOf(self),
273
+ hideComponent: self.hideComponent === true,
274
+ shell,
275
+ callComponent:
276
+ shell === "text"
277
+ ? undefined
278
+ : (self.callRendererComponent ?? undefined),
279
+ resultComponent:
280
+ shell === "text"
281
+ ? undefined
282
+ : (self.resultRendererComponent ?? undefined),
283
+ stateSnapshot: stateSnapshotOf(self),
284
+ };
285
+ entry[slotKey] = built;
286
+ entry.current = slotKey;
287
+ };
288
+
289
+ const inputsMatch =
290
+ slot && sameFingerprint(slot.inputFingerprint, fingerprint);
291
+ if (inputsMatch && entry) {
292
+ if (entry.current === slotKey) {
293
+ if (sameComponents(slot.components, componentsNow)) {
294
+ // 容器内容仍是该槽,无重建必要;仅恢复可能被外部改写的派生字段。
295
+ self.hideComponent = slot.hideComponent;
296
+ return;
297
+ }
298
+ // 外部(第三方 renderer、测试)替换了派生组件:走重建刷新。
299
+ rebuild();
300
+ return;
301
+ }
302
+ if (!mountSlot(self, slotKey, slot)) {
303
+ // text fallback 或防御性失败:退化为原生重建。
304
+ rebuild();
305
+ return;
306
+ }
307
+ entry.current = slotKey;
308
+ return;
309
+ }
310
+ rebuild();
311
+ };
312
+
313
+ prototype.updateDisplay = patch.installed;
314
+ patchRegistry.install(TOGGLE_RENDER_CACHE_PATCH, patch);
315
+
316
+ return {
317
+ setTheme(theme: unknown) {
318
+ patch.theme = theme;
319
+ },
320
+ clear() {
321
+ componentCaches = new WeakMap();
322
+ },
323
+ shutdown() {
324
+ if (!patch.active) return;
325
+ patch.active = false;
326
+ if (prototype.updateDisplay === patch.installed) {
327
+ prototype.updateDisplay = patch.original;
328
+ }
329
+ patchRegistry.dispose(TOGGLE_RENDER_CACHE_PATCH, patch);
330
+ componentCaches = new WeakMap();
331
+ },
332
+ };
333
+ }
@@ -143,10 +143,15 @@ export class EditorLayoutController {
143
143
  ): { applied: boolean; reason?: string } {
144
144
  this.context.saveComponent(patch);
145
145
  let result: EditorChangeResult | undefined;
146
- if (patch.enabled !== undefined && this.isTuiContext(ctx))
147
- result = this.reconcile(ctx);
148
- if (patch.style !== undefined && patch.style !== "minimalist")
146
+ if (patch.style === "off") {
147
+ // off 透传原生渲染:不替换工厂,既有编辑器实例与 overlay preFocus
148
+ // 保持有效(面板关闭后仍能聚焦输入框)。
149
149
  this.setMinimalistDecorationActive(false);
150
+ } else if (patch.style === "on" && this.isTuiContext(ctx)) {
151
+ // on 收敛所有权:未安装或第三方接管时安装回来;
152
+ // 已有自身工厂时 installEditor 短路,不产生替换。
153
+ result = this.reconcile(ctx);
154
+ }
150
155
  if (patch.modelLabel !== undefined) this.context.onModelLabelChanged(ctx);
151
156
  this.context.onProjectRequirementChanged();
152
157
  this.requestRender();
@@ -196,7 +201,7 @@ export class EditorLayoutController {
196
201
  Boolean(
197
202
  this.activeTuiContext && this.ownsActiveFactory(this.activeTuiContext),
198
203
  ) &&
199
- editor.style === "minimalist" &&
204
+ editor.style === "on" &&
200
205
  (minimalist.showGit || minimalist.pathDisplay === "project")
201
206
  );
202
207
  }
@@ -328,7 +333,7 @@ export class EditorLayoutController {
328
333
  this.minimalistDecorationActive &&
329
334
  this.isEditorEnabled() &&
330
335
  this.ownsActiveFactory(ctx) &&
331
- config.style === "minimalist" &&
336
+ config.style === "on" &&
332
337
  config.styles.minimalist.showTimer,
333
338
  );
334
339
  if (!needed) {
@@ -365,14 +370,17 @@ export class EditorLayoutController {
365
370
  }
366
371
 
367
372
  /**
368
- * Returns whether the configured Editor style can be installed.
373
+ * Returns whether the configured Editor factory can be installed.
374
+ *
375
+ * style "off" restores Pi's native editor: the factory is not installed and
376
+ * any third-party or native editor keeps ownership.
369
377
  *
370
- * @returns Whether the Editor is enabled and supported.
378
+ * @returns Whether the Editor is on and supported.
371
379
  */
372
380
  private isEditorEnabled(): boolean {
373
381
  const editor = this.context.getConfig().components.editor;
374
382
  return (
375
- editor.enabled &&
383
+ editor.style === "on" &&
376
384
  !hasUnsupportedComponentStyle(this.context.getConfig(), "editor")
377
385
  );
378
386
  }
@@ -10,6 +10,7 @@ export { formatElapsedDuration } from "../../shared/format.ts";
10
10
  import {
11
11
  EDITOR_ACCENT_FALLBACK,
12
12
  EDITOR_BORDER_FALLBACK,
13
+ renderSourceColor,
13
14
  renderStyleForSource,
14
15
  renderStyleForSourceOrFallback,
15
16
  safeThemeFg,
@@ -213,11 +214,12 @@ function renderTopRight(
213
214
  const model = sanitizeEditorMetadataText(metadata.modelLabel ?? "");
214
215
  if (model) {
215
216
  parts.push(
216
- renderStyleForSourceOrFallback(
217
+ renderSourceColor(
217
218
  uiTheme,
218
219
  source,
219
220
  config.colors.editorModel,
220
- MINIMALIST_MODEL_FALLBACK,
221
+ "editorModel",
222
+ MINIMALIST_MODEL_FALLBACK.theme,
221
223
  model,
222
224
  ),
223
225
  );
@@ -293,10 +295,12 @@ function renderBottomRight(
293
295
  ): string {
294
296
  const cwd = sanitizeEditorMetadataText(minimalistCwdLabel(metadata, config));
295
297
  return cwd
296
- ? renderStyleForSource(
298
+ ? renderSourceColor(
297
299
  uiTheme,
298
300
  config.components.editor.colorSource,
299
301
  config.colors.cwd,
302
+ "cwd",
303
+ "bold cyan",
300
304
  cwd,
301
305
  )
302
306
  : "";
@@ -386,11 +390,12 @@ export function renderMinimalistFrame({
386
390
  const activeThinking =
387
391
  thinking && thinking.toLowerCase() !== "off" ? thinking : "";
388
392
  const renderStaticBorder = (text: string) =>
389
- renderStyleForSourceOrFallback(
393
+ renderSourceColor(
390
394
  uiTheme,
391
395
  source,
392
396
  config.colors.editorBorder,
393
- EDITOR_BORDER_FALLBACK,
397
+ "editorBorder",
398
+ EDITOR_BORDER_FALLBACK.theme,
394
399
  text,
395
400
  );
396
401
  const terminalAdaptiveThinkingStyle = activeThinking