pi-cc-extensions 1.0.6 → 1.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.en.md CHANGED
@@ -28,7 +28,7 @@ Run `/reload` after installation.
28
28
  | Feature | Description | Entry point |
29
29
  | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- |
30
30
  | Claude Code UI | Tool summaries, expand/collapse, rich edit/write diffs, and `on` / `compact` / `off` modes | `/ccstyle` |
31
- | Markdown enhancements | Mermaid diagrams, admonitions, URL linking, and more | Automatic |
31
+ | Markdown enhancements | Mermaid dialects and circled-number conversion | Automatic; turn off with `enableMarkdownEnhance: false` |
32
32
  | Fullscreen mode | Tool card/group expand and collapse on click, previews, hover highlight, and a back-to-bottom button | `TUIMODE=fullscreen` or `--tui-mode fullscreen` |
33
33
  | Settings panel | `Style / Features / UI / Diff / Thinking / Footer` tabs | `/ccstyle` |
34
34
  | Context inspection | Usage breakdown and previews for the system prompt, memory, skills, tools definition, and messages | `/context` |
@@ -55,6 +55,7 @@ Renderer snapshots for `on` / `compact` modes: [default](./docs/tool-render-exam
55
55
  "enableAgentSummary": true, // per-turn tool summary
56
56
  "enableWorkingMessage": true, // Working... bottom token/elapsed
57
57
  "enableAliases": true, // /clear, /exit aliases
58
+ "enableMarkdownEnhance": true, // markdown pass: Mermaid dialects and circled-number conversion; false hands it to other extensions
58
59
  "enableEditorPrefix": true, // editor ❯ prefix
59
60
 
60
61
  // ui
@@ -97,6 +98,8 @@ Renderer snapshots for `on` / `compact` modes: [default](./docs/tool-render-exam
97
98
 
98
99
  > [!TIP]
99
100
  > **Fullscreen**: click `click to show more` to expand tool cards, thinking, Skill, and compact summaries. An expanded diff always shows every line; when expanded Input/Output exceeds the line cap, the footer `… +N more lines · click to show more` opens a full preview. Click to collapse.
101
+ >
102
+ > Both `scrollButtonMode` values show the same `Back to bottom` / `N new message(s)` text: `dock` draws it in a dedicated row, `native` in Pi’s floating overlay.
100
103
 
101
104
  > [!NOTE]
102
105
  > **Mermaid rendering**: set `markdown.mermaid` to `final` via `~/.pi/agent/settings.json` or the Mermaid diagrams option in `/settings`. Default `streaming` redraws per frame; `final` renders once at completion.
@@ -115,10 +118,11 @@ pi -e . # one run, does not edit settings.json
115
118
 
116
119
  ## Recommended companions
117
120
 
118
- | Extension | Purpose |
119
- | ------------------------ | ----------------------------------------------------------- |
120
- | `npm:pi-web-access` | Web search, URL fetching, GitHub cloning, PDF/video parsing |
121
- | `npm:@narumitw/pi-usage` | Current-account usage for Codex / Copilot / OpenRouter |
121
+ | Extension | Purpose |
122
+ | ---------------------------------------- | ----------------------------------------------------------- |
123
+ | `npm:pi-web-access` | Web search, URL fetching, GitHub cloning, PDF/video parsing |
124
+ | `npm:@narumitw/pi-usage` | Current-account usage for Codex / Copilot / OpenRouter |
125
+ | `npm:@juicesharp/rpiv-ask-user-question` | Multiple-choice questionnaire the model can ask you |
122
126
 
123
127
  ## Credits
124
128
 
package/README.md CHANGED
@@ -28,7 +28,7 @@ pi install git:github.com/minuque/pi-cc-extensions
28
28
  | 功能 | 说明 | 入口 |
29
29
  | --------------------- | ------------------------------------------------------------------------------- | ----------------------------------------------- |
30
30
  | Claude Code UI | 工具摘要、折叠展开、rich edit/write diff,以及`on` / `compact` / `off` 三种模式 | `/ccstyle` |
31
- | Markdown 增强 | Mermaid 图、提示框、URL 链接化等 | 自动生效 |
31
+ | Markdown 增强 | Mermaid 方言渲染、圈数字转半角括号 | 自动生效;`enableMarkdownEnhance: false` 关闭 |
32
32
  | Fullscreen mode | 工具卡/group 单击展开与收起、预览、hover 高亮、回到底部按钮 | `TUIMODE=fullscreen` 或 `--tui-mode fullscreen` |
33
33
  | 配置面板 | `Style / Features / UI / Diff / Thinking / Footer` 六页签 | `/ccstyle` |
34
34
  | 上下文检查 | 查看上下文占用,并预览 System prompt、Memory、Skills、Tools definition 和消息内容 | `/context` |
@@ -55,6 +55,7 @@ pi install git:github.com/minuque/pi-cc-extensions
55
55
  "enableAgentSummary": true, // 每回合工具摘要
56
56
  "enableWorkingMessage": true, // Working... 底部 token/耗时
57
57
  "enableAliases": true, // /clear、/exit 别名
58
+ "enableMarkdownEnhance": true, // markdown 转换:Mermaid 方言渲染、圈数字转半角括号;false 让位给其他扩展
58
59
  "enableEditorPrefix": true, // 输入框 ❯ 前缀
59
60
 
60
61
  // ui
@@ -97,6 +98,8 @@ pi install git:github.com/minuque/pi-cc-extensions
97
98
 
98
99
  > [!TIP]
99
100
  > **全屏模式**:单击 `click to show more` 展开工具卡、思考、Skill 和 compact 摘要;工具卡展开态 diff 全量显示,Input/Output 超行时末行 `… +N more lines · click to show more` 打开全量预览,单击收起。
101
+ >
102
+ > `scrollButtonMode` 两种模式文案相同(`Back to bottom` / `N new message(s)`):`dock` 画在独占行,`native` 画在 Pi 原生浮层。
100
103
 
101
104
  > [!NOTE]
102
105
  > **Mermaid 渲染**:建议把 `markdown.mermaid` 设为 `final`(`~/.pi/agent/settings.json` 或 `/settings` 面板的 Mermaid diagrams 选项)。默认 `streaming` 逐帧重绘,`final` 渲染最终版更稳定。
@@ -119,6 +122,7 @@ pi -e . # 只跑一次,不改 settings.json
119
122
  | ---------------------------------------- | ------------------------------------------------ |
120
123
  | `npm:pi-web-access` | 网页搜索、URL 抓取、GitHub 克隆、PDF/视频解析 |
121
124
  | `npm:@narumitw/pi-usage` | 查看当前账号用量(Codex / Copilot / OpenRouter) |
125
+ | `npm:@juicesharp/rpiv-ask-user-question` | 模型拿不准时向你提问的选择题面板 |
122
126
 
123
127
  ## 致谢
124
128
 
@@ -66,7 +66,7 @@ export type Config = {
66
66
  showStartupHeader: boolean;
67
67
  /** fullscreen:userMessage 正文首行滚出视口上沿后钉住,直到下一条指令接手。 */
68
68
  stickyUserMessage: boolean;
69
- /** fullscreen 回到底部按钮:扩展 dock 行,或 Pi 原生浮层。 */
69
+ /** fullscreen 回到底部按钮:扩展 dock 行,或 Pi 原生浮层;两者文案相同。 */
70
70
  scrollButtonMode: ScrollButtonMode;
71
71
  enableSessionReference: boolean;
72
72
  enableSubagentAutocomplete: boolean;
@@ -74,6 +74,8 @@ export type Config = {
74
74
  enableAgentSummary: boolean;
75
75
  enableWorkingMessage: boolean;
76
76
  enableAliases: boolean;
77
+ /** markdown 转换:Mermaid 方言渲染、圈数字转半角括号。关掉让位给其他扩展。 */
78
+ enableMarkdownEnhance: boolean;
77
79
  /** 输入框 `❯ ` 前缀(占左侧 2 列,颜色随 thinking/bash mode 联动)。 */
78
80
  enableEditorPrefix: boolean;
79
81
  enableCustomFooter: boolean;
@@ -157,6 +159,7 @@ export const DEFAULT_CONFIG: Config = {
157
159
  enableAgentSummary: true,
158
160
  enableWorkingMessage: true,
159
161
  enableAliases: true,
162
+ enableMarkdownEnhance: true,
160
163
  enableEditorPrefix: true,
161
164
  enableCustomFooter: true,
162
165
  footerTokenRate: true,
@@ -290,6 +293,7 @@ export function normalizeConfig(input: unknown): Config {
290
293
  enableAgentSummary: source.enableAgentSummary !== false,
291
294
  enableWorkingMessage: source.enableWorkingMessage !== false,
292
295
  enableAliases: source.enableAliases !== false,
296
+ enableMarkdownEnhance: source.enableMarkdownEnhance !== false,
293
297
  enableEditorPrefix: (source.enableEditorPrefix ?? source.enableInputPrompt) !== false, // 兼容旧配置键
294
298
  enableCustomFooter: source.enableCustomFooter !== false,
295
299
  footerTokenRate: source.footerTokenRate !== false,
@@ -361,6 +365,7 @@ export function formatConfigStatus(source: Config = config): string {
361
365
  `agentSummary=${source.enableAgentSummary ? "on" : "off"}`,
362
366
  `workingMsg=${source.enableWorkingMessage ? "on" : "off"}`,
363
367
  `aliases=${source.enableAliases ? "on" : "off"}`,
368
+ `markdownEnhance=${source.enableMarkdownEnhance ? "on" : "off"}`,
364
369
  `editorPrefix=${source.enableEditorPrefix ? "on" : "off"}`,
365
370
  `footer=${source.enableCustomFooter ? "on" : "off"}`,
366
371
  `footerTokenRate=${source.footerTokenRate ? "on" : "off"}`,
@@ -604,11 +604,11 @@ export async function showCcstylePanel(
604
604
  };
605
605
  const scrollButtonSetting = {
606
606
  id: "scrollButtonMode",
607
- label: "Scroll to bottom",
607
+ label: "Scroll button mode",
608
608
  description:
609
609
  config.scrollButtonMode === "dock"
610
610
  ? "Fullscreen: show the extension's dedicated Back to bottom row."
611
- : "Fullscreen: use Pi's native floating Jump to latest overlay.",
611
+ : "Fullscreen: show the same Back to bottom text in Pi's native floating overlay.",
612
612
  currentValue: config.scrollButtonMode,
613
613
  values: [...SCROLL_BUTTON_MODES],
614
614
  };
@@ -656,6 +656,13 @@ export async function showCcstylePanel(
656
656
  "Aliases disabled.",
657
657
  config.enableAliases,
658
658
  );
659
+ const markdownEnhanceToggle = featureToggleSetting(
660
+ "enableMarkdownEnhance",
661
+ "Markdown enhance",
662
+ "Mermaid dialects and circled-number conversion. Next restart applies.",
663
+ "Markdown enhancements off; another extension can own the markdown pass.",
664
+ config.enableMarkdownEnhance,
665
+ );
659
666
  const footerCurrencySetting = {
660
667
  id: "footerCurrency",
661
668
  label: "Cost currency",
@@ -798,6 +805,7 @@ export async function showCcstylePanel(
798
805
  enableAgentSummary: agentSummaryToggle,
799
806
  enableWorkingMessage: workingMessageToggle,
800
807
  enableAliases: aliasesToggle,
808
+ enableMarkdownEnhance: markdownEnhanceToggle,
801
809
  };
802
810
 
803
811
  const onSettingChange = (id: string, value: string) => {
@@ -1012,7 +1020,7 @@ export async function showCcstylePanel(
1012
1020
  scrollButtonSetting.description =
1013
1021
  config.scrollButtonMode === "dock"
1014
1022
  ? "Fullscreen: show the extension's dedicated Back to bottom row."
1015
- : "Fullscreen: use Pi's native floating Jump to latest overlay.";
1023
+ : "Fullscreen: show the same Back to bottom text in Pi's native floating overlay.";
1016
1024
  break;
1017
1025
  }
1018
1026
  case "showStartupHeader": {
@@ -1049,6 +1057,7 @@ export async function showCcstylePanel(
1049
1057
  agentSummaryToggle.setting,
1050
1058
  workingMessageToggle.setting,
1051
1059
  aliasesToggle.setting,
1060
+ markdownEnhanceToggle.setting,
1052
1061
  ],
1053
1062
  },
1054
1063
  {
@@ -40,10 +40,7 @@ const LEGACY_FIXED_FOOTER_KEYS = [
40
40
  FOOTER_GIT_KEY,
41
41
  ] as const;
42
42
 
43
- const BUILTIN_FOOTER_KEYS = new Set<string>([
44
- ...DEFAULT_FOOTER_LINE1_KEYS,
45
- ...DEFAULT_FOOTER_LINE2_KEYS,
46
- ]);
43
+ const BUILTIN_FOOTER_KEYS = new Set<string>(LEGACY_FIXED_FOOTER_KEYS);
47
44
 
48
45
  export function isBuiltinFooterKey(key: string): boolean {
49
46
  return BUILTIN_FOOTER_KEYS.has(key);
@@ -3,8 +3,10 @@
3
3
  *
4
4
  * average:一次用户指令的墙钟平均(工具等待计入分母),显示 `avg`。
5
5
  * live:最近 1 秒的瞬时速率,生成期暂停时钟,显示 `live`;指令结束定格为整段平均。
6
- * token 与 working-message 同源:优先 provider `usage.output`,否则按 delta 增量估算
7
- * (CJK 1 token,其余 chars/4)。provider 值回填到本段消息窗口。
6
+ * token 与 working-message 同源:变大的 provider `usage.output` 覆盖估算,否则按 delta 累加
7
+ * (CJK 1 token,其余 chars/4)。没有变大的 usage 不当作累计值:Anthropic 的 message_start
8
+ * 会给出一个很小的 output_tokens,并在每个 partial 上重复,直到最后的 message_delta。
9
+ * 消息结束时用 message_end 上的 usage 定格本段总量。
8
10
  * Pi 的 turn 是单次 assistant 消息,工具调用结束会开新 turn。token 跨 turn 累加,
9
11
  * 计时与 TTFT 直到下一条用户指令才重置。
10
12
  * TTFT:用户指令开始到第一个内容块(text/thinking/toolcall)开始的毫秒数,
@@ -120,15 +122,29 @@ export function sampleTokenDelta(delta: string, now = Date.now()): void {
120
122
  }
121
123
 
122
124
  /**
123
- * 记录本段 provider `usage.output`。usage 按锚点摊回窗口:
125
+ * 记录本段 provider `usage.output`。变大的 usage 按锚点摊回窗口:
124
126
  * 首次报告从消息起点铺到现在,之后每次把增量摊在上次报告之后。
127
+ * 没有变大的报告直接忽略,避免 message_start 的占位值反复覆盖 delta 累计。
128
+ * `final` 用于消息结束:以本次 output 为本段总量,估算偏高偏低都按它修正。
129
+ * 结束值含未逐字流出的思考 token,它们不属于最近一秒,所以窗口只整体平移,不回填。
125
130
  */
126
- export function recordProviderUsage(output: number, now = Date.now()): void {
131
+ export function recordProviderUsage(output: number, now = Date.now(), final = false): void {
127
132
  if (!runStartTime || output <= 0) return;
133
+ if (final) {
134
+ const shift = output - currentTokens;
135
+ for (const sample of samples) sample.cum += shift;
136
+ countedUsage = output;
137
+ currentTokens = output;
138
+ usageAnchorTime = now;
139
+ usageAnchorTokens = output;
140
+ updateRates(now);
141
+ return;
142
+ }
143
+ if (output <= countedUsage) return;
128
144
  const firstReport = countedUsage === 0;
129
145
  const jump = output - countedUsage;
130
146
  countedUsage = output;
131
- // provider 是权威计数;首次报告以它为准,之后增量接管。
147
+ // 变大的 provider 计数是权威值,覆盖本段估算。
132
148
  currentTokens = output;
133
149
  if (firstReport) {
134
150
  samples = samples.filter((sample) => sample.t < usageAnchorTime);
@@ -49,9 +49,9 @@ export default function (pi: ExtensionAPI): void {
49
49
  return totalTokens();
50
50
  }
51
51
 
52
- function updateProviderUsage(message: StreamMessage): void {
52
+ function updateProviderUsage(message: StreamMessage, final = false): void {
53
53
  const output = outputUsage(message);
54
- if (output > 0) recordProviderUsage(output);
54
+ if (output > 0) recordProviderUsage(output, Date.now(), final);
55
55
  }
56
56
 
57
57
  function buildWorkingMessage(): string {
@@ -171,13 +171,20 @@ export default function (pi: ExtensionAPI): void {
171
171
  // 分段锚点只在 turn_start 重置。这里再重置会把累计 usage.output 当成新的首报。
172
172
  markFirstToken();
173
173
  updateProviderUsage(evt.partial);
174
- } else if (evt.type === "thinking_delta" || evt.type === "text_delta") {
174
+ } else if (
175
+ evt.type === "thinking_delta" ||
176
+ evt.type === "text_delta" ||
177
+ evt.type === "toolcall_delta"
178
+ ) {
179
+ // 工具参数走 toolcall_delta;不计入的话,写长文件这类调用期间 token 一直不涨。
175
180
  if (typeof evt.delta === "string") sampleTokenDelta(evt.delta);
176
181
  updateProviderUsage(evt.partial);
177
182
  } else if (evt.type === "text_end" || evt.type === "thinking_end") {
178
183
  updateProviderUsage(evt.partial);
179
184
  } else if (evt.type === "done") {
180
- updateProviderUsage(evt.message);
185
+ // Pi 目前不会把 done/error 转成 message_update,定格在下面的 message_end 里做。
186
+ // 这两个分支只在宿主直接转发 done/error 时生效;同一个值重复定格不改变结果。
187
+ updateProviderUsage(evt.message, true);
181
188
  } else if (evt.type === "error") {
182
189
  updateProviderUsage(evt.error);
183
190
  } else {
@@ -188,6 +195,18 @@ export default function (pi: ExtensionAPI): void {
188
195
  scheduleRefreshTick();
189
196
  });
190
197
 
198
+ // message_delta 的累计 usage 只出现在 message_end 上,含未逐字流出的思考 token。
199
+ // message_end 在工具执行和 turn_end 之前,先于 carryTokenRate 结转。
200
+ pi.on("message_end", async (event, ctx) => {
201
+ const message = event?.message;
202
+ if (message?.role !== "assistant") return;
203
+ activeCtx = ctx;
204
+ // 出错/中断时 usage 可能还是 message_start 的占位值,只接受变大的值。
205
+ const failed = message.stopReason === "error" || message.stopReason === "aborted";
206
+ updateProviderUsage(message, !failed);
207
+ syncWorkingMessage();
208
+ });
209
+
191
210
  pi.on("tool_execution_start", async () => {
192
211
  pauseTokenRate();
193
212
  });
@@ -35,7 +35,8 @@ export default function (pi: ExtensionAPI): void {
35
35
  });
36
36
 
37
37
  // render stack:thinking controller 直接交给 style 作 query
38
- markdownEnhance(pi);
38
+ // markdownEnhance 单独开关:其他扩展也注册 transformer 时会互相改写输出。
39
+ if (config.enableMarkdownEnhance) markdownEnhance(pi);
39
40
  claudeCodeStyle(pi, undefined, installCompactThinking(pi, getCompactThinkingConfig()));
40
41
 
41
42
  // features
@@ -7,9 +7,11 @@
7
7
  * 与 `editDiffCollapsedLines` / `writeDiffCollapsedLines` / `expandedPreviewMaxLines`。
8
8
  * Agent/Task 族:调用只进摘要计数,tool 卡始终折叠(避免 pending→完成高度闪动)。
9
9
  * edit/write 与 ask_user_question 留在外层:不进摘要计数,折叠态也单独成行。
10
+ * 外层行一旦在消息里出现,当前回合就地完结(不计静默期),后续消息另起回合:
11
+ * 摘要行因此只折叠连续的一段工具调用,展开/收起不再跨外层行搬位置。
10
12
  * 用户消息是回合的硬边界:steer 消息插进回合中间、或上一回合没正文就收尾时,回合
11
- * 就地收口、后续消息另起一回合,区间里的用户消息不会被当成外来行挪走(摘要行、
12
- * 展开面板因此留在自己回合的位置上)。
13
+ * 就地收口、后续消息另起一回合。custom 通知/summary 类组件既不是成员也不算外来行,
14
+ * 原地留在两段之间,不打断统计也不被挪位。
13
15
  * 底部 Agents/Tasks 面板由 pi-subagents/pi-tasks 独立 widget 负责,不经 tool 卡外置。
14
16
  *
15
17
  * live 围观态(回合进行中 / 收尾静默期):正文跟折叠态同形;摘要行与单一槽位卡
@@ -30,6 +32,7 @@
30
32
  */
31
33
  import {
32
34
  AssistantMessageComponent,
35
+ CustomMessageComponent,
33
36
  SkillInvocationMessageComponent,
34
37
  ToolExecutionComponent,
35
38
  UserMessageComponent,
@@ -385,6 +388,9 @@ function layoutExpandedToolCard(
385
388
  ): { lines: string[]; hits: Array<{ child: any; start: number; end: number }> } {
386
389
  const slot = aggregateToolCardBgSlot(children.map((child) => child?.__ccTool).filter(Boolean));
387
390
  const toolBgAnsi = darkenBgAnsi(theme, slot);
391
+ // 展开面板外卡底色随组内工具状态聚合(与 default 模式分组卡同口径):
392
+ // 有一个失败用失败底色、没有失败但有进行中用进行中底色、全成功用成功底色。
393
+ const outerSlot = slot;
388
394
  /** live 每行前面的标记宽度(首行 `↳ `,其余对齐空格)。 */
389
395
  const markerWidth = live ? 2 : 0;
390
396
  const innerWidth = Math.max(0, width - 2 - markerWidth);
@@ -399,7 +405,7 @@ function layoutExpandedToolCard(
399
405
  // 面板 pad 1 是唯一左边距:剥掉子行 collapsed 态自带的 1 格 lead,
400
406
  // 否则标题行偏到 2、↳ 行偏到 4,左右不再对称。
401
407
  const line = raw.replace(/^((?:\x1b\[[0-?]*[ -/]*[@-~])*) /, "$1");
402
- lines.push(paddedBackgroundRow(theme, slot, line, width));
408
+ lines.push(paddedBackgroundRow(theme, outerSlot, line, width));
403
409
  return;
404
410
  }
405
411
  // 槽位里不重复展开入口(见 SLOT_HINT_PATTERNS)。
@@ -420,14 +426,13 @@ function layoutExpandedToolCard(
420
426
  };
421
427
  const pushBlank = () => {
422
428
  if (live || lastBlank) return;
423
- lines.push(paddedBackgroundRow(theme, slot, "", width));
429
+ lines.push(paddedBackgroundRow(theme, outerSlot, "", width));
424
430
  lastBlank = true;
425
431
  };
426
432
  for (let childIndex = 0; childIndex < children.length; childIndex++) {
427
433
  const child = children[childIndex];
428
434
  const painted = paints?.[childIndex];
429
435
  const childLines = Array.isArray(painted) ? painted : child.render(innerWidth);
430
- // live 不留空行、也不叠深色内卡:槽位没有边框。
431
436
  const innerCard = !live && isThinkingPreview(child) && child.expanded === true;
432
437
  let first = -1;
433
438
  let last = -1;
@@ -440,7 +445,7 @@ function layoutExpandedToolCard(
440
445
  if (first < 0) {
441
446
  if (live) continue;
442
447
  for (const line of childLines) {
443
- lines.push(paddedBackgroundRow(theme, slot, line, width));
448
+ lines.push(paddedBackgroundRow(theme, outerSlot, line, width));
444
449
  lastBlank = !hasVisibleText(line);
445
450
  }
446
451
  continue;
@@ -502,6 +507,7 @@ function compactRoundCard(
502
507
  | undefined;
503
508
  const layout = (width: number) => {
504
509
  const theme = themeOf();
510
+ // 与 default 模式分组卡同口径:外卡底色随组内工具聚合,子卡状态变了要重绘。
505
511
  const bgSlot = aggregateToolCardBgSlot(
506
512
  children.map((child) => child?.__ccTool).filter(Boolean),
507
513
  );
@@ -1320,6 +1326,16 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1320
1326
  }, FOLD_SETTLE_MS);
1321
1327
  };
1322
1328
 
1329
+ /**
1330
+ * 外层行(edit/write/ask)切出的段落封口:跳过静默期直接定稿。
1331
+ * 静默期是为"正文 token 先于 toolCall 到达"的双重转场准备的;外层边界
1332
+ * 出现时摘要段内容已完整,立即收拢让 Ran for 当场落位,槽位卡一并撤走。
1333
+ */
1334
+ const sealRound = (round: CompactRound, endTimestamp?: number): void => {
1335
+ finalizeRound(round, endTimestamp);
1336
+ renderRound(round);
1337
+ };
1338
+
1323
1339
  /** 新回合接替时立即收掉还在静默期的回合,把两次转场并成一次。 */
1324
1340
  const flushSettle = (): void => {
1325
1341
  for (const round of [...pendingFoldRounds]) {
@@ -1460,6 +1476,13 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1460
1476
  const isUserMessageComponent = (value: any): boolean =>
1461
1477
  value instanceof UserMessageComponent || value instanceof SkillInvocationMessageComponent;
1462
1478
 
1479
+ /**
1480
+ * 回合内的透明行:custom 通知(系统提示、extension 注入)既不是成员也不算外来行。
1481
+ * 它落在两个成员之间时原地保留——不打断统计、不被搬到摘要行下方。
1482
+ * 硬边界仍只有用户消息;summary/compaction 块由各自的 message 边界收口。
1483
+ */
1484
+ const isTransparentComponent = (value: any): boolean => value instanceof CustomMessageComponent;
1485
+
1463
1486
  /**
1464
1487
  * 新组件与回合已有成员之间是否隔着一条用户消息。隔着就说明这是下一回合的内容:
1465
1488
  * steer 消息插进了回合中间,或上一回合没有正文就收尾、用户又问了新问题。
@@ -1599,7 +1622,14 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1599
1622
  const intruders: any[] = [];
1600
1623
  for (let i = last - 1; i > first; i--) {
1601
1624
  // 用户消息永远留在原地:挪走会把别人的消息挪到用户消息下面去。
1602
- if (isMember(children[i]) || isUserMessageComponent(children[i])) continue;
1625
+ // custom 通知同理:它是回合内的透明行,不被统计也不挪位。
1626
+ if (
1627
+ isMember(children[i]) ||
1628
+ isUserMessageComponent(children[i]) ||
1629
+ isTransparentComponent(children[i])
1630
+ ) {
1631
+ continue;
1632
+ }
1603
1633
  intruders.unshift(children.splice(i, 1)[0]);
1604
1634
  }
1605
1635
  if (lastMember) last = children.indexOf(lastMember);
@@ -1759,22 +1789,24 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1759
1789
 
1760
1790
  const thinkingKids: any[] = [];
1761
1791
  for (const [component, message] of round.messages) {
1762
- if (component !== previewComponent) {
1792
+ if (component === previewComponent) {
1793
+ passThroughAssistant(component, message);
1794
+ const kids = Array.isArray(component.contentContainer?.children)
1795
+ ? [...component.contentContainer.children]
1796
+ : [];
1763
1797
  component.contentContainer?.clear?.();
1764
- continue;
1765
- }
1766
- passThroughAssistant(component, message);
1767
- const kids = Array.isArray(component.contentContainer?.children)
1768
- ? [...component.contentContainer.children]
1769
- : [];
1770
- component.contentContainer?.clear?.();
1771
- // 只摘思考预览块进槽位;已收尾的思考回收进摘要行。
1772
- for (const kid of kids) {
1773
- if (typeof kid?.setHintHovered === "function") thinkingKids.push(kid);
1798
+ // 只摘思考预览块进槽位;已收尾的思考回收进摘要行。
1799
+ for (const kid of kids) {
1800
+ if (typeof kid?.setHintHovered === "function") thinkingKids.push(kid);
1801
+ }
1774
1802
  }
1803
+ // text 在各消息自己的位置渲染,不随回合折叠态;非 anchor 成员的
1804
+ // 中止/错误状态仍汇总在 anchor 外层,不在成员处重复成行。
1805
+ renderAssistantWithoutThinking(
1806
+ component,
1807
+ component === round.anchor ? message : { ...message, stopReason: "stop" },
1808
+ );
1775
1809
  }
1776
- // 正文照折叠态渲染(不含 thinking):live 与收尾后同形,收尾只剩槽位卡消失。
1777
- renderAssistantWithoutThinking(round.anchor, round.messages.get(round.anchor));
1778
1810
 
1779
1811
  const toolsById = new Map<string, any>();
1780
1812
  for (const tool of trackedToolComponents) {
@@ -1900,10 +1932,14 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1900
1932
  // 否则消息内容里的 thinking([thinking, text, toolCall])会盖到文本上面。
1901
1933
  const anchorChildren: any[] = [];
1902
1934
  let cardItems: Array<{ child?: any; tool?: any }> = [];
1903
- const flushPanel = () => {
1935
+ // 纯 spacer 串拆不开面板,还给它所在消息的组件(通常是上一个
1936
+ // text 块的前导空行)。
1937
+ const flushPanel = (spacerOwner: any = round.anchor) => {
1904
1938
  if (cardItems.length === 0) return;
1905
1939
  if (cardItems.every((item) => !item.tool && isSpacerComponent(item.child))) {
1906
- for (const item of cardItems) anchorChildren.push(item.child);
1940
+ for (const item of cardItems) {
1941
+ spacerOwner?.contentContainer?.addChild?.(item.child);
1942
+ }
1907
1943
  } else {
1908
1944
  // 面板上沿留 1 行卡外空行:面板底色与 userMessage 同色,
1909
1945
  // 贴在一起会看成一个块;卡内顶部再加 1 行 padding 保持上下对称。
@@ -1920,13 +1956,15 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1920
1956
  }
1921
1957
  cardItems = [];
1922
1958
  };
1923
- const takeChild = (child: any, outside: boolean) => {
1959
+ // outside 的块(text 及其前导空行)留在各自组件上原位渲染:展开态下
1960
+ // 助手文本不跨消息搬家;它不切断面板——cardItems 跨消息延续,回合的
1961
+ // thinking/工具统一汇成末尾的一张共享面板,混排 text 不再把卡撕成几段。
1962
+ const takeChild = (component: any, child: any, outside: boolean) => {
1924
1963
  if (!outside) {
1925
1964
  cardItems.push({ child });
1926
1965
  return;
1927
1966
  }
1928
- flushPanel();
1929
- anchorChildren.push(child);
1967
+ component.contentContainer?.addChild?.(child);
1930
1968
  };
1931
1969
  const placedToolIds = new Set<string>();
1932
1970
  const placeTool = (id: string, fallbackName?: string) => {
@@ -1972,11 +2010,11 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1972
2010
  }
1973
2011
  }
1974
2012
  while (cursor.i < kids.length) local.push({ outside: false, child: kids[cursor.i++] });
1975
- for (const entry of local) if (entry.outside) takeChild(entry.child, true);
2013
+ for (const entry of local) if (entry.outside) takeChild(component, entry.child, true);
1976
2014
  for (const entry of local) {
1977
2015
  if (entry.outside) continue;
1978
2016
  if (entry.toolId) placeTool(entry.toolId, entry.toolName);
1979
- else takeChild(entry.child, false);
2017
+ else takeChild(component, entry.child, false);
1980
2018
  }
1981
2019
  }
1982
2020
  for (const message of round.detachedMessages) {
@@ -2001,15 +2039,10 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
2001
2039
  !explicitRoundToolIds.has(String(tool.toolCallId ?? "")),
2002
2040
  );
2003
2041
  flushPanel();
2042
+ // anchorChildren 只剩面板件(spacer+panelBoundSpacer 都是 childAtRow 组件):
2043
+ // 面板挂回摘要行原位,text 已在各自组件上。
2004
2044
  if (mounted) {
2005
- // 正文留在各自的 assistant 消息上,面板挂回摘要行原位。
2006
- const outside: any[] = [];
2007
- const panel: any[] = [];
2008
- for (const child of anchorChildren) {
2009
- (typeof child?.childAtRow === "function" ? panel : outside).push(child);
2010
- }
2011
- for (const child of outside) round.anchor.contentContainer.addChild(child);
2012
- if (panel.length > 0) mountRoundTail(round, () => panel);
2045
+ if (anchorChildren.length > 0) mountRoundTail(round, () => anchorChildren);
2013
2046
  else unmountRoundTail(round);
2014
2047
  } else {
2015
2048
  unmountRoundTail(round);
@@ -2041,7 +2074,8 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
2041
2074
  // 折叠时工具行被隐藏:abort/error/length 必须挂在摘要外层。
2042
2075
  appendStopStatus(component, stopStatus);
2043
2076
  } else {
2044
- component.contentContainer?.clear?.();
2077
+ // 成员的 text 同样留在自己组件上;中止状态只在 anchor 外层报一次。
2078
+ renderAssistantWithoutThinking(component, { ...message, stopReason: "stop" });
2045
2079
  }
2046
2080
  }
2047
2081
  };
@@ -2143,6 +2177,15 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
2143
2177
 
2144
2178
  const content = Array.isArray(message?.content) ? message.content : [];
2145
2179
  const hasToolCalls = content.some((item: any) => item?.type === "toolCall");
2180
+ const hasOuterToolCall = content.some(
2181
+ (item: any) => item?.type === "toolCall" && OUTER_TOOLS.has(String(item?.name ?? "")),
2182
+ );
2183
+ const hasInnerToolCall = content.some(
2184
+ (item: any) => item?.type === "toolCall" && !OUTER_TOOLS.has(String(item?.name ?? "")),
2185
+ );
2186
+ const hasThinkingContent = content.some(
2187
+ (item: any) => item?.type === "thinking" && String(item?.thinking ?? "").trim(),
2188
+ );
2146
2189
  const hasText = content.some(
2147
2190
  (item: any) => item?.type === "text" && typeof item.text === "string" && item.text.trim(),
2148
2191
  );
@@ -2150,13 +2193,54 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
2150
2193
 
2151
2194
  if (hasToolCalls) {
2152
2195
  let round = roundByComponent.get(this);
2196
+ const previousMessage = round?.messages.get(this);
2197
+ const previousHadOuter = Array.isArray(previousMessage?.content)
2198
+ ? previousMessage.content.some(
2199
+ (item: any) => item?.type === "toolCall" && OUTER_TOOLS.has(String(item?.name ?? "")),
2200
+ )
2201
+ : false;
2153
2202
  if (!round) {
2154
- // 本条消息是否带 text 不是回合边界:pi 流式协议里同一条
2155
- // assistant 消息会以 text+toolCall、toolCall-only 反复到达。
2156
- // 只在「组件未挂进任何回合」时并入 activeRound;被用户消息
2157
- // 隔开(steer/上回合没正文)时由 joinOrCreateRound 收尾另起。
2158
- round = joinOrCreateRound(this, message);
2159
- roundByComponent.set(this, round);
2203
+ // 外层行打断摘要段:本条消息带外层调用且可并入的活动回合已有内容时,
2204
+ // 旧回合就地完结(不走静默期)。本条消息自己含 inner 调用或思考才另起一段;
2205
+ // 外层独段(edit-only 消息)不建回合——没有可汇总内容,建段只会挂空摘要行。
2206
+ const joinable = joinableActiveRound(this);
2207
+ if (joinable && hasOuterToolCall) {
2208
+ sealRound(joinable, message?.timestamp);
2209
+ if (hasInnerToolCall || hasThinkingContent) {
2210
+ round = createRound(this, message);
2211
+ roundByComponent.set(this, round);
2212
+ round.messages.set(this, message);
2213
+ if (isUsableTimestamp(message.timestamp) && message.timestamp < round.startedAt) {
2214
+ round.startedAt = message.timestamp;
2215
+ }
2216
+ renderRound(round);
2217
+ return undefined;
2218
+ }
2219
+ // 外层独段:text/thinking 照原生渲染,edit 行由 tool render 外置,没有摘要行。
2220
+ return passThroughAssistant(this, message);
2221
+ }
2222
+ if (hasInnerToolCall || hasThinkingContent) {
2223
+ // 本条消息是否带 text 不是回合边界:pi 流式协议里同一条
2224
+ // assistant 消息会以 text+toolCall、toolCall-only 反复到达。
2225
+ // 只在「组件未挂进任何回合」时并入 activeRound;被用户消息
2226
+ // 隔开(steer/上回合没正文)时由 joinOrCreateRound 收尾另起。
2227
+ round = joinOrCreateRound(this, message);
2228
+ roundByComponent.set(this, round);
2229
+ round.messages.set(this, message);
2230
+ if (isUsableTimestamp(message.timestamp) && message.timestamp < round.startedAt) {
2231
+ round.startedAt = message.timestamp;
2232
+ }
2233
+ renderRound(round);
2234
+ return undefined;
2235
+ }
2236
+ // 外层独段、又没有可并入的活动回合:不建回合,text/工具各归其位。
2237
+ if (activeRound) finishRound(activeRound, message?.timestamp);
2238
+ return passThroughAssistant(this, message);
2239
+ }
2240
+ if (hasOuterToolCall && !previousHadOuter && roundLive(round)) {
2241
+ // 消息入段后流式增长才冒出外层调用(先 thinking 后 toolCall):
2242
+ // 就地封口所在段,本条消息按消息级归属留在原段,后续消息另起一段。
2243
+ sealRound(round, message?.timestamp);
2160
2244
  }
2161
2245
  round.messages.set(this, message);
2162
2246
  if (isUsableTimestamp(message.timestamp) && message.timestamp < round.startedAt) {
@@ -41,99 +41,6 @@ function collectFence(lines: string[], i: number): { diagram: string; next: numb
41
41
  return null; // 未闭合:放弃转换,保持原文
42
42
  }
43
43
 
44
- // ============================================================================
45
- // GitHub 风格提示框(admonition)
46
- // ============================================================================
47
-
48
- const ADMONITION = /^>\s*\[\s*!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\s*\]\s*(.*)$/i;
49
- const ADMONITION_STYLE: Record<string, { icon: string; label: string }> = {
50
- NOTE: { icon: "💡", label: "NOTE" },
51
- TIP: { icon: "✅", label: "TIP" },
52
- IMPORTANT: { icon: "❗", label: "IMPORTANT" },
53
- WARNING: { icon: "⚠️", label: "WARNING" },
54
- CAUTION: { icon: "⚠️", label: "CAUTION" },
55
- };
56
-
57
- /**
58
- * 把 > [!TYPE] 及其后续 > 行转成带加粗标签的引用块,视觉上成提示框。
59
- * 多行内容用空格合并为一行。返回 null 表示该行不是提示框。
60
- */
61
- function renderAdmonition(lines: string[], i: number): { output: string[]; next: number } | null {
62
- const m = lines[i].match(ADMONITION);
63
- if (!m) return null;
64
- const style = ADMONITION_STYLE[m[1].toUpperCase()];
65
- const body: string[] = [m[2]];
66
- let j = i + 1;
67
- while (j < lines.length && /^>\s?/.test(lines[j]) && !ADMONITION.test(lines[j])) {
68
- body.push(lines[j].replace(/^>\s?/, ""));
69
- j++;
70
- }
71
- while (body.length > 0 && body[body.length - 1].trim() === "") body.pop();
72
- // 内容合并为一行,作为引用块正文
73
- const content = body.join(" ").trim();
74
- // 保留引用块形态(pi 渲染为左侧竖线),标签加粗
75
- return {
76
- output: [`> **${style.icon} ${style.label}**${content ? ` ${content}` : ""}`, ""],
77
- next: j,
78
- };
79
- }
80
-
81
- // ============================================================================
82
- // 裸 URL 转可点击超链接
83
- // ============================================================================
84
-
85
- const URL_RE = /(?<!<)(?<!\]\()https?:\/\/[^\s<>'"|,。;:!?、」』】()【】《》『』「」]+/g;
86
- const TRIM_URL_RE = /[.,;:!?】」』"'》)}]+$/;
87
-
88
- /**
89
- * 去掉 URL 尾部标点;括号按平衡保留(如 Wikipedia 链接含括号、
90
- * IPv6 地址含 [::1]),尾部不成对的 ] 与 ) 会截掉。
91
- */
92
- function trimUrl(url: string): string {
93
- let t = url.replace(TRIM_URL_RE, "");
94
- for (;;) {
95
- if (t.endsWith(")") && (t.match(/\(/g)?.length ?? 0) < (t.match(/\)/g)?.length ?? 0)) {
96
- t = t.slice(0, -1);
97
- } else if (t.endsWith("]") && (t.match(/\[/g)?.length ?? 0) < (t.match(/\]/g)?.length ?? 0)) {
98
- t = t.slice(0, -1);
99
- } else {
100
- break;
101
- }
102
- }
103
- return t;
104
- }
105
-
106
- /**
107
- * 把代码块和行内代码外的裸 URL 转成 markdown 链接。
108
- * 已有的 [text](url) / ![alt](url) / <url> 自动链接用 lookbehind 排除。
109
- */
110
- function linkifyUrls(markdown: string): string {
111
- const lines = markdown.split("\n");
112
- const out: string[] = [];
113
- let fence: MarkdownFence | undefined;
114
- for (const line of lines) {
115
- const scanned = scanMarkdownFence(line, fence);
116
- fence = scanned.fence;
117
- if (scanned.protected) {
118
- out.push(line);
119
- continue;
120
- }
121
- // 行内代码(`...`)按反引号分片,只处理偶数片(代码外)
122
- const parts = line.split("`");
123
- for (let p = 0; p < parts.length; p++) {
124
- if (p % 2 === 0) {
125
- parts[p] = parts[p].replace(URL_RE, (url) => {
126
- const trimmed = trimUrl(url);
127
- if (trimmed.length === 0) return url;
128
- return `[${trimmed}](${trimmed})`;
129
- });
130
- }
131
- }
132
- out.push(parts.join("`"));
133
- }
134
- return out.join("\n");
135
- }
136
-
137
44
  // ============================================================================
138
45
  // 圈数字 → 半角括号
139
46
  // ============================================================================
@@ -231,7 +138,9 @@ function normalizeMultilineLinks(markdown: string): string {
231
138
 
232
139
  export default function (pi: ExtensionAPI): void {
233
140
  // 注意:pi 每个扩展只有一个 markdownTransformer 槽位,多次注册会互相覆盖,
234
- // 所以三个转换合并为一次注册,内部按序链式执行。
141
+ // 所以剩下的转换合并为一次注册,内部按序链式执行。
142
+ // 提示框和裸 URL 不接管:pi 的 marked 已自带裸 URL 链接化(带尾标点回退,
143
+ // 且在链接文本内不再嵌套),提示框交给默认引用块风格。
235
144
  pi.registerMarkdownTransformer((markdown, context) => {
236
145
  const { messageType, isStreaming = false } = context ?? {};
237
146
  // thinking 保持原文;普通回复即使流式中也先关闭跨行链接的空白点击区。
@@ -241,11 +150,7 @@ export default function (pi: ExtensionAPI): void {
241
150
  // 0. 圈数字转半角括号(Nerd Font 补丁字形缺陷规避)
242
151
  markdown = deCircled(markdown);
243
152
  // 1. Mermaid 方言渲染
244
- markdown = renderDiagrams(markdown, context);
245
- // 2. GitHub 风格提示框
246
- markdown = renderAdmonitions(markdown);
247
- // 3. 裸 URL 转超链接
248
- return linkifyUrls(markdown);
153
+ return renderDiagrams(markdown, context);
249
154
  });
250
155
  }
251
156
 
@@ -307,32 +212,3 @@ function renderDiagrams(
307
212
  }
308
213
  return out.join("\n");
309
214
  }
310
-
311
- /** GitHub 风格提示框 → 加粗标签引用块(跳过代码块)。 */
312
- function renderAdmonitions(markdown: string): string {
313
- const lines = markdown.split("\n");
314
- const out: string[] = [];
315
- let fence: MarkdownFence | undefined;
316
- let i = 0;
317
- while (i < lines.length) {
318
- const line = lines[i];
319
- const scanned = scanMarkdownFence(line, fence);
320
- fence = scanned.fence;
321
- if (scanned.protected) {
322
- out.push(line);
323
- i++;
324
- continue;
325
- }
326
- if (/^>\s*\[\s*!/i.test(line)) {
327
- const result = renderAdmonition(lines, i);
328
- if (result) {
329
- out.push(...result.output);
330
- i = result.next;
331
- continue;
332
- }
333
- }
334
- out.push(line);
335
- i++;
336
- }
337
- return out.join("\n");
338
- }
@@ -50,8 +50,8 @@ import {
50
50
  hideScrollButton,
51
51
  dockScrollButtonActive,
52
52
  restoreOfficialScrollToEnd,
53
+ restoreNativeScrollIndicatorText,
53
54
  syncOfficialScrollToEnd,
54
- isScrollBottomInput,
55
55
  renderScrollButton,
56
56
  resetScrollButtonState,
57
57
  scheduleScrollButtonSync,
@@ -78,6 +78,7 @@ import {
78
78
  } from "./hover.ts";
79
79
  import {
80
80
  handleStickyPromptClick,
81
+ stickyBarHit,
81
82
  resetStickyPromptState,
82
83
  updateStickyPromptHover,
83
84
  } from "./sticky-prompt.ts";
@@ -613,6 +614,18 @@ function packetInsideOverlay(tui: any, packet: SgrMousePacket): boolean {
613
614
  return false;
614
615
  }
615
616
 
617
+ /** 原生指示器优先于工具卡;覆盖它的粘性行仍保留原有点击优先级。 */
618
+ function packetOnNativeIndicator(tui: any, packet: SgrMousePacket): boolean {
619
+ const rect = tui.scrollToEndIndicatorRect;
620
+ return Boolean(
621
+ rect &&
622
+ packet.row - 1 === rect.row &&
623
+ packet.col - 1 >= rect.column &&
624
+ packet.col - 1 < rect.column + rect.width &&
625
+ !stickyBarHit(tui, packet),
626
+ );
627
+ }
628
+
616
629
  /**
617
630
  * 实例级包装 TuiAltScreen.handleViewportInput(惰性 Proxy 安全):
618
631
  * 原型方法取 original(绕开 proxy 函数包装),实例 own property 装 wrapper
@@ -636,6 +649,13 @@ function patchFullscreenViewportInput(tui: any): void {
636
649
  // dispatchMouseToOverlay → overlay.handleMouse;overlay 外的包仍
637
650
  // 走扩展收起/展开/hover。两条路径各管各的。
638
651
  if (packets) {
652
+ const nativePacket = packets.find((packet) => packetOnNativeIndicator(tui, packet));
653
+ if (nativePacket) {
654
+ clearPendingCollapsePress();
655
+ applyFullscreenHover(tui, null);
656
+ updateStickyPromptHover(tui, nativePacket);
657
+ return Reflect.apply(original, this, [data]);
658
+ }
639
659
  // 布局还没出来或仍是上一帧:不要把点击收成工具展开。返回 undefined
640
660
  // 让焦点 overlay 的 handleInput 拿到原始 SGR;官方链也靠这份布局,调它会消费且打到 transcript。
641
661
  if (tui.hasOverlay?.() && !overlayLayoutsCurrent(tui)) return undefined;
@@ -985,13 +1005,9 @@ function handleToolMouseInput(data: string): { consume: true } | undefined {
985
1005
  if (!getToolMouseTui()) return undefined;
986
1006
  // 惰性 Proxy fullscreen:鼠标由 handleViewportInput 包装消费(官方链之前),
987
1007
  // 此处只处理键盘(鼠标事件在官方 listener 已被 consume,到不了这里)。
1008
+ // 回底键归 Pi viewport(tui.altScreen.bottom,overlay 聚焦时让给 overlay),不在此重复处理。
988
1009
  if (fullscreenLazyTui(getToolMouseTui())) {
989
1010
  scheduleScrollButtonSync(getToolMouseTui(), data);
990
- if (isScrollBottomInput(data)) {
991
- getToolMouseTui().scrollToBottom?.();
992
- hideScrollButton(getToolMouseTui());
993
- return { consume: true };
994
- }
995
1011
  return undefined;
996
1012
  }
997
1013
  updateScrollButtonFromInput(getToolMouseTui(), data);
@@ -1080,6 +1096,7 @@ export function resetToolHoverState(): void {
1080
1096
  setScrollButtonVisible(false);
1081
1097
  setScrollButtonHovered(false);
1082
1098
  resetStickyPromptState();
1099
+ restoreNativeScrollIndicatorText();
1083
1100
  restoreOfficialScrollToEnd(getToolMouseTui());
1084
1101
  releaseFullscreenToolMouseMotion(getToolMouseTui());
1085
1102
  }
@@ -1172,6 +1189,7 @@ export function installToolMouseInteraction(
1172
1189
  return renderScrollButton(width, theme);
1173
1190
  },
1174
1191
  invalidate() {},
1192
+ dispose: restoreNativeScrollIndicatorText,
1175
1193
  });
1176
1194
  return getScrollButtonWidget();
1177
1195
  }
@@ -17,7 +17,7 @@ import {
17
17
 
18
18
  const ZENTUI_PAGE_UP_INPUT = /^\x1b\[5;9(?::[12])?~$|^\x1b\[57421;9(?::[12])?u$|^\x1b\[1;6A$/;
19
19
  const ZENTUI_PAGE_DOWN_INPUT = /^\x1b\[6;9(?::[12])?~$|^\x1b\[57422;9(?::[12])?u$|^\x1b\[1;6B$/;
20
- const SCROLL_BOTTOM_SHORTCUT = "ctrl+end";
20
+ const SCROLL_BOTTOM_KEYBINDING = "tui.altScreen.bottom";
21
21
 
22
22
  patchRegistry.ensure(TOOL_MOUSE_TUI_SLOT, () => null);
23
23
  export function getToolMouseTui(): any {
@@ -68,6 +68,14 @@ export function setScrollButtonVisible(visible: boolean): void {
68
68
  export function noteNewTranscriptItem(): void {
69
69
  const state = scrollButtonState();
70
70
  const tui = getToolMouseTui();
71
+ if (nativeScrollTextEnabled(tui)) {
72
+ syncOfficialScrollToEnd(tui);
73
+ if (nativeScrollTextPatch && !isFullscreenAtBottom(tui)) {
74
+ state.newCount += 1;
75
+ tui.requestRender?.();
76
+ }
77
+ return;
78
+ }
71
79
  if (!dockScrollButtonActive() || !state.visible || !tui) return;
72
80
  // 提交新消息时官方可能直接跳回底部(不经过滚动输入):先按实时跟随状态校正,
73
81
  // 否则跟随期间的内容会被当成“离开底部时的新消息”计入。
@@ -137,34 +145,78 @@ export function restoreOfficialScrollToEnd(tui: any): void {
137
145
  tui[OFFICIAL_SCROLL_TO_END_KEY] = undefined;
138
146
  }
139
147
 
140
- /** dock 模式替换官方浮层;native 或 ccstyle off 时保留 Pi 原生 Jump to latest。 */
148
+ /** dock 模式替换官方浮层;native 保留 Pi 原生浮层并换上 dock 文案;ccstyle off 还原 Pi 原样。 */
141
149
  export function syncOfficialScrollToEnd(tui: any): void {
150
+ if (!nativeScrollTextEnabled(tui)) restoreNativeScrollIndicatorText();
142
151
  if (dockScrollButtonActive()) {
143
152
  disableOfficialScrollToEnd(tui);
144
153
  return;
145
154
  }
146
155
  restoreOfficialScrollToEnd(tui);
156
+ if (nativeScrollTextEnabled(tui)) {
157
+ // 离开 dock 时清零一次;native 的后续渲染保留共享计数。
158
+ if (getScrollButtonVisible()) setScrollButtonVisible(false);
159
+ setScrollButtonHovered(false);
160
+ syncNativeScrollIndicatorText(tui);
161
+ return;
162
+ }
147
163
  setScrollButtonVisible(false);
148
164
  setScrollButtonHovered(false);
149
165
  }
150
166
 
167
+ /** 只记录文案补丁;dock 的显隐、快捷键和原生浮层开关仍走原路径。 */
168
+ let nativeScrollTextPatch: { renderer: any; original: () => string } | undefined;
169
+
170
+ function nativeScrollTextEnabled(tui: any): boolean {
171
+ return (
172
+ toolMouseInteractionActive() && config.scrollButtonMode === "native" && fullscreenLazyTui(tui)
173
+ );
174
+ }
175
+
176
+ /** widget 销毁或离开 native 模式时还原 Pi 的原始回调。 */
177
+ export function restoreNativeScrollIndicatorText(): void {
178
+ if (!nativeScrollTextPatch) return;
179
+ const { renderer, original } = nativeScrollTextPatch;
180
+ renderer.scrollToEndIndicator = original;
181
+ nativeScrollTextPatch = undefined;
182
+ scrollButtonState().newCount = 0;
183
+ }
184
+
185
+ function syncNativeScrollIndicatorText(tui: any): void {
186
+ // Pi 的稳定 Proxy 会重绑捕获的方法;文案回调必须从真实 renderer 上取得。
187
+ const renderer = tui.valueOf();
188
+ if (nativeScrollTextPatch?.renderer !== renderer) restoreNativeScrollIndicatorText();
189
+ if (!nativeScrollTextPatch) {
190
+ const original = renderer.scrollToEndIndicator;
191
+ if (typeof original !== "function") return;
192
+ nativeScrollTextPatch = { renderer, original };
193
+ renderer.scrollToEndIndicator = () =>
194
+ original.call(renderer).replace("Jump to latest message", scrollButtonText());
195
+ }
196
+ if (isFullscreenAtBottom(renderer)) scrollButtonState().newCount = 0;
197
+ }
198
+
151
199
  /** 官方 fullscreen:是否已跟随 transcript 底部(按钮隐藏条件)。 */
152
200
  export function isFullscreenAtBottom(tui: any): boolean {
153
201
  const following = tui.isFollowingOutput ?? tui.getPrimaryScrollView?.()?.isFollowingEnd ?? true;
154
202
  return Boolean(following);
155
203
  }
156
204
 
157
- function formatShortcut(shortcut: string): string {
158
- return shortcut
159
- .split("+")
160
- .map((part) =>
161
- part.length <= 1 ? part.toUpperCase() : `${part[0]?.toUpperCase() ?? ""}${part.slice(1)}`,
205
+ /** 与 Pi 原生浮层同源(keyDisplayText):用户实际绑定,首字母大写,macOS 上 alt 显示为 option。 */
206
+ function scrollBottomShortcutText(): string {
207
+ return getKeybindings()
208
+ .getKeys(SCROLL_BOTTOM_KEYBINDING)
209
+ .map((key) =>
210
+ key
211
+ .split("+")
212
+ .map((part) => {
213
+ const display =
214
+ process.platform === "darwin" && part.toLowerCase() === "alt" ? "option" : part;
215
+ return display.charAt(0).toUpperCase() + display.slice(1);
216
+ })
217
+ .join("+"),
162
218
  )
163
- .join("+");
164
- }
165
-
166
- export function isScrollBottomInput(data: string): boolean {
167
- return matchesKey(data, SCROLL_BOTTOM_SHORTCUT);
219
+ .join("/");
168
220
  }
169
221
 
170
222
  function isScrollNavigationInput(data: string): boolean {
@@ -251,9 +303,8 @@ export function updateScrollButtonFromInput(tui: any, data: string): void {
251
303
  /** 无新内容时提示原文案;有新内容时换成累计条数。 */
252
304
  function scrollButtonText(): string {
253
305
  const count = getScrollButtonNewCount();
254
- const shortcut = formatShortcut(SCROLL_BOTTOM_SHORTCUT);
255
- if (count <= 0) return `Back to bottom · ${shortcut}`;
256
- return `${count} new message${count === 1 ? "" : "s"} · ${shortcut}`;
306
+ if (count <= 0) return "Back to bottom";
307
+ return `${count} new message${count === 1 ? "" : "s"}`;
257
308
  }
258
309
 
259
310
  export function renderScrollButton(width: number, theme: any): string[] {
@@ -263,9 +314,11 @@ export function renderScrollButton(width: number, theme: any): string[] {
263
314
  !fullscreenLazyTui(getToolMouseTui())
264
315
  )
265
316
  return [];
317
+ // 未绑定时与 Pi 原生一样省略快捷键段。
318
+ const shortcut = scrollBottomShortcutText();
266
319
  const label = theme.fg(
267
320
  getScrollButtonHovered() ? "text" : "accent",
268
- `[ ↓ ${scrollButtonText()} ]`,
321
+ `[ ↓ ${scrollButtonText()}${shortcut ? ` · ${shortcut}` : ""} ]`,
269
322
  );
270
323
  const leftPad = Math.max(0, Math.floor((width - visibleWidth(label)) / 2));
271
324
  return [`${" ".repeat(leftPad)}${truncateToWidth(label, width, "…")}`];
@@ -296,7 +296,7 @@ export function renderStickyPromptScreen(
296
296
  }
297
297
 
298
298
  /** 行与列都落在本帧画出的粘性条内。滚动条那一列在条外。 */
299
- function stickyBarHit(tui: any, packet: SgrMousePacket): boolean {
299
+ export function stickyBarHit(tui: any, packet: SgrMousePacket): boolean {
300
300
  const state = stickyState();
301
301
  if (!stickyPromptActive() || !state.visible || state.layout !== tui?.currentLayout) return false;
302
302
  if (packet.row - 1 !== state.row) return false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-cc-extensions",
3
- "version": "1.0.6",
3
+ "version": "1.0.8",
4
4
  "license": "MIT",
5
5
  "description": "Claude Code-style TUI output with some personal touches and handy utilities.",
6
6
  "type": "module",