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 { hasVisibleText, stripBackgroundAnsi } from "../utils/ansi-text.ts";
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
- const innerWidth = Math.max(0, width - 2);
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
- // 展开的 thinking 带深色内卡(自带上下 padding,不再补外卡分隔行)。
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
- : (line: string) => lines.push(paddedBackgroundRow(theme, slot, line, width));
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 = Math.max(0, width - 2);
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
- let text = theme.fg("muted", plainText);
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)}${theme.fg("muted", tools)}`;
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 endRound = (round: CompactRound, render = false): void => {
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
- if (render) renderRound(round);
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
- if (typeof other.setExpanded === "function") other.setExpanded(false);
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
- for (const id of roundToolCallIds(round)) explicitRoundToolIds.delete(id);
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
- for (const tool of trackedToolComponents) {
1252
- if (
1253
- !ids.has(tool.toolCallId) ||
1254
- EDIT_WRITE_TOOLS.has(String(tool.toolName ?? "")) ||
1255
- tool.expanded !== true ||
1256
- explicitRoundToolIds.has(String(tool.toolCallId ?? ""))
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
- endRound(round);
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 = { visible: boolean; hovered: boolean; widget: any };
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().visible = visible;
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
- `[ ↓ Back to bottom · ${shortcut} ]`,
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-cc-extensions",
3
- "version": "0.9.6",
3
+ "version": "0.9.7",
4
4
  "license": "MIT",
5
5
  "description": "Claude Code-style TUI output with some personal touches and handy utilities.",
6
6
  "type": "module",