pi-cc-extensions 0.5.2 → 0.5.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.
@@ -12,7 +12,7 @@ import {
12
12
  renderDiff,
13
13
  ToolExecutionComponent,
14
14
  } from "@earendil-works/pi-coding-agent";
15
- import { Text, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
15
+ import { Text, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
16
16
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
17
17
  import { readFile, stat } from "node:fs/promises";
18
18
  import { homedir } from "node:os";
@@ -317,12 +317,15 @@ type ToolRenderHit = {
317
317
  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
+ const ZENTUI_PAGE_UP_INPUT = /^\x1b\[5;9(?::[12])?~$|^\x1b\[57421;9(?::[12])?u$|^\x1b\[1;6A$/;
320
321
  let toolMouseTui: any = null;
321
322
  let toolMouseUi: any = null;
322
323
  let toolMouseInputUnsubscribe: (() => void) | null = null;
323
324
  let toolMouseInputPatchTui: any = null;
324
- let toolMouseInputPatchOriginalAdd: ((...args: any[]) => any) | null = null;
325
+ let toolMouseInputPatchOriginalHandle: ((...args: any[]) => any) | null = null;
325
326
  let toolMouseInputPatchWrapper: ((...args: any[]) => any) | null = null;
327
+ let scrollButtonVisible = false;
328
+ let scrollButtonWidget: any = null;
326
329
 
327
330
  function parseSgrMousePackets(data: string): SgrMousePacket[] | null {
328
331
  const pattern = /\x1b\[<(\d+);(\d+);(\d+)([Mm])/g;
@@ -409,13 +412,51 @@ function collectToolRenderHits(
409
412
  return start + count;
410
413
  }
411
414
 
415
+ function findToolAtFixedEditorRow(
416
+ tui: any,
417
+ bufferRow: number,
418
+ previousLines: string[],
419
+ width: number,
420
+ ): ToolRenderHit | null {
421
+ if (bufferRow < 0 || bufferRow >= previousLines.length) return null;
422
+ const clickedLine = stripTerminalSequences(String(previousLines[bufferRow] ?? ""));
423
+ if (!clickedLine) return null;
424
+
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;
435
+ const expanded = Boolean(component.expanded);
436
+ 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;
446
+ }
447
+ return null;
448
+ }
449
+
412
450
  function findToolAtScreenRow(tui: any, screenRow: number): ToolRenderHit | null {
413
451
  const previousLines = Array.isArray(tui?.previousLines) ? tui.previousLines : [];
414
452
  const viewportTop = Number.isFinite(tui?.previousViewportTop) ? tui.previousViewportTop : 0;
415
453
  const bufferRow = viewportTop + screenRow - 1;
454
+ const width = Math.max(1, Number(tui?.terminal?.columns) || 80);
455
+ if (isFixedEditorTui(tui)) {
456
+ return findToolAtFixedEditorRow(tui, bufferRow, previousLines, width);
457
+ }
416
458
  if (bufferRow < 0 || bufferRow >= previousLines.length) return null;
417
459
 
418
- const width = Math.max(1, Number(tui?.terminal?.columns) || 80);
419
460
  const hits: ToolRenderHit[] = [];
420
461
  const cache = new WeakMap<object, number>();
421
462
  collectToolRenderHits(tui, 0, width, hits, cache, new Set<object>());
@@ -433,6 +474,199 @@ function findToolAtScreenRow(tui: any, screenRow: number): ToolRenderHit | null
433
474
  return null;
434
475
  }
435
476
 
477
+ function isFixedEditorTui(tui: any): boolean {
478
+ const terminal = tui?.terminal;
479
+ if (!terminal) return false;
480
+ const ownRows = Object.getOwnPropertyDescriptor(terminal, "rows");
481
+ const prototype = Object.getPrototypeOf(terminal);
482
+ const inheritedRows = prototype
483
+ ? Object.getOwnPropertyDescriptor(prototype, "rows")
484
+ : undefined;
485
+ return typeof ownRows?.get === "function" && ownRows.get !== inheritedRows?.get;
486
+ }
487
+
488
+ function updateScrollButtonFromInput(tui: any, data: string): void {
489
+ if (!isFixedEditorTui(tui)) return;
490
+
491
+ let movedUp = matchesKey(data, "pageUp") || ZENTUI_PAGE_UP_INPUT.test(data);
492
+ const packets = parseSgrMousePackets(data);
493
+ if (packets) {
494
+ movedUp = packets.some((packet) => {
495
+ const baseButton = packet.code & ~(4 | 8 | 16 | 32);
496
+ return packet.final === "M" && baseButton === 64;
497
+ });
498
+ }
499
+
500
+ const jumpedBottom = matchesKey(data, "enter") || matchesKey(data, "return");
501
+ const nextVisible = jumpedBottom ? false : movedUp ? true : scrollButtonVisible;
502
+ if (nextVisible !== scrollButtonVisible) {
503
+ scrollButtonVisible = nextVisible;
504
+ tui.requestRender?.();
505
+ }
506
+ }
507
+
508
+ function renderComponentTree(component: any, width: number): string[] {
509
+ if (!component || typeof component !== "object") return [];
510
+ try {
511
+ const lines = component.render?.(width);
512
+ if (Array.isArray(lines) && lines.length > 0) return lines;
513
+ } catch {
514
+ // Fall through to children for hidden container renderers.
515
+ }
516
+ if (!Array.isArray(component.children)) return [];
517
+ return component.children.flatMap((child: any) => renderComponentTree(child, width));
518
+ }
519
+
520
+ function renderTreeWithTarget(
521
+ component: any,
522
+ target: any,
523
+ width: number,
524
+ seen = new Set<any>(),
525
+ ): { lines: string[]; targetStart: number | null } {
526
+ if (!component || typeof component !== "object" || seen.has(component)) {
527
+ return { lines: [], targetStart: null };
528
+ }
529
+ seen.add(component);
530
+ if (component === target) {
531
+ return { lines: renderComponentTree(component, width), targetStart: 0 };
532
+ }
533
+
534
+ if (Array.isArray(component.children)) {
535
+ const lines: string[] = [];
536
+ let targetStart: number | null = null;
537
+ for (const child of component.children) {
538
+ const result = renderTreeWithTarget(child, target, width, seen);
539
+ if (result.targetStart !== null) targetStart = lines.length + result.targetStart;
540
+ lines.push(...result.lines);
541
+ }
542
+ if (targetStart !== null) return { lines, targetStart };
543
+ }
544
+
545
+ return { lines: renderComponentTree(component, width), targetStart: null };
546
+ }
547
+
548
+ function normalizedClusterLines(component: any, width: number): string[] {
549
+ if (!component) return [];
550
+ const lines = renderComponentTree(component, width);
551
+ let end = lines.length;
552
+ while (end > 0 && visibleWidth(lines[end - 1] ?? "") === 0) end--;
553
+ return lines.slice(0, Math.max(end, 1));
554
+ }
555
+
556
+ function rawTerminalRows(tui: any): number {
557
+ const terminal = tui?.terminal;
558
+ if (!terminal) return 0;
559
+ const prototype = Object.getPrototypeOf(terminal);
560
+ const rows = prototype ? Object.getOwnPropertyDescriptor(prototype, "rows") : undefined;
561
+ if (typeof rows?.get === "function") {
562
+ try {
563
+ const value = rows.get.call(terminal);
564
+ if (typeof value === "number" && Number.isFinite(value)) return value;
565
+ } catch {
566
+ // Fall through to the current terminal value.
567
+ }
568
+ }
569
+ return typeof terminal.rows === "number" && Number.isFinite(terminal.rows) ? terminal.rows : 0;
570
+ }
571
+
572
+ function containsEditorLike(component: any, focused: any, seen = new Set<any>()): boolean {
573
+ if (!component || typeof component !== "object" || seen.has(component)) return false;
574
+ seen.add(component);
575
+ if (component === focused) return true;
576
+ if (
577
+ typeof component.getText === "function"
578
+ && typeof component.setText === "function"
579
+ && typeof component.handleInput === "function"
580
+ ) return true;
581
+ return Array.isArray(component.children)
582
+ && component.children.some((child: any) => containsEditorLike(child, focused, seen));
583
+ }
584
+
585
+ function scrollButtonScreenRow(tui: any, width: number): number | null {
586
+ if (!scrollButtonVisible || !isFixedEditorTui(tui) || !scrollButtonWidget) return null;
587
+ const children = Array.isArray(tui?.children) ? tui.children : [];
588
+ const editorIndex = children.findIndex((child: any) =>
589
+ containsEditorLike(child, tui.focusedComponent),
590
+ );
591
+ if (editorIndex < 2 || editorIndex + 2 >= children.length) return null;
592
+
593
+ const above = children[editorIndex - 1];
594
+ const widthValue = Math.max(1, width || Number(tui?.terminal?.columns) || 80);
595
+ const target = renderTreeWithTarget(above, scrollButtonWidget, widthValue);
596
+ if (target.targetStart === null) return null;
597
+
598
+ const rawRows = rawTerminalRows(tui);
599
+ if (rawRows <= 0) return null;
600
+ const maxRows = Math.max(1, rawRows - 1);
601
+ const status = normalizedClusterLines(children[editorIndex - 2], widthValue);
602
+ const editor = normalizedClusterLines(children[editorIndex], widthValue);
603
+ const below = normalizedClusterLines(children[editorIndex + 1], widthValue);
604
+ const footer = normalizedClusterLines(children[editorIndex + 2], widthValue);
605
+ const aboveLines = target.lines.length > 0 ? target.lines : normalizedClusterLines(above, widthValue);
606
+
607
+ const takeLast = (lines: string[], count: number): string[] =>
608
+ count > 0 ? lines.slice(-count) : [];
609
+ const editorVisible = takeLast(editor, Math.min(editor.length, maxRows));
610
+ let remaining = Math.max(0, maxRows - editorVisible.length);
611
+ const footerVisible = takeLast(footer, remaining);
612
+ remaining -= footerVisible.length;
613
+ const belowVisible = takeLast(below, remaining);
614
+ remaining -= belowVisible.length;
615
+ const aboveVisible = takeLast(aboveLines, remaining);
616
+ const statusVisible = takeLast(status, Math.max(0, remaining - aboveVisible.length));
617
+ const aboveStart = aboveLines.length - aboveVisible.length;
618
+ const targetRow = target.targetStart - aboveStart;
619
+ if (targetRow < 0 || targetRow >= aboveVisible.length) return null;
620
+
621
+ const allLines = [...statusVisible, ...aboveVisible, ...editorVisible, ...belowVisible, ...footerVisible];
622
+ let leadingBlank = 0;
623
+ while (leadingBlank < allLines.length - 1 && visibleWidth(allLines[leadingBlank] ?? "") === 0) {
624
+ leadingBlank++;
625
+ }
626
+ const clusterRow = statusVisible.length + targetRow - leadingBlank;
627
+ if (clusterRow < 0 || clusterRow >= allLines.length - leadingBlank) return null;
628
+ return rawRows - allLines.length + clusterRow + 1;
629
+ }
630
+
631
+ function isScrollButtonAtScreenRow(tui: any, packet: SgrMousePacket): boolean {
632
+ const width = Math.max(1, Number(tui?.terminal?.columns) || 80);
633
+ if (scrollButtonScreenRow(tui, width) !== packet.row || !scrollButtonWidget) return false;
634
+ const rendered = scrollButtonWidget.render?.(width)?.[0];
635
+ if (typeof rendered !== "string") return false;
636
+ const plain = rendered
637
+ .replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, "")
638
+ .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "");
639
+ const leading = plain.length - plain.trimStart().length;
640
+ const end = visibleWidth(plain.trimEnd());
641
+ return packet.col >= leading + 1 && packet.col <= end;
642
+ }
643
+
644
+ function jumpToBottomWithoutSubmit(tui: any): boolean {
645
+ const originalHandle = toolMouseInputPatchTui === tui
646
+ ? toolMouseInputPatchOriginalHandle
647
+ : null;
648
+ if (!originalHandle) return false;
649
+
650
+ // Route Enter through Pi's normal listener chain so pi-zentui can update its
651
+ // private scroll offset, but suppress the focused editor for this synthetic
652
+ // dispatch so clicking the button never submits the current input.
653
+ const focused = tui.focusedComponent;
654
+ try {
655
+ tui.focusedComponent = null;
656
+ Reflect.apply(originalHandle, tui, ["\r"]);
657
+ } finally {
658
+ tui.focusedComponent = focused;
659
+ }
660
+ scrollButtonVisible = false;
661
+ tui.requestRender?.();
662
+ return true;
663
+ }
664
+
665
+ function handleScrollButtonClick(tui: any, packet: SgrMousePacket): boolean {
666
+ if (!isScrollButtonAtScreenRow(tui, packet)) return false;
667
+ return jumpToBottomWithoutSubmit(tui);
668
+ }
669
+
436
670
  function toggleToolAtMouseClick(tui: any, packet: SgrMousePacket): boolean {
437
671
  const hit = findToolAtScreenRow(tui, packet.row);
438
672
  if (!hit) return false;
@@ -444,68 +678,74 @@ function toggleToolAtMouseClick(tui: any, packet: SgrMousePacket): boolean {
444
678
  return true;
445
679
  }
446
680
 
447
- /**
448
- * Pi TUI input listeners are ordered by registration. pi-zentui's fixed editor
449
- * consumes left-button presses for selection, so a later click listener never
450
- * sees tool rows. Keep this handler first while still returning undefined for
451
- * scroll/selection events so pi-zentui remains the owner of those interactions.
452
- */
453
- function prioritizeToolMouseInput(tui: any): void {
454
- const listeners = tui?.inputListeners;
455
- if (!(listeners instanceof Set) || !listeners.has(handleToolMouseInput)) return;
456
-
457
- const ordered = Array.from(listeners);
458
- if (ordered[0] === handleToolMouseInput) return;
459
- listeners.clear();
460
- listeners.add(handleToolMouseInput);
461
- for (const listener of ordered) {
462
- if (listener !== handleToolMouseInput) listeners.add(listener);
463
- }
681
+ function renderScrollButton(width: number, theme: any): string[] {
682
+ if (!scrollButtonVisible || !isFixedEditorTui(toolMouseTui)) return [];
683
+ const label = theme.fg("accent", "[ ↓ Back to bottom · click ]");
684
+ const leftPad = Math.max(0, Math.floor((width - visibleWidth(label)) / 2));
685
+ return [`${" ".repeat(leftPad)}${truncateToWidth(label, width, "…")}`];
464
686
  }
465
687
 
466
- function patchToolMouseInputPriority(tui: any): void {
467
- if (toolMouseInputPatchTui === tui) {
468
- prioritizeToolMouseInput(tui);
469
- return;
470
- }
688
+ /**
689
+ * pi-zentui consumes left-button presses for text selection. Intercept only a
690
+ * tool-row click at the TUI input boundary, before extension listeners run.
691
+ * Keyboard, wheel, drag, release, and non-tool clicks continue through Pi's
692
+ * original dispatcher, preserving pi-zentui's scroll-to-bottom behavior.
693
+ */
694
+ function patchToolMouseInputCapture(tui: any): void {
695
+ if (toolMouseInputPatchTui === tui) return;
471
696
 
472
- restoreToolMouseInputPriority();
473
- const originalAdd = tui?.addInputListener;
474
- if (typeof originalAdd !== "function") return;
697
+ restoreToolMouseInputCapture();
698
+ const originalHandle = tui?.handleInput;
699
+ if (typeof originalHandle !== "function") return;
475
700
 
476
701
  const wrapper = function (this: any, ...args: any[]): any {
477
- const result = Reflect.apply(originalAdd, this, args);
478
- prioritizeToolMouseInput(this);
479
- return result;
702
+ const data = args[0];
703
+ if (typeof data === "string") {
704
+ updateScrollButtonFromInput(this, data);
705
+ const packets = parseSgrMousePackets(data);
706
+ if (packets) {
707
+ for (const packet of packets) {
708
+ if (!isSgrLeftPress(packet)) continue;
709
+ if (handleScrollButtonClick(this, packet) || toggleToolAtMouseClick(this, packet)) return;
710
+ }
711
+ }
712
+ }
713
+ return Reflect.apply(originalHandle, this, args);
480
714
  };
481
- tui.addInputListener = wrapper;
715
+
716
+ try {
717
+ tui.handleInput = wrapper;
718
+ } catch {
719
+ return;
720
+ }
482
721
  toolMouseInputPatchTui = tui;
483
- toolMouseInputPatchOriginalAdd = originalAdd;
722
+ toolMouseInputPatchOriginalHandle = originalHandle;
484
723
  toolMouseInputPatchWrapper = wrapper;
485
- prioritizeToolMouseInput(tui);
486
724
  }
487
725
 
488
- function restoreToolMouseInputPriority(): void {
726
+ function restoreToolMouseInputCapture(): void {
489
727
  if (
490
728
  toolMouseInputPatchTui
491
- && toolMouseInputPatchOriginalAdd
492
- && toolMouseInputPatchTui.addInputListener === toolMouseInputPatchWrapper
729
+ && toolMouseInputPatchOriginalHandle
730
+ && toolMouseInputPatchTui.handleInput === toolMouseInputPatchWrapper
493
731
  ) {
494
- toolMouseInputPatchTui.addInputListener = toolMouseInputPatchOriginalAdd;
732
+ toolMouseInputPatchTui.handleInput = toolMouseInputPatchOriginalHandle;
495
733
  }
496
734
  toolMouseInputPatchTui = null;
497
- toolMouseInputPatchOriginalAdd = null;
735
+ toolMouseInputPatchOriginalHandle = null;
498
736
  toolMouseInputPatchWrapper = null;
499
737
  }
500
738
 
501
739
  function handleToolMouseInput(data: string): { consume: true } | undefined {
502
740
  if (!toolMouseTui) return undefined;
741
+ updateScrollButtonFromInput(toolMouseTui, data);
503
742
  const packets = parseSgrMousePackets(data);
504
743
  if (!packets) return undefined;
505
744
 
506
745
  let consumed = false;
507
746
  for (const packet of packets) {
508
- if (isSgrLeftPress(packet) && toggleToolAtMouseClick(toolMouseTui, packet)) {
747
+ if (!isSgrLeftPress(packet)) continue;
748
+ if (handleScrollButtonClick(toolMouseTui, packet) || toggleToolAtMouseClick(toolMouseTui, packet)) {
509
749
  consumed = true;
510
750
  }
511
751
  }
@@ -528,7 +768,9 @@ function teardownToolMouseInteraction(): void {
528
768
  } catch {
529
769
  // The UI context may already have been reset during /reload.
530
770
  }
531
- restoreToolMouseInputPriority();
771
+ restoreToolMouseInputCapture();
772
+ scrollButtonVisible = false;
773
+ scrollButtonWidget = null;
532
774
  toolMouseTui = null;
533
775
  toolMouseUi = null;
534
776
  }
@@ -539,12 +781,16 @@ function installToolMouseInteraction(ctx: any): void {
539
781
  if (typeof ctx.ui?.onTerminalInput !== "function" || typeof ctx.ui?.setWidget !== "function") return;
540
782
 
541
783
  toolMouseUi = ctx.ui;
542
- ctx.ui.setWidget(TOOL_MOUSE_WIDGET_KEY, (tui: any) => {
784
+ ctx.ui.setWidget(TOOL_MOUSE_WIDGET_KEY, (tui: any, theme: any) => {
543
785
  toolMouseTui = tui;
544
- patchToolMouseInputPriority(tui);
786
+ patchToolMouseInputCapture(tui);
545
787
  tui?.terminal?.write?.(TOOL_MOUSE_ENABLE);
546
- // Keep the widget visually empty so Pi's default input bar/layout remains.
547
- return { render: () => [], invalidate() {} };
788
+ const widget = {
789
+ render: (width: number) => renderScrollButton(width, theme),
790
+ invalidate() {},
791
+ };
792
+ scrollButtonWidget = widget;
793
+ return widget;
548
794
  });
549
795
  toolMouseInputUnsubscribe = ctx.ui.onTerminalInput(handleToolMouseInput);
550
796
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-cc-extensions",
3
- "version": "0.5.2",
3
+ "version": "0.5.4",
4
4
  "description": "Personal extensions for the Pi coding agent",
5
5
  "type": "module",
6
6
  "keywords": [