@herbertgao/pi-extensions 2026.9.18 → 2026.9.20

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.
Files changed (226) hide show
  1. package/README.md +20 -28
  2. package/THIRD_PARTY_NOTICES.md +1 -1
  3. package/node_modules/@herbertgao/pi-cc-extensions/README.en.md +76 -59
  4. package/node_modules/@herbertgao/pi-cc-extensions/README.md +76 -59
  5. package/node_modules/@herbertgao/pi-cc-extensions/assets/readme/preview.webp +0 -0
  6. package/node_modules/@herbertgao/pi-cc-extensions/extensions/config/config.ts +93 -34
  7. package/node_modules/@herbertgao/pi-cc-extensions/extensions/config/panel.ts +346 -117
  8. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/agent-summary/core.ts +6 -36
  9. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/agent-summary/index.ts +2 -2
  10. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/compact-thinking.ts +24 -29
  11. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/context.ts +330 -360
  12. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/reference/index.ts +56 -108
  13. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/reference/session.ts +97 -15
  14. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/reference/subagent.ts +80 -45
  15. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/shell/flush-docked-bash.ts +36 -0
  16. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/shell/footer-layout.ts +229 -0
  17. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/shell/footer.ts +479 -0
  18. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/shell/startup-header.ts +92 -61
  19. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/shell/working-message.ts +75 -40
  20. package/node_modules/@herbertgao/pi-cc-extensions/extensions/index.ts +19 -3
  21. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/compact-mode.ts +1080 -229
  22. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/default-mode.ts +232 -259
  23. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/index.ts +42 -41
  24. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/markdown-enhance.ts +8 -2
  25. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/hover.ts +2 -7
  26. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/interaction.ts +277 -113
  27. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/layout.ts +15 -6
  28. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/packets.ts +18 -72
  29. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/scroll.ts +61 -26
  30. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/ansi-utils.ts +25 -57
  31. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/diff-component.ts +125 -0
  32. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/diff-edit-render.ts +227 -0
  33. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/diff-header.ts +136 -0
  34. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/diff-highlight.ts +115 -0
  35. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/diff-inline.ts +324 -0
  36. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/diff-layout.ts +762 -0
  37. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/diff-limits.ts +152 -0
  38. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/diff-palette.ts +410 -0
  39. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/diff-parse.ts +535 -0
  40. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/diff-presentation.ts +2 -2
  41. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/diff-renderer.ts +21 -3219
  42. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/diff-text.ts +44 -0
  43. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/diff-write-render.ts +568 -0
  44. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/index.ts +47 -10
  45. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/line-width-safety.ts +2 -2
  46. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/write-execution.ts +1 -0
  47. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/grouping.ts +145 -190
  48. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/mcp-title.ts +39 -0
  49. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/{message-display.ts → tool/message-display.ts} +24 -90
  50. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/names.ts +426 -0
  51. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/result.ts +90 -132
  52. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/{show-more-hint.ts → tool/show-more-hint.ts} +10 -0
  53. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/transcript-refresh.ts +8 -26
  54. package/node_modules/@herbertgao/pi-cc-extensions/extensions/utils/ansi-color.ts +44 -0
  55. package/node_modules/@herbertgao/pi-cc-extensions/extensions/utils/ansi-text.ts +76 -0
  56. package/node_modules/@herbertgao/pi-cc-extensions/extensions/utils/component-tree.ts +34 -0
  57. package/node_modules/@herbertgao/pi-cc-extensions/extensions/utils/format.ts +28 -0
  58. package/node_modules/@herbertgao/pi-cc-extensions/extensions/utils/fullscreen-detect.ts +51 -0
  59. package/node_modules/@herbertgao/pi-cc-extensions/extensions/utils/patch-keys.ts +124 -0
  60. package/node_modules/@herbertgao/pi-cc-extensions/extensions/utils/sgr-mouse.ts +70 -0
  61. package/node_modules/@herbertgao/pi-cc-extensions/package.json +8 -8
  62. package/node_modules/pi-antigravity/README.md +29 -6
  63. package/node_modules/pi-antigravity/package.json +12 -7
  64. package/node_modules/pi-antigravity/src/auth/accounts.ts +327 -0
  65. package/node_modules/pi-antigravity/src/auth/index.ts +9 -0
  66. package/node_modules/pi-antigravity/src/client/client.ts +1 -1
  67. package/node_modules/pi-antigravity/src/image/image.ts +4 -4
  68. package/node_modules/pi-antigravity/src/index.ts +203 -13
  69. package/node_modules/pi-antigravity/src/search/index.ts +1 -0
  70. package/node_modules/pi-antigravity/src/search/search.ts +325 -0
  71. package/node_modules/pi-antigravity/src/stream/stream.ts +191 -14
  72. package/node_modules/pi-antigravity/src/types/enums.ts +45 -36
  73. package/node_modules/pi-antigravity/src/types/types.ts +5 -12
  74. package/node_modules/pi-antigravity/src/utils/http.ts +7 -2
  75. package/node_modules/pi-antigravity/src/utils/util.ts +31 -3
  76. package/node_modules/pi-typesafe/README.md +2 -0
  77. package/node_modules/pi-typesafe/package.json +2 -1
  78. package/node_modules/pi-web-access/CHANGELOG.md +72 -0
  79. package/node_modules/pi-web-access/README.md +34 -13
  80. package/node_modules/pi-web-access/chrome-cookies.ts +79 -25
  81. package/node_modules/pi-web-access/curator-page.ts +5 -1
  82. package/node_modules/pi-web-access/curator-server.ts +2 -0
  83. package/node_modules/pi-web-access/dist/index.js +1337 -1009
  84. package/node_modules/pi-web-access/exa.ts +20 -52
  85. package/node_modules/pi-web-access/extract.ts +41 -4
  86. package/node_modules/pi-web-access/gemini-search.ts +12 -4
  87. package/node_modules/pi-web-access/index.ts +29 -37
  88. package/node_modules/pi-web-access/openai-search.ts +35 -7
  89. package/node_modules/pi-web-access/package.json +6 -5
  90. package/node_modules/pi-web-access/page-query.ts +2 -2
  91. package/node_modules/pi-web-access/query-rewrite.ts +2 -3
  92. package/node_modules/pi-web-access/ssrf-protection.ts +27 -10
  93. package/node_modules/pi-web-access/storage.ts +48 -77
  94. package/node_modules/pi-web-access/summary-model-scope.ts +6 -77
  95. package/node_modules/pi-web-access/summary-review.ts +3 -4
  96. package/node_modules/pi-web-access/tavily.ts +67 -20
  97. package/node_modules/pi-web-access/tool-activation.ts +53 -33
  98. package/node_modules/pi-web-access/utils.ts +1 -1
  99. package/node_modules/pi-web-access/you.ts +164 -0
  100. package/node_modules/pi-web-access/zai.ts +161 -0
  101. package/package.json +7 -11
  102. package/node_modules/@herbertgao/pi-cc-extensions/assets/readme/hero.en.svg +0 -101
  103. package/node_modules/@herbertgao/pi-cc-extensions/assets/readme/hero.svg +0 -101
  104. package/node_modules/@herbertgao/pi-cc-extensions/assets/readme/welcome.webp +0 -0
  105. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/render-utils.ts +0 -5
  106. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/types.ts +0 -30
  107. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/write-display-utils.ts +0 -12
  108. package/node_modules/pi-footer/CHANGELOG.md +0 -145
  109. package/node_modules/pi-footer/LICENSE +0 -21
  110. package/node_modules/pi-footer/README.md +0 -354
  111. package/node_modules/pi-footer/package.json +0 -68
  112. package/node_modules/pi-footer/src/cache.ts +0 -106
  113. package/node_modules/pi-footer/src/colors.ts +0 -237
  114. package/node_modules/pi-footer/src/config.ts +0 -202
  115. package/node_modules/pi-footer/src/event-widgets.ts +0 -41
  116. package/node_modules/pi-footer/src/extension-statuses.ts +0 -102
  117. package/node_modules/pi-footer/src/git.ts +0 -135
  118. package/node_modules/pi-footer/src/index.ts +0 -268
  119. package/node_modules/pi-footer/src/metrics.ts +0 -141
  120. package/node_modules/pi-footer/src/presets.ts +0 -383
  121. package/node_modules/pi-footer/src/render.ts +0 -114
  122. package/node_modules/pi-footer/src/separators.ts +0 -52
  123. package/node_modules/pi-footer/src/types.ts +0 -122
  124. package/node_modules/pi-footer/src/ui/color-level-confirm.ts +0 -22
  125. package/node_modules/pi-footer/src/ui/color-options.ts +0 -195
  126. package/node_modules/pi-footer/src/ui/config-lifecycle.ts +0 -61
  127. package/node_modules/pi-footer/src/ui/edit-colors.ts +0 -13
  128. package/node_modules/pi-footer/src/ui/events.ts +0 -35
  129. package/node_modules/pi-footer/src/ui/extension-status-picker.ts +0 -60
  130. package/node_modules/pi-footer/src/ui/extension-statuses.ts +0 -73
  131. package/node_modules/pi-footer/src/ui/fields.ts +0 -252
  132. package/node_modules/pi-footer/src/ui/global-menu.ts +0 -126
  133. package/node_modules/pi-footer/src/ui/helpers.ts +0 -44
  134. package/node_modules/pi-footer/src/ui/layout.ts +0 -17
  135. package/node_modules/pi-footer/src/ui/line-list.ts +0 -19
  136. package/node_modules/pi-footer/src/ui/model.ts +0 -64
  137. package/node_modules/pi-footer/src/ui/navigation.ts +0 -29
  138. package/node_modules/pi-footer/src/ui/option-edit.ts +0 -93
  139. package/node_modules/pi-footer/src/ui/overlay-render.ts +0 -103
  140. package/node_modules/pi-footer/src/ui/screen-context.ts +0 -18
  141. package/node_modules/pi-footer/src/ui/screen-controller.ts +0 -27
  142. package/node_modules/pi-footer/src/ui/screen-render.ts +0 -32
  143. package/node_modules/pi-footer/src/ui/screen-state.ts +0 -20
  144. package/node_modules/pi-footer/src/ui/screen.ts +0 -243
  145. package/node_modules/pi-footer/src/ui/screens/add-widget.ts +0 -92
  146. package/node_modules/pi-footer/src/ui/screens/confirm-exit.ts +0 -52
  147. package/node_modules/pi-footer/src/ui/screens/controller.ts +0 -12
  148. package/node_modules/pi-footer/src/ui/screens/edit-colors.ts +0 -68
  149. package/node_modules/pi-footer/src/ui/screens/edit-widget.ts +0 -115
  150. package/node_modules/pi-footer/src/ui/screens/extension-status-row.ts +0 -61
  151. package/node_modules/pi-footer/src/ui/screens/global.ts +0 -75
  152. package/node_modules/pi-footer/src/ui/screens/line-list.ts +0 -108
  153. package/node_modules/pi-footer/src/ui/screens/main.ts +0 -99
  154. package/node_modules/pi-footer/src/ui/screens/terminal.ts +0 -121
  155. package/node_modules/pi-footer/src/ui/screens/widget-list.ts +0 -154
  156. package/node_modules/pi-footer/src/ui/terminal-menu.ts +0 -32
  157. package/node_modules/pi-footer/src/ui/theme.ts +0 -48
  158. package/node_modules/pi-footer/src/ui/title-bar.ts +0 -100
  159. package/node_modules/pi-footer/src/ui/widget-actions.ts +0 -76
  160. package/node_modules/pi-footer/src/ui.ts +0 -66
  161. package/node_modules/pi-footer/src/widgets/context.ts +0 -28
  162. package/node_modules/pi-footer/src/widgets/core/active-tools.ts +0 -17
  163. package/node_modules/pi-footer/src/widgets/core/context-window.ts +0 -24
  164. package/node_modules/pi-footer/src/widgets/core/cwd-basename.ts +0 -19
  165. package/node_modules/pi-footer/src/widgets/core/cwd.ts +0 -108
  166. package/node_modules/pi-footer/src/widgets/core/event.ts +0 -34
  167. package/node_modules/pi-footer/src/widgets/core/external-status.ts +0 -102
  168. package/node_modules/pi-footer/src/widgets/core/model-provider.ts +0 -20
  169. package/node_modules/pi-footer/src/widgets/core/model.ts +0 -35
  170. package/node_modules/pi-footer/src/widgets/core/provider.ts +0 -17
  171. package/node_modules/pi-footer/src/widgets/core/session-name.ts +0 -17
  172. package/node_modules/pi-footer/src/widgets/core/text-verbosity.ts +0 -17
  173. package/node_modules/pi-footer/src/widgets/core/thinking-level.ts +0 -17
  174. package/node_modules/pi-footer/src/widgets/git/ahead-behind.ts +0 -17
  175. package/node_modules/pi-footer/src/widgets/git/branch.ts +0 -81
  176. package/node_modules/pi-footer/src/widgets/git/clean.ts +0 -19
  177. package/node_modules/pi-footer/src/widgets/git/deletions.ts +0 -17
  178. package/node_modules/pi-footer/src/widgets/git/diff.ts +0 -38
  179. package/node_modules/pi-footer/src/widgets/git/insertions.ts +0 -17
  180. package/node_modules/pi-footer/src/widgets/git/remote.ts +0 -17
  181. package/node_modules/pi-footer/src/widgets/git/root.ts +0 -17
  182. package/node_modules/pi-footer/src/widgets/git/sha.ts +0 -17
  183. package/node_modules/pi-footer/src/widgets/git/staged.ts +0 -17
  184. package/node_modules/pi-footer/src/widgets/git/status.ts +0 -19
  185. package/node_modules/pi-footer/src/widgets/git/unstaged.ts +0 -17
  186. package/node_modules/pi-footer/src/widgets/git/untracked.ts +0 -17
  187. package/node_modules/pi-footer/src/widgets/instance.ts +0 -118
  188. package/node_modules/pi-footer/src/widgets/layout/custom-text.ts +0 -18
  189. package/node_modules/pi-footer/src/widgets/layout/flex-separator.ts +0 -32
  190. package/node_modules/pi-footer/src/widgets/layout/separator.ts +0 -57
  191. package/node_modules/pi-footer/src/widgets/layout/spacer.ts +0 -31
  192. package/node_modules/pi-footer/src/widgets/options.ts +0 -122
  193. package/node_modules/pi-footer/src/widgets/project/runtime.ts +0 -361
  194. package/node_modules/pi-footer/src/widgets/registry.ts +0 -223
  195. package/node_modules/pi-footer/src/widgets/session/assistant-messages.ts +0 -17
  196. package/node_modules/pi-footer/src/widgets/session/compactions.ts +0 -17
  197. package/node_modules/pi-footer/src/widgets/session/elapsed.ts +0 -18
  198. package/node_modules/pi-footer/src/widgets/session/last-activity.ts +0 -18
  199. package/node_modules/pi-footer/src/widgets/session/messages.ts +0 -19
  200. package/node_modules/pi-footer/src/widgets/session/session-id.ts +0 -17
  201. package/node_modules/pi-footer/src/widgets/session/session-start.ts +0 -18
  202. package/node_modules/pi-footer/src/widgets/session/tool-results.ts +0 -17
  203. package/node_modules/pi-footer/src/widgets/session/total-messages.ts +0 -19
  204. package/node_modules/pi-footer/src/widgets/session/total-time.ts +0 -18
  205. package/node_modules/pi-footer/src/widgets/session/user-messages.ts +0 -17
  206. package/node_modules/pi-footer/src/widgets/store.ts +0 -26
  207. package/node_modules/pi-footer/src/widgets/tokens/cache-hit-rate.ts +0 -55
  208. package/node_modules/pi-footer/src/widgets/tokens/cache-read.ts +0 -18
  209. package/node_modules/pi-footer/src/widgets/tokens/cache-write.ts +0 -18
  210. package/node_modules/pi-footer/src/widgets/tokens/context-bar.ts +0 -108
  211. package/node_modules/pi-footer/src/widgets/tokens/context-length.ts +0 -26
  212. package/node_modules/pi-footer/src/widgets/tokens/context-remaining.ts +0 -29
  213. package/node_modules/pi-footer/src/widgets/tokens/context.ts +0 -29
  214. package/node_modules/pi-footer/src/widgets/tokens/cost.ts +0 -51
  215. package/node_modules/pi-footer/src/widgets/tokens/input-speed.ts +0 -25
  216. package/node_modules/pi-footer/src/widgets/tokens/input-tokens.ts +0 -18
  217. package/node_modules/pi-footer/src/widgets/tokens/output-speed.ts +0 -25
  218. package/node_modules/pi-footer/src/widgets/tokens/output-tokens.ts +0 -18
  219. package/node_modules/pi-footer/src/widgets/tokens/tokens.ts +0 -20
  220. package/node_modules/pi-footer/src/widgets/tokens/total-speed.ts +0 -25
  221. package/node_modules/pi-footer/src/widgets/tokens/total-tokens.ts +0 -18
  222. package/node_modules/pi-footer/src/widgets/types.ts +0 -181
  223. package/node_modules/pi-footer/src/widgets/utils/colors.ts +0 -11
  224. package/node_modules/pi-footer/src/widgets/utils/context.ts +0 -112
  225. package/node_modules/pi-footer/src/widgets/utils/session.ts +0 -19
  226. package/node_modules/pi-footer/src/widgets/utils/token-format.ts +0 -78
@@ -1,11 +1,21 @@
1
1
  /**
2
2
  * Compact mode:每条含 toolCall 的 assistant message 折叠为一条逐步累加的摘要行
3
- * (`Ran for 8s, bash×2, read×2`),edit/write 独立单行(`✓ write <path> (+25 -0)`),
4
- * 普通工具折叠时不显示独立行;展开(Ctrl+O / fullscreen 点击)在单个工具卡中恢复
5
- * compact-thinking/Pi 原生或专用 renderer。edit/write 折叠时显示统计,展开时显示 rich diff。
3
+ * (`Ran for 8s, bash×2, read×2`),edit/write 独立标题行(`✓ write <path> (+25 -0)`),
4
+ * 普通工具折叠时不显示独立行;展开(Ctrl+O / fullscreen 点击)时助手文本按原生渲染,
5
+ * thinking 与工具卡装进 userMessageBg 面板(卡内恢复 compact-thinking/Pi 原生或专用
6
+ * renderer)。edit/write 复用 mode=on 的 rich diff
7
+ * 与 `editDiffCollapsedLines` / `writeDiffCollapsedLines` / `expandedPreviewMaxLines`。
6
8
  * Agent/Task 族:调用只进摘要计数,tool 卡始终折叠(避免 pending→完成高度闪动)。
7
9
  * 底部 Agents/Tasks 面板由 pi-subagents/pi-tasks 独立 widget 负责,不经 tool 卡外置。
8
10
  *
11
+ * live 围观态(回合进行中 / 收尾静默期):正文跟折叠态同形;摘要行与单一槽位卡
12
+ * (`↳` + 最新的一个块:仍在增长的思考预览,或本回合最近的工具,运行中优先)挂在
13
+ * transcript 容器的回合末尾(尾行组件,anchor 与工具卡之后、下一消息之前)。
14
+ * 思考原地增长不轮换,只有新的思考块或工具调用进来才换槽位内容。
15
+ * 摘要行挂末尾是刻意的:运行中它恒在可写视口底缘,收尾时原地从 Running... 翻成
16
+ * Ran for 真实落进 scrollback;放在 anchor 内部则会被工具/diff 顶出视口后定格。
17
+ * (anchor 未挂载进容器的独立渲染场景退回组件内旧行为。)
18
+ *
9
19
  * 工具计数:read 按非空路径去重、其余按调用计数(首次出现顺序);edit/write 不进摘要。
10
20
  * 时长 = 回合流逝挂钟;进行中 Running...,结束 Ran for。
11
21
  * abort/error/length 状态行挂在摘要外层,避免被折叠吞掉。
@@ -19,13 +29,13 @@ import {
19
29
  ToolExecutionComponent,
20
30
  type Theme,
21
31
  } from "@earendil-works/pi-coding-agent";
22
- import { truncateToWidth, visibleWidth, Box } from "@earendil-works/pi-tui";
32
+ import { truncateToWidth, visibleWidth, Box, Spacer } from "@earendil-works/pi-tui";
23
33
  import { config, getToolDisplayConfig } from "../config/config.ts";
24
34
  import { toolLoadingIcon } from "../utils/tool-loading-icon.ts";
25
35
  import { sanitizeToolResultText } from "../utils/tool-result-sanitize.ts";
26
36
  import { refreshTranscriptComponent } from "./transcript-refresh.ts";
27
- import { getMessageDisplayTheme } from "./message-display.ts";
28
- import { showMoreHintText } from "./show-more-hint.ts";
37
+ import { getMessageDisplayTheme } from "./tool/message-display.ts";
38
+ import { showMoreHintText } from "./tool/show-more-hint.ts";
29
39
  import {
30
40
  countEditDiffStats,
31
41
  countWriteDiffStats,
@@ -33,13 +43,33 @@ import {
33
43
  } from "./tool/diff/diff-renderer.ts";
34
44
  import { renderRichToolResult } from "./tool/diff/index.ts";
35
45
  import type { WriteExecutionMetadataStore } from "./tool/diff/write-execution.ts";
46
+ import { isToolCallHovered } from "./mouse/hover.ts";
47
+ import { getToolMouseTui } from "./mouse/scroll.ts";
48
+ import { insetComponent, renderExpandedToolResult, scheduleAnimation } from "./tool/result.ts";
49
+ import { paddedBackgroundRow } from "./tool/grouping.ts";
50
+ import { formatDisplayPath } from "./tool/names.ts";
51
+ import {
52
+ hasVisibleText,
53
+ removeStyledRange,
54
+ stripBackgroundAnsi,
55
+ stripTerminalSequencesPreservingLayout,
56
+ } from "../utils/ansi-text.ts";
57
+ import { walkComponentTree } from "../utils/component-tree.ts";
36
58
  import {
37
- formatDisplayPath,
38
- insetComponent,
39
- renderExpandedToolResult,
40
- scheduleAnimation,
41
- } from "./tool/result.ts";
42
- import { paddedBackgroundRow, stripBackgroundAnsi } from "./tool/grouping.ts";
59
+ ASSISTANT_REENTRY_KEY,
60
+ ASSISTANT_SET_EXPANDED_KEY,
61
+ ASSISTANT_TOGGLE_ROUND_KEY,
62
+ COMPACT_MODE_PATCH_KEY,
63
+ COMPACT_THINKING_PATCH_KEY,
64
+ patchRegistry,
65
+ PROTOTYPE_ORIGINAL_KEY,
66
+ TOOL_GROUPING_PARENT_KEY,
67
+ } from "../utils/patch-keys.ts";
68
+
69
+ /** Pi 的 native 组件与扩展可能各持一份 pi-tui,Spacer 不能只靠 instanceof 判。 */
70
+ function isSpacerComponent(value: any): boolean {
71
+ return value instanceof Spacer || value?.constructor?.name === "Spacer";
72
+ }
43
73
 
44
74
  /** compact 渲染层对 compact-thinking 的只读查询面(不建第二套计时器)。 */
45
75
  export type CompactThinkingQuery = {
@@ -49,19 +79,14 @@ export type CompactThinkingQuery = {
49
79
  setCompactSummaryActive?(active: boolean): void;
50
80
  };
51
81
 
52
- const COMPACT_MODE_PATCH_KEY = Symbol.for("pi.ccstyle.compact-mode-patch");
53
- const COMPACT_THINKING_OWNER_KEY = Symbol.for("pi.ccstyle.compact-thinking-owner");
54
- const COMPACT_THINKING_PATCH_KEY = Symbol.for("pi.ccstyle.compact-thinking-update");
55
- const PROTOTYPE_ORIGINAL_KEY = Symbol.for("pi.ccstyle.prototype-original");
56
- const ASSISTANT_SET_EXPANDED_DESCRIPTION = "pi.ccstyle.compact-assistant-set-expanded";
57
- const ASSISTANT_TOGGLE_ROUND_DESCRIPTION = "pi.ccstyle.compact-assistant-toggle-round";
58
- const ASSISTANT_REENTRY_DESCRIPTION = "pi.ccstyle.compact-assistant-reentry";
59
- const ASSISTANT_SET_EXPANDED_KEY = Symbol.for(ASSISTANT_SET_EXPANDED_DESCRIPTION);
60
- const ASSISTANT_TOGGLE_ROUND_KEY = Symbol.for(ASSISTANT_TOGGLE_ROUND_DESCRIPTION);
61
- const ASSISTANT_REENTRY_KEY = Symbol.for(ASSISTANT_REENTRY_DESCRIPTION);
62
-
63
82
  const EDIT_WRITE_TOOLS = new Set(["edit", "write"]);
64
83
 
84
+ /**
85
+ * 回合收尾后延迟收拢的静默期(ms)。正文 token 往往先于 toolCall 到达,
86
+ * 立刻收拢会得到「先收再开」两次转场;静默期内被新回合接替则一并收拢。
87
+ */
88
+ const FOLD_SETTLE_MS = 250;
89
+
65
90
  type CompactThinkingTheme = Pick<Theme, "fg" | "italic" | "bold">;
66
91
 
67
92
  /** 与 compact-thinking 主渲染器共用的静态文字样式。 */
@@ -290,16 +315,6 @@ function darkenBgAnsi(theme: any, slot: string): string {
290
315
  return `\x1b[48;2;${r};${g};${b}m`;
291
316
  }
292
317
 
293
- /** 去掉行内所有 CSI/OSC 序列后是否有可见文本(判断工具卡首尾内容行)。 */
294
- function hasVisibleText(line: string): boolean {
295
- return (
296
- line
297
- .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
298
- .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, "")
299
- .trim().length > 0
300
- );
301
- }
302
-
303
318
  /** 工具卡深色行:左右内缩(左 2 右 3 格,含工具卡自身 padding),背景只到内容区,
304
319
  * 修复行尾 reset 截断。 */
305
320
  function toolCardBgRow(
@@ -320,7 +335,7 @@ function toolCardBgRow(
320
335
  const body = ` ${clipped}${" ".repeat(pad)} `;
321
336
  const stable = body.replace(/\x1b\[(?:0)?m/g, (reset) => reset + bgAnsi);
322
337
  const outerBg = typeof theme?.getBgAnsi === "function" ? String(theme.getBgAnsi(slot)) : "";
323
- const inset = (n) => (outerBg ? `${outerBg}${" ".repeat(n)}\x1b[49m` : " ".repeat(n));
338
+ const inset = (n: number) => (outerBg ? `${outerBg}${" ".repeat(n)}\x1b[49m` : " ".repeat(n));
324
339
  return `${inset(leftInset)}${bgAnsi}${stable}\x1b[49m${inset(rightInset)}`;
325
340
  }
326
341
  /** edit/write 展开卡:保持原样式(userMessageBg Box),不应用工具卡深色/间距改动。 */
@@ -332,35 +347,66 @@ function editWriteExpandedCard(theme: any): any {
332
347
  );
333
348
  }
334
349
 
335
- /** compact 展开卡:保留本地嵌套工具背景,并记录 thinking 的点击行区间。 */
350
+ /** compact 展开面板:外卡片保持 userMessageBg;展开的 thinking 另包一层更深的
351
+ * 内卡(自身 Box 背景与外卡同色,不区分会糊在一起),工具卡直接铺在外卡背景上。
352
+ * 每个子卡前面补 1 行分隔(上一条已经是空行时不再补),子卡自带的首尾空行会被裁掉,
353
+ * 避免与外卡/内卡 padding 叠出双空行。
354
+ * hits:子卡的行区间与命中目标(thinking 命中自身,工具命中 tool 组件),
355
+ * 供展开后点击 hint。未开启 toolHits 时不映射工具,点击仍归外层面板。
356
+ * live=true:live 槽位用,不铺底色也不留上下空行,首行紧跟 `↳`、其余行缩进对齐
357
+ * (Claude Code 的 ⎿ 口径)。行的产出仍是一行对一行,hits 行号无需平移。
358
+ */
336
359
  function layoutExpandedToolCard(
337
360
  theme: any,
338
361
  children: any[],
339
362
  width: number,
363
+ paints?: string[][],
364
+ toolHits = false,
365
+ live = false,
340
366
  ): { lines: string[]; hits: Array<{ child: any; start: number; end: number }> } {
341
367
  const slot = "userMessageBg";
342
368
  const toolBgAnsi = darkenBgAnsi(theme, slot);
343
- const innerWidth = Math.max(0, width - 2);
369
+ /** live 每行前面的标记宽度(首行 `↳ `,其余对齐空格)。 */
370
+ const markerWidth = live ? 2 : 0;
371
+ const innerWidth = Math.max(0, width - 2 - markerWidth);
344
372
  const lines: string[] = [];
345
373
  const hits: Array<{ child: any; start: number; end: number }> = [];
346
- lines.push(paddedBackgroundRow(theme, slot, "", width));
347
- let skipLeadingBlank = true;
348
- for (const child of children) {
349
- const childLines = child.render(innerWidth);
350
- if (!child.__ccToolCard) {
351
- let start = 0;
352
- if (skipLeadingBlank) {
353
- while (start < childLines.length && !hasVisibleText(childLines[start])) start++;
354
- skipLeadingBlank = false;
355
- }
356
- const rangeStart = lines.length;
357
- for (let i = start; i < childLines.length; i++) {
358
- lines.push(paddedBackgroundRow(theme, slot, childLines[i], width));
359
- }
360
- if (lines.length > rangeStart) hits.push({ child, start: rangeStart, end: lines.length });
361
- continue;
374
+ const isThinkingPreview = (child: any) => typeof child?.setHintHovered === "function";
375
+ let lastBlank = false;
376
+ /** live 首行被吃掉的前导缩进:续行同步左移同样格数,保持块内相对对齐。 */
377
+ let liveLead = 0;
378
+ const pushRow = (raw: string) => {
379
+ if (!live) {
380
+ lines.push(paddedBackgroundRow(theme, slot, raw, width));
381
+ return;
362
382
  }
363
- skipLeadingBlank = false;
383
+ // 槽位里不重复展开入口(见 SLOT_HINT_PATTERNS)。
384
+ const line = stripSlotHint(raw);
385
+ // 工具卡行首带 SGR:ANSI 之后才是缩进空格。
386
+ const head = line.match(/^((?:\x1b\[[0-9;]*m)*)( *)/);
387
+ const ansi = head?.[1] ?? "";
388
+ const spaces = head?.[2]?.length ?? 0;
389
+ const first = lines.length === 0;
390
+ if (first) liveLead = spaces;
391
+ // 首行挂 muted 的 `↳`(与 default-mode 工具结果行同色);续行按 liveLead 去缩进。
392
+ // 两侧都保留行首 SGR:丢掉它整行会掉回终端默认前景色。
393
+ const marker = first ? `${theme.fg("muted", "↳")} ` : " ";
394
+ const lead = first ? spaces : Math.min(spaces, liveLead);
395
+ lines.push(
396
+ `${marker}${ansi}${truncateToWidth(line.slice(ansi.length + lead), innerWidth, "")}`,
397
+ );
398
+ };
399
+ const pushBlank = () => {
400
+ if (live || lastBlank) return;
401
+ lines.push(paddedBackgroundRow(theme, slot, "", width));
402
+ lastBlank = true;
403
+ };
404
+ for (let childIndex = 0; childIndex < children.length; childIndex++) {
405
+ const child = children[childIndex];
406
+ const painted = paints?.[childIndex];
407
+ const childLines = Array.isArray(painted) ? painted : child.render(innerWidth);
408
+ // live 不留空行、也不叠深色内卡:槽位没有边框。
409
+ const innerCard = !live && isThinkingPreview(child) && child.expanded === true;
364
410
  let first = -1;
365
411
  let last = -1;
366
412
  for (let i = 0; i < childLines.length; i++) {
@@ -370,25 +416,41 @@ function layoutExpandedToolCard(
370
416
  }
371
417
  }
372
418
  if (first < 0) {
419
+ if (live) continue;
373
420
  for (const line of childLines) {
374
421
  lines.push(paddedBackgroundRow(theme, slot, line, width));
422
+ lastBlank = !hasVisibleText(line);
375
423
  }
376
424
  continue;
377
425
  }
378
- lines.push(paddedBackgroundRow(theme, slot, "", width));
379
- lines.push(toolCardBgRow(theme, slot, toolBgAnsi, "", width));
380
- for (let i = first; i <= last; i++) {
381
- lines.push(toolCardBgRow(theme, slot, toolBgAnsi, childLines[i], width));
426
+ if (!innerCard) pushBlank();
427
+ const rangeStart = lines.length;
428
+ // 展开的 thinking 走深色内卡(上下各 1 行 padding),工具卡直接铺在外卡上。
429
+ const paintRow = innerCard
430
+ ? (line: string) => lines.push(toolCardBgRow(theme, slot, toolBgAnsi, line, width))
431
+ : pushRow;
432
+ if (innerCard) paintRow("");
433
+ for (let i = first; i <= last; i++) paintRow(childLines[i]);
434
+ if (innerCard) paintRow("");
435
+ // 内卡的下 padding 不能当分隔:后面的子卡要再留 1 行外卡空行。
436
+ lastBlank = false;
437
+ // thinking 命中自身;工具在展开卡内需要能二次展开时才映射回 tool 组件。
438
+ if (isThinkingPreview(child)) hits.push({ child, start: rangeStart, end: lines.length });
439
+ else if (toolHits && child.__ccTool) {
440
+ hits.push({ child: child.__ccTool, start: rangeStart, end: lines.length });
382
441
  }
383
- lines.push(toolCardBgRow(theme, slot, toolBgAnsi, "", width));
384
442
  }
385
- lines.push(paddedBackgroundRow(theme, slot, "", width));
443
+ pushBlank();
386
444
  return { lines, hits };
387
445
  }
388
446
 
389
447
  function compactRoundCard(
390
448
  cardItems: Array<{ child?: any; tool?: any }>,
391
449
  toolRender: (tool: any, width: number) => string[],
450
+ /** true 时卡内工具行归 tool 组件,可点击二次展开(整回合展开卡)。 */
451
+ toolHits = false,
452
+ /** true 时走 live 槽位排布:不留空行、首行紧跟 `↳`。 */
453
+ live = false,
392
454
  ): any {
393
455
  const children: any[] = [];
394
456
  for (const item of cardItems) {
@@ -397,26 +459,62 @@ function compactRoundCard(
397
459
  const tool = item.tool;
398
460
  children.push({
399
461
  __ccToolCard: true,
462
+ __ccTool: tool,
400
463
  render: (innerWidth: number) => toolRender(tool, innerWidth),
401
464
  invalidate: () => tool.invalidate?.(),
402
465
  });
403
466
  }
404
467
  }
468
+ /** 子块可用宽度:live 还要让出首行 `↳ ` 的标记位。 */
469
+ const contentWidth = (width: number) => Math.max(0, width - 2 - (live ? 2 : 0));
470
+ let paint:
471
+ | {
472
+ width: number;
473
+ theme: unknown;
474
+ paints: unknown[];
475
+ lines: string[];
476
+ hits: Array<{ child: any; start: number; end: number }>;
477
+ }
478
+ | undefined;
479
+ const layout = (width: number) => {
480
+ const theme = themeOf();
481
+ const innerWidth = contentWidth(width);
482
+ const paints = children.map((child) => {
483
+ const lines = child.render?.(innerWidth);
484
+ return Array.isArray(lines) ? lines : [];
485
+ });
486
+ if (
487
+ paint &&
488
+ paint.width === width &&
489
+ paint.theme === theme &&
490
+ paint.paints.length === paints.length &&
491
+ paint.paints.every((item, index) => item === paints[index])
492
+ ) {
493
+ return paint;
494
+ }
495
+ const laid = layoutExpandedToolCard(theme, children, width, paints, toolHits, live);
496
+ paint = {
497
+ width,
498
+ theme,
499
+ paints,
500
+ lines: laid.lines,
501
+ hits: laid.hits,
502
+ };
503
+ return paint;
504
+ };
405
505
  return {
406
506
  children,
407
507
  render(width: number): string[] {
408
- return ["", ...layoutExpandedToolCard(themeOf(), children, width).lines];
508
+ return layout(width).lines;
409
509
  },
410
510
  childAtRow(localRow: number, width: number) {
411
- if (localRow < 1) return null;
412
- const row = localRow - 1;
413
- const { hits } = layoutExpandedToolCard(themeOf(), children, width);
414
- for (const hit of hits) {
415
- if (row >= hit.start && row < hit.end) return hit.child;
511
+ for (const hit of layout(width).hits) {
512
+ if (localRow >= hit.start && localRow < hit.end) return hit.child;
416
513
  }
417
514
  return null;
418
515
  },
419
516
  invalidate() {
517
+ paint = undefined;
420
518
  for (const child of children) child.invalidate?.();
421
519
  },
422
520
  };
@@ -431,28 +529,16 @@ function isToolComponent(value: any): boolean {
431
529
  }
432
530
 
433
531
  function detachAssistantExpansion(component: any): void {
434
- let owned =
435
- typeof component?.[ASSISTANT_SET_EXPANDED_KEY] === "function" &&
436
- component.setExpanded === component[ASSISTANT_SET_EXPANDED_KEY];
437
- if (!owned && component && typeof component === "object") {
438
- // 首次升级时清理旧模块使用 Symbol() 留下的实例方法。
439
- owned = Object.getOwnPropertySymbols(component).some(
440
- (symbol) =>
441
- symbol.description === ASSISTANT_SET_EXPANDED_DESCRIPTION && component[symbol] === true,
442
- );
532
+ if (
533
+ typeof component?.[ASSISTANT_SET_EXPANDED_KEY] !== "function" ||
534
+ component.setExpanded !== component[ASSISTANT_SET_EXPANDED_KEY]
535
+ ) {
536
+ return;
443
537
  }
444
- if (!owned) return;
445
-
446
538
  delete component.setExpanded;
447
- for (const symbol of Object.getOwnPropertySymbols(component)) {
448
- if (
449
- symbol.description === ASSISTANT_SET_EXPANDED_DESCRIPTION ||
450
- symbol.description === ASSISTANT_TOGGLE_ROUND_DESCRIPTION ||
451
- symbol.description === ASSISTANT_REENTRY_DESCRIPTION
452
- ) {
453
- delete component[symbol];
454
- }
455
- }
539
+ delete component[ASSISTANT_SET_EXPANDED_KEY];
540
+ delete component[ASSISTANT_TOGGLE_ROUND_KEY];
541
+ delete component[ASSISTANT_REENTRY_KEY];
456
542
  }
457
543
 
458
544
  /** 供 mouse-interaction 识别可点击的 compact assistant 行(仅 compact 模式下生效)。 */
@@ -500,27 +586,91 @@ const trackedAssistantComponents = new Set<any>();
500
586
  const trackedToolComponents = new Set<any>();
501
587
  let hoveredAssistantComponent: any;
502
588
 
589
+ /**
590
+ * 活动回合摘要 getter:常驻状态行(pi working row)镜像用。
591
+ * installCompactMode 注册、卸载时清空,直接读 activeRound,不持有回合闭包。
592
+ */
593
+ let compactRunStatusGetter: (() => string | undefined) | undefined;
594
+
595
+ /** 活动回合的摘要文本(`Running... · 9s, bash×1`);无活动回合返回 undefined。 */
596
+ export function getCompactRunStatusText(): string | undefined {
597
+ return compactRunStatusGetter?.();
598
+ }
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
+
503
616
  export function setHoveredCompactAssistant(component: any): boolean {
504
617
  if (hoveredAssistantComponent === component) return false;
505
618
  hoveredAssistantComponent = component;
506
619
  return true;
507
620
  }
508
621
 
622
+ /**
623
+ * 整回合展开卡内的工具被点击展开时调用:让 compact 的强制折叠放行这一个,
624
+ * 并把同一回合里其他展开的工具收回去(保持单开)。非 round 内工具是空操作。
625
+ */
626
+ let roundToolExpansion: { markUserExpanded(tool: any): void } | undefined;
627
+
628
+ export function markCompactRoundToolExpanded(tool: any): void {
629
+ roundToolExpansion?.markUserExpanded(tool);
630
+ }
631
+
632
+ /** 面板展开前的视口位置:收起时还原,避免面板撑高后收起导致视口错位。 */
633
+ const panelViewportBeforeExpand = new WeakMap<
634
+ object,
635
+ { scrollTop: number; followingEnd: boolean }
636
+ >();
637
+
638
+ function capturePanelViewport(card: any): void {
639
+ if (!card || panelViewportBeforeExpand.has(card)) return;
640
+ const view = getToolMouseTui()?.getPrimaryScrollView?.();
641
+ if (!view || typeof view.scrollTop !== "number" || typeof view.scrollTo !== "function") return;
642
+ panelViewportBeforeExpand.set(card, {
643
+ scrollTop: view.scrollTop,
644
+ followingEnd: view.isFollowingEnd !== false,
645
+ });
646
+ }
647
+
648
+ function restorePanelViewport(card: any): void {
649
+ const snapshot = panelViewportBeforeExpand.get(card);
650
+ if (!snapshot) return;
651
+ panelViewportBeforeExpand.delete(card);
652
+ const view = getToolMouseTui()?.getPrimaryScrollView?.();
653
+ if (!view) return;
654
+ // 展开前就在底部:交回官方 follow;否则回到展开前的滚动偏移。
655
+ if (snapshot.followingEnd && typeof view.scrollToEnd === "function") view.scrollToEnd();
656
+ else if (typeof view.scrollTo === "function") view.scrollTo(snapshot.scrollTop);
657
+ }
658
+
509
659
  function compactEditWriteLine(
510
660
  component: any,
511
661
  width: number,
512
662
  writeMetadata?: WriteExecutionMetadataStore,
663
+ options: { hint?: boolean; flushLeft?: boolean } = {},
513
664
  ): string[] {
514
665
  const theme = themeOf();
515
666
  const name = String(component.toolName ?? "tool");
516
667
  const args = component.args ?? {};
517
- const path = sanitizeToolResultText(
668
+ const path =
518
669
  typeof args.path === "string" && args.path
519
670
  ? args.path
520
671
  : typeof args.file_path === "string" && args.file_path
521
672
  ? args.file_path
522
- : "",
523
- );
673
+ : "";
524
674
  const isError = component.result?.isError === true;
525
675
  const isPending = !component.result || component.isPartial === true;
526
676
  const icon = isError ? "✗" : isPending ? toolLoadingIcon() : "✓";
@@ -543,9 +693,11 @@ function compactEditWriteLine(
543
693
  statsStyled = ` ${theme.fg("dim", "(")}${theme.fg("success", `+${stats.added}`)} ${theme.fg("error", `-${stats.removed}`)}${theme.fg("dim", ")")}`;
544
694
  }
545
695
  }
546
- const iconPart = ` ${theme.fg(iconColor, icon)} `;
696
+ // 展开卡 Box(1,1) 已 pad;折叠行自己留 1 格前导空格
697
+ const iconPart = `${options.flushLeft ? "" : " "}${theme.fg(iconColor, icon)} `;
547
698
  const namePart = theme.fg("toolTitle", name);
548
- const hintText = component.expanded ? "" : ` • ${showMoreHintText()}`;
699
+ const hintText =
700
+ options.hint !== false && component.expanded !== true ? ` • ${showMoreHintText()}` : "";
549
701
  const fixedWidth =
550
702
  visibleWidth(iconPart) +
551
703
  visibleWidth(namePart) +
@@ -558,34 +710,129 @@ function compactEditWriteLine(
558
710
  return ["", truncateToWidth(line, width, "")];
559
711
  }
560
712
 
561
- /** compact edit/write 展开:复用 mode=on 的 rich diff;不可用时回退 Input/Output。 */
562
- function compactEditWriteExpandedLines(
713
+ /** expanded 写进 rich diff 闭包,折叠/展开分槽缓存,避免每帧 parseDiff。 */
714
+ const compactRichDiffCache = new WeakMap<
715
+ object,
716
+ { result: unknown; collapsed?: unknown; expanded?: unknown }
717
+ >();
718
+
719
+ type CompactEditPaintHit = {
720
+ width: number;
721
+ theme: unknown;
722
+ expanded: boolean;
723
+ result: unknown;
724
+ isPartial: boolean;
725
+ args: unknown;
726
+ hover: boolean;
727
+ ioHover: unknown;
728
+ lines: string[];
729
+ };
730
+ const compactEditPaintCache = new WeakMap<object, CompactEditPaintHit>();
731
+
732
+ function compactEditIoHover(component: any): unknown {
733
+ const view = component?.resultRendererComponent;
734
+ return typeof view?.getHoveredSection === "function" ? view.getHoveredSection() : null;
735
+ }
736
+
737
+ /**
738
+ * compact edit/write:标题行 + mode=on 同一套 rich diff。
739
+ * 折叠/展开都走 `renderRichToolResult`,limits 不另开一套。
740
+ * 已完成的结果跨帧复用行;pending 不缓存,避免 loader 动画冻住。
741
+ */
742
+ function compactEditWriteLines(
743
+ component: any,
744
+ width: number,
745
+ writeMetadata?: WriteExecutionMetadataStore,
746
+ ): string[] {
747
+ const expanded = component.expanded === true;
748
+ const isPartial = component.isPartial === true;
749
+ const pending = !component.result || isPartial;
750
+ const hover = isToolCallHovered(component.toolCallId);
751
+ const ioHover = compactEditIoHover(component);
752
+ const theme = themeOf();
753
+ if (!pending) {
754
+ const hit = compactEditPaintCache.get(component);
755
+ if (
756
+ hit &&
757
+ hit.width === width &&
758
+ hit.theme === theme &&
759
+ hit.expanded === expanded &&
760
+ hit.result === component.result &&
761
+ hit.isPartial === isPartial &&
762
+ hit.args === component.args &&
763
+ hit.hover === hover &&
764
+ hit.ioHover === ioHover
765
+ ) {
766
+ return hit.lines;
767
+ }
768
+ }
769
+ const lines = paintCompactEditWrite(component, width, writeMetadata);
770
+ if (!pending) {
771
+ compactEditPaintCache.set(component, {
772
+ width,
773
+ theme,
774
+ expanded,
775
+ result: component.result,
776
+ isPartial,
777
+ args: component.args,
778
+ hover,
779
+ ioHover: compactEditIoHover(component),
780
+ lines,
781
+ });
782
+ }
783
+ return lines;
784
+ }
785
+
786
+ function paintCompactEditWrite(
563
787
  component: any,
564
788
  width: number,
565
789
  writeMetadata?: WriteExecutionMetadataStore,
566
790
  ): string[] {
567
791
  const theme = themeOf();
568
792
  const result = component.result;
793
+ const expanded = component.expanded === true;
569
794
  const isError = result?.isError === true;
570
- const candidate = writeMetadata
571
- ? renderRichToolResult(
795
+ const isPending = !result || component.isPartial === true;
796
+ let candidate: unknown;
797
+ if (!isPending && writeMetadata) {
798
+ let entry = compactRichDiffCache.get(component);
799
+ if (!entry || entry.result !== result) {
800
+ entry = { result };
801
+ compactRichDiffCache.set(component, entry);
802
+ }
803
+ const slot = expanded ? "expanded" : "collapsed";
804
+ candidate = entry[slot];
805
+ if (!isRichDiffComponent(candidate)) {
806
+ candidate = renderRichToolResult(
572
807
  String(component.toolName ?? ""),
573
808
  result,
574
809
  {
575
- expanded: true,
810
+ expanded,
576
811
  isPartial: component.isPartial === true,
577
812
  isError,
813
+ isHovered: () => isToolCallHovered(component.toolCallId),
578
814
  },
579
815
  theme,
580
816
  component,
581
817
  writeMetadata,
582
818
  getToolDisplayConfig,
583
- )
584
- : undefined;
585
- let detail: any;
586
- if (isRichDiffComponent(candidate)) {
819
+ );
820
+ if (isRichDiffComponent(candidate)) entry[slot] = candidate;
821
+ }
822
+ }
823
+ const hasRich = isRichDiffComponent(candidate);
824
+ if (hasRich) {
587
825
  component.resultRendererComponent = candidate;
588
- detail = insetComponent(candidate as any);
826
+ }
827
+
828
+ const title = compactEditWriteLine(component, width, writeMetadata, { hint: !hasRich });
829
+ if (!hasRich && !expanded) {
830
+ return title;
831
+ }
832
+
833
+ let detail: any;
834
+ if (hasRich) {
835
+ detail = expanded ? candidate : insetComponent(candidate as any);
589
836
  } else {
590
837
  const outputText = sanitizeToolResultText(
591
838
  Array.isArray(result?.content)
@@ -603,19 +850,28 @@ function compactEditWriteExpandedLines(
603
850
  state.ccstyleIoView,
604
851
  component.args,
605
852
  component,
853
+ true, // 外层 Box(1,1) 已 pad
606
854
  );
607
855
  component.resultRendererComponent = detail;
608
856
  }
609
857
 
858
+ if (!expanded) {
859
+ return [...title, ...detail.render(width)];
860
+ }
861
+
610
862
  const box = editWriteExpandedCard(theme);
611
863
  box.addChild({
612
864
  render(innerWidth: number): string[] {
613
- return compactEditWriteLine(component, innerWidth, writeMetadata).slice(1);
865
+ return compactEditWriteLine(component, innerWidth, writeMetadata, {
866
+ hint: false,
867
+ flushLeft: true,
868
+ }).slice(1);
614
869
  },
615
870
  invalidate() {},
616
871
  });
617
872
  box.addChild(detail);
618
- return box.render(width);
873
+ // 与原生 ToolExecutionComponent 一样:Spacer(1) + Box,否则贴上一条 tool 少 1 行间距
874
+ return ["", ...box.render(width)];
619
875
  }
620
876
 
621
877
  function compactAssistantLineComponent(
@@ -626,17 +882,49 @@ function compactAssistantLineComponent(
626
882
  options: { hint?: boolean; leadingBlank?: boolean; pad?: number } = {},
627
883
  ): any {
628
884
  const self = component as any;
885
+ let paint:
886
+ | {
887
+ width: number;
888
+ theme: unknown;
889
+ resolved: string;
890
+ hover: boolean;
891
+ hint: boolean;
892
+ pad: number;
893
+ frame: number;
894
+ leadingBlank: boolean;
895
+ lines: string[];
896
+ }
897
+ | undefined;
629
898
  return {
630
899
  render(width: number): string[] {
631
900
  const theme = themeOf();
632
901
  const pad = Math.max(0, options.pad ?? (Number(self.outputPad) || 0));
633
902
  const available = Math.max(0, width - pad);
634
- const hintText = options.hint === false ? "" : ` • ${showMoreHintText()}`;
903
+ const hint = options.hint !== false;
904
+ const hintText = hint ? ` • ${showMoreHintText()}` : "";
635
905
  const summaryWidth = Math.max(0, available - visibleWidth(hintText));
636
906
  const resolved = typeof summary === "function" ? summary() : summary;
637
907
  const runningActive = resolved.startsWith("Running...");
908
+ const hover = hoveredAssistantComponent === component;
909
+ const frame = runningActive ? (query?.getThinkingAnimationFrame?.() ?? 0) : 0;
910
+ const leadingBlank = options.leadingBlank !== false;
911
+ if (
912
+ paint &&
913
+ paint.width === width &&
914
+ paint.theme === theme &&
915
+ paint.resolved === resolved &&
916
+ paint.hover === hover &&
917
+ paint.hint === hint &&
918
+ paint.pad === pad &&
919
+ paint.frame === frame &&
920
+ paint.leadingBlank === leadingBlank
921
+ ) {
922
+ return paint.lines;
923
+ }
638
924
  const plainText = truncateToWidth(resolved, summaryWidth, "…");
639
- let text = theme.fg("muted", plainText);
925
+ // Dim thinking text 开启时整行统一 dim,否则工具计数保持 muted。
926
+ const plain = (value: string) => theme.fg(config.dimThinkingText ? "dim" : "muted", value);
927
+ let text = plain(plainText);
640
928
  if (runningActive || plainText.startsWith("Ran for ")) {
641
929
  const separator = plainText.indexOf(", ");
642
930
  const heading = separator < 0 ? plainText : plainText.slice(0, separator);
@@ -645,21 +933,31 @@ function compactAssistantLineComponent(
645
933
  const durationSeparator = heading.indexOf(" · ");
646
934
  const label = durationSeparator < 0 ? heading : heading.slice(0, durationSeparator);
647
935
  const duration = durationSeparator < 0 ? "" : heading.slice(durationSeparator);
648
- text = `${animateCompactThinkingText(
649
- label,
650
- theme,
651
- query?.getThinkingAnimationFrame?.() ?? 0,
652
- )}${styleCompactThinkingText(duration, theme)}${theme.fg("muted", tools)}`;
936
+ text = `${animateCompactThinkingText(label, theme, query?.getThinkingAnimationFrame?.() ?? 0)}${styleCompactThinkingText(duration, theme)}${plain(tools)}`;
653
937
  } else {
654
- text = `${styleCompactThinkingText(heading, theme)}${theme.fg("muted", tools)}`;
938
+ text = `${styleCompactThinkingText(heading, theme)}${plain(tools)}`;
655
939
  }
656
940
  }
657
- const hintColor = hoveredAssistantComponent === component ? "text" : "dim";
941
+ const hintColor = hover ? "text" : "dim";
658
942
  const line = `${text}${hintText ? theme.fg(hintColor, hintText) : ""}`;
659
943
  const rendered = `${" ".repeat(pad)}${truncateToWidth(line, available, "")}`;
660
- return options.leadingBlank === false ? [rendered] : ["", rendered];
944
+ const lines = leadingBlank ? ["", rendered] : [rendered];
945
+ paint = {
946
+ width,
947
+ theme,
948
+ resolved,
949
+ hover,
950
+ hint,
951
+ pad,
952
+ frame,
953
+ leadingBlank,
954
+ lines,
955
+ };
956
+ return lines;
957
+ },
958
+ invalidate() {
959
+ paint = undefined;
661
960
  },
662
- invalidate() {},
663
961
  };
664
962
  }
665
963
 
@@ -675,14 +973,49 @@ function compactStopStatusLine(status: string, pad = 0): any {
675
973
  };
676
974
  }
677
975
 
678
- function compactAssistantLine(
679
- component: any,
680
- summary: string | (() => string),
681
- query?: CompactThinkingQuery,
682
- ): void {
683
- // 空静态串跳过;getter 留给 render 判断(Running 可能稍后才有时长)。
684
- if (typeof summary === "string" && !summary) return;
685
- component.contentContainer.addChild(compactAssistantLineComponent(component, summary, query));
976
+ /**
977
+ * 槽位卡内的展开入口:卡里是只读围观,展开走摘要行自己的 hint,卡内不再重复提示。
978
+ * 提示有两处来源——我们的 showMoreHintText(`… +N more lines • click to show more`)与
979
+ * pi 原生工具卡(`… (N earlier lines, ctrl+o to expand)`),所以按渲染结果收尾裁剪。
980
+ * 只去掉动作词,`(N more lines)` 这类计数保留。
981
+ */
982
+ /** 展开入口的动作词:我们的 `click to show more`、pi 的 `<key> to expand`(键名可能取不到)。 */
983
+ const EXPAND_ACTION = String.raw`(?:click to show more|(?:[\w/+]+\s+)?to (?:show more|expand))`;
984
+
985
+ const SLOT_HINT_PATTERNS: RegExp[] = [
986
+ // 卡在括号里:`(9 more lines, click to show more)` / `(195 earlier lines, ctrl+o to expand)`,
987
+ // 只去掉动作词,收尾括号与计数保留。
988
+ new RegExp(String.raw`,\s*[^()]*?${EXPAND_ACTION}(?=\))`),
989
+ // 直接挂在行尾:整段去掉。
990
+ new RegExp(String.raw`\s*(?:•\s*)?${EXPAND_ACTION}\s*$`),
991
+ ];
992
+
993
+ function stripSlotHint(line: string): string {
994
+ const plain = stripTerminalSequencesPreservingLayout(line);
995
+ for (const re of SLOT_HINT_PATTERNS) {
996
+ const match = re.exec(plain);
997
+ if (!match) continue;
998
+ // 删除区间而非截断后拼纯文本:收尾括号留在原样式里(dim),不会掉回默认前景色。
999
+ return removeStyledRange(line, match.index, match.index + match[0].length);
1000
+ }
1001
+ return line;
1002
+ }
1003
+
1004
+ /** live 槽位卡:按 pad 缩进;首行 `↳` 由卡自身的 live 排布加,行不增不减。 */
1005
+ function compactLiveSlot(card: any, pad = 0): any {
1006
+ const innerOf = (width: number) => Math.max(0, width - Math.max(0, pad));
1007
+ return {
1008
+ render(width: number): string[] {
1009
+ const indent = " ".repeat(Math.max(0, pad));
1010
+ return card.render(innerOf(width)).map((line: string) => `${indent}${line}`);
1011
+ },
1012
+ childAtRow(localRow: number, width: number) {
1013
+ return card.childAtRow?.(localRow, innerOf(width)) ?? null;
1014
+ },
1015
+ invalidate() {
1016
+ card.invalidate();
1017
+ },
1018
+ };
686
1019
  }
687
1020
 
688
1021
  function appendStopStatus(component: any, status: string | undefined): void {
@@ -724,16 +1057,9 @@ function ensureAssistantSetExpanded(component: any): void {
724
1057
 
725
1058
  function collectMountedComponents(root: any): void {
726
1059
  if (!root || typeof root !== "object") return;
727
- const seen = new Set<any>();
728
1060
  const assistants = new Set<any>();
729
1061
  const tools = new Set<any>();
730
- const visit = (value: any): void => {
731
- if (!value || typeof value !== "object" || seen.has(value)) return;
732
- seen.add(value);
733
- if (Array.isArray(value)) {
734
- for (const child of value) visit(child);
735
- return;
736
- }
1062
+ walkComponentTree(root, (value: any) => {
737
1063
  if (isAssistantComponent(value)) {
738
1064
  assistants.add(value);
739
1065
  // 仅在 compact 模式给实例装 setExpanded,避免 on/off 下 Ctrl+O/mouse 回归。
@@ -741,20 +1067,7 @@ function collectMountedComponents(root: any): void {
741
1067
  } else if (isToolComponent(value)) {
742
1068
  tools.add(value);
743
1069
  }
744
- const children = value.children;
745
- if (Array.isArray(children)) {
746
- for (const child of children) visit(child);
747
- }
748
- try {
749
- const mounted = value.getMountedRoots?.();
750
- if (Array.isArray(mounted)) {
751
- for (const root2 of mounted) visit(root2);
752
- }
753
- } catch {
754
- // renderer 切换中的惰性 Proxy 可能暂时没有 mounted roots
755
- }
756
- };
757
- visit(root);
1070
+ });
758
1071
  // 扫到组件才替换跟踪表。面板/custom UI 打开时 root 往往扫不到 transcript,
759
1072
  // 若此时清空会丢掉 live updateContent/updateDisplay 已登记的实例,
760
1073
  // /ccstyle 切换就只能靠 /reload 重建。
@@ -769,14 +1082,8 @@ function collectMountedComponents(root: any): void {
769
1082
  }
770
1083
 
771
1084
  export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHooks {
772
- const host = globalThis as any;
773
- const previous = host[COMPACT_MODE_PATCH_KEY] as CompactModePatch | undefined;
774
- if (previous) {
775
- // Replacement reload:先释放旧 compact 外层,再清理其 thinking owner,
776
- // 否则下面会把旧 thinking wrapper 误采样为 native prototype。
777
- previous.dispose();
778
- host[COMPACT_THINKING_OWNER_KEY]?.stop?.();
779
- }
1085
+ const previous = patchRegistry.get<CompactModePatch>(COMPACT_MODE_PATCH_KEY);
1086
+ if (previous) previous.dispose();
780
1087
 
781
1088
  const assistantPrototype = AssistantMessageComponent.prototype as any;
782
1089
  const toolPrototype = ToolExecutionComponent.prototype as any;
@@ -812,13 +1119,24 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
812
1119
  detachedMessages: any[];
813
1120
  active: boolean;
814
1121
  suppressedToolIds: Set<string>;
1122
+ /** live 槽位内联渲染的工具 id;回合结束时随槽位一起交还外层。 */
1123
+ liveSlotToolIds: Set<string>;
1124
+ /** 收尾后的静默期截止时间戳;期间仍按围观态渲染,到点才收拢。 */
1125
+ settleUntil?: number;
1126
+ foldTimer?: ReturnType<typeof setTimeout>;
815
1127
  /** 回合挂钟起点,保证 Running 时长连续递增。 */
816
1128
  startedAt: number;
817
1129
  endedAt?: number;
1130
+ /** 回合尾行(摘要 + live 槽位),挂在 transcript 容器的回合末尾。 */
1131
+ tail?: MountedRoundTail;
818
1132
  };
819
1133
  let activeRound: CompactRound | undefined;
820
1134
  let roundByComponent = new WeakMap<object, CompactRound>();
821
1135
  const expandedRoundToolIds = new Set<string>();
1136
+ /** 用户显式点开的 round 内工具;强制折叠不压回这些 id。 */
1137
+ const explicitRoundToolIds = new Set<string>();
1138
+ const liveSlotToolIds = new Set<string>();
1139
+ const pendingFoldRounds = new Set<CompactRound>();
822
1140
  let uiRef: { requestRender?: (force?: boolean) => void } | undefined;
823
1141
  let roundTickTimer: ReturnType<typeof setInterval> | undefined;
824
1142
 
@@ -849,17 +1167,32 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
849
1167
  stopRoundTick();
850
1168
  return;
851
1169
  }
1170
+ settleStrandedRounds();
852
1171
  try {
853
- uiRef?.requestRender?.(true);
1172
+ // 非 force:保留 fullscreen 布局缓存和差分绘制。
1173
+ uiRef?.requestRender?.();
854
1174
  } catch {
855
1175
  /* 无 UI */
856
1176
  }
857
1177
  }, 250);
858
- roundTickTimer.unref?.();
859
1178
  };
860
1179
 
861
- /** 结束回合活动态;可选立即重绘。 */
862
- const endRound = (round: CompactRound, render = false): void => {
1180
+ /** 围观中:活动回合,或收尾静默期还没走完的回合(槽位卡仍挂在屏幕上)。 */
1181
+ const roundLive = (round: CompactRound): boolean =>
1182
+ round.active || (round.settleUntil !== undefined && Date.now() < round.settleUntil);
1183
+
1184
+ const clearFoldTimer = (round: CompactRound): void => {
1185
+ if (round.foldTimer !== undefined) {
1186
+ clearTimeout(round.foldTimer);
1187
+ round.foldTimer = undefined;
1188
+ }
1189
+ round.settleUntil = undefined;
1190
+ pendingFoldRounds.delete(round);
1191
+ };
1192
+
1193
+ /** 结束回合活动态;不做任何重绘。 */
1194
+ const finalizeRound = (round: CompactRound): void => {
1195
+ clearFoldTimer(round);
863
1196
  round.active = false;
864
1197
  if (round.endedAt === undefined) round.endedAt = Date.now();
865
1198
  if (activeRound === round) {
@@ -867,7 +1200,36 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
867
1200
  deps.query?.setCompactSummaryActive?.(false);
868
1201
  stopRoundTick();
869
1202
  }
870
- if (render) renderRound(round);
1203
+ };
1204
+
1205
+ /**
1206
+ * 结束回合活动态;render=true 时走静默期,延迟 FOLD_SETTLE_MS 再收拢。
1207
+ * 分组语义不受影响:activeRound 立即让位,新消息不会被并进已收尾的回合。
1208
+ */
1209
+ const endRound = (round: CompactRound, render = false): void => {
1210
+ if (!render || !round.active) {
1211
+ finalizeRound(round);
1212
+ if (render) renderRound(round);
1213
+ return;
1214
+ }
1215
+ finalizeRound(round);
1216
+ if (round.foldTimer !== undefined) return;
1217
+ round.settleUntil = Date.now() + FOLD_SETTLE_MS;
1218
+ pendingFoldRounds.add(round);
1219
+ round.foldTimer = setTimeout(() => {
1220
+ round.foldTimer = undefined;
1221
+ round.settleUntil = undefined;
1222
+ pendingFoldRounds.delete(round);
1223
+ renderRound(round);
1224
+ }, FOLD_SETTLE_MS);
1225
+ };
1226
+
1227
+ /** 新回合接替时立即收掉还在静默期的回合,把两次转场并成一次。 */
1228
+ const flushSettle = (): void => {
1229
+ for (const round of [...pendingFoldRounds]) {
1230
+ clearFoldTimer(round);
1231
+ renderRound(round);
1232
+ }
871
1233
  };
872
1234
 
873
1235
  const renderAssistantWithoutThinking = (
@@ -888,11 +1250,46 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
888
1250
  return result;
889
1251
  };
890
1252
 
1253
+ /** compact 只允许单开:收回一张工具卡的展开态。 */
1254
+ const collapseTool = (tool: any): void => {
1255
+ if (typeof tool?.setExpanded === "function") tool.setExpanded(false);
1256
+ else {
1257
+ tool.expanded = false;
1258
+ tool.updateDisplay?.();
1259
+ }
1260
+ };
1261
+
1262
+ /** 按条件收回已展开的工具卡。 */
1263
+ const collapseTools = (shouldCollapse: (tool: any) => boolean): void => {
1264
+ for (const tool of trackedToolComponents) {
1265
+ if (tool.expanded === true && shouldCollapse(tool)) collapseTool(tool);
1266
+ }
1267
+ };
1268
+
1269
+ const markRoundToolUserExpanded = (tool: any): void => {
1270
+ const id = String(tool?.toolCallId ?? "");
1271
+ if (!id || !expandedRoundToolIds.has(id)) return;
1272
+ for (const other of trackedToolComponents) {
1273
+ const otherId = String(other?.toolCallId ?? "");
1274
+ if (other === tool || !expandedRoundToolIds.has(otherId) || other.expanded !== true) continue;
1275
+ explicitRoundToolIds.delete(otherId);
1276
+ collapseTool(other);
1277
+ }
1278
+ explicitRoundToolIds.add(id);
1279
+ };
1280
+ roundToolExpansion = { markUserExpanded: markRoundToolUserExpanded };
1281
+
891
1282
  const roundMessages = (round: CompactRound): any[] => [
892
1283
  ...round.messages.values(),
893
1284
  ...round.detachedMessages,
894
1285
  ];
895
1286
 
1287
+ /** 镜像给常驻状态行的文案:只认当前活动回合(摘要行被顶出视口时由 working-message 取用)。 */
1288
+ compactRunStatusGetter = () => {
1289
+ const round = activeRound;
1290
+ return round?.active ? summarize(roundMessages(round), true, round) || undefined : undefined;
1291
+ };
1292
+
896
1293
  const roundToolCallIds = (round: CompactRound): Set<string> => {
897
1294
  const ids = new Set<string>();
898
1295
  for (const message of roundMessages(round)) {
@@ -903,83 +1300,519 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
903
1300
  return ids;
904
1301
  };
905
1302
 
1303
+ /**
1304
+ * anchor 所在的 transcript 容器(pi chatContainer)。
1305
+ * 优先走分组补丁在 addChild 时挂的父指针;没有则沿 tui 组件树找直属父容器。
1306
+ */
1307
+ const tailContainerOf = (anchor: any): any => {
1308
+ const keyed = anchor?.[TOOL_GROUPING_PARENT_KEY];
1309
+ if (keyed && Array.isArray(keyed.children) && keyed.children.includes(anchor)) {
1310
+ return keyed;
1311
+ }
1312
+ let found: any;
1313
+ walkComponentTree(getToolMouseTui(), (value: any) => {
1314
+ if (found) return false;
1315
+ if (Array.isArray(value?.children) && value.children.includes(anchor)) {
1316
+ found = value;
1317
+ return false;
1318
+ }
1319
+ return undefined;
1320
+ });
1321
+ return found;
1322
+ };
1323
+
1324
+ /** 组件在树里的直属父容器。TUI 切换/reparent 后缓存的 container 会过期。 */
1325
+ const containerOfHost = (host: any): any => {
1326
+ let found: any;
1327
+ walkComponentTree(getToolMouseTui(), (value: any) => {
1328
+ if (found) return false;
1329
+ if (Array.isArray(value?.children) && value.children.includes(host)) {
1330
+ found = value;
1331
+ return false;
1332
+ }
1333
+ return undefined;
1334
+ });
1335
+ return found;
1336
+ };
1337
+
1338
+ /** 尾行实际所在容器;缓存的 container 过期时按树找回来。 */
1339
+ const tailHostContainer = (tail: MountedRoundTail): any =>
1340
+ containerOfHost(tail.host) ??
1341
+ (tail.container?.children?.includes?.(tail.host) === true ? tail.container : undefined);
1342
+
1343
+ /** 摘尾行:按实际所在容器摘,避免缓存过期时摘了个空、行留在树上。 */
1344
+ const removeTailHost = (tail: MountedRoundTail): void => {
1345
+ tailHostContainer(tail)?.removeChild?.(tail.host);
1346
+ };
1347
+
1348
+ const ensureTailHost = (round: CompactRound): MountedRoundTail => {
1349
+ if (round.tail) return round.tail;
1350
+ // 同一 anchor 上残留的旧回合尾行(reset/重组后建新回合)先摘除,避免双摘要。
1351
+ const stale = tailByAnchor.get(round.anchor);
1352
+ if (stale) {
1353
+ removeTailHost(stale);
1354
+ mountedTails.delete(stale);
1355
+ tailByAnchor.delete(round.anchor);
1356
+ }
1357
+ const tail: MountedRoundTail = { host: undefined, container: undefined, parts: [], round };
1358
+ const host: any = {
1359
+ outputPad: Number(round.anchor?.outputPad) || 0,
1360
+ render: (width: number) => tail.parts.flatMap((part: any) => part?.render?.(width) ?? []),
1361
+ invalidate: () => {
1362
+ for (const part of tail.parts) part?.invalidate?.();
1363
+ },
1364
+ };
1365
+ Object.defineProperty(host, "expanded", {
1366
+ configurable: true,
1367
+ get: () => tail.round?.anchor?.expanded === true,
1368
+ set: (value: boolean) => {
1369
+ const anchor = tail.round?.anchor;
1370
+ if (anchor) anchor.expanded = value;
1371
+ },
1372
+ });
1373
+ // 点击/悬停归属与 anchor 同一展开入口;toggle 每次 renderRound 重建,按值转发。
1374
+ // 经 tail.round 动态解引用:重建过户时 anchor 可能换成别的组件。
1375
+ ensureAssistantSetExpanded(host);
1376
+ host[ASSISTANT_TOGGLE_ROUND_KEY] = (expanded: boolean) =>
1377
+ tail.round?.anchor?.[ASSISTANT_TOGGLE_ROUND_KEY]?.(expanded);
1378
+ tail.host = host;
1379
+ tailByAnchor.set(round.anchor, tail);
1380
+ mountedTails.add(tail);
1381
+ round.tail = tail;
1382
+ return tail;
1383
+ };
1384
+
1385
+ /**
1386
+ * 尾行归位。运行中(含收尾静默期)尾行恒为容器最末一行:计数行钉在
1387
+ * transcript 底缘,运行中到达的提示行/custom 条目只能落在它上方,计数
1388
+ * 不会被顶离底部截成两行。回合收尾后摘要行回落到回合最后一个成员之后,
1389
+ * 外来行统一换到摘要行之下,通知行永远落在回合边界外。
1390
+ */
1391
+ const syncTailPosition = (round: CompactRound): void => {
1392
+ const tail = round.tail;
1393
+ if (!tail?.host) return;
1394
+ const container =
1395
+ tail.container?.children?.includes?.(round.anchor) === true
1396
+ ? tail.container
1397
+ : tailContainerOf(round.anchor);
1398
+ if (!container || !Array.isArray(container.children)) return;
1399
+ tail.container = container;
1400
+ const children = container.children as any[];
1401
+ const current = children.indexOf(tail.host);
1402
+ if (current >= 0) children.splice(current, 1);
1403
+ const ids = roundToolCallIds(round);
1404
+ const matchesTool = (value: any): boolean =>
1405
+ typeof value?.toolCallId === "string" && ids.has(value.toolCallId);
1406
+ const isMember = (value: any): boolean =>
1407
+ round.messages.has(value) ||
1408
+ matchesTool(value) ||
1409
+ (Array.isArray(value?.children) && value.children.some(matchesTool));
1410
+ let first = -1;
1411
+ let last = -1;
1412
+ for (let i = 0; i < children.length; i++) {
1413
+ if (!isMember(children[i])) continue;
1414
+ if (first < 0) first = i;
1415
+ last = i;
1416
+ }
1417
+ const lastMember = last >= 0 ? children[last] : undefined;
1418
+ const intruders: any[] = [];
1419
+ for (let i = last - 1; i > first; i--) {
1420
+ if (!isMember(children[i])) intruders.unshift(children.splice(i, 1)[0]);
1421
+ }
1422
+ if (lastMember) last = children.indexOf(lastMember);
1423
+ const insertAt = last < 0 ? children.length : last + 1;
1424
+ if (roundLive(round)) {
1425
+ // 活回合:外来行压到最后成员之后,尾行占容器最末。
1426
+ children.splice(insertAt, 0, ...intruders);
1427
+ children.push(tail.host);
1428
+ } else {
1429
+ children.splice(insertAt, 0, tail.host, ...intruders);
1430
+ }
1431
+ tail.host[TOOL_GROUPING_PARENT_KEY] = container;
1432
+ };
1433
+
1434
+ const unmountRoundTail = (round: CompactRound): void => {
1435
+ const tail = round.tail;
1436
+ if (!tail) return;
1437
+ round.tail = undefined;
1438
+ removeTailHost(tail);
1439
+ mountedTails.delete(tail);
1440
+ if (tailByAnchor.get(round.anchor) === tail) tailByAnchor.delete(round.anchor);
1441
+ };
1442
+
1443
+ /**
1444
+ * pi 把新工具卡 append 在容器末尾(尾行之后):updateDisplay 时把所属回合的
1445
+ * 尾行压回末尾。只有 live/收尾静默期的回合还会长工具。
1446
+ */
1447
+ const syncTailForTool = (toolCallId: string): void => {
1448
+ if (!toolCallId) return;
1449
+ for (const round of [activeRound, ...pendingFoldRounds]) {
1450
+ if (round?.tail && roundToolCallIds(round).has(toolCallId)) {
1451
+ syncTailPosition(round);
1452
+ return;
1453
+ }
1454
+ }
1455
+ };
1456
+
1457
+ /**
1458
+ * refresh 重建时继承旧回合:resetRounds 只清索引不摘 compact 尾行,
1459
+ * 残留尾行带着旧 round。同一组件重放同一消息(lastMessage 回放)
1460
+ * 才算重建——保留挂钟起点、已脱离的工具历史与尾行组件本身;
1461
+ * 旧回合已收尾的直接落成折叠态(不回摆 Running、不重演静默期),
1462
+ * 仍活动的只接回挂钟,Running 时长继续走。
1463
+ */
1464
+ const adoptPriorRound = (round: CompactRound, message: any): void => {
1465
+ let prior: CompactRound | undefined;
1466
+ for (const tail of mountedTails) {
1467
+ const stale = tail.round as CompactRound | undefined;
1468
+ if (
1469
+ stale &&
1470
+ // 先认组件:重放时消息对象未必是同一个(会话重读/重建),
1471
+ // 只认消息身份会漏掉本该接回的回合,摘要行就此掉队。
1472
+ (stale.anchor === round.anchor ||
1473
+ stale.messages.has(round.anchor) ||
1474
+ stale.detachedMessages.includes(message))
1475
+ ) {
1476
+ prior = stale;
1477
+ break;
1478
+ }
1479
+ }
1480
+ if (!prior) return;
1481
+ round.startedAt = prior.startedAt;
1482
+ if (prior.detachedMessages.length) round.detachedMessages.push(...prior.detachedMessages);
1483
+ if (!prior.active) {
1484
+ round.active = false;
1485
+ round.endedAt = prior.endedAt ?? round.startedAt;
1486
+ }
1487
+ const tail = prior.tail;
1488
+ if (tail) {
1489
+ prior.tail = undefined;
1490
+ if (tailByAnchor.get(prior.anchor) === tail) tailByAnchor.delete(prior.anchor);
1491
+ tail.round = round;
1492
+ tailByAnchor.set(round.anchor, tail);
1493
+ round.tail = tail;
1494
+ }
1495
+ };
1496
+
1497
+ /** 新建活回合:literal + 激活 + refresh 重建时的旧回合收编。 */
1498
+ const createRound = (anchor: any, message: any): CompactRound => {
1499
+ const round: CompactRound = {
1500
+ anchor,
1501
+ messages: new Map(),
1502
+ detachedMessages: [],
1503
+ active: true,
1504
+ suppressedToolIds: new Set(),
1505
+ liveSlotToolIds: new Set(),
1506
+ startedAt: Date.now(),
1507
+ };
1508
+ activateRound(round);
1509
+ adoptPriorRound(round, message);
1510
+ return round;
1511
+ };
1512
+
1513
+ /**
1514
+ * 尾行挂载入口:makeParts 按渲染态产出内容(折叠态 [摘要行],live 态
1515
+ * [摘要行, 槽位卡]),identity 决定行内 hover/点击归属组件(尾行模式下是
1516
+ * host,hit-test 直达它)。anchor 未挂进任何容器时(独立渲染)退回
1517
+ * contentContainer——与旧行为一致。
1518
+ */
1519
+ const mountRoundTail = (round: CompactRound, makeParts: (identity: any) => any[]): void => {
1520
+ if (!tailContainerOf(round.anchor)) {
1521
+ // anchor 不在任何容器里(独立渲染/未挂载):退回 anchor 内部,与旧行为一致。
1522
+ unmountRoundTail(round);
1523
+ for (const part of makeParts(round.anchor)) {
1524
+ round.anchor?.contentContainer?.addChild?.(part);
1525
+ }
1526
+ return;
1527
+ }
1528
+ const tail = ensureTailHost(round);
1529
+ tail.parts = makeParts(tail.host);
1530
+ syncTailPosition(round);
1531
+ };
1532
+
1533
+ /**
1534
+ * 末块仍是 thinking = 思考还在长;后面跟了正文或工具调用就算结束。
1535
+ * 围观态只预览这一种,已完成的思考直接回收进摘要行,不在屏幕上留 Thought 行。
1536
+ */
1537
+ const hasTrailingThinking = (message: any): boolean => {
1538
+ const content = Array.isArray(message?.content) ? message.content : [];
1539
+ for (let i = content.length - 1; i >= 0; i--) {
1540
+ const item = content[i];
1541
+ if (item?.type === "thinking") return true;
1542
+ if (item?.type === "toolCall") return false;
1543
+ if (item?.type === "text" && String(item.text ?? "").trim()) return false;
1544
+ }
1545
+ return false;
1546
+ };
1547
+
1548
+ /** 运行中的工具:已开始且尚无最终结果。 */
1549
+ const isRunningTool = (tool: any): boolean =>
1550
+ tool?.executionStarted === true && (!tool.result || tool.isPartial === true);
1551
+
1552
+ /**
1553
+ * live 围观态:正文 + 摘要行照折叠态渲染,只在摘要行下多一张槽位卡。
1554
+ * - 思考:只预览最后一条仍在思考的消息,它的块是当前最新内容;
1555
+ * - 工具:没有活动思考时才占槽位,运行中优先,否则取本回合最近的,完成也留着;
1556
+ * - 槽位只放一个块(思考或工具),新的思考块/工具调用进来才轮换;
1557
+ * - 本回合所有工具都由槽位卡接管,外层不再单独成行。
1558
+ * 可见性判断只在这里发生;回合结束(renderRound 非 active)只剩槽位卡消失。
1559
+ */
1560
+ const renderRoundLive = (
1561
+ round: CompactRound,
1562
+ stopStatus: string | undefined,
1563
+ getSummary: () => string,
1564
+ ): void => {
1565
+ // 仍在思考的消息 = 当前最新内容,优先占槽位。
1566
+ let previewComponent: any;
1567
+ for (const [component, message] of round.messages) {
1568
+ if (hasTrailingThinking(message)) previewComponent = component;
1569
+ }
1570
+
1571
+ const thinkingKids: any[] = [];
1572
+ for (const [component, message] of round.messages) {
1573
+ if (component !== previewComponent) {
1574
+ component.contentContainer?.clear?.();
1575
+ continue;
1576
+ }
1577
+ passThroughAssistant(component, message);
1578
+ const kids = Array.isArray(component.contentContainer?.children)
1579
+ ? [...component.contentContainer.children]
1580
+ : [];
1581
+ component.contentContainer?.clear?.();
1582
+ // 只摘思考预览块进槽位;已收尾的思考回收进摘要行。
1583
+ for (const kid of kids) {
1584
+ if (typeof kid?.setHintHovered === "function") thinkingKids.push(kid);
1585
+ }
1586
+ }
1587
+ // 正文照折叠态渲染(不含 thinking):live 与收尾后同形,收尾只剩槽位卡消失。
1588
+ renderAssistantWithoutThinking(round.anchor, round.messages.get(round.anchor));
1589
+
1590
+ const toolsById = new Map<string, any>();
1591
+ for (const tool of trackedToolComponents) {
1592
+ if (typeof tool?.toolCallId === "string") toolsById.set(tool.toolCallId, tool);
1593
+ }
1594
+ let runningTool: any;
1595
+ let lastTool: any;
1596
+ for (const [, message] of round.messages) {
1597
+ for (const item of Array.isArray(message?.content) ? message.content : []) {
1598
+ if (item?.type !== "toolCall" || typeof item.id !== "string") continue;
1599
+ const tool = toolsById.get(item.id);
1600
+ // edit/write 保留自己的 compact 行,不进槽位。
1601
+ if (EDIT_WRITE_TOOLS.has(String(tool?.toolName ?? item.name ?? ""))) continue;
1602
+ round.liveSlotToolIds.add(item.id);
1603
+ liveSlotToolIds.add(item.id);
1604
+ if (!tool) continue;
1605
+ lastTool = tool;
1606
+ if (isRunningTool(tool)) runningTool = tool;
1607
+ }
1608
+ }
1609
+ // 槽位单块:活动思考最新;否则本回合最近的工具(运行中原位增长,完成也留着)。
1610
+ const slotTool = thinkingKids.length > 0 ? undefined : (runningTool ?? lastTool);
1611
+ const cardItems: Array<{ child?: any; tool?: any }> = slotTool
1612
+ ? [{ tool: slotTool }]
1613
+ : thinkingKids.map((child) => ({ child }));
1614
+
1615
+ const anchor = round.anchor;
1616
+ const pad = Number(anchor.outputPad) || 0;
1617
+ // 摘要行与槽位卡挂回合尾行(transcript 容器内回合末尾,而非 anchor 内部):
1618
+ // 运行中恒在可写视口底缘,收尾的 Ran for 原地落进 scrollback,不再留下
1619
+ // 被挤出视口后定格的 Running 帧。
1620
+ mountRoundTail(round, (identity) => {
1621
+ const parts: any[] = [compactAssistantLineComponent(identity, getSummary, deps.query)];
1622
+ if (cardItems.length > 0) {
1623
+ parts.push(
1624
+ compactLiveSlot(
1625
+ compactRoundCard(
1626
+ cardItems,
1627
+ (tool, innerWidth) => patch.toolOriginalRender.call(tool, innerWidth),
1628
+ false,
1629
+ true,
1630
+ ),
1631
+ pad,
1632
+ ),
1633
+ );
1634
+ }
1635
+ return parts;
1636
+ });
1637
+ appendStopStatus(anchor, stopStatus);
1638
+ };
1639
+
906
1640
  const renderRound = (round: CompactRound): void => {
907
1641
  const messages = roundMessages(round);
908
1642
  const stopStatus = roundStopStatus(messages);
909
1643
  if (stopStatus) endRound(round);
910
1644
  // Running 时每次 render 重算时长(含挂钟下限);结束后固定。
1645
+ // 静默期内回合已收尾,摘要行直接给 Ran for,只有槽位卡多留一拍。
911
1646
  const getSummary = () => summarize(roundMessages(round), round.active, round);
912
1647
  const summary = getSummary();
913
1648
  for (const id of round.suppressedToolIds) expandedRoundToolIds.delete(id);
914
1649
  round.suppressedToolIds.clear();
1650
+ // 回合收起(或本回合不在展开态)时忘掉用户的单开选择,并把面板接管的工具一起收回:
1651
+ // 少了后一步,被点开的工具会带着自身的 expanded 落到 toolInstalledRender 的放行分支,
1652
+ // 面板一收就单独渲染成一张卡。
1653
+ if (round.anchor.expanded !== true) {
1654
+ const ids = roundToolCallIds(round);
1655
+ for (const id of ids) explicitRoundToolIds.delete(id);
1656
+ collapseTools(
1657
+ (tool) =>
1658
+ ids.has(String(tool.toolCallId ?? "")) &&
1659
+ !EDIT_WRITE_TOOLS.has(String(tool.toolName ?? "")),
1660
+ );
1661
+ }
1662
+ for (const id of round.liveSlotToolIds) liveSlotToolIds.delete(id);
1663
+ round.liveSlotToolIds.clear();
915
1664
 
916
1665
  for (const [component, message] of round.messages) {
917
1666
  component.lastMessage = message;
918
1667
  ensureAssistantSetExpanded(component);
919
1668
  component[ASSISTANT_TOGGLE_ROUND_KEY] = (expanded: boolean) => {
1669
+ const wasExpanded = round.anchor.expanded === true;
1670
+ if (expanded && !wasExpanded) capturePanelViewport(round.anchor);
920
1671
  for (const member of round.messages.keys()) member.expanded = expanded;
921
1672
  if (!expanded) {
922
1673
  for (const id of round.suppressedToolIds) expandedRoundToolIds.delete(id);
923
1674
  round.suppressedToolIds.clear();
924
1675
  }
925
1676
  renderRound(round);
1677
+ if (!expanded && wasExpanded) restorePanelViewport(round.anchor);
926
1678
  };
927
1679
  }
928
1680
 
929
1681
  if (round.anchor.expanded === true) {
930
- const assistantChildren: any[] = [];
1682
+ // 展开卡自带全部内容,尾行摘下(收起时在折叠分支重新挂载)。
1683
+ unmountRoundTail(round);
1684
+ const toolsById = new Map<string, any>();
1685
+ for (const tool of trackedToolComponents) {
1686
+ if (typeof tool?.toolCallId === "string") toolsById.set(tool.toolCallId, tool);
1687
+ }
1688
+ // 助手文本按原生渲染、不进面板(面板只留 thinking 与工具卡)。
1689
+ // thinking/工具卡按连续段各包一张面板;先出文本再出面板,
1690
+ // 否则消息内容里的 thinking([thinking, text, toolCall])会盖到文本上面。
1691
+ const anchorChildren: any[] = [];
1692
+ let cardItems: Array<{ child?: any; tool?: any }> = [];
1693
+ const flushPanel = () => {
1694
+ if (cardItems.length === 0) return;
1695
+ if (cardItems.every((item) => !item.tool && isSpacerComponent(item.child))) {
1696
+ for (const item of cardItems) anchorChildren.push(item.child);
1697
+ } else {
1698
+ // 面板上沿留 1 行卡外空行:面板底色与 userMessage 同色,
1699
+ // 贴在一起会看成一个块;卡内顶部再加 1 行 padding 保持上下对称。
1700
+ anchorChildren.push(new Spacer(1));
1701
+ anchorChildren.push(
1702
+ compactRoundCard(
1703
+ cardItems,
1704
+ (tool, innerWidth) => patch.toolOriginalRender.call(tool, innerWidth),
1705
+ // 整回合展开卡:卡内工具行归 tool 组件,可单击二次展开。
1706
+ true,
1707
+ ),
1708
+ );
1709
+ }
1710
+ cardItems = [];
1711
+ };
1712
+ const takeChild = (child: any, outside: boolean) => {
1713
+ if (!outside) {
1714
+ cardItems.push({ child });
1715
+ return;
1716
+ }
1717
+ flushPanel();
1718
+ anchorChildren.push(child);
1719
+ };
1720
+ const placedToolIds = new Set<string>();
1721
+ const placeTool = (id: string, fallbackName?: string) => {
1722
+ const tool = toolsById.get(id);
1723
+ if (!tool || EDIT_WRITE_TOOLS.has(String(tool.toolName ?? fallbackName ?? ""))) return;
1724
+ if (placedToolIds.has(id)) return;
1725
+ placedToolIds.add(id);
1726
+ cardItems.push({ tool });
1727
+ };
1728
+ type LocalEntry = { outside: boolean; child?: any; toolId?: string; toolName?: string };
931
1729
  for (const [component, message] of round.messages) {
932
1730
  passThroughAssistant(component, message);
933
- if (Array.isArray(component.contentContainer?.children)) {
934
- assistantChildren.push(...component.contentContainer.children);
935
- }
1731
+ const kids = Array.isArray(component.contentContainer?.children)
1732
+ ? [...component.contentContainer.children]
1733
+ : [];
936
1734
  component.contentContainer?.clear?.();
1735
+ const cursor = { i: 0 };
1736
+ let inThinkingRun = false;
1737
+ // 本消息产出先收集再发射:文本(含其前导 Spacer)统一排在面板前面。
1738
+ const local: LocalEntry[] = [];
1739
+ const takeRun = (outside: boolean) => {
1740
+ while (cursor.i < kids.length && isSpacerComponent(kids[cursor.i])) {
1741
+ local.push({ outside, child: kids[cursor.i++] });
1742
+ }
1743
+ if (cursor.i < kids.length) local.push({ outside, child: kids[cursor.i++] });
1744
+ };
1745
+ for (const item of Array.isArray(message?.content) ? message.content : []) {
1746
+ if (item?.type === "thinking") {
1747
+ if (!String(item.thinking ?? "").trim()) continue;
1748
+ if (!inThinkingRun) {
1749
+ takeRun(false);
1750
+ inThinkingRun = true;
1751
+ }
1752
+ continue;
1753
+ }
1754
+ inThinkingRun = false;
1755
+ if (item?.type === "text" && String(item.text ?? "").trim()) {
1756
+ takeRun(true);
1757
+ continue;
1758
+ }
1759
+ if (item?.type === "toolCall" && typeof item.id === "string") {
1760
+ local.push({ outside: false, toolId: item.id, toolName: item.name });
1761
+ }
1762
+ }
1763
+ while (cursor.i < kids.length) local.push({ outside: false, child: kids[cursor.i++] });
1764
+ for (const entry of local) if (entry.outside) takeChild(entry.child, true);
1765
+ for (const entry of local) {
1766
+ if (entry.outside) continue;
1767
+ if (entry.toolId) placeTool(entry.toolId, entry.toolName);
1768
+ else takeChild(entry.child, false);
1769
+ }
1770
+ }
1771
+ for (const message of round.detachedMessages) {
1772
+ for (const item of Array.isArray(message?.content) ? message.content : []) {
1773
+ if (item?.type === "toolCall" && typeof item.id === "string") {
1774
+ placeTool(item.id, item.name);
1775
+ }
1776
+ }
937
1777
  }
938
1778
  const ids = roundToolCallIds(round);
939
1779
  for (const id of ids) {
940
1780
  round.suppressedToolIds.add(id);
941
1781
  expandedRoundToolIds.add(id);
1782
+ if (!placedToolIds.has(id)) placeTool(id);
942
1783
  }
943
- // Round 展开只打开外层卡片。普通工具保持折叠,避免长输出递归撑满屏幕。
944
- for (const tool of trackedToolComponents) {
945
- if (
946
- !ids.has(tool.toolCallId) ||
947
- EDIT_WRITE_TOOLS.has(String(tool.toolName ?? "")) ||
948
- tool.expanded !== true
949
- ) {
950
- continue;
951
- }
952
- if (typeof tool.setExpanded === "function") tool.setExpanded(false);
953
- else {
954
- tool.expanded = false;
955
- tool.updateDisplay?.();
956
- }
957
- }
958
- const cardItems: Array<{ child?: any; tool?: any }> = assistantChildren.map((child) => ({
959
- child,
960
- }));
961
- const roundIds = roundToolCallIds(round);
962
- for (const tool of trackedToolComponents) {
963
- if (!roundIds.has(tool.toolCallId) || EDIT_WRITE_TOOLS.has(String(tool.toolName ?? ""))) {
964
- continue;
965
- }
966
- cardItems.push({ tool });
967
- }
968
- round.anchor.contentContainer.addChild(
969
- compactRoundCard(cardItems, (tool, innerWidth) =>
970
- patch.toolOriginalRender.call(tool, innerWidth),
971
- ),
1784
+ // Round 展开只打开外层卡片。普通工具保持折叠,避免长输出递归撑满屏幕;
1785
+ // 用户单独点开的(explicitRoundToolIds)不压回去。
1786
+ collapseTools(
1787
+ (tool) =>
1788
+ ids.has(String(tool.toolCallId ?? "")) &&
1789
+ !EDIT_WRITE_TOOLS.has(String(tool.toolName ?? "")) &&
1790
+ !explicitRoundToolIds.has(String(tool.toolCallId ?? "")),
972
1791
  );
1792
+ flushPanel();
1793
+ for (const child of anchorChildren) round.anchor.contentContainer.addChild(child);
973
1794
  // 展开卡内工具会显示 error,外层仍挂 abort/length,避免只藏在折叠工具里。
974
1795
  appendStopStatus(round.anchor, stopStatus);
975
1796
  return;
976
1797
  }
977
1798
 
1799
+ if (roundLive(round)) {
1800
+ // 围观态:单一槽位卡(活动思考预览 + 当前工具),回合结束自动收回。
1801
+ renderRoundLive(round, stopStatus, getSummary);
1802
+ return;
1803
+ }
1804
+
978
1805
  for (const [component, message] of round.messages) {
979
1806
  if (component === round.anchor) {
980
1807
  renderAssistantWithoutThinking(component, message);
981
1808
  // 空摘要不挂行(getter 在 Running 启动瞬间也可能短暂为空)
982
- if (summary || round.active) compactAssistantLine(component, getSummary, deps.query);
1809
+ if (summary || round.active) {
1810
+ mountRoundTail(round, (identity) => [
1811
+ compactAssistantLineComponent(identity, getSummary, deps.query),
1812
+ ]);
1813
+ } else {
1814
+ unmountRoundTail(round);
1815
+ }
983
1816
  // 折叠时工具行被隐藏:abort/error/length 必须挂在摘要外层。
984
1817
  appendStopStatus(component, stopStatus);
985
1818
  } else {
@@ -989,6 +1822,7 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
989
1822
  };
990
1823
 
991
1824
  const activateRound = (round: CompactRound): void => {
1825
+ flushSettle();
992
1826
  round.active = true;
993
1827
  if (!round.startedAt) round.startedAt = Date.now();
994
1828
  delete round.endedAt;
@@ -1000,15 +1834,50 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1000
1834
  const finishRound = (round: CompactRound): void => endRound(round, true);
1001
1835
 
1002
1836
  const resetRounds = (): void => {
1837
+ for (const round of [...pendingFoldRounds]) clearFoldTimer(round);
1003
1838
  activeRound = undefined;
1004
1839
  roundByComponent = new WeakMap();
1005
1840
  expandedRoundToolIds.clear();
1841
+ explicitRoundToolIds.clear();
1842
+ liveSlotToolIds.clear();
1843
+ // 尾行是 transcript 兄弟组件:compact 下保留为静态行(与原 anchor 内摘要
1844
+ // 一致;同 anchor 重建回合时 ensureTailHost 去重摘除),非 compact 摘掉,
1845
+ // 别把紧凑摘要带进 default 渲染。
1846
+ for (const tail of [...mountedTails]) {
1847
+ const container = tailHostContainer(tail);
1848
+ if (!container) {
1849
+ mountedTails.delete(tail);
1850
+ continue;
1851
+ }
1852
+ tail.container = container;
1853
+ if (config.mode === "compact") continue;
1854
+ container.removeChild(tail.host);
1855
+ mountedTails.delete(tail);
1856
+ if (tail.round?.tail === tail) tail.round.tail = undefined;
1857
+ if (tail.round?.anchor && tailByAnchor.get(tail.round.anchor) === tail) {
1858
+ tailByAnchor.delete(tail.round.anchor);
1859
+ }
1860
+ }
1006
1861
  deps.query?.setCompactSummaryActive?.(false);
1007
1862
  stopRoundTick();
1008
1863
  };
1009
1864
 
1865
+ /**
1866
+ * 兜底:resetRounds 会丢掉回合索引但保留已挂的尾行,重放没能接回的回合会一直
1867
+ * active——尾行永远显示 Running...(时长还在涨)却不再计入新工具。既不是活动
1868
+ * 回合也不在收尾静默期的活回合就地收尾,不让它永远挂在屏幕上。
1869
+ */
1870
+ const settleStrandedRounds = (): void => {
1871
+ for (const tail of [...mountedTails]) {
1872
+ const round = tail.round as CompactRound | undefined;
1873
+ if (!round?.active || round === activeRound || pendingFoldRounds.has(round)) continue;
1874
+ endRound(round, true);
1875
+ }
1876
+ };
1877
+
1010
1878
  patch.assistantInstalled = function (this: any, message: any, isStreaming?: boolean) {
1011
1879
  const self = this as any;
1880
+ // 同 compact-thinking:isStreaming 丢失 → mermaid 流式误渲染来回闪。
1012
1881
  if (isStreaming !== undefined) self.isStreaming = isStreaming;
1013
1882
  if (self[ASSISTANT_REENTRY_KEY] === patch) {
1014
1883
  return patch.assistantNative.call(this, message, isStreaming);
@@ -1034,27 +1903,11 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1034
1903
  if (hasText && (!round || round.anchor !== this)) {
1035
1904
  if (round) round.messages.delete(this);
1036
1905
  if (activeRound) finishRound(activeRound);
1037
- round = {
1038
- anchor: this,
1039
- messages: new Map(),
1040
- detachedMessages: [],
1041
- active: true,
1042
- suppressedToolIds: new Set(),
1043
- startedAt: Date.now(),
1044
- };
1906
+ round = createRound(this, message);
1045
1907
  roundByComponent.set(this, round);
1046
- activateRound(round);
1047
1908
  } else if (!round) {
1048
- round = activeRound ?? {
1049
- anchor: this,
1050
- messages: new Map(),
1051
- detachedMessages: [],
1052
- active: true,
1053
- suppressedToolIds: new Set(),
1054
- startedAt: Date.now(),
1055
- };
1909
+ round = activeRound ?? createRound(this, message);
1056
1910
  roundByComponent.set(this, round);
1057
- if (!activeRound) activateRound(round);
1058
1911
  }
1059
1912
  round.messages.set(this, message);
1060
1913
  renderRound(round);
@@ -1074,7 +1927,8 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1074
1927
  return renderAssistantWithoutThinking(this, message);
1075
1928
  }
1076
1929
  if (round) {
1077
- endRound(round);
1930
+ // 围观态下 anchor 还挂着槽位卡,必须重绘才能收回。
1931
+ endRound(round, true);
1078
1932
  roundByComponent.delete(this);
1079
1933
  return passThroughAssistant(this, message);
1080
1934
  }
@@ -1086,16 +1940,8 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1086
1940
  if (hasThinking) {
1087
1941
  let round = roundByComponent.get(this);
1088
1942
  if (!round) {
1089
- round = activeRound ?? {
1090
- anchor: this,
1091
- messages: new Map(),
1092
- detachedMessages: [],
1093
- active: true,
1094
- suppressedToolIds: new Set(),
1095
- startedAt: Date.now(),
1096
- };
1943
+ round = activeRound ?? createRound(this, message);
1097
1944
  roundByComponent.set(this, round);
1098
- if (!activeRound) activateRound(round);
1099
1945
  }
1100
1946
  round.messages.set(this, message);
1101
1947
  renderRound(round);
@@ -1123,12 +1969,13 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1123
1969
  if (EDIT_WRITE_TOOLS.has(name)) {
1124
1970
  if (this.executionStarted && (!this.result || this.isPartial === true))
1125
1971
  scheduleAnimation(this);
1126
- const lines = compactEditWriteLine(this, width, deps.writeMetadata);
1127
- if (this.expanded !== true) return lines;
1128
- return compactEditWriteExpandedLines(this, width, deps.writeMetadata);
1972
+ return compactEditWriteLines(this, width, deps.writeMetadata);
1129
1973
  }
1130
1974
  // Agent/Task 等同普通工具:折叠不外置(live 面板走独立 widget)。
1131
1975
  if (expandedRoundToolIds.has(String(this.toolCallId ?? ""))) return [];
1976
+ // live 围观态:本回合工具交由槽位卡内联渲染,外层不再单独成行,
1977
+ // 卡片因此不会随工具完成反复挂载/卸载。
1978
+ if (liveSlotToolIds.has(String(this.toolCallId ?? ""))) return [];
1132
1979
  // 普通工具折叠时不显示独立行(摘要行已统计),独立展开走原 renderer。
1133
1980
  if (this.expanded === true) {
1134
1981
  return patch.toolOriginalRender.call(this, width);
@@ -1137,16 +1984,16 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1137
1984
  };
1138
1985
 
1139
1986
  patch.toolInstalledUpdateDisplay = function (this: any) {
1140
- if (
1141
- patch.active &&
1142
- config.mode === "compact" &&
1143
- expandedRoundToolIds.has(String(this.toolCallId ?? ""))
1144
- ) {
1145
- this.expanded = false;
1987
+ const id = String(this.toolCallId ?? "");
1988
+ if (patch.active && config.mode === "compact" && expandedRoundToolIds.has(id)) {
1989
+ // 用户点开的保留展开;其余(含全局展开被收回的)继续强制折叠。
1990
+ if (this.expanded !== true) explicitRoundToolIds.delete(id);
1991
+ else if (!explicitRoundToolIds.has(id)) this.expanded = false;
1146
1992
  }
1147
1993
  const result = patch.toolOriginalUpdateDisplay.call(this);
1148
1994
  if (!patch.active) return result;
1149
1995
  trackedToolComponents.add(this);
1996
+ if (config.mode === "compact") syncTailForTool(id);
1150
1997
  return result;
1151
1998
  };
1152
1999
 
@@ -1170,6 +2017,9 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1170
2017
  patch.dispose = () => {
1171
2018
  if (!patch.active) return;
1172
2019
  patch.active = false;
2020
+ if (roundToolExpansion?.markUserExpanded === markRoundToolUserExpanded) {
2021
+ roundToolExpansion = undefined;
2022
+ }
1173
2023
  if (assistantPrototype.updateContent === patch.assistantInstalled) {
1174
2024
  assistantPrototype.updateContent = patch.assistantOriginal;
1175
2025
  }
@@ -1179,18 +2029,19 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1179
2029
  if (toolPrototype.updateDisplay === patch.toolInstalledUpdateDisplay) {
1180
2030
  toolPrototype.updateDisplay = patch.toolOriginalUpdateDisplay;
1181
2031
  }
1182
- if (host[COMPACT_MODE_PATCH_KEY] === patch) delete host[COMPACT_MODE_PATCH_KEY];
2032
+ patchRegistry.dispose(COMPACT_MODE_PATCH_KEY, patch);
1183
2033
  for (const component of trackedAssistantComponents) detachAssistantExpansion(component);
1184
2034
  trackedAssistantComponents.clear();
1185
2035
  trackedToolComponents.clear();
1186
2036
  hoveredAssistantComponent = undefined;
1187
2037
  resetRounds();
2038
+ compactRunStatusGetter = undefined;
1188
2039
  };
1189
2040
 
1190
2041
  assistantPrototype.updateContent = patch.assistantInstalled;
1191
2042
  toolPrototype.render = patch.toolInstalledRender;
1192
2043
  toolPrototype.updateDisplay = patch.toolInstalledUpdateDisplay;
1193
- host[COMPACT_MODE_PATCH_KEY] = patch;
2044
+ patchRegistry.install(COMPACT_MODE_PATCH_KEY, patch);
1194
2045
 
1195
2046
  const syncGlobalExpanded = (ctx: any): void => {
1196
2047
  let globalExpanded = false;