pi-cc-extensions 1.0.12 → 1.0.14

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
@@ -33,7 +33,7 @@ Run `/reload` after installation.
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` |
35
35
  | Session/Subagent references | Search and inject effective context from previous Sessions or existing SubAgents | `@` |
36
- | Status bar | Shows model, context, cache, cost, and git; also works with `@narumitw/pi-usage` for live quota. Git/cache Nerd Font icons can be turned off | `/ccstyle` |
36
+ | Status bar | Shows model, context, cache, cost, the conversation token total, and git; also works with `@narumitw/pi-usage` for live quota. Git/cache Nerd Font icons can be turned off | `/ccstyle` |
37
37
  | Theme | Bundled CC Dark and CC Light themes | `/settings` → Theme |
38
38
 
39
39
  Renderer snapshots for `on` / `compact` modes: [default](./docs/tool-render-examples-default.md) · [compact](./docs/tool-render-examples-compact.md)
@@ -77,7 +77,8 @@ Renderer snapshots for `on` / `compact` modes: [default](./docs/tool-render-exam
77
77
 
78
78
  // thinking
79
79
  "useSummaryTitlesAsThinkingTitle": true, // use latest summary as thinking title
80
- "previewLines": 3, // preview lines; 0 hides
80
+ "previewLines": 3, // preview lines while thinking; 0 hides
81
+ "completedPreviewLines": null, // completed preview lines; null follows previewLines, 0 hides. Panel accepts any number or follow
81
82
  "animationIntervalMs": 90, // title animation interval (ms)
82
83
 
83
84
  // footer
@@ -85,12 +86,13 @@ Renderer snapshots for `on` / `compact` modes: [default](./docs/tool-render-exam
85
86
  "footerTokenRate": true, // output rate chip; false hides it
86
87
  "footerTokenRateMode": "average", // average: whole-prompt avg; live: trailing 1s rate
87
88
  "footerTokenRateTtft": true, // append TTFT to the rate chip; false shows tok/s only
89
+ "footerSessionTokens": false, // conversation token total chip (∑); sums usage.totalTokens from session entries. Off by default
88
90
  "footerCurrency": "USD", // display currency for the footer cost
89
91
  "footerCurrencyRate": null, // display units per 1 cost unit; null changes the symbol only
90
92
  "footerNerdIcons": true, // Nerd Font glyphs for git/cache; false = plain text
91
93
  "footerGitStatsMode": "working", // working (default): uncommitted; branch: cumulative net changes
92
94
  "footerHiddenKeys": [], // hidden chip keys
93
- "footerLine1Keys": ["model", "context", "cache", "rate", "cost", "pi-usage"], // chip order per line; builtins and plugins share one layout
95
+ "footerLine1Keys": ["model", "context", "cache", "rate", "cost", "tokens", "pi-usage"], // chip order per line; builtins and plugins share one layout
94
96
  "footerLine2Keys": ["cwd", "git"], // cwd: directory and session; git: branch and changes. Either can move to any line
95
97
  "footerLine3Keys": [], // painted only when a chip is visible
96
98
  }
package/README.md CHANGED
@@ -33,7 +33,7 @@ pi install git:github.com/minuque/pi-cc-extensions
33
33
  | 配置面板 | `Style / Features / UI / Diff / Thinking / Footer` 六页签 | `/ccstyle` |
34
34
  | 上下文检查 | 查看上下文占用,并预览 System prompt、Memory、Skills、Tools definition 和消息内容 | `/context` |
35
35
  | Session/Subagent 引用 | 搜索并注入历史 Session 或现有 SubAgent 的有效上下文 | `@` |
36
- | 状态栏 | 显示:模型、上下文、缓存、费用、git并适配 @narumitw/pi-usage 实时显示额度;git/缓存图标可关 Nerd Font | `/ccstyle` |
36
+ | 状态栏 | 显示:模型、上下文、缓存、费用、本次对话累计 token、git并适配 @narumitw/pi-usage 实时显示额度;git/缓存图标可关 Nerd Font | `/ccstyle` |
37
37
  | 主题 | 随包提供内置 CC Dark、CC Light 主题 | `/settings` → Theme |
38
38
 
39
39
  `on` / `compact` 两种模式的渲染样例:[默认模式](./docs/tool-render-examples-default.md) · [紧凑模式](./docs/tool-render-examples-compact.md)
@@ -77,7 +77,8 @@ pi install git:github.com/minuque/pi-cc-extensions
77
77
 
78
78
  // thinking
79
79
  "useSummaryTitlesAsThinkingTitle": true, // 用最新摘要作思考标题
80
- "previewLines": 3, // 预览行数,0 隐藏
80
+ "previewLines": 3, // 思考中的预览行数,0 隐藏
81
+ "completedPreviewLines": null, // 完成态预览行数,0 隐藏;null 跟随 previewLines。面板可输入任意值或 follow
81
82
  "animationIntervalMs": 90, // 标题动画间隔(毫秒)
82
83
 
83
84
  // footer
@@ -85,12 +86,13 @@ pi install git:github.com/minuque/pi-cc-extensions
85
86
  "footerTokenRate": true, // 输出速率芯片;false 隐藏
86
87
  "footerTokenRateMode": "average", // average:整段指令平均(avg);live:最近 1 秒瞬时(live)
87
88
  "footerTokenRateTtft": true, // 速率芯片是否带 TTFT;false 只显示 tok/s
89
+ "footerSessionTokens": false, // 本次对话累计 token 芯片(∑);累加会话条目的 usage.totalTokens,默认关
88
90
  "footerCurrency": "USD", // 底栏费用的显示币种
89
91
  "footerCurrencyRate": null, // 1 单位费用换成多少显示币种;null 只换符号、不乘倍率
90
92
  "footerNerdIcons": true, // git/缓存用 Nerd Font 图标;false 为纯文本
91
93
  "footerGitStatsMode": "working", // working(默认):未提交改动;branch:分支累计净改动
92
94
  "footerHiddenKeys": [], // 隐藏的芯片 key
93
- "footerLine1Keys": ["model", "context", "cache", "rate", "cost", "pi-usage"], // 每行芯片顺序;内置项与插件同一套排布
95
+ "footerLine1Keys": ["model", "context", "cache", "rate", "cost", "tokens", "pi-usage"], // 每行芯片顺序;内置项与插件同一套排布
94
96
  "footerLine2Keys": ["cwd", "git"], // cwd:目录和会话名;git:分支和改动。可换到任意行
95
97
  "footerLine3Keys": [] // 有可见芯片才占行
96
98
  }
@@ -62,6 +62,8 @@ export type Config = {
62
62
  toolLabelClip: number;
63
63
  useSummaryTitlesAsThinkingTitle: boolean;
64
64
  previewLines: number;
65
+ /** 思考完成后的折叠预览行数;null = 跟随 previewLines(保持旧行为)。 */
66
+ completedPreviewLines: number | null;
65
67
  animationIntervalMs: number;
66
68
  showStartupHeader: boolean;
67
69
  /** fullscreen:userMessage 正文首行滚出视口上沿后钉住,直到下一条指令接手。 */
@@ -85,6 +87,8 @@ export type Config = {
85
87
  footerTokenRateMode: FooterTokenRateMode;
86
88
  /** 速率芯片是否带 TTFT。关则只显示速率。 */
87
89
  footerTokenRateTtft: boolean;
90
+ /** 会话累计 token 芯片(累加会话条目的 usage.totalTokens)。默认关。 */
91
+ footerSessionTokens: boolean;
88
92
  /** ISO 4217 显示币种。USD 时不换算。 */
89
93
  footerCurrency: string;
90
94
  /** 1 单位费用对应的显示币种数量。null 只换符号、不乘倍率。 */
@@ -119,6 +123,8 @@ export const EXPANDED_OUTPUT_MAX_LINES_VALUES = ["10", "20", "40", "80", "120"];
119
123
  /** 工具摘要里 path/command 等输入的折叠字符数;0 = 只按可用宽度截断。 */
120
124
  export const TOOL_LABEL_CLIP_VALUES = ["0", "40", "60", "80", "100", "120", "160"];
121
125
  export const THINKING_PREVIEW_LINES_VALUES = ["0", "1", "3", "5", "10"];
126
+ /** 完成态思考预览预设;follow = 跟随 previewLines。 */
127
+ export const COMPLETED_THINKING_PREVIEW_LINES_VALUES = ["follow", ...THINKING_PREVIEW_LINES_VALUES];
122
128
  export const THINKING_ANIMATION_INTERVAL_VALUES = ["40", "60", "90", "120", "180"];
123
129
  /** Tools commonly toggled in excludeRenderers via the settings panel. */
124
130
  export const EXCLUDE_RENDERER_CANDIDATES = [
@@ -150,6 +156,7 @@ export const DEFAULT_CONFIG: Config = {
150
156
  toolLabelClip: 0,
151
157
  useSummaryTitlesAsThinkingTitle: true,
152
158
  previewLines: 3,
159
+ completedPreviewLines: null,
153
160
  animationIntervalMs: 90,
154
161
  showStartupHeader: true,
155
162
  stickyUserMessage: true,
@@ -166,6 +173,7 @@ export const DEFAULT_CONFIG: Config = {
166
173
  footerTokenRate: true,
167
174
  footerTokenRateMode: "average",
168
175
  footerTokenRateTtft: true,
176
+ footerSessionTokens: false,
169
177
  footerCurrency: "USD",
170
178
  footerCurrencyRate: null,
171
179
  footerNerdIcons: true,
@@ -196,6 +204,13 @@ export function pickPositiveNumber(value: unknown, fallback: number, min = 1): n
196
204
  return Number.isFinite(n) ? Math.max(min, n) : fallback;
197
205
  }
198
206
 
207
+ /** 思考完成后的预览行数。null(缺失/空/非法)= 跟随 previewLines,保持旧配置行为。 */
208
+ export function pickCompletedPreviewLines(value: unknown): number | null {
209
+ if (value === undefined || value === null || value === "") return null;
210
+ const n = typeof value === "number" ? value : typeof value === "string" ? Number(value) : NaN;
211
+ return Number.isFinite(n) ? Math.max(0, Math.floor(n)) : null;
212
+ }
213
+
199
214
  function normalizeCurrency(value: unknown): string {
200
215
  return typeof value === "string" && /^[A-Za-z]{3}$/.test(value.trim())
201
216
  ? value.trim().toUpperCase()
@@ -277,6 +292,7 @@ export function normalizeConfig(input: unknown): Config {
277
292
  0,
278
293
  Number.MAX_SAFE_INTEGER,
279
294
  ),
295
+ completedPreviewLines: pickCompletedPreviewLines(source.completedPreviewLines),
280
296
  animationIntervalMs: pickPositiveNumber(
281
297
  source.animationIntervalMs,
282
298
  DEFAULT_CONFIG.animationIntervalMs,
@@ -304,6 +320,7 @@ export function normalizeConfig(input: unknown): Config {
304
320
  DEFAULT_CONFIG.footerTokenRateMode,
305
321
  ),
306
322
  footerTokenRateTtft: source.footerTokenRateTtft !== false,
323
+ footerSessionTokens: source.footerSessionTokens === true,
307
324
  footerCurrency: normalizeCurrency(source.footerCurrency),
308
325
  footerCurrencyRate: normalizeFooterCurrencyRate(source.footerCurrencyRate),
309
326
  footerNerdIcons: source.footerNerdIcons !== false,
@@ -320,6 +337,7 @@ export function getCompactThinkingConfig(source: Config = config): CompactThinki
320
337
  return {
321
338
  useSummaryTitlesAsThinkingTitle: source.useSummaryTitlesAsThinkingTitle,
322
339
  previewLines: source.previewLines,
340
+ completedPreviewLines: source.completedPreviewLines,
323
341
  animationIntervalMs: source.animationIntervalMs,
324
342
  };
325
343
  }
@@ -393,6 +411,7 @@ export function formatConfigStatus(source: Config = config): string {
393
411
  `toolLabelClip=${source.toolLabelClip}`,
394
412
  `thinkingTitle=${source.useSummaryTitlesAsThinkingTitle ? "summary" : "default"}`,
395
413
  `thinkingPreview=${source.previewLines}`,
414
+ `thinkingPreviewDone=${source.completedPreviewLines ?? "follow"}`,
396
415
  `thinkingAnimation=${source.animationIntervalMs}ms`,
397
416
  `startupHeader=${source.showStartupHeader ? "on" : "off"}`,
398
417
  `stickyUserMessage=${source.stickyUserMessage ? "on" : "off"}`,
@@ -409,6 +428,7 @@ export function formatConfigStatus(source: Config = config): string {
409
428
  `footerTokenRate=${source.footerTokenRate ? "on" : "off"}`,
410
429
  `footerTokenRateMode=${source.footerTokenRateMode}`,
411
430
  `footerTokenRateTtft=${source.footerTokenRateTtft ? "on" : "off"}`,
431
+ `footerSessionTokens=${source.footerSessionTokens ? "on" : "off"}`,
412
432
  `footerCurrency=${source.footerCurrency}`,
413
433
  `footerRate=${source.footerCurrencyRate ?? "-"}`,
414
434
  `footerIcons=${source.footerNerdIcons ? "nerd" : "plain"}`,
@@ -33,6 +33,7 @@ import { applyStartupHeader, clearStartupHeader } from "../feature/shell/startup
33
33
  import type { ToolGroupingHooks } from "../renderer/tool/grouping.ts";
34
34
  import {
35
35
  config,
36
+ COMPLETED_THINKING_PREVIEW_LINES_VALUES,
36
37
  DEFAULT_CONFIG,
37
38
  DIFF_COLLAPSED_LINES_VALUES,
38
39
  DIFF_INDICATOR_MODES,
@@ -45,6 +46,7 @@ import {
45
46
  FOOTER_TOKEN_RATE_MODES,
46
47
  formatExcludeRenderers,
47
48
  getCompactThinkingConfig,
49
+ pickCompletedPreviewLines,
48
50
  pickPositiveInt,
49
51
  pickPositiveNumber,
50
52
  pickToolLabelClip,
@@ -111,6 +113,12 @@ function footerTokenRateTtftDescription(enabled: boolean): string {
111
113
  : "Time to first token hidden. The rate chip shows tok/s only.";
112
114
  }
113
115
 
116
+ function footerSessionTokensDescription(enabled: boolean): string {
117
+ return enabled
118
+ ? "Cumulative tokens used in this conversation, summed from the session's usage.totalTokens. Turn off to hide it."
119
+ : "Conversation token total hidden.";
120
+ }
121
+
114
122
  function footerGitStatsDescription(mode: FooterGitStatsMode): string {
115
123
  return mode === "branch"
116
124
  ? "Net changes since the merge base, including uncommitted edits. Base: origin/HEAD, then local main/master. Untracked files excluded; unavailable stats hidden."
@@ -363,20 +371,28 @@ function buildValueInputSubmenu(
363
371
  };
364
372
  }
365
373
 
366
- /** 数值项:预填当前值,空输入取消,非数字留下错误。 */
374
+ /** 数值项:预填当前值,空输入取消,非法输入留下错误;allowFollow 时额外接受 follow。 */
367
375
  function buildNumberInputSubmenu(
368
376
  theme: any,
369
377
  setting: { label: string; currentValue: string },
370
378
  closeSubmenu: (selected?: string) => void,
379
+ allowFollow = false,
371
380
  ) {
372
381
  return buildValueInputSubmenu(
373
382
  theme,
374
383
  {
375
384
  prompt: ` ${setting.label} — custom value:`,
376
385
  initial: setting.currentValue,
377
- hint: " Enter to apply · Esc to go back",
378
- accept: (raw) => (raw === "" ? undefined : Number.isFinite(Number(raw)) ? raw : null),
379
- invalid: (raw) => `Invalid number: "${raw}"`,
386
+ hint: allowFollow
387
+ ? " Enter to apply · Esc to go back · follow = Preview lines"
388
+ : " Enter to apply · Esc to go back",
389
+ accept: (raw) =>
390
+ raw === ""
391
+ ? undefined
392
+ : (allowFollow && raw === "follow") || Number.isFinite(Number(raw))
393
+ ? raw
394
+ : null,
395
+ invalid: (raw) => `${allowFollow ? "Invalid value" : "Invalid number"}: "${raw}"`,
380
396
  },
381
397
  closeSubmenu,
382
398
  );
@@ -575,12 +591,23 @@ export async function showCcstylePanel(
575
591
  const thinkingPreviewSetting = {
576
592
  id: "previewLines",
577
593
  label: "Preview lines",
578
- description: "Thinking preview lines; 0 hides the preview body.",
594
+ description: "Thinking preview lines while a run is in progress; 0 hides the preview body.",
579
595
  currentValue: String(config.previewLines),
580
596
  values: [...THINKING_PREVIEW_LINES_VALUES],
581
597
  submenu: (_current: string, closeSubmenu: (selected?: string) => void) =>
582
598
  buildNumberInputSubmenu(theme, thinkingPreviewSetting, closeSubmenu),
583
599
  };
600
+ const completedThinkingPreviewSetting = {
601
+ id: "completedPreviewLines",
602
+ label: "Completed preview",
603
+ description:
604
+ "Thinking preview lines once the run finishes; follow = same as Preview lines. Enter to type a custom value or follow.",
605
+ currentValue:
606
+ config.completedPreviewLines === null ? "follow" : String(config.completedPreviewLines),
607
+ values: [...COMPLETED_THINKING_PREVIEW_LINES_VALUES],
608
+ submenu: (_current: string, closeSubmenu: (selected?: string) => void) =>
609
+ buildNumberInputSubmenu(theme, completedThinkingPreviewSetting, closeSubmenu, true),
610
+ };
584
611
  const thinkingAnimationSetting = {
585
612
  id: "animationIntervalMs",
586
613
  label: "Animation interval ms",
@@ -781,6 +808,13 @@ export async function showCcstylePanel(
781
808
  currentValue: config.footerTokenRateTtft ? "on" : "off",
782
809
  values: ["on", "off"],
783
810
  };
811
+ const footerSessionTokensSetting = {
812
+ id: "footerSessionTokens",
813
+ label: "Conversation tokens",
814
+ description: footerSessionTokensDescription(config.footerSessionTokens),
815
+ currentValue: config.footerSessionTokens ? "on" : "off",
816
+ values: ["on", "off"],
817
+ };
784
818
  const pluginChipsSetting = {
785
819
  id: "footerPluginChips",
786
820
  label: "Footer chips",
@@ -879,6 +913,14 @@ export async function showCcstylePanel(
879
913
  ctx.ui.notify(`Updated ${id}: ${value}`, "info");
880
914
  return;
881
915
  }
916
+ if (id === "footerSessionTokens") {
917
+ const enabled = value === "on";
918
+ updateConfig({ footerSessionTokens: enabled });
919
+ footerSessionTokensSetting.currentValue = enabled ? "on" : "off";
920
+ footerSessionTokensSetting.description = footerSessionTokensDescription(enabled);
921
+ ctx.ui.notify(`Updated ${id}: ${value}`, "info");
922
+ return;
923
+ }
882
924
  if (id === "enableEditorPrefix") {
883
925
  const enabled = value === "on";
884
926
  updateConfig({ enableEditorPrefix: enabled });
@@ -1007,6 +1049,13 @@ export async function showCcstylePanel(
1007
1049
  updateConfig({ previewLines: pickPositiveInt(value, DEFAULT_CONFIG.previewLines, 0) });
1008
1050
  thinkingPreviewSetting.currentValue = String(config.previewLines);
1009
1051
  break;
1052
+ case "completedPreviewLines":
1053
+ updateConfig({
1054
+ completedPreviewLines: value === "follow" ? null : pickCompletedPreviewLines(value),
1055
+ });
1056
+ completedThinkingPreviewSetting.currentValue =
1057
+ config.completedPreviewLines === null ? "follow" : String(config.completedPreviewLines);
1058
+ break;
1010
1059
  case "animationIntervalMs":
1011
1060
  updateConfig({
1012
1061
  animationIntervalMs: pickPositiveNumber(value, DEFAULT_CONFIG.animationIntervalMs),
@@ -1094,7 +1143,12 @@ export async function showCcstylePanel(
1094
1143
  {
1095
1144
  id: "thinking",
1096
1145
  label: "Thinking",
1097
- items: [thinkingTitleSetting, thinkingPreviewSetting, thinkingAnimationSetting],
1146
+ items: [
1147
+ thinkingTitleSetting,
1148
+ thinkingPreviewSetting,
1149
+ completedThinkingPreviewSetting,
1150
+ thinkingAnimationSetting,
1151
+ ],
1098
1152
  },
1099
1153
  {
1100
1154
  id: "footer",
@@ -1104,6 +1158,7 @@ export async function showCcstylePanel(
1104
1158
  footerTokenRateSetting,
1105
1159
  footerTokenRateModeSetting,
1106
1160
  footerTokenRateTtftSetting,
1161
+ footerSessionTokensSetting,
1107
1162
  footerCurrencySetting,
1108
1163
  footerCurrencyRateSetting,
1109
1164
  footerNerdIconsSetting,
@@ -5,11 +5,11 @@
5
5
  * 计数:bash/powershell 按调用次数;read/edit/write 按非空 path/file_path 去重;other 按调用次数。
6
6
  * 失败单独累计;另记回合耗时。
7
7
  *
8
- * 呈现:`summaryLine` 纯文本,`summaryMarkdown` Markdown(可 box 引用块)。
8
+ * 呈现:`summarySegments` 分段(文字 + 语义色),渲染层自己拼行并上色。
9
+ * 不要经 Markdown 渲染:pi 的 Markdown 组件会剥掉输入里的终端序列,数字会失去语义色。
9
10
  */
10
11
 
11
12
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
12
- import { formatDuration } from "../../utils/format.ts";
13
13
 
14
14
  /** 工具分类。 */
15
15
  export type AgentToolCategory = "bash" | "read" | "edit" | "write" | "other";
@@ -113,25 +113,20 @@ function summaryParts(data: AgentSummaryData): string[] {
113
113
  return parts;
114
114
  }
115
115
 
116
- export function summaryMarkdown(
117
- data: AgentSummaryData,
118
- colors: { success: string; failed: string } = { success: "", failed: "" },
119
- ): string {
116
+ /** 摘要段:一段文字 + 语义色;数字上色由渲染层完成。 */
117
+ export type SummarySegment = { text: string; tone: "success" | "failed" };
118
+
119
+ export function summarySegments(data: AgentSummaryData): SummarySegment[] {
120
120
  const parts = summaryParts(data);
121
- if (parts.length === 0) return "";
121
+ if (parts.length === 0) return [];
122
122
  const capitalizeFirst = (part: string, first: boolean) => {
123
123
  const verb = part.match(/^[a-z]+/)?.[0] ?? "";
124
124
  return first && verb ? verb[0].toUpperCase() + verb.slice(1) + part.slice(verb.length) : part;
125
125
  };
126
- const paintNumber = (code: string, part: string) =>
127
- code ? part.replace(/(\d+)/, `${code}$1\x1b[0m`) : part;
128
- const text = parts
129
- .map((part, index) => capitalizeFirst(part, index === 0))
130
- .map((part) => paintNumber(part.endsWith("failed") ? colors.failed : colors.success, part))
131
- .join(", ");
132
- const duration = formatDuration(data.durationMs);
133
- const line = duration ? `${text} · ${duration}` : text;
134
- return `> *${line}*`;
126
+ return parts.map((part, index) => ({
127
+ text: capitalizeFirst(part, index === 0),
128
+ tone: part.endsWith("failed") ? "failed" : "success",
129
+ }));
135
130
  }
136
131
 
137
132
  /**
@@ -1,27 +1,76 @@
1
1
  /**
2
2
  * Agent 回合摘要展示:agent_end 时把本回合工具统计写入会话条目,
3
- * 由 entry renderer 渲染为 markdown 引用块 `> [!TIP] *斜体内容*`。
3
+ * 由 entry renderer 渲染成一行引用行 `│ Ran 1 command, wrote 1 file · 15s`。
4
4
  *
5
5
  * 统计复用 ./core.ts(bash|powershell/read/edit/write/other)。
6
6
  * appendEntry 不进 LLM 上下文,只显示在聊天区。
7
- * 引用块文字色取主题 mdQuote(cc 主题下为 muted 灰),内容用 *斜体* 语法。
7
+ *
8
+ * 不走 Markdown:pi 的 Markdown 组件会剥掉输入里的终端序列,
9
+ * 数字的语义色只能由组件自己拼。竖线取 mdQuoteBorder,文字取 mdQuote + 斜体,数字取 success/error。
8
10
  */
9
11
 
10
- import { Markdown } from "@earendil-works/pi-tui";
11
- import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
12
+ import { wrapTextWithAnsi } from "@earendil-works/pi-tui";
12
13
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
13
- import { bindAgentSummary, summaryMarkdown, type AgentSummaryData } from "./core.ts";
14
+ import { formatDuration } from "../../utils/format.ts";
15
+ import { bindAgentSummary, summarySegments, type SummarySegment } from "./core.ts";
16
+ import type { AgentSummaryData } from "./core.ts";
14
17
 
15
18
  export const AGENT_SUMMARY_ENTRY_TYPE = "agent-summary";
16
19
 
20
+ type SummaryTheme = {
21
+ fg(color: string, text: string): string;
22
+ italic(text: string): string;
23
+ getFgAnsi(color: string): string;
24
+ };
25
+
26
+ /** 取样式函数在正文前产生的 SGR 前缀;行内 reset 之后要靠它把样式续上。 */
27
+ function stylePrefix(style: (text: string) => string): string {
28
+ const sentinel = "\u0000";
29
+ const styled = style(sentinel);
30
+ const index = styled.indexOf(sentinel);
31
+ return index >= 0 ? styled.slice(0, index) : "";
32
+ }
33
+
34
+ /** 只给段内第一个数字上色;没有数字的段原样返回。 */
35
+ function paintNumber(segment: SummarySegment, theme: SummaryTheme): string {
36
+ const match = /(\d+)/.exec(segment.text);
37
+ if (!match) return segment.text;
38
+ const ansi = theme.getFgAnsi(segment.tone === "failed" ? "error" : "success");
39
+ if (!ansi) return segment.text;
40
+ const start = match.index;
41
+ return `${segment.text.slice(0, start)}${ansi}${match[1]}\x1b[0m${segment.text.slice(start + match[1].length)}`;
42
+ }
43
+
44
+ /** 摘要行:左侧 mdQuoteBorder 竖线 + 斜体 mdQuote 正文,数字按成功/失败上色。 */
45
+ class AgentSummaryLine {
46
+ private readonly body: string;
47
+ private readonly border: string;
48
+ private readonly theme: SummaryTheme;
49
+
50
+ constructor(segments: readonly SummarySegment[], duration: string, theme: SummaryTheme) {
51
+ this.theme = theme;
52
+ this.border = theme.fg("mdQuoteBorder", "│");
53
+ const quoteStyle = (text: string) => theme.italic(theme.fg("mdQuote", text));
54
+ const counts = segments.map((segment) => paintNumber(segment, theme)).join(", ");
55
+ const text = duration ? `${counts} · ${duration}` : counts;
56
+ const prefix = stylePrefix(quoteStyle);
57
+ this.body = quoteStyle(prefix ? text.replace(/\x1b\[0m/g, `\x1b[0m${prefix}`) : text);
58
+ }
59
+
60
+ invalidate(): void {}
61
+
62
+ render(width: number): string[] {
63
+ const contentWidth = Math.max(1, width - 4);
64
+ return wrapTextWithAnsi(this.body, contentWidth).map((line) => ` ${this.border} ${line}`);
65
+ }
66
+ }
67
+
17
68
  export default function (pi: ExtensionAPI): void {
18
69
  pi.registerEntryRenderer(AGENT_SUMMARY_ENTRY_TYPE, (entry, _options, theme) => {
19
- const line = summaryMarkdown(entry.data as AgentSummaryData, {
20
- success: theme.getFgAnsi("success"),
21
- failed: theme.getFgAnsi("error"),
22
- });
23
- if (!line) return undefined;
24
- return new Markdown(line, 1, 0, getMarkdownTheme());
70
+ const segments = summarySegments(entry.data as AgentSummaryData);
71
+ if (segments.length === 0) return undefined;
72
+ const duration = formatDuration((entry.data as AgentSummaryData).durationMs);
73
+ return new AgentSummaryLine(segments, duration, theme as unknown as SummaryTheme);
25
74
  });
26
75
 
27
76
  bindAgentSummary(pi, (data) => {
@@ -41,6 +41,8 @@ type AssistantMessageComponentLike = InstanceType<typeof AssistantMessageCompone
41
41
  export type CompactThinkingConfig = {
42
42
  useSummaryTitlesAsThinkingTitle: boolean;
43
43
  previewLines: number;
44
+ /** 思考完成后的折叠预览行数;null = 跟随 previewLines(保持旧行为)。 */
45
+ completedPreviewLines: number | null;
44
46
  animationIntervalMs: number;
45
47
  };
46
48
 
@@ -112,6 +114,7 @@ type PatchedPrototype = typeof AssistantMessageComponent.prototype & {
112
114
  const config: CompactThinkingConfig = {
113
115
  useSummaryTitlesAsThinkingTitle: true,
114
116
  previewLines: 3,
117
+ completedPreviewLines: null,
115
118
  animationIntervalMs: 90,
116
119
  };
117
120
 
@@ -273,11 +276,12 @@ function layoutThinkingPreview(
273
276
  text: string,
274
277
  width: number,
275
278
  padding: number,
279
+ previewLines: number,
276
280
  ): { visible: string[]; hiddenLines: number } {
277
- if (!text || config.previewLines <= 0) return { visible: [], hiddenLines: 0 };
281
+ if (!text || previewLines <= 0) return { visible: [], hiddenLines: 0 };
278
282
  const contentWidth = Math.max(1, width - padding * 2);
279
283
  const budget = Math.max(
280
- width * (config.previewLines + PREVIEW_BUDGET_SLACK_LINES),
284
+ width * (previewLines + PREVIEW_BUDGET_SLACK_LINES),
281
285
  PREVIEW_BUDGET_MIN_CHARS,
282
286
  );
283
287
  let source = text;
@@ -298,8 +302,8 @@ function layoutThinkingPreview(
298
302
  };
299
303
  cachePreview(cacheKey, entry);
300
304
  }
301
- const hiddenLines = entry.hiddenBefore + Math.max(0, entry.lines.length - config.previewLines);
302
- const visible = hiddenLines > 0 ? entry.lines.slice(-config.previewLines) : entry.lines;
305
+ const hiddenLines = entry.hiddenBefore + Math.max(0, entry.lines.length - previewLines);
306
+ const visible = hiddenLines > 0 ? entry.lines.slice(-previewLines) : entry.lines;
303
307
  return { visible, hiddenLines };
304
308
  }
305
309
 
@@ -335,6 +339,8 @@ export class ThinkingPreviewBlock implements Component {
335
339
  private style: (text: string) => string;
336
340
  private theme: Theme | undefined;
337
341
  private hintHovered = false;
342
+ /** 构造时的活动态:活动块用 previewLines,完成块用 completedPreviewLines。 */
343
+ private active: boolean;
338
344
  private collapsedMemo:
339
345
  | {
340
346
  width: number;
@@ -351,8 +357,9 @@ export class ThinkingPreviewBlock implements Component {
351
357
  padding: number,
352
358
  messageTimestamp: number,
353
359
  style: (text: string) => string,
354
- theme?: Theme,
355
- runStartIndex = 0,
360
+ theme: Theme | undefined,
361
+ runStartIndex: number,
362
+ active: boolean,
356
363
  ) {
357
364
  this.heading = heading;
358
365
  this.text = text;
@@ -361,6 +368,14 @@ export class ThinkingPreviewBlock implements Component {
361
368
  this.runStartIndex = runStartIndex;
362
369
  this.style = style;
363
370
  this.theme = theme;
371
+ this.active = active;
372
+ }
373
+
374
+ /** 活动块用 previewLines;完成块用 completedPreviewLines(null 跟随 previewLines)。 */
375
+ private effectivePreviewLines(): number {
376
+ return this.active
377
+ ? config.previewLines
378
+ : (config.completedPreviewLines ?? config.previewLines);
364
379
  }
365
380
 
366
381
  private paint(color: string, text: string): string {
@@ -393,7 +408,7 @@ export class ThinkingPreviewBlock implements Component {
393
408
  private headingLines(width: number, hiddenLines: number, padding: number): string[] {
394
409
  const hint = this.expanded
395
410
  ? undefined
396
- : hiddenPreviewHint(hiddenLines, Boolean(this.text) && config.previewLines <= 0);
411
+ : hiddenPreviewHint(hiddenLines, Boolean(this.text) && this.effectivePreviewLines() <= 0);
397
412
  if (!hint) return new Text(this.heading, padding, 0).render(width);
398
413
  const rawHint = hint.prefix + hint.action + hint.suffix;
399
414
  const contentWidth = Math.max(1, width - padding * 2);
@@ -411,7 +426,7 @@ export class ThinkingPreviewBlock implements Component {
411
426
  }
412
427
 
413
428
  private bodyLines(width: number, padding: number): { lines: string[]; hiddenLines: number } {
414
- if (!this.text || (config.previewLines <= 0 && !this.expanded)) {
429
+ if (!this.text || (this.effectivePreviewLines() <= 0 && !this.expanded)) {
415
430
  return { lines: [], hiddenLines: 0 };
416
431
  }
417
432
  if (this.expanded) {
@@ -420,7 +435,7 @@ export class ThinkingPreviewBlock implements Component {
420
435
  hiddenLines: 0,
421
436
  };
422
437
  }
423
- const preview = layoutThinkingPreview(this.text, width, padding);
438
+ const preview = layoutThinkingPreview(this.text, width, padding, this.effectivePreviewLines());
424
439
  return { lines: preview.visible, hiddenLines: preview.hiddenLines };
425
440
  }
426
441
 
@@ -447,7 +462,7 @@ export class ThinkingPreviewBlock implements Component {
447
462
  const expandAction = thinkingExpandAction();
448
463
  if (
449
464
  this.collapsedMemo?.width === width &&
450
- this.collapsedMemo.previewLines === config.previewLines &&
465
+ this.collapsedMemo.previewLines === this.effectivePreviewLines() &&
451
466
  this.collapsedMemo.hintHovered === this.hintHovered &&
452
467
  this.collapsedMemo.expandAction === expandAction
453
468
  ) {
@@ -464,7 +479,7 @@ export class ThinkingPreviewBlock implements Component {
464
479
  : heading;
465
480
  this.collapsedMemo = {
466
481
  width,
467
- previewLines: config.previewLines,
482
+ previewLines: this.effectivePreviewLines(),
468
483
  hintHovered: this.hintHovered,
469
484
  expandAction,
470
485
  lines,
@@ -715,6 +730,8 @@ function compactThinking(pi: ExtensionAPI) {
715
730
  }
716
731
  const runEndIndex = i - 1;
717
732
  i--;
733
+ // Keep this run-scoped snapshot: a timestamp-only render query would mark
734
+ // earlier thinking runs active when one message alternates text and thinking.
718
735
  const active = isActiveRun(message, runStartIndex, runEndIndex);
719
736
  // OpenAI can spend several seconds reasoning before it emits the first
720
737
  // summary token. Keep an empty active block visible as animated
@@ -759,6 +776,7 @@ function compactThinking(pi: ExtensionAPI) {
759
776
  thinkingStyle,
760
777
  activeTheme,
761
778
  runStartIndex,
779
+ active,
762
780
  ),
763
781
  );
764
782
 
@@ -13,6 +13,8 @@ export const FOOTER_CONTEXT_KEY = "context";
13
13
  export const FOOTER_CACHE_KEY = "cache";
14
14
  export const FOOTER_RATE_KEY = "rate";
15
15
  export const FOOTER_COST_KEY = "cost";
16
+ /** 本次对话累计消耗 token(累加会话条目的 usage.totalTokens)。 */
17
+ export const FOOTER_TOKENS_KEY = "tokens";
16
18
  export const FOOTER_CWD_KEY = "cwd";
17
19
  export const FOOTER_GIT_KEY = "git";
18
20
 
@@ -23,6 +25,7 @@ export const DEFAULT_FOOTER_LINE1_KEYS = [
23
25
  FOOTER_CACHE_KEY,
24
26
  FOOTER_RATE_KEY,
25
27
  FOOTER_COST_KEY,
28
+ FOOTER_TOKENS_KEY,
26
29
  PI_USAGE_KEY,
27
30
  ] as const;
28
31
 
@@ -40,7 +43,18 @@ const LEGACY_FIXED_FOOTER_KEYS = [
40
43
  FOOTER_GIT_KEY,
41
44
  ] as const;
42
45
 
43
- const BUILTIN_FOOTER_KEYS = new Set<string>(LEGACY_FIXED_FOOTER_KEYS);
46
+ /**
47
+ * 后加的内置芯片:老配置里不会出现,读取时补到默认行上 `after` 那个芯片后面。
48
+ * 与 LEGACY_FIXED_FOOTER_KEYS 分开,旧配置的「只排插件芯片」迁移判断才不会误判。
49
+ */
50
+ const ADDED_FOOTER_KEYS: ReadonlyArray<{ key: string; after: string }> = [
51
+ { key: FOOTER_TOKENS_KEY, after: FOOTER_COST_KEY },
52
+ ];
53
+
54
+ const BUILTIN_FOOTER_KEYS = new Set<string>([
55
+ ...LEGACY_FIXED_FOOTER_KEYS,
56
+ ...ADDED_FOOTER_KEYS.map((item) => item.key),
57
+ ]);
44
58
 
45
59
  export function isBuiltinFooterKey(key: string): boolean {
46
60
  return BUILTIN_FOOTER_KEYS.has(key);
@@ -62,9 +76,12 @@ export const DEFAULT_FOOTER_CHIP_LAYOUT: FooterChipLayout = {
62
76
  footerLine3Keys: [],
63
77
  };
64
78
 
65
- /** 别的扩展写的 model 状态与本扩展的模型芯片重名,不当插件芯片接纳。配置里的 model 不受影响。 */
79
+ /**
80
+ * 别的扩展写的 model / tokens 状态与本扩展的内置芯片重名,不当插件芯片接纳。
81
+ * 配置里的同名内置 key 不受影响。
82
+ */
66
83
  export function isSkippedFooterStatusKey(key: string): boolean {
67
- return key === FOOTER_MODEL_KEY;
84
+ return key === FOOTER_MODEL_KEY || key === FOOTER_TOKENS_KEY;
68
85
  }
69
86
 
70
87
  /** 未单独配置时的默认行:内置项按原来的行,usage 类在 line1,其余 line2。不会默认进 line3。 */
@@ -113,7 +130,10 @@ function ensureBuiltinKeys(
113
130
  if (LEGACY_FIXED_FOOTER_KEYS.some((key) => seen.has(key))) {
114
131
  return { footerLine1Keys: line1, footerLine2Keys: line2, footerLine3Keys: line3 };
115
132
  }
116
- const line1Builtins = DEFAULT_FOOTER_LINE1_KEYS.filter((key) => key !== PI_USAGE_KEY);
133
+ // tokens 属于后加芯片,由 ensureAddedBuiltins 负责插入。
134
+ const line1Builtins = DEFAULT_FOOTER_LINE1_KEYS.filter(
135
+ (key) => key !== PI_USAGE_KEY && key !== FOOTER_TOKENS_KEY,
136
+ );
117
137
  const usageAt = line1.indexOf(PI_USAGE_KEY);
118
138
  const footerLine1Keys =
119
139
  usageAt >= 0
@@ -126,6 +146,33 @@ function ensureBuiltinKeys(
126
146
  };
127
147
  }
128
148
 
149
+ function ensureAddedBuiltins(layout: FooterChipLayout): FooterChipLayout {
150
+ const present = new Set([
151
+ ...layout.footerLine1Keys,
152
+ ...layout.footerLine2Keys,
153
+ ...layout.footerLine3Keys,
154
+ ...layout.footerHiddenKeys,
155
+ ]);
156
+ const missing = ADDED_FOOTER_KEYS.filter((item) => !present.has(item.key));
157
+ if (missing.length === 0) return layout;
158
+ const next: FooterChipLayout = {
159
+ ...layout,
160
+ footerLine1Keys: [...layout.footerLine1Keys],
161
+ footerLine2Keys: [...layout.footerLine2Keys],
162
+ footerLine3Keys: [...layout.footerLine3Keys],
163
+ };
164
+ for (const item of missing) {
165
+ // 贴着锚点芯片所在的行放(如 tokens 跟 cost)。
166
+ const line = footerLineOfKey(next, item.after) ?? defaultFooterLine(item.key);
167
+ const keys = keysOnLine(next, line);
168
+ const anchorAt = keys.indexOf(item.after);
169
+ // 锚点被用户删掉时接到该行末尾:芯片留在布局里,面板菜单与开关才管得到。
170
+ // 默认关闭,不渲染就不占位,多出来的 key 不会让底栏多一行。
171
+ keys.splice(anchorAt >= 0 ? anchorAt + 1 : keys.length, 0, item.key);
172
+ }
173
+ return next;
174
+ }
175
+
129
176
  /** 跨行去重(line1 → line2 → line3);旧配置补上内置芯片。 */
130
177
  export function normalizeFooterChipLayout(source: Record<string, unknown>): FooterChipLayout {
131
178
  const hasHidden = Array.isArray(source.footerHiddenKeys);
@@ -144,10 +191,10 @@ export function normalizeFooterChipLayout(source: Record<string, unknown>): Foot
144
191
  const line1 = takeUnique(normalizeFooterKeyList(source.footerLine1Keys), seen);
145
192
  const line2 = takeUnique(normalizeFooterKeyList(source.footerLine2Keys), seen);
146
193
  const line3 = takeUnique(normalizeFooterKeyList(source.footerLine3Keys), seen);
147
- return {
194
+ return ensureAddedBuiltins({
148
195
  footerHiddenKeys: normalizeFooterKeyList(source.footerHiddenKeys),
149
196
  ...ensureBuiltinKeys(line1, line2, line3),
150
- };
197
+ });
151
198
  }
152
199
 
153
200
  const BUILTIN_FOOTER_LABEL: Record<string, string> = {
@@ -156,6 +203,7 @@ const BUILTIN_FOOTER_LABEL: Record<string, string> = {
156
203
  [FOOTER_CACHE_KEY]: "Cache hit rate",
157
204
  [FOOTER_RATE_KEY]: "Output rate",
158
205
  [FOOTER_COST_KEY]: "Session cost",
206
+ [FOOTER_TOKENS_KEY]: "Tokens used in this conversation",
159
207
  [FOOTER_CWD_KEY]: "Working directory and session name",
160
208
  [FOOTER_GIT_KEY]: "Branch and change stats",
161
209
  };
@@ -4,7 +4,7 @@
4
4
  * 默认第一行:model thinking · ██████░░░░ 45%/200k · 󰆼 42% · $0.01 · pi-usage · 其余 line1 芯片
5
5
  * 默认第二行:cwd in session ·  branch (+16 −1) · 其余 line2 芯片
6
6
  * 第三行:仅当 line3 有可见芯片时出现
7
- * model / context / cache / rate / cost / cwd / git 与插件芯片同一套排布,可换行、调序、隐藏
7
+ * model / context / cache / rate / cost / tokens / cwd / git 与插件芯片同一套排布,可换行、调序、隐藏
8
8
  *
9
9
  * - line1 短芯片,· 分隔;缓存用 zentui 󰆼,费用 success
10
10
  * - line2 用 zentui 句式 in / on + 
@@ -31,6 +31,7 @@ import {
31
31
  FOOTER_GIT_KEY,
32
32
  FOOTER_MODEL_KEY,
33
33
  FOOTER_RATE_KEY,
34
+ FOOTER_TOKENS_KEY,
34
35
  PI_USAGE_KEY,
35
36
  isBuiltinFooterKey,
36
37
  isSkippedFooterStatusKey,
@@ -39,6 +40,7 @@ import {
39
40
  type FooterChipLayout,
40
41
  } from "./footer-layout.ts";
41
42
  import { createGitStatsRefresher, readGitStats, type GitStats } from "./git-stats.ts";
43
+ import { formatSessionTokenTotal, sessionEntryTokens } from "./session-tokens.ts";
42
44
  import { MCP_STATUS_KEY, buildMcpChip } from "./mcp-chip.ts";
43
45
  import { inputTextIndent } from "./prompt-editor.ts";
44
46
  import { currentTtftMs, displayTokenRate, formatTokenRate } from "./token-rate.ts";
@@ -391,7 +393,12 @@ const createCustomFooterFactory =
391
393
  let statsMemoKey = "";
392
394
  let statsMemoAt = 0;
393
395
  let statsMemo:
394
- | { contextUsage: ReturnType<typeof ctx.getContextUsage>; cost: number; cachePct: number }
396
+ | {
397
+ contextUsage: ReturnType<typeof ctx.getContextUsage>;
398
+ cost: number;
399
+ tokens: number;
400
+ cachePct: number;
401
+ }
395
402
  | undefined;
396
403
  const sessionStats = () => {
397
404
  const model = ctx.model;
@@ -404,19 +411,28 @@ const createCustomFooterFactory =
404
411
  return statsMemo;
405
412
  }
406
413
  let cost = 0;
414
+ let tokens = 0;
407
415
  for (const entry of ctx.sessionManager.getEntries()) {
408
416
  if (entry.type === "message") {
409
417
  const m = entry.message;
410
418
  if (m.role === "assistant") cost += m.usage?.cost?.total || 0;
411
419
  else if (m.role === "toolResult" && m.usage) cost += m.usage.cost?.total || 0;
412
420
  } else if (
413
- (entry.type === "branch_summary" || entry.type === "compaction") &&
421
+ (entry.type === "branch_summary" ||
422
+ entry.type === "compaction" ||
423
+ entry.type === "usage") &&
414
424
  entry.usage
415
425
  ) {
416
426
  cost += entry.usage.cost?.total || 0;
417
427
  }
428
+ tokens += sessionEntryTokens(entry);
418
429
  }
419
- statsMemo = { contextUsage: ctx.getContextUsage(), cost, cachePct: getCachePct() };
430
+ statsMemo = {
431
+ contextUsage: ctx.getContextUsage(),
432
+ cost,
433
+ tokens,
434
+ cachePct: getCachePct(),
435
+ };
420
436
  statsMemoKey = key;
421
437
  statsMemoAt = performance.now();
422
438
  return statsMemo;
@@ -424,7 +440,7 @@ const createCustomFooterFactory =
424
440
 
425
441
  const render = (width: number): string[] => {
426
442
  const model = ctx.model;
427
- const { contextUsage, cost, cachePct } = sessionStats();
443
+ const { contextUsage, cost, tokens, cachePct } = sessionStats();
428
444
  const contextWindow = contextUsage?.contextWindow ?? model?.contextWindow ?? 0;
429
445
  const percent = contextUsage?.percent;
430
446
 
@@ -471,6 +487,7 @@ const createCustomFooterFactory =
471
487
  const costChip = costLabel
472
488
  ? theme.fg("dim", costLabel) + (usingSubscription ? theme.fg("warning", " sub") : "")
473
489
  : "";
490
+ const tokensLabel = config.footerSessionTokens ? formatSessionTokenTotal(tokens) : "";
474
491
 
475
492
  const gitColor = theme.getThinkingBorderColor("medium");
476
493
  let place = theme.fg("accent", cwdLabel);
@@ -508,6 +525,7 @@ const createCustomFooterFactory =
508
525
  [FOOTER_CACHE_KEY, cacheLabel ? theme.fg("dim", cacheLabel) : ""],
509
526
  [FOOTER_RATE_KEY, rateLabel ? theme.fg("dim", rateLabel) : ""],
510
527
  [FOOTER_COST_KEY, costChip],
528
+ [FOOTER_TOKENS_KEY, tokensLabel ? theme.fg("dim", tokensLabel) : ""],
511
529
  [FOOTER_CWD_KEY, place],
512
530
  [FOOTER_GIT_KEY, gitChip],
513
531
  ]);
@@ -0,0 +1,52 @@
1
+ /**
2
+ * footer 的会话累计 token:统计 `sessionManager.getEntries()` 里每条条目的 usage。
3
+ *
4
+ * 口径是 `usage.totalTokens` 累加(输入 + 输出 + 缓存读 + 缓存写),
5
+ * 覆盖 assistant / toolResult 消息,以及 compaction、branch_summary 与 `usage` 条目
6
+ * 自己那次 LLM 调用的开销。整个会话全量累加,不沿当前分支裁剪:
7
+ * 回退/重发放弃的节点也算进「这次对话烧掉了多少 token」。
8
+ */
9
+
10
+ import { formatTokenCount } from "../../utils/format.ts";
11
+
12
+ type UnknownRecord = Record<string, unknown>;
13
+
14
+ function asRecord(value: unknown): UnknownRecord | undefined {
15
+ return value && typeof value === "object" ? (value as UnknownRecord) : undefined;
16
+ }
17
+
18
+ /** `usage.totalTokens` 优先;缺失时用四项相加,非数字一律忽略。 */
19
+ export function usageTokenTotal(usage: unknown): number {
20
+ const record = asRecord(usage);
21
+ if (!record) return 0;
22
+ const total = record.totalTokens;
23
+ if (typeof total === "number" && Number.isFinite(total) && total > 0) return total;
24
+ let sum = 0;
25
+ for (const key of ["input", "output", "cacheRead", "cacheWrite"] as const) {
26
+ const part = record[key];
27
+ if (typeof part === "number" && Number.isFinite(part) && part > 0) sum += part;
28
+ }
29
+ return sum;
30
+ }
31
+
32
+ /** 一条会话条目贡献的 token;user/system/自定义条目不消费 LLM token,返回 0。 */
33
+ export function sessionEntryTokens(entry: unknown): number {
34
+ const record = asRecord(entry);
35
+ if (!record) return 0;
36
+ if (record.type === "message") {
37
+ const message = asRecord(record.message);
38
+ if (!message) return 0;
39
+ if (message.role !== "assistant" && message.role !== "toolResult") return 0;
40
+ return usageTokenTotal(message.usage);
41
+ }
42
+ if (record.type === "compaction" || record.type === "branch_summary" || record.type === "usage") {
43
+ return usageTokenTotal(record.usage);
44
+ }
45
+ return 0;
46
+ }
47
+
48
+ /** 芯片文案:`∑ 402k`;没有数据返回空串(不占位置)。 */
49
+ export function formatSessionTokenTotal(total: number): string {
50
+ if (!Number.isFinite(total) || total <= 0) return "";
51
+ return `∑ ${formatTokenCount(Math.round(total))}`;
52
+ }
@@ -8,7 +8,8 @@
8
8
  * Agent/Task 族:调用只进摘要计数,tool 卡始终折叠(避免 pending→完成高度闪动)。
9
9
  * edit/write 与 ask_user_question 留在外层:不进摘要计数,折叠态也单独成行。
10
10
  * 外层行一旦在消息里出现,当前回合就地完结(不计静默期),后续消息另起回合:
11
- * 摘要行因此只折叠连续的一段工具调用,展开/收起不再跨外层行搬位置。
11
+ * 摘要行因此只折叠连续的一段工具调用。收尾后本回合的外层行排在摘要行之下,
12
+ * 摘要行覆盖的是它们之前的工具段;回合还在跑时它们留在原位,摘要行钉在可写底缘。
12
13
  * 用户消息是回合的硬边界:steer 消息插进回合中间、或上一回合没正文就收尾时,回合
13
14
  * 就地收口、后续消息另起一回合。custom 通知/summary 类组件既不是成员也不算外来行,
14
15
  * 原地留在两段之间,不打断统计也不被挪位。
@@ -1211,6 +1212,8 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1211
1212
  liveSlotToolIds: Set<string>;
1212
1213
  /** 收尾后的静默期截止时间戳;期间仍按围观态渲染,到点才收拢。 */
1213
1214
  settleUntil?: number;
1215
+ /** 已请求收尾但回合还有活干(消息仍在流式、工具还在跑):活干完再兑现。 */
1216
+ endRequest?: { endTimestamp?: number };
1214
1217
  /** 消息已有 toolCall、工具组件还没到:等它一 updateDisplay 就重画槽位。 */
1215
1218
  pendingLiveTool?: boolean;
1216
1219
  foldTimer?: ReturnType<typeof setTimeout>;
@@ -1235,6 +1238,19 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1235
1238
  let idleRef: (() => boolean) | undefined = deps.isIdle;
1236
1239
  let roundTickTimer: ReturnType<typeof setInterval> | undefined;
1237
1240
 
1241
+ /** 本进程见过的回合:活动回合、本次 pass 建起的、收尾静默期的、还挂着尾行的。 */
1242
+ const allRounds = (): CompactRound[] => {
1243
+ const rounds = new Set<CompactRound>();
1244
+ if (activeRound) rounds.add(activeRound);
1245
+ for (const round of liveRounds) rounds.add(round);
1246
+ for (const round of pendingFoldRounds) rounds.add(round);
1247
+ for (const tail of mountedTails) {
1248
+ const round = tail.round as CompactRound | undefined;
1249
+ if (round) rounds.add(round);
1250
+ }
1251
+ return [...rounds];
1252
+ };
1253
+
1238
1254
  /** 会话是否空闲:回合活性的事实来源,取不到就当没空闲(宁可保留 Running)。 */
1239
1255
  const sessionIdle = (): boolean => {
1240
1256
  try {
@@ -1263,14 +1279,23 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1263
1279
  roundTickTimer = undefined;
1264
1280
  };
1265
1281
 
1282
+ /** 还有回合要盯就得继续 tick:活动回合的时长要跳,挂起的收尾请求要等活干完。 */
1283
+ const roundTickNeeded = (): boolean => {
1284
+ for (const round of allRounds()) {
1285
+ if (round.active || round.endRequest !== undefined) return true;
1286
+ }
1287
+ return false;
1288
+ };
1289
+
1266
1290
  // 自备 tick:getter 在 render 重算挂钟;兼驱动 Running 扫光(setCompactSummaryActive)。
1267
1291
  const ensureRoundTick = (): void => {
1268
1292
  if (roundTickTimer) return;
1269
1293
  roundTickTimer = setInterval(() => {
1270
- if (!patch.active || !activeRound?.active) {
1294
+ if (!patch.active || !roundTickNeeded()) {
1271
1295
  stopRoundTick();
1272
1296
  return;
1273
1297
  }
1298
+ for (const round of allRounds()) retryDeferredFinalize(round);
1274
1299
  settleStrandedRounds();
1275
1300
  try {
1276
1301
  // 非 force:保留 fullscreen 布局缓存和差分绘制。
@@ -1302,9 +1327,15 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1302
1327
  value <= Date.now() + 60_000;
1303
1328
 
1304
1329
  /** 结束回合活动态;不做任何重绘。更晚的消息 timestamp 用来保住回放时长;
1305
- * 同一条消息的 timestamp 不动,活回合改用本进程挂钟,避免把历史 timestamp 收到「现在」。 */
1306
- const finalizeRound = (round: CompactRound, endTimestamp?: number): void => {
1330
+ * 同一条消息的 timestamp 不动,活回合改用本进程挂钟,避免把历史 timestamp 收到「现在」。
1331
+ * 回合还有活干时只记下收尾请求:提前翻成 Ran for 会把仍在跑的工具报成已结束。 */
1332
+ const finalizeRound = (round: CompactRound, endTimestamp?: number): boolean => {
1307
1333
  clearFoldTimer(round);
1334
+ if (roundHasLiveWork(round)) {
1335
+ round.endRequest ??= { endTimestamp };
1336
+ return false;
1337
+ }
1338
+ round.endRequest = undefined;
1308
1339
  round.active = false;
1309
1340
  if (round.endedAt === undefined) {
1310
1341
  let fallback = 0;
@@ -1321,22 +1352,26 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1321
1352
  }
1322
1353
  if (activeRound === round) {
1323
1354
  activeRound = undefined;
1324
- deps.query?.setCompactSummaryActive?.(false);
1325
- stopRoundTick();
1355
+ // 还有回合在跑(另一个活动回合或挂起的收尾):扫光与 tick 不能跟着一起停。
1356
+ const stillLive = roundTickNeeded();
1357
+ deps.query?.setCompactSummaryActive?.(stillLive);
1358
+ if (!stillLive) stopRoundTick();
1326
1359
  }
1360
+ return true;
1327
1361
  };
1328
1362
 
1329
1363
  /**
1330
1364
  * 结束回合活动态;render=true 时走静默期,延迟 FOLD_SETTLE_MS 再收拢。
1331
1365
  * 分组语义不受影响:activeRound 立即让位,新消息不会被并进已收尾的回合。
1366
+ * 还有活干时整段跳过:回合保持 live,等活干完再收尾。
1332
1367
  */
1333
1368
  const endRound = (round: CompactRound, render = false, endTimestamp?: number): void => {
1334
- if (!render || !round.active) {
1335
- finalizeRound(round, endTimestamp);
1369
+ const wasActive = round.active;
1370
+ if (!finalizeRound(round, endTimestamp)) return;
1371
+ if (!render || !wasActive) {
1336
1372
  if (render) renderRound(round);
1337
1373
  return;
1338
1374
  }
1339
- finalizeRound(round, endTimestamp);
1340
1375
  if (round.foldTimer !== undefined) return;
1341
1376
  round.settleUntil = Date.now() + FOLD_SETTLE_MS;
1342
1377
  pendingFoldRounds.add(round);
@@ -1348,10 +1383,23 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1348
1383
  }, FOLD_SETTLE_MS);
1349
1384
  };
1350
1385
 
1386
+ /**
1387
+ * 兑现挂起的收尾请求:活干完了当场收尾并重绘,摘要行从 Running 翻成 Ran for。
1388
+ * 挂在工具收尾与 tick 上,不另建定时器。
1389
+ */
1390
+ const retryDeferredFinalize = (round: CompactRound): boolean => {
1391
+ const request = round.endRequest;
1392
+ if (!request || roundHasLiveWork(round)) return false;
1393
+ endRound(round, true, request.endTimestamp);
1394
+ renderRound(round);
1395
+ return true;
1396
+ };
1397
+
1351
1398
  /**
1352
1399
  * 外层行(edit/write/ask)切出的段落封口:跳过静默期直接定稿。
1353
1400
  * 静默期是为"正文 token 先于 toolCall 到达"的双重转场准备的;外层边界
1354
1401
  * 出现时摘要段内容已完整,立即收拢让 Ran for 当场落位,槽位卡一并撤走。
1402
+ * 本回合还有工具在跑时只挂起收尾:摘要行继续 Running,活干完再定稿。
1355
1403
  */
1356
1404
  const sealRound = (round: CompactRound, endTimestamp?: number): void => {
1357
1405
  finalizeRound(round, endTimestamp);
@@ -1428,6 +1476,24 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1428
1476
  return ids;
1429
1477
  };
1430
1478
 
1479
+ /**
1480
+ * 回合还有活干:成员消息仍在流式,或本回合的工具还没拿到最终结果。
1481
+ * 收尾要么按事实等活干完,要么摘要行就会把仍在跑的工具报成 Ran for。
1482
+ */
1483
+ const roundHasLiveWork = (round: CompactRound): boolean => {
1484
+ for (const component of round.messages.keys()) {
1485
+ if (component?.isStreaming === true) return true;
1486
+ }
1487
+ const ids = roundToolCallIds(round);
1488
+ if (ids.size === 0) return false;
1489
+ for (const tool of trackedToolComponents) {
1490
+ if (typeof tool?.toolCallId === "string" && ids.has(tool.toolCallId) && isRunningTool(tool)) {
1491
+ return true;
1492
+ }
1493
+ }
1494
+ return false;
1495
+ };
1496
+
1431
1497
  /**
1432
1498
  * 摘要行有东西可汇总才挂:普通工具(edit/write/ask 之外)或非空思考。
1433
1499
  * 只剩外层卡的回合不挂——那些卡各自能展开,空摘要点开只会跳位。
@@ -1602,6 +1668,12 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1602
1668
  return tail;
1603
1669
  };
1604
1670
 
1671
+ /** 它们排在摘要行之下;定位时不把它们当摘要行之前的成员。 */
1672
+ const isOuterToolRow = (ids: Set<string>, value: any): boolean =>
1673
+ typeof value?.toolCallId === "string" &&
1674
+ ids.has(value.toolCallId) &&
1675
+ OUTER_TOOLS.has(String(value.toolName ?? ""));
1676
+
1605
1677
  /**
1606
1678
  * 尾行归位。目标位:活回合(运行中/收尾静默期)且未展开 → 容器最末,
1607
1679
  * 计数行钉在 transcript 可写底缘,运行中到达的提示行/custom 条目只能落在
@@ -1621,22 +1693,33 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1621
1693
  if (!container || !Array.isArray(container.children)) return;
1622
1694
  tail.container = container;
1623
1695
  const children = container.children as any[];
1696
+ const ids = roundToolCallIds(round);
1697
+ const live = roundLive(round);
1624
1698
  const isMember = roundMemberMatcher(round);
1699
+ // 收尾后,本回合的外露行(edit/write/ask)一律排到摘要行之下;
1700
+ // 回合还在跑时它们留在原位,摘要行才能钉在可写底缘。
1701
+ const staysAbove = (value: any): boolean =>
1702
+ isMember(value) && (!isOuterToolRow(ids, value) || live);
1625
1703
  const current = children.indexOf(tail.host);
1626
1704
  // 摘尾行前先定目标位,已在位就整段跳过。
1627
1705
  let last = -1;
1628
1706
  for (let i = 0; i < children.length; i++) {
1629
- if (children[i] === tail.host || !isMember(children[i])) continue;
1707
+ if (children[i] === tail.host || !staysAbove(children[i])) continue;
1630
1708
  last = i;
1631
1709
  }
1632
- const pinToEnd = roundLive(round) && round.anchor?.expanded !== true;
1710
+ // 外露行压在摘要行上方时仍要重排:尾行就算在目标位上也得让位。
1711
+ const outerAbove =
1712
+ !live &&
1713
+ current > 0 &&
1714
+ children.some((value: any, index: number) => index < current && isOuterToolRow(ids, value));
1715
+ const pinToEnd = live && round.anchor?.expanded !== true;
1633
1716
  const desired = pinToEnd || last < 0 ? children.length - 1 : last + 1;
1634
- if (current === desired) return;
1717
+ if (current === desired && !outerAbove) return;
1635
1718
  if (current >= 0) children.splice(current, 1);
1636
1719
  let first = -1;
1637
1720
  last = -1;
1638
1721
  for (let i = 0; i < children.length; i++) {
1639
- if (!isMember(children[i])) continue;
1722
+ if (!staysAbove(children[i])) continue;
1640
1723
  if (first < 0) first = i;
1641
1724
  last = i;
1642
1725
  }
@@ -1646,7 +1729,7 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1646
1729
  // 用户消息永远留在原地:挪走会把别人的消息挪到用户消息下面去。
1647
1730
  // custom 通知同理:它是回合内的透明行,不被统计也不挪位。
1648
1731
  if (
1649
- isMember(children[i]) ||
1732
+ staysAbove(children[i]) ||
1650
1733
  isUserMessageComponent(children[i]) ||
1651
1734
  isTransparentComponent(children[i])
1652
1735
  ) {
@@ -1681,8 +1764,10 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1681
1764
  */
1682
1765
  const syncTailForTool = (toolCallId: string): void => {
1683
1766
  if (!toolCallId) return;
1684
- for (const round of [activeRound, ...pendingFoldRounds]) {
1685
- if (!round || !roundToolCallIds(round).has(toolCallId)) continue;
1767
+ for (const round of allRounds()) {
1768
+ if (!roundToolCallIds(round).has(toolCallId)) continue;
1769
+ // 工具收尾后活干完了:挂起的收尾请求当场兑现,摘要行立刻翻成 Ran for。
1770
+ if (retryDeferredFinalize(round)) return;
1686
1771
  // 槽位是在消息更新时画的,当时工具组件还不存在:组件到了就补上。
1687
1772
  // 参数流式更新也走这里,槽位卡跟着换内容。
1688
1773
  if (round.pendingLiveTool || roundLive(round)) renderRound(round);
@@ -2288,6 +2373,14 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
2288
2373
  if (hasText) {
2289
2374
  const round = roundByComponent.get(this);
2290
2375
  const previousMessage = round?.messages.get(this);
2376
+ // 消息还在流式时正文不是回合边界:同一条消息常常先出正文、再出 toolCall,
2377
+ // 这时收尾会在下一帧另起一条摘要行,一段工作被拆成两条。
2378
+ // 正文先按成员渲染,等消息收尾(isStreaming=false)再判。
2379
+ if (round && previousMessage && self.isStreaming === true) {
2380
+ round.messages.set(this, message);
2381
+ renderRound(round);
2382
+ return undefined;
2383
+ }
2291
2384
  if (round && previousMessage && (round.anchor !== this || round.messages.size > 1)) {
2292
2385
  // 最终回答开始后,当前组件恢复原生文本;它已完成的 thinking
2293
2386
  // 留在上一轮摘要中,避免再次生成独立 Thought 行。
@@ -84,7 +84,7 @@ export async function executeWriteWithMetadata(
84
84
  // 覆盖层只揽写前快照:路径解析(含 Windows 下 /mnt/<drive>、/cygdrive/<drive> 转换)、
85
85
  // 目录创建、mutation queue 与错误文案全交回 pi 官方 write,参数与官方逐字一致。
86
86
  const definition = createWriteToolDefinition(cwd, {
87
- operations: writeSnapshotOperations(store, toolCallId),
87
+ operations: writeSnapshotOperations(store, toolCallId, signal),
88
88
  }) as any;
89
89
  try {
90
90
  await definition.execute(toolCallId, params, signal, undefined, { cwd });
@@ -101,12 +101,16 @@ export async function executeWriteWithMetadata(
101
101
  function writeSnapshotOperations(
102
102
  store: WriteExecutionMetadataStore,
103
103
  toolCallId: string,
104
+ signal: AbortSignal | undefined,
104
105
  ): WriteOperations {
105
106
  return {
106
107
  mkdir: (dir) => mkdir(dir, { recursive: true }).then(() => {}),
107
108
  writeFile: async (absolutePath, content) => {
108
109
  store.delete(toolCallId);
109
110
  const metadata = await capturePreviousContent(absolutePath);
111
+ // 官方只在进 operations 之前查 abort(紧接着这次调用,中间没有 await),
112
+ // 快照读盘期间的取消只能在这里补:抛错,不写文件,也不留 metadata。
113
+ if (signal?.aborted) throw new Error("Operation aborted");
110
114
  await writeFile(absolutePath, content, "utf8");
111
115
  // 写已生效:后续 abort 不得把这次写报成失败,快照先落下。
112
116
  store.set(toolCallId, metadata);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-cc-extensions",
3
- "version": "1.0.12",
3
+ "version": "1.0.14",
4
4
  "license": "MIT",
5
5
  "description": "Claude Code-style TUI output with some personal touches and handy utilities.",
6
6
  "type": "module",