pi-cc-extensions 0.8.22 → 0.8.24

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,
@@ -228,6 +224,12 @@ function saveConfig() {
228
224
  writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
229
225
  }
230
226
 
227
+ const TOOL_VIEWPORT_WIDTH_RATIO = 0.8;
228
+
229
+ function toolViewportWidth(width: number): number {
230
+ return Math.max(1, Math.floor(width * TOOL_VIEWPORT_WIDTH_RATIO));
231
+ }
232
+
231
233
  function oneLine(value: unknown, max = 72): string {
232
234
  const text = String(value ?? "")
233
235
  .replace(/\s+/g, " ")
@@ -235,10 +237,19 @@ function oneLine(value: unknown, max = 72): string {
235
237
  return text.length > max ? `${text.slice(0, max - 1)}…` : text;
236
238
  }
237
239
 
238
- function textFromResult(result: any): string {
240
+ function rawTextFromResult(result: any): string {
239
241
  const item = result?.content?.find?.((c: any) => c.type === "text");
240
- // Compact previews only need line counts / short text; bound sanitize work.
241
- return item?.type === "text" ? sanitizeToolResultText(item.text ?? "", 16_384) : "";
242
+ return item?.type === "text" ? String(item.text ?? "") : "";
243
+ }
244
+
245
+ function textFromResult(result: any): string {
246
+ // Compact previews only need short text; bound sanitize work.
247
+ return sanitizeToolResultText(rawTextFromResult(result), 16_384);
248
+ }
249
+
250
+ export function outputLineCount(result: any): number {
251
+ const text = rawTextFromResult(result).replace(/\r\n?/g, "\n").replace(/\n+$/, "");
252
+ return text ? text.split("\n").length : 0;
242
253
  }
243
254
 
244
255
  function countLines(text: string): number {
@@ -415,6 +426,10 @@ export const SHOW_MORE_LABEL = "[show more]";
415
426
 
416
427
  export type ToolIoSection = "input" | "output";
417
428
 
429
+ // Module-local token: /reload creates a fresh token, so stale view instances are
430
+ // replaced instead of retaining their old render implementation.
431
+ const EXPANDED_TOOL_IO_VIEW_GENERATION = Symbol("ccstyle-expanded-tool-io-view");
432
+
418
433
  /**
419
434
  * Expanded tool body with clear Input / Output sections (Grok Build–style).
420
435
  *
@@ -428,6 +443,7 @@ export type ToolIoSection = "input" | "output";
428
443
  * Reused across re-renders via context.lastComponent when possible.
429
444
  */
430
445
  export class ExpandedToolIoView {
446
+ readonly [EXPANDED_TOOL_IO_VIEW_GENERATION] = true;
431
447
  private inputBody: string;
432
448
  private outputBody: string;
433
449
  private isError: boolean;
@@ -436,6 +452,7 @@ export class ExpandedToolIoView {
436
452
  private maxInputLines: number;
437
453
  private cachedWidth: number | undefined;
438
454
  private cachedLines: string[] | undefined;
455
+ private hoveredSection: ToolIoSection | null = null;
439
456
  /** Which sections currently show the [show more] affordance (after last render). */
440
457
  private truncated: { input: boolean; output: boolean } = { input: false, output: false };
441
458
 
@@ -490,6 +507,12 @@ export class ExpandedToolIoView {
490
507
  return this.outputBody.trim() ? this.outputBody : "Done";
491
508
  }
492
509
 
510
+ setHoveredSection(section: ToolIoSection | null): void {
511
+ if (this.hoveredSection === section) return;
512
+ this.hoveredSection = section;
513
+ this.invalidate();
514
+ }
515
+
493
516
  /** True when the plain header line is a truncated section with [show more]. */
494
517
  matchShowMoreLine(plainLine: string): ToolIoSection | null {
495
518
  const line = plainLine.replace(/\x1b\[[0-9;]*m/g, "");
@@ -517,18 +540,26 @@ export class ExpandedToolIoView {
517
540
  const safeWidth = Math.max(1, Math.floor(width));
518
541
  const rail = " │ ";
519
542
  const railWidth = visibleWidth(rail);
520
- const contentWidth = Math.max(1, safeWidth - railWidth);
543
+ const bodyWidth = toolViewportWidth(safeWidth);
544
+ const contentWidth = Math.max(1, bodyWidth - railWidth);
521
545
  const bodyColor = this.isError ? "error" : "toolOutput";
522
546
  const lines: string[] = [];
523
547
  this.truncated = { input: false, output: false };
524
548
 
525
- const pushHeader = (corner: "┌" | "└", label: string, showMore: boolean) => {
549
+ const pushHeader = (
550
+ corner: "┌" | "└",
551
+ label: string,
552
+ section: ToolIoSection,
553
+ showMore: boolean,
554
+ ) => {
526
555
  const mark = theme.fg("dim", ` ${corner} `);
527
556
  const title = theme.fg(
528
557
  "accent",
529
558
  typeof theme.bold === "function" ? theme.bold(label) : label,
530
559
  );
531
- const more = showMore ? theme.fg("dim", ` ${SHOW_MORE_LABEL}`) : "";
560
+ const more = showMore
561
+ ? theme.fg(this.hoveredSection === section ? "text" : "dim", ` ${SHOW_MORE_LABEL}`)
562
+ : "";
532
563
  lines.push(truncateToWidth(mark + title + more, safeWidth, ""));
533
564
  };
534
565
 
@@ -597,15 +628,15 @@ export class ExpandedToolIoView {
597
628
 
598
629
  if (hasInput) {
599
630
  this.truncated.input = inputWouldTruncate;
600
- pushHeader("┌", "Input", inputWouldTruncate);
631
+ pushHeader("┌", "Input", "input", inputWouldTruncate);
601
632
  pushBody(this.inputBody, { input: true, limit: this.maxInputLines });
602
633
  pushBlankRail();
603
634
  this.truncated.output = outputWouldTruncate;
604
- pushHeader("└", "Output", outputWouldTruncate);
635
+ pushHeader("└", "Output", "output", outputWouldTruncate);
605
636
  pushBody(outputText, { limit: this.maxOutputLines });
606
637
  } else {
607
638
  this.truncated.output = outputWouldTruncate;
608
- pushHeader("┌", "Output", outputWouldTruncate);
639
+ pushHeader("┌", "Output", "output", outputWouldTruncate);
609
640
  pushBody(outputText, { limit: this.maxOutputLines });
610
641
  }
611
642
 
@@ -620,6 +651,18 @@ export class ExpandedToolIoView {
620
651
  }
621
652
  }
622
653
 
654
+ function isExpandedToolIoView(value: unknown): value is ExpandedToolIoView {
655
+ return Boolean(
656
+ value &&
657
+ typeof value === "object" &&
658
+ (value as ExpandedToolIoView)[EXPANDED_TOOL_IO_VIEW_GENERATION] === true &&
659
+ typeof (value as ExpandedToolIoView).getInputBody === "function" &&
660
+ typeof (value as ExpandedToolIoView).getOutputBody === "function" &&
661
+ typeof (value as ExpandedToolIoView).setHoveredSection === "function" &&
662
+ typeof (value as ExpandedToolIoView).render === "function",
663
+ );
664
+ }
665
+
623
666
  /** True when body needs truncation at the given line limit (source lines or wrapped rows). */
624
667
  function bodyExceedsLineLimit(
625
668
  body: string,
@@ -655,6 +698,21 @@ export function renderCollapsedToolResult(body: string, collapsedHint = ""): str
655
698
  return ` ↳ ${body}${collapsedHint}`;
656
699
  }
657
700
 
701
+ export function renderCollapsedToolResultToWidth(
702
+ body: string,
703
+ collapsedHint: string,
704
+ width: number,
705
+ ): string {
706
+ const previewWidth = toolViewportWidth(width);
707
+ const prefix = " ↳ ";
708
+ const bodyWidth = Math.max(1, previewWidth - visibleWidth(prefix) - visibleWidth(collapsedHint));
709
+ return truncateToWidth(
710
+ prefix + middleTruncateToWidth(body, bodyWidth) + collapsedHint,
711
+ previewWidth,
712
+ "",
713
+ );
714
+ }
715
+
658
716
  /** Pretty-print tool call args for the expanded Input section. */
659
717
  export function formatToolInputArgs(args: unknown, maxChars = 8_000): string {
660
718
  if (args === undefined || args === null) return "";
@@ -752,7 +810,7 @@ function renderExpandedToolResult(
752
810
  // Prefer structured Input/Output when we have args or non-empty output.
753
811
  if (inputBody.trim() || outputBody.trim()) {
754
812
  let view: ExpandedToolIoView;
755
- if (lastComponent instanceof ExpandedToolIoView) {
813
+ if (isExpandedToolIoView(lastComponent)) {
756
814
  lastComponent.setContent(inputBody, outputBody, isError, maxLines, maxLines);
757
815
  view = lastComponent;
758
816
  } else {
@@ -767,10 +825,14 @@ function renderExpandedToolResult(
767
825
  return new Text(theme.fg(color, renderCollapsedToolResult("Done")), 0, 0);
768
826
  }
769
827
 
770
- function expandHint(theme: any): string {
828
+ export function formatExpandHint(lineCount: number): string {
829
+ return ` (${lineCount} more line${lineCount === 1 ? "" : "s"} / click)`;
830
+ }
831
+
832
+ function expandHint(theme: any, lineCount: number, hovered = false): string {
771
833
  // Keep interaction guidance neutral; it should not inherit success/error
772
834
  // coloring from the tool result surrounding it.
773
- return `${theme.fg("muted", " (")}${keyHint("app.tools.expand", "expand")}${theme.fg("muted", " / click)")}`;
835
+ return theme.fg(hovered ? "text" : "muted", formatExpandHint(lineCount));
774
836
  }
775
837
 
776
838
  type SgrMousePacket = {
@@ -784,10 +846,12 @@ type ToolRenderHit = {
784
846
  component: any;
785
847
  start: number;
786
848
  end: number;
849
+ row: number;
787
850
  };
788
851
 
789
852
  const TOOL_MOUSE_WIDGET_KEY = "ccstyle-tool-mouse";
790
853
  const TOOL_MOUSE_ENABLE = "\x1b[?1000h\x1b[?1003h\x1b[?1006h";
854
+ const TOOL_MOUSE_MOTION_ENABLE = "\x1b[?1003h\x1b[?1006h";
791
855
  const TOOL_MOUSE_DISABLE = "\x1b[?1006l\x1b[?1003l\x1b[?1000l";
792
856
  const ZENTUI_PAGE_UP_INPUT = /^\x1b\[5;9(?::[12])?~$|^\x1b\[57421;9(?::[12])?u$|^\x1b\[1;6A$/;
793
857
  const ZENTUI_PAGE_DOWN_INPUT = /^\x1b\[6;9(?::[12])?~$|^\x1b\[57422;9(?::[12])?u$|^\x1b\[1;6B$/;
@@ -804,6 +868,10 @@ let toolMouseInputUnsubscribe: (() => void) | null = null;
804
868
  let toolMouseInputPatchTui: any = null;
805
869
  let toolMouseInputPatchOriginalHandle: ((...args: any[]) => any) | null = null;
806
870
  let toolMouseInputPatchWrapper: ((...args: any[]) => any) | null = null;
871
+ let toolMouseRenderPatchTui: any = null;
872
+ let toolMouseRenderPatchOriginal: ((...args: any[]) => any) | null = null;
873
+ let toolMouseRenderPatchWrapper: ((...args: any[]) => any) | null = null;
874
+ let toolMouseRawWrite: ((data: string) => unknown) | null = null;
807
875
  let scrollButtonVisible = false;
808
876
  let scrollButtonWidget: any = null;
809
877
  let pendingScrollMessages = 0;
@@ -836,12 +904,14 @@ function isSgrLeftPress(packet: SgrMousePacket): boolean {
836
904
  return packet.final === "M" && baseButton === 0 && (packet.code & 32) === 0;
837
905
  }
838
906
 
839
- function stripTerminalSequences(value: string): string {
907
+ function stripTerminalSequencesPreservingLayout(value: string): string {
840
908
  return value
841
909
  .replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, "")
842
- .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
843
- .replace(/\s+/g, " ")
844
- .trim();
910
+ .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "");
911
+ }
912
+
913
+ function stripTerminalSequences(value: string): string {
914
+ return stripTerminalSequencesPreservingLayout(value).replace(/\s+/g, " ").trim();
845
915
  }
846
916
 
847
917
  function isToolExecutionComponent(value: any): boolean {
@@ -883,7 +953,7 @@ function collectToolRenderHits(
883
953
 
884
954
  const count = renderedLineCount(component, width, cache);
885
955
  if (isToolExecutionComponent(component)) {
886
- if (count > 0) hits.push({ component, start, end: start + count });
956
+ if (count > 0) hits.push({ component, start, end: start + count, row: 0 });
887
957
  return start + count;
888
958
  }
889
959
 
@@ -946,8 +1016,10 @@ function findToolAtFixedEditorRow(
946
1016
  ): ToolRenderHit | null {
947
1017
  if (visibleRow < 0 || visibleRow >= previousLines.length) return null;
948
1018
  const visibleLines = previousLines.map((line) => stripTerminalSequences(String(line)));
1019
+ const rawClickedLine = String(previousLines[visibleRow] ?? "");
949
1020
  const clickedLine = visibleLines[visibleRow] ?? "";
950
- if (!clickedLine) return null;
1021
+ const hasBackground = /\x1b\[(?:4[0-8]|10[0-7])(?:;[0-9;:]*)?m/.test(rawClickedLine);
1022
+ if (!clickedLine && !hasBackground) return null;
951
1023
 
952
1024
  const tools: any[] = [];
953
1025
  collectToolComponents(tui, tools);
@@ -964,7 +1036,12 @@ function findToolAtFixedEditorRow(
964
1036
  const score = fixedEditorContextScore(renderedLines, renderedRow, visibleLines, visibleRow);
965
1037
  if (!best || score > best.score) {
966
1038
  best = {
967
- hit: { component, start: visibleRow, end: visibleRow + renderedLines.length },
1039
+ hit: {
1040
+ component,
1041
+ start: visibleRow,
1042
+ end: visibleRow + renderedLines.length,
1043
+ row: renderedRow,
1044
+ },
968
1045
  score,
969
1046
  };
970
1047
  if (score >= TOOL_CLICK_EARLY_EXIT_SCORE) return best.hit;
@@ -997,7 +1074,7 @@ function findToolAtScreenRow(tui: any, screenRow: number): ToolRenderHit | null
997
1074
  // The stable summary marker keeps the whole row clickable even when the
998
1075
  // trailing keyboard/click hint was truncated (common for subagent output).
999
1076
  if (!hit.component.expanded && !COLLAPSED_TOOL_SUMMARY.test(clickedLine)) continue;
1000
- return hit;
1077
+ return { ...hit, row: bufferRow - hit.start };
1001
1078
  }
1002
1079
  return null;
1003
1080
  }
@@ -1364,6 +1441,8 @@ function scheduleCollapseViewportCompensation(
1364
1441
 
1365
1442
  /** toolCallId → latest expanded IO view (survives context/state identity quirks). */
1366
1443
  const ioViewsByToolCallId = new Map<string, ExpandedToolIoView>();
1444
+ let hoveredToolIoView: ExpandedToolIoView | null = null;
1445
+ let hoveredToolIoSection: ToolIoSection | null = null;
1367
1446
 
1368
1447
  function rememberIoView(context: any, view: ExpandedToolIoView): void {
1369
1448
  if (!context || typeof context !== "object") return;
@@ -1387,7 +1466,7 @@ function rememberIoView(context: any, view: ExpandedToolIoView): void {
1387
1466
 
1388
1467
  function resolveIoViewFromTool(component: any): ExpandedToolIoView | null {
1389
1468
  const fromState = component?.state?.ccstyleIoView;
1390
- if (fromState instanceof ExpandedToolIoView) return fromState;
1469
+ if (isExpandedToolIoView(fromState)) return fromState;
1391
1470
  const id =
1392
1471
  (typeof component?.toolCallId === "string" && component.toolCallId) ||
1393
1472
  (typeof component?.state?.toolCallId === "string" && component.state.toolCallId) ||
@@ -1398,7 +1477,7 @@ function resolveIoViewFromTool(component: any): ExpandedToolIoView | null {
1398
1477
  }
1399
1478
  // Some hosts keep the last result component on the tool instance.
1400
1479
  const last = component?.lastComponent ?? component?.resultComponent ?? component?.content;
1401
- if (last instanceof ExpandedToolIoView) return last;
1480
+ if (isExpandedToolIoView(last)) return last;
1402
1481
  return null;
1403
1482
  }
1404
1483
 
@@ -1406,6 +1485,25 @@ function resolveIoViewFromTool(component: any): ExpandedToolIoView | null {
1406
1485
  * If the click lands on a truncated section's [show more], open the /context-style
1407
1486
  * full-text preview instead of toggling expand/collapse.
1408
1487
  */
1488
+ function visibleMouseLine(tui: any, packet: SgrMousePacket): string {
1489
+ const previousLines = Array.isArray(tui?.previousLines) ? tui.previousLines : [];
1490
+ const viewportTop = useFixedEditorFeatures(tui)
1491
+ ? 0
1492
+ : Number.isFinite(tui?.previousViewportTop)
1493
+ ? tui.previousViewportTop
1494
+ : 0;
1495
+ return stripTerminalSequencesPreservingLayout(
1496
+ String(previousLines[viewportTop + packet.row - 1] ?? ""),
1497
+ );
1498
+ }
1499
+
1500
+ function isCollapsedHintAtColumn(line: string, col: number): boolean {
1501
+ const match = /(\([^()\n]* \/ click\))\s*$/.exec(line);
1502
+ if (!match?.[1] || col <= 0) return false;
1503
+ const start = visibleWidth(line.slice(0, match.index)) + 1;
1504
+ return col >= start && col < start + visibleWidth(match[1]);
1505
+ }
1506
+
1409
1507
  function tryOpenToolIoShowMore(tui: any, packet: SgrMousePacket, hit: ToolRenderHit): boolean {
1410
1508
  if (!Boolean(hit.component.expanded)) return false;
1411
1509
  const ioView = resolveIoViewFromTool(hit.component);
@@ -1417,13 +1515,12 @@ function tryOpenToolIoShowMore(tui: any, packet: SgrMousePacket, hit: ToolRender
1417
1515
  : (Number.isFinite(tui?.previousViewportTop) ? tui.previousViewportTop : 0) + packet.row - 1;
1418
1516
  if (visibleRow < 0 || visibleRow >= previousLines.length) return false;
1419
1517
 
1420
- const plain = stripTerminalSequences(String(previousLines[visibleRow] ?? ""));
1518
+ const plain = stripTerminalSequencesPreservingLayout(String(previousLines[visibleRow] ?? ""));
1421
1519
  const section = ioView.matchShowMoreLine(plain);
1422
1520
  if (!section) return false;
1423
1521
 
1424
- // Prefer the [show more] cells; allow a little slack so imprecise clicks still work.
1425
1522
  const box = ioView.showMoreHitbox(plain);
1426
- if (box && packet.col > 0 && packet.col < box.startCol - 4) return false;
1523
+ if (!box || packet.col < box.startCol || packet.col > box.endCol) return false;
1427
1524
 
1428
1525
  const ui = toolMouseUi;
1429
1526
  if (!ui || typeof ui.custom !== "function") {
@@ -1438,31 +1535,85 @@ function tryOpenToolIoShowMore(tui: any, packet: SgrMousePacket, hit: ToolRender
1438
1535
  return true;
1439
1536
  }
1440
1537
 
1538
+ function setHoveredToolIo(view: ExpandedToolIoView | null, section: ToolIoSection | null): boolean {
1539
+ if (view === hoveredToolIoView && section === hoveredToolIoSection) return false;
1540
+ hoveredToolIoView?.setHoveredSection(null);
1541
+ hoveredToolIoView = view;
1542
+ hoveredToolIoSection = section;
1543
+ view?.setHoveredSection(section);
1544
+ return true;
1545
+ }
1546
+
1441
1547
  function updateToolSummaryHover(tui: any, packet: SgrMousePacket): void {
1442
1548
  if ((packet.code & 32) === 0 || packet.final !== "M") return;
1443
- const hit = findToolAtScreenRow(tui, packet.row);
1444
- const nextToolCallId = hit && !hit.component.expanded ? hit.component.toolCallId : null;
1445
- if (nextToolCallId === hoveredToolCallId) return;
1549
+ const visibleLine = visibleMouseLine(tui, packet);
1550
+ let nextToolCallId: string | null = null;
1551
+ let nextIoView: ExpandedToolIoView | null = null;
1552
+ let nextIoSection: ToolIoSection | null = null;
1553
+
1554
+ if (
1555
+ COLLAPSED_TOOL_SUMMARY.test(visibleLine) &&
1556
+ isCollapsedHintAtColumn(visibleLine, packet.col)
1557
+ ) {
1558
+ const hit = findToolAtScreenRow(tui, packet.row);
1559
+ nextToolCallId = hit && !hit.component.expanded ? hit.component.toolCallId : null;
1560
+ } else if (visibleLine.includes(SHOW_MORE_LABEL)) {
1561
+ const hit = findToolAtScreenRow(tui, packet.row);
1562
+ const view = hit && hit.component.expanded ? resolveIoViewFromTool(hit.component) : null;
1563
+ const section = view?.matchShowMoreLine(visibleLine) ?? null;
1564
+ const box = view?.showMoreHitbox(visibleLine);
1565
+ if (view && section && box && packet.col >= box.startCol && packet.col <= box.endCol) {
1566
+ nextIoView = view;
1567
+ nextIoSection = section;
1568
+ }
1569
+ }
1570
+
1571
+ const changed = nextToolCallId !== hoveredToolCallId;
1446
1572
  hoveredToolCallId = nextToolCallId;
1447
- tui.requestRender?.();
1573
+ if (setHoveredToolIo(nextIoView, nextIoSection) || changed) tui.requestRender?.();
1574
+ }
1575
+
1576
+ function isExpandedToolCardHit(hit: ToolRenderHit, width: number): boolean {
1577
+ if (!Boolean(hit.component.expanded)) return false;
1578
+ const box = hit.component.contentBox;
1579
+ if (!box || !Array.isArray(hit.component.children) || !hit.component.children.includes(box)) {
1580
+ return false;
1581
+ }
1582
+ const boxLines = box.render?.(width);
1583
+ const componentLines = renderComponentTree(hit.component, width);
1584
+ if (!Array.isArray(boxLines) || boxLines.length === 0) return false;
1585
+ const cardStart = componentLines.length - boxLines.length;
1586
+ return hit.row >= cardStart && hit.row < componentLines.length;
1448
1587
  }
1449
1588
 
1450
1589
  function toggleToolAtMouseClick(tui: any, packet: SgrMousePacket): boolean {
1590
+ const line = visibleMouseLine(tui, packet);
1591
+ const collapsedHint = isCollapsedHintAtColumn(line, packet.col);
1592
+ const hasShowMore = line.includes(SHOW_MORE_LABEL);
1593
+
1451
1594
  const hit = findToolAtScreenRow(tui, packet.row);
1452
1595
  if (!hit) return false;
1453
-
1454
- if (tryOpenToolIoShowMore(tui, packet, hit)) return true;
1455
-
1456
- const wasExpanded = Boolean(hit.component.expanded);
1457
1596
  const width = Math.max(1, Number(tui?.terminal?.columns) || 80);
1458
- const previousHeight = wasExpanded ? renderComponentTree(hit.component, width).length : 0;
1459
- hit.component.setExpanded(!wasExpanded);
1460
- hit.component.invalidate?.();
1461
- const nextHeight = wasExpanded ? renderComponentTree(hit.component, width).length : 0;
1462
- tui.requestRender?.();
1463
- if (wasExpanded) {
1597
+ if (Boolean(hit.component.expanded)) {
1598
+ if (hasShowMore && tryOpenToolIoShowMore(tui, packet, hit)) return true;
1599
+ if (!isExpandedToolCardHit(hit, width)) return false;
1600
+ const previousHeight = renderComponentTree(hit.component, width).length;
1601
+ hit.component.setExpanded(false);
1602
+ hoveredToolCallId = null;
1603
+ setHoveredToolIo(null, null);
1604
+ hit.component.invalidate?.();
1605
+ const nextHeight = renderComponentTree(hit.component, width).length;
1606
+ tui.requestRender?.();
1464
1607
  scheduleCollapseViewportCompensation(tui, previousHeight - nextHeight, packet);
1608
+ return true;
1465
1609
  }
1610
+ if (!collapsedHint) return false;
1611
+
1612
+ hit.component.setExpanded(true);
1613
+ hoveredToolCallId = null;
1614
+ setHoveredToolIo(null, null);
1615
+ hit.component.invalidate?.();
1616
+ tui.requestRender?.();
1466
1617
  return true;
1467
1618
  }
1468
1619
 
@@ -1548,8 +1699,59 @@ function restoreToolMouseInputCapture(): void {
1548
1699
  toolMouseInputPatchWrapper = null;
1549
1700
  }
1550
1701
 
1702
+ function restoreToolMouseRenderPatch(): void {
1703
+ if (
1704
+ toolMouseRenderPatchTui &&
1705
+ toolMouseRenderPatchOriginal &&
1706
+ toolMouseRenderPatchTui.doRender === toolMouseRenderPatchWrapper
1707
+ ) {
1708
+ toolMouseRenderPatchTui.doRender = toolMouseRenderPatchOriginal;
1709
+ }
1710
+ toolMouseRenderPatchTui = null;
1711
+ toolMouseRenderPatchOriginal = null;
1712
+ toolMouseRenderPatchWrapper = null;
1713
+ toolMouseRawWrite = null;
1714
+ }
1715
+
1716
+ function patchToolMouseMotionAfterRender(tui: any): void {
1717
+ if (!useFixedEditorFeatures(tui) || toolMouseRenderPatchTui === tui) return;
1718
+ restoreToolMouseRenderPatch();
1719
+ const original = tui?.doRender;
1720
+ const terminal = tui?.terminal;
1721
+ let prototype = terminal && Object.getPrototypeOf(terminal);
1722
+ let rawWrite: ((...args: any[]) => unknown) | undefined;
1723
+ while (prototype && !rawWrite) {
1724
+ const candidate = Object.getOwnPropertyDescriptor(prototype, "write")?.value;
1725
+ if (typeof candidate === "function") rawWrite = candidate;
1726
+ prototype = Object.getPrototypeOf(prototype);
1727
+ }
1728
+ if (typeof original !== "function" || !rawWrite) return;
1729
+
1730
+ toolMouseRawWrite = (data) => Reflect.apply(rawWrite, terminal, [data]);
1731
+ const wrapper = function (this: any, ...args: any[]) {
1732
+ const result = Reflect.apply(original, this, args);
1733
+ toolMouseRawWrite?.(TOOL_MOUSE_MOTION_ENABLE);
1734
+ return result;
1735
+ };
1736
+ try {
1737
+ tui.doRender = wrapper;
1738
+ } catch {
1739
+ toolMouseRawWrite = null;
1740
+ return;
1741
+ }
1742
+ toolMouseRenderPatchTui = tui;
1743
+ toolMouseRenderPatchOriginal = original;
1744
+ toolMouseRenderPatchWrapper = wrapper;
1745
+ toolMouseRawWrite(TOOL_MOUSE_MOTION_ENABLE);
1746
+ }
1747
+
1551
1748
  function handleToolMouseInput(data: string): { consume: true } | undefined {
1552
1749
  if (!toolMouseTui) return undefined;
1750
+ if (
1751
+ toolMouseInputPatchTui === toolMouseTui &&
1752
+ toolMouseTui.handleInput === toolMouseInputPatchWrapper
1753
+ )
1754
+ return undefined;
1553
1755
  updateScrollButtonFromInput(toolMouseTui, data);
1554
1756
  if (isScrollBottomInput(data)) {
1555
1757
  if (useFixedEditorFeatures(toolMouseTui) && jumpToBottomWithoutSubmit(toolMouseTui)) {
@@ -1596,6 +1798,7 @@ function teardownToolMouseInteraction(): void {
1596
1798
  toolMouseInputUnsubscribe?.();
1597
1799
  toolMouseInputUnsubscribe = null;
1598
1800
  hoveredToolCallId = null;
1801
+ setHoveredToolIo(null, null);
1599
1802
  try {
1600
1803
  toolMouseTui?.terminal?.write?.(TOOL_MOUSE_DISABLE);
1601
1804
  } catch {
@@ -1607,6 +1810,7 @@ function teardownToolMouseInteraction(): void {
1607
1810
  // The UI context may already have been reset during /reload.
1608
1811
  }
1609
1812
  restoreToolMouseInputCapture();
1813
+ restoreToolMouseRenderPatch();
1610
1814
  scrollButtonVisible = false;
1611
1815
  scrollButtonWidget = null;
1612
1816
  pendingScrollMessages = 0;
@@ -1635,7 +1839,7 @@ export function installToolMouseInteraction(
1635
1839
  toolMouseTui = tui;
1636
1840
  if (fixedEditorFeatures) patchToolMouseInputCapture(tui);
1637
1841
  // Motion reporting is required for hover in both native and fixed-editor layouts.
1638
- tui?.terminal?.write?.(fixedEditorFeatures ? TOOL_MOUSE_ENABLE : "\x1b[?1003h\x1b[?1006h");
1842
+ tui?.terminal?.write?.(fixedEditorFeatures ? TOOL_MOUSE_ENABLE : TOOL_MOUSE_MOTION_ENABLE);
1639
1843
  const widget = {
1640
1844
  render: (width: number) => renderScrollButton(width, theme),
1641
1845
  invalidate() {},
@@ -1646,6 +1850,12 @@ export function installToolMouseInteraction(
1646
1850
  toolMouseInputUnsubscribe = ctx.ui.onTerminalInput(handleToolMouseInput);
1647
1851
  }
1648
1852
 
1853
+ function refreshToolRendererComponents(tui: any): void {
1854
+ const tools: any[] = [];
1855
+ collectToolComponents(tui, tools);
1856
+ for (const tool of tools) tool.invalidate?.();
1857
+ }
1858
+
1649
1859
  function scheduleSessionRender(refresh?: () => void): void {
1650
1860
  const tui = toolMouseTui;
1651
1861
  if (!tui || typeof tui.requestRender !== "function") return;
@@ -1656,6 +1866,8 @@ function scheduleSessionRender(refresh?: () => void): void {
1656
1866
  sessionRenderTimer = setTimeout(() => {
1657
1867
  sessionRenderTimer = null;
1658
1868
  if (toolMouseTui !== tui) return;
1869
+ patchToolMouseMotionAfterRender(tui);
1870
+ refreshToolRendererComponents(tui);
1659
1871
  refresh?.();
1660
1872
  tui.requestRender(true);
1661
1873
  }, 0);
@@ -1663,7 +1875,7 @@ function scheduleSessionRender(refresh?: () => void): void {
1663
1875
 
1664
1876
  // Bright green for success icon (truecolor ANSI escape)
1665
1877
  const BRIGHT_GREEN = "\x1b[38;2;80;220;100m";
1666
- const ANSI_RESET = "\x1b[0m";
1878
+ const ANSI_FG_RESET = "\x1b[39m";
1667
1879
 
1668
1880
  function refreshCurrentTranscript(compactStyle: CompactStyleHooks, ctx?: any): void {
1669
1881
  compactStyle.refresh();
@@ -2144,19 +2356,22 @@ function createCcstyleTool(
2144
2356
  const rawIcon = isPending ? pendingIcon(toolName) : settledIcon(toolName, visualState);
2145
2357
  const icon =
2146
2358
  visualState === "success"
2147
- ? `${BRIGHT_GREEN}${rawIcon}${ANSI_RESET}`
2359
+ ? `${BRIGHT_GREEN}${rawIcon}${ANSI_FG_RESET}`
2148
2360
  : theme.fg(toolIconColor(context), rawIcon);
2149
2361
 
2150
2362
  const argument = toolCallArgument(args);
2363
+ let cachedWidth: number | undefined;
2364
+ let cachedLine: string | undefined;
2151
2365
  return {
2152
2366
  render(width: number) {
2153
- const argumentWidth = Math.max(1, width - visibleWidth(`${rawIcon} ${label}()`));
2367
+ if (cachedLine !== undefined && cachedWidth === width) return [cachedLine];
2368
+ const callWidth = Math.max(0, toolViewportWidth(width) - visibleWidth(icon) - 1);
2369
+ const argumentWidth = Math.max(1, callWidth - visibleWidth(`${label}()`));
2154
2370
  const shown = argument ? middleTruncateToWidth(argument.value, argumentWidth) : undefined;
2155
2371
  const call = shown === undefined ? label : `${label}(${shown})`;
2156
- // Leave one cell unused so the host shell never performs a second,
2157
- // unthemed truncation after this renderer has applied toolTitle.
2158
- const callWidth = Math.max(0, width - visibleWidth(icon) - 2);
2159
- return [`${icon} ${theme.fg("toolTitle", truncateToWidth(call, callWidth, "…"))}`];
2372
+ cachedWidth = width;
2373
+ cachedLine = `${icon} ${theme.fg("toolTitle", truncateToWidth(call, callWidth, ""))}`;
2374
+ return [cachedLine];
2160
2375
  },
2161
2376
  invalidate() {},
2162
2377
  };
@@ -2195,9 +2410,12 @@ function createCcstyleTool(
2195
2410
 
2196
2411
  const text = textFromResult(result);
2197
2412
  const args = context?.args;
2198
- const rendered = !expanded ? (text ? oneLine(text, 96) : "Done") : text;
2413
+ const rendered = !expanded ? (text ? oneLine(text) : "Done") : text;
2199
2414
 
2200
- const hint = !expanded && hasExpandableDetail(text, args) ? expandHint(theme) : "";
2415
+ const detailLineCount = outputLineCount(result) || countLines(formatToolInputArgs(args));
2416
+ const hint =
2417
+ !expanded && hasExpandableDetail(text, args) ? expandHint(theme, detailLineCount) : "";
2418
+ const hoveredHint = hint ? expandHint(theme, detailLineCount, true) : "";
2201
2419
  if (expanded) {
2202
2420
  return renderExpandedToolResult(
2203
2421
  text || "",
@@ -2210,18 +2428,23 @@ function createCcstyleTool(
2210
2428
  }
2211
2429
  if (context?.state) context.state.ccstyleIoView = undefined;
2212
2430
  const toolCallId = context?.toolCallId;
2213
- const summary = theme.fg(
2214
- isError ? "error" : "muted",
2215
- renderCollapsedToolResult(rendered, hint),
2216
- );
2431
+ let cachedWidth: number | undefined;
2432
+ let cachedLine: string | undefined;
2433
+ let cachedHoveredLine: string | undefined;
2217
2434
  return {
2218
2435
  render(width: number) {
2219
- const line = truncateToWidth(summary, width, "…");
2220
- if (!toolCallId || hoveredToolCallId !== toolCallId) return [line];
2221
- const highlighted = line.replace(/\x1b\[0m/g, "\x1b[0;7m");
2222
- return [
2223
- `\x1b[7m${highlighted}${" ".repeat(Math.max(0, width - visibleWidth(line)))}\x1b[0m`,
2224
- ];
2436
+ if (cachedLine === undefined || cachedWidth !== width) {
2437
+ cachedWidth = width;
2438
+ cachedLine = theme.fg(
2439
+ isError ? "error" : "muted",
2440
+ renderCollapsedToolResultToWidth(rendered, hint, width),
2441
+ );
2442
+ cachedHoveredLine = theme.fg(
2443
+ isError ? "error" : "muted",
2444
+ renderCollapsedToolResultToWidth(rendered, hoveredHint, width),
2445
+ );
2446
+ }
2447
+ return [toolCallId && hoveredToolCallId === toolCallId ? cachedHoveredLine! : cachedLine];
2225
2448
  },
2226
2449
  invalidate() {},
2227
2450
  };
@@ -2480,10 +2703,14 @@ function installGlobalToolRendering(
2480
2703
  },
2481
2704
  getRenderShell: function (this: any, ...args: any[]) {
2482
2705
  if (!patch.active) return patch.downstream.getRenderShell.apply(this, args);
2706
+ const useSelfShell = shouldUseSelfShell(this, patch);
2707
+ const useCcstyle = shouldGloballyStyleTool(this, patch);
2483
2708
  const shell =
2484
- shouldUseSelfShell(this, patch) || shouldGloballyStyleTool(this, patch)
2709
+ useSelfShell || (useCcstyle && !this.expanded)
2485
2710
  ? "self"
2486
- : patch.downstream.getRenderShell.apply(this, args);
2711
+ : useCcstyle
2712
+ ? "default"
2713
+ : patch.downstream.getRenderShell.apply(this, args);
2487
2714
  syncToolShell(this, shell);
2488
2715
  return shell;
2489
2716
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-cc-extensions",
3
- "version": "0.8.22",
3
+ "version": "0.8.24",
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": [