pi-cc-extensions 1.0.0 → 1.0.1
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 +2 -0
- package/README.md +3 -1
- package/extensions/config/config.ts +16 -0
- package/extensions/config/panel.ts +47 -0
- package/extensions/feature/shell/footer.ts +54 -10
- package/extensions/feature/shell/token-rate.ts +192 -0
- package/extensions/feature/shell/working-message.ts +52 -65
- package/extensions/renderer/tool/grouping.ts +3 -16
- package/extensions/utils/format.ts +9 -0
- package/package.json +1 -1
package/README.en.md
CHANGED
|
@@ -81,6 +81,8 @@ Renderer snapshots for `on` / `compact` modes: [default](./docs/tool-render-exam
|
|
|
81
81
|
|
|
82
82
|
// footer
|
|
83
83
|
"enableCustomFooter": true, // custom status bar
|
|
84
|
+
"footerTokenRate": true, // output rate chip; false hides it
|
|
85
|
+
"footerTokenRateMode": "average", // average: whole-prompt avg; live: trailing 1s rate
|
|
84
86
|
"footerCurrency": "USD", // display currency for the footer cost
|
|
85
87
|
"footerCurrencyRate": null, // display units per 1 cost unit; null changes the symbol only
|
|
86
88
|
"footerNerdIcons": true, // Nerd Font glyphs for git/cache; false = plain text
|
package/README.md
CHANGED
|
@@ -63,7 +63,7 @@ pi install git:github.com/minuque/pi-cc-extensions
|
|
|
63
63
|
"expandedCardBackground": "userMessageBg", // 展开卡背景槽位;userMessageBg 与用户消息同色,toolPendingBg 为中性灰
|
|
64
64
|
"toolLabelClip": 0, // 工具摘要 path/command 折叠字符数;0 = 按可用宽度
|
|
65
65
|
"showStartupHeader": true, // 启动头(logo + tips)开关
|
|
66
|
-
"stickyUserMessage": true,
|
|
66
|
+
"stickyUserMessage": true, // fullscreen:userMessage 正文首行滚出视口上沿后钉住,直到下一条指令接手
|
|
67
67
|
|
|
68
68
|
// diff
|
|
69
69
|
"diffViewMode": "auto", // 布局:auto / split / unified
|
|
@@ -81,6 +81,8 @@ pi install git:github.com/minuque/pi-cc-extensions
|
|
|
81
81
|
|
|
82
82
|
// footer
|
|
83
83
|
"enableCustomFooter": true, // 自定义状态栏
|
|
84
|
+
"footerTokenRate": true, // 输出速率芯片;false 隐藏
|
|
85
|
+
"footerTokenRateMode": "average", // average:整段指令平均(avg);live:最近 1 秒瞬时(live)
|
|
84
86
|
"footerCurrency": "USD", // 底栏费用的显示币种
|
|
85
87
|
"footerCurrencyRate": null, // 1 单位费用换成多少显示币种;null 只换符号、不乘倍率
|
|
86
88
|
"footerNerdIcons": true, // git/缓存用 Nerd Font 图标;false 为纯文本
|
|
@@ -14,6 +14,7 @@ export type CompactStyleMode = "on" | "compact" | "off";
|
|
|
14
14
|
export type DiffViewMode = "auto" | "split" | "unified";
|
|
15
15
|
export type DiffIndicatorMode = "bars" | "classic" | "none";
|
|
16
16
|
export type FooterGitStatsMode = "working" | "branch";
|
|
17
|
+
export type FooterTokenRateMode = "average" | "live";
|
|
17
18
|
|
|
18
19
|
export interface ToolDisplayConfig {
|
|
19
20
|
diffViewMode: DiffViewMode;
|
|
@@ -74,6 +75,10 @@ export type Config = {
|
|
|
74
75
|
enableWorkingMessage: boolean;
|
|
75
76
|
enableAliases: boolean;
|
|
76
77
|
enableCustomFooter: boolean;
|
|
78
|
+
/** footer 输出速率芯片。关则不显示。 */
|
|
79
|
+
footerTokenRate: boolean;
|
|
80
|
+
/** average:整段指令墙钟平均;live:最近 1 秒瞬时速率,空档回退平均。 */
|
|
81
|
+
footerTokenRateMode: FooterTokenRateMode;
|
|
77
82
|
/** ISO 4217 显示币种。USD 时不换算。 */
|
|
78
83
|
footerCurrency: string;
|
|
79
84
|
/** 1 单位费用对应的显示币种数量。null 只换符号、不乘倍率。 */
|
|
@@ -93,6 +98,7 @@ const LEGACY_CONFIG_PATH = join(AGENT_DIR, "claude-code-style.json");
|
|
|
93
98
|
export const DIFF_VIEW_MODES: DiffViewMode[] = ["auto", "split", "unified"];
|
|
94
99
|
export const DIFF_INDICATOR_MODES: DiffIndicatorMode[] = ["bars", "classic", "none"];
|
|
95
100
|
export const FOOTER_GIT_STATS_MODES: FooterGitStatsMode[] = ["working", "branch"];
|
|
101
|
+
export const FOOTER_TOKEN_RATE_MODES: FooterTokenRateMode[] = ["average", "live"];
|
|
96
102
|
export const DIFF_SPLIT_MIN_WIDTH_VALUES = ["80", "100", "120", "140", "160", "180"];
|
|
97
103
|
export const DIFF_COLLAPSED_LINES_VALUES = ["0", "12", "24", "36", "48", "80", "120"];
|
|
98
104
|
/** Write collapsed presets. 0 = stats only (`+N -0` + expand hint). */
|
|
@@ -163,6 +169,8 @@ export const DEFAULT_CONFIG: Config = {
|
|
|
163
169
|
enableWorkingMessage: true,
|
|
164
170
|
enableAliases: true,
|
|
165
171
|
enableCustomFooter: true,
|
|
172
|
+
footerTokenRate: true,
|
|
173
|
+
footerTokenRateMode: "average",
|
|
166
174
|
footerCurrency: "USD",
|
|
167
175
|
footerCurrencyRate: null,
|
|
168
176
|
footerNerdIcons: true,
|
|
@@ -293,6 +301,12 @@ export function normalizeConfig(input: unknown): Config {
|
|
|
293
301
|
enableWorkingMessage: source.enableWorkingMessage !== false,
|
|
294
302
|
enableAliases: source.enableAliases !== false,
|
|
295
303
|
enableCustomFooter: source.enableCustomFooter !== false,
|
|
304
|
+
footerTokenRate: source.footerTokenRate !== false,
|
|
305
|
+
footerTokenRateMode: pickEnum(
|
|
306
|
+
source.footerTokenRateMode,
|
|
307
|
+
FOOTER_TOKEN_RATE_MODES,
|
|
308
|
+
DEFAULT_CONFIG.footerTokenRateMode,
|
|
309
|
+
),
|
|
296
310
|
footerCurrency: normalizeCurrency(source.footerCurrency),
|
|
297
311
|
footerCurrencyRate: normalizeFooterCurrencyRate(source.footerCurrencyRate),
|
|
298
312
|
footerNerdIcons: source.footerNerdIcons !== false,
|
|
@@ -356,6 +370,8 @@ export function formatConfigStatus(source: Config = config): string {
|
|
|
356
370
|
`workingMsg=${source.enableWorkingMessage ? "on" : "off"}`,
|
|
357
371
|
`aliases=${source.enableAliases ? "on" : "off"}`,
|
|
358
372
|
`footer=${source.enableCustomFooter ? "on" : "off"}`,
|
|
373
|
+
`footerTokenRate=${source.footerTokenRate ? "on" : "off"}`,
|
|
374
|
+
`footerTokenRateMode=${source.footerTokenRateMode}`,
|
|
359
375
|
`footerCurrency=${source.footerCurrency}`,
|
|
360
376
|
`footerRate=${source.footerCurrencyRate ?? "-"}`,
|
|
361
377
|
`footerIcons=${source.footerNerdIcons ? "nerd" : "plain"}`,
|
|
@@ -43,6 +43,7 @@ import {
|
|
|
43
43
|
EXPANDED_OUTPUT_MAX_LINES_VALUES,
|
|
44
44
|
EXPANDED_PREVIEW_MAX_LINES_VALUES,
|
|
45
45
|
FOOTER_GIT_STATS_MODES,
|
|
46
|
+
FOOTER_TOKEN_RATE_MODES,
|
|
46
47
|
formatExcludeRenderers,
|
|
47
48
|
getCompactThinkingConfig,
|
|
48
49
|
pickPositiveInt,
|
|
@@ -58,6 +59,7 @@ import {
|
|
|
58
59
|
type DiffIndicatorMode,
|
|
59
60
|
type DiffViewMode,
|
|
60
61
|
type FooterGitStatsMode,
|
|
62
|
+
type FooterTokenRateMode,
|
|
61
63
|
} from "./config.ts";
|
|
62
64
|
|
|
63
65
|
/** renderer 注入的渲染副作用,面板自身不触碰渲染状态。 */
|
|
@@ -87,6 +89,18 @@ function customFooterDescription(enabled: boolean): string {
|
|
|
87
89
|
return "Custom status bar with model, context, cache, cost, git, and plugin chips.";
|
|
88
90
|
}
|
|
89
91
|
|
|
92
|
+
function footerTokenRateDescription(enabled: boolean): string {
|
|
93
|
+
return enabled
|
|
94
|
+
? "Output rate chip after the cache hit rate. Turn off to hide it."
|
|
95
|
+
: "Output rate chip hidden.";
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function footerTokenRateModeDescription(mode: FooterTokenRateMode): string {
|
|
99
|
+
return mode === "live"
|
|
100
|
+
? "Sliding 1s rate while generating (live). Tool waits are excluded. Falls back to the run average between chunks and after the prompt."
|
|
101
|
+
: "Average over the whole user prompt (avg), including tool waits. Stays until the next prompt.";
|
|
102
|
+
}
|
|
103
|
+
|
|
90
104
|
function footerGitStatsDescription(mode: FooterGitStatsMode): string {
|
|
91
105
|
return mode === "branch"
|
|
92
106
|
? "Net changes since the merge base, including uncommitted edits. Base: origin/HEAD, then local main/master. Untracked files excluded; unavailable stats hidden."
|
|
@@ -730,6 +744,20 @@ export async function showCcstylePanel(
|
|
|
730
744
|
currentValue: config.footerGitStatsMode,
|
|
731
745
|
values: FOOTER_GIT_STATS_MODES,
|
|
732
746
|
};
|
|
747
|
+
const footerTokenRateSetting = {
|
|
748
|
+
id: "footerTokenRate",
|
|
749
|
+
label: "Token rate",
|
|
750
|
+
description: footerTokenRateDescription(config.footerTokenRate),
|
|
751
|
+
currentValue: config.footerTokenRate ? "on" : "off",
|
|
752
|
+
values: ["on", "off"],
|
|
753
|
+
};
|
|
754
|
+
const footerTokenRateModeSetting = {
|
|
755
|
+
id: "footerTokenRateMode",
|
|
756
|
+
label: "Token rate mode",
|
|
757
|
+
description: footerTokenRateModeDescription(config.footerTokenRateMode),
|
|
758
|
+
currentValue: config.footerTokenRateMode,
|
|
759
|
+
values: FOOTER_TOKEN_RATE_MODES,
|
|
760
|
+
};
|
|
733
761
|
const pluginChipsSetting = {
|
|
734
762
|
id: "footerPluginChips",
|
|
735
763
|
label: "Plugin chips",
|
|
@@ -802,6 +830,23 @@ export async function showCcstylePanel(
|
|
|
802
830
|
ctx.ui.notify(`Updated ${id}: ${value}`, "info");
|
|
803
831
|
return;
|
|
804
832
|
}
|
|
833
|
+
if (id === "footerTokenRate") {
|
|
834
|
+
const enabled = value === "on";
|
|
835
|
+
updateConfig({ footerTokenRate: enabled });
|
|
836
|
+
footerTokenRateSetting.currentValue = enabled ? "on" : "off";
|
|
837
|
+
footerTokenRateSetting.description = footerTokenRateDescription(enabled);
|
|
838
|
+
ctx.ui.notify(`Updated ${id}: ${value}`, "info");
|
|
839
|
+
return;
|
|
840
|
+
}
|
|
841
|
+
if (id === "footerTokenRateMode") {
|
|
842
|
+
updateConfig({ footerTokenRateMode: value as FooterTokenRateMode });
|
|
843
|
+
footerTokenRateModeSetting.currentValue = config.footerTokenRateMode;
|
|
844
|
+
footerTokenRateModeSetting.description = footerTokenRateModeDescription(
|
|
845
|
+
config.footerTokenRateMode,
|
|
846
|
+
);
|
|
847
|
+
ctx.ui.notify(`Updated ${id}: ${value}`, "info");
|
|
848
|
+
return;
|
|
849
|
+
}
|
|
805
850
|
if (id === "footerNerdIcons") {
|
|
806
851
|
const enabled = value === "on";
|
|
807
852
|
updateConfig({ footerNerdIcons: enabled });
|
|
@@ -1020,6 +1065,8 @@ export async function showCcstylePanel(
|
|
|
1020
1065
|
label: "Footer",
|
|
1021
1066
|
items: [
|
|
1022
1067
|
customFooterSetting,
|
|
1068
|
+
footerTokenRateSetting,
|
|
1069
|
+
footerTokenRateModeSetting,
|
|
1023
1070
|
footerCurrencySetting,
|
|
1024
1071
|
footerCurrencyRateSetting,
|
|
1025
1072
|
footerNerdIconsSetting,
|
|
@@ -21,6 +21,7 @@ import { pathToFileURL } from "node:url";
|
|
|
21
21
|
import { config } from "../../config/config.ts";
|
|
22
22
|
import { stripAnsi } from "../../utils/ansi-text.ts";
|
|
23
23
|
import { formatFooterCost } from "./footer-currency.ts";
|
|
24
|
+
import { formatTokenCount } from "../../utils/format.ts";
|
|
24
25
|
import {
|
|
25
26
|
PI_USAGE_KEY,
|
|
26
27
|
isSkippedFooterStatusKey,
|
|
@@ -30,6 +31,7 @@ import {
|
|
|
30
31
|
} from "./footer-layout.ts";
|
|
31
32
|
import { createGitStatsRefresher, readGitStats, type GitStats } from "./git-stats.ts";
|
|
32
33
|
import { MCP_STATUS_KEY, buildMcpChip } from "./mcp-chip.ts";
|
|
34
|
+
import { currentTtftMs, displayTokenRate, formatTokenRate } from "./token-rate.ts";
|
|
33
35
|
|
|
34
36
|
export { parseGitStats } from "./git-stats.ts";
|
|
35
37
|
|
|
@@ -51,6 +53,46 @@ export function footerGlyphs(nerdIcons: boolean): { git: string; cache: string;
|
|
|
51
53
|
: { git: "", cache: "", mcp: "" };
|
|
52
54
|
}
|
|
53
55
|
|
|
56
|
+
/** 思考等级的填充圆标:等级越高填得越满。 */
|
|
57
|
+
const EFFORT_SYMBOL: Record<string, string> = {
|
|
58
|
+
minimal: "\u25cc",
|
|
59
|
+
low: "\u25cb",
|
|
60
|
+
medium: "\u25d4",
|
|
61
|
+
high: "\u25d1",
|
|
62
|
+
xhigh: "\u25d5",
|
|
63
|
+
max: "\u25cf",
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/** 模型名后直接接圆标和等级,中间不用分隔点。 */
|
|
67
|
+
export function modelChip(
|
|
68
|
+
theme: { fg: (color: string, text: string) => string },
|
|
69
|
+
modelLabel: string,
|
|
70
|
+
level: string,
|
|
71
|
+
thinkingColor: (text: string) => string,
|
|
72
|
+
): string {
|
|
73
|
+
const symbol = EFFORT_SYMBOL[level];
|
|
74
|
+
if (!symbol) return theme.fg("accent", modelLabel);
|
|
75
|
+
return `${theme.fg("accent", modelLabel)} ${thinkingColor(symbol)} ${theme.fg("dim", level)}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** git 状态符号按职能分色:冲突/落后 error,修改 warning,未跟踪 muted,领先 success。 */
|
|
79
|
+
export function colorGitStatus(
|
|
80
|
+
theme: { fg: (color: string, text: string) => string },
|
|
81
|
+
status: string,
|
|
82
|
+
): string {
|
|
83
|
+
let out = "";
|
|
84
|
+
let color = "dim";
|
|
85
|
+
for (const ch of status) {
|
|
86
|
+
if (ch === "=" || ch === "\u21e3") color = "error";
|
|
87
|
+
else if (ch === "!") color = "warning";
|
|
88
|
+
else if (ch === "?") color = "muted";
|
|
89
|
+
else if (ch === "\u21e1") color = "success";
|
|
90
|
+
else if (!/[0-9]/.test(ch)) color = "dim";
|
|
91
|
+
out += theme.fg(color, ch);
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
|
|
54
96
|
type XaiFooterReport = {
|
|
55
97
|
buckets?: Array<{
|
|
56
98
|
id?: string;
|
|
@@ -303,13 +345,7 @@ const createCustomFooterFactory =
|
|
|
303
345
|
tui.requestRender();
|
|
304
346
|
});
|
|
305
347
|
|
|
306
|
-
const fmt =
|
|
307
|
-
if (n < 1000) return `${n}`;
|
|
308
|
-
if (n < 10000) return `${(n / 1000).toFixed(1)}k`;
|
|
309
|
-
if (n < 1000000) return `${Math.round(n / 1000)}k`;
|
|
310
|
-
if (n < 10000000) return `${(n / 1000000).toFixed(1)}M`;
|
|
311
|
-
return `${Math.round(n / 1000000)}M`;
|
|
312
|
-
};
|
|
348
|
+
const fmt = formatTokenCount;
|
|
313
349
|
|
|
314
350
|
// ---- pi-cc-status 风格:上下文条形图 ----
|
|
315
351
|
const BAR_WIDTH = 10;
|
|
@@ -400,6 +436,14 @@ const createCustomFooterFactory =
|
|
|
400
436
|
const glyphs = footerGlyphs(config.footerNerdIcons);
|
|
401
437
|
const cacheLabel =
|
|
402
438
|
cachePct > 0 ? `${glyphs.cache ? `${glyphs.cache} ` : ""}${Math.floor(cachePct)}%` : "";
|
|
439
|
+
const rateLabel = config.footerTokenRate
|
|
440
|
+
? formatTokenRate(
|
|
441
|
+
displayTokenRate(config.footerTokenRateMode),
|
|
442
|
+
currentTtftMs(),
|
|
443
|
+
config.footerTokenRateMode,
|
|
444
|
+
)
|
|
445
|
+
: "";
|
|
446
|
+
const rateChip = rateLabel ? theme.fg("dim", rateLabel) : "";
|
|
403
447
|
const costChip =
|
|
404
448
|
cost || usingSubscription
|
|
405
449
|
? theme.fg(
|
|
@@ -408,10 +452,10 @@ const createCustomFooterFactory =
|
|
|
408
452
|
) + (usingSubscription ? theme.fg("warning", " sub") : "")
|
|
409
453
|
: "";
|
|
410
454
|
const line1 = joinChips([
|
|
411
|
-
theme
|
|
412
|
-
model?.reasoning ? thinkingColor(thinkingLevelStr) : "",
|
|
455
|
+
modelChip(theme, modelLabel, model?.reasoning ? thinkingLevelStr : "", thinkingColor),
|
|
413
456
|
barGauge(percent ?? 0) + theme.fg("dim", ` ${pctLabel}/${fmt(contextWindow)}`),
|
|
414
457
|
cacheLabel ? theme.fg("dim", cacheLabel) : "",
|
|
458
|
+
rateChip,
|
|
415
459
|
costChip,
|
|
416
460
|
...line1Plugins,
|
|
417
461
|
]);
|
|
@@ -427,7 +471,7 @@ const createCustomFooterFactory =
|
|
|
427
471
|
const s = gitStats;
|
|
428
472
|
let stats = "";
|
|
429
473
|
if (s && s.status)
|
|
430
|
-
stats = theme.fg("dim", " [") +
|
|
474
|
+
stats = theme.fg("dim", " [") + colorGitStatus(theme, s.status) + theme.fg("dim", "]");
|
|
431
475
|
if (s && (s.add || s.del))
|
|
432
476
|
stats +=
|
|
433
477
|
theme.fg("dim", "(") +
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* footer 输出速率的共享状态:working-message 负责采集,footer 负责显示。
|
|
3
|
+
*
|
|
4
|
+
* average:一次用户指令的墙钟平均(工具等待计入分母),显示 `avg`。
|
|
5
|
+
* live:最近 1 秒的瞬时速率,生成期暂停时钟,显示 `live`;指令结束定格为整段平均。
|
|
6
|
+
* token 与 working-message 同源:优先 provider `usage.output`,否则按 delta 增量估算
|
|
7
|
+
* (CJK 1 token,其余 chars/4)。provider 值回填到本段消息窗口。
|
|
8
|
+
* Pi 的 turn 是单次 assistant 消息,工具调用结束会开新 turn。token 跨 turn 累加,
|
|
9
|
+
* 计时与 TTFT 直到下一条用户指令才重置。
|
|
10
|
+
* TTFT:用户指令开始到第一个内容块(text/thinking/toolcall)开始的毫秒数,
|
|
11
|
+
* 同一指令内只记第一次,定格后保留到下一条指令覆盖。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export type TokenRateMode = "average" | "live";
|
|
15
|
+
|
|
16
|
+
const LIVE_WINDOW_MS = 1_000;
|
|
17
|
+
const MIN_SPAN_MS = 250;
|
|
18
|
+
const CJK_CHARS =
|
|
19
|
+
/[\u3000-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uac00-\ud7af\uf900-\ufaff\uff00-\uffef]/g;
|
|
20
|
+
|
|
21
|
+
interface TokenSample {
|
|
22
|
+
t: number;
|
|
23
|
+
cum: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
let runStartTime = 0;
|
|
27
|
+
let carriedTokens = 0;
|
|
28
|
+
let currentTokens = 0;
|
|
29
|
+
let pauseStartedAt = 0;
|
|
30
|
+
/** 并行工具调用数;>0 时瞬时采样暂停。 */
|
|
31
|
+
let activeTools = 0;
|
|
32
|
+
let lastRate = 0;
|
|
33
|
+
let lastLiveRate = 0;
|
|
34
|
+
let firstTokenAt = 0;
|
|
35
|
+
let samples: TokenSample[] = [];
|
|
36
|
+
let usageAnchorTime = 0;
|
|
37
|
+
let usageAnchorTokens = 0;
|
|
38
|
+
let countedUsage = 0;
|
|
39
|
+
|
|
40
|
+
/** 新的用户指令:重置计时、累计 token、定格值与 TTFT。 */
|
|
41
|
+
export function resetTokenRate(startAt = 0): void {
|
|
42
|
+
runStartTime = startAt;
|
|
43
|
+
carriedTokens = 0;
|
|
44
|
+
currentTokens = 0;
|
|
45
|
+
pauseStartedAt = 0;
|
|
46
|
+
activeTools = 0;
|
|
47
|
+
lastRate = 0;
|
|
48
|
+
lastLiveRate = 0;
|
|
49
|
+
firstTokenAt = 0;
|
|
50
|
+
samples = [];
|
|
51
|
+
usageAnchorTime = 0;
|
|
52
|
+
usageAnchorTokens = 0;
|
|
53
|
+
countedUsage = 0;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 一段 assistant 回复结束:保留计时与 TTFT,把本段已计 token 结算进累计。
|
|
58
|
+
* 下一段的 token 从 0 重计,采样时加回来。
|
|
59
|
+
*/
|
|
60
|
+
export function carryTokenRate(): void {
|
|
61
|
+
if (currentTokens > 0) carriedTokens += currentTokens;
|
|
62
|
+
currentTokens = 0;
|
|
63
|
+
countedUsage = 0;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** 工具开始执行:计数 +1,第一个工具才真正暂停瞬时采样。 */
|
|
67
|
+
export function pauseTokenRate(now = Date.now()): void {
|
|
68
|
+
if (!runStartTime) return;
|
|
69
|
+
activeTools += 1;
|
|
70
|
+
if (activeTools === 1 && !pauseStartedAt) pauseStartedAt = now;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** 工具执行结束:计数 -1,最后一个工具结束才恢复采样。 */
|
|
74
|
+
export function resumeTokenRate(): void {
|
|
75
|
+
activeTools = Math.max(0, activeTools - 1);
|
|
76
|
+
if (activeTools === 0 && pauseStartedAt) {
|
|
77
|
+
pauseStartedAt = 0;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** 本指令第一个内容块开始时调用;同一指令后续消息不覆盖。 */
|
|
82
|
+
export function markFirstToken(now = Date.now()): void {
|
|
83
|
+
if (runStartTime && !firstTokenAt) firstTokenAt = now;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function estimateTokens(text: string): number {
|
|
87
|
+
if (!text) return 0;
|
|
88
|
+
const cjk = text.match(CJK_CHARS)?.length ?? 0;
|
|
89
|
+
return Math.ceil(cjk + (text.length - cjk) / 4);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function pushSample(time: number, cum: number): void {
|
|
93
|
+
samples.push({ t: time, cum });
|
|
94
|
+
const cutoff = time - LIVE_WINDOW_MS;
|
|
95
|
+
while (samples.length > 1 && samples[0]!.t < cutoff) samples.shift();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function updateRates(now: number): void {
|
|
99
|
+
const total = carriedTokens + currentTokens;
|
|
100
|
+
const elapsed = (now - runStartTime) / 1000;
|
|
101
|
+
if (elapsed > 0) lastRate = total / elapsed;
|
|
102
|
+
if (samples.length < 2) return;
|
|
103
|
+
const first = samples[0]!;
|
|
104
|
+
const last = samples[samples.length - 1]!;
|
|
105
|
+
const span = last.t - first.t;
|
|
106
|
+
if (span >= MIN_SPAN_MS) {
|
|
107
|
+
lastLiveRate = ((last.cum - first.cum) * 1000) / span;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** 每个文本增量采样一次。工具暂停期的增量只累计 token,不进窗口。 */
|
|
112
|
+
export function sampleTokenDelta(delta: string, now = Date.now()): void {
|
|
113
|
+
if (!runStartTime) return;
|
|
114
|
+
const added = estimateTokens(delta);
|
|
115
|
+
if (added <= 0) return;
|
|
116
|
+
currentTokens += added;
|
|
117
|
+
if (pauseStartedAt) return;
|
|
118
|
+
pushSample(now, carriedTokens + currentTokens);
|
|
119
|
+
updateRates(now);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* 记录本段 provider `usage.output`。usage 按锚点摊回窗口:
|
|
124
|
+
* 首次报告从消息起点铺到现在,之后每次把增量摊在上次报告之后。
|
|
125
|
+
*/
|
|
126
|
+
export function recordProviderUsage(output: number, now = Date.now()): void {
|
|
127
|
+
if (!runStartTime || output <= 0) return;
|
|
128
|
+
const firstReport = countedUsage === 0;
|
|
129
|
+
const jump = output - countedUsage;
|
|
130
|
+
countedUsage = output;
|
|
131
|
+
// provider 是权威计数;首次报告以它为准,之后增量接管。
|
|
132
|
+
currentTokens = output;
|
|
133
|
+
if (firstReport) {
|
|
134
|
+
samples = samples.filter((sample) => sample.t < usageAnchorTime);
|
|
135
|
+
pushSample(usageAnchorTime, carriedTokens + usageAnchorTokens);
|
|
136
|
+
}
|
|
137
|
+
const span = Math.max(now - usageAnchorTime, 1);
|
|
138
|
+
const count = Math.min(Math.max(Math.ceil(jump / 16), 1), 32);
|
|
139
|
+
const base = firstReport
|
|
140
|
+
? carriedTokens + usageAnchorTokens
|
|
141
|
+
: carriedTokens + currentTokens - jump;
|
|
142
|
+
for (let i = 1; i <= count; i++) {
|
|
143
|
+
pushSample(usageAnchorTime + (span * i) / count, base + (jump * i) / count);
|
|
144
|
+
}
|
|
145
|
+
usageAnchorTime = now;
|
|
146
|
+
usageAnchorTokens = currentTokens;
|
|
147
|
+
updateRates(now);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** 本段消息开始:重置 provider 锚点。 */
|
|
151
|
+
export function startMessageSegment(now = Date.now()): void {
|
|
152
|
+
if (!runStartTime) return;
|
|
153
|
+
usageAnchorTime = now;
|
|
154
|
+
usageAnchorTokens = currentTokens;
|
|
155
|
+
countedUsage = 0;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** 整段指令的累计 token 数:已结转段 + 当前段。 */
|
|
159
|
+
export function totalTokens(): number {
|
|
160
|
+
return carriedTokens + currentTokens;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** average 的当前值:指令中为实时平均,指令外为上一段定格值。 */
|
|
164
|
+
export function currentTokenRate(): number {
|
|
165
|
+
return lastRate;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* 按显示模式取值。live 在生成中取窗口速率,窗口数据不足或指令结束后回到整段平均。
|
|
170
|
+
* 工具暂停期间时钟不动,显示上一条窗口值。
|
|
171
|
+
*/
|
|
172
|
+
export function displayTokenRate(mode: TokenRateMode): number {
|
|
173
|
+
if (mode === "live" && samples.length >= 2 && lastLiveRate > 0) {
|
|
174
|
+
return lastLiveRate;
|
|
175
|
+
}
|
|
176
|
+
return lastRate;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** 本指令 TTFT(毫秒);未出首 token 或无活动指令返回 0。定格后保留。 */
|
|
180
|
+
export function currentTtftMs(): number {
|
|
181
|
+
if (!runStartTime || !firstTokenAt) return 0;
|
|
182
|
+
return Math.max(0, firstTokenAt - runStartTime);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** `avg 1.2k tok/s (TTFT: 812ms)`;无数据显示空串,未出首 token 时无括号段。 */
|
|
186
|
+
export function formatTokenRate(rate: number, ttftMs = 0, mode: TokenRateMode = "average"): string {
|
|
187
|
+
if (!Number.isFinite(rate) || rate <= 0) return "";
|
|
188
|
+
const value = rate >= 1000 ? `${(rate / 1000).toFixed(1)}k` : `${Math.round(rate)}`;
|
|
189
|
+
const ttft = Number.isFinite(ttftMs) && ttftMs > 0 ? ` (TTFT: ${Math.round(ttftMs)}ms)` : "";
|
|
190
|
+
const label = mode === "live" ? "live" : "avg";
|
|
191
|
+
return `${label} ${value} tok/s${ttft}`;
|
|
192
|
+
}
|
|
@@ -5,43 +5,28 @@ import {
|
|
|
5
5
|
getToolMouseTui,
|
|
6
6
|
isFullscreenAtBottom,
|
|
7
7
|
} from "../../renderer/mouse/scroll.ts";
|
|
8
|
-
import { formatDuration } from "../../utils/format.ts";
|
|
8
|
+
import { formatDuration, formatTokenCount } from "../../utils/format.ts";
|
|
9
|
+
import {
|
|
10
|
+
resetTokenRate,
|
|
11
|
+
carryTokenRate,
|
|
12
|
+
pauseTokenRate,
|
|
13
|
+
resumeTokenRate,
|
|
14
|
+
markFirstToken,
|
|
15
|
+
sampleTokenDelta,
|
|
16
|
+
recordProviderUsage,
|
|
17
|
+
startMessageSegment,
|
|
18
|
+
totalTokens,
|
|
19
|
+
} from "./token-rate.ts";
|
|
9
20
|
|
|
10
21
|
const REFRESH_INTERVAL_MS = 1_000;
|
|
11
22
|
/** Elapsed time is only shown once the turn has run this long. */
|
|
12
23
|
const SHOW_TIMER_AFTER_MS = 3_000;
|
|
13
24
|
|
|
14
|
-
function formatCount(value: number): string {
|
|
15
|
-
return new Intl.NumberFormat("en-US").format(value);
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
type ContentBlock = {
|
|
19
|
-
type?: unknown;
|
|
20
|
-
text?: unknown;
|
|
21
|
-
thinkingSignature?: { body?: unknown };
|
|
22
|
-
};
|
|
23
|
-
|
|
24
25
|
type StreamMessage = {
|
|
25
26
|
content?: unknown;
|
|
26
27
|
usage?: { output?: unknown };
|
|
27
28
|
};
|
|
28
29
|
|
|
29
|
-
/** 每个 content index 的可见文本/思考长度;无对应块的 index 保持稀疏洞。 */
|
|
30
|
-
function textBlockLengths(message: StreamMessage): number[] {
|
|
31
|
-
const content = message.content;
|
|
32
|
-
if (!Array.isArray(content)) return [];
|
|
33
|
-
const lengths: number[] = [];
|
|
34
|
-
for (let index = 0; index < content.length; index++) {
|
|
35
|
-
const block = content[index] as ContentBlock;
|
|
36
|
-
if (block?.type === "text" && typeof block.text === "string") {
|
|
37
|
-
lengths[index] = block.text.length;
|
|
38
|
-
} else if (block?.type === "thinking" && block.thinkingSignature?.body) {
|
|
39
|
-
lengths[index] = (block.thinkingSignature.body as string).length;
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
return lengths;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
30
|
function outputUsage(message: StreamMessage): number {
|
|
46
31
|
const value = Number(message?.usage?.output);
|
|
47
32
|
return Number.isFinite(value) && value > 0 ? Math.round(value) : 0;
|
|
@@ -55,39 +40,24 @@ type WorkingUi = {
|
|
|
55
40
|
* Extend Pi's footer working row while preserving its spinner and "Working...":
|
|
56
41
|
* `⠋ Working... (↓ 1,234 tokens · 12s)`
|
|
57
42
|
*
|
|
58
|
-
*
|
|
59
|
-
* provider `usage.output` whenever the stream exposes an actual count.
|
|
60
|
-
*/
|
|
43
|
+
* token 数直接读 token-rate 的累计(估算 → provider usage 同源),不再本地重数。 */
|
|
61
44
|
export default function (pi: ExtensionAPI): void {
|
|
62
45
|
let turnActive = false;
|
|
63
46
|
let agentStartTime = 0;
|
|
64
47
|
let turnStartTime = 0;
|
|
65
|
-
|
|
66
|
-
let
|
|
67
|
-
let providerOutputTokens = 0;
|
|
48
|
+
/** 当前用户指令的起点;工具调用后的 turn 复用它,直到下一条指令。 */
|
|
49
|
+
let rateRunStartTime = 0;
|
|
68
50
|
let refreshTimer: ReturnType<typeof setTimeout> | null = null;
|
|
69
51
|
let lastMessage: string | null = null;
|
|
70
52
|
let activeCtx: { ui: WorkingUi | undefined; hasUI: boolean } | null = null;
|
|
71
53
|
|
|
72
54
|
function tokenCount(): number {
|
|
73
|
-
return
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
function setTextBlockLength(index: number, length: number): void {
|
|
77
|
-
const previous = responseTextBlockLengths[index] ?? 0;
|
|
78
|
-
responseTextBlockLengths[index] = Math.max(0, length);
|
|
79
|
-
responseLength = Math.max(0, responseLength + responseTextBlockLengths[index] - previous);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
function resetResponseTracking(message?: StreamMessage): void {
|
|
83
|
-
responseTextBlockLengths = message ? textBlockLengths(message) : [];
|
|
84
|
-
responseLength = responseTextBlockLengths.reduce((sum, length) => sum + length, 0);
|
|
85
|
-
providerOutputTokens = message ? outputUsage(message) : 0;
|
|
55
|
+
return totalTokens();
|
|
86
56
|
}
|
|
87
57
|
|
|
88
58
|
function updateProviderUsage(message: StreamMessage): void {
|
|
89
59
|
const output = outputUsage(message);
|
|
90
|
-
if (output > 0)
|
|
60
|
+
if (output > 0) recordProviderUsage(output);
|
|
91
61
|
}
|
|
92
62
|
|
|
93
63
|
/**
|
|
@@ -103,7 +73,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
103
73
|
function buildWorkingMessage(): string {
|
|
104
74
|
const parts: string[] = [];
|
|
105
75
|
const tokens = tokenCount();
|
|
106
|
-
if (tokens > 0) parts.push(`↓ ${
|
|
76
|
+
if (tokens > 0) parts.push(`↓ ${formatTokenCount(tokens)} tokens`);
|
|
107
77
|
// compact 活动回合且已滚出摘要行:直接用摘要行文案(自带回合时长,不叠 agent 计时)。
|
|
108
78
|
const compactStatus = compactMirrorText();
|
|
109
79
|
if (compactStatus) return [compactStatus, ...parts].join(" · ");
|
|
@@ -179,7 +149,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
179
149
|
stopRefreshLoop();
|
|
180
150
|
agentStartTime = 0;
|
|
181
151
|
turnStartTime = 0;
|
|
182
|
-
|
|
152
|
+
// 不重置 token-rate:footer 定格显示上一回合速率
|
|
183
153
|
restoreDefaultWorkingMessage();
|
|
184
154
|
}
|
|
185
155
|
|
|
@@ -187,12 +157,19 @@ export default function (pi: ExtensionAPI): void {
|
|
|
187
157
|
if (!agentStartTime) agentStartTime = Date.now();
|
|
188
158
|
});
|
|
189
159
|
|
|
190
|
-
pi.on("turn_start", async (
|
|
160
|
+
pi.on("turn_start", async (event, ctx) => {
|
|
191
161
|
turnActive = true;
|
|
192
162
|
activeCtx = ctx;
|
|
193
|
-
|
|
163
|
+
// 事件时间戳在 handler 之前产生;用 Date.now() 会和紧随的采样落在同一毫秒,分母变成 0。
|
|
164
|
+
turnStartTime = event?.timestamp || Date.now();
|
|
194
165
|
if (!agentStartTime) agentStartTime = turnStartTime;
|
|
195
|
-
|
|
166
|
+
// 工具调用后的 turn 属于同一条用户指令:计时、TTFT 与累计 token 保持到下一条指令。
|
|
167
|
+
// 速率起点用本指令第一个 turn 的时间戳;before_agent_start 只当指令 id。
|
|
168
|
+
if (rateRunStartTime !== agentStartTime) {
|
|
169
|
+
rateRunStartTime = agentStartTime;
|
|
170
|
+
resetTokenRate(turnStartTime);
|
|
171
|
+
}
|
|
172
|
+
startMessageSegment(turnStartTime);
|
|
196
173
|
syncWorkingMessage(true);
|
|
197
174
|
scheduleRefreshTick();
|
|
198
175
|
});
|
|
@@ -203,24 +180,25 @@ export default function (pi: ExtensionAPI): void {
|
|
|
203
180
|
if (!evt) return;
|
|
204
181
|
|
|
205
182
|
if (evt.type === "start") {
|
|
206
|
-
|
|
207
|
-
} else if (
|
|
208
|
-
|
|
183
|
+
// 新 assistant 消息起点:速率分段已在 turn_start 处理。
|
|
184
|
+
} else if (
|
|
185
|
+
evt.type === "thinking_start" ||
|
|
186
|
+
evt.type === "text_start" ||
|
|
187
|
+
evt.type === "toolcall_start"
|
|
188
|
+
) {
|
|
189
|
+
// TTFT 的首块含 toolcall_start:模型直接发起 tool call(thinking/text 省略)时也算首 token
|
|
190
|
+
markFirstToken();
|
|
191
|
+
startMessageSegment();
|
|
209
192
|
updateProviderUsage(evt.partial);
|
|
210
193
|
} else if (evt.type === "thinking_delta" || evt.type === "text_delta") {
|
|
211
|
-
|
|
212
|
-
setTextBlockLength(evt.contentIndex, (responseTextBlockLengths[evt.contentIndex] ?? 0) + add);
|
|
194
|
+
if (typeof evt.delta === "string") sampleTokenDelta(evt.delta);
|
|
213
195
|
updateProviderUsage(evt.partial);
|
|
214
|
-
} else if (evt.type === "text_end") {
|
|
215
|
-
setTextBlockLength(
|
|
216
|
-
evt.contentIndex,
|
|
217
|
-
typeof evt.content === "string" ? evt.content.length : 0,
|
|
218
|
-
);
|
|
196
|
+
} else if (evt.type === "text_end" || evt.type === "thinking_end") {
|
|
219
197
|
updateProviderUsage(evt.partial);
|
|
220
198
|
} else if (evt.type === "done") {
|
|
221
|
-
|
|
199
|
+
updateProviderUsage(evt.message);
|
|
222
200
|
} else if (evt.type === "error") {
|
|
223
|
-
|
|
201
|
+
updateProviderUsage(evt.error);
|
|
224
202
|
} else {
|
|
225
203
|
updateProviderUsage(evt.partial);
|
|
226
204
|
}
|
|
@@ -229,11 +207,20 @@ export default function (pi: ExtensionAPI): void {
|
|
|
229
207
|
scheduleRefreshTick();
|
|
230
208
|
});
|
|
231
209
|
|
|
210
|
+
pi.on("tool_execution_start", async () => {
|
|
211
|
+
pauseTokenRate();
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
pi.on("tool_execution_end", async () => {
|
|
215
|
+
resumeTokenRate();
|
|
216
|
+
});
|
|
217
|
+
|
|
232
218
|
pi.on("turn_end", async (_event, ctx) => {
|
|
233
219
|
turnActive = false;
|
|
234
220
|
activeCtx = ctx;
|
|
235
221
|
stopRefreshLoop();
|
|
236
|
-
|
|
222
|
+
// 本段 token 结转进累计,下一段 turn 继续累加;此处不清速率。
|
|
223
|
+
carryTokenRate();
|
|
237
224
|
// No completion message: return immediately to Pi's default idle state.
|
|
238
225
|
restoreDefaultWorkingMessage();
|
|
239
226
|
});
|
|
@@ -34,11 +34,9 @@ import {
|
|
|
34
34
|
type ToolCallSummary,
|
|
35
35
|
} from "./names.ts";
|
|
36
36
|
import {
|
|
37
|
-
collapsedResultSummary,
|
|
38
37
|
formatToolDuration,
|
|
39
38
|
headTruncateToWidth,
|
|
40
39
|
isLiveToolCall,
|
|
41
|
-
textFromResult,
|
|
42
40
|
toolViewportWidth,
|
|
43
41
|
} from "./result.ts";
|
|
44
42
|
import {
|
|
@@ -263,8 +261,8 @@ function codemodeResultSummary(tool: any): ToolCallSummary | undefined {
|
|
|
263
261
|
}
|
|
264
262
|
|
|
265
263
|
/**
|
|
266
|
-
* 组内 shell 行的尾巴:只报耗时(`· 12.4s
|
|
267
|
-
*
|
|
264
|
+
* 组内 shell 行的尾巴:只报耗时(`· 12.4s`)。输出和错误正文都在展开后看;
|
|
265
|
+
* 失败靠行首的 ✗,组头另有 failed 计数。
|
|
268
266
|
* 耗时取 pi 记在结果上的 durationMs(单卡渲染上下文里的同一个值),运行中还拿不到就不报。
|
|
269
267
|
*/
|
|
270
268
|
function shellResultTail(
|
|
@@ -273,23 +271,12 @@ function shellResultTail(
|
|
|
273
271
|
toolStatus: ToolStatus,
|
|
274
272
|
): string {
|
|
275
273
|
if (!SHELL_TOOL_NAMES.has(toolName(tool)) || toolStatus === "pending" || !tool?.result) return "";
|
|
276
|
-
const isError = tool.result?.isError === true;
|
|
277
|
-
// 失败报错误首行(不该只在展开后可见);成功只报耗时,行数是冗余信息。
|
|
278
|
-
const failure = isError
|
|
279
|
-
? collapsedResultSummary({
|
|
280
|
-
result: tool.result,
|
|
281
|
-
toolName: toolName(tool),
|
|
282
|
-
text: textFromResult(tool.result),
|
|
283
|
-
isError: true,
|
|
284
|
-
})
|
|
285
|
-
: "";
|
|
286
274
|
const durationMs = tool.result?.durationMs;
|
|
287
275
|
const duration =
|
|
288
276
|
typeof durationMs === "number" && Number.isFinite(durationMs) && durationMs >= 0
|
|
289
277
|
? formatToolDuration(durationMs)
|
|
290
278
|
: "";
|
|
291
|
-
|
|
292
|
-
return tail ? ` ${fg("dim", `· ${tail}`)}` : "";
|
|
279
|
+
return duration ? ` ${fg("dim", `· ${duration}`)}` : "";
|
|
293
280
|
}
|
|
294
281
|
|
|
295
282
|
/**
|
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
2
2
|
import { sanitizeToolResultText } from "./tool-result-sanitize.ts";
|
|
3
3
|
|
|
4
|
+
/** token/计数缩写:<1k 原数,<1M 用 k,≥1M 用 M。footer 与 working message 共用。 */
|
|
5
|
+
export function formatTokenCount(value: number): string {
|
|
6
|
+
if (value < 1000) return `${value}`;
|
|
7
|
+
if (value < 10_000) return `${(value / 1000).toFixed(1)}k`;
|
|
8
|
+
if (value < 1_000_000) return `${Math.round(value / 1000)}k`;
|
|
9
|
+
if (value < 10_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
|
|
10
|
+
return `${Math.round(value / 1_000_000)}M`;
|
|
11
|
+
}
|
|
12
|
+
|
|
4
13
|
/** 毫秒 → "1h 2m 3s"/"2m 3s"/"3s";低于 1 秒返回 ""(省略)。 */
|
|
5
14
|
export function formatDuration(ms: number): string {
|
|
6
15
|
const totalSec = Math.floor(ms / 1000);
|