pi-cc-extensions 1.0.12 → 1.0.13

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
+ }
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.13",
4
4
  "license": "MIT",
5
5
  "description": "Claude Code-style TUI output with some personal touches and handy utilities.",
6
6
  "type": "module",