pi-cc-extensions 0.5.3 → 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
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,6 +678,13 @@ function toggleToolAtMouseClick(tui: any, packet: SgrMousePacket): boolean {
444
678
  return true;
445
679
  }
446
680
 
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, "…")}`];
686
+ }
687
+
447
688
  /**
448
689
  * pi-zentui consumes left-button presses for text selection. Intercept only a
449
690
  * tool-row click at the TUI input boundary, before extension listeners run.
@@ -460,10 +701,12 @@ function patchToolMouseInputCapture(tui: any): void {
460
701
  const wrapper = function (this: any, ...args: any[]): any {
461
702
  const data = args[0];
462
703
  if (typeof data === "string") {
704
+ updateScrollButtonFromInput(this, data);
463
705
  const packets = parseSgrMousePackets(data);
464
706
  if (packets) {
465
707
  for (const packet of packets) {
466
- if (isSgrLeftPress(packet) && toggleToolAtMouseClick(this, packet)) return;
708
+ if (!isSgrLeftPress(packet)) continue;
709
+ if (handleScrollButtonClick(this, packet) || toggleToolAtMouseClick(this, packet)) return;
467
710
  }
468
711
  }
469
712
  }
@@ -495,12 +738,14 @@ function restoreToolMouseInputCapture(): void {
495
738
 
496
739
  function handleToolMouseInput(data: string): { consume: true } | undefined {
497
740
  if (!toolMouseTui) return undefined;
741
+ updateScrollButtonFromInput(toolMouseTui, data);
498
742
  const packets = parseSgrMousePackets(data);
499
743
  if (!packets) return undefined;
500
744
 
501
745
  let consumed = false;
502
746
  for (const packet of packets) {
503
- if (isSgrLeftPress(packet) && toggleToolAtMouseClick(toolMouseTui, packet)) {
747
+ if (!isSgrLeftPress(packet)) continue;
748
+ if (handleScrollButtonClick(toolMouseTui, packet) || toggleToolAtMouseClick(toolMouseTui, packet)) {
504
749
  consumed = true;
505
750
  }
506
751
  }
@@ -524,6 +769,8 @@ function teardownToolMouseInteraction(): void {
524
769
  // The UI context may already have been reset during /reload.
525
770
  }
526
771
  restoreToolMouseInputCapture();
772
+ scrollButtonVisible = false;
773
+ scrollButtonWidget = null;
527
774
  toolMouseTui = null;
528
775
  toolMouseUi = null;
529
776
  }
@@ -534,12 +781,16 @@ function installToolMouseInteraction(ctx: any): void {
534
781
  if (typeof ctx.ui?.onTerminalInput !== "function" || typeof ctx.ui?.setWidget !== "function") return;
535
782
 
536
783
  toolMouseUi = ctx.ui;
537
- ctx.ui.setWidget(TOOL_MOUSE_WIDGET_KEY, (tui: any) => {
784
+ ctx.ui.setWidget(TOOL_MOUSE_WIDGET_KEY, (tui: any, theme: any) => {
538
785
  toolMouseTui = tui;
539
786
  patchToolMouseInputCapture(tui);
540
787
  tui?.terminal?.write?.(TOOL_MOUSE_ENABLE);
541
- // Keep the widget visually empty so Pi's default input bar/layout remains.
542
- return { render: () => [], invalidate() {} };
788
+ const widget = {
789
+ render: (width: number) => renderScrollButton(width, theme),
790
+ invalidate() {},
791
+ };
792
+ scrollButtonWidget = widget;
793
+ return widget;
543
794
  });
544
795
  toolMouseInputUnsubscribe = ctx.ui.onTerminalInput(handleToolMouseInput);
545
796
  }
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.4",
4
4
  "description": "Personal extensions for the Pi coding agent",
5
5
  "type": "module",
6
6
  "keywords": [