pi-cc-extensions 0.5.5 → 0.5.6

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.
@@ -318,6 +318,8 @@ const TOOL_MOUSE_WIDGET_KEY = "ccstyle-tool-mouse";
318
318
  const TOOL_MOUSE_ENABLE = "\x1b[?1000h\x1b[?1006h";
319
319
  const TOOL_MOUSE_DISABLE = "\x1b[?1006l\x1b[?1000l";
320
320
  const ZENTUI_PAGE_UP_INPUT = /^\x1b\[5;9(?::[12])?~$|^\x1b\[57421;9(?::[12])?u$|^\x1b\[1;6A$/;
321
+ const ZENTUI_PAGE_DOWN_INPUT = /^\x1b\[6;9(?::[12])?~$|^\x1b\[57422;9(?::[12])?u$|^\x1b\[1;6B$/;
322
+ const SCROLL_BOTTOM_SHORTCUT = "ctrl+end";
321
323
  let toolMouseTui: any = null;
322
324
  let toolMouseUi: any = null;
323
325
  let toolMouseInputUnsubscribe: (() => void) | null = null;
@@ -326,6 +328,9 @@ let toolMouseInputPatchOriginalHandle: ((...args: any[]) => any) | null = null;
326
328
  let toolMouseInputPatchWrapper: ((...args: any[]) => any) | null = null;
327
329
  let scrollButtonVisible = false;
328
330
  let scrollButtonWidget: any = null;
331
+ let pendingScrollMessages = 0;
332
+ let assistantMessageActive = false;
333
+ let scrollButtonSyncScheduled = false;
329
334
 
330
335
  function parseSgrMousePackets(data: string): SgrMousePacket[] | null {
331
336
  const pattern = /\x1b\[<(\d+);(\d+);(\d+)([Mm])/g;
@@ -520,6 +525,96 @@ function isFixedEditorTui(tui: any): boolean {
520
525
  return typeof ownRows?.get === "function" && ownRows.get !== inheritedRows?.get;
521
526
  }
522
527
 
528
+ function formatShortcut(shortcut: string): string {
529
+ return shortcut
530
+ .split("+")
531
+ .map((part) => part.length <= 1 ? part.toUpperCase() : `${part[0]?.toUpperCase() ?? ""}${part.slice(1)}`)
532
+ .join("+");
533
+ }
534
+
535
+ function isScrollBottomInput(data: string): boolean {
536
+ return matchesKey(data, SCROLL_BOTTOM_SHORTCUT);
537
+ }
538
+
539
+ function isScrollNavigationInput(data: string): boolean {
540
+ if (
541
+ matchesKey(data, "pageUp")
542
+ || matchesKey(data, "pageDown")
543
+ || ZENTUI_PAGE_UP_INPUT.test(data)
544
+ || ZENTUI_PAGE_DOWN_INPUT.test(data)
545
+ ) {
546
+ return true;
547
+ }
548
+ const packets = parseSgrMousePackets(data);
549
+ return Boolean(packets?.some((packet) => {
550
+ const baseButton = packet.code & ~(4 | 8 | 16 | 32);
551
+ return packet.final === "M" && (baseButton === 64 || baseButton === 65);
552
+ }));
553
+ }
554
+
555
+ function directRenderLines(component: any, width: number): string[] {
556
+ try {
557
+ const lines = component?.render?.(width);
558
+ return Array.isArray(lines) ? lines : [];
559
+ } catch {
560
+ return [];
561
+ }
562
+ }
563
+
564
+ /** Render only the transcript portion that fixed-editor leaves scrollable. */
565
+ function renderFixedScrollableRoot(tui: any, width: number): string[] {
566
+ const children = Array.isArray(tui?.children) ? tui.children : [];
567
+ const editorIndex = children.findIndex((child: any) =>
568
+ containsEditorLike(child, tui.focusedComponent),
569
+ );
570
+ const end = editorIndex >= 2 ? editorIndex - 2 : children.length;
571
+ return children.slice(0, end).flatMap((child: any) => directRenderLines(child, width));
572
+ }
573
+
574
+ function isFixedEditorAtBottom(tui: any): boolean {
575
+ if (!isFixedEditorTui(tui)) return true;
576
+ const visibleLines = Array.isArray(tui?.previousLines) ? tui.previousLines : [];
577
+ if (visibleLines.length === 0) return true;
578
+ const width = Math.max(1, Number(tui?.terminal?.columns) || 80);
579
+ const rootLines = renderFixedScrollableRoot(tui, width);
580
+ if (rootLines.length <= visibleLines.length) return true;
581
+ const tail = rootLines.slice(-visibleLines.length);
582
+ return tail.every((line, index) =>
583
+ stripTerminalSequences(String(line)) === stripTerminalSequences(String(visibleLines[index] ?? "")),
584
+ );
585
+ }
586
+
587
+ function hideScrollButton(tui: any): void {
588
+ const changed = scrollButtonVisible || pendingScrollMessages > 0;
589
+ scrollButtonVisible = false;
590
+ pendingScrollMessages = 0;
591
+ if (changed) tui.requestRender?.();
592
+ }
593
+
594
+ function scheduleScrollButtonSync(tui: any, data: string): void {
595
+ if (!scrollButtonVisible || !isScrollNavigationInput(data) || scrollButtonSyncScheduled) return;
596
+ scrollButtonSyncScheduled = true;
597
+ const previousLines = tui.previousLines;
598
+ const check = (attempt: number) => {
599
+ scrollButtonSyncScheduled = false;
600
+ if (toolMouseTui !== tui || !scrollButtonVisible) return;
601
+ // Pi renders on its own frame timer. Do not inspect the old visible window
602
+ // before Zentui has applied the new scroll offset, or PageUp at bottom would
603
+ // immediately hide the button it just requested.
604
+ const rendered = tui.previousLines !== previousLines;
605
+ if (!rendered && attempt < 4) {
606
+ scrollButtonSyncScheduled = true;
607
+ const timer = setTimeout(() => check(attempt + 1), 16);
608
+ if (typeof timer === "object" && timer !== null && "unref" in timer) {
609
+ (timer as { unref: () => void }).unref();
610
+ }
611
+ return;
612
+ }
613
+ if (isFixedEditorAtBottom(tui)) hideScrollButton(tui);
614
+ };
615
+ process.nextTick(() => check(0));
616
+ }
617
+
523
618
  function updateScrollButtonFromInput(tui: any, data: string): void {
524
619
  if (!isFixedEditorTui(tui)) return;
525
620
 
@@ -532,8 +627,15 @@ function updateScrollButtonFromInput(tui: any, data: string): void {
532
627
  });
533
628
  }
534
629
 
535
- const jumpedBottom = matchesKey(data, "enter") || matchesKey(data, "return");
536
- const nextVisible = jumpedBottom ? false : movedUp ? true : scrollButtonVisible;
630
+ const jumpedBottom =
631
+ matchesKey(data, "enter")
632
+ || matchesKey(data, "return")
633
+ || isScrollBottomInput(data);
634
+ if (jumpedBottom) {
635
+ hideScrollButton(tui);
636
+ return;
637
+ }
638
+ const nextVisible = movedUp ? true : scrollButtonVisible;
537
639
  if (nextVisible !== scrollButtonVisible) {
538
640
  scrollButtonVisible = nextVisible;
539
641
  tui.requestRender?.();
@@ -692,8 +794,7 @@ function jumpToBottomWithoutSubmit(tui: any): boolean {
692
794
  } finally {
693
795
  tui.focusedComponent = focused;
694
796
  }
695
- scrollButtonVisible = false;
696
- tui.requestRender?.();
797
+ hideScrollButton(tui);
697
798
  return true;
698
799
  }
699
800
 
@@ -715,7 +816,11 @@ function toggleToolAtMouseClick(tui: any, packet: SgrMousePacket): boolean {
715
816
 
716
817
  function renderScrollButton(width: number, theme: any): string[] {
717
818
  if (!scrollButtonVisible || !isFixedEditorTui(toolMouseTui)) return [];
718
- const label = theme.fg("accent", "[ ↓ Back to bottom · click ]");
819
+ const shortcut = formatShortcut(SCROLL_BOTTOM_SHORTCUT);
820
+ const messageText = pendingScrollMessages > 0
821
+ ? `${pendingScrollMessages} new message${pendingScrollMessages === 1 ? "" : "s"}`
822
+ : "Back to bottom";
823
+ const label = theme.fg("accent", `[ ↓ ${messageText} · ${shortcut} · click ]`);
719
824
  const leftPad = Math.max(0, Math.floor((width - visibleWidth(label)) / 2));
720
825
  return [`${" ".repeat(leftPad)}${truncateToWidth(label, width, "…")}`];
721
826
  }
@@ -737,6 +842,7 @@ function patchToolMouseInputCapture(tui: any): void {
737
842
  const data = args[0];
738
843
  if (typeof data === "string") {
739
844
  updateScrollButtonFromInput(this, data);
845
+ if (isFixedEditorTui(this) && isScrollBottomInput(data) && jumpToBottomWithoutSubmit(this)) return;
740
846
  const packets = parseSgrMousePackets(data);
741
847
  if (packets) {
742
848
  for (const packet of packets) {
@@ -745,7 +851,9 @@ function patchToolMouseInputCapture(tui: any): void {
745
851
  }
746
852
  }
747
853
  }
748
- return Reflect.apply(originalHandle, this, args);
854
+ const result = Reflect.apply(originalHandle, this, args);
855
+ if (typeof data === "string") scheduleScrollButtonSync(this, data);
856
+ return result;
749
857
  };
750
858
 
751
859
  try {
@@ -774,8 +882,14 @@ function restoreToolMouseInputCapture(): void {
774
882
  function handleToolMouseInput(data: string): { consume: true } | undefined {
775
883
  if (!toolMouseTui) return undefined;
776
884
  updateScrollButtonFromInput(toolMouseTui, data);
885
+ if (isFixedEditorTui(toolMouseTui) && isScrollBottomInput(data) && jumpToBottomWithoutSubmit(toolMouseTui)) {
886
+ return { consume: true };
887
+ }
777
888
  const packets = parseSgrMousePackets(data);
778
- if (!packets) return undefined;
889
+ if (!packets) {
890
+ scheduleScrollButtonSync(toolMouseTui, data);
891
+ return undefined;
892
+ }
779
893
 
780
894
  let consumed = false;
781
895
  for (const packet of packets) {
@@ -787,6 +901,7 @@ function handleToolMouseInput(data: string): { consume: true } | undefined {
787
901
 
788
902
  // Let scrolling, motion, release, and clicks outside tool results reach the
789
903
  // normal TUI input chain (including other extensions such as pi-zentui).
904
+ scheduleScrollButtonSync(toolMouseTui, data);
790
905
  return consumed ? { consume: true } : undefined;
791
906
  }
792
907
 
@@ -806,6 +921,9 @@ function teardownToolMouseInteraction(): void {
806
921
  restoreToolMouseInputCapture();
807
922
  scrollButtonVisible = false;
808
923
  scrollButtonWidget = null;
924
+ pendingScrollMessages = 0;
925
+ assistantMessageActive = false;
926
+ scrollButtonSyncScheduled = false;
809
927
  toolMouseTui = null;
810
928
  toolMouseUi = null;
811
929
  }
@@ -1369,6 +1487,18 @@ function registerWrappedTool(pi: ExtensionAPI, name: keyof ReturnType<typeof cre
1369
1487
  });
1370
1488
  }
1371
1489
 
1490
+ function notePendingScrollMessage(role: unknown): void {
1491
+ if (!toolMouseTui || !isFixedEditorTui(toolMouseTui) || !scrollButtonVisible) return;
1492
+ if (role === "assistant") {
1493
+ if (assistantMessageActive) return;
1494
+ assistantMessageActive = true;
1495
+ } else if (role !== "toolResult") {
1496
+ return;
1497
+ }
1498
+ pendingScrollMessages += 1;
1499
+ toolMouseTui.requestRender?.();
1500
+ }
1501
+
1372
1502
  export default function (pi: ExtensionAPI) {
1373
1503
  installGlobalToolRendering();
1374
1504
  deactivateLegacyCompactionRendering();
@@ -1423,10 +1553,24 @@ export default function (pi: ExtensionAPI) {
1423
1553
 
1424
1554
  pi.on("session_start", async (_event, ctx) => {
1425
1555
  diffPreviewByCall.clear();
1556
+ pendingScrollMessages = 0;
1557
+ assistantMessageActive = false;
1426
1558
  updateStatus(ctx);
1427
1559
  installToolMouseInteraction(ctx);
1428
1560
  });
1429
1561
 
1562
+ pi.on("message_start", async (event) => {
1563
+ notePendingScrollMessage(event?.message?.role);
1564
+ });
1565
+
1566
+ pi.on("message_update", async (event) => {
1567
+ if (event?.message?.role === "assistant") notePendingScrollMessage("assistant");
1568
+ });
1569
+
1570
+ pi.on("message_end", async (event) => {
1571
+ if (event?.message?.role === "assistant") assistantMessageActive = false;
1572
+ });
1573
+
1430
1574
  pi.on("session_shutdown", async () => {
1431
1575
  teardownToolMouseInteraction();
1432
1576
  deactivateGlobalToolRendering();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-cc-extensions",
3
- "version": "0.5.5",
3
+ "version": "0.5.6",
4
4
  "description": "Personal extensions for the Pi coding agent",
5
5
  "type": "module",
6
6
  "keywords": [