pi-cc-extensions 0.9.5 → 0.9.7

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.
@@ -221,7 +221,7 @@ function createCcstyleTool(
221
221
  ...originalTool,
222
222
  renderShell: "self",
223
223
  renderCall(args: any, theme: any, context: any) {
224
- if (config.mode !== "on") {
224
+ if (config.mode === "off") {
225
225
  return renderDefault(originalTool, "renderCall", [args, theme, context], String(toolName));
226
226
  }
227
227
 
@@ -293,7 +293,7 @@ function createCcstyleTool(
293
293
  };
294
294
  },
295
295
  renderResult(result: any, options: any, theme: any, context: any) {
296
- if (config.mode !== "on") {
296
+ if (config.mode === "off") {
297
297
  return renderDefault(
298
298
  originalTool,
299
299
  "renderResult",
@@ -416,8 +416,9 @@ function shouldGloballyStyleTool(component: any, patch: GlobalToolRenderPatch):
416
416
  const builtInDefinition = component.builtInToolDefinition;
417
417
  const definition = extensionDefinition ?? builtInDefinition;
418
418
  const toolName = String(component.toolName || definition?.name || "");
419
+ // compact 也复用同一套 ccstyle call/result,避免折叠态工具卡回落到 Pi 原生样式。
419
420
  const useCcstyle =
420
- patch.mode() === "on" &&
421
+ patch.mode() !== "off" &&
421
422
  !DEDICATED_RENDERER_TOOLS.has(toolName) &&
422
423
  !preservesOriginalRenderer(extensionDefinition, toolName, builtInDefinition);
423
424
  component[COMPONENT_TOOL_RENDER_MODE] = useCcstyle;
@@ -585,19 +586,27 @@ function installGlobalToolRendering(
585
586
  return originalRender.call(this, width);
586
587
  }
587
588
  const cache = patch.paintCache;
588
- const hit = cache?.get(this);
589
+ // A running partial tool owns a time-varying spinner. Light animation ticks
590
+ // request a repaint without invalidating the component, so settled-content
591
+ // caching must not intercept those paints.
592
+ const cacheable = !(this.executionStarted && this.isPartial);
593
+ const hit = cacheable ? cache?.get(this) : undefined;
589
594
  if (hit && toolPaintMatches(hit, this, width)) return hit.lines;
590
595
  const lines = originalRender.call(this, width);
591
- cache?.set(this, {
592
- width,
593
- expanded: Boolean(this.expanded),
594
- isPartial: Boolean(this.isPartial),
595
- result: this.result,
596
- args: this.args,
597
- callHover: isToolCallHovered(this.toolCallId),
598
- ioHover: ioHoverOf(this),
599
- lines,
600
- });
596
+ if (cacheable) {
597
+ cache?.set(this, {
598
+ width,
599
+ expanded: Boolean(this.expanded),
600
+ isPartial: Boolean(this.isPartial),
601
+ result: this.result,
602
+ args: this.args,
603
+ callHover: isToolCallHovered(this.toolCallId),
604
+ ioHover: ioHoverOf(this),
605
+ lines,
606
+ });
607
+ } else {
608
+ cache?.delete(this);
609
+ }
601
610
  return lines;
602
611
  };
603
612
  patch.invalidatePaint = function (this: any): void {
@@ -30,7 +30,7 @@ import {
30
30
  teardownToolMouseInteraction,
31
31
  TOOL_MOUSE_DISABLE,
32
32
  } from "./mouse/interaction.ts";
33
- import { getToolMouseTui } from "./mouse/scroll.ts";
33
+ import { getToolMouseTui, noteNewTranscriptItem } from "./mouse/scroll.ts";
34
34
  import { setHoveredToolGroup, setHoveredToolIo } from "./mouse/hover.ts";
35
35
  import { clearAllAnimations } from "./tool/result.ts";
36
36
  import { installWriteOverride, WriteExecutionMetadataStore } from "./tool/diff/index.ts";
@@ -189,6 +189,13 @@ export default function (
189
189
  }
190
190
  });
191
191
 
192
+ // 回到底部按钮的累计计数:离开底部期间每落一块内容 +1。
193
+ pi.on("message_start", async (event) => {
194
+ const role = event.message?.role;
195
+ // toolResult 不单独成块(渲染在工具卡内),跳过以免与工具卡重复计数。
196
+ if (role === "user" || role === "assistant" || role === "custom") noteNewTranscriptItem();
197
+ });
198
+
192
199
  pi.on("tool_execution_end", async (event) => {
193
200
  if (config.mode !== "compact") return;
194
201
  // Agent 等工具收尾后延迟刷新,让 compact-thinking 先落最终态。
@@ -243,6 +250,7 @@ export default function (
243
250
 
244
251
  pi.on("tool_execution_start", async (_event, ctx) => {
245
252
  installation?.toolGrouping.setTheme(ctx.ui.theme);
253
+ noteNewTranscriptItem();
246
254
  });
247
255
 
248
256
  pi.on("session_shutdown", async () => {
@@ -1,13 +1,21 @@
1
1
  import { ToolExecutionComponent } from "@earendil-works/pi-coding-agent";
2
2
  import { hasActiveTextPreview, showTextPreview } from "../../feature/context.ts";
3
3
  import { ThinkingPreviewBlock } from "../../feature/compact-thinking.ts";
4
- import { patchRegistry, TOOL_MOUSE_OWNER_KEY } from "../../utils/patch-keys.ts";
4
+ import {
5
+ patchRegistry,
6
+ TOOL_GROUPING_PARENT_KEY,
7
+ TOOL_MOUSE_OWNER_KEY,
8
+ } from "../../utils/patch-keys.ts";
5
9
  import { ToolGroupComponent } from "../tool/grouping.ts";
6
- import { isCompactAssistantComponent, setHoveredCompactAssistant } from "../compact-mode.ts";
10
+ import {
11
+ isCompactAssistantComponent,
12
+ markCompactRoundToolExpanded,
13
+ setHoveredCompactAssistant,
14
+ } from "../compact-mode.ts";
7
15
  import { isMessageDisplayComponent } from "../tool/message-display.ts";
8
16
  import { config } from "../../config/config.ts";
9
17
  import { isLazyProxyTui } from "../../utils/fullscreen-detect.ts";
10
- import { setToolTuiFullscreen } from "../tool/show-more-hint.ts";
18
+ import { setToolTuiFullscreen, isCollapseHintLine } from "../tool/show-more-hint.ts";
11
19
  import {
12
20
  type ExpandedToolIoView,
13
21
  getActiveIoViewFrame,
@@ -76,7 +84,7 @@ type FrameToolRender = {
76
84
  };
77
85
 
78
86
  type InteractionRegion = {
79
- kind: "collapsed-hint" | "expanded-card" | "show-more" | "scroll-bottom";
87
+ kind: "collapsed-hint" | "collapse-hint" | "expanded-card" | "show-more" | "scroll-bottom";
80
88
  row: number;
81
89
  startCol: number;
82
90
  endCol: number;
@@ -119,6 +127,7 @@ function interactionRegionAt(packet: SgrMousePacket): InteractionRegion | null {
119
127
  matches.find((region) => region.kind === "show-more") ??
120
128
  matches.find((region) => region.kind === "scroll-bottom") ??
121
129
  matches.find((region) => region.kind === "collapsed-hint") ??
130
+ matches.find((region) => region.kind === "collapse-hint") ??
122
131
  matches.find((region) => region.kind === "expanded-card") ??
123
132
  null
124
133
  );
@@ -146,7 +155,12 @@ function updateToolSummaryHover(tui: any, packet: SgrMousePacket): void {
146
155
  const scrollButtonChanged = setScrollButtonHovered(nextScrollButtonHovered);
147
156
  const component = region?.component;
148
157
  const nextToolCallId = region?.kind === "collapsed-hint" ? (component?.toolCallId ?? null) : null;
149
- const nextGroup = component instanceof ToolGroupComponent ? component : null;
158
+ // group 只在提示文字上高亮(展开卡整行都是 expanded-card,不能算 hint)。
159
+ const nextGroup =
160
+ (region?.kind === "collapsed-hint" || region?.kind === "collapse-hint") &&
161
+ component instanceof ToolGroupComponent
162
+ ? component
163
+ : null;
150
164
  const nextIoView = region?.kind === "show-more" ? (region.view ?? null) : null;
151
165
  const nextIoSection = region?.kind === "show-more" ? (region.section ?? null) : null;
152
166
  const changed = nextToolCallId !== sharedToolHoverState().toolCallId;
@@ -160,75 +174,92 @@ function updateToolSummaryHover(tui: any, packet: SgrMousePacket): void {
160
174
  tui.requestRender?.();
161
175
  }
162
176
 
163
- const EXPAND_PANEL_DOUBLE_CLICK_MS = 400;
164
- /** 松开后短时间内的重复按下视为同一次单击(终端会在 mouseup 后再打一次 press)。 */
165
- const EXPAND_PANEL_CLICK_ARM_MS = 50;
166
- let pendingExpandPress: { id: unknown } | null = null;
167
- let lastExpandClick: { id: unknown; at: number } | null = null;
168
- let armExpandClickTimer: ReturnType<typeof setTimeout> | null = null;
177
+ /** 单击判定:按下与松开落在同一格(renderer 合成 click 的条件),位移与时限各留容差。 */
178
+ const COLLAPSE_CLICK_MOVE_TOLERANCE = 1;
179
+ const COLLAPSE_CLICK_MAX_MS = 600;
180
+ /** 扩展收起自有卡后,官方 click 可能对同一次点击再 toggle 内部工具卡,短暂吞掉。 */
181
+ const SUPPRESS_OFFICIAL_CARD_CLICK_MS = 300;
182
+ /** 官方 fullscreen 的焦点事件串:发给原 handler 可清掉选区锚点(见 tui-alt-screen)。 */
183
+ const FULLSCREEN_FOCUS_OUT = "\x1b[O";
169
184
 
170
- function expandPanelIdentity(card: any): unknown {
171
- return card instanceof ThinkingPreviewBlock
172
- ? `${card.messageTimestamp}:${card.runStartIndex}`
173
- : card;
174
- }
185
+ let pendingCollapsePress: {
186
+ card: any;
187
+ /** 命中的最内层组件:官方工具卡由 MouseRegion 的 click 收起。 */
188
+ component: any;
189
+ col: number;
190
+ row: number;
191
+ at: number;
192
+ } | null = null;
193
+ let suppressOfficialCardClickUntil = 0;
175
194
 
176
- function isExpandPanelDoubleClick(card: any): boolean {
177
- const now = Date.now();
178
- const id = expandPanelIdentity(card);
179
- const prev = lastExpandClick;
180
- pendingExpandPress = { id };
181
- if (prev && prev.id === id && now - prev.at <= EXPAND_PANEL_DOUBLE_CLICK_MS) {
182
- pendingExpandPress = null;
183
- lastExpandClick = null;
184
- return true;
185
- }
186
- return false;
195
+ function clearPendingCollapsePress(): void {
196
+ pendingCollapsePress = null;
187
197
  }
188
198
 
189
- function completeExpandPanelClick(): void {
190
- if (!pendingExpandPress) return;
191
- const click = { id: pendingExpandPress.id, at: Date.now() };
192
- pendingExpandPress = null;
193
- if (armExpandClickTimer) clearTimeout(armExpandClickTimer);
194
- armExpandClickTimer = setTimeout(() => {
195
- armExpandClickTimer = null;
196
- // 50ms 内又按下:仍是同一次单击,不能武装成双击。
197
- if (pendingExpandPress) return;
198
- lastExpandClick = click;
199
- }, EXPAND_PANEL_CLICK_ARM_MS);
200
- if (
201
- typeof armExpandClickTimer === "object" &&
202
- armExpandClickTimer &&
203
- "unref" in armExpandClickTimer
204
- ) {
205
- armExpandClickTimer.unref();
199
+ /** 带键拖动或滚轮:位置真的变了(超出抖动容差)就不再是单击。 */
200
+ function clearPendingCollapsePressOnMove(packet: SgrMousePacket): void {
201
+ const press = pendingCollapsePress;
202
+ if (!press) return;
203
+ if ((packet.code & 64) !== 0) {
204
+ pendingCollapsePress = null;
205
+ return;
206
206
  }
207
- }
208
-
209
- function clearExpandPanelDoubleClick(): void {
210
- pendingExpandPress = null;
211
- lastExpandClick = null;
212
- if (armExpandClickTimer) {
213
- clearTimeout(armExpandClickTimer);
214
- armExpandClickTimer = null;
207
+ if (Math.abs(packet.col - press.col) <= COLLAPSE_CLICK_MOVE_TOLERANCE) {
208
+ if (Math.abs(packet.row - press.row) <= COLLAPSE_CLICK_MOVE_TOLERANCE) return;
215
209
  }
210
+ pendingCollapsePress = null;
216
211
  }
217
212
 
218
- function collapseExpandedCard(tui: any, card: any): boolean {
219
- // 单击也 consume,避免官方链再合成一次 press 把单击当成双击。
220
- if (!isExpandPanelDoubleClick(card)) return true;
221
- card.setExpanded(false);
213
+ function clearHoverState(): void {
222
214
  setHoveredToolCallId(null);
223
215
  setHoveredToolGroup(null);
224
216
  setHoveredThinking(null);
225
217
  setHoveredMessageDisplay(null);
226
218
  setHoveredToolIo(null, null);
227
219
  setHoveredCompactAssistant(null);
220
+ }
221
+
222
+ /** 收起一张展开卡(group 的 setExpanded 会传播到内部工具)。 */
223
+ function collapseExpandedCard(tui: any, card: any): void {
224
+ card.setExpanded(false);
225
+ clearHoverState();
228
226
  card.invalidate?.();
229
- tui.requestRender?.();
230
- clearExpandPanelDoubleClick();
231
- return true;
227
+ tui?.requestRender?.();
228
+ }
229
+
230
+ function rememberCollapsePress(card: any, component: any, packet: SgrMousePacket): void {
231
+ pendingCollapsePress = { card, component, col: packet.col, row: packet.row, at: Date.now() };
232
+ }
233
+
234
+ /**
235
+ * 松手结算:只有"按下与松开在同一格"的完整单击才收起,拖动选择文本时保持展开。
236
+ * 官方工具卡(result 区的 MouseRegion)由 guard 在官方 click 里收起,这里必须跳过,
237
+ * 否则先收起会被官方 click 的 setExpanded(!expanded) 翻回来。
238
+ */
239
+ function resolveCollapsePress(
240
+ tui: any,
241
+ packet: SgrMousePacket,
242
+ options: { skipOfficialCards: boolean },
243
+ ): any {
244
+ const press = pendingCollapsePress;
245
+ pendingCollapsePress = null;
246
+ if (!press || packet.final !== "m") return undefined;
247
+ if (Math.abs(packet.col - press.col) > COLLAPSE_CLICK_MOVE_TOLERANCE) return undefined;
248
+ if (Math.abs(packet.row - press.row) > COLLAPSE_CLICK_MOVE_TOLERANCE) return undefined;
249
+ if (Date.now() - press.at > COLLAPSE_CLICK_MAX_MS) return undefined;
250
+ if (options.skipOfficialCards && press.component instanceof ToolExecutionComponent)
251
+ return undefined;
252
+ const card = press.card;
253
+ if (
254
+ (card instanceof ToolGroupComponent || isCompactAssistantComponent(card)) &&
255
+ press.component !== card
256
+ ) {
257
+ // 点击落在卡内工具行:官方 click 会对同一次点击再次 toggle,吞掉它。
258
+ suppressOfficialCardClickUntil = Date.now() + SUPPRESS_OFFICIAL_CARD_CLICK_MS;
259
+ }
260
+ collapseExpandedCard(tui, card);
261
+ // 返回收起的卡:调用方只对 compact 面板清官方选区。
262
+ return card;
232
263
  }
233
264
 
234
265
  function toggleToolAtMouseClick(tui: any, packet: SgrMousePacket): boolean {
@@ -238,9 +269,13 @@ function toggleToolAtMouseClick(tui: any, packet: SgrMousePacket): boolean {
238
269
  if (region.kind === "show-more") return tryOpenToolIoShowMore(region);
239
270
  const component = region.component;
240
271
  if (!component) return false;
241
- if (region.kind === "expanded-card") return collapseExpandedCard(tui, component);
272
+ if (region.kind === "expanded-card" || region.kind === "collapse-hint") {
273
+ // regular 模式没有官方 click 合成,按下先记账,松手结算是否收起。
274
+ rememberCollapsePress(component, component, packet);
275
+ return true;
276
+ }
242
277
  component.setExpanded(true);
243
- clearExpandPanelDoubleClick();
278
+ clearPendingCollapsePress();
244
279
  setHoveredToolCallId(null);
245
280
  setHoveredToolGroup(null);
246
281
  setHoveredToolIo(null, null);
@@ -339,9 +374,10 @@ function blocksOfficialCardToggle(component: any, event: any): boolean {
339
374
 
340
375
  /**
341
376
  * 官方 fullscreen 工具卡点击:collapsed hint 单击展开
342
- * (有且仅保持一个展开:展开前收起其他工具卡),expanded 整卡双击收起,
343
- * 截断头 show-more 单击打开全量预览;回到底部按钮 scrollToBottom。
344
- * 滚动条列、含 OSC8 链接行、非工具区域、展开卡单击放行官方。
377
+ * (有且仅保持一个展开:展开前收起其他工具卡),expanded 卡放行官方 press,
378
+ * 位置不变的松手才收起(官方卡经 guard,思考/group/compact 自有卡由扩展结算),
379
+ * 拖动选择文本时不收起;截断头 show-more 单击打开全量预览;回到底部按钮 scrollToBottom。
380
+ * 滚动条列、含 OSC8 链接行、非工具区域、折叠卡正文放行官方。
345
381
  */
346
382
  function handleFullscreenToolClick(tui: any, packet: SgrMousePacket): boolean {
347
383
  const layout = tui.currentLayout;
@@ -359,7 +395,10 @@ function handleFullscreenToolClick(tui: any, packet: SgrMousePacket): boolean {
359
395
  const target = componentAtLocalRow(hit.box.component, hit.localRow, contentWidth);
360
396
  if (!target) return false;
361
397
  const component = target.component;
362
- const card = target.group ?? component;
398
+ // compact 展开面板内命中时 owner 是面板:收起对象与记账对象都归面板,
399
+ // 否则 skipOfficialCards 会把内部工具当成官方卡跳过。
400
+ const card = target.group ?? target.owner ?? component;
401
+ const pressComponent = target.owner ?? component;
363
402
  // 回到底部按钮:按组件引用命中,不依赖渲染行缓存。
364
403
  if (getScrollButtonVisible() && component === getScrollButtonWidget()) {
365
404
  tui.scrollToBottom?.();
@@ -377,21 +416,32 @@ function handleFullscreenToolClick(tui: any, packet: SgrMousePacket): boolean {
377
416
  if (!component.expanded) {
378
417
  // collapsed 仅按钮文本可展开,不能把同一行正文/留白变成点击区。
379
418
  const hint = collapsedHintHitbox(line);
380
- if (!hint || packet.col < hint.startCol || packet.col > hint.endCol) return false;
381
- if (!isCollapsedHintRow(component, line)) return false;
419
+ const onHint = Boolean(
420
+ hint &&
421
+ packet.col >= hint.startCol &&
422
+ packet.col <= hint.endCol &&
423
+ isCollapsedHintRow(component, line),
424
+ );
425
+ if (!onHint) {
426
+ // 面板内非提示区(工具卡标题/摘要行、thinking 预览正文):单击收起整块面板。
427
+ if (target.owner) rememberCollapsePress(target.owner, target.owner, packet);
428
+ return false;
429
+ }
382
430
  // single-expand:展开前收起其他已展开工具卡/group。
383
431
  const others: any[] = [];
384
432
  collectFullscreenToolCards(hit.box.component, others);
385
433
  for (const other of others) {
386
434
  if (other !== component && other.expanded) {
387
- // 展开 round 内 thinking 时不要把外层 compact 卡收起。
388
- if (isThinking && isCompactAssistantComponent(other)) continue;
435
+ // 展开 round 卡内 thinking/工具时,外层 compact 卡是它的容器,不能收起。
436
+ if ((isThinking || isTool) && isCompactAssistantComponent(other)) continue;
389
437
  other.setExpanded(false);
390
438
  other.invalidate?.();
391
439
  }
392
440
  }
441
+ // 展开 round 卡内工具时,让 compact 的强制折叠放行它(非 round 内工具为空操作)。
442
+ if (isTool) markCompactRoundToolExpanded(component);
393
443
  component.setExpanded(true);
394
- clearExpandPanelDoubleClick();
444
+ clearPendingCollapsePress();
395
445
  } else {
396
446
  // 普通工具截断头 show-more:打开全量预览(不收起)。
397
447
  const view = isTool ? component.resultRendererComponent : null;
@@ -399,7 +449,7 @@ function handleFullscreenToolClick(tui: any, packet: SgrMousePacket): boolean {
399
449
  const plain = stripTerminalSequencesPreservingLayout(line);
400
450
  const section = view.matchShowMoreLine(plain);
401
451
  if (section) {
402
- clearExpandPanelDoubleClick();
452
+ clearPendingCollapsePress();
403
453
  const box = view.showMoreHitbox(plain);
404
454
  return tryOpenToolIoShowMore({
405
455
  kind: "show-more",
@@ -412,17 +462,20 @@ function handleFullscreenToolClick(tui: any, packet: SgrMousePacket): boolean {
412
462
  });
413
463
  }
414
464
  }
415
- // 双击收起:内部工具仍归所属 group。单击放行官方(选择/链接)。
416
- return collapseExpandedCard(tui, card);
465
+ // 其余放行官方:选区、OSC8 链接与 click 合成都交给 renderer。
466
+ // 完整单击的收起:官方卡由 guard 处理,自有卡在松手时结算。
467
+ rememberCollapsePress(card, pressComponent, packet);
468
+ return false;
417
469
  }
418
- // 点击后清 hover 高亮。
470
+ // 点击后清 hover 高亮。只 invalidate 点中的组件(或它所在的 group),
471
+ // 不要动 owner 面板:面板 invalidate 会连带重跑 updateContent,重建 thinking 实例。
419
472
  setHoveredToolCallId(null);
420
473
  setHoveredToolGroup(null);
421
474
  setHoveredThinking(null);
422
475
  setHoveredMessageDisplay(null);
423
476
  setHoveredToolIo(null, null);
424
477
  setHoveredCompactAssistant(null);
425
- card.invalidate?.();
478
+ (target.group ?? component).invalidate?.();
426
479
  tui.requestRender?.();
427
480
  return true;
428
481
  }
@@ -526,7 +579,22 @@ function patchFullscreenViewportInput(tui: any): void {
526
579
  if (packets && tui.hasOverlay?.() && hasActiveTextPreview()) return undefined;
527
580
  if (packets && !tui.hasOverlay?.()) {
528
581
  for (const packet of packets) {
529
- if (isSgrLeftRelease(packet)) completeExpandPanelClick();
582
+ if (isSgrLeftRelease(packet)) {
583
+ // 只给 compact 面板清选区:自有面板收起会重排布局,官方选区锚点
584
+ // 还停在旧布局上,同一次 release 会结算出多行高亮。
585
+ if (
586
+ isCompactAssistantComponent(
587
+ resolveCollapsePress(tui, packet, { skipOfficialCards: true }),
588
+ )
589
+ ) {
590
+ try {
591
+ Reflect.apply(original, this, [FULLSCREEN_FOCUS_OUT]);
592
+ } catch {
593
+ /* 老版本没有选区状态 */
594
+ }
595
+ }
596
+ } else if (!isSgrLeftPress(packet) && !isSgrIdleMotion(packet))
597
+ clearPendingCollapsePressOnMove(packet);
530
598
  if (isSgrLeftPress(packet) && handleFullscreenToolClick(tui, packet)) {
531
599
  return { consume: true };
532
600
  }
@@ -639,6 +707,11 @@ function buildInteractionFrame(
639
707
  if (placement.componentRow < cardStart) continue;
640
708
  const finalRow = lineIndexToScreenRow(placement.lineIndex);
641
709
  if (finalRow >= 1 && finalRow <= visibleRows) {
710
+ // 展开态的 ↑ Collapse 提示单独成区:hover 高亮 hint,点击仍收起整卡。
711
+ const hintBox = collapsedHintHitbox(placement.finalLine);
712
+ if (hintBox && isCollapseHintLine(stripTerminalSequences(placement.finalLine))) {
713
+ regions.push({ kind: "collapse-hint", row: finalRow, ...hintBox, component });
714
+ }
642
715
  regions.push({
643
716
  kind: "expanded-card",
644
717
  row: finalRow,
@@ -868,8 +941,15 @@ function handleToolMouseInput(data: string): { consume: true } | undefined {
868
941
  let consumed = false;
869
942
  for (const packet of packets) {
870
943
  updateToolSummaryHover(getToolMouseTui(), packet);
871
- if (isSgrLeftRelease(packet)) completeExpandPanelClick();
872
- if (!isSgrLeftPress(packet)) continue;
944
+ if (isSgrLeftRelease(packet)) {
945
+ resolveCollapsePress(getToolMouseTui(), packet, { skipOfficialCards: false });
946
+ continue;
947
+ }
948
+ if (!isSgrLeftPress(packet)) {
949
+ // 带键拖动或滚轮:位置变了就不再是单击。
950
+ if (!isSgrIdleMotion(packet)) clearPendingCollapsePressOnMove(packet);
951
+ continue;
952
+ }
873
953
  if (toggleToolAtMouseClick(getToolMouseTui(), packet)) {
874
954
  consumed = true;
875
955
  }
@@ -899,7 +979,8 @@ export function teardownToolMouseInteraction(
899
979
  setHoveredToolIo(null, null);
900
980
  setHoveredCompactAssistant(null);
901
981
  releaseToolCardMouseGuard();
902
- clearExpandPanelDoubleClick();
982
+ clearPendingCollapsePress();
983
+ suppressOfficialCardClickUntil = 0;
903
984
  try {
904
985
  if (isLazyProxyTui(getToolMouseTui())) releaseFullscreenToolMouseMotion(getToolMouseTui());
905
986
  else getToolMouseTui()?.terminal?.write?.(TOOL_MOUSE_DISABLE);
@@ -947,6 +1028,13 @@ type ToolCardMouseGuard = {
947
1028
  wrapper: (event: any) => any;
948
1029
  };
949
1030
 
1031
+ /** 官方 click 落在展开卡上:同一 group 的任意内部工具行都收起整个 group。 */
1032
+ function collapseExpandedCardFromOfficialClick(component: any): void {
1033
+ if (typeof component?.setExpanded !== "function") return;
1034
+ const group = component?.[TOOL_GROUPING_PARENT_KEY];
1035
+ collapseExpandedCard(getToolMouseTui(), group instanceof ToolGroupComponent ? group : component);
1036
+ }
1037
+
950
1038
  function installToolCardMouseGuard(): void {
951
1039
  const prototype = (ToolExecutionComponent as any)?.prototype;
952
1040
  if (typeof prototype?.handleMouse !== "function") return;
@@ -956,6 +1044,19 @@ function installToolCardMouseGuard(): void {
956
1044
  // 每次取当前值作为下游,可能是别的扩展的包装。
957
1045
  const original = prototype.handleMouse;
958
1046
  const wrapper = function (this: any, event: any) {
1047
+ if (event?.type === "click" && event.button === "left") {
1048
+ // 扩展刚收起过自有卡:这次 click 属于同一次单击,吞掉避免内部工具被翻回展开。
1049
+ if (suppressOfficialCardClickUntil && Date.now() <= suppressOfficialCardClickUntil) {
1050
+ suppressOfficialCardClickUntil = 0;
1051
+ if (this?.expanded) collapseExpandedCardFromOfficialClick(this);
1052
+ return { handled: true };
1053
+ }
1054
+ // 展开卡整卡 click:接管官方 toggle,改为收起(group 内部工具行收起整个 group)。
1055
+ if (this?.expanded && typeof this.setExpanded === "function") {
1056
+ collapseExpandedCardFromOfficialClick(this);
1057
+ return { handled: true };
1058
+ }
1059
+ }
959
1060
  if (blocksOfficialCardToggle(this, event)) return { handled: true };
960
1061
  return original.call(this, event);
961
1062
  };
@@ -11,12 +11,17 @@ export type ComponentRowHit = {
11
11
  row: number;
12
12
  /** 内部工具命中时所属的展开 group;普通卡点击仍折叠整个 group。 */
13
13
  group?: ToolGroupComponent;
14
+ /** compact 展开面板内的命中:外层面板,单击非提示区时收起它。 */
15
+ owner?: any;
14
16
  };
15
17
 
16
- /** 行内 [click to show more] 提示的命中列区间(1-based,含两端)。 */
18
+ /** 行内 [click to show more] / [↑ Collapse] 提示的命中列区间(1-based,含两端)。 */
17
19
  export function collapsedHintHitbox(line: string): { startCol: number; endCol: number } | null {
18
20
  const plain = stripTerminalSequencesPreservingLayout(line);
19
- const match = /(\([^()\n]* \/ click\)|click to show more|to show more)(?=\)?\s*$)/.exec(plain);
21
+ const match =
22
+ /(\([^()\n]* \/ click\)|click to show more|to show more|↑ Collapse|to collapse)(?=\)?\s*$)/.exec(
23
+ plain,
24
+ );
20
25
  if (!match?.[1]) return null;
21
26
  const startCol = visibleWidth(plain.slice(0, match.index)) + 1;
22
27
  return { startCol, endCol: startCol + visibleWidth(match[1]) - 1 };
@@ -119,11 +124,15 @@ export function componentAtLocalRow(
119
124
  return { component, row: localRow };
120
125
  }
121
126
  if (isCompactAssistantComponent(component)) {
122
- // 折叠:整行摘要。展开:先命中内部 thinking hint,其余仍归外层卡片。
127
+ // 折叠:整行摘要。展开:先命中面板内 thinking/工具卡/文本,其余(空行)仍归外层卡片。
123
128
  if (component.expanded === true) {
124
129
  const inner = nestedChildAtRow(component, localRow, width);
125
- if (inner instanceof ThinkingPreviewBlock) {
126
- return { component: inner, row: localRow };
130
+ if (inner) {
131
+ // 展开的 thinking 有自己的展开态:点它只收它自己,不收整块面板。
132
+ const ownExpandable = inner instanceof ThinkingPreviewBlock && inner.expanded === true;
133
+ return ownExpandable
134
+ ? { component: inner, row: localRow }
135
+ : { component: inner, row: localRow, owner: component };
127
136
  }
128
137
  }
129
138
  return { component, row: localRow };
@@ -27,12 +27,19 @@ export function setToolMouseTui(tui: any): void {
27
27
  patchRegistry.install(TOOL_MOUSE_TUI_SLOT, tui);
28
28
  }
29
29
 
30
- type ScrollButtonState = { visible: boolean; hovered: boolean; widget: any };
30
+ type ScrollButtonState = {
31
+ visible: boolean;
32
+ hovered: boolean;
33
+ widget: any;
34
+ /** 按钮可见期间新落的 transcript 块数(消息 + 工具卡)。 */
35
+ newCount: number;
36
+ };
31
37
  function scrollButtonState(): ScrollButtonState {
32
38
  return patchRegistry.ensure(SCROLL_BUTTON_STATE_SLOT, () => ({
33
39
  visible: false,
34
40
  hovered: false,
35
41
  widget: null,
42
+ newCount: 0,
36
43
  }));
37
44
  }
38
45
  export function getScrollButtonVisible(): boolean {
@@ -44,8 +51,32 @@ export function getScrollButtonHovered(): boolean {
44
51
  export function getScrollButtonWidget(): any {
45
52
  return scrollButtonState().widget;
46
53
  }
54
+ export function getScrollButtonNewCount(): number {
55
+ return scrollButtonState().newCount;
56
+ }
47
57
  export function setScrollButtonVisible(visible: boolean): void {
48
- scrollButtonState().visible = visible;
58
+ const state = scrollButtonState();
59
+ state.visible = visible;
60
+ // 计数只在离开底部期间有效:跟随输出即清零,文案回到 Back to bottom。
61
+ if (!visible) state.newCount = 0;
62
+ }
63
+
64
+ /**
65
+ * 记账一块新落进 transcript 的内容(用户/助手消息、工具卡)。
66
+ * 仅在按钮可见(已滚动离开底部)时累加,并请求重绘让按钮文案立即更新。
67
+ */
68
+ export function noteNewTranscriptItem(): void {
69
+ const state = scrollButtonState();
70
+ const tui = getToolMouseTui();
71
+ if (!state.visible || !tui) return;
72
+ // 提交新消息时官方可能直接跳回底部(不经过滚动输入):先按实时跟随状态校正,
73
+ // 否则跟随期间的内容会被当成“离开底部时的新消息”计入。
74
+ if (isAtTranscriptBottom(tui)) {
75
+ hideScrollButton(tui);
76
+ return;
77
+ }
78
+ state.newCount += 1;
79
+ tui.requestRender?.();
49
80
  }
50
81
 
51
82
  /** 返回是否发生变化(调用方据此决定是否需要重渲染)。 */
@@ -64,6 +95,7 @@ export function resetScrollButtonState(): void {
64
95
  scrollButtonState().visible = false;
65
96
  scrollButtonState().hovered = false;
66
97
  scrollButtonState().widget = null;
98
+ scrollButtonState().newCount = 0;
67
99
  scrollButtonSyncScheduled = false;
68
100
  }
69
101
 
@@ -206,12 +238,19 @@ export function updateScrollButtonFromInput(tui: any, data: string): void {
206
238
  if (matchesKey(data, "enter") || matchesKey(data, "return")) hideScrollButton(tui);
207
239
  }
208
240
 
241
+ /** 无新内容时提示原文案;有新内容时换成累计条数。 */
242
+ function scrollButtonText(): string {
243
+ const count = getScrollButtonNewCount();
244
+ const shortcut = formatShortcut(SCROLL_BOTTOM_SHORTCUT);
245
+ if (count <= 0) return `Back to bottom · ${shortcut}`;
246
+ return `${count} new message${count === 1 ? "" : "s"} · ${shortcut}`;
247
+ }
248
+
209
249
  export function renderScrollButton(width: number, theme: any): string[] {
210
250
  if (!getScrollButtonVisible() || !fullscreenLazyTui(getToolMouseTui())) return [];
211
- const shortcut = formatShortcut(SCROLL_BOTTOM_SHORTCUT);
212
251
  const label = theme.fg(
213
252
  getScrollButtonHovered() ? "text" : "accent",
214
- `[ ↓ Back to bottom · ${shortcut} ]`,
253
+ `[ ↓ ${scrollButtonText()} ]`,
215
254
  );
216
255
  const leftPad = Math.max(0, Math.floor((width - visibleWidth(label)) / 2));
217
256
  return [`${" ".repeat(leftPad)}${truncateToWidth(label, width, "…")}`];
@@ -9,7 +9,7 @@ import {
9
9
  import { TOOL_LOADING_INTERVAL_MS, toolLoadingIcon } from "../../utils/tool-loading-icon.ts";
10
10
  import { getToolMouseTui } from "../mouse/scroll.ts";
11
11
  import { mcpToolTitle } from "./mcp-title.ts";
12
- import { isToolTuiFullscreen, showMoreHintText } from "./show-more-hint.ts";
12
+ import { collapseHintText, isToolTuiFullscreen, showMoreHintText } from "./show-more-hint.ts";
13
13
  import { stripAnsi, stripBackgroundAnsi, stripLeadingStatusIcon } from "../../utils/ansi-text.ts";
14
14
  import { walkComponentTree } from "../../utils/component-tree.ts";
15
15
  import {
@@ -372,7 +372,8 @@ export class ToolGroupComponent extends Container {
372
372
  const overallColor = overall === "pending" ? "accent" : overall;
373
373
  const nameList = names.size > 1 ? ` ${fg("dim", `• ${toolNameList(this.children)}`)}` : "";
374
374
  // 圆点保持 dim;hover 只高亮可点击文字。
375
- const hint = `${fg("dim", "•")} ${fg(this.hintHovered ? "text" : "dim", showMoreHintText())}`;
375
+ const hintText = this._expanded ? collapseHintText() : showMoreHintText();
376
+ const hint = `${fg("dim", "•")} ${fg(this.hintHovered ? "text" : "dim", hintText)}`;
376
377
  const lines = [
377
378
  "",
378
379
  truncateToWidth(
@@ -36,3 +36,13 @@ export function expandShortcutText(): string {
36
36
  export function showMoreHintText(): string {
37
37
  return isToolTuiFullscreen() ? "click to show more" : `${expandShortcutText()} to show more`;
38
38
  }
39
+
40
+ /** 展开卡的收起提示,与折叠态 showMoreHintText 同一位置对称。 */
41
+ export function collapseHintText(): string {
42
+ return isToolTuiFullscreen() ? "↑ Collapse" : `${expandShortcutText()} to collapse`;
43
+ }
44
+
45
+ /** 该行是否带展开态收起提示(用于展开卡的 hint hover 命中)。 */
46
+ export function isCollapseHintLine(plainLine: string): boolean {
47
+ return plainLine.includes(collapseHintText());
48
+ }