pi-cc-extensions 0.9.7 → 0.9.8

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.
@@ -8,10 +8,13 @@
8
8
  * Agent/Task 族:调用只进摘要计数,tool 卡始终折叠(避免 pending→完成高度闪动)。
9
9
  * 底部 Agents/Tasks 面板由 pi-subagents/pi-tasks 独立 widget 负责,不经 tool 卡外置。
10
10
  *
11
- * live 围观态(回合进行中 / 收尾静默期):正文与摘要行跟折叠态同形,只在摘要行下面多一张
12
- * 单一槽位卡(`↳` + 最新的一个块:仍在增长的思考预览,或本回合最近的工具,运行中优先)。
11
+ * live 围观态(回合进行中 / 收尾静默期):正文跟折叠态同形;摘要行与单一槽位卡
12
+ * (`↳` + 最新的一个块:仍在增长的思考预览,或本回合最近的工具,运行中优先)挂在
13
+ * transcript 容器的回合末尾(尾行组件,anchor 与工具卡之后、下一消息之前)。
13
14
  * 思考原地增长不轮换,只有新的思考块或工具调用进来才换槽位内容。
14
- * 收尾时摘要行原地从 Running... 翻成 Ran for、槽位卡消失,正文与摘要行都不换位置。
15
+ * 摘要行挂末尾是刻意的:运行中它恒在可写视口底缘,收尾时原地从 Running... 翻成
16
+ * Ran for 真实落进 scrollback;放在 anchor 内部则会被工具/diff 顶出视口后定格。
17
+ * (anchor 未挂载进容器的独立渲染场景退回组件内旧行为。)
15
18
  *
16
19
  * 工具计数:read 按非空路径去重、其余按调用计数(首次出现顺序);edit/write 不进摘要。
17
20
  * 时长 = 回合流逝挂钟;进行中 Running...,结束 Ran for。
@@ -60,6 +63,7 @@ import {
60
63
  COMPACT_THINKING_PATCH_KEY,
61
64
  patchRegistry,
62
65
  PROTOTYPE_ORIGINAL_KEY,
66
+ TOOL_GROUPING_PARENT_KEY,
63
67
  } from "../utils/patch-keys.ts";
64
68
 
65
69
  /** Pi 的 native 组件与扩展可能各持一份 pi-tui,Spacer 不能只靠 instanceof 判。 */
@@ -593,6 +597,22 @@ export function getCompactRunStatusText(): string | undefined {
593
597
  return compactRunStatusGetter?.();
594
598
  }
595
599
 
600
+ /**
601
+ * 回合尾行记录:摘要行(live 时连槽位卡)挂成 transcript 容器里回合末尾的
602
+ * 兄弟组件,而不是 anchor 内部。anchor 内部的可变内容一旦被工具/diff 顶出视口
603
+ * 就再也写不进 scrollback(regular 主屏只能差分可视区),运行态 Running 会永久
604
+ * 定格在挤出前最后一帧;尾行恒在可写底缘,收尾的 Ran for 才真实落进历史。
605
+ * 模块级持有(与 trackedAssistantComponents 同理):跨 /reload 去重与清理。
606
+ */
607
+ type MountedRoundTail = {
608
+ host: any;
609
+ container: any;
610
+ parts: any[];
611
+ round: any;
612
+ };
613
+ const tailByAnchor = new WeakMap<object, MountedRoundTail>();
614
+ const mountedTails = new Set<MountedRoundTail>();
615
+
596
616
  export function setHoveredCompactAssistant(component: any): boolean {
597
617
  if (hoveredAssistantComponent === component) return false;
598
618
  hoveredAssistantComponent = component;
@@ -998,16 +1018,6 @@ function compactLiveSlot(card: any, pad = 0): any {
998
1018
  };
999
1019
  }
1000
1020
 
1001
- function compactAssistantLine(
1002
- component: any,
1003
- summary: string | (() => string),
1004
- query?: CompactThinkingQuery,
1005
- ): void {
1006
- // 空静态串跳过;getter 留给 render 判断(Running 可能稍后才有时长)。
1007
- if (typeof summary === "string" && !summary) return;
1008
- component.contentContainer.addChild(compactAssistantLineComponent(component, summary, query));
1009
- }
1010
-
1011
1021
  function appendStopStatus(component: any, status: string | undefined): void {
1012
1022
  if (!status || !component?.contentContainer?.addChild) return;
1013
1023
  component.contentContainer.addChild(
@@ -1117,6 +1127,8 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1117
1127
  /** 回合挂钟起点,保证 Running 时长连续递增。 */
1118
1128
  startedAt: number;
1119
1129
  endedAt?: number;
1130
+ /** 回合尾行(摘要 + live 槽位),挂在 transcript 容器的回合末尾。 */
1131
+ tail?: MountedRoundTail;
1120
1132
  };
1121
1133
  let activeRound: CompactRound | undefined;
1122
1134
  let roundByComponent = new WeakMap<object, CompactRound>();
@@ -1287,6 +1299,208 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1287
1299
  return ids;
1288
1300
  };
1289
1301
 
1302
+ /**
1303
+ * anchor 所在的 transcript 容器(pi chatContainer)。
1304
+ * 优先走分组补丁在 addChild 时挂的父指针;没有则沿 tui 组件树找直属父容器。
1305
+ */
1306
+ const tailContainerOf = (anchor: any): any => {
1307
+ const keyed = anchor?.[TOOL_GROUPING_PARENT_KEY];
1308
+ if (keyed && Array.isArray(keyed.children) && keyed.children.includes(anchor)) {
1309
+ return keyed;
1310
+ }
1311
+ let found: any;
1312
+ walkComponentTree(getToolMouseTui(), (value: any) => {
1313
+ if (found) return false;
1314
+ if (Array.isArray(value?.children) && value.children.includes(anchor)) {
1315
+ found = value;
1316
+ return false;
1317
+ }
1318
+ return undefined;
1319
+ });
1320
+ return found;
1321
+ };
1322
+
1323
+ const ensureTailHost = (round: CompactRound): MountedRoundTail => {
1324
+ if (round.tail) return round.tail;
1325
+ // 同一 anchor 上残留的旧回合尾行(reset/重组后建新回合)先摘除,避免双摘要。
1326
+ const stale = tailByAnchor.get(round.anchor);
1327
+ if (stale) {
1328
+ stale.container?.removeChild?.(stale.host);
1329
+ mountedTails.delete(stale);
1330
+ tailByAnchor.delete(round.anchor);
1331
+ }
1332
+ const tail: MountedRoundTail = { host: undefined, container: undefined, parts: [], round };
1333
+ const host: any = {
1334
+ outputPad: Number(round.anchor?.outputPad) || 0,
1335
+ render: (width: number) => tail.parts.flatMap((part: any) => part?.render?.(width) ?? []),
1336
+ invalidate: () => {
1337
+ for (const part of tail.parts) part?.invalidate?.();
1338
+ },
1339
+ };
1340
+ Object.defineProperty(host, "expanded", {
1341
+ configurable: true,
1342
+ get: () => tail.round?.anchor?.expanded === true,
1343
+ set: (value: boolean) => {
1344
+ const anchor = tail.round?.anchor;
1345
+ if (anchor) anchor.expanded = value;
1346
+ },
1347
+ });
1348
+ // 点击/悬停归属与 anchor 同一展开入口;toggle 每次 renderRound 重建,按值转发。
1349
+ // 经 tail.round 动态解引用:重建过户时 anchor 可能换成别的组件。
1350
+ ensureAssistantSetExpanded(host);
1351
+ host[ASSISTANT_TOGGLE_ROUND_KEY] = (expanded: boolean) =>
1352
+ tail.round?.anchor?.[ASSISTANT_TOGGLE_ROUND_KEY]?.(expanded);
1353
+ tail.host = host;
1354
+ tailByAnchor.set(round.anchor, tail);
1355
+ mountedTails.add(tail);
1356
+ round.tail = tail;
1357
+ return tail;
1358
+ };
1359
+
1360
+ /**
1361
+ * 尾行归位。运行中(含收尾静默期)尾行恒为容器最末一行:计数行钉在
1362
+ * transcript 底缘,运行中到达的提示行/custom 条目只能落在它上方,计数
1363
+ * 不会被顶离底部截成两行。回合收尾后摘要行回落到回合最后一个成员之后,
1364
+ * 外来行统一换到摘要行之下,通知行永远落在回合边界外。
1365
+ */
1366
+ const syncTailPosition = (round: CompactRound): void => {
1367
+ const tail = round.tail;
1368
+ if (!tail?.host) return;
1369
+ const container =
1370
+ tail.container?.children?.includes?.(round.anchor) === true
1371
+ ? tail.container
1372
+ : tailContainerOf(round.anchor);
1373
+ if (!container || !Array.isArray(container.children)) return;
1374
+ tail.container = container;
1375
+ const children = container.children as any[];
1376
+ const current = children.indexOf(tail.host);
1377
+ if (current >= 0) children.splice(current, 1);
1378
+ const ids = roundToolCallIds(round);
1379
+ const matchesTool = (value: any): boolean =>
1380
+ typeof value?.toolCallId === "string" && ids.has(value.toolCallId);
1381
+ const isMember = (value: any): boolean =>
1382
+ round.messages.has(value) ||
1383
+ matchesTool(value) ||
1384
+ (Array.isArray(value?.children) && value.children.some(matchesTool));
1385
+ let first = -1;
1386
+ let last = -1;
1387
+ for (let i = 0; i < children.length; i++) {
1388
+ if (!isMember(children[i])) continue;
1389
+ if (first < 0) first = i;
1390
+ last = i;
1391
+ }
1392
+ const lastMember = last >= 0 ? children[last] : undefined;
1393
+ const intruders: any[] = [];
1394
+ for (let i = last - 1; i > first; i--) {
1395
+ if (!isMember(children[i])) intruders.unshift(children.splice(i, 1)[0]);
1396
+ }
1397
+ if (lastMember) last = children.indexOf(lastMember);
1398
+ const insertAt = last < 0 ? children.length : last + 1;
1399
+ if (roundLive(round)) {
1400
+ // 活回合:外来行压到最后成员之后,尾行占容器最末。
1401
+ children.splice(insertAt, 0, ...intruders);
1402
+ children.push(tail.host);
1403
+ } else {
1404
+ children.splice(insertAt, 0, tail.host, ...intruders);
1405
+ }
1406
+ tail.host[TOOL_GROUPING_PARENT_KEY] = container;
1407
+ };
1408
+
1409
+ const unmountRoundTail = (round: CompactRound): void => {
1410
+ const tail = round.tail;
1411
+ if (!tail) return;
1412
+ round.tail = undefined;
1413
+ tail.container?.removeChild?.(tail.host);
1414
+ mountedTails.delete(tail);
1415
+ if (tailByAnchor.get(round.anchor) === tail) tailByAnchor.delete(round.anchor);
1416
+ };
1417
+
1418
+ /**
1419
+ * pi 把新工具卡 append 在容器末尾(尾行之后):updateDisplay 时把所属回合的
1420
+ * 尾行压回末尾。只有 live/收尾静默期的回合还会长工具。
1421
+ */
1422
+ const syncTailForTool = (toolCallId: string): void => {
1423
+ if (!toolCallId) return;
1424
+ for (const round of [activeRound, ...pendingFoldRounds]) {
1425
+ if (round?.tail && roundToolCallIds(round).has(toolCallId)) {
1426
+ syncTailPosition(round);
1427
+ return;
1428
+ }
1429
+ }
1430
+ };
1431
+
1432
+ /**
1433
+ * refresh 重建时继承旧回合:resetRounds 只清索引不摘 compact 尾行,
1434
+ * 残留尾行带着旧 round。同一组件重放同一消息(lastMessage 回放)
1435
+ * 才算重建——保留挂钟起点、已脱离的工具历史与尾行组件本身;
1436
+ * 旧回合已收尾的直接落成折叠态(不回摆 Running、不重演静默期),
1437
+ * 仍活动的只接回挂钟,Running 时长继续走。
1438
+ */
1439
+ const adoptPriorRound = (round: CompactRound, message: any): void => {
1440
+ let prior: CompactRound | undefined;
1441
+ for (const tail of mountedTails) {
1442
+ const stale = tail.round as CompactRound | undefined;
1443
+ if (
1444
+ stale &&
1445
+ (stale.messages.get(round.anchor) === message || stale.detachedMessages.includes(message))
1446
+ ) {
1447
+ prior = stale;
1448
+ break;
1449
+ }
1450
+ }
1451
+ if (!prior) return;
1452
+ round.startedAt = prior.startedAt;
1453
+ if (prior.detachedMessages.length) round.detachedMessages.push(...prior.detachedMessages);
1454
+ if (!prior.active) {
1455
+ round.active = false;
1456
+ round.endedAt = prior.endedAt ?? round.startedAt;
1457
+ }
1458
+ const tail = prior.tail;
1459
+ if (tail) {
1460
+ prior.tail = undefined;
1461
+ if (tailByAnchor.get(prior.anchor) === tail) tailByAnchor.delete(prior.anchor);
1462
+ tail.round = round;
1463
+ tailByAnchor.set(round.anchor, tail);
1464
+ round.tail = tail;
1465
+ }
1466
+ };
1467
+
1468
+ /** 新建活回合:literal + 激活 + refresh 重建时的旧回合收编。 */
1469
+ const createRound = (anchor: any, message: any): CompactRound => {
1470
+ const round: CompactRound = {
1471
+ anchor,
1472
+ messages: new Map(),
1473
+ detachedMessages: [],
1474
+ active: true,
1475
+ suppressedToolIds: new Set(),
1476
+ liveSlotToolIds: new Set(),
1477
+ startedAt: Date.now(),
1478
+ };
1479
+ activateRound(round);
1480
+ adoptPriorRound(round, message);
1481
+ return round;
1482
+ };
1483
+
1484
+ /**
1485
+ * 尾行挂载入口:makeParts 按渲染态产出内容(折叠态 [摘要行],live 态
1486
+ * [摘要行, 槽位卡]),identity 决定行内 hover/点击归属组件(尾行模式下是
1487
+ * host,hit-test 直达它)。anchor 未挂进任何容器时(独立渲染)退回
1488
+ * contentContainer——与旧行为一致。
1489
+ */
1490
+ const mountRoundTail = (round: CompactRound, makeParts: (identity: any) => any[]): void => {
1491
+ if (!tailContainerOf(round.anchor)) {
1492
+ // anchor 不在任何容器里(独立渲染/未挂载):退回 anchor 内部,与旧行为一致。
1493
+ unmountRoundTail(round);
1494
+ for (const part of makeParts(round.anchor)) {
1495
+ round.anchor?.contentContainer?.addChild?.(part);
1496
+ }
1497
+ return;
1498
+ }
1499
+ const tail = ensureTailHost(round);
1500
+ tail.parts = makeParts(tail.host);
1501
+ syncTailPosition(round);
1502
+ };
1503
+
1290
1504
  /**
1291
1505
  * 末块仍是 thinking = 思考还在长;后面跟了正文或工具调用就算结束。
1292
1506
  * 围观态只预览这一种,已完成的思考直接回收进摘要行,不在屏幕上留 Thought 行。
@@ -1371,21 +1585,26 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1371
1585
 
1372
1586
  const anchor = round.anchor;
1373
1587
  const pad = Number(anchor.outputPad) || 0;
1374
- // 摘要行位置与折叠态一致(正文之后),收尾只有时态变化和槽位卡消失。
1375
- compactAssistantLine(anchor, getSummary, deps.query);
1376
- if (cardItems.length > 0) {
1377
- anchor.contentContainer.addChild(
1378
- compactLiveSlot(
1379
- compactRoundCard(
1380
- cardItems,
1381
- (tool, innerWidth) => patch.toolOriginalRender.call(tool, innerWidth),
1382
- false,
1383
- true,
1588
+ // 摘要行与槽位卡挂回合尾行(transcript 容器内回合末尾,而非 anchor 内部):
1589
+ // 运行中恒在可写视口底缘,收尾的 Ran for 原地落进 scrollback,不再留下
1590
+ // 被挤出视口后定格的 Running 帧。
1591
+ mountRoundTail(round, (identity) => {
1592
+ const parts: any[] = [compactAssistantLineComponent(identity, getSummary, deps.query)];
1593
+ if (cardItems.length > 0) {
1594
+ parts.push(
1595
+ compactLiveSlot(
1596
+ compactRoundCard(
1597
+ cardItems,
1598
+ (tool, innerWidth) => patch.toolOriginalRender.call(tool, innerWidth),
1599
+ false,
1600
+ true,
1601
+ ),
1602
+ pad,
1384
1603
  ),
1385
- pad,
1386
- ),
1387
- );
1388
- }
1604
+ );
1605
+ }
1606
+ return parts;
1607
+ });
1389
1608
  appendStopStatus(anchor, stopStatus);
1390
1609
  };
1391
1610
 
@@ -1431,6 +1650,8 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1431
1650
  }
1432
1651
 
1433
1652
  if (round.anchor.expanded === true) {
1653
+ // 展开卡自带全部内容,尾行摘下(收起时在折叠分支重新挂载)。
1654
+ unmountRoundTail(round);
1434
1655
  const toolsById = new Map<string, any>();
1435
1656
  for (const tool of trackedToolComponents) {
1436
1657
  if (typeof tool?.toolCallId === "string") toolsById.set(tool.toolCallId, tool);
@@ -1556,7 +1777,13 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1556
1777
  if (component === round.anchor) {
1557
1778
  renderAssistantWithoutThinking(component, message);
1558
1779
  // 空摘要不挂行(getter 在 Running 启动瞬间也可能短暂为空)
1559
- if (summary || round.active) compactAssistantLine(component, getSummary, deps.query);
1780
+ if (summary || round.active) {
1781
+ mountRoundTail(round, (identity) => [
1782
+ compactAssistantLineComponent(identity, getSummary, deps.query),
1783
+ ]);
1784
+ } else {
1785
+ unmountRoundTail(round);
1786
+ }
1560
1787
  // 折叠时工具行被隐藏:abort/error/length 必须挂在摘要外层。
1561
1788
  appendStopStatus(component, stopStatus);
1562
1789
  } else {
@@ -1584,6 +1811,22 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1584
1811
  expandedRoundToolIds.clear();
1585
1812
  explicitRoundToolIds.clear();
1586
1813
  liveSlotToolIds.clear();
1814
+ // 尾行是 transcript 兄弟组件:compact 下保留为静态行(与原 anchor 内摘要
1815
+ // 一致;同 anchor 重建回合时 ensureTailHost 去重摘除),非 compact 摘掉,
1816
+ // 别把紧凑摘要带进 default 渲染。
1817
+ for (const tail of [...mountedTails]) {
1818
+ if (tail.container?.children?.includes?.(tail.host) !== true) {
1819
+ mountedTails.delete(tail);
1820
+ continue;
1821
+ }
1822
+ if (config.mode === "compact") continue;
1823
+ tail.container.removeChild(tail.host);
1824
+ mountedTails.delete(tail);
1825
+ if (tail.round?.tail === tail) tail.round.tail = undefined;
1826
+ if (tail.round?.anchor && tailByAnchor.get(tail.round.anchor) === tail) {
1827
+ tailByAnchor.delete(tail.round.anchor);
1828
+ }
1829
+ }
1587
1830
  deps.query?.setCompactSummaryActive?.(false);
1588
1831
  stopRoundTick();
1589
1832
  };
@@ -1616,29 +1859,11 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1616
1859
  if (hasText && (!round || round.anchor !== this)) {
1617
1860
  if (round) round.messages.delete(this);
1618
1861
  if (activeRound) finishRound(activeRound);
1619
- round = {
1620
- anchor: this,
1621
- messages: new Map(),
1622
- detachedMessages: [],
1623
- active: true,
1624
- suppressedToolIds: new Set(),
1625
- liveSlotToolIds: new Set(),
1626
- startedAt: Date.now(),
1627
- };
1862
+ round = createRound(this, message);
1628
1863
  roundByComponent.set(this, round);
1629
- activateRound(round);
1630
1864
  } else if (!round) {
1631
- round = activeRound ?? {
1632
- anchor: this,
1633
- messages: new Map(),
1634
- detachedMessages: [],
1635
- active: true,
1636
- suppressedToolIds: new Set(),
1637
- liveSlotToolIds: new Set(),
1638
- startedAt: Date.now(),
1639
- };
1865
+ round = activeRound ?? createRound(this, message);
1640
1866
  roundByComponent.set(this, round);
1641
- if (!activeRound) activateRound(round);
1642
1867
  }
1643
1868
  round.messages.set(this, message);
1644
1869
  renderRound(round);
@@ -1671,17 +1896,8 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1671
1896
  if (hasThinking) {
1672
1897
  let round = roundByComponent.get(this);
1673
1898
  if (!round) {
1674
- round = activeRound ?? {
1675
- anchor: this,
1676
- messages: new Map(),
1677
- detachedMessages: [],
1678
- active: true,
1679
- suppressedToolIds: new Set(),
1680
- liveSlotToolIds: new Set(),
1681
- startedAt: Date.now(),
1682
- };
1899
+ round = activeRound ?? createRound(this, message);
1683
1900
  roundByComponent.set(this, round);
1684
- if (!activeRound) activateRound(round);
1685
1901
  }
1686
1902
  round.messages.set(this, message);
1687
1903
  renderRound(round);
@@ -1733,6 +1949,7 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1733
1949
  const result = patch.toolOriginalUpdateDisplay.call(this);
1734
1950
  if (!patch.active) return result;
1735
1951
  trackedToolComponents.add(this);
1952
+ if (config.mode === "compact") syncTailForTool(id);
1736
1953
  return result;
1737
1954
  };
1738
1955
 
@@ -100,6 +100,8 @@ type ToolExpandedBackgroundPatch = {
100
100
  dispose: () => void;
101
101
  };
102
102
 
103
+ /** rich diff 的两个入口(本处与 compact 的 paintCompactEditWrite)都靠 renderRichToolResult
104
+ * 返回 undefined 让位,这里只判断模式与工具名,避免多一个可能与让位不一致的 gate。 */
103
105
  export function shouldRenderRichDiff(
104
106
  mode: CompactStyleMode,
105
107
  toolName: string,
@@ -12,7 +12,7 @@ import {
12
12
  installToolExpandedBackground,
13
13
  type DefaultModeHooks,
14
14
  } from "./default-mode.ts";
15
- import { isLazyProxyTui } from "../utils/fullscreen-detect.ts";
15
+ import { installMainScreenDoRenderPatch, isLazyProxyTui } from "../utils/fullscreen-detect.ts";
16
16
  import { showCcstylePanel } from "../config/panel.ts";
17
17
  import {
18
18
  config,
@@ -116,6 +116,8 @@ export default function (
116
116
  // 渲染层(工具样式/分组)是原型与组件级 patch,fullscreen 官方布局
117
117
  // 同样渲染这些组件,因此两种模式都安装。
118
118
  if (installation) return installation;
119
+ // regular 主屏的差分渲染补丁:视口上方变化不再触发清回滚的 fullRender。
120
+ installMainScreenDoRenderPatch();
119
121
  const defaultMode = installDefaultMode(writeExecutionMetadata);
120
122
  const toolGrouping = installToolGrouping(() => config.mode === "on");
121
123
  const compactMode = installCompactMode({
@@ -206,10 +208,18 @@ export default function (
206
208
  });
207
209
 
208
210
  pi.on("session_start", async (event, ctx) => {
209
- // 延迟到 session_start 注册 write override:加载阶段 getAllTools 不可用且其他扩展
210
- // 尚未注册工具,无法检测外部 write 所有者(如 pi-spark),直接注册会与对方撞名。
211
- // session_start 时所有扩展已加载完毕,installWriteOverride 内部会检测并让位。
212
- installWriteOverride(pi, writeExecutionMetadata);
211
+ // 延后一拍再确认 write 归属:其他扩展同样在各自的 session_start 里注册工具,
212
+ // 本 handler 执行时可能还看不到对方,此时注册会与对方撞名(注册表先到先得),
213
+ // 对方的 write 会被静默丢弃。等所有 session_start 跑完,检测结果才是权威的。
214
+ setTimeout(() => {
215
+ installWriteOverride(pi, writeExecutionMetadata, (owner) => {
216
+ // 冲突可见:对方接管 write 后本插件不再提供富 diff。
217
+ ctx.ui?.notify?.(
218
+ `ccstyle: write 工具已被 ${owner.path || owner.source} 占用,富 diff 已让位`,
219
+ "warning",
220
+ );
221
+ });
222
+ }, 0);
213
223
  const hooks = ensureTuiInstallation(ctx);
214
224
  // 鼠标交互独立于渲染层:fullscreen 渲染层让位(hooks undefined)但
215
225
  // 工具点击/回到底部适配仍需安装;保持在渲染层安装之后以维持原顺序。
@@ -6,6 +6,7 @@ import {
6
6
  type DisplayConfigInput,
7
7
  } from "./diff-renderer.ts";
8
8
  import { DEFAULT_TOOL_DISPLAY_CONFIG } from "../../../config/config.ts";
9
+ import { patchRegistry, WRITE_OWNERSHIP_SLOT } from "../../../utils/patch-keys.ts";
9
10
  import { executeWriteWithMetadata, WriteExecutionMetadataStore } from "./write-execution.ts";
10
11
 
11
12
  function resultText(result: any): string {
@@ -59,6 +60,8 @@ export function renderRichToolResult(
59
60
  );
60
61
  }
61
62
  if (toolName !== "write") return undefined;
63
+ // 让位点:write 归其他扩展时不提供富 diff,交回普通结果行。
64
+ if (!ownsWriteTool()) return undefined;
62
65
 
63
66
  const metadata = writeMetadata.get(context?.toolCallId);
64
67
  if (!metadata) {
@@ -83,23 +86,57 @@ export function renderRichToolResult(
83
86
  );
84
87
  }
85
88
 
86
- function hasExternalWriteOwner(pi: ExtensionAPI): boolean {
89
+ /** write 被其他扩展占用时的来源,用于提示冲突。 */
90
+ export type ExternalWriteOwner = { source: string; path: string };
91
+
92
+ type WriteOwnershipState = {
93
+ /** write 是否由本插件执行;undefined 表示尚未确认,按拥有处理以保持既有行为。 */
94
+ owned?: boolean;
95
+ };
96
+
97
+ function writeOwnership(): WriteOwnershipState {
98
+ return patchRegistry.ensure<WriteOwnershipState>(WRITE_OWNERSHIP_SLOT, () => ({}));
99
+ }
100
+
101
+ /**
102
+ * write 是否由本插件执行。只有注册过 write override 才有执行元数据,
103
+ * 否则渲染层要放行给普通结果行,避免每张卡降级成 "diff unavailable"。
104
+ */
105
+ export function ownsWriteTool(): boolean {
106
+ return writeOwnership().owned !== false;
107
+ }
108
+
109
+ /** 当前占用 write 的其他扩展;builtin 或未注册时返回 undefined。 */
110
+ function findExternalWriteOwner(pi: ExtensionAPI): ExternalWriteOwner | undefined {
87
111
  try {
88
- const tools = pi.getAllTools();
89
- const write = tools.find((tool: any) => tool?.name === "write") as any;
90
- const source = write?.sourceInfo?.source;
91
- return Boolean(write && typeof source === "string" && source !== "builtin");
112
+ const tools = pi.getAllTools() as any[];
113
+ const write = tools?.find((tool: any) => tool?.name === "write") as any;
114
+ const sourceInfo = write?.sourceInfo;
115
+ const source = sourceInfo?.source;
116
+ if (!write || typeof source !== "string" || source === "builtin") return undefined;
117
+ return { source, path: typeof sourceInfo?.path === "string" ? sourceInfo.path : "" };
92
118
  } catch {
93
- // getAllTools is unavailable before the extension runtime is bound.
94
- return false;
119
+ // getAllTools 在扩展运行时绑定前不可用。
120
+ return undefined;
95
121
  }
96
122
  }
97
123
 
98
124
  export function installWriteOverride(
99
125
  pi: ExtensionAPI,
100
126
  store = new WriteExecutionMetadataStore(),
127
+ /** write 已被其他扩展占用时回调一次,调用方据此提示冲突。 */
128
+ onExternalOwner?: (owner: ExternalWriteOwner) => void,
101
129
  ): WriteExecutionMetadataStore {
102
- if (typeof (pi as any).registerTool !== "function" || hasExternalWriteOwner(pi)) return store;
130
+ if (typeof (pi as any).registerTool !== "function") return store;
131
+ const state = writeOwnership();
132
+ const external = findExternalWriteOwner(pi);
133
+ if (external) {
134
+ // 让位:执行与 diff 都归对方,渲染层据 owned=false 走普通结果行。
135
+ state.owned = false;
136
+ onExternalOwner?.(external);
137
+ return store;
138
+ }
139
+ state.owned = true;
103
140
  const nativeWrite = createWriteToolDefinition(process.cwd()) as any;
104
141
  pi.registerTool({
105
142
  ...nativeWrite,
@@ -62,8 +62,8 @@ export type ToolCallSummary = {
62
62
  };
63
63
 
64
64
  function pathApi(value: string) {
65
- if (win32.isAbsolute(value)) return win32;
66
65
  if (posix.isAbsolute(value)) return posix;
66
+ if (win32.isAbsolute(value)) return win32;
67
67
  return undefined;
68
68
  }
69
69
 
@@ -1,3 +1,6 @@
1
+ import { TuiMainScreen } from "@earendil-works/pi-tui";
2
+ import { MAIN_SCREEN_DO_RENDER_PATCH, patchRegistry } from "./patch-keys.ts";
3
+
1
4
  /**
2
5
  * 官方 0.84+ 的 tui 引用是惰性 Proxy(createInteractiveTuiReference):
3
6
  * 函数属性每次 get 都返回新包装,执行时才解析到当前实现。
@@ -9,3 +12,51 @@ export function isLazyProxyTui(tui: any): boolean {
9
12
  const probe = tui.requestRender;
10
13
  return typeof probe === "function" && probe !== tui.requestRender;
11
14
  }
15
+
16
+ type MainScreenDoRenderPatch = {
17
+ active: boolean;
18
+ original: (lines: string[]) => string[];
19
+ installed: (lines: string[]) => string[];
20
+ };
21
+
22
+ /**
23
+ * regular(TuiMainScreen)模式的 doRender 走差分绘制:一旦第一个变化行落在
24
+ * previousViewportTop 之上(终端写不进 scrollback 的区域),只能
25
+ * fullRender(true)——\x1b[2J\x1b[H\x1b[3J 清屏并清掉整段 scrollback。
26
+ * compact 直播式回合的摘要行动画与流式正文渲染在回合工具卡/diff 面板上方;
27
+ * diff 面板一落地把下方内容撑过屏高,之后每帧重绘都命中该分支:scrollback
28
+ * 被反复清空,滚动条回弹、视口钉住,直到输出结束才恢复。
29
+ *
30
+ * 视口上方的行本就无法重写,只剩「清回滚重打」与「scrollback 保留旧文本」
31
+ * 两条路。这里选后者:applyLineResets 是差分比较前最后一个拿到整帧的环节,
32
+ * 把 previousLines 的视口上方前缀直接对齐新帧,差分只看到可写区域,照常增量
33
+ * 绘制。代价仅是 scrollback 里那几行停留在最后一次实际写入的内容。
34
+ */
35
+ export function installMainScreenDoRenderPatch(): void {
36
+ const prototype = TuiMainScreen.prototype as any;
37
+ if (typeof prototype?.applyLineResets !== "function") return;
38
+ const previous = patchRegistry.get<MainScreenDoRenderPatch>(MAIN_SCREEN_DO_RENDER_PATCH);
39
+ if (previous) previous.active = false;
40
+ const original =
41
+ previous && prototype.applyLineResets === previous.installed
42
+ ? previous.original
43
+ : prototype.applyLineResets;
44
+ const patch: MainScreenDoRenderPatch = {
45
+ active: true,
46
+ original,
47
+ installed(this: any, lines: string[]) {
48
+ const result = original.call(this, lines);
49
+ if (!patch.active) return result;
50
+ const prev = this.previousLines;
51
+ if (!Array.isArray(prev)) return result;
52
+ const top = Math.min(
53
+ Math.max(0, Math.floor(Number(this.previousViewportTop) || 0)),
54
+ result.length,
55
+ );
56
+ for (let i = 0; i < top; i++) prev[i] = result[i];
57
+ return result;
58
+ },
59
+ };
60
+ prototype.applyLineResets = patch.installed;
61
+ patchRegistry.install(MAIN_SCREEN_DO_RENDER_PATCH, patch);
62
+ }
@@ -29,6 +29,8 @@ export const COMPONENT_TOOL_RENDER_MODE = Symbol.for("pi.ccstyle.component-tool-
29
29
  export const TOOL_EXPANDED_BACKGROUND_PATCH = Symbol.for(
30
30
  "pi.ccstyle.tool-expanded-background-patch",
31
31
  );
32
+ // write 归属:注册期确认一次,渲染期据此决定是否提供 rich diff。
33
+ export const WRITE_OWNERSHIP_SLOT = Symbol.for("pi.ccstyle.write-ownership");
32
34
 
33
35
  // ── 消息组件补丁 ──
34
36
  export const MESSAGE_DISPLAY_PATCH = Symbol.for("pi.ccstyle.message-display-patch");
@@ -38,6 +40,9 @@ export const TOOL_GROUPING_PATCH_KEY = Symbol.for("pi.ccstyle.tool-grouping-patc
38
40
  export const TOOL_GROUPING_PARENT_KEY = Symbol.for("pi.ccstyle.tool-grouping-parent");
39
41
  export const TOOL_GROUPING_GENERATION_KEY = Symbol.for("pi.ccstyle.tool-grouping-generation");
40
42
 
43
+ // ── regular 主屏渲染补丁 ──
44
+ export const MAIN_SCREEN_DO_RENDER_PATCH = Symbol.for("pi.ccstyle.main-screen-do-render-patch");
45
+
41
46
  // ── 鼠标交互 ──
42
47
  export const TOOL_MOUSE_OWNER_KEY = Symbol.for("pi.ccstyle.tool-mouse-owner");
43
48
  export const TOOL_MOUSE_TUI_SLOT = Symbol.for("pi.ccstyle.tool-mouse-tui");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-cc-extensions",
3
- "version": "0.9.7",
3
+ "version": "0.9.8",
4
4
  "license": "MIT",
5
5
  "description": "Claude Code-style TUI output with some personal touches and handy utilities.",
6
6
  "type": "module",