pi-cc-extensions 0.5.4 → 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;
@@ -412,49 +417,84 @@ function collectToolRenderHits(
412
417
  return start + count;
413
418
  }
414
419
 
420
+ function collectToolComponents(component: any, tools: any[], seen = new Set<any>()): void {
421
+ if (!component || typeof component !== "object" || seen.has(component)) return;
422
+ seen.add(component);
423
+ if (isToolExecutionComponent(component)) {
424
+ tools.push(component);
425
+ return;
426
+ }
427
+ if (!Array.isArray(component.children)) return;
428
+ for (const child of component.children) collectToolComponents(child, tools, seen);
429
+ }
430
+
431
+ function fixedEditorLineMatch(rendered: string, visible: string): boolean {
432
+ return rendered === visible
433
+ || (visible.length >= 8 && (rendered.includes(visible) || visible.includes(rendered)));
434
+ }
435
+
436
+ function fixedEditorContextScore(
437
+ renderedLines: string[],
438
+ renderedRow: number,
439
+ visibleLines: string[],
440
+ visibleRow: number,
441
+ ): number {
442
+ let score = renderedLines[renderedRow] === visibleLines[visibleRow] ? 100 : 50;
443
+ for (const direction of [-1, 1]) {
444
+ for (let distance = 1; distance <= 4; distance++) {
445
+ const candidate = renderedLines[renderedRow + direction * distance];
446
+ const visible = visibleLines[visibleRow + direction * distance];
447
+ if (candidate === undefined || visible === undefined || candidate !== visible) break;
448
+ score += 5 - distance;
449
+ }
450
+ }
451
+ return score;
452
+ }
453
+
415
454
  function findToolAtFixedEditorRow(
416
455
  tui: any,
417
- bufferRow: number,
456
+ visibleRow: number,
418
457
  previousLines: string[],
419
458
  width: number,
420
459
  ): ToolRenderHit | null {
421
- if (bufferRow < 0 || bufferRow >= previousLines.length) return null;
422
- const clickedLine = stripTerminalSequences(String(previousLines[bufferRow] ?? ""));
460
+ if (visibleRow < 0 || visibleRow >= previousLines.length) return null;
461
+ const visibleLines = previousLines.map((line) => stripTerminalSequences(String(line)));
462
+ const clickedLine = visibleLines[visibleRow] ?? "";
423
463
  if (!clickedLine) return null;
424
464
 
425
- const hits: ToolRenderHit[] = [];
426
- collectToolRenderHits(tui, 0, width, hits, new WeakMap<object, number>(), new Set<object>());
427
- const clickedOccurrence = previousLines
428
- .slice(0, bufferRow + 1)
429
- .filter((line) => stripTerminalSequences(String(line)) === clickedLine)
430
- .length;
431
- let matchingOccurrence = 0;
432
-
433
- for (const hit of hits) {
434
- const component = hit.component;
465
+ const tools: any[] = [];
466
+ collectToolComponents(tui, tools);
467
+ let best: { hit: ToolRenderHit; score: number } | null = null;
468
+ for (const component of tools) {
435
469
  const expanded = Boolean(component.expanded);
436
470
  if (!expanded && !/(?:expand|\/ click)/i.test(clickedLine)) continue;
437
- const renderedLines = renderComponentTree(component, width);
438
- const matchingLines = renderedLines.filter((line) => {
439
- const rendered = stripTerminalSequences(String(line));
440
- return rendered === clickedLine
441
- || (clickedLine.length >= 8 && (rendered.includes(clickedLine) || clickedLine.includes(rendered)));
442
- }).length;
443
- if (matchingLines === 0) continue;
444
- matchingOccurrence += matchingLines;
445
- if (clickedOccurrence <= matchingOccurrence) return hit;
471
+ const renderedLines = renderComponentTree(component, width)
472
+ .map((line) => stripTerminalSequences(String(line)));
473
+ for (let renderedRow = 0; renderedRow < renderedLines.length; renderedRow++) {
474
+ if (!fixedEditorLineMatch(renderedLines[renderedRow] ?? "", clickedLine)) continue;
475
+ const score = fixedEditorContextScore(renderedLines, renderedRow, visibleLines, visibleRow);
476
+ if (!best || score > best.score) {
477
+ best = {
478
+ hit: { component, start: visibleRow, end: visibleRow + renderedLines.length },
479
+ score,
480
+ };
481
+ }
482
+ }
446
483
  }
447
- return null;
484
+ return best?.hit ?? null;
448
485
  }
449
486
 
450
487
  function findToolAtScreenRow(tui: any, screenRow: number): ToolRenderHit | null {
451
488
  const previousLines = Array.isArray(tui?.previousLines) ? tui.previousLines : [];
452
- const viewportTop = Number.isFinite(tui?.previousViewportTop) ? tui.previousViewportTop : 0;
453
- const bufferRow = viewportTop + screenRow - 1;
454
489
  const width = Math.max(1, Number(tui?.terminal?.columns) || 80);
455
490
  if (isFixedEditorTui(tui)) {
456
- return findToolAtFixedEditorRow(tui, bufferRow, previousLines, width);
491
+ // Zentui replaces Pi's root render with the already-sliced visible
492
+ // transcript. previousViewportTop remains cursor bookkeeping and must not
493
+ // be added to a physical mouse row here.
494
+ return findToolAtFixedEditorRow(tui, screenRow - 1, previousLines, width);
457
495
  }
496
+ const viewportTop = Number.isFinite(tui?.previousViewportTop) ? tui.previousViewportTop : 0;
497
+ const bufferRow = viewportTop + screenRow - 1;
458
498
  if (bufferRow < 0 || bufferRow >= previousLines.length) return null;
459
499
 
460
500
  const hits: ToolRenderHit[] = [];
@@ -485,6 +525,96 @@ function isFixedEditorTui(tui: any): boolean {
485
525
  return typeof ownRows?.get === "function" && ownRows.get !== inheritedRows?.get;
486
526
  }
487
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
+
488
618
  function updateScrollButtonFromInput(tui: any, data: string): void {
489
619
  if (!isFixedEditorTui(tui)) return;
490
620
 
@@ -497,8 +627,15 @@ function updateScrollButtonFromInput(tui: any, data: string): void {
497
627
  });
498
628
  }
499
629
 
500
- const jumpedBottom = matchesKey(data, "enter") || matchesKey(data, "return");
501
- 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;
502
639
  if (nextVisible !== scrollButtonVisible) {
503
640
  scrollButtonVisible = nextVisible;
504
641
  tui.requestRender?.();
@@ -657,8 +794,7 @@ function jumpToBottomWithoutSubmit(tui: any): boolean {
657
794
  } finally {
658
795
  tui.focusedComponent = focused;
659
796
  }
660
- scrollButtonVisible = false;
661
- tui.requestRender?.();
797
+ hideScrollButton(tui);
662
798
  return true;
663
799
  }
664
800
 
@@ -680,7 +816,11 @@ function toggleToolAtMouseClick(tui: any, packet: SgrMousePacket): boolean {
680
816
 
681
817
  function renderScrollButton(width: number, theme: any): string[] {
682
818
  if (!scrollButtonVisible || !isFixedEditorTui(toolMouseTui)) return [];
683
- 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 ]`);
684
824
  const leftPad = Math.max(0, Math.floor((width - visibleWidth(label)) / 2));
685
825
  return [`${" ".repeat(leftPad)}${truncateToWidth(label, width, "…")}`];
686
826
  }
@@ -702,6 +842,7 @@ function patchToolMouseInputCapture(tui: any): void {
702
842
  const data = args[0];
703
843
  if (typeof data === "string") {
704
844
  updateScrollButtonFromInput(this, data);
845
+ if (isFixedEditorTui(this) && isScrollBottomInput(data) && jumpToBottomWithoutSubmit(this)) return;
705
846
  const packets = parseSgrMousePackets(data);
706
847
  if (packets) {
707
848
  for (const packet of packets) {
@@ -710,7 +851,9 @@ function patchToolMouseInputCapture(tui: any): void {
710
851
  }
711
852
  }
712
853
  }
713
- 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;
714
857
  };
715
858
 
716
859
  try {
@@ -739,8 +882,14 @@ function restoreToolMouseInputCapture(): void {
739
882
  function handleToolMouseInput(data: string): { consume: true } | undefined {
740
883
  if (!toolMouseTui) return undefined;
741
884
  updateScrollButtonFromInput(toolMouseTui, data);
885
+ if (isFixedEditorTui(toolMouseTui) && isScrollBottomInput(data) && jumpToBottomWithoutSubmit(toolMouseTui)) {
886
+ return { consume: true };
887
+ }
742
888
  const packets = parseSgrMousePackets(data);
743
- if (!packets) return undefined;
889
+ if (!packets) {
890
+ scheduleScrollButtonSync(toolMouseTui, data);
891
+ return undefined;
892
+ }
744
893
 
745
894
  let consumed = false;
746
895
  for (const packet of packets) {
@@ -752,6 +901,7 @@ function handleToolMouseInput(data: string): { consume: true } | undefined {
752
901
 
753
902
  // Let scrolling, motion, release, and clicks outside tool results reach the
754
903
  // normal TUI input chain (including other extensions such as pi-zentui).
904
+ scheduleScrollButtonSync(toolMouseTui, data);
755
905
  return consumed ? { consume: true } : undefined;
756
906
  }
757
907
 
@@ -771,6 +921,9 @@ function teardownToolMouseInteraction(): void {
771
921
  restoreToolMouseInputCapture();
772
922
  scrollButtonVisible = false;
773
923
  scrollButtonWidget = null;
924
+ pendingScrollMessages = 0;
925
+ assistantMessageActive = false;
926
+ scrollButtonSyncScheduled = false;
774
927
  toolMouseTui = null;
775
928
  toolMouseUi = null;
776
929
  }
@@ -1334,6 +1487,18 @@ function registerWrappedTool(pi: ExtensionAPI, name: keyof ReturnType<typeof cre
1334
1487
  });
1335
1488
  }
1336
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
+
1337
1502
  export default function (pi: ExtensionAPI) {
1338
1503
  installGlobalToolRendering();
1339
1504
  deactivateLegacyCompactionRendering();
@@ -1388,10 +1553,24 @@ export default function (pi: ExtensionAPI) {
1388
1553
 
1389
1554
  pi.on("session_start", async (_event, ctx) => {
1390
1555
  diffPreviewByCall.clear();
1556
+ pendingScrollMessages = 0;
1557
+ assistantMessageActive = false;
1391
1558
  updateStatus(ctx);
1392
1559
  installToolMouseInteraction(ctx);
1393
1560
  });
1394
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
+
1395
1574
  pi.on("session_shutdown", async () => {
1396
1575
  teardownToolMouseInteraction();
1397
1576
  deactivateGlobalToolRendering();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-cc-extensions",
3
- "version": "0.5.4",
3
+ "version": "0.5.6",
4
4
  "description": "Personal extensions for the Pi coding agent",
5
5
  "type": "module",
6
6
  "keywords": [