pi-cc-extensions 0.8.21 → 0.8.23

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.
@@ -1,9 +1,5 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
- import {
3
- getSettingsListTheme,
4
- keyHint,
5
- ToolExecutionComponent,
6
- } from "@earendil-works/pi-coding-agent";
2
+ import { getSettingsListTheme, ToolExecutionComponent } from "@earendil-works/pi-coding-agent";
7
3
  import {
8
4
  installCompactStyle,
9
5
  type CompactStyleHooks,
@@ -29,12 +25,6 @@ import {
29
25
  wrapTextWithAnsi,
30
26
  } from "@earendil-works/pi-tui";
31
27
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
32
- import {
33
- bottomDialogBounds,
34
- isDialogCloseClick,
35
- renderDialogHeader,
36
- renderDialogTopBorder,
37
- } from "./closable-dialog.ts";
38
28
  import { homedir } from "node:os";
39
29
  import { join } from "node:path";
40
30
 
@@ -241,10 +231,19 @@ function oneLine(value: unknown, max = 72): string {
241
231
  return text.length > max ? `${text.slice(0, max - 1)}…` : text;
242
232
  }
243
233
 
244
- function textFromResult(result: any): string {
234
+ function rawTextFromResult(result: any): string {
245
235
  const item = result?.content?.find?.((c: any) => c.type === "text");
246
- // Compact previews only need line counts / short text; bound sanitize work.
247
- return item?.type === "text" ? sanitizeToolResultText(item.text ?? "", 16_384) : "";
236
+ return item?.type === "text" ? String(item.text ?? "") : "";
237
+ }
238
+
239
+ function textFromResult(result: any): string {
240
+ // Compact previews only need short text; bound sanitize work.
241
+ return sanitizeToolResultText(rawTextFromResult(result), 16_384);
242
+ }
243
+
244
+ export function outputLineCount(result: any): number {
245
+ const text = rawTextFromResult(result).replace(/\r\n?/g, "\n").replace(/\n+$/, "");
246
+ return text ? text.split("\n").length : 0;
248
247
  }
249
248
 
250
249
  function countLines(text: string): number {
@@ -421,6 +420,10 @@ export const SHOW_MORE_LABEL = "[show more]";
421
420
 
422
421
  export type ToolIoSection = "input" | "output";
423
422
 
423
+ // Module-local token: /reload creates a fresh token, so stale view instances are
424
+ // replaced instead of retaining their old render implementation.
425
+ const EXPANDED_TOOL_IO_VIEW_GENERATION = Symbol("ccstyle-expanded-tool-io-view");
426
+
424
427
  /**
425
428
  * Expanded tool body with clear Input / Output sections (Grok Build–style).
426
429
  *
@@ -434,6 +437,7 @@ export type ToolIoSection = "input" | "output";
434
437
  * Reused across re-renders via context.lastComponent when possible.
435
438
  */
436
439
  export class ExpandedToolIoView {
440
+ readonly [EXPANDED_TOOL_IO_VIEW_GENERATION] = true;
437
441
  private inputBody: string;
438
442
  private outputBody: string;
439
443
  private isError: boolean;
@@ -442,6 +446,7 @@ export class ExpandedToolIoView {
442
446
  private maxInputLines: number;
443
447
  private cachedWidth: number | undefined;
444
448
  private cachedLines: string[] | undefined;
449
+ private hoveredSection: ToolIoSection | null = null;
445
450
  /** Which sections currently show the [show more] affordance (after last render). */
446
451
  private truncated: { input: boolean; output: boolean } = { input: false, output: false };
447
452
 
@@ -496,6 +501,12 @@ export class ExpandedToolIoView {
496
501
  return this.outputBody.trim() ? this.outputBody : "Done";
497
502
  }
498
503
 
504
+ setHoveredSection(section: ToolIoSection | null): void {
505
+ if (this.hoveredSection === section) return;
506
+ this.hoveredSection = section;
507
+ this.invalidate();
508
+ }
509
+
499
510
  /** True when the plain header line is a truncated section with [show more]. */
500
511
  matchShowMoreLine(plainLine: string): ToolIoSection | null {
501
512
  const line = plainLine.replace(/\x1b\[[0-9;]*m/g, "");
@@ -523,18 +534,26 @@ export class ExpandedToolIoView {
523
534
  const safeWidth = Math.max(1, Math.floor(width));
524
535
  const rail = " │ ";
525
536
  const railWidth = visibleWidth(rail);
526
- const contentWidth = Math.max(1, safeWidth - railWidth);
537
+ const bodyWidth = Math.max(1, Math.floor(safeWidth * 0.8));
538
+ const contentWidth = Math.max(1, bodyWidth - railWidth);
527
539
  const bodyColor = this.isError ? "error" : "toolOutput";
528
540
  const lines: string[] = [];
529
541
  this.truncated = { input: false, output: false };
530
542
 
531
- const pushHeader = (corner: "┌" | "└", label: string, showMore: boolean) => {
543
+ const pushHeader = (
544
+ corner: "┌" | "└",
545
+ label: string,
546
+ section: ToolIoSection,
547
+ showMore: boolean,
548
+ ) => {
532
549
  const mark = theme.fg("dim", ` ${corner} `);
533
550
  const title = theme.fg(
534
551
  "accent",
535
552
  typeof theme.bold === "function" ? theme.bold(label) : label,
536
553
  );
537
- const more = showMore ? theme.fg("dim", ` ${SHOW_MORE_LABEL}`) : "";
554
+ const more = showMore
555
+ ? theme.fg(this.hoveredSection === section ? "text" : "dim", ` ${SHOW_MORE_LABEL}`)
556
+ : "";
538
557
  lines.push(truncateToWidth(mark + title + more, safeWidth, ""));
539
558
  };
540
559
 
@@ -603,15 +622,15 @@ export class ExpandedToolIoView {
603
622
 
604
623
  if (hasInput) {
605
624
  this.truncated.input = inputWouldTruncate;
606
- pushHeader("┌", "Input", inputWouldTruncate);
625
+ pushHeader("┌", "Input", "input", inputWouldTruncate);
607
626
  pushBody(this.inputBody, { input: true, limit: this.maxInputLines });
608
627
  pushBlankRail();
609
628
  this.truncated.output = outputWouldTruncate;
610
- pushHeader("└", "Output", outputWouldTruncate);
629
+ pushHeader("└", "Output", "output", outputWouldTruncate);
611
630
  pushBody(outputText, { limit: this.maxOutputLines });
612
631
  } else {
613
632
  this.truncated.output = outputWouldTruncate;
614
- pushHeader("┌", "Output", outputWouldTruncate);
633
+ pushHeader("┌", "Output", "output", outputWouldTruncate);
615
634
  pushBody(outputText, { limit: this.maxOutputLines });
616
635
  }
617
636
 
@@ -626,6 +645,18 @@ export class ExpandedToolIoView {
626
645
  }
627
646
  }
628
647
 
648
+ function isExpandedToolIoView(value: unknown): value is ExpandedToolIoView {
649
+ return Boolean(
650
+ value &&
651
+ typeof value === "object" &&
652
+ (value as ExpandedToolIoView)[EXPANDED_TOOL_IO_VIEW_GENERATION] === true &&
653
+ typeof (value as ExpandedToolIoView).getInputBody === "function" &&
654
+ typeof (value as ExpandedToolIoView).getOutputBody === "function" &&
655
+ typeof (value as ExpandedToolIoView).setHoveredSection === "function" &&
656
+ typeof (value as ExpandedToolIoView).render === "function",
657
+ );
658
+ }
659
+
629
660
  /** True when body needs truncation at the given line limit (source lines or wrapped rows). */
630
661
  function bodyExceedsLineLimit(
631
662
  body: string,
@@ -661,6 +692,20 @@ export function renderCollapsedToolResult(body: string, collapsedHint = ""): str
661
692
  return ` ↳ ${body}${collapsedHint}`;
662
693
  }
663
694
 
695
+ export function renderCollapsedToolResultToWidth(
696
+ body: string,
697
+ collapsedHint: string,
698
+ width: number,
699
+ ): string {
700
+ const prefix = " ↳ ";
701
+ const bodyWidth = Math.max(1, width - visibleWidth(prefix) - visibleWidth(collapsedHint));
702
+ return truncateToWidth(
703
+ prefix + middleTruncateToWidth(body, bodyWidth) + collapsedHint,
704
+ width,
705
+ "",
706
+ );
707
+ }
708
+
664
709
  /** Pretty-print tool call args for the expanded Input section. */
665
710
  export function formatToolInputArgs(args: unknown, maxChars = 8_000): string {
666
711
  if (args === undefined || args === null) return "";
@@ -758,7 +803,7 @@ function renderExpandedToolResult(
758
803
  // Prefer structured Input/Output when we have args or non-empty output.
759
804
  if (inputBody.trim() || outputBody.trim()) {
760
805
  let view: ExpandedToolIoView;
761
- if (lastComponent instanceof ExpandedToolIoView) {
806
+ if (isExpandedToolIoView(lastComponent)) {
762
807
  lastComponent.setContent(inputBody, outputBody, isError, maxLines, maxLines);
763
808
  view = lastComponent;
764
809
  } else {
@@ -773,10 +818,14 @@ function renderExpandedToolResult(
773
818
  return new Text(theme.fg(color, renderCollapsedToolResult("Done")), 0, 0);
774
819
  }
775
820
 
776
- function expandHint(theme: any): string {
821
+ export function formatExpandHint(lineCount: number): string {
822
+ return ` (${lineCount} more line${lineCount === 1 ? "" : "s"} / click)`;
823
+ }
824
+
825
+ function expandHint(theme: any, lineCount: number, hovered = false): string {
777
826
  // Keep interaction guidance neutral; it should not inherit success/error
778
827
  // coloring from the tool result surrounding it.
779
- return `${theme.fg("muted", " (")}${keyHint("app.tools.expand", "expand")}${theme.fg("muted", " / click)")}`;
828
+ return theme.fg(hovered ? "text" : "muted", formatExpandHint(lineCount));
780
829
  }
781
830
 
782
831
  type SgrMousePacket = {
@@ -790,10 +839,12 @@ type ToolRenderHit = {
790
839
  component: any;
791
840
  start: number;
792
841
  end: number;
842
+ row: number;
793
843
  };
794
844
 
795
845
  const TOOL_MOUSE_WIDGET_KEY = "ccstyle-tool-mouse";
796
846
  const TOOL_MOUSE_ENABLE = "\x1b[?1000h\x1b[?1003h\x1b[?1006h";
847
+ const TOOL_MOUSE_MOTION_ENABLE = "\x1b[?1003h\x1b[?1006h";
797
848
  const TOOL_MOUSE_DISABLE = "\x1b[?1006l\x1b[?1003l\x1b[?1000l";
798
849
  const ZENTUI_PAGE_UP_INPUT = /^\x1b\[5;9(?::[12])?~$|^\x1b\[57421;9(?::[12])?u$|^\x1b\[1;6A$/;
799
850
  const ZENTUI_PAGE_DOWN_INPUT = /^\x1b\[6;9(?::[12])?~$|^\x1b\[57422;9(?::[12])?u$|^\x1b\[1;6B$/;
@@ -810,6 +861,10 @@ let toolMouseInputUnsubscribe: (() => void) | null = null;
810
861
  let toolMouseInputPatchTui: any = null;
811
862
  let toolMouseInputPatchOriginalHandle: ((...args: any[]) => any) | null = null;
812
863
  let toolMouseInputPatchWrapper: ((...args: any[]) => any) | null = null;
864
+ let toolMouseRenderPatchTui: any = null;
865
+ let toolMouseRenderPatchOriginal: ((...args: any[]) => any) | null = null;
866
+ let toolMouseRenderPatchWrapper: ((...args: any[]) => any) | null = null;
867
+ let toolMouseRawWrite: ((data: string) => unknown) | null = null;
813
868
  let scrollButtonVisible = false;
814
869
  let scrollButtonWidget: any = null;
815
870
  let pendingScrollMessages = 0;
@@ -842,12 +897,14 @@ function isSgrLeftPress(packet: SgrMousePacket): boolean {
842
897
  return packet.final === "M" && baseButton === 0 && (packet.code & 32) === 0;
843
898
  }
844
899
 
845
- function stripTerminalSequences(value: string): string {
900
+ function stripTerminalSequencesPreservingLayout(value: string): string {
846
901
  return value
847
902
  .replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, "")
848
- .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
849
- .replace(/\s+/g, " ")
850
- .trim();
903
+ .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "");
904
+ }
905
+
906
+ function stripTerminalSequences(value: string): string {
907
+ return stripTerminalSequencesPreservingLayout(value).replace(/\s+/g, " ").trim();
851
908
  }
852
909
 
853
910
  function isToolExecutionComponent(value: any): boolean {
@@ -889,7 +946,7 @@ function collectToolRenderHits(
889
946
 
890
947
  const count = renderedLineCount(component, width, cache);
891
948
  if (isToolExecutionComponent(component)) {
892
- if (count > 0) hits.push({ component, start, end: start + count });
949
+ if (count > 0) hits.push({ component, start, end: start + count, row: 0 });
893
950
  return start + count;
894
951
  }
895
952
 
@@ -952,8 +1009,10 @@ function findToolAtFixedEditorRow(
952
1009
  ): ToolRenderHit | null {
953
1010
  if (visibleRow < 0 || visibleRow >= previousLines.length) return null;
954
1011
  const visibleLines = previousLines.map((line) => stripTerminalSequences(String(line)));
1012
+ const rawClickedLine = String(previousLines[visibleRow] ?? "");
955
1013
  const clickedLine = visibleLines[visibleRow] ?? "";
956
- if (!clickedLine) return null;
1014
+ const hasBackground = /\x1b\[(?:4[0-8]|10[0-7])(?:;[0-9;:]*)?m/.test(rawClickedLine);
1015
+ if (!clickedLine && !hasBackground) return null;
957
1016
 
958
1017
  const tools: any[] = [];
959
1018
  collectToolComponents(tui, tools);
@@ -970,7 +1029,12 @@ function findToolAtFixedEditorRow(
970
1029
  const score = fixedEditorContextScore(renderedLines, renderedRow, visibleLines, visibleRow);
971
1030
  if (!best || score > best.score) {
972
1031
  best = {
973
- hit: { component, start: visibleRow, end: visibleRow + renderedLines.length },
1032
+ hit: {
1033
+ component,
1034
+ start: visibleRow,
1035
+ end: visibleRow + renderedLines.length,
1036
+ row: renderedRow,
1037
+ },
974
1038
  score,
975
1039
  };
976
1040
  if (score >= TOOL_CLICK_EARLY_EXIT_SCORE) return best.hit;
@@ -1003,7 +1067,7 @@ function findToolAtScreenRow(tui: any, screenRow: number): ToolRenderHit | null
1003
1067
  // The stable summary marker keeps the whole row clickable even when the
1004
1068
  // trailing keyboard/click hint was truncated (common for subagent output).
1005
1069
  if (!hit.component.expanded && !COLLAPSED_TOOL_SUMMARY.test(clickedLine)) continue;
1006
- return hit;
1070
+ return { ...hit, row: bufferRow - hit.start };
1007
1071
  }
1008
1072
  return null;
1009
1073
  }
@@ -1370,6 +1434,8 @@ function scheduleCollapseViewportCompensation(
1370
1434
 
1371
1435
  /** toolCallId → latest expanded IO view (survives context/state identity quirks). */
1372
1436
  const ioViewsByToolCallId = new Map<string, ExpandedToolIoView>();
1437
+ let hoveredToolIoView: ExpandedToolIoView | null = null;
1438
+ let hoveredToolIoSection: ToolIoSection | null = null;
1373
1439
 
1374
1440
  function rememberIoView(context: any, view: ExpandedToolIoView): void {
1375
1441
  if (!context || typeof context !== "object") return;
@@ -1393,7 +1459,7 @@ function rememberIoView(context: any, view: ExpandedToolIoView): void {
1393
1459
 
1394
1460
  function resolveIoViewFromTool(component: any): ExpandedToolIoView | null {
1395
1461
  const fromState = component?.state?.ccstyleIoView;
1396
- if (fromState instanceof ExpandedToolIoView) return fromState;
1462
+ if (isExpandedToolIoView(fromState)) return fromState;
1397
1463
  const id =
1398
1464
  (typeof component?.toolCallId === "string" && component.toolCallId) ||
1399
1465
  (typeof component?.state?.toolCallId === "string" && component.state.toolCallId) ||
@@ -1404,7 +1470,7 @@ function resolveIoViewFromTool(component: any): ExpandedToolIoView | null {
1404
1470
  }
1405
1471
  // Some hosts keep the last result component on the tool instance.
1406
1472
  const last = component?.lastComponent ?? component?.resultComponent ?? component?.content;
1407
- if (last instanceof ExpandedToolIoView) return last;
1473
+ if (isExpandedToolIoView(last)) return last;
1408
1474
  return null;
1409
1475
  }
1410
1476
 
@@ -1412,6 +1478,25 @@ function resolveIoViewFromTool(component: any): ExpandedToolIoView | null {
1412
1478
  * If the click lands on a truncated section's [show more], open the /context-style
1413
1479
  * full-text preview instead of toggling expand/collapse.
1414
1480
  */
1481
+ function visibleMouseLine(tui: any, packet: SgrMousePacket): string {
1482
+ const previousLines = Array.isArray(tui?.previousLines) ? tui.previousLines : [];
1483
+ const viewportTop = useFixedEditorFeatures(tui)
1484
+ ? 0
1485
+ : Number.isFinite(tui?.previousViewportTop)
1486
+ ? tui.previousViewportTop
1487
+ : 0;
1488
+ return stripTerminalSequencesPreservingLayout(
1489
+ String(previousLines[viewportTop + packet.row - 1] ?? ""),
1490
+ );
1491
+ }
1492
+
1493
+ function isCollapsedHintAtColumn(line: string, col: number): boolean {
1494
+ const match = /(\([^()\n]* \/ click\))\s*$/.exec(line);
1495
+ if (!match?.[1] || col <= 0) return false;
1496
+ const start = visibleWidth(line.slice(0, match.index)) + 1;
1497
+ return col >= start && col < start + visibleWidth(match[1]);
1498
+ }
1499
+
1415
1500
  function tryOpenToolIoShowMore(tui: any, packet: SgrMousePacket, hit: ToolRenderHit): boolean {
1416
1501
  if (!Boolean(hit.component.expanded)) return false;
1417
1502
  const ioView = resolveIoViewFromTool(hit.component);
@@ -1423,13 +1508,12 @@ function tryOpenToolIoShowMore(tui: any, packet: SgrMousePacket, hit: ToolRender
1423
1508
  : (Number.isFinite(tui?.previousViewportTop) ? tui.previousViewportTop : 0) + packet.row - 1;
1424
1509
  if (visibleRow < 0 || visibleRow >= previousLines.length) return false;
1425
1510
 
1426
- const plain = stripTerminalSequences(String(previousLines[visibleRow] ?? ""));
1511
+ const plain = stripTerminalSequencesPreservingLayout(String(previousLines[visibleRow] ?? ""));
1427
1512
  const section = ioView.matchShowMoreLine(plain);
1428
1513
  if (!section) return false;
1429
1514
 
1430
- // Prefer the [show more] cells; allow a little slack so imprecise clicks still work.
1431
1515
  const box = ioView.showMoreHitbox(plain);
1432
- if (box && packet.col > 0 && packet.col < box.startCol - 4) return false;
1516
+ if (!box || packet.col < box.startCol || packet.col > box.endCol) return false;
1433
1517
 
1434
1518
  const ui = toolMouseUi;
1435
1519
  if (!ui || typeof ui.custom !== "function") {
@@ -1444,31 +1528,85 @@ function tryOpenToolIoShowMore(tui: any, packet: SgrMousePacket, hit: ToolRender
1444
1528
  return true;
1445
1529
  }
1446
1530
 
1531
+ function setHoveredToolIo(view: ExpandedToolIoView | null, section: ToolIoSection | null): boolean {
1532
+ if (view === hoveredToolIoView && section === hoveredToolIoSection) return false;
1533
+ hoveredToolIoView?.setHoveredSection(null);
1534
+ hoveredToolIoView = view;
1535
+ hoveredToolIoSection = section;
1536
+ view?.setHoveredSection(section);
1537
+ return true;
1538
+ }
1539
+
1447
1540
  function updateToolSummaryHover(tui: any, packet: SgrMousePacket): void {
1448
1541
  if ((packet.code & 32) === 0 || packet.final !== "M") return;
1449
- const hit = findToolAtScreenRow(tui, packet.row);
1450
- const nextToolCallId = hit && !hit.component.expanded ? hit.component.toolCallId : null;
1451
- if (nextToolCallId === hoveredToolCallId) return;
1542
+ const visibleLine = visibleMouseLine(tui, packet);
1543
+ let nextToolCallId: string | null = null;
1544
+ let nextIoView: ExpandedToolIoView | null = null;
1545
+ let nextIoSection: ToolIoSection | null = null;
1546
+
1547
+ if (
1548
+ COLLAPSED_TOOL_SUMMARY.test(visibleLine) &&
1549
+ isCollapsedHintAtColumn(visibleLine, packet.col)
1550
+ ) {
1551
+ const hit = findToolAtScreenRow(tui, packet.row);
1552
+ nextToolCallId = hit && !hit.component.expanded ? hit.component.toolCallId : null;
1553
+ } else if (visibleLine.includes(SHOW_MORE_LABEL)) {
1554
+ const hit = findToolAtScreenRow(tui, packet.row);
1555
+ const view = hit && hit.component.expanded ? resolveIoViewFromTool(hit.component) : null;
1556
+ const section = view?.matchShowMoreLine(visibleLine) ?? null;
1557
+ const box = view?.showMoreHitbox(visibleLine);
1558
+ if (view && section && box && packet.col >= box.startCol && packet.col <= box.endCol) {
1559
+ nextIoView = view;
1560
+ nextIoSection = section;
1561
+ }
1562
+ }
1563
+
1564
+ const changed = nextToolCallId !== hoveredToolCallId;
1452
1565
  hoveredToolCallId = nextToolCallId;
1453
- tui.requestRender?.();
1566
+ if (setHoveredToolIo(nextIoView, nextIoSection) || changed) tui.requestRender?.();
1567
+ }
1568
+
1569
+ function isExpandedToolCardHit(hit: ToolRenderHit, width: number): boolean {
1570
+ if (!Boolean(hit.component.expanded)) return false;
1571
+ const box = hit.component.contentBox;
1572
+ if (!box || !Array.isArray(hit.component.children) || !hit.component.children.includes(box)) {
1573
+ return false;
1574
+ }
1575
+ const boxLines = box.render?.(width);
1576
+ const componentLines = renderComponentTree(hit.component, width);
1577
+ if (!Array.isArray(boxLines) || boxLines.length === 0) return false;
1578
+ const cardStart = componentLines.length - boxLines.length;
1579
+ return hit.row >= cardStart && hit.row < componentLines.length;
1454
1580
  }
1455
1581
 
1456
1582
  function toggleToolAtMouseClick(tui: any, packet: SgrMousePacket): boolean {
1583
+ const line = visibleMouseLine(tui, packet);
1584
+ const collapsedHint = isCollapsedHintAtColumn(line, packet.col);
1585
+ const hasShowMore = line.includes(SHOW_MORE_LABEL);
1586
+
1457
1587
  const hit = findToolAtScreenRow(tui, packet.row);
1458
1588
  if (!hit) return false;
1459
-
1460
- if (tryOpenToolIoShowMore(tui, packet, hit)) return true;
1461
-
1462
- const wasExpanded = Boolean(hit.component.expanded);
1463
1589
  const width = Math.max(1, Number(tui?.terminal?.columns) || 80);
1464
- const previousHeight = wasExpanded ? renderComponentTree(hit.component, width).length : 0;
1465
- hit.component.setExpanded(!wasExpanded);
1466
- hit.component.invalidate?.();
1467
- const nextHeight = wasExpanded ? renderComponentTree(hit.component, width).length : 0;
1468
- tui.requestRender?.();
1469
- if (wasExpanded) {
1590
+ if (Boolean(hit.component.expanded)) {
1591
+ if (hasShowMore && tryOpenToolIoShowMore(tui, packet, hit)) return true;
1592
+ if (!isExpandedToolCardHit(hit, width)) return false;
1593
+ const previousHeight = renderComponentTree(hit.component, width).length;
1594
+ hit.component.setExpanded(false);
1595
+ hoveredToolCallId = null;
1596
+ setHoveredToolIo(null, null);
1597
+ hit.component.invalidate?.();
1598
+ const nextHeight = renderComponentTree(hit.component, width).length;
1599
+ tui.requestRender?.();
1470
1600
  scheduleCollapseViewportCompensation(tui, previousHeight - nextHeight, packet);
1601
+ return true;
1471
1602
  }
1603
+ if (!collapsedHint) return false;
1604
+
1605
+ hit.component.setExpanded(true);
1606
+ hoveredToolCallId = null;
1607
+ setHoveredToolIo(null, null);
1608
+ hit.component.invalidate?.();
1609
+ tui.requestRender?.();
1472
1610
  return true;
1473
1611
  }
1474
1612
 
@@ -1554,8 +1692,59 @@ function restoreToolMouseInputCapture(): void {
1554
1692
  toolMouseInputPatchWrapper = null;
1555
1693
  }
1556
1694
 
1695
+ function restoreToolMouseRenderPatch(): void {
1696
+ if (
1697
+ toolMouseRenderPatchTui &&
1698
+ toolMouseRenderPatchOriginal &&
1699
+ toolMouseRenderPatchTui.doRender === toolMouseRenderPatchWrapper
1700
+ ) {
1701
+ toolMouseRenderPatchTui.doRender = toolMouseRenderPatchOriginal;
1702
+ }
1703
+ toolMouseRenderPatchTui = null;
1704
+ toolMouseRenderPatchOriginal = null;
1705
+ toolMouseRenderPatchWrapper = null;
1706
+ toolMouseRawWrite = null;
1707
+ }
1708
+
1709
+ function patchToolMouseMotionAfterRender(tui: any): void {
1710
+ if (!useFixedEditorFeatures(tui) || toolMouseRenderPatchTui === tui) return;
1711
+ restoreToolMouseRenderPatch();
1712
+ const original = tui?.doRender;
1713
+ const terminal = tui?.terminal;
1714
+ let prototype = terminal && Object.getPrototypeOf(terminal);
1715
+ let rawWrite: ((...args: any[]) => unknown) | undefined;
1716
+ while (prototype && !rawWrite) {
1717
+ const candidate = Object.getOwnPropertyDescriptor(prototype, "write")?.value;
1718
+ if (typeof candidate === "function") rawWrite = candidate;
1719
+ prototype = Object.getPrototypeOf(prototype);
1720
+ }
1721
+ if (typeof original !== "function" || !rawWrite) return;
1722
+
1723
+ toolMouseRawWrite = (data) => Reflect.apply(rawWrite, terminal, [data]);
1724
+ const wrapper = function (this: any, ...args: any[]) {
1725
+ const result = Reflect.apply(original, this, args);
1726
+ toolMouseRawWrite?.(TOOL_MOUSE_MOTION_ENABLE);
1727
+ return result;
1728
+ };
1729
+ try {
1730
+ tui.doRender = wrapper;
1731
+ } catch {
1732
+ toolMouseRawWrite = null;
1733
+ return;
1734
+ }
1735
+ toolMouseRenderPatchTui = tui;
1736
+ toolMouseRenderPatchOriginal = original;
1737
+ toolMouseRenderPatchWrapper = wrapper;
1738
+ toolMouseRawWrite(TOOL_MOUSE_MOTION_ENABLE);
1739
+ }
1740
+
1557
1741
  function handleToolMouseInput(data: string): { consume: true } | undefined {
1558
1742
  if (!toolMouseTui) return undefined;
1743
+ if (
1744
+ toolMouseInputPatchTui === toolMouseTui &&
1745
+ toolMouseTui.handleInput === toolMouseInputPatchWrapper
1746
+ )
1747
+ return undefined;
1559
1748
  updateScrollButtonFromInput(toolMouseTui, data);
1560
1749
  if (isScrollBottomInput(data)) {
1561
1750
  if (useFixedEditorFeatures(toolMouseTui) && jumpToBottomWithoutSubmit(toolMouseTui)) {
@@ -1602,6 +1791,7 @@ function teardownToolMouseInteraction(): void {
1602
1791
  toolMouseInputUnsubscribe?.();
1603
1792
  toolMouseInputUnsubscribe = null;
1604
1793
  hoveredToolCallId = null;
1794
+ setHoveredToolIo(null, null);
1605
1795
  try {
1606
1796
  toolMouseTui?.terminal?.write?.(TOOL_MOUSE_DISABLE);
1607
1797
  } catch {
@@ -1613,6 +1803,7 @@ function teardownToolMouseInteraction(): void {
1613
1803
  // The UI context may already have been reset during /reload.
1614
1804
  }
1615
1805
  restoreToolMouseInputCapture();
1806
+ restoreToolMouseRenderPatch();
1616
1807
  scrollButtonVisible = false;
1617
1808
  scrollButtonWidget = null;
1618
1809
  pendingScrollMessages = 0;
@@ -1641,7 +1832,7 @@ export function installToolMouseInteraction(
1641
1832
  toolMouseTui = tui;
1642
1833
  if (fixedEditorFeatures) patchToolMouseInputCapture(tui);
1643
1834
  // Motion reporting is required for hover in both native and fixed-editor layouts.
1644
- tui?.terminal?.write?.(fixedEditorFeatures ? TOOL_MOUSE_ENABLE : "\x1b[?1003h\x1b[?1006h");
1835
+ tui?.terminal?.write?.(fixedEditorFeatures ? TOOL_MOUSE_ENABLE : TOOL_MOUSE_MOTION_ENABLE);
1645
1836
  const widget = {
1646
1837
  render: (width: number) => renderScrollButton(width, theme),
1647
1838
  invalidate() {},
@@ -1652,6 +1843,12 @@ export function installToolMouseInteraction(
1652
1843
  toolMouseInputUnsubscribe = ctx.ui.onTerminalInput(handleToolMouseInput);
1653
1844
  }
1654
1845
 
1846
+ function refreshToolRendererComponents(tui: any): void {
1847
+ const tools: any[] = [];
1848
+ collectToolComponents(tui, tools);
1849
+ for (const tool of tools) tool.invalidate?.();
1850
+ }
1851
+
1655
1852
  function scheduleSessionRender(refresh?: () => void): void {
1656
1853
  const tui = toolMouseTui;
1657
1854
  if (!tui || typeof tui.requestRender !== "function") return;
@@ -1662,6 +1859,8 @@ function scheduleSessionRender(refresh?: () => void): void {
1662
1859
  sessionRenderTimer = setTimeout(() => {
1663
1860
  sessionRenderTimer = null;
1664
1861
  if (toolMouseTui !== tui) return;
1862
+ patchToolMouseMotionAfterRender(tui);
1863
+ refreshToolRendererComponents(tui);
1665
1864
  refresh?.();
1666
1865
  tui.requestRender(true);
1667
1866
  }, 0);
@@ -1669,7 +1868,7 @@ function scheduleSessionRender(refresh?: () => void): void {
1669
1868
 
1670
1869
  // Bright green for success icon (truecolor ANSI escape)
1671
1870
  const BRIGHT_GREEN = "\x1b[38;2;80;220;100m";
1672
- const ANSI_RESET = "\x1b[0m";
1871
+ const ANSI_FG_RESET = "\x1b[39m";
1673
1872
 
1674
1873
  function refreshCurrentTranscript(compactStyle: CompactStyleHooks, ctx?: any): void {
1675
1874
  compactStyle.refresh();
@@ -1992,8 +2191,6 @@ async function showCcstylePanel(ctx: any, compactStyle: CompactStyleHooks): Prom
1992
2191
  ];
1993
2192
 
1994
2193
  let activeSection = 0;
1995
- let lastWidth = 0;
1996
- let lastHeight = 0;
1997
2194
  const settingsTheme = getSettingsListTheme();
1998
2195
  const lists = sections.map(
1999
2196
  (section) =>
@@ -2030,15 +2227,8 @@ async function showCcstylePanel(ctx: any, compactStyle: CompactStyleHooks): Prom
2030
2227
  while (listBody.length > 0 && listBody[listBody.length - 1] === "") listBody.pop();
2031
2228
 
2032
2229
  // Frame: top rule · tabs · mid rule · settings · mid rule · footer · bottom rule
2033
- const lines = [
2034
- renderDialogTopBorder(safeWidth, (text) => theme.fg("border", text)),
2035
- renderDialogHeader(
2036
- " CC Style",
2037
- safeWidth,
2038
- (text) => theme.fg("border", text),
2039
- (text) => theme.fg("toolTitle", text),
2040
- (text) => theme.fg("muted", text),
2041
- ),
2230
+ return [
2231
+ rule,
2042
2232
  renderSectionTabBar(theme, sections, activeSection, safeWidth),
2043
2233
  rule,
2044
2234
  ...listBody,
@@ -2052,22 +2242,11 @@ async function showCcstylePanel(ctx: any, compactStyle: CompactStyleHooks): Prom
2052
2242
  ),
2053
2243
  rule,
2054
2244
  ];
2055
- lastWidth = safeWidth;
2056
- lastHeight = lines.length;
2057
- return lines;
2058
2245
  },
2059
2246
  invalidate() {
2060
2247
  for (const list of lists) list.invalidate();
2061
2248
  },
2062
2249
  handleInput(data: string) {
2063
- if (
2064
- !excludeSubmenuOpen &&
2065
- tui.terminal &&
2066
- isDialogCloseClick(data, bottomDialogBounds(tui.terminal, lastWidth, lastHeight))
2067
- ) {
2068
- done();
2069
- return;
2070
- }
2071
2250
  if (!excludeSubmenuOpen && isForwardTabKey(data)) {
2072
2251
  switchSection(1);
2073
2252
  tui.requestRender();
@@ -2118,15 +2297,22 @@ function toolCallArgument(args: any): { key: string; value: string } | undefined
2118
2297
  : undefined;
2119
2298
  }
2120
2299
 
2121
- function tailToWidth(text: string, width: number): string {
2300
+ export function middleTruncateToWidth(text: string, width: number): string {
2122
2301
  if (visibleWidth(text) <= width) return text;
2123
2302
  if (width <= 1) return "…";
2124
- let tail = "";
2125
- for (const char of Array.from(text).reverse()) {
2126
- if (visibleWidth(`…${char}${tail}`) > width) break;
2127
- tail = char + tail;
2303
+ const chars = Array.from(text);
2304
+ const leftWidth = Math.ceil((width - 1) / 2);
2305
+ let left = "";
2306
+ let right = "";
2307
+ for (const char of chars) {
2308
+ if (visibleWidth(left + char) > leftWidth) break;
2309
+ left += char;
2128
2310
  }
2129
- return `…${tail}`;
2311
+ for (const char of chars.reverse()) {
2312
+ if (visibleWidth(left + "…" + char + right) > width) break;
2313
+ right = char + right;
2314
+ }
2315
+ return `${left}…${right}`;
2130
2316
  }
2131
2317
 
2132
2318
  export function shouldRenderRichDiff(
@@ -2163,23 +2349,22 @@ function createCcstyleTool(
2163
2349
  const rawIcon = isPending ? pendingIcon(toolName) : settledIcon(toolName, visualState);
2164
2350
  const icon =
2165
2351
  visualState === "success"
2166
- ? `${BRIGHT_GREEN}${rawIcon}${ANSI_RESET}`
2352
+ ? `${BRIGHT_GREEN}${rawIcon}${ANSI_FG_RESET}`
2167
2353
  : theme.fg(toolIconColor(context), rawIcon);
2168
2354
 
2169
2355
  const argument = toolCallArgument(args);
2356
+ let cachedWidth: number | undefined;
2357
+ let cachedLine: string | undefined;
2170
2358
  return {
2171
2359
  render(width: number) {
2172
- const argumentWidth = Math.max(1, width - visibleWidth(`${rawIcon} ${label}()`));
2173
- const shown = argument
2174
- ? argument.key === "path"
2175
- ? tailToWidth(argument.value, argumentWidth)
2176
- : truncateToWidth(argument.value, argumentWidth, "…")
2177
- : undefined;
2360
+ if (cachedLine !== undefined && cachedWidth === width) return [cachedLine];
2361
+ const callWidth = Math.max(0, width - visibleWidth(icon) - 1);
2362
+ const argumentWidth = Math.max(1, callWidth - visibleWidth(`${label}()`));
2363
+ const shown = argument ? middleTruncateToWidth(argument.value, argumentWidth) : undefined;
2178
2364
  const call = shown === undefined ? label : `${label}(${shown})`;
2179
- // Leave one cell unused so the host shell never performs a second,
2180
- // unthemed truncation after this renderer has applied toolTitle.
2181
- const callWidth = Math.max(0, width - visibleWidth(icon) - 2);
2182
- return [`${icon} ${theme.fg("toolTitle", truncateToWidth(call, callWidth, "…"))}`];
2365
+ cachedWidth = width;
2366
+ cachedLine = `${icon} ${theme.fg("toolTitle", truncateToWidth(call, callWidth, ""))}`;
2367
+ return [cachedLine];
2183
2368
  },
2184
2369
  invalidate() {},
2185
2370
  };
@@ -2218,9 +2403,12 @@ function createCcstyleTool(
2218
2403
 
2219
2404
  const text = textFromResult(result);
2220
2405
  const args = context?.args;
2221
- const rendered = !expanded ? (text ? oneLine(text, 96) : "Done") : text;
2406
+ const rendered = !expanded ? (text ? oneLine(text, Infinity) : "Done") : text;
2222
2407
 
2223
- const hint = !expanded && hasExpandableDetail(text, args) ? expandHint(theme) : "";
2408
+ const detailLineCount = outputLineCount(result) || countLines(formatToolInputArgs(args));
2409
+ const hint =
2410
+ !expanded && hasExpandableDetail(text, args) ? expandHint(theme, detailLineCount) : "";
2411
+ const hoveredHint = hint ? expandHint(theme, detailLineCount, true) : "";
2224
2412
  if (expanded) {
2225
2413
  return renderExpandedToolResult(
2226
2414
  text || "",
@@ -2233,18 +2421,23 @@ function createCcstyleTool(
2233
2421
  }
2234
2422
  if (context?.state) context.state.ccstyleIoView = undefined;
2235
2423
  const toolCallId = context?.toolCallId;
2236
- const summary = theme.fg(
2237
- isError ? "error" : "muted",
2238
- renderCollapsedToolResult(rendered, hint),
2239
- );
2424
+ let cachedWidth: number | undefined;
2425
+ let cachedLine: string | undefined;
2426
+ let cachedHoveredLine: string | undefined;
2240
2427
  return {
2241
2428
  render(width: number) {
2242
- const line = truncateToWidth(summary, width, "…");
2243
- if (!toolCallId || hoveredToolCallId !== toolCallId) return [line];
2244
- const highlighted = line.replace(/\x1b\[0m/g, "\x1b[0;7m");
2245
- return [
2246
- `\x1b[7m${highlighted}${" ".repeat(Math.max(0, width - visibleWidth(line)))}\x1b[0m`,
2247
- ];
2429
+ if (cachedLine === undefined || cachedWidth !== width) {
2430
+ cachedWidth = width;
2431
+ cachedLine = theme.fg(
2432
+ isError ? "error" : "muted",
2433
+ renderCollapsedToolResultToWidth(rendered, hint, width),
2434
+ );
2435
+ cachedHoveredLine = theme.fg(
2436
+ isError ? "error" : "muted",
2437
+ renderCollapsedToolResultToWidth(rendered, hoveredHint, width),
2438
+ );
2439
+ }
2440
+ return [toolCallId && hoveredToolCallId === toolCallId ? cachedHoveredLine! : cachedLine];
2248
2441
  },
2249
2442
  invalidate() {},
2250
2443
  };
@@ -2503,10 +2696,14 @@ function installGlobalToolRendering(
2503
2696
  },
2504
2697
  getRenderShell: function (this: any, ...args: any[]) {
2505
2698
  if (!patch.active) return patch.downstream.getRenderShell.apply(this, args);
2699
+ const useSelfShell = shouldUseSelfShell(this, patch);
2700
+ const useCcstyle = shouldGloballyStyleTool(this, patch);
2506
2701
  const shell =
2507
- shouldUseSelfShell(this, patch) || shouldGloballyStyleTool(this, patch)
2702
+ useSelfShell || (useCcstyle && !this.expanded)
2508
2703
  ? "self"
2509
- : patch.downstream.getRenderShell.apply(this, args);
2704
+ : useCcstyle
2705
+ ? "default"
2706
+ : patch.downstream.getRenderShell.apply(this, args);
2510
2707
  syncToolShell(this, shell);
2511
2708
  return shell;
2512
2709
  },
@@ -10,12 +10,6 @@ import {
10
10
  visibleWidth,
11
11
  wrapTextWithAnsi,
12
12
  } from "@earendil-works/pi-tui";
13
- import {
14
- centeredDialogBounds,
15
- isDialogCloseClick,
16
- renderDialogHeader,
17
- renderDialogTopBorder,
18
- } from "./closable-dialog.ts";
19
13
 
20
14
  export type ContextPart = {
21
15
  label: string;
@@ -71,8 +65,6 @@ export async function showTextPreview(
71
65
  let scrollOffset = 0;
72
66
  let pageSize = 1;
73
67
  let totalLines = 1;
74
- let lastWidth = 0;
75
- let lastHeight = 0;
76
68
  const wrappedContent = createWrappedTextCache(content);
77
69
 
78
70
  const scrollTo = (nextOffset: number): void => {
@@ -85,10 +77,6 @@ export async function showTextPreview(
85
77
  wrappedContent.invalidate();
86
78
  },
87
79
  handleInput(data: string) {
88
- if (isDialogCloseClick(data, centeredDialogBounds(tui.terminal, lastWidth, lastHeight))) {
89
- done(undefined);
90
- return;
91
- }
92
80
  if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
93
81
  done(undefined);
94
82
  return;
@@ -150,24 +138,15 @@ export async function showTextPreview(
150
138
  const end = Math.min(totalLines, scrollOffset + pageSize);
151
139
  const status = `${start}-${end} / ${totalLines} lines · ↑↓ PgUp/PgDn Home/End · Esc close`;
152
140
 
153
- const lines = [
154
- renderDialogTopBorder(width, border),
155
- renderDialogHeader(
156
- ` ${title}`,
157
- width,
158
- border,
159
- (text) => theme.bold(theme.fg("accent", text)),
160
- (text) => theme.fg("muted", text),
161
- ),
141
+ return [
142
+ border(`╭${"─".repeat(inner)}╮`),
143
+ `${border("│")}${padLine(` ${theme.bold(theme.fg("accent", title))}`)}${border("│")}`,
162
144
  `${border("├")}${border("─".repeat(inner))}${border("┤")}`,
163
145
  ...bodyRows,
164
146
  `${border("├")}${border("─".repeat(inner))}${border("┤")}`,
165
147
  `${border("│")}${padLine(theme.fg("dim", ` ${status}`))}${border("│")}`,
166
148
  border(`╰${"─".repeat(inner)}╯`),
167
149
  ];
168
- lastWidth = width;
169
- lastHeight = lines.length;
170
- return lines;
171
150
  },
172
151
  };
173
152
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-cc-extensions",
3
- "version": "0.8.21",
3
+ "version": "0.8.23",
4
4
  "description": "A Pi productivity suite with Claude Code-style UI, structured questionnaires, context inspection, and agent/session references",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -1,57 +0,0 @@
1
- import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
-
3
- export type DialogBounds = { left: number; top: number; width: number };
4
-
5
- export function renderDialogTopBorder(width: number, color: (text: string) => string): string {
6
- const safeWidth = Math.max(4, Math.floor(width));
7
- return color(`╭${"─".repeat(safeWidth - 2)}╮`);
8
- }
9
-
10
- /** Render title and a larger [ × ] target on the row below the top border. */
11
- export function renderDialogHeader(
12
- title: string,
13
- width: number,
14
- color: (text: string) => string,
15
- titleColor: (text: string) => string,
16
- closeColor: (text: string) => string,
17
- ): string {
18
- const safeWidth = Math.max(8, Math.floor(width));
19
- const innerWidth = safeWidth - 2;
20
- const button = "[ × ]";
21
- const titleWidth = Math.max(0, innerWidth - visibleWidth(button) - 2);
22
- const shownTitle = truncateToWidth(title, titleWidth, "…");
23
- const gap = " ".repeat(Math.max(1, innerWidth - visibleWidth(shownTitle) - visibleWidth(button)));
24
- return `${color("│")}${titleColor(shownTitle)}${gap}${closeColor(button)}${color("│")}`;
25
- }
26
-
27
- /** Match a left-button press anywhere on the [ × ] target. */
28
- export function isDialogCloseClick(data: string, bounds: DialogBounds): boolean {
29
- const match = data.match(/^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/);
30
- if (!match || match[4] !== "M") return false;
31
- const code = Number(match[1]);
32
- if ((code & 3) !== 0 || (code & 32) !== 0) return false;
33
- const col = Number(match[2]);
34
- const firstButtonCol = bounds.left + bounds.width - 7;
35
- const lastButtonCol = bounds.left + bounds.width - 3;
36
- return col >= firstButtonCol && col <= lastButtonCol && Number(match[3]) === bounds.top + 1;
37
- }
38
-
39
- export function centeredDialogBounds(
40
- terminal: { columns: number; rows: number },
41
- width: number,
42
- height: number,
43
- ): DialogBounds {
44
- return {
45
- left: Math.floor((terminal.columns - width) / 2) + 1,
46
- top: Math.floor((terminal.rows - height) / 2) + 1,
47
- width,
48
- };
49
- }
50
-
51
- export function bottomDialogBounds(
52
- terminal: { rows: number },
53
- width: number,
54
- height: number,
55
- ): DialogBounds {
56
- return { left: 1, top: Math.max(1, terminal.rows - height + 1), width };
57
- }