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 +5 -3
- package/README.md +5 -3
- package/extensions/config/config.ts +20 -0
- package/extensions/config/panel.ts +61 -6
- package/extensions/feature/agent-summary/core.ts +11 -16
- package/extensions/feature/agent-summary/index.ts +60 -11
- package/extensions/feature/compact-thinking.ts +29 -11
- package/extensions/feature/shell/footer-layout.ts +54 -6
- package/extensions/feature/shell/footer.ts +23 -5
- package/extensions/feature/shell/session-tokens.ts +52 -0
- package/extensions/renderer/compact-mode.ts +109 -16
- package/extensions/renderer/tool/diff/write-execution.ts +5 -1
- package/package.json +1 -1
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
|
-
| 状态栏 |
|
|
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, //
|
|
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:
|
|
378
|
-
|
|
379
|
-
|
|
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: [
|
|
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
|
-
* 呈现:`
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
):
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
|
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
|
-
*
|
|
7
|
+
*
|
|
8
|
+
* 不走 Markdown:pi 的 Markdown 组件会剥掉输入里的终端序列,
|
|
9
|
+
* 数字的语义色只能由组件自己拼。竖线取 mdQuoteBorder,文字取 mdQuote + 斜体,数字取 success/error。
|
|
8
10
|
*/
|
|
9
11
|
|
|
10
|
-
import {
|
|
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 {
|
|
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
|
|
20
|
-
|
|
21
|
-
|
|
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 ||
|
|
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 * (
|
|
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 -
|
|
302
|
-
const visible = hiddenLines > 0 ? entry.lines.slice(-
|
|
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
|
|
355
|
-
runStartIndex
|
|
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) &&
|
|
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 || (
|
|
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 ===
|
|
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:
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
| {
|
|
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" ||
|
|
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 = {
|
|
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 || !
|
|
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
|
-
|
|
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
|
-
|
|
1325
|
-
|
|
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
|
-
|
|
1335
|
-
|
|
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 || !
|
|
1707
|
+
if (children[i] === tail.host || !staysAbove(children[i])) continue;
|
|
1630
1708
|
last = i;
|
|
1631
1709
|
}
|
|
1632
|
-
|
|
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 (!
|
|
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
|
-
|
|
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
|
|
1685
|
-
if (!
|
|
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);
|