pi-cc-extensions 0.5.3 → 0.5.5

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
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,86 @@ function collectToolRenderHits(
409
412
  return start + count;
410
413
  }
411
414
 
415
+ function collectToolComponents(component: any, tools: any[], seen = new Set<any>()): void {
416
+ if (!component || typeof component !== "object" || seen.has(component)) return;
417
+ seen.add(component);
418
+ if (isToolExecutionComponent(component)) {
419
+ tools.push(component);
420
+ return;
421
+ }
422
+ if (!Array.isArray(component.children)) return;
423
+ for (const child of component.children) collectToolComponents(child, tools, seen);
424
+ }
425
+
426
+ function fixedEditorLineMatch(rendered: string, visible: string): boolean {
427
+ return rendered === visible
428
+ || (visible.length >= 8 && (rendered.includes(visible) || visible.includes(rendered)));
429
+ }
430
+
431
+ function fixedEditorContextScore(
432
+ renderedLines: string[],
433
+ renderedRow: number,
434
+ visibleLines: string[],
435
+ visibleRow: number,
436
+ ): number {
437
+ let score = renderedLines[renderedRow] === visibleLines[visibleRow] ? 100 : 50;
438
+ for (const direction of [-1, 1]) {
439
+ for (let distance = 1; distance <= 4; distance++) {
440
+ const candidate = renderedLines[renderedRow + direction * distance];
441
+ const visible = visibleLines[visibleRow + direction * distance];
442
+ if (candidate === undefined || visible === undefined || candidate !== visible) break;
443
+ score += 5 - distance;
444
+ }
445
+ }
446
+ return score;
447
+ }
448
+
449
+ function findToolAtFixedEditorRow(
450
+ tui: any,
451
+ visibleRow: number,
452
+ previousLines: string[],
453
+ width: number,
454
+ ): ToolRenderHit | null {
455
+ if (visibleRow < 0 || visibleRow >= previousLines.length) return null;
456
+ const visibleLines = previousLines.map((line) => stripTerminalSequences(String(line)));
457
+ const clickedLine = visibleLines[visibleRow] ?? "";
458
+ if (!clickedLine) return null;
459
+
460
+ const tools: any[] = [];
461
+ collectToolComponents(tui, tools);
462
+ let best: { hit: ToolRenderHit; score: number } | null = null;
463
+ for (const component of tools) {
464
+ const expanded = Boolean(component.expanded);
465
+ if (!expanded && !/(?:expand|\/ click)/i.test(clickedLine)) continue;
466
+ const renderedLines = renderComponentTree(component, width)
467
+ .map((line) => stripTerminalSequences(String(line)));
468
+ for (let renderedRow = 0; renderedRow < renderedLines.length; renderedRow++) {
469
+ if (!fixedEditorLineMatch(renderedLines[renderedRow] ?? "", clickedLine)) continue;
470
+ const score = fixedEditorContextScore(renderedLines, renderedRow, visibleLines, visibleRow);
471
+ if (!best || score > best.score) {
472
+ best = {
473
+ hit: { component, start: visibleRow, end: visibleRow + renderedLines.length },
474
+ score,
475
+ };
476
+ }
477
+ }
478
+ }
479
+ return best?.hit ?? null;
480
+ }
481
+
412
482
  function findToolAtScreenRow(tui: any, screenRow: number): ToolRenderHit | null {
413
483
  const previousLines = Array.isArray(tui?.previousLines) ? tui.previousLines : [];
484
+ const width = Math.max(1, Number(tui?.terminal?.columns) || 80);
485
+ if (isFixedEditorTui(tui)) {
486
+ // Zentui replaces Pi's root render with the already-sliced visible
487
+ // transcript. previousViewportTop remains cursor bookkeeping and must not
488
+ // be added to a physical mouse row here.
489
+ return findToolAtFixedEditorRow(tui, screenRow - 1, previousLines, width);
490
+ }
414
491
  const viewportTop = Number.isFinite(tui?.previousViewportTop) ? tui.previousViewportTop : 0;
415
492
  const bufferRow = viewportTop + screenRow - 1;
416
493
  if (bufferRow < 0 || bufferRow >= previousLines.length) return null;
417
494
 
418
- const width = Math.max(1, Number(tui?.terminal?.columns) || 80);
419
495
  const hits: ToolRenderHit[] = [];
420
496
  const cache = new WeakMap<object, number>();
421
497
  collectToolRenderHits(tui, 0, width, hits, cache, new Set<object>());
@@ -433,6 +509,199 @@ function findToolAtScreenRow(tui: any, screenRow: number): ToolRenderHit | null
433
509
  return null;
434
510
  }
435
511
 
512
+ function isFixedEditorTui(tui: any): boolean {
513
+ const terminal = tui?.terminal;
514
+ if (!terminal) return false;
515
+ const ownRows = Object.getOwnPropertyDescriptor(terminal, "rows");
516
+ const prototype = Object.getPrototypeOf(terminal);
517
+ const inheritedRows = prototype
518
+ ? Object.getOwnPropertyDescriptor(prototype, "rows")
519
+ : undefined;
520
+ return typeof ownRows?.get === "function" && ownRows.get !== inheritedRows?.get;
521
+ }
522
+
523
+ function updateScrollButtonFromInput(tui: any, data: string): void {
524
+ if (!isFixedEditorTui(tui)) return;
525
+
526
+ let movedUp = matchesKey(data, "pageUp") || ZENTUI_PAGE_UP_INPUT.test(data);
527
+ const packets = parseSgrMousePackets(data);
528
+ if (packets) {
529
+ movedUp = packets.some((packet) => {
530
+ const baseButton = packet.code & ~(4 | 8 | 16 | 32);
531
+ return packet.final === "M" && baseButton === 64;
532
+ });
533
+ }
534
+
535
+ const jumpedBottom = matchesKey(data, "enter") || matchesKey(data, "return");
536
+ const nextVisible = jumpedBottom ? false : movedUp ? true : scrollButtonVisible;
537
+ if (nextVisible !== scrollButtonVisible) {
538
+ scrollButtonVisible = nextVisible;
539
+ tui.requestRender?.();
540
+ }
541
+ }
542
+
543
+ function renderComponentTree(component: any, width: number): string[] {
544
+ if (!component || typeof component !== "object") return [];
545
+ try {
546
+ const lines = component.render?.(width);
547
+ if (Array.isArray(lines) && lines.length > 0) return lines;
548
+ } catch {
549
+ // Fall through to children for hidden container renderers.
550
+ }
551
+ if (!Array.isArray(component.children)) return [];
552
+ return component.children.flatMap((child: any) => renderComponentTree(child, width));
553
+ }
554
+
555
+ function renderTreeWithTarget(
556
+ component: any,
557
+ target: any,
558
+ width: number,
559
+ seen = new Set<any>(),
560
+ ): { lines: string[]; targetStart: number | null } {
561
+ if (!component || typeof component !== "object" || seen.has(component)) {
562
+ return { lines: [], targetStart: null };
563
+ }
564
+ seen.add(component);
565
+ if (component === target) {
566
+ return { lines: renderComponentTree(component, width), targetStart: 0 };
567
+ }
568
+
569
+ if (Array.isArray(component.children)) {
570
+ const lines: string[] = [];
571
+ let targetStart: number | null = null;
572
+ for (const child of component.children) {
573
+ const result = renderTreeWithTarget(child, target, width, seen);
574
+ if (result.targetStart !== null) targetStart = lines.length + result.targetStart;
575
+ lines.push(...result.lines);
576
+ }
577
+ if (targetStart !== null) return { lines, targetStart };
578
+ }
579
+
580
+ return { lines: renderComponentTree(component, width), targetStart: null };
581
+ }
582
+
583
+ function normalizedClusterLines(component: any, width: number): string[] {
584
+ if (!component) return [];
585
+ const lines = renderComponentTree(component, width);
586
+ let end = lines.length;
587
+ while (end > 0 && visibleWidth(lines[end - 1] ?? "") === 0) end--;
588
+ return lines.slice(0, Math.max(end, 1));
589
+ }
590
+
591
+ function rawTerminalRows(tui: any): number {
592
+ const terminal = tui?.terminal;
593
+ if (!terminal) return 0;
594
+ const prototype = Object.getPrototypeOf(terminal);
595
+ const rows = prototype ? Object.getOwnPropertyDescriptor(prototype, "rows") : undefined;
596
+ if (typeof rows?.get === "function") {
597
+ try {
598
+ const value = rows.get.call(terminal);
599
+ if (typeof value === "number" && Number.isFinite(value)) return value;
600
+ } catch {
601
+ // Fall through to the current terminal value.
602
+ }
603
+ }
604
+ return typeof terminal.rows === "number" && Number.isFinite(terminal.rows) ? terminal.rows : 0;
605
+ }
606
+
607
+ function containsEditorLike(component: any, focused: any, seen = new Set<any>()): boolean {
608
+ if (!component || typeof component !== "object" || seen.has(component)) return false;
609
+ seen.add(component);
610
+ if (component === focused) return true;
611
+ if (
612
+ typeof component.getText === "function"
613
+ && typeof component.setText === "function"
614
+ && typeof component.handleInput === "function"
615
+ ) return true;
616
+ return Array.isArray(component.children)
617
+ && component.children.some((child: any) => containsEditorLike(child, focused, seen));
618
+ }
619
+
620
+ function scrollButtonScreenRow(tui: any, width: number): number | null {
621
+ if (!scrollButtonVisible || !isFixedEditorTui(tui) || !scrollButtonWidget) return null;
622
+ const children = Array.isArray(tui?.children) ? tui.children : [];
623
+ const editorIndex = children.findIndex((child: any) =>
624
+ containsEditorLike(child, tui.focusedComponent),
625
+ );
626
+ if (editorIndex < 2 || editorIndex + 2 >= children.length) return null;
627
+
628
+ const above = children[editorIndex - 1];
629
+ const widthValue = Math.max(1, width || Number(tui?.terminal?.columns) || 80);
630
+ const target = renderTreeWithTarget(above, scrollButtonWidget, widthValue);
631
+ if (target.targetStart === null) return null;
632
+
633
+ const rawRows = rawTerminalRows(tui);
634
+ if (rawRows <= 0) return null;
635
+ const maxRows = Math.max(1, rawRows - 1);
636
+ const status = normalizedClusterLines(children[editorIndex - 2], widthValue);
637
+ const editor = normalizedClusterLines(children[editorIndex], widthValue);
638
+ const below = normalizedClusterLines(children[editorIndex + 1], widthValue);
639
+ const footer = normalizedClusterLines(children[editorIndex + 2], widthValue);
640
+ const aboveLines = target.lines.length > 0 ? target.lines : normalizedClusterLines(above, widthValue);
641
+
642
+ const takeLast = (lines: string[], count: number): string[] =>
643
+ count > 0 ? lines.slice(-count) : [];
644
+ const editorVisible = takeLast(editor, Math.min(editor.length, maxRows));
645
+ let remaining = Math.max(0, maxRows - editorVisible.length);
646
+ const footerVisible = takeLast(footer, remaining);
647
+ remaining -= footerVisible.length;
648
+ const belowVisible = takeLast(below, remaining);
649
+ remaining -= belowVisible.length;
650
+ const aboveVisible = takeLast(aboveLines, remaining);
651
+ const statusVisible = takeLast(status, Math.max(0, remaining - aboveVisible.length));
652
+ const aboveStart = aboveLines.length - aboveVisible.length;
653
+ const targetRow = target.targetStart - aboveStart;
654
+ if (targetRow < 0 || targetRow >= aboveVisible.length) return null;
655
+
656
+ const allLines = [...statusVisible, ...aboveVisible, ...editorVisible, ...belowVisible, ...footerVisible];
657
+ let leadingBlank = 0;
658
+ while (leadingBlank < allLines.length - 1 && visibleWidth(allLines[leadingBlank] ?? "") === 0) {
659
+ leadingBlank++;
660
+ }
661
+ const clusterRow = statusVisible.length + targetRow - leadingBlank;
662
+ if (clusterRow < 0 || clusterRow >= allLines.length - leadingBlank) return null;
663
+ return rawRows - allLines.length + clusterRow + 1;
664
+ }
665
+
666
+ function isScrollButtonAtScreenRow(tui: any, packet: SgrMousePacket): boolean {
667
+ const width = Math.max(1, Number(tui?.terminal?.columns) || 80);
668
+ if (scrollButtonScreenRow(tui, width) !== packet.row || !scrollButtonWidget) return false;
669
+ const rendered = scrollButtonWidget.render?.(width)?.[0];
670
+ if (typeof rendered !== "string") return false;
671
+ const plain = rendered
672
+ .replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, "")
673
+ .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "");
674
+ const leading = plain.length - plain.trimStart().length;
675
+ const end = visibleWidth(plain.trimEnd());
676
+ return packet.col >= leading + 1 && packet.col <= end;
677
+ }
678
+
679
+ function jumpToBottomWithoutSubmit(tui: any): boolean {
680
+ const originalHandle = toolMouseInputPatchTui === tui
681
+ ? toolMouseInputPatchOriginalHandle
682
+ : null;
683
+ if (!originalHandle) return false;
684
+
685
+ // Route Enter through Pi's normal listener chain so pi-zentui can update its
686
+ // private scroll offset, but suppress the focused editor for this synthetic
687
+ // dispatch so clicking the button never submits the current input.
688
+ const focused = tui.focusedComponent;
689
+ try {
690
+ tui.focusedComponent = null;
691
+ Reflect.apply(originalHandle, tui, ["\r"]);
692
+ } finally {
693
+ tui.focusedComponent = focused;
694
+ }
695
+ scrollButtonVisible = false;
696
+ tui.requestRender?.();
697
+ return true;
698
+ }
699
+
700
+ function handleScrollButtonClick(tui: any, packet: SgrMousePacket): boolean {
701
+ if (!isScrollButtonAtScreenRow(tui, packet)) return false;
702
+ return jumpToBottomWithoutSubmit(tui);
703
+ }
704
+
436
705
  function toggleToolAtMouseClick(tui: any, packet: SgrMousePacket): boolean {
437
706
  const hit = findToolAtScreenRow(tui, packet.row);
438
707
  if (!hit) return false;
@@ -444,6 +713,13 @@ function toggleToolAtMouseClick(tui: any, packet: SgrMousePacket): boolean {
444
713
  return true;
445
714
  }
446
715
 
716
+ function renderScrollButton(width: number, theme: any): string[] {
717
+ if (!scrollButtonVisible || !isFixedEditorTui(toolMouseTui)) return [];
718
+ const label = theme.fg("accent", "[ ↓ Back to bottom · click ]");
719
+ const leftPad = Math.max(0, Math.floor((width - visibleWidth(label)) / 2));
720
+ return [`${" ".repeat(leftPad)}${truncateToWidth(label, width, "…")}`];
721
+ }
722
+
447
723
  /**
448
724
  * pi-zentui consumes left-button presses for text selection. Intercept only a
449
725
  * tool-row click at the TUI input boundary, before extension listeners run.
@@ -460,10 +736,12 @@ function patchToolMouseInputCapture(tui: any): void {
460
736
  const wrapper = function (this: any, ...args: any[]): any {
461
737
  const data = args[0];
462
738
  if (typeof data === "string") {
739
+ updateScrollButtonFromInput(this, data);
463
740
  const packets = parseSgrMousePackets(data);
464
741
  if (packets) {
465
742
  for (const packet of packets) {
466
- if (isSgrLeftPress(packet) && toggleToolAtMouseClick(this, packet)) return;
743
+ if (!isSgrLeftPress(packet)) continue;
744
+ if (handleScrollButtonClick(this, packet) || toggleToolAtMouseClick(this, packet)) return;
467
745
  }
468
746
  }
469
747
  }
@@ -495,12 +773,14 @@ function restoreToolMouseInputCapture(): void {
495
773
 
496
774
  function handleToolMouseInput(data: string): { consume: true } | undefined {
497
775
  if (!toolMouseTui) return undefined;
776
+ updateScrollButtonFromInput(toolMouseTui, data);
498
777
  const packets = parseSgrMousePackets(data);
499
778
  if (!packets) return undefined;
500
779
 
501
780
  let consumed = false;
502
781
  for (const packet of packets) {
503
- if (isSgrLeftPress(packet) && toggleToolAtMouseClick(toolMouseTui, packet)) {
782
+ if (!isSgrLeftPress(packet)) continue;
783
+ if (handleScrollButtonClick(toolMouseTui, packet) || toggleToolAtMouseClick(toolMouseTui, packet)) {
504
784
  consumed = true;
505
785
  }
506
786
  }
@@ -524,6 +804,8 @@ function teardownToolMouseInteraction(): void {
524
804
  // The UI context may already have been reset during /reload.
525
805
  }
526
806
  restoreToolMouseInputCapture();
807
+ scrollButtonVisible = false;
808
+ scrollButtonWidget = null;
527
809
  toolMouseTui = null;
528
810
  toolMouseUi = null;
529
811
  }
@@ -534,12 +816,16 @@ function installToolMouseInteraction(ctx: any): void {
534
816
  if (typeof ctx.ui?.onTerminalInput !== "function" || typeof ctx.ui?.setWidget !== "function") return;
535
817
 
536
818
  toolMouseUi = ctx.ui;
537
- ctx.ui.setWidget(TOOL_MOUSE_WIDGET_KEY, (tui: any) => {
819
+ ctx.ui.setWidget(TOOL_MOUSE_WIDGET_KEY, (tui: any, theme: any) => {
538
820
  toolMouseTui = tui;
539
821
  patchToolMouseInputCapture(tui);
540
822
  tui?.terminal?.write?.(TOOL_MOUSE_ENABLE);
541
- // Keep the widget visually empty so Pi's default input bar/layout remains.
542
- return { render: () => [], invalidate() {} };
823
+ const widget = {
824
+ render: (width: number) => renderScrollButton(width, theme),
825
+ invalidate() {},
826
+ };
827
+ scrollButtonWidget = widget;
828
+ return widget;
543
829
  });
544
830
  toolMouseInputUnsubscribe = ctx.ui.onTerminalInput(handleToolMouseInput);
545
831
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-cc-extensions",
3
- "version": "0.5.3",
3
+ "version": "0.5.5",
4
4
  "description": "Personal extensions for the Pi coding agent",
5
5
  "type": "module",
6
6
  "keywords": [