pi-cc-extensions 1.0.3 → 1.0.4

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.
@@ -9,13 +9,14 @@ import {
9
9
  getMarkdownTheme,
10
10
  } from "@earendil-works/pi-coding-agent";
11
11
  import { Key, Markdown, matchesKey, visibleWidth } from "@earendil-works/pi-tui";
12
- import { mouseBaseButton, parseSgrMousePacket } from "../utils/sgr-mouse.ts";
12
+ import { type Color, rgbColor } from "@earendil-works/pi-tui";
13
13
  import { padLine } from "../utils/format.ts";
14
14
 
15
15
  export type ContextPart = {
16
16
  label: string;
17
17
  tokens: number;
18
- color: "accent" | "success" | "warning" | "customMessageLabel" | "muted" | "dim" | "error";
18
+ /** 固定 RGB 色值,不走主题 token——避免不同主题撞色。渲染用 theme.style({fg: color})。 */
19
+ color: Color;
19
20
  };
20
21
 
21
22
  type PreviewKey =
@@ -107,6 +108,7 @@ export async function showTextPreview(
107
108
  let totalLines = 1;
108
109
  let escHovered = false;
109
110
  let copyHovered = false;
111
+ let scrollbarHovered = false;
110
112
  let escHitbox: { row: number; startCol: number; endCol: number } | undefined;
111
113
  let copyHitbox: { row: number; startCol: number; endCol: number } | undefined;
112
114
  let scrollbarHitbox:
@@ -141,6 +143,12 @@ export async function showTextPreview(
141
143
  tui.requestRender();
142
144
  };
143
145
 
146
+ const setScrollbarHovered = (hovered: boolean): void => {
147
+ if (hovered === scrollbarHovered) return;
148
+ scrollbarHovered = hovered;
149
+ tui.requestRender();
150
+ };
151
+
144
152
  const copyPreview = (): void => {
145
153
  void copyTextImpl(content)
146
154
  .then(() => {
@@ -168,6 +176,92 @@ export async function showTextPreview(
168
176
  scrollTo(nextOffset);
169
177
  };
170
178
 
179
+ /**
180
+ * 预览鼠标共用处:1-based 屏幕 col/row + 已解码的按键/动作。
181
+ * handleInput(regular 主屏 raw SGR)与 handleMouse(overlay 事件对象)
182
+ * 都收在这里,两条链同一套 esc/copy/滚动条/滚轮行为。
183
+ * 返回 true 表示这次输入被预览消费。
184
+ */
185
+ const handlePreviewMouse = (args: {
186
+ col: number;
187
+ row: number;
188
+ release: boolean;
189
+ motion: boolean;
190
+ /** 基础按键码:0 左键、64/65 滚轮上/下。 */
191
+ baseButton: number;
192
+ }): boolean => {
193
+ const { col, row, release, motion, baseButton } = args;
194
+ if (release) {
195
+ scrollbarDragOffset = null;
196
+ setScrollbarHovered(false);
197
+ return true;
198
+ }
199
+ const overEsc = Boolean(
200
+ escHitbox &&
201
+ row === escHitbox.row &&
202
+ col >= escHitbox.startCol &&
203
+ col <= escHitbox.endCol,
204
+ );
205
+ const overCopy = Boolean(
206
+ copyHitbox &&
207
+ row === copyHitbox.row &&
208
+ col >= copyHitbox.startCol &&
209
+ col <= copyHitbox.endCol,
210
+ );
211
+ setEscHovered(overEsc);
212
+ setCopyHovered(overCopy);
213
+ // 拖动中保持 hover:thumb 一直亮,官方 scrollbarDrag 同理。
214
+ setScrollbarHovered(
215
+ Boolean(
216
+ scrollbarHitbox &&
217
+ col === scrollbarHitbox.col &&
218
+ row >= scrollbarHitbox.startRow &&
219
+ row <= scrollbarHitbox.endRow,
220
+ ) || scrollbarDragOffset !== null,
221
+ );
222
+ if (baseButton === 0 && !motion) {
223
+ if (overEsc) {
224
+ done(undefined);
225
+ return true;
226
+ }
227
+ if (overCopy) {
228
+ copyPreview();
229
+ return true;
230
+ }
231
+ if (
232
+ scrollbarHitbox &&
233
+ col === scrollbarHitbox.col &&
234
+ row >= scrollbarHitbox.startRow &&
235
+ row <= scrollbarHitbox.endRow
236
+ ) {
237
+ const trackRow = row - scrollbarHitbox.startRow;
238
+ const inThumb =
239
+ trackRow >= scrollbarHitbox.thumbStart &&
240
+ trackRow < scrollbarHitbox.thumbStart + scrollbarHitbox.thumbSize;
241
+ scrollbarDragOffset = inThumb
242
+ ? trackRow - scrollbarHitbox.thumbStart
243
+ : Math.floor(scrollbarHitbox.thumbSize / 2);
244
+ dragScrollbarTo(row);
245
+ return true;
246
+ }
247
+ }
248
+ if (motion && scrollbarDragOffset !== null) {
249
+ dragScrollbarTo(row);
250
+ return true;
251
+ }
252
+ if (baseButton === 64) {
253
+ scrollTo(scrollOffset - 3);
254
+ return true;
255
+ }
256
+ if (baseButton === 65) {
257
+ scrollTo(scrollOffset + 3);
258
+ return true;
259
+ }
260
+ // 预览矩形内但未命中任何控件的包也算预览的:返回 true 让 overlay
261
+ // 命中它(hit 成立),官方不再往底下的 transcript 分发。
262
+ return true;
263
+ };
264
+
171
265
  return {
172
266
  invalidate() {
173
267
  markdownView.invalidate();
@@ -183,62 +277,24 @@ export async function showTextPreview(
183
277
  else if (matchesKey(data, "pageDown")) scrollTo(scrollOffset + pageSize);
184
278
  else if (matchesKey(data, Key.home)) scrollTo(0);
185
279
  else if (matchesKey(data, Key.end)) scrollTo(totalLines - pageSize);
186
- else {
187
- const mouse = parseSgrMousePacket(data);
188
- if (!mouse) return;
189
- if (mouse.final === "m") {
190
- scrollbarDragOffset = null;
191
- return;
192
- }
193
- const overEsc = Boolean(
194
- escHitbox &&
195
- mouse.row === escHitbox.row &&
196
- mouse.col >= escHitbox.startCol &&
197
- mouse.col <= escHitbox.endCol,
198
- );
199
- const overCopy = Boolean(
200
- copyHitbox &&
201
- mouse.row === copyHitbox.row &&
202
- mouse.col >= copyHitbox.startCol &&
203
- mouse.col <= copyHitbox.endCol,
204
- );
205
- setEscHovered(overEsc);
206
- setCopyHovered(overCopy);
207
- const button = mouseBaseButton(mouse.code);
208
- const motion = (mouse.code & 32) !== 0;
209
- if (button === 0 && !motion) {
210
- if (overEsc) {
211
- done(undefined);
212
- return;
213
- }
214
- if (overCopy) {
215
- copyPreview();
216
- return;
217
- }
218
- if (
219
- scrollbarHitbox &&
220
- mouse.col === scrollbarHitbox.col &&
221
- mouse.row >= scrollbarHitbox.startRow &&
222
- mouse.row <= scrollbarHitbox.endRow
223
- ) {
224
- const trackRow = mouse.row - scrollbarHitbox.startRow;
225
- const inThumb =
226
- trackRow >= scrollbarHitbox.thumbStart &&
227
- trackRow < scrollbarHitbox.thumbStart + scrollbarHitbox.thumbSize;
228
- scrollbarDragOffset = inThumb
229
- ? trackRow - scrollbarHitbox.thumbStart
230
- : Math.floor(scrollbarHitbox.thumbSize / 2);
231
- dragScrollbarTo(mouse.row);
232
- return;
233
- }
234
- }
235
- if (motion && scrollbarDragOffset !== null) {
236
- dragScrollbarTo(mouse.row);
237
- return;
238
- }
239
- if (button === 64) scrollTo(scrollOffset - 3);
240
- else if (button === 65) scrollTo(scrollOffset + 3);
241
- }
280
+ },
281
+ // overlay 事件对象路径:官方 dispatchMouseToOverlay 把鼠标派到
282
+ // handleMouse,event.x/y 是相对弹框左上角的 0-based 局部坐标,
283
+ // 与 hitbox 同基准,天然对齐官方放置位置。
284
+ handleMouse(event: any) {
285
+ // 一次单击 = press + release + 合成 click。动作已在 press 执行,
286
+ // click 只消费不重复触发(否则 copy/esc 会跑两次)。
287
+ if (event.type === "click") return { handled: true };
288
+ const wheelUp = event.type === "wheel" && Number(event.wheelDelta) < 0;
289
+ const wheelDown = event.type === "wheel" && Number(event.wheelDelta) > 0;
290
+ handlePreviewMouse({
291
+ col: Number(event.x),
292
+ row: Number(event.y),
293
+ release: event.type === "release",
294
+ motion: event.type === "move" || event.type === "drag",
295
+ baseButton: wheelUp ? 64 : wheelDown ? 65 : event.button === "left" ? 0 : -1,
296
+ });
297
+ return { handled: true };
242
298
  },
243
299
 
244
300
  render(width: number) {
@@ -257,16 +313,24 @@ export async function showTextPreview(
257
313
  const wrapped = markdownView.render(bodyWidth);
258
314
  totalLines = wrapped.length;
259
315
  scrollOffset = Math.min(scrollOffset, Math.max(0, totalLines - pageSize));
260
- // Centered overlay with margin 2: mirror TUI resolveOverlayLayout for anchor "center".
261
- const overlayTop = 2 + Math.floor((availableHeight - viewportHeight) / 2);
262
- const overlayLeft = Math.floor((Math.max(1, tui.terminal.columns) - width) / 2);
263
- escHitbox = escCloseHitbox({ left: overlayLeft, top: overlayTop, width });
264
- copyHitbox = copyButtonHitbox({ left: overlayLeft, top: overlayTop, width });
316
+ // hitbox 用弹框局部坐标(0-based):title 固定第 1 行,esc/copy
317
+ // 在行尾,滚动条字符在 body 行右 border 内侧(width-2 列)。
318
+ escHitbox = { row: 1, startCol: width - 6, endCol: width - 2 };
319
+ copyHitbox = {
320
+ row: 1,
321
+ startCol: width - 6 - 1 - visibleWidth(COPY_LABEL),
322
+ endCol: width - 8,
323
+ };
265
324
  const visible = wrapped.slice(scrollOffset, scrollOffset + pageSize);
266
325
  const border = (text: string) => theme.fg("border", text);
267
326
  const scrollable = totalLines > pageSize;
327
+ // thumb 几何对齐官方 getScrollbarGeometry:trackHeight=pageSize、
328
+ // contentHeight=totalLines,minThumb=min(2,track),thumb=round(track²/content)。
268
329
  const thumbSize = scrollable
269
- ? Math.max(1, Math.floor((pageSize * pageSize) / totalLines))
330
+ ? Math.max(
331
+ Math.min(2, pageSize),
332
+ Math.min(pageSize, Math.round((pageSize * pageSize) / totalLines)),
333
+ )
270
334
  : 0;
271
335
  const maxScrollOffset = Math.max(0, totalLines - pageSize);
272
336
  const thumbStart =
@@ -275,9 +339,9 @@ export async function showTextPreview(
275
339
  : 0;
276
340
  scrollbarHitbox = scrollable
277
341
  ? {
278
- col: overlayLeft + width - 1,
279
- startRow: overlayTop + 4,
280
- endRow: overlayTop + 3 + pageSize,
342
+ col: width - 2,
343
+ startRow: 3,
344
+ endRow: 2 + pageSize,
281
345
  thumbStart,
282
346
  thumbSize,
283
347
  maxOffset: maxScrollOffset,
@@ -286,7 +350,10 @@ export async function showTextPreview(
286
350
  const scrollbar = (row: number): string => {
287
351
  if (!scrollable) return " ";
288
352
  const inThumb = row >= thumbStart && row < thumbStart + thumbSize;
289
- return theme.fg(inThumb ? "accent" : "borderMuted", inThumb ? "█" : "│");
353
+ // 对齐官方:thumb 常态 ┃、hover/拖动中 █;track 始终 │。
354
+ const thumbActive = scrollbarHovered || scrollbarDragOffset !== null;
355
+ if (inThumb) return theme.fg("accent", thumbActive ? "█" : "┃");
356
+ return theme.fg("borderMuted", "│");
290
357
  };
291
358
  const bodyRows = Array.from({ length: pageSize }, (_, row) => {
292
359
  const line = visible[row] ?? "";
@@ -294,7 +361,7 @@ export async function showTextPreview(
294
361
  });
295
362
  const start = totalLines === 0 ? 0 : scrollOffset + 1;
296
363
  const end = Math.min(totalLines, scrollOffset + pageSize);
297
- const status = `${start}-${end} / ${totalLines} lines · ↑↓ PgUp/PgDn Home/End · [copy] · [esc] close`;
364
+ const status = `${start}-${end} / ${totalLines} lines · ↑↓ PgUp/PgDn Home/End`;
298
365
 
299
366
  return [
300
367
  border(`╭${"─".repeat(inner)}╮`),
@@ -312,7 +379,7 @@ export async function showTextPreview(
312
379
  overlay: true,
313
380
  overlayOptions: {
314
381
  anchor: "center",
315
- width: "85%",
382
+ width: "80%",
316
383
  minWidth: 50,
317
384
  maxHeight: "80%",
318
385
  margin: 2,
@@ -330,19 +397,15 @@ const tokenEstimate = (value: unknown): number => {
330
397
  return Math.max(0, Math.ceil(text.length / 4));
331
398
  };
332
399
 
333
- /** system 消息对请求的贡献:正文 + 各 section 文本 + 声明的工具 schema,与 pi 的 estimateTokens 同口径。 */
400
+ /** 只算会进请求正文的 content 和 sections。工具 schema 走 tools 参数,由 Tools definition 单独计一次。 */
334
401
  function systemMessageTokens(message: {
335
402
  content?: unknown;
336
403
  sections?: Record<string, string | null> | null;
337
- toolsAdded?: unknown;
338
- toolsRemoved?: unknown;
339
404
  }): number {
340
405
  let chars = typeof message.content === "string" ? message.content.length : 0;
341
406
  for (const section of Object.values(message.sections ?? {})) {
342
407
  if (typeof section === "string") chars += section.length;
343
408
  }
344
- if (message.toolsAdded) chars += JSON.stringify(message.toolsAdded).length;
345
- if (message.toolsRemoved) chars += JSON.stringify(message.toolsRemoved).length;
346
409
  return Math.ceil(chars / 4);
347
410
  }
348
411
 
@@ -527,12 +590,16 @@ export function collectContextBreakdown(
527
590
  systemTokens = Math.max(0, systemTokens);
528
591
  return {
529
592
  parts: [
530
- { label: "System prompt", tokens: systemTokens, color: "accent" },
531
- { label: "Memory", tokens: memoryTokens, color: "error" },
532
- { label: "Skills", tokens: skillsTokens, color: "warning" },
533
- { label: "Tools definition", tokens: toolDefinitionTokens, color: "success" },
534
- { label: "Tool results", tokens: toolResultTokens, color: "customMessageLabel" },
535
- { label: "Context", tokens: contextTokens, color: "warning" },
593
+ { label: "System prompt", tokens: systemTokens, color: rgbColor(0x79, 0xc0, 0xff) },
594
+ { label: "Memory", tokens: memoryTokens, color: rgbColor(0xff, 0x7b, 0x72) },
595
+ { label: "Skills", tokens: skillsTokens, color: rgbColor(0xcb, 0x92, 0x14) },
596
+ {
597
+ label: "Tools definition",
598
+ tokens: toolDefinitionTokens,
599
+ color: rgbColor(0x37, 0xb3, 0x4a),
600
+ },
601
+ { label: "Tool results", tokens: toolResultTokens, color: rgbColor(0xf7, 0x78, 0xba) },
602
+ { label: "Context", tokens: contextTokens, color: rgbColor(0xf0, 0x88, 0x3e) },
536
603
  ] satisfies ContextPart[],
537
604
  previews: {
538
605
  systemPrompt:
@@ -566,8 +633,8 @@ export default function contextUsageExtension(pi: ExtensionAPI) {
566
633
  const free = Math.max(0, contextWindow - used);
567
634
  const allParts = [
568
635
  ...parts,
569
- { label: "Other", tokens: other, color: "muted" as const },
570
- { label: "Free space", tokens: free, color: "dim" as const },
636
+ { label: "Other", tokens: other, color: rgbColor(0x92, 0x9e, 0xab) },
637
+ { label: "Free space", tokens: free, color: rgbColor(0x76, 0x84, 0x94) },
571
638
  ];
572
639
 
573
640
  if (ctx.mode !== "tui") {
@@ -642,6 +709,59 @@ export default function contextUsageExtension(pi: ExtensionAPI) {
642
709
  let escHovered = false;
643
710
  let hoveredKey: PreviewKey | undefined;
644
711
 
712
+ /** context 主弹框鼠标共用处:handleInput(raw SGR)与 handleMouse(overlay
713
+ * 事件对象)同一套 hover/选中/关闭行为。返回 true 表示被预览消费。 */
714
+ const handleDialogMouse = (args: {
715
+ col: number;
716
+ row: number;
717
+ release: boolean;
718
+ motion: boolean;
719
+ baseButton: number;
720
+ }): boolean => {
721
+ const { col, row, release, motion, baseButton } = args;
722
+ if (release) return true;
723
+ if (motion) {
724
+ const overEsc = Boolean(
725
+ escHitbox &&
726
+ row === escHitbox.row &&
727
+ col >= escHitbox.startCol &&
728
+ col <= escHitbox.endCol,
729
+ );
730
+ const hovered = previewHitboxes.find(
731
+ (candidate) =>
732
+ row === candidate.row && col >= candidate.startCol && col <= candidate.endCol,
733
+ );
734
+ if (overEsc !== escHovered || hovered?.key !== hoveredKey) {
735
+ escHovered = overEsc;
736
+ hoveredKey = hovered?.key;
737
+ tui.requestRender();
738
+ }
739
+ return true;
740
+ }
741
+ if (baseButton !== 0) return true;
742
+ if (
743
+ escHitbox &&
744
+ row === escHitbox.row &&
745
+ col >= escHitbox.startCol &&
746
+ col <= escHitbox.endCol
747
+ ) {
748
+ done(undefined);
749
+ return true;
750
+ }
751
+ const hitbox = previewHitboxes.find(
752
+ (candidate) =>
753
+ row === candidate.row && col >= candidate.startCol && col <= candidate.endCol,
754
+ );
755
+ if (hitbox) {
756
+ selectedPreviewIndex = Math.max(
757
+ 0,
758
+ visiblePreviews.findIndex((preview) => preview.key === hitbox.key),
759
+ );
760
+ done(hitbox.key);
761
+ }
762
+ return true;
763
+ };
764
+
645
765
  return {
646
766
  invalidate() {},
647
767
  handleInput(data: string) {
@@ -664,53 +784,20 @@ export default function contextUsageExtension(pi: ExtensionAPI) {
664
784
  done(visiblePreviews[selectedPreviewIndex]?.key);
665
785
  return;
666
786
  }
667
-
668
- const mouse = parseSgrMousePacket(data);
669
- if (!mouse || mouse.final !== "M") return;
670
- if ((mouse.code & 32) !== 0) {
671
- // SGR 1003 的无按键 hover code 为 35,不能按左键事件过滤。
672
- const overEsc = Boolean(
673
- escHitbox &&
674
- mouse.row === escHitbox.row &&
675
- mouse.col >= escHitbox.startCol &&
676
- mouse.col <= escHitbox.endCol,
677
- );
678
- const hovered = previewHitboxes.find(
679
- (candidate) =>
680
- mouse.row === candidate.row &&
681
- mouse.col >= candidate.startCol &&
682
- mouse.col <= candidate.endCol,
683
- );
684
- if (overEsc !== escHovered || hovered?.key !== hoveredKey) {
685
- escHovered = overEsc;
686
- hoveredKey = hovered?.key;
687
- tui.requestRender();
688
- }
689
- return;
690
- }
691
- if (mouseBaseButton(mouse.code) !== 0) return;
692
- if (
693
- escHitbox &&
694
- mouse.row === escHitbox.row &&
695
- mouse.col >= escHitbox.startCol &&
696
- mouse.col <= escHitbox.endCol
697
- ) {
698
- done(undefined);
699
- return;
700
- }
701
- const hitbox = previewHitboxes.find(
702
- (candidate) =>
703
- mouse.row === candidate.row &&
704
- mouse.col >= candidate.startCol &&
705
- mouse.col <= candidate.endCol,
706
- );
707
- if (hitbox) {
708
- selectedPreviewIndex = Math.max(
709
- 0,
710
- visiblePreviews.findIndex((preview) => preview.key === hitbox.key),
711
- );
712
- done(hitbox.key);
713
- }
787
+ },
788
+ // overlay 事件对象路径:event.x/y 是弹框局部 0-based 坐标,
789
+ // 与 hitbox 同基准。click 是 press+release 的合成,动作已在
790
+ // press 做过,只消费。
791
+ handleMouse(event: any) {
792
+ if (event.type === "click") return { handled: true };
793
+ handleDialogMouse({
794
+ col: Number(event.x),
795
+ row: Number(event.y),
796
+ release: event.type === "release",
797
+ motion: event.type === "move" || event.type === "drag",
798
+ baseButton: event.button === "left" ? 0 : -1,
799
+ });
800
+ return { handled: true };
714
801
  },
715
802
  render(width: number) {
716
803
  const inner = Math.max(1, width - 2);
@@ -730,7 +817,7 @@ export default function contextUsageExtension(pi: ExtensionAPI) {
730
817
  Math.round((part.tokens / Math.max(1, contextWindow)) * barWidth),
731
818
  );
732
819
  remaining -= cells;
733
- return theme.fg(part.color, "█".repeat(Math.max(0, cells)));
820
+ return theme.style("█".repeat(Math.max(0, cells)), { fg: part.color });
734
821
  })
735
822
  .join("");
736
823
  const labelWidth = Math.min(
@@ -740,7 +827,7 @@ export default function contextUsageExtension(pi: ExtensionAPI) {
740
827
  const selectedLabel = visiblePreviews[selectedPreviewIndex]?.label;
741
828
  const partRows = allParts.map((part) => {
742
829
  const pct = contextWindow > 0 ? (part.tokens / contextWindow) * 100 : 0;
743
- const swatch = theme.fg(part.color, "■");
830
+ const swatch = theme.style("■", { fg: part.color });
744
831
  const label = part.label.padEnd(labelWidth);
745
832
  const amount = `${formatTokens(part.tokens).padStart(7)} ${pct.toFixed(1).padStart(5)}%`;
746
833
  const selected = part.label === selectedLabel;
@@ -769,10 +856,10 @@ export default function contextUsageExtension(pi: ExtensionAPI) {
769
856
  Math.max(1, terminalHeight - 2),
770
857
  );
771
858
  const visibleHeight = Math.min(lines.length, maxHeight);
772
- const overlayTop =
773
- 1 + Math.floor((Math.max(1, terminalHeight - 2) - visibleHeight) / 2);
774
- const overlayLeft = Math.floor((Math.max(1, tui.terminal.columns) - width) / 2);
775
- escHitbox = escCloseHitbox({ left: overlayLeft, top: overlayTop, width });
859
+ // hitbox 用弹框局部坐标(0-based):esc 在 title 行(第 1 行)
860
+ // 尾部 │ 前 5 列,preview 行对应 partRows(顶 border=0、title=1、
861
+ // ├=2、segments=3、空行=4,part 从 5 起)。不依赖官方放置行列。
862
+ escHitbox = { row: 1, startCol: width - 6, endCol: width - 2 };
776
863
  previewHitboxes = visiblePreviews.flatMap((preview) => {
777
864
  const partIndex = allParts.findIndex((part) => part.label === preview.label);
778
865
  const line = 5 + partIndex;
@@ -780,9 +867,9 @@ export default function contextUsageExtension(pi: ExtensionAPI) {
780
867
  ? [
781
868
  {
782
869
  key: preview.key,
783
- row: overlayTop + line + 1,
784
- startCol: overlayLeft + 1,
785
- endCol: overlayLeft + width,
870
+ row: line,
871
+ startCol: 0,
872
+ endCol: width - 1,
786
873
  },
787
874
  ]
788
875
  : [];
@@ -1201,6 +1201,8 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1201
1201
  };
1202
1202
  let activeRound: CompactRound | undefined;
1203
1203
  let roundByComponent = new WeakMap<object, CompactRound>();
1204
+ /** 本 pass 建起的回合(含被收编的):清扫孤儿尾行的在册名单。 */
1205
+ const liveRounds = new Set<CompactRound>();
1204
1206
  const expandedRoundToolIds = new Set<string>();
1205
1207
  /** 用户显式点开的 round 内工具;强制折叠不压回这些 id。 */
1206
1208
  const explicitRoundToolIds = new Set<string>();
@@ -1259,11 +1261,30 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1259
1261
  pendingFoldRounds.delete(round);
1260
1262
  };
1261
1263
 
1262
- /** 结束回合活动态;不做任何重绘。 */
1263
- const finalizeRound = (round: CompactRound): void => {
1264
+ /** 供采纳为回合边界的 timestamp:必须是看起来像 epoch 毫秒的数(桩数据/秒级会被拒)。 */
1265
+ const isUsableTimestamp = (value: unknown): value is number =>
1266
+ typeof value === "number" &&
1267
+ Number.isFinite(value) &&
1268
+ value >= 1_600_000_000_000 &&
1269
+ value <= Date.now() + 60_000;
1270
+
1271
+ /** 结束回合活动态;不做任何重绘。endTimestamp 用后一段内容的真实时刻收边(回放里
1272
+ * 同一毫秒完成的 create/finalize 差不出真实耗时);缺省取回合内消息的最大 timestamp。 */
1273
+ const finalizeRound = (round: CompactRound, endTimestamp?: number): void => {
1264
1274
  clearFoldTimer(round);
1265
1275
  round.active = false;
1266
- if (round.endedAt === undefined) round.endedAt = Date.now();
1276
+ if (round.endedAt === undefined) {
1277
+ let fallback = 0;
1278
+ for (const message of roundMessages(round)) {
1279
+ if (isUsableTimestamp(message?.timestamp) && message.timestamp > fallback) {
1280
+ fallback = message.timestamp;
1281
+ }
1282
+ }
1283
+ round.endedAt = Math.max(
1284
+ round.startedAt,
1285
+ isUsableTimestamp(endTimestamp) ? endTimestamp : fallback || Date.now(),
1286
+ );
1287
+ }
1267
1288
  if (activeRound === round) {
1268
1289
  activeRound = undefined;
1269
1290
  deps.query?.setCompactSummaryActive?.(false);
@@ -1275,13 +1296,13 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1275
1296
  * 结束回合活动态;render=true 时走静默期,延迟 FOLD_SETTLE_MS 再收拢。
1276
1297
  * 分组语义不受影响:activeRound 立即让位,新消息不会被并进已收尾的回合。
1277
1298
  */
1278
- const endRound = (round: CompactRound, render = false): void => {
1299
+ const endRound = (round: CompactRound, render = false, endTimestamp?: number): void => {
1279
1300
  if (!render || !round.active) {
1280
- finalizeRound(round);
1301
+ finalizeRound(round, endTimestamp);
1281
1302
  if (render) renderRound(round);
1282
1303
  return;
1283
1304
  }
1284
- finalizeRound(round);
1305
+ finalizeRound(round, endTimestamp);
1285
1306
  if (round.foldTimer !== undefined) return;
1286
1307
  round.settleUntil = Date.now() + FOLD_SETTLE_MS;
1287
1308
  pendingFoldRounds.add(round);
@@ -1462,7 +1483,7 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1462
1483
  /** 取并入目标;旧回合被用户消息隔开时先收尾(静默期一转场,新回合立即接手)。 */
1463
1484
  const joinOrCreateRound = (component: any, message: any): CompactRound => {
1464
1485
  const joinable = joinableActiveRound(component);
1465
- if (!joinable && activeRound) finishRound(activeRound);
1486
+ if (!joinable && activeRound) finishRound(activeRound, message?.timestamp);
1466
1487
  return joinable ?? createRound(component, message);
1467
1488
  };
1468
1489
 
@@ -1531,10 +1552,13 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1531
1552
  };
1532
1553
 
1533
1554
  /**
1534
- * 尾行归位。运行中(含收尾静默期)尾行恒为容器最末一行:计数行钉在
1535
- * transcript 底缘,运行中到达的提示行/custom 条目只能落在它上方,计数
1536
- * 不会被顶离底部截成两行。回合收尾后摘要行回落到回合最后一个成员之后,
1537
- * 外来行统一换到摘要行之下,通知行永远落在回合边界外。
1555
+ * 尾行归位。目标位:活回合(运行中/收尾静默期)且未展开 → 容器最末,
1556
+ * 计数行钉在 transcript 可写底缘,运行中到达的提示行/custom 条目只能落在
1557
+ * 它上方,不会被顶离底部截成两行;其余(收尾回合、展开的回合)→ 回合最后
1558
+ * 一个成员之后,外来行统一换到摘要行之下。展开的回合不按 live 推到底:
1559
+ * 展开卡属于回合原位,推进容器末尾会把它挪到别的消息之间。
1560
+ * 尾行已在目标位就不动容器:展开/收起只换 tail.parts,无谓的重摘重插
1561
+ * 本身就是摘要行挪位的来源。
1538
1562
  */
1539
1563
  const syncTailPosition = (round: CompactRound): void => {
1540
1564
  const tail = round.tail;
@@ -1546,11 +1570,20 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1546
1570
  if (!container || !Array.isArray(container.children)) return;
1547
1571
  tail.container = container;
1548
1572
  const children = container.children as any[];
1573
+ const isMember = roundMemberMatcher(round);
1549
1574
  const current = children.indexOf(tail.host);
1575
+ // 摘尾行前先定目标位,已在位就整段跳过。
1576
+ let last = -1;
1577
+ for (let i = 0; i < children.length; i++) {
1578
+ if (children[i] === tail.host || !isMember(children[i])) continue;
1579
+ last = i;
1580
+ }
1581
+ const pinToEnd = roundLive(round) && round.anchor?.expanded !== true;
1582
+ const desired = pinToEnd || last < 0 ? children.length - 1 : last + 1;
1583
+ if (current === desired) return;
1550
1584
  if (current >= 0) children.splice(current, 1);
1551
- const isMember = roundMemberMatcher(round);
1552
1585
  let first = -1;
1553
- let last = -1;
1586
+ last = -1;
1554
1587
  for (let i = 0; i < children.length; i++) {
1555
1588
  if (!isMember(children[i])) continue;
1556
1589
  if (first < 0) first = i;
@@ -1565,7 +1598,7 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1565
1598
  }
1566
1599
  if (lastMember) last = children.indexOf(lastMember);
1567
1600
  const insertAt = last < 0 ? children.length : last + 1;
1568
- if (roundLive(round)) {
1601
+ if (pinToEnd) {
1569
1602
  // 活回合:外来行压到最后成员之后,尾行占容器最末。
1570
1603
  children.splice(insertAt, 0, ...intruders);
1571
1604
  children.push(tail.host);
@@ -1640,7 +1673,7 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1640
1673
  }
1641
1674
  };
1642
1675
 
1643
- /** 新建活回合:literal + 激活 + refresh 重建时的旧回合收编。 */
1676
+ /** 新建活回合:起点用消息真实时刻(回放里 Date.now 会把整轮挤在同一毫秒)。 */
1644
1677
  const createRound = (anchor: any, message: any): CompactRound => {
1645
1678
  const round: CompactRound = {
1646
1679
  anchor,
@@ -1649,8 +1682,9 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1649
1682
  active: true,
1650
1683
  suppressedToolIds: new Set(),
1651
1684
  liveSlotToolIds: new Set(),
1652
- startedAt: Date.now(),
1685
+ startedAt: isUsableTimestamp(message?.timestamp) ? message.timestamp : Date.now(),
1653
1686
  };
1687
+ liveRounds.add(round);
1654
1688
  activateRound(round);
1655
1689
  adoptPriorRound(round, message);
1656
1690
  return round;
@@ -2014,11 +2048,32 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
2014
2048
  ensureRoundTick();
2015
2049
  };
2016
2050
 
2017
- const finishRound = (round: CompactRound): void => endRound(round, true);
2051
+ /** 结束回合活动态;render=true 时走静默期,延迟 FOLD_SETTLE_MS 再收拢。 */
2052
+ const finishRound = (round: CompactRound, endTimestamp?: number): void =>
2053
+ endRound(round, true, endTimestamp);
2054
+
2055
+ /**
2056
+ * 重放/重建结束后的对账:mountedTails 里凡 tail.round 不在本 pass 回合名单的
2057
+ * 都是没被收编的孤儿(anchor 组件没了、消息换成了新对象、回放顺序错位),
2058
+ * 就地摘除。收编过的尾行 round 已过户到新 round,不在此列。
2059
+ */
2060
+ const sweepOrphanTails = (): void => {
2061
+ for (const tail of [...mountedTails]) {
2062
+ const round = tail.round as CompactRound | undefined;
2063
+ if (round && liveRounds.has(round)) continue;
2064
+ removeTailHost(tail);
2065
+ mountedTails.delete(tail);
2066
+ if (round?.anchor && tailByAnchor.get(round.anchor) === tail) {
2067
+ tailByAnchor.delete(round.anchor);
2068
+ }
2069
+ if (round?.tail === tail) round.tail = undefined;
2070
+ }
2071
+ };
2018
2072
 
2019
2073
  const resetRounds = (): void => {
2020
2074
  for (const round of [...pendingFoldRounds]) clearFoldTimer(round);
2021
2075
  activeRound = undefined;
2076
+ liveRounds.clear();
2022
2077
  roundByComponent = new WeakMap();
2023
2078
  expandedRoundToolIds.clear();
2024
2079
  explicitRoundToolIds.clear();
@@ -2088,7 +2143,7 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
2088
2143
  round.messages.delete(this);
2089
2144
  clearRoundAnchor(this);
2090
2145
  }
2091
- if (activeRound) finishRound(activeRound);
2146
+ if (activeRound) finishRound(activeRound, message.timestamp);
2092
2147
  round = createRound(this, message);
2093
2148
  roundByComponent.set(this, round);
2094
2149
  } else if (!round) {
@@ -2097,6 +2152,9 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
2097
2152
  roundByComponent.set(this, round);
2098
2153
  }
2099
2154
  round.messages.set(this, message);
2155
+ if (isUsableTimestamp(message.timestamp) && message.timestamp < round.startedAt) {
2156
+ round.startedAt = message.timestamp;
2157
+ }
2100
2158
  renderRound(round);
2101
2159
  return undefined;
2102
2160
  }
@@ -2111,17 +2169,17 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
2111
2169
  clearRoundAnchor(this);
2112
2170
  round.detachedMessages.push(previousMessage);
2113
2171
  roundByComponent.delete(this);
2114
- finishRound(round);
2172
+ finishRound(round, message.timestamp);
2115
2173
  return renderAssistantWithoutThinking(this, message);
2116
2174
  }
2117
2175
  if (round) {
2118
2176
  // 围观态下 anchor 还挂着槽位卡,必须重绘才能收回。
2119
- endRound(round, true);
2177
+ endRound(round, true, message.timestamp);
2120
2178
  roundByComponent.delete(this);
2121
2179
  clearRoundAnchor(this);
2122
2180
  return passThroughAssistant(this, message);
2123
2181
  }
2124
- if (activeRound) finishRound(activeRound);
2182
+ if (activeRound) finishRound(activeRound, message.timestamp);
2125
2183
  return renderAssistantWithoutThinking(this, message);
2126
2184
  }
2127
2185
 
@@ -2133,6 +2191,9 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
2133
2191
  roundByComponent.set(this, round);
2134
2192
  }
2135
2193
  round.messages.set(this, message);
2194
+ if (isUsableTimestamp(message.timestamp) && message.timestamp < round.startedAt) {
2195
+ round.startedAt = message.timestamp;
2196
+ }
2136
2197
  renderRound(round);
2137
2198
  return undefined;
2138
2199
  }
@@ -2140,7 +2201,7 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
2140
2201
  // 无可见内容的 abort/error/length:直接外层状态行,并结束进行中的回合。
2141
2202
  const loneStatus = messageStopStatus(message);
2142
2203
  if (loneStatus) {
2143
- if (activeRound) finishRound(activeRound);
2204
+ if (activeRound) finishRound(activeRound, message.timestamp);
2144
2205
  self.contentContainer.clear();
2145
2206
  appendStopStatus(this, loneStatus);
2146
2207
  return undefined;
@@ -2265,6 +2326,7 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
2265
2326
  if (config.mode === "compact") syncGlobalExpanded(ctx);
2266
2327
  else hoveredAssistantComponent = undefined;
2267
2328
  refreshTrackedComponents();
2329
+ sweepOrphanTails();
2268
2330
  },
2269
2331
  refresh() {
2270
2332
  if (!patch.active) return;
@@ -2272,6 +2334,7 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
2272
2334
  patch.assertAssistantOwnership();
2273
2335
  if (config.mode !== "compact") hoveredAssistantComponent = undefined;
2274
2336
  refreshTrackedComponents();
2337
+ sweepOrphanTails();
2275
2338
  },
2276
2339
  assertOwnership() {
2277
2340
  if (!patch.active) return;
@@ -191,7 +191,7 @@ const FULLSCREEN_FOCUS_OUT = "\x1b[O";
191
191
 
192
192
  let pendingCollapsePress: {
193
193
  card: any;
194
- /** 命中的最内层组件:官方工具卡由 MouseRegion 的 click 收起。 */
194
+ /** 命中的最内层组件:卡内工具行命中时用于触发官方 click 抑制窗口。 */
195
195
  component: any;
196
196
  col: number;
197
197
  row: number;
@@ -240,22 +240,17 @@ function rememberCollapsePress(card: any, component: any, packet: SgrMousePacket
240
240
 
241
241
  /**
242
242
  * 松手结算:只有"按下与松开在同一格"的完整单击才收起,拖动选择文本时保持展开。
243
- * 官方工具卡(result 区的 MouseRegion)由 guard 在官方 click 里收起,这里必须跳过,
244
- * 否则先收起会被官方 click 的 setExpanded(!expanded) 翻回来。
243
+ * 官方卡与自有卡统一在这里结算:fullscreen 下结算成功的 release 被 consume,
244
+ * 官方不会再合成 click(合成 click 要求按下/松开坐标完全一致,手抖一格就不发,
245
+ * 还会把刚收起的卡 toggle 回去、或误触卡内其他 MouseRegion)。
245
246
  */
246
- function resolveCollapsePress(
247
- tui: any,
248
- packet: SgrMousePacket,
249
- options: { skipOfficialCards: boolean },
250
- ): any {
247
+ function resolveCollapsePress(tui: any, packet: SgrMousePacket): any {
251
248
  const press = pendingCollapsePress;
252
249
  pendingCollapsePress = null;
253
250
  if (!press || packet.final !== "m") return undefined;
254
251
  if (Math.abs(packet.col - press.col) > COLLAPSE_CLICK_MOVE_TOLERANCE) return undefined;
255
252
  if (Math.abs(packet.row - press.row) > COLLAPSE_CLICK_MOVE_TOLERANCE) return undefined;
256
253
  if (Date.now() - press.at > COLLAPSE_CLICK_MAX_MS) return undefined;
257
- if (options.skipOfficialCards && press.component instanceof ToolExecutionComponent)
258
- return undefined;
259
254
  const card = press.card;
260
255
  if (
261
256
  (card instanceof ToolGroupComponent || isCompactAssistantComponent(card)) &&
@@ -265,7 +260,7 @@ function resolveCollapsePress(
265
260
  suppressOfficialCardClickUntil = Date.now() + SUPPRESS_OFFICIAL_CARD_CLICK_MS;
266
261
  }
267
262
  collapseExpandedCard(tui, card);
268
- // 返回收起的卡:调用方只对 compact 面板清官方选区。
263
+ // 返回收起的卡:fullscreen 调用方据此吞掉这次 release 并清官方按下/选区状态。
269
264
  return card;
270
265
  }
271
266
 
@@ -398,7 +393,7 @@ function handleFullscreenToolClick(tui: any, packet: SgrMousePacket): boolean {
398
393
  if (!target) return false;
399
394
  const component = target.component;
400
395
  // compact 展开面板内命中时 owner 是面板:收起对象与记账对象都归面板,
401
- // 否则 skipOfficialCards 会把内部工具当成官方卡跳过。
396
+ // 面板内工具行命中时 release 统一由扩展结算。
402
397
  const card = target.group ?? target.owner ?? component;
403
398
  const pressComponent = target.owner ?? component;
404
399
  // 回到底部按钮:按组件引用命中,不依赖渲染行缓存。
@@ -474,8 +469,8 @@ function handleFullscreenToolClick(tui: any, packet: SgrMousePacket): boolean {
474
469
  });
475
470
  }
476
471
  }
477
- // 其余放行官方:选区、OSC8 链接与 click 合成都交给 renderer。
478
- // 完整单击的收起:官方卡由 guard 处理,自有卡在松手时结算。
472
+ // 其余放行官方:选区、OSC8 链接与官方按下登记都交给 renderer。
473
+ // 完整单击的收起统一在松手结算(release 被 consume,官方不会再合成 click)。
479
474
  rememberCollapsePress(card, pressComponent, packet);
480
475
  return false;
481
476
  }
@@ -584,6 +579,27 @@ function handleFullscreenToolHover(tui: any, packet: SgrMousePacket): void {
584
579
  applyFullscreenHover(tui, target);
585
580
  }
586
581
 
582
+ /**
583
+ * 鼠标包(1-based 屏幕坐标)是否落在某个已渲染 overlay 的矩形内。
584
+ * renderedOverlayLayouts 与官方 dispatchMouseToOverlay 同源:只含上一帧画出的可见 overlay。
585
+ */
586
+ function packetInsideOverlay(tui: any, packet: SgrMousePacket): boolean {
587
+ const layouts = tui?.renderedOverlayLayouts;
588
+ if (!Array.isArray(layouts)) return false;
589
+ const x = packet.col - 1;
590
+ const y = packet.row - 1;
591
+ for (const layout of layouts) {
592
+ if (
593
+ x >= layout.col &&
594
+ x < layout.col + layout.width &&
595
+ y >= layout.row &&
596
+ y < layout.row + layout.height
597
+ )
598
+ return true;
599
+ }
600
+ return false;
601
+ }
602
+
587
603
  /**
588
604
  * 实例级包装 TuiAltScreen.handleViewportInput(惰性 Proxy 安全):
589
605
  * 原型方法取 original(绕开 proxy 函数包装),实例 own property 装 wrapper
@@ -602,24 +618,32 @@ function patchFullscreenViewportInput(tui: any): void {
602
618
  // 官方 viewport 会消费键盘,扩展监听器无法补偿,必须在这里调度。
603
619
  scheduleScrollButtonSync(tui, data);
604
620
  const packets = parseSgrMousePackets(data);
605
- // 官方 fullscreen 会消费全部鼠标;文本预览 overlay 活动时放行给 focused
606
- // custom component,使 [esc] 点击和滚轮可用。
607
- if (packets && tui.hasOverlay?.() && hasActiveTextPreview()) return undefined;
608
- if (packets && !tui.hasOverlay?.()) {
621
+ // overlay(文本预览/context 弹框,都是真 overlay)可见时:落在
622
+ // overlay 矩形内的包经 packetInsideOverlay 放行官方
623
+ // dispatchMouseToOverlay → overlay.handleMouse;overlay 外的包仍
624
+ // 走扩展收起/展开/hover。两条路径各管各的。
625
+ if (packets) {
626
+ // overlay 可见时:落在 overlay 矩形内的包全部放行官方(文本预览的
627
+ // [esc] 点击/滚轮、官方搜索框等),扩展不展开/收起/hover。
628
+ const overlayPackets = tui.hasOverlay?.()
629
+ ? packets.filter((packet) => packetInsideOverlay(tui, packet))
630
+ : undefined;
631
+ const insideOverlay = (packet: SgrMousePacket) => overlayPackets?.includes(packet) === true;
609
632
  for (const packet of packets) {
633
+ if (insideOverlay(packet)) continue;
610
634
  if (isSgrLeftRelease(packet)) {
611
- // 只给 compact 面板清选区:自有面板收起会重排布局,官方选区锚点
612
- // 还停在旧布局上,同一次 release 会结算出多行高亮。
613
- if (
614
- isCompactAssistantComponent(
615
- resolveCollapsePress(tui, packet, { skipOfficialCards: true }),
616
- )
617
- ) {
635
+ // 结算单击收起:成功时吞掉 release,官方不会再对同一次点击合成
636
+ // click(官方 click 要求按下/松开坐标严格一致,还会把刚收起的卡
637
+ // toggle 回去,或误触卡内其他 MouseRegion 区)。
638
+ if (resolveCollapsePress(tui, packet) !== undefined) {
639
+ // 收起会重排布局:官方还留着按下时登记的 press 目标与选区锚点,
640
+ // 发 FOCUS_OUT 一次清干净,避免旧坐标结算出误选/误发 click。
618
641
  try {
619
642
  Reflect.apply(original, this, [FULLSCREEN_FOCUS_OUT]);
620
643
  } catch {
621
644
  /* 老版本没有选区状态 */
622
645
  }
646
+ return { consume: true };
623
647
  }
624
648
  } else if (!isSgrLeftPress(packet) && !isSgrIdleMotion(packet))
625
649
  clearPendingCollapsePressOnMove(packet);
@@ -965,12 +989,15 @@ function handleToolMouseInput(data: string): { consume: true } | undefined {
965
989
  scheduleScrollButtonSync(getToolMouseTui(), data);
966
990
  return undefined;
967
991
  }
992
+ // 文本预览活动时,点击交给预览自己的 handleInput(esc/copy/滚动条),
993
+ // 不被残留的工具 region 吞掉;预览关闭后恢复。
994
+ if (hasActiveTextPreview()) return undefined;
968
995
 
969
996
  let consumed = false;
970
997
  for (const packet of packets) {
971
998
  updateToolSummaryHover(getToolMouseTui(), packet);
972
999
  if (isSgrLeftRelease(packet)) {
973
- resolveCollapsePress(getToolMouseTui(), packet, { skipOfficialCards: false });
1000
+ resolveCollapsePress(getToolMouseTui(), packet);
974
1001
  continue;
975
1002
  }
976
1003
  if (!isSgrLeftPress(packet)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-cc-extensions",
3
- "version": "1.0.3",
3
+ "version": "1.0.4",
4
4
  "license": "MIT",
5
5
  "description": "Claude Code-style TUI output with some personal touches and handy utilities.",
6
6
  "type": "module",