pi-cc-extensions 0.9.6 → 0.9.7
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.
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { getCompactRunStatusText } from "../../renderer/compact-mode.ts";
|
|
3
|
+
import {
|
|
4
|
+
fullscreenLazyTui,
|
|
5
|
+
getToolMouseTui,
|
|
6
|
+
isFullscreenAtBottom,
|
|
7
|
+
} from "../../renderer/mouse/scroll.ts";
|
|
2
8
|
import { formatDuration } from "../../utils/format.ts";
|
|
3
9
|
|
|
4
10
|
const REFRESH_INTERVAL_MS = 1_000;
|
|
@@ -84,11 +90,24 @@ export default function (pi: ExtensionAPI): void {
|
|
|
84
90
|
if (output > 0) providerOutputTokens = output;
|
|
85
91
|
}
|
|
86
92
|
|
|
93
|
+
/**
|
|
94
|
+
* 摘要行滚出视口才镜像:仅 fullscreen 且已离开 transcript 底部。
|
|
95
|
+
* regular 没有“离开底部”信号(transcript 在终端回滚区),保持 Pi 默认文案。
|
|
96
|
+
*/
|
|
97
|
+
function compactMirrorText(): string | undefined {
|
|
98
|
+
const tui = getToolMouseTui();
|
|
99
|
+
if (!tui || !fullscreenLazyTui(tui) || isFullscreenAtBottom(tui)) return undefined;
|
|
100
|
+
return getCompactRunStatusText();
|
|
101
|
+
}
|
|
102
|
+
|
|
87
103
|
function buildWorkingMessage(): string {
|
|
88
|
-
const elapsed = Date.now() - (agentStartTime || turnStartTime);
|
|
89
|
-
const tokens = tokenCount();
|
|
90
104
|
const parts: string[] = [];
|
|
105
|
+
const tokens = tokenCount();
|
|
91
106
|
if (tokens > 0) parts.push(`↓ ${formatCount(tokens)} tokens`);
|
|
107
|
+
// compact 活动回合且已滚出摘要行:直接用摘要行文案(自带回合时长,不叠 agent 计时)。
|
|
108
|
+
const compactStatus = compactMirrorText();
|
|
109
|
+
if (compactStatus) return [compactStatus, ...parts].join(" · ");
|
|
110
|
+
const elapsed = Date.now() - (agentStartTime || turnStartTime);
|
|
92
111
|
if (elapsed >= SHOW_TIMER_AFTER_MS || tokens > 0) {
|
|
93
112
|
// formatDuration 低于 1 秒返回 "",此处回退 "0s" 保持计时器连续跳动。
|
|
94
113
|
parts.push(formatDuration(elapsed) || "0s");
|
|
@@ -8,6 +8,11 @@
|
|
|
8
8
|
* Agent/Task 族:调用只进摘要计数,tool 卡始终折叠(避免 pending→完成高度闪动)。
|
|
9
9
|
* 底部 Agents/Tasks 面板由 pi-subagents/pi-tasks 独立 widget 负责,不经 tool 卡外置。
|
|
10
10
|
*
|
|
11
|
+
* live 围观态(回合进行中 / 收尾静默期):正文与摘要行跟折叠态同形,只在摘要行下面多一张
|
|
12
|
+
* 单一槽位卡(`↳` + 最新的一个块:仍在增长的思考预览,或本回合最近的工具,运行中优先)。
|
|
13
|
+
* 思考原地增长不轮换,只有新的思考块或工具调用进来才换槽位内容。
|
|
14
|
+
* 收尾时摘要行原地从 Running... 翻成 Ran for、槽位卡消失,正文与摘要行都不换位置。
|
|
15
|
+
*
|
|
11
16
|
* 工具计数:read 按非空路径去重、其余按调用计数(首次出现顺序);edit/write 不进摘要。
|
|
12
17
|
* 时长 = 回合流逝挂钟;进行中 Running...,结束 Ran for。
|
|
13
18
|
* abort/error/length 状态行挂在摘要外层,避免被折叠吞掉。
|
|
@@ -40,7 +45,12 @@ import { getToolMouseTui } from "./mouse/scroll.ts";
|
|
|
40
45
|
import { insetComponent, renderExpandedToolResult, scheduleAnimation } from "./tool/result.ts";
|
|
41
46
|
import { paddedBackgroundRow } from "./tool/grouping.ts";
|
|
42
47
|
import { formatDisplayPath } from "./tool/names.ts";
|
|
43
|
-
import {
|
|
48
|
+
import {
|
|
49
|
+
hasVisibleText,
|
|
50
|
+
removeStyledRange,
|
|
51
|
+
stripBackgroundAnsi,
|
|
52
|
+
stripTerminalSequencesPreservingLayout,
|
|
53
|
+
} from "../utils/ansi-text.ts";
|
|
44
54
|
import { walkComponentTree } from "../utils/component-tree.ts";
|
|
45
55
|
import {
|
|
46
56
|
ASSISTANT_REENTRY_KEY,
|
|
@@ -67,6 +77,12 @@ export type CompactThinkingQuery = {
|
|
|
67
77
|
|
|
68
78
|
const EDIT_WRITE_TOOLS = new Set(["edit", "write"]);
|
|
69
79
|
|
|
80
|
+
/**
|
|
81
|
+
* 回合收尾后延迟收拢的静默期(ms)。正文 token 往往先于 toolCall 到达,
|
|
82
|
+
* 立刻收拢会得到「先收再开」两次转场;静默期内被新回合接替则一并收拢。
|
|
83
|
+
*/
|
|
84
|
+
const FOLD_SETTLE_MS = 250;
|
|
85
|
+
|
|
70
86
|
type CompactThinkingTheme = Pick<Theme, "fg" | "italic" | "bold">;
|
|
71
87
|
|
|
72
88
|
/** 与 compact-thinking 主渲染器共用的静态文字样式。 */
|
|
@@ -332,23 +348,52 @@ function editWriteExpandedCard(theme: any): any {
|
|
|
332
348
|
* 每个子卡前面补 1 行分隔(上一条已经是空行时不再补),子卡自带的首尾空行会被裁掉,
|
|
333
349
|
* 避免与外卡/内卡 padding 叠出双空行。
|
|
334
350
|
* hits:子卡的行区间与命中目标(thinking 命中自身,工具命中 tool 组件),
|
|
335
|
-
* 供展开后点击 hint。未开启 toolHits 时不映射工具,点击仍归外层面板。
|
|
351
|
+
* 供展开后点击 hint。未开启 toolHits 时不映射工具,点击仍归外层面板。
|
|
352
|
+
* live=true:live 槽位用,不铺底色也不留上下空行,首行紧跟 `↳`、其余行缩进对齐
|
|
353
|
+
* (Claude Code 的 ⎿ 口径)。行的产出仍是一行对一行,hits 行号无需平移。
|
|
354
|
+
*/
|
|
336
355
|
function layoutExpandedToolCard(
|
|
337
356
|
theme: any,
|
|
338
357
|
children: any[],
|
|
339
358
|
width: number,
|
|
340
359
|
paints?: string[][],
|
|
341
360
|
toolHits = false,
|
|
361
|
+
live = false,
|
|
342
362
|
): { lines: string[]; hits: Array<{ child: any; start: number; end: number }> } {
|
|
343
363
|
const slot = "userMessageBg";
|
|
344
364
|
const toolBgAnsi = darkenBgAnsi(theme, slot);
|
|
345
|
-
|
|
365
|
+
/** live 每行前面的标记宽度(首行 `↳ `,其余对齐空格)。 */
|
|
366
|
+
const markerWidth = live ? 2 : 0;
|
|
367
|
+
const innerWidth = Math.max(0, width - 2 - markerWidth);
|
|
346
368
|
const lines: string[] = [];
|
|
347
369
|
const hits: Array<{ child: any; start: number; end: number }> = [];
|
|
348
370
|
const isThinkingPreview = (child: any) => typeof child?.setHintHovered === "function";
|
|
349
371
|
let lastBlank = false;
|
|
372
|
+
/** live 首行被吃掉的前导缩进:续行同步左移同样格数,保持块内相对对齐。 */
|
|
373
|
+
let liveLead = 0;
|
|
374
|
+
const pushRow = (raw: string) => {
|
|
375
|
+
if (!live) {
|
|
376
|
+
lines.push(paddedBackgroundRow(theme, slot, raw, width));
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
// 槽位里不重复展开入口(见 SLOT_HINT_PATTERNS)。
|
|
380
|
+
const line = stripSlotHint(raw);
|
|
381
|
+
// 工具卡行首带 SGR:ANSI 之后才是缩进空格。
|
|
382
|
+
const head = line.match(/^((?:\x1b\[[0-9;]*m)*)( *)/);
|
|
383
|
+
const ansi = head?.[1] ?? "";
|
|
384
|
+
const spaces = head?.[2]?.length ?? 0;
|
|
385
|
+
const first = lines.length === 0;
|
|
386
|
+
if (first) liveLead = spaces;
|
|
387
|
+
// 首行挂 muted 的 `↳`(与 default-mode 工具结果行同色);续行按 liveLead 去缩进。
|
|
388
|
+
// 两侧都保留行首 SGR:丢掉它整行会掉回终端默认前景色。
|
|
389
|
+
const marker = first ? `${theme.fg("muted", "↳")} ` : " ";
|
|
390
|
+
const lead = first ? spaces : Math.min(spaces, liveLead);
|
|
391
|
+
lines.push(
|
|
392
|
+
`${marker}${ansi}${truncateToWidth(line.slice(ansi.length + lead), innerWidth, "")}`,
|
|
393
|
+
);
|
|
394
|
+
};
|
|
350
395
|
const pushBlank = () => {
|
|
351
|
-
if (lastBlank) return;
|
|
396
|
+
if (live || lastBlank) return;
|
|
352
397
|
lines.push(paddedBackgroundRow(theme, slot, "", width));
|
|
353
398
|
lastBlank = true;
|
|
354
399
|
};
|
|
@@ -356,8 +401,8 @@ function layoutExpandedToolCard(
|
|
|
356
401
|
const child = children[childIndex];
|
|
357
402
|
const painted = paints?.[childIndex];
|
|
358
403
|
const childLines = Array.isArray(painted) ? painted : child.render(innerWidth);
|
|
359
|
-
//
|
|
360
|
-
const innerCard = isThinkingPreview(child) && child.expanded === true;
|
|
404
|
+
// live 不留空行、也不叠深色内卡:槽位没有边框。
|
|
405
|
+
const innerCard = !live && isThinkingPreview(child) && child.expanded === true;
|
|
361
406
|
let first = -1;
|
|
362
407
|
let last = -1;
|
|
363
408
|
for (let i = 0; i < childLines.length; i++) {
|
|
@@ -367,6 +412,7 @@ function layoutExpandedToolCard(
|
|
|
367
412
|
}
|
|
368
413
|
}
|
|
369
414
|
if (first < 0) {
|
|
415
|
+
if (live) continue;
|
|
370
416
|
for (const line of childLines) {
|
|
371
417
|
lines.push(paddedBackgroundRow(theme, slot, line, width));
|
|
372
418
|
lastBlank = !hasVisibleText(line);
|
|
@@ -378,7 +424,7 @@ function layoutExpandedToolCard(
|
|
|
378
424
|
// 展开的 thinking 走深色内卡(上下各 1 行 padding),工具卡直接铺在外卡上。
|
|
379
425
|
const paintRow = innerCard
|
|
380
426
|
? (line: string) => lines.push(toolCardBgRow(theme, slot, toolBgAnsi, line, width))
|
|
381
|
-
:
|
|
427
|
+
: pushRow;
|
|
382
428
|
if (innerCard) paintRow("");
|
|
383
429
|
for (let i = first; i <= last; i++) paintRow(childLines[i]);
|
|
384
430
|
if (innerCard) paintRow("");
|
|
@@ -399,6 +445,8 @@ function compactRoundCard(
|
|
|
399
445
|
toolRender: (tool: any, width: number) => string[],
|
|
400
446
|
/** true 时卡内工具行归 tool 组件,可点击二次展开(整回合展开卡)。 */
|
|
401
447
|
toolHits = false,
|
|
448
|
+
/** true 时走 live 槽位排布:不留空行、首行紧跟 `↳`。 */
|
|
449
|
+
live = false,
|
|
402
450
|
): any {
|
|
403
451
|
const children: any[] = [];
|
|
404
452
|
for (const item of cardItems) {
|
|
@@ -413,6 +461,8 @@ function compactRoundCard(
|
|
|
413
461
|
});
|
|
414
462
|
}
|
|
415
463
|
}
|
|
464
|
+
/** 子块可用宽度:live 还要让出首行 `↳ ` 的标记位。 */
|
|
465
|
+
const contentWidth = (width: number) => Math.max(0, width - 2 - (live ? 2 : 0));
|
|
416
466
|
let paint:
|
|
417
467
|
| {
|
|
418
468
|
width: number;
|
|
@@ -424,7 +474,7 @@ function compactRoundCard(
|
|
|
424
474
|
| undefined;
|
|
425
475
|
const layout = (width: number) => {
|
|
426
476
|
const theme = themeOf();
|
|
427
|
-
const innerWidth =
|
|
477
|
+
const innerWidth = contentWidth(width);
|
|
428
478
|
const paints = children.map((child) => {
|
|
429
479
|
const lines = child.render?.(innerWidth);
|
|
430
480
|
return Array.isArray(lines) ? lines : [];
|
|
@@ -438,7 +488,7 @@ function compactRoundCard(
|
|
|
438
488
|
) {
|
|
439
489
|
return paint;
|
|
440
490
|
}
|
|
441
|
-
const laid = layoutExpandedToolCard(theme, children, width, paints, toolHits);
|
|
491
|
+
const laid = layoutExpandedToolCard(theme, children, width, paints, toolHits, live);
|
|
442
492
|
paint = {
|
|
443
493
|
width,
|
|
444
494
|
theme,
|
|
@@ -532,6 +582,17 @@ const trackedAssistantComponents = new Set<any>();
|
|
|
532
582
|
const trackedToolComponents = new Set<any>();
|
|
533
583
|
let hoveredAssistantComponent: any;
|
|
534
584
|
|
|
585
|
+
/**
|
|
586
|
+
* 活动回合摘要 getter:常驻状态行(pi working row)镜像用。
|
|
587
|
+
* installCompactMode 注册、卸载时清空,直接读 activeRound,不持有回合闭包。
|
|
588
|
+
*/
|
|
589
|
+
let compactRunStatusGetter: (() => string | undefined) | undefined;
|
|
590
|
+
|
|
591
|
+
/** 活动回合的摘要文本(`Running... · 9s, bash×1`);无活动回合返回 undefined。 */
|
|
592
|
+
export function getCompactRunStatusText(): string | undefined {
|
|
593
|
+
return compactRunStatusGetter?.();
|
|
594
|
+
}
|
|
595
|
+
|
|
535
596
|
export function setHoveredCompactAssistant(component: any): boolean {
|
|
536
597
|
if (hoveredAssistantComponent === component) return false;
|
|
537
598
|
hoveredAssistantComponent = component;
|
|
@@ -841,7 +902,9 @@ function compactAssistantLineComponent(
|
|
|
841
902
|
return paint.lines;
|
|
842
903
|
}
|
|
843
904
|
const plainText = truncateToWidth(resolved, summaryWidth, "…");
|
|
844
|
-
|
|
905
|
+
// Dim thinking text 开启时整行统一 dim,否则工具计数保持 muted。
|
|
906
|
+
const plain = (value: string) => theme.fg(config.dimThinkingText ? "dim" : "muted", value);
|
|
907
|
+
let text = plain(plainText);
|
|
845
908
|
if (runningActive || plainText.startsWith("Ran for ")) {
|
|
846
909
|
const separator = plainText.indexOf(", ");
|
|
847
910
|
const heading = separator < 0 ? plainText : plainText.slice(0, separator);
|
|
@@ -850,13 +913,9 @@ function compactAssistantLineComponent(
|
|
|
850
913
|
const durationSeparator = heading.indexOf(" · ");
|
|
851
914
|
const label = durationSeparator < 0 ? heading : heading.slice(0, durationSeparator);
|
|
852
915
|
const duration = durationSeparator < 0 ? "" : heading.slice(durationSeparator);
|
|
853
|
-
text = `${animateCompactThinkingText(
|
|
854
|
-
label,
|
|
855
|
-
theme,
|
|
856
|
-
query?.getThinkingAnimationFrame?.() ?? 0,
|
|
857
|
-
)}${styleCompactThinkingText(duration, theme)}${theme.fg("muted", tools)}`;
|
|
916
|
+
text = `${animateCompactThinkingText(label, theme, query?.getThinkingAnimationFrame?.() ?? 0)}${styleCompactThinkingText(duration, theme)}${plain(tools)}`;
|
|
858
917
|
} else {
|
|
859
|
-
text = `${styleCompactThinkingText(heading, theme)}${
|
|
918
|
+
text = `${styleCompactThinkingText(heading, theme)}${plain(tools)}`;
|
|
860
919
|
}
|
|
861
920
|
}
|
|
862
921
|
const hintColor = hover ? "text" : "dim";
|
|
@@ -894,6 +953,51 @@ function compactStopStatusLine(status: string, pad = 0): any {
|
|
|
894
953
|
};
|
|
895
954
|
}
|
|
896
955
|
|
|
956
|
+
/**
|
|
957
|
+
* 槽位卡内的展开入口:卡里是只读围观,展开走摘要行自己的 hint,卡内不再重复提示。
|
|
958
|
+
* 提示有两处来源——我们的 showMoreHintText(`… +N more lines • click to show more`)与
|
|
959
|
+
* pi 原生工具卡(`… (N earlier lines, ctrl+o to expand)`),所以按渲染结果收尾裁剪。
|
|
960
|
+
* 只去掉动作词,`(N more lines)` 这类计数保留。
|
|
961
|
+
*/
|
|
962
|
+
/** 展开入口的动作词:我们的 `click to show more`、pi 的 `<key> to expand`(键名可能取不到)。 */
|
|
963
|
+
const EXPAND_ACTION = String.raw`(?:click to show more|(?:[\w/+]+\s+)?to (?:show more|expand))`;
|
|
964
|
+
|
|
965
|
+
const SLOT_HINT_PATTERNS: RegExp[] = [
|
|
966
|
+
// 卡在括号里:`(9 more lines, click to show more)` / `(195 earlier lines, ctrl+o to expand)`,
|
|
967
|
+
// 只去掉动作词,收尾括号与计数保留。
|
|
968
|
+
new RegExp(String.raw`,\s*[^()]*?${EXPAND_ACTION}(?=\))`),
|
|
969
|
+
// 直接挂在行尾:整段去掉。
|
|
970
|
+
new RegExp(String.raw`\s*(?:•\s*)?${EXPAND_ACTION}\s*$`),
|
|
971
|
+
];
|
|
972
|
+
|
|
973
|
+
function stripSlotHint(line: string): string {
|
|
974
|
+
const plain = stripTerminalSequencesPreservingLayout(line);
|
|
975
|
+
for (const re of SLOT_HINT_PATTERNS) {
|
|
976
|
+
const match = re.exec(plain);
|
|
977
|
+
if (!match) continue;
|
|
978
|
+
// 删除区间而非截断后拼纯文本:收尾括号留在原样式里(dim),不会掉回默认前景色。
|
|
979
|
+
return removeStyledRange(line, match.index, match.index + match[0].length);
|
|
980
|
+
}
|
|
981
|
+
return line;
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
/** live 槽位卡:按 pad 缩进;首行 `↳` 由卡自身的 live 排布加,行不增不减。 */
|
|
985
|
+
function compactLiveSlot(card: any, pad = 0): any {
|
|
986
|
+
const innerOf = (width: number) => Math.max(0, width - Math.max(0, pad));
|
|
987
|
+
return {
|
|
988
|
+
render(width: number): string[] {
|
|
989
|
+
const indent = " ".repeat(Math.max(0, pad));
|
|
990
|
+
return card.render(innerOf(width)).map((line: string) => `${indent}${line}`);
|
|
991
|
+
},
|
|
992
|
+
childAtRow(localRow: number, width: number) {
|
|
993
|
+
return card.childAtRow?.(localRow, innerOf(width)) ?? null;
|
|
994
|
+
},
|
|
995
|
+
invalidate() {
|
|
996
|
+
card.invalidate();
|
|
997
|
+
},
|
|
998
|
+
};
|
|
999
|
+
}
|
|
1000
|
+
|
|
897
1001
|
function compactAssistantLine(
|
|
898
1002
|
component: any,
|
|
899
1003
|
summary: string | (() => string),
|
|
@@ -1005,6 +1109,11 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
|
|
|
1005
1109
|
detachedMessages: any[];
|
|
1006
1110
|
active: boolean;
|
|
1007
1111
|
suppressedToolIds: Set<string>;
|
|
1112
|
+
/** live 槽位内联渲染的工具 id;回合结束时随槽位一起交还外层。 */
|
|
1113
|
+
liveSlotToolIds: Set<string>;
|
|
1114
|
+
/** 收尾后的静默期截止时间戳;期间仍按围观态渲染,到点才收拢。 */
|
|
1115
|
+
settleUntil?: number;
|
|
1116
|
+
foldTimer?: ReturnType<typeof setTimeout>;
|
|
1008
1117
|
/** 回合挂钟起点,保证 Running 时长连续递增。 */
|
|
1009
1118
|
startedAt: number;
|
|
1010
1119
|
endedAt?: number;
|
|
@@ -1014,6 +1123,8 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
|
|
|
1014
1123
|
const expandedRoundToolIds = new Set<string>();
|
|
1015
1124
|
/** 用户显式点开的 round 内工具;强制折叠不压回这些 id。 */
|
|
1016
1125
|
const explicitRoundToolIds = new Set<string>();
|
|
1126
|
+
const liveSlotToolIds = new Set<string>();
|
|
1127
|
+
const pendingFoldRounds = new Set<CompactRound>();
|
|
1017
1128
|
let uiRef: { requestRender?: (force?: boolean) => void } | undefined;
|
|
1018
1129
|
let roundTickTimer: ReturnType<typeof setInterval> | undefined;
|
|
1019
1130
|
|
|
@@ -1053,8 +1164,22 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
|
|
|
1053
1164
|
}, 250);
|
|
1054
1165
|
};
|
|
1055
1166
|
|
|
1056
|
-
/**
|
|
1057
|
-
const
|
|
1167
|
+
/** 围观中:活动回合,或收尾静默期还没走完的回合(槽位卡仍挂在屏幕上)。 */
|
|
1168
|
+
const roundLive = (round: CompactRound): boolean =>
|
|
1169
|
+
round.active || (round.settleUntil !== undefined && Date.now() < round.settleUntil);
|
|
1170
|
+
|
|
1171
|
+
const clearFoldTimer = (round: CompactRound): void => {
|
|
1172
|
+
if (round.foldTimer !== undefined) {
|
|
1173
|
+
clearTimeout(round.foldTimer);
|
|
1174
|
+
round.foldTimer = undefined;
|
|
1175
|
+
}
|
|
1176
|
+
round.settleUntil = undefined;
|
|
1177
|
+
pendingFoldRounds.delete(round);
|
|
1178
|
+
};
|
|
1179
|
+
|
|
1180
|
+
/** 结束回合活动态;不做任何重绘。 */
|
|
1181
|
+
const finalizeRound = (round: CompactRound): void => {
|
|
1182
|
+
clearFoldTimer(round);
|
|
1058
1183
|
round.active = false;
|
|
1059
1184
|
if (round.endedAt === undefined) round.endedAt = Date.now();
|
|
1060
1185
|
if (activeRound === round) {
|
|
@@ -1062,7 +1187,36 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
|
|
|
1062
1187
|
deps.query?.setCompactSummaryActive?.(false);
|
|
1063
1188
|
stopRoundTick();
|
|
1064
1189
|
}
|
|
1065
|
-
|
|
1190
|
+
};
|
|
1191
|
+
|
|
1192
|
+
/**
|
|
1193
|
+
* 结束回合活动态;render=true 时走静默期,延迟 FOLD_SETTLE_MS 再收拢。
|
|
1194
|
+
* 分组语义不受影响:activeRound 立即让位,新消息不会被并进已收尾的回合。
|
|
1195
|
+
*/
|
|
1196
|
+
const endRound = (round: CompactRound, render = false): void => {
|
|
1197
|
+
if (!render || !round.active) {
|
|
1198
|
+
finalizeRound(round);
|
|
1199
|
+
if (render) renderRound(round);
|
|
1200
|
+
return;
|
|
1201
|
+
}
|
|
1202
|
+
finalizeRound(round);
|
|
1203
|
+
if (round.foldTimer !== undefined) return;
|
|
1204
|
+
round.settleUntil = Date.now() + FOLD_SETTLE_MS;
|
|
1205
|
+
pendingFoldRounds.add(round);
|
|
1206
|
+
round.foldTimer = setTimeout(() => {
|
|
1207
|
+
round.foldTimer = undefined;
|
|
1208
|
+
round.settleUntil = undefined;
|
|
1209
|
+
pendingFoldRounds.delete(round);
|
|
1210
|
+
renderRound(round);
|
|
1211
|
+
}, FOLD_SETTLE_MS);
|
|
1212
|
+
};
|
|
1213
|
+
|
|
1214
|
+
/** 新回合接替时立即收掉还在静默期的回合,把两次转场并成一次。 */
|
|
1215
|
+
const flushSettle = (): void => {
|
|
1216
|
+
for (const round of [...pendingFoldRounds]) {
|
|
1217
|
+
clearFoldTimer(round);
|
|
1218
|
+
renderRound(round);
|
|
1219
|
+
}
|
|
1066
1220
|
};
|
|
1067
1221
|
|
|
1068
1222
|
const renderAssistantWithoutThinking = (
|
|
@@ -1083,6 +1237,22 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
|
|
|
1083
1237
|
return result;
|
|
1084
1238
|
};
|
|
1085
1239
|
|
|
1240
|
+
/** compact 只允许单开:收回一张工具卡的展开态。 */
|
|
1241
|
+
const collapseTool = (tool: any): void => {
|
|
1242
|
+
if (typeof tool?.setExpanded === "function") tool.setExpanded(false);
|
|
1243
|
+
else {
|
|
1244
|
+
tool.expanded = false;
|
|
1245
|
+
tool.updateDisplay?.();
|
|
1246
|
+
}
|
|
1247
|
+
};
|
|
1248
|
+
|
|
1249
|
+
/** 按条件收回已展开的工具卡。 */
|
|
1250
|
+
const collapseTools = (shouldCollapse: (tool: any) => boolean): void => {
|
|
1251
|
+
for (const tool of trackedToolComponents) {
|
|
1252
|
+
if (tool.expanded === true && shouldCollapse(tool)) collapseTool(tool);
|
|
1253
|
+
}
|
|
1254
|
+
};
|
|
1255
|
+
|
|
1086
1256
|
const markRoundToolUserExpanded = (tool: any): void => {
|
|
1087
1257
|
const id = String(tool?.toolCallId ?? "");
|
|
1088
1258
|
if (!id || !expandedRoundToolIds.has(id)) return;
|
|
@@ -1090,11 +1260,7 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
|
|
|
1090
1260
|
const otherId = String(other?.toolCallId ?? "");
|
|
1091
1261
|
if (other === tool || !expandedRoundToolIds.has(otherId) || other.expanded !== true) continue;
|
|
1092
1262
|
explicitRoundToolIds.delete(otherId);
|
|
1093
|
-
|
|
1094
|
-
else {
|
|
1095
|
-
other.expanded = false;
|
|
1096
|
-
other.updateDisplay?.();
|
|
1097
|
-
}
|
|
1263
|
+
collapseTool(other);
|
|
1098
1264
|
}
|
|
1099
1265
|
explicitRoundToolIds.add(id);
|
|
1100
1266
|
};
|
|
@@ -1105,6 +1271,12 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
|
|
|
1105
1271
|
...round.detachedMessages,
|
|
1106
1272
|
];
|
|
1107
1273
|
|
|
1274
|
+
/** 镜像给常驻状态行的文案:只认当前活动回合(摘要行被顶出视口时由 working-message 取用)。 */
|
|
1275
|
+
compactRunStatusGetter = () => {
|
|
1276
|
+
const round = activeRound;
|
|
1277
|
+
return round?.active ? summarize(roundMessages(round), true, round) || undefined : undefined;
|
|
1278
|
+
};
|
|
1279
|
+
|
|
1108
1280
|
const roundToolCallIds = (round: CompactRound): Set<string> => {
|
|
1109
1281
|
const ids = new Set<string>();
|
|
1110
1282
|
for (const message of roundMessages(round)) {
|
|
@@ -1115,19 +1287,132 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
|
|
|
1115
1287
|
return ids;
|
|
1116
1288
|
};
|
|
1117
1289
|
|
|
1290
|
+
/**
|
|
1291
|
+
* 末块仍是 thinking = 思考还在长;后面跟了正文或工具调用就算结束。
|
|
1292
|
+
* 围观态只预览这一种,已完成的思考直接回收进摘要行,不在屏幕上留 Thought 行。
|
|
1293
|
+
*/
|
|
1294
|
+
const hasTrailingThinking = (message: any): boolean => {
|
|
1295
|
+
const content = Array.isArray(message?.content) ? message.content : [];
|
|
1296
|
+
for (let i = content.length - 1; i >= 0; i--) {
|
|
1297
|
+
const item = content[i];
|
|
1298
|
+
if (item?.type === "thinking") return true;
|
|
1299
|
+
if (item?.type === "toolCall") return false;
|
|
1300
|
+
if (item?.type === "text" && String(item.text ?? "").trim()) return false;
|
|
1301
|
+
}
|
|
1302
|
+
return false;
|
|
1303
|
+
};
|
|
1304
|
+
|
|
1305
|
+
/** 运行中的工具:已开始且尚无最终结果。 */
|
|
1306
|
+
const isRunningTool = (tool: any): boolean =>
|
|
1307
|
+
tool?.executionStarted === true && (!tool.result || tool.isPartial === true);
|
|
1308
|
+
|
|
1309
|
+
/**
|
|
1310
|
+
* live 围观态:正文 + 摘要行照折叠态渲染,只在摘要行下多一张槽位卡。
|
|
1311
|
+
* - 思考:只预览最后一条仍在思考的消息,它的块是当前最新内容;
|
|
1312
|
+
* - 工具:没有活动思考时才占槽位,运行中优先,否则取本回合最近的,完成也留着;
|
|
1313
|
+
* - 槽位只放一个块(思考或工具),新的思考块/工具调用进来才轮换;
|
|
1314
|
+
* - 本回合所有工具都由槽位卡接管,外层不再单独成行。
|
|
1315
|
+
* 可见性判断只在这里发生;回合结束(renderRound 非 active)只剩槽位卡消失。
|
|
1316
|
+
*/
|
|
1317
|
+
const renderRoundLive = (
|
|
1318
|
+
round: CompactRound,
|
|
1319
|
+
stopStatus: string | undefined,
|
|
1320
|
+
getSummary: () => string,
|
|
1321
|
+
): void => {
|
|
1322
|
+
// 仍在思考的消息 = 当前最新内容,优先占槽位。
|
|
1323
|
+
let previewComponent: any;
|
|
1324
|
+
for (const [component, message] of round.messages) {
|
|
1325
|
+
if (hasTrailingThinking(message)) previewComponent = component;
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
const thinkingKids: any[] = [];
|
|
1329
|
+
for (const [component, message] of round.messages) {
|
|
1330
|
+
if (component !== previewComponent) {
|
|
1331
|
+
component.contentContainer?.clear?.();
|
|
1332
|
+
continue;
|
|
1333
|
+
}
|
|
1334
|
+
passThroughAssistant(component, message);
|
|
1335
|
+
const kids = Array.isArray(component.contentContainer?.children)
|
|
1336
|
+
? [...component.contentContainer.children]
|
|
1337
|
+
: [];
|
|
1338
|
+
component.contentContainer?.clear?.();
|
|
1339
|
+
// 只摘思考预览块进槽位;已收尾的思考回收进摘要行。
|
|
1340
|
+
for (const kid of kids) {
|
|
1341
|
+
if (typeof kid?.setHintHovered === "function") thinkingKids.push(kid);
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
// 正文照折叠态渲染(不含 thinking):live 与收尾后同形,收尾只剩槽位卡消失。
|
|
1345
|
+
renderAssistantWithoutThinking(round.anchor, round.messages.get(round.anchor));
|
|
1346
|
+
|
|
1347
|
+
const toolsById = new Map<string, any>();
|
|
1348
|
+
for (const tool of trackedToolComponents) {
|
|
1349
|
+
if (typeof tool?.toolCallId === "string") toolsById.set(tool.toolCallId, tool);
|
|
1350
|
+
}
|
|
1351
|
+
let runningTool: any;
|
|
1352
|
+
let lastTool: any;
|
|
1353
|
+
for (const [, message] of round.messages) {
|
|
1354
|
+
for (const item of Array.isArray(message?.content) ? message.content : []) {
|
|
1355
|
+
if (item?.type !== "toolCall" || typeof item.id !== "string") continue;
|
|
1356
|
+
const tool = toolsById.get(item.id);
|
|
1357
|
+
// edit/write 保留自己的 compact 行,不进槽位。
|
|
1358
|
+
if (EDIT_WRITE_TOOLS.has(String(tool?.toolName ?? item.name ?? ""))) continue;
|
|
1359
|
+
round.liveSlotToolIds.add(item.id);
|
|
1360
|
+
liveSlotToolIds.add(item.id);
|
|
1361
|
+
if (!tool) continue;
|
|
1362
|
+
lastTool = tool;
|
|
1363
|
+
if (isRunningTool(tool)) runningTool = tool;
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
// 槽位单块:活动思考最新;否则本回合最近的工具(运行中原位增长,完成也留着)。
|
|
1367
|
+
const slotTool = thinkingKids.length > 0 ? undefined : (runningTool ?? lastTool);
|
|
1368
|
+
const cardItems: Array<{ child?: any; tool?: any }> = slotTool
|
|
1369
|
+
? [{ tool: slotTool }]
|
|
1370
|
+
: thinkingKids.map((child) => ({ child }));
|
|
1371
|
+
|
|
1372
|
+
const anchor = round.anchor;
|
|
1373
|
+
const pad = Number(anchor.outputPad) || 0;
|
|
1374
|
+
// 摘要行位置与折叠态一致(正文之后),收尾只有时态变化和槽位卡消失。
|
|
1375
|
+
compactAssistantLine(anchor, getSummary, deps.query);
|
|
1376
|
+
if (cardItems.length > 0) {
|
|
1377
|
+
anchor.contentContainer.addChild(
|
|
1378
|
+
compactLiveSlot(
|
|
1379
|
+
compactRoundCard(
|
|
1380
|
+
cardItems,
|
|
1381
|
+
(tool, innerWidth) => patch.toolOriginalRender.call(tool, innerWidth),
|
|
1382
|
+
false,
|
|
1383
|
+
true,
|
|
1384
|
+
),
|
|
1385
|
+
pad,
|
|
1386
|
+
),
|
|
1387
|
+
);
|
|
1388
|
+
}
|
|
1389
|
+
appendStopStatus(anchor, stopStatus);
|
|
1390
|
+
};
|
|
1391
|
+
|
|
1118
1392
|
const renderRound = (round: CompactRound): void => {
|
|
1119
1393
|
const messages = roundMessages(round);
|
|
1120
1394
|
const stopStatus = roundStopStatus(messages);
|
|
1121
1395
|
if (stopStatus) endRound(round);
|
|
1122
1396
|
// Running 时每次 render 重算时长(含挂钟下限);结束后固定。
|
|
1397
|
+
// 静默期内回合已收尾,摘要行直接给 Ran for,只有槽位卡多留一拍。
|
|
1123
1398
|
const getSummary = () => summarize(roundMessages(round), round.active, round);
|
|
1124
1399
|
const summary = getSummary();
|
|
1125
1400
|
for (const id of round.suppressedToolIds) expandedRoundToolIds.delete(id);
|
|
1126
1401
|
round.suppressedToolIds.clear();
|
|
1127
|
-
//
|
|
1402
|
+
// 回合收起(或本回合不在展开态)时忘掉用户的单开选择,并把面板接管的工具一起收回:
|
|
1403
|
+
// 少了后一步,被点开的工具会带着自身的 expanded 落到 toolInstalledRender 的放行分支,
|
|
1404
|
+
// 面板一收就单独渲染成一张卡。
|
|
1128
1405
|
if (round.anchor.expanded !== true) {
|
|
1129
|
-
|
|
1406
|
+
const ids = roundToolCallIds(round);
|
|
1407
|
+
for (const id of ids) explicitRoundToolIds.delete(id);
|
|
1408
|
+
collapseTools(
|
|
1409
|
+
(tool) =>
|
|
1410
|
+
ids.has(String(tool.toolCallId ?? "")) &&
|
|
1411
|
+
!EDIT_WRITE_TOOLS.has(String(tool.toolName ?? "")),
|
|
1412
|
+
);
|
|
1130
1413
|
}
|
|
1414
|
+
for (const id of round.liveSlotToolIds) liveSlotToolIds.delete(id);
|
|
1415
|
+
round.liveSlotToolIds.clear();
|
|
1131
1416
|
|
|
1132
1417
|
for (const [component, message] of round.messages) {
|
|
1133
1418
|
component.lastMessage = message;
|
|
@@ -1248,21 +1533,12 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
|
|
|
1248
1533
|
}
|
|
1249
1534
|
// Round 展开只打开外层卡片。普通工具保持折叠,避免长输出递归撑满屏幕;
|
|
1250
1535
|
// 用户单独点开的(explicitRoundToolIds)不压回去。
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
EDIT_WRITE_TOOLS.has(String(tool.toolName ?? ""))
|
|
1255
|
-
tool.
|
|
1256
|
-
|
|
1257
|
-
) {
|
|
1258
|
-
continue;
|
|
1259
|
-
}
|
|
1260
|
-
if (typeof tool.setExpanded === "function") tool.setExpanded(false);
|
|
1261
|
-
else {
|
|
1262
|
-
tool.expanded = false;
|
|
1263
|
-
tool.updateDisplay?.();
|
|
1264
|
-
}
|
|
1265
|
-
}
|
|
1536
|
+
collapseTools(
|
|
1537
|
+
(tool) =>
|
|
1538
|
+
ids.has(String(tool.toolCallId ?? "")) &&
|
|
1539
|
+
!EDIT_WRITE_TOOLS.has(String(tool.toolName ?? "")) &&
|
|
1540
|
+
!explicitRoundToolIds.has(String(tool.toolCallId ?? "")),
|
|
1541
|
+
);
|
|
1266
1542
|
flushPanel();
|
|
1267
1543
|
for (const child of anchorChildren) round.anchor.contentContainer.addChild(child);
|
|
1268
1544
|
// 展开卡内工具会显示 error,外层仍挂 abort/length,避免只藏在折叠工具里。
|
|
@@ -1270,6 +1546,12 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
|
|
|
1270
1546
|
return;
|
|
1271
1547
|
}
|
|
1272
1548
|
|
|
1549
|
+
if (roundLive(round)) {
|
|
1550
|
+
// 围观态:单一槽位卡(活动思考预览 + 当前工具),回合结束自动收回。
|
|
1551
|
+
renderRoundLive(round, stopStatus, getSummary);
|
|
1552
|
+
return;
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1273
1555
|
for (const [component, message] of round.messages) {
|
|
1274
1556
|
if (component === round.anchor) {
|
|
1275
1557
|
renderAssistantWithoutThinking(component, message);
|
|
@@ -1284,6 +1566,7 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
|
|
|
1284
1566
|
};
|
|
1285
1567
|
|
|
1286
1568
|
const activateRound = (round: CompactRound): void => {
|
|
1569
|
+
flushSettle();
|
|
1287
1570
|
round.active = true;
|
|
1288
1571
|
if (!round.startedAt) round.startedAt = Date.now();
|
|
1289
1572
|
delete round.endedAt;
|
|
@@ -1295,10 +1578,12 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
|
|
|
1295
1578
|
const finishRound = (round: CompactRound): void => endRound(round, true);
|
|
1296
1579
|
|
|
1297
1580
|
const resetRounds = (): void => {
|
|
1581
|
+
for (const round of [...pendingFoldRounds]) clearFoldTimer(round);
|
|
1298
1582
|
activeRound = undefined;
|
|
1299
1583
|
roundByComponent = new WeakMap();
|
|
1300
1584
|
expandedRoundToolIds.clear();
|
|
1301
1585
|
explicitRoundToolIds.clear();
|
|
1586
|
+
liveSlotToolIds.clear();
|
|
1302
1587
|
deps.query?.setCompactSummaryActive?.(false);
|
|
1303
1588
|
stopRoundTick();
|
|
1304
1589
|
};
|
|
@@ -1337,6 +1622,7 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
|
|
|
1337
1622
|
detachedMessages: [],
|
|
1338
1623
|
active: true,
|
|
1339
1624
|
suppressedToolIds: new Set(),
|
|
1625
|
+
liveSlotToolIds: new Set(),
|
|
1340
1626
|
startedAt: Date.now(),
|
|
1341
1627
|
};
|
|
1342
1628
|
roundByComponent.set(this, round);
|
|
@@ -1348,6 +1634,7 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
|
|
|
1348
1634
|
detachedMessages: [],
|
|
1349
1635
|
active: true,
|
|
1350
1636
|
suppressedToolIds: new Set(),
|
|
1637
|
+
liveSlotToolIds: new Set(),
|
|
1351
1638
|
startedAt: Date.now(),
|
|
1352
1639
|
};
|
|
1353
1640
|
roundByComponent.set(this, round);
|
|
@@ -1371,7 +1658,8 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
|
|
|
1371
1658
|
return renderAssistantWithoutThinking(this, message);
|
|
1372
1659
|
}
|
|
1373
1660
|
if (round) {
|
|
1374
|
-
|
|
1661
|
+
// 围观态下 anchor 还挂着槽位卡,必须重绘才能收回。
|
|
1662
|
+
endRound(round, true);
|
|
1375
1663
|
roundByComponent.delete(this);
|
|
1376
1664
|
return passThroughAssistant(this, message);
|
|
1377
1665
|
}
|
|
@@ -1389,6 +1677,7 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
|
|
|
1389
1677
|
detachedMessages: [],
|
|
1390
1678
|
active: true,
|
|
1391
1679
|
suppressedToolIds: new Set(),
|
|
1680
|
+
liveSlotToolIds: new Set(),
|
|
1392
1681
|
startedAt: Date.now(),
|
|
1393
1682
|
};
|
|
1394
1683
|
roundByComponent.set(this, round);
|
|
@@ -1424,6 +1713,9 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
|
|
|
1424
1713
|
}
|
|
1425
1714
|
// Agent/Task 等同普通工具:折叠不外置(live 面板走独立 widget)。
|
|
1426
1715
|
if (expandedRoundToolIds.has(String(this.toolCallId ?? ""))) return [];
|
|
1716
|
+
// live 围观态:本回合工具交由槽位卡内联渲染,外层不再单独成行,
|
|
1717
|
+
// 卡片因此不会随工具完成反复挂载/卸载。
|
|
1718
|
+
if (liveSlotToolIds.has(String(this.toolCallId ?? ""))) return [];
|
|
1427
1719
|
// 普通工具折叠时不显示独立行(摘要行已统计),独立展开走原 renderer。
|
|
1428
1720
|
if (this.expanded === true) {
|
|
1429
1721
|
return patch.toolOriginalRender.call(this, width);
|
|
@@ -1482,6 +1774,7 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
|
|
|
1482
1774
|
trackedToolComponents.clear();
|
|
1483
1775
|
hoveredAssistantComponent = undefined;
|
|
1484
1776
|
resetRounds();
|
|
1777
|
+
compactRunStatusGetter = undefined;
|
|
1485
1778
|
};
|
|
1486
1779
|
|
|
1487
1780
|
assistantPrototype.updateContent = patch.assistantInstalled;
|
|
@@ -30,7 +30,7 @@ import {
|
|
|
30
30
|
teardownToolMouseInteraction,
|
|
31
31
|
TOOL_MOUSE_DISABLE,
|
|
32
32
|
} from "./mouse/interaction.ts";
|
|
33
|
-
import { getToolMouseTui } from "./mouse/scroll.ts";
|
|
33
|
+
import { getToolMouseTui, noteNewTranscriptItem } from "./mouse/scroll.ts";
|
|
34
34
|
import { setHoveredToolGroup, setHoveredToolIo } from "./mouse/hover.ts";
|
|
35
35
|
import { clearAllAnimations } from "./tool/result.ts";
|
|
36
36
|
import { installWriteOverride, WriteExecutionMetadataStore } from "./tool/diff/index.ts";
|
|
@@ -189,6 +189,13 @@ export default function (
|
|
|
189
189
|
}
|
|
190
190
|
});
|
|
191
191
|
|
|
192
|
+
// 回到底部按钮的累计计数:离开底部期间每落一块内容 +1。
|
|
193
|
+
pi.on("message_start", async (event) => {
|
|
194
|
+
const role = event.message?.role;
|
|
195
|
+
// toolResult 不单独成块(渲染在工具卡内),跳过以免与工具卡重复计数。
|
|
196
|
+
if (role === "user" || role === "assistant" || role === "custom") noteNewTranscriptItem();
|
|
197
|
+
});
|
|
198
|
+
|
|
192
199
|
pi.on("tool_execution_end", async (event) => {
|
|
193
200
|
if (config.mode !== "compact") return;
|
|
194
201
|
// Agent 等工具收尾后延迟刷新,让 compact-thinking 先落最终态。
|
|
@@ -243,6 +250,7 @@ export default function (
|
|
|
243
250
|
|
|
244
251
|
pi.on("tool_execution_start", async (_event, ctx) => {
|
|
245
252
|
installation?.toolGrouping.setTheme(ctx.ui.theme);
|
|
253
|
+
noteNewTranscriptItem();
|
|
246
254
|
});
|
|
247
255
|
|
|
248
256
|
pi.on("session_shutdown", async () => {
|
|
@@ -27,12 +27,19 @@ export function setToolMouseTui(tui: any): void {
|
|
|
27
27
|
patchRegistry.install(TOOL_MOUSE_TUI_SLOT, tui);
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
type ScrollButtonState = {
|
|
30
|
+
type ScrollButtonState = {
|
|
31
|
+
visible: boolean;
|
|
32
|
+
hovered: boolean;
|
|
33
|
+
widget: any;
|
|
34
|
+
/** 按钮可见期间新落的 transcript 块数(消息 + 工具卡)。 */
|
|
35
|
+
newCount: number;
|
|
36
|
+
};
|
|
31
37
|
function scrollButtonState(): ScrollButtonState {
|
|
32
38
|
return patchRegistry.ensure(SCROLL_BUTTON_STATE_SLOT, () => ({
|
|
33
39
|
visible: false,
|
|
34
40
|
hovered: false,
|
|
35
41
|
widget: null,
|
|
42
|
+
newCount: 0,
|
|
36
43
|
}));
|
|
37
44
|
}
|
|
38
45
|
export function getScrollButtonVisible(): boolean {
|
|
@@ -44,8 +51,32 @@ export function getScrollButtonHovered(): boolean {
|
|
|
44
51
|
export function getScrollButtonWidget(): any {
|
|
45
52
|
return scrollButtonState().widget;
|
|
46
53
|
}
|
|
54
|
+
export function getScrollButtonNewCount(): number {
|
|
55
|
+
return scrollButtonState().newCount;
|
|
56
|
+
}
|
|
47
57
|
export function setScrollButtonVisible(visible: boolean): void {
|
|
48
|
-
scrollButtonState()
|
|
58
|
+
const state = scrollButtonState();
|
|
59
|
+
state.visible = visible;
|
|
60
|
+
// 计数只在离开底部期间有效:跟随输出即清零,文案回到 Back to bottom。
|
|
61
|
+
if (!visible) state.newCount = 0;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* 记账一块新落进 transcript 的内容(用户/助手消息、工具卡)。
|
|
66
|
+
* 仅在按钮可见(已滚动离开底部)时累加,并请求重绘让按钮文案立即更新。
|
|
67
|
+
*/
|
|
68
|
+
export function noteNewTranscriptItem(): void {
|
|
69
|
+
const state = scrollButtonState();
|
|
70
|
+
const tui = getToolMouseTui();
|
|
71
|
+
if (!state.visible || !tui) return;
|
|
72
|
+
// 提交新消息时官方可能直接跳回底部(不经过滚动输入):先按实时跟随状态校正,
|
|
73
|
+
// 否则跟随期间的内容会被当成“离开底部时的新消息”计入。
|
|
74
|
+
if (isAtTranscriptBottom(tui)) {
|
|
75
|
+
hideScrollButton(tui);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
state.newCount += 1;
|
|
79
|
+
tui.requestRender?.();
|
|
49
80
|
}
|
|
50
81
|
|
|
51
82
|
/** 返回是否发生变化(调用方据此决定是否需要重渲染)。 */
|
|
@@ -64,6 +95,7 @@ export function resetScrollButtonState(): void {
|
|
|
64
95
|
scrollButtonState().visible = false;
|
|
65
96
|
scrollButtonState().hovered = false;
|
|
66
97
|
scrollButtonState().widget = null;
|
|
98
|
+
scrollButtonState().newCount = 0;
|
|
67
99
|
scrollButtonSyncScheduled = false;
|
|
68
100
|
}
|
|
69
101
|
|
|
@@ -206,12 +238,19 @@ export function updateScrollButtonFromInput(tui: any, data: string): void {
|
|
|
206
238
|
if (matchesKey(data, "enter") || matchesKey(data, "return")) hideScrollButton(tui);
|
|
207
239
|
}
|
|
208
240
|
|
|
241
|
+
/** 无新内容时提示原文案;有新内容时换成累计条数。 */
|
|
242
|
+
function scrollButtonText(): string {
|
|
243
|
+
const count = getScrollButtonNewCount();
|
|
244
|
+
const shortcut = formatShortcut(SCROLL_BOTTOM_SHORTCUT);
|
|
245
|
+
if (count <= 0) return `Back to bottom · ${shortcut}`;
|
|
246
|
+
return `${count} new message${count === 1 ? "" : "s"} · ${shortcut}`;
|
|
247
|
+
}
|
|
248
|
+
|
|
209
249
|
export function renderScrollButton(width: number, theme: any): string[] {
|
|
210
250
|
if (!getScrollButtonVisible() || !fullscreenLazyTui(getToolMouseTui())) return [];
|
|
211
|
-
const shortcut = formatShortcut(SCROLL_BOTTOM_SHORTCUT);
|
|
212
251
|
const label = theme.fg(
|
|
213
252
|
getScrollButtonHovered() ? "text" : "accent",
|
|
214
|
-
`[ ↓
|
|
253
|
+
`[ ↓ ${scrollButtonText()} ]`,
|
|
215
254
|
);
|
|
216
255
|
const leftPad = Math.max(0, Math.floor((width - visibleWidth(label)) / 2));
|
|
217
256
|
return [`${" ".repeat(leftPad)}${truncateToWidth(label, width, "…")}`];
|
|
@@ -45,3 +45,32 @@ export function stripLeadingStatusIcon(line: string): string {
|
|
|
45
45
|
"$1",
|
|
46
46
|
);
|
|
47
47
|
}
|
|
48
|
+
|
|
49
|
+
/** 终端序列零宽(不计入 plain 下标),与 stripAnsi/stripTerminalSequencesPreservingLayout 一致。 */
|
|
50
|
+
const ANY_SEQUENCE_RE = new RegExp(`${CSI_SEQUENCE_RE.source}|${OSC_SEQUENCE_RE.source}`, "g");
|
|
51
|
+
|
|
52
|
+
/** 只保留 [start, end) 之外的字符(base 为 text 首字符在 plain 串中的下标)。 */
|
|
53
|
+
function keepOutsideRange(text: string, base: number, start: number, end: number): string {
|
|
54
|
+
if (base + text.length <= start || base >= end) return text;
|
|
55
|
+
return text.slice(0, Math.max(0, start - base)) + text.slice(Math.max(0, end - base));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 删除 plain 区间 [start, end) 的字符,保留其余字符的 ANSI 样式。
|
|
60
|
+
* 区间内的终端序列原样保留,因此区间后的字符(如收尾括号)沿用前文颜色,
|
|
61
|
+
* 不会像截断后拼纯文本那样掉回终端默认前景色。
|
|
62
|
+
*/
|
|
63
|
+
export function removeStyledRange(styled: string, start: number, end: number): string {
|
|
64
|
+
if (end <= start) return styled;
|
|
65
|
+
let plain = 0;
|
|
66
|
+
let cursor = 0;
|
|
67
|
+
let out = "";
|
|
68
|
+
for (const match of styled.matchAll(ANY_SEQUENCE_RE)) {
|
|
69
|
+
const text = styled.slice(cursor, match.index);
|
|
70
|
+
out += keepOutsideRange(text, plain, start, end);
|
|
71
|
+
plain += text.length;
|
|
72
|
+
out += match[0];
|
|
73
|
+
cursor = match.index + match[0].length;
|
|
74
|
+
}
|
|
75
|
+
return out + keepOutsideRange(styled.slice(cursor), plain, start, end);
|
|
76
|
+
}
|