pi-cc-extensions 1.0.0 → 1.0.2

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 (34) hide show
  1. package/README.en.md +2 -0
  2. package/README.md +3 -1
  3. package/extensions/config/config.ts +16 -0
  4. package/extensions/config/panel.ts +47 -0
  5. package/extensions/feature/compact-thinking.ts +1 -1
  6. package/extensions/feature/reference/subagent.ts +2 -1
  7. package/extensions/feature/shell/footer.ts +57 -10
  8. package/extensions/feature/shell/token-rate.ts +192 -0
  9. package/extensions/feature/shell/working-message.ts +52 -65
  10. package/extensions/renderer/compact-mode.ts +38 -28
  11. package/extensions/renderer/default-mode.ts +23 -463
  12. package/extensions/renderer/index.ts +3 -3
  13. package/extensions/renderer/markdown-enhance.ts +63 -22
  14. package/extensions/renderer/mouse/hover.ts +1 -1
  15. package/extensions/renderer/mouse/interaction.ts +2 -2
  16. package/extensions/renderer/mouse/packets.ts +1 -1
  17. package/extensions/renderer/mouse/sticky-prompt.ts +61 -9
  18. package/extensions/renderer/tool/ask-user-question.ts +1 -1
  19. package/extensions/renderer/tool/ccstyle-tool.ts +467 -0
  20. package/extensions/renderer/tool/codemode.ts +1 -1
  21. package/extensions/renderer/tool/diff/diff-edit-render.ts +1 -1
  22. package/extensions/renderer/tool/diff/diff-limits.ts +1 -1
  23. package/extensions/renderer/tool/diff/diff-write-render.ts +1 -1
  24. package/extensions/renderer/tool/diff/line-width-safety.ts +1 -1
  25. package/extensions/renderer/tool/grouping.ts +56 -50
  26. package/extensions/renderer/tool/message-display.ts +1 -1
  27. package/extensions/renderer/tool/names.ts +41 -1
  28. package/extensions/renderer/tool/result.ts +2 -862
  29. package/extensions/renderer/tool/tool-io.ts +735 -0
  30. package/extensions/renderer/tool/tool-state.ts +138 -0
  31. package/extensions/utils/format.ts +9 -0
  32. package/package.json +1 -1
  33. package/themes/cc-light.json +1 -1
  34. package/extensions/renderer/tool/show-more-hint.ts +0 -48
@@ -5,43 +5,28 @@ import {
5
5
  getToolMouseTui,
6
6
  isFullscreenAtBottom,
7
7
  } from "../../renderer/mouse/scroll.ts";
8
- import { formatDuration } from "../../utils/format.ts";
8
+ import { formatDuration, formatTokenCount } from "../../utils/format.ts";
9
+ import {
10
+ resetTokenRate,
11
+ carryTokenRate,
12
+ pauseTokenRate,
13
+ resumeTokenRate,
14
+ markFirstToken,
15
+ sampleTokenDelta,
16
+ recordProviderUsage,
17
+ startMessageSegment,
18
+ totalTokens,
19
+ } from "./token-rate.ts";
9
20
 
10
21
  const REFRESH_INTERVAL_MS = 1_000;
11
22
  /** Elapsed time is only shown once the turn has run this long. */
12
23
  const SHOW_TIMER_AFTER_MS = 3_000;
13
24
 
14
- function formatCount(value: number): string {
15
- return new Intl.NumberFormat("en-US").format(value);
16
- }
17
-
18
- type ContentBlock = {
19
- type?: unknown;
20
- text?: unknown;
21
- thinkingSignature?: { body?: unknown };
22
- };
23
-
24
25
  type StreamMessage = {
25
26
  content?: unknown;
26
27
  usage?: { output?: unknown };
27
28
  };
28
29
 
29
- /** 每个 content index 的可见文本/思考长度;无对应块的 index 保持稀疏洞。 */
30
- function textBlockLengths(message: StreamMessage): number[] {
31
- const content = message.content;
32
- if (!Array.isArray(content)) return [];
33
- const lengths: number[] = [];
34
- for (let index = 0; index < content.length; index++) {
35
- const block = content[index] as ContentBlock;
36
- if (block?.type === "text" && typeof block.text === "string") {
37
- lengths[index] = block.text.length;
38
- } else if (block?.type === "thinking" && block.thinkingSignature?.body) {
39
- lengths[index] = (block.thinkingSignature.body as string).length;
40
- }
41
- }
42
- return lengths;
43
- }
44
-
45
30
  function outputUsage(message: StreamMessage): number {
46
31
  const value = Number(message?.usage?.output);
47
32
  return Number.isFinite(value) && value > 0 ? Math.round(value) : 0;
@@ -55,39 +40,24 @@ type WorkingUi = {
55
40
  * Extend Pi's footer working row while preserving its spinner and "Working...":
56
41
  * `⠋ Working... (↓ 1,234 tokens · 12s)`
57
42
  *
58
- * Live tokens use the same chars/4 estimate as pi-claude-code-ui, then switch to
59
- * provider `usage.output` whenever the stream exposes an actual count.
60
- */
43
+ * token 数直接读 token-rate 的累计(估算 → provider usage 同源),不再本地重数。 */
61
44
  export default function (pi: ExtensionAPI): void {
62
45
  let turnActive = false;
63
46
  let agentStartTime = 0;
64
47
  let turnStartTime = 0;
65
- let responseLength = 0;
66
- let responseTextBlockLengths: number[] = [];
67
- let providerOutputTokens = 0;
48
+ /** 当前用户指令的起点;工具调用后的 turn 复用它,直到下一条指令。 */
49
+ let rateRunStartTime = 0;
68
50
  let refreshTimer: ReturnType<typeof setTimeout> | null = null;
69
51
  let lastMessage: string | null = null;
70
52
  let activeCtx: { ui: WorkingUi | undefined; hasUI: boolean } | null = null;
71
53
 
72
54
  function tokenCount(): number {
73
- return providerOutputTokens || Math.max(0, Math.round(responseLength / 4));
74
- }
75
-
76
- function setTextBlockLength(index: number, length: number): void {
77
- const previous = responseTextBlockLengths[index] ?? 0;
78
- responseTextBlockLengths[index] = Math.max(0, length);
79
- responseLength = Math.max(0, responseLength + responseTextBlockLengths[index] - previous);
80
- }
81
-
82
- function resetResponseTracking(message?: StreamMessage): void {
83
- responseTextBlockLengths = message ? textBlockLengths(message) : [];
84
- responseLength = responseTextBlockLengths.reduce((sum, length) => sum + length, 0);
85
- providerOutputTokens = message ? outputUsage(message) : 0;
55
+ return totalTokens();
86
56
  }
87
57
 
88
58
  function updateProviderUsage(message: StreamMessage): void {
89
59
  const output = outputUsage(message);
90
- if (output > 0) providerOutputTokens = output;
60
+ if (output > 0) recordProviderUsage(output);
91
61
  }
92
62
 
93
63
  /**
@@ -103,7 +73,7 @@ export default function (pi: ExtensionAPI): void {
103
73
  function buildWorkingMessage(): string {
104
74
  const parts: string[] = [];
105
75
  const tokens = tokenCount();
106
- if (tokens > 0) parts.push(`↓ ${formatCount(tokens)} tokens`);
76
+ if (tokens > 0) parts.push(`↓ ${formatTokenCount(tokens)} tokens`);
107
77
  // compact 活动回合且已滚出摘要行:直接用摘要行文案(自带回合时长,不叠 agent 计时)。
108
78
  const compactStatus = compactMirrorText();
109
79
  if (compactStatus) return [compactStatus, ...parts].join(" · ");
@@ -179,7 +149,7 @@ export default function (pi: ExtensionAPI): void {
179
149
  stopRefreshLoop();
180
150
  agentStartTime = 0;
181
151
  turnStartTime = 0;
182
- resetResponseTracking();
152
+ // 不重置 token-rate:footer 定格显示上一回合速率
183
153
  restoreDefaultWorkingMessage();
184
154
  }
185
155
 
@@ -187,12 +157,19 @@ export default function (pi: ExtensionAPI): void {
187
157
  if (!agentStartTime) agentStartTime = Date.now();
188
158
  });
189
159
 
190
- pi.on("turn_start", async (_event, ctx) => {
160
+ pi.on("turn_start", async (event, ctx) => {
191
161
  turnActive = true;
192
162
  activeCtx = ctx;
193
- turnStartTime = Date.now();
163
+ // 事件时间戳在 handler 之前产生;用 Date.now() 会和紧随的采样落在同一毫秒,分母变成 0。
164
+ turnStartTime = event?.timestamp || Date.now();
194
165
  if (!agentStartTime) agentStartTime = turnStartTime;
195
- resetResponseTracking();
166
+ // 工具调用后的 turn 属于同一条用户指令:计时、TTFT 与累计 token 保持到下一条指令。
167
+ // 速率起点用本指令第一个 turn 的时间戳;before_agent_start 只当指令 id。
168
+ if (rateRunStartTime !== agentStartTime) {
169
+ rateRunStartTime = agentStartTime;
170
+ resetTokenRate(turnStartTime);
171
+ }
172
+ startMessageSegment(turnStartTime);
196
173
  syncWorkingMessage(true);
197
174
  scheduleRefreshTick();
198
175
  });
@@ -203,24 +180,25 @@ export default function (pi: ExtensionAPI): void {
203
180
  if (!evt) return;
204
181
 
205
182
  if (evt.type === "start") {
206
- resetResponseTracking(evt.partial);
207
- } else if (evt.type === "thinking_start" || evt.type === "text_start") {
208
- setTextBlockLength(evt.contentIndex, 0);
183
+ // 新 assistant 消息起点:速率分段已在 turn_start 处理。
184
+ } else if (
185
+ evt.type === "thinking_start" ||
186
+ evt.type === "text_start" ||
187
+ evt.type === "toolcall_start"
188
+ ) {
189
+ // TTFT 的首块含 toolcall_start:模型直接发起 tool call(thinking/text 省略)时也算首 token。
190
+ // 分段锚点只在 turn_start 重置。这里再重置会把累计 usage.output 当成新的首报。
191
+ markFirstToken();
209
192
  updateProviderUsage(evt.partial);
210
193
  } else if (evt.type === "thinking_delta" || evt.type === "text_delta") {
211
- const add = typeof evt.delta === "string" ? evt.delta.length : 0;
212
- setTextBlockLength(evt.contentIndex, (responseTextBlockLengths[evt.contentIndex] ?? 0) + add);
194
+ if (typeof evt.delta === "string") sampleTokenDelta(evt.delta);
213
195
  updateProviderUsage(evt.partial);
214
- } else if (evt.type === "text_end") {
215
- setTextBlockLength(
216
- evt.contentIndex,
217
- typeof evt.content === "string" ? evt.content.length : 0,
218
- );
196
+ } else if (evt.type === "text_end" || evt.type === "thinking_end") {
219
197
  updateProviderUsage(evt.partial);
220
198
  } else if (evt.type === "done") {
221
- resetResponseTracking(evt.message);
199
+ updateProviderUsage(evt.message);
222
200
  } else if (evt.type === "error") {
223
- resetResponseTracking(evt.error);
201
+ updateProviderUsage(evt.error);
224
202
  } else {
225
203
  updateProviderUsage(evt.partial);
226
204
  }
@@ -229,11 +207,20 @@ export default function (pi: ExtensionAPI): void {
229
207
  scheduleRefreshTick();
230
208
  });
231
209
 
210
+ pi.on("tool_execution_start", async () => {
211
+ pauseTokenRate();
212
+ });
213
+
214
+ pi.on("tool_execution_end", async () => {
215
+ resumeTokenRate();
216
+ });
217
+
232
218
  pi.on("turn_end", async (_event, ctx) => {
233
219
  turnActive = false;
234
220
  activeCtx = ctx;
235
221
  stopRefreshLoop();
236
- resetResponseTracking();
222
+ // 本段 token 结转进累计,下一段 turn 继续累加;此处不清速率。
223
+ carryTokenRate();
237
224
  // No completion message: return immediately to Pi's default idle state.
238
225
  restoreDefaultWorkingMessage();
239
226
  });
@@ -42,7 +42,7 @@ import { toolLoadingIcon } from "../utils/tool-loading-icon.ts";
42
42
  import { sanitizeToolResultText } from "../utils/tool-result-sanitize.ts";
43
43
  import { refreshTranscriptComponent } from "./transcript-refresh.ts";
44
44
  import { getMessageDisplayTheme } from "./tool/message-display.ts";
45
- import { showMoreHintText } from "./tool/show-more-hint.ts";
45
+ import { showMoreHintText } from "./tool/names.ts";
46
46
  import {
47
47
  countEditDiffStats,
48
48
  countWriteDiffStats,
@@ -53,13 +53,11 @@ import { displayConfigCacheKey } from "./tool/diff/diff-component.ts";
53
53
  import type { WriteExecutionMetadataStore } from "./tool/diff/write-execution.ts";
54
54
  import { isToolCallHovered } from "./mouse/hover.ts";
55
55
  import { getToolMouseTui } from "./mouse/scroll.ts";
56
- import {
57
- insetComponent,
58
- isLiveToolCall,
59
- renderExpandedToolResult,
60
- scheduleAnimation,
61
- } from "./tool/result.ts";
56
+ import { insetComponent } from "./tool/result.ts";
57
+ import { isLiveToolCall, scheduleAnimation } from "./tool/tool-state.ts";
58
+ import { renderExpandedToolResult } from "./tool/tool-io.ts";
62
59
  import { paddedBackgroundRow } from "./tool/grouping.ts";
60
+ import { styleChangeCounts } from "./tool/ccstyle-tool.ts";
63
61
  import { formatDisplayPath } from "./tool/names.ts";
64
62
  import {
65
63
  hasVisibleText,
@@ -663,31 +661,37 @@ export function markCompactRoundToolExpanded(tool: any): void {
663
661
  roundToolExpansion?.markUserExpanded(tool);
664
662
  }
665
663
 
666
- /** 面板展开前的视口位置:收起时还原,避免面板撑高后收起导致视口错位。 */
667
- const panelViewportBeforeExpand = new WeakMap<
668
- object,
669
- { scrollTop: number; followingEnd: boolean }
670
- >();
664
+ /**
665
+ * 展开面板前的分隔空行与面板绑成一体:挂在同一尾行上,才不会被拆到
666
+ * anchor 正文之后(面板挂回合尾行,空行却留在消息里)。
667
+ * 带 childAtRow 返回 null:拆分判据认它为面板成员,行命中落空时归外层面板。
668
+ */
669
+ function panelBoundSpacer(): any {
670
+ return {
671
+ render: () => [""],
672
+ childAtRow: () => null,
673
+ invalidate() {},
674
+ };
675
+ }
676
+
677
+ /** 面板展开前的视口标记:收起时需要重钉视口的卡。 */
678
+ const panelViewportBeforeExpand = new WeakSet<object>();
671
679
 
672
680
  function capturePanelViewport(card: any): void {
673
- if (!card || panelViewportBeforeExpand.has(card)) return;
674
- const view = getToolMouseTui()?.getPrimaryScrollView?.();
675
- if (!view || typeof view.scrollTop !== "number" || typeof view.scrollTo !== "function") return;
676
- panelViewportBeforeExpand.set(card, {
677
- scrollTop: view.scrollTop,
678
- followingEnd: view.isFollowingEnd !== false,
679
- });
681
+ if (!card) return;
682
+ panelViewportBeforeExpand.add(card);
680
683
  }
681
684
 
682
685
  function restorePanelViewport(card: any): void {
683
- const snapshot = panelViewportBeforeExpand.get(card);
684
- if (!snapshot) return;
685
- panelViewportBeforeExpand.delete(card);
686
+ if (!panelViewportBeforeExpand.delete(card)) return;
686
687
  const view = getToolMouseTui()?.getPrimaryScrollView?.();
687
688
  if (!view) return;
688
- // 展开前就在底部:交回官方 follow;否则回到展开前的滚动偏移。
689
- if (snapshot.followingEnd && typeof view.scrollToEnd === "function") view.scrollToEnd();
690
- else if (typeof view.scrollTo === "function") view.scrollTo(snapshot.scrollTop);
689
+ // 不用展开前的快照:展开期间用户可能滚进面板里,回跳旧位置才是"跳"。
690
+ // 面板在摘要行之下,收起只删它以下的行,保持当前 scrollTop 摘要行原地不动;
691
+ // clamp 后落在新底时 scrollTo 会自动恢复 followingEnd(并清 followSuppressedAtEnd)。
692
+ if (typeof view.scrollTo === "function" && typeof view.scrollTop === "number") {
693
+ view.scrollTo(view.scrollTop);
694
+ }
691
695
  }
692
696
 
693
697
  function compactEditWriteLine(
@@ -724,8 +728,13 @@ function compactEditWriteLine(
724
728
  )
725
729
  : undefined;
726
730
  if (stats) {
727
- statsText = ` (+${stats.added} -${stats.removed})`;
728
- statsStyled = ` ${theme.fg("dim", "(")}${theme.fg("success", `+${stats.added}`)} ${theme.fg("error", `-${stats.removed}`)}${theme.fg("dim", ")")}`;
731
+ const counts = styleChangeCounts(
732
+ (color, text) => theme.fg(color, text),
733
+ stats.added,
734
+ stats.removed,
735
+ );
736
+ statsText = counts.text;
737
+ statsStyled = counts.styled;
729
738
  }
730
739
  }
731
740
  // 展开卡 Box(1,1) 已 pad;折叠行自己留 1 格前导空格
@@ -1828,7 +1837,8 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1828
1837
  } else {
1829
1838
  // 面板上沿留 1 行卡外空行:面板底色与 userMessage 同色,
1830
1839
  // 贴在一起会看成一个块;卡内顶部再加 1 行 padding 保持上下对称。
1831
- anchorChildren.push(new Spacer(1));
1840
+ // 空行必须与面板同进退(childAtRow 参与面板拆分),用 panelBoundSpacer。
1841
+ anchorChildren.push(panelBoundSpacer());
1832
1842
  anchorChildren.push(
1833
1843
  compactRoundCard(
1834
1844
  cardItems,