pi-claude-code-ui 1.0.83 → 1.0.84

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.
Files changed (2) hide show
  1. package/extensions/index.ts +361 -47
  2. package/package.json +2 -1
@@ -41,6 +41,24 @@ import {
41
41
  visibleWidth,
42
42
  wrapTextWithAnsi,
43
43
  } from "@earendil-works/pi-tui";
44
+ import * as PiTui from "@earendil-works/pi-tui";
45
+
46
+ // Pi 0.85+ fullscreen mouse types. The 0.79 peer typings do not export them yet.
47
+ interface TuiMouseEvent {
48
+ type: string;
49
+ button: string;
50
+ x: number;
51
+ y: number;
52
+ width: number;
53
+ height: number;
54
+ }
55
+ interface TuiMouseEventResult {
56
+ handled?: boolean;
57
+ capture?: boolean;
58
+ focus?: boolean;
59
+ render?: boolean;
60
+ }
61
+ type MouseLayoutEntry = { component?: any; height: number; top: number };
44
62
 
45
63
  import * as Diff from "diff";
46
64
  import type { BundledLanguage, BundledTheme } from "shiki";
@@ -71,6 +89,8 @@ const TOOL_RENDER_CACHE = Symbol.for("pi-claude-style-tools:tool-render-cache");
71
89
  const COMPONENT_PARENT = Symbol.for("pi-claude-style-tools:component-parent");
72
90
  const PARENT_TRACKING_PATCH_FLAG = Symbol.for("pi-claude-style-tools:patched-parent-tracking");
73
91
  const TOOL_CACHE_PATCH_FLAG = Symbol.for("pi-claude-style-tools:patched-tool-cache-invalidation");
92
+ const TOOL_MOUSE_PATCH_FLAG = Symbol.for("pi-claude-style-tools:patched-tool-fullscreen-mouse");
93
+ const FULLSCREEN_HOVER_PATCH_FLAG = Symbol.for("pi-claude-style-tools:patched-fullscreen-hover");
74
94
  const TOOL_IMAGE_EXPAND_PATCH_FLAG = Symbol.for("pi-claude-style-tools:patched-read-image-expansion");
75
95
  const CUSTOM_MESSAGE_PATCH_FLAG = Symbol.for("pi-claude-style-tools:patched-custom-message-render");
76
96
  const USER_MESSAGE_PATCH_FLAG = Symbol.for("pi-claude-style-tools:patched-user-message-render");
@@ -880,7 +900,8 @@ function isGroupableTool(value: unknown): value is InstanceType<typeof ToolExecu
880
900
 
881
901
  class ToolGroupComponent extends Container {
882
902
  private tools: any[] = [];
883
- private expanded = false;
903
+ expanded = false;
904
+ private mouseLayout?: { width: number; children: MouseLayoutEntry[] };
884
905
  // Memoize full group output. Grouped history is the long-chat bottleneck:
885
906
  // each warm frame used to re-render every child tool, re-branch lines, and
886
907
  // re-clamp every row even when nothing changed.
@@ -933,6 +954,8 @@ class ToolGroupComponent extends Container {
933
954
  addTool(tool: any): void {
934
955
  ACTIVE_TOOL_GROUPS.add(this);
935
956
  this.tools.push(tool);
957
+ setToolHover(tool, false);
958
+ if (hoveredClickTarget?.owner === tool) hoveredClickTarget = undefined;
936
959
  tool[COMPONENT_PARENT] = this;
937
960
  // Don't cascade invalidate into every child — only drop our own cache.
938
961
  // Child tools already rebuild via their own updateDisplay path.
@@ -948,7 +971,7 @@ class ToolGroupComponent extends Container {
948
971
  }
949
972
 
950
973
  setExpanded(expanded: boolean): void {
951
- if (this.expanded === expanded) return;
974
+ if (this.expanded === expanded && this.tools.every((tool) => tool.expanded === expanded)) return;
952
975
  this.expanded = expanded;
953
976
  for (const tool of this.tools) tool.setExpanded?.(expanded);
954
977
  this.clearRenderCache();
@@ -962,8 +985,27 @@ class ToolGroupComponent extends Container {
962
985
  this.clearRenderCache();
963
986
  }
964
987
 
988
+ handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
989
+ const target = mouseLayoutTarget(this.mouseLayout, event);
990
+ if (event.type === "move") {
991
+ const row = target?.top ?? -1;
992
+ claimClickHover(this, () => { if (setGroupHover(this, -1)) requestComponentRender(this); });
993
+ return setGroupHover(this, row) ? { handled: true, render: true } : undefined;
994
+ }
995
+ if (isPrimaryClick(event) && target) {
996
+ const targets = target.component
997
+ ? Array.isArray(target.component) ? target.component : [target.component]
998
+ : this.tools;
999
+ return toggleGroupedTools(this, targets);
1000
+ }
1001
+ return undefined;
1002
+ }
1003
+
965
1004
  render(width: number): string[] {
966
- if (this.tools.length === 0) return [];
1005
+ if (this.tools.length === 0) {
1006
+ rememberMouseLayout(this, width, []);
1007
+ return [];
1008
+ }
967
1009
  const safeWidth = Number.isFinite(width) ? Math.max(1, Math.floor(width)) : 1;
968
1010
  // Fast path: settled groups with a valid memo skip ALL child walks.
969
1011
  // Child mutations mark dirty via clearToolRenderCache → invalidate().
@@ -975,7 +1017,7 @@ class ToolGroupComponent extends Container {
975
1017
  && this.cachedMode === toolBackgroundMode
976
1018
  && this.cachedExpanded === this.expanded
977
1019
  ) {
978
- return this.cachedLines;
1020
+ return applyGroupHover(this.cachedLines, this.mouseLayout, (this as any)[GROUP_HOVER_KEY]);
979
1021
  }
980
1022
 
981
1023
  const status = this.statusSnapshot();
@@ -988,6 +1030,12 @@ class ToolGroupComponent extends Container {
988
1030
  const label = getToolGroupLabel(this.tools);
989
1031
  const names = groupedName ? "" : formatToolNameList(this.tools);
990
1032
  const overall: ToolStatus = status.error > 0 ? "error" : status.pending > 0 ? "pending" : "success";
1033
+ const mouseChildren: MouseLayoutEntry[] = [];
1034
+ let mouseY = 0;
1035
+ const recordLines = (rowLines: string[], target?: any) => {
1036
+ mouseChildren.push({ component: target, height: rowLines.length, top: mouseY });
1037
+ mouseY += rowLines.length;
1038
+ };
991
1039
  // Group header breathes only when every pending member is Agent-family;
992
1040
  // mixed groups keep the ordinary on/off light.
993
1041
  const pendingTools = this.tools.filter((tool) => getToolStatusForGroup(tool) === "pending");
@@ -1006,13 +1054,17 @@ class ToolGroupComponent extends Container {
1006
1054
  if (collapseToSingleRow) {
1007
1055
  const entry = { tools: this.tools, name: groupedName, subject };
1008
1056
  const entryLine = getCollapsedToolEntryLine(entry, Math.max(1, safeWidth - 3));
1009
- lines.push(clampLineWidth(
1057
+ const row = [clampLineWidth(
1010
1058
  ` ${light} ${entryLine}${toolOutputDetailHint(undefined as any, false, true)}`,
1011
1059
  safeWidth,
1012
- ));
1060
+ )];
1061
+ recordLines(row, undefined);
1062
+ lines.push(...row);
1013
1063
  } else {
1014
1064
  const summary = ` ${light} ${summaryLabel} ${countsText}${names ? ` ${TRANSPARENT_RESET}• ${names}` : ""}${toolOutputDetailHint(undefined as any, this.expanded, true)}`;
1015
- lines.push(" ".repeat(safeWidth), clampLineWidth(summary, safeWidth));
1065
+ const header = [" ".repeat(safeWidth), clampLineWidth(summary, safeWidth)];
1066
+ recordLines(header, undefined);
1067
+ lines.push(...header);
1016
1068
  const childWidth = Math.max(1, safeWidth - 6);
1017
1069
  if (this.expanded) {
1018
1070
  for (let index = 0; index < total; index++) {
@@ -1025,7 +1077,9 @@ class ToolGroupComponent extends Container {
1025
1077
  getToolStatusForGroup(tool),
1026
1078
  { agentBreathe: isAgentFamilyToolName(getToolName(tool)) },
1027
1079
  );
1028
- for (const line of branched) lines.push(clampLineWidth(line, safeWidth));
1080
+ const row = branched.map((line) => clampLineWidth(line, safeWidth));
1081
+ recordLines(row, tool);
1082
+ lines.push(...row);
1029
1083
  }
1030
1084
  } else {
1031
1085
  const entries = collapseRepeatedToolEntries(this.tools);
@@ -1039,10 +1093,13 @@ class ToolGroupComponent extends Container {
1039
1093
  getToolGroupOverallStatus(entry.tools),
1040
1094
  { agentBreathe: entry.tools.every((tool) => isAgentFamilyToolName(getToolName(tool))) },
1041
1095
  );
1042
- for (const line of branched) lines.push(clampLineWidth(line, safeWidth));
1096
+ const row = branched.map((line) => clampLineWidth(line, safeWidth));
1097
+ recordLines(row, entry.tools.length === 1 ? entry.tools[0] : entry.tools);
1098
+ lines.push(...row);
1043
1099
  }
1044
1100
  }
1045
1101
  }
1102
+ rememberMouseLayout(this, safeWidth, mouseChildren);
1046
1103
 
1047
1104
  // Final clamp already applied per-line above; avoid a second full pass.
1048
1105
  if (canCache) {
@@ -1058,10 +1115,20 @@ class ToolGroupComponent extends Container {
1058
1115
  } else {
1059
1116
  this.clearRenderCache();
1060
1117
  }
1061
- return lines;
1118
+ return applyGroupHover(lines, this.mouseLayout, (this as any)[GROUP_HOVER_KEY]);
1062
1119
  }
1063
1120
  }
1064
1121
 
1122
+ function applyGroupHover(lines: string[], layout: { children: MouseLayoutEntry[] } | undefined, hoverStart: unknown): string[] {
1123
+ if (typeof hoverStart !== "number" || hoverStart < 0 || !layout) return lines;
1124
+ const entry = layout.children.find((candidate) => candidate.top === hoverStart);
1125
+ if (!entry) return lines;
1126
+ const rendered = lines.slice();
1127
+ const line = entry.top + (entry.component ? 0 : Math.max(0, entry.height - 1));
1128
+ if (line < rendered.length) rendered[line] = brightenRenderedLine(rendered[line]);
1129
+ return rendered;
1130
+ }
1131
+
1065
1132
  function isToolGroupComponent(value: unknown): value is ToolGroupComponent {
1066
1133
  return value instanceof ToolGroupComponent;
1067
1134
  }
@@ -1189,7 +1256,7 @@ function maybeGroupToolComponent(parent: any, component: any): void {
1189
1256
  }
1190
1257
  if (isGroupableTool(previous)) {
1191
1258
  const group = new ToolGroupComponent();
1192
- group.setExpanded(Boolean((previous as any).expanded));
1259
+ group.expanded = (previous as any).expanded === true || (component as any).expanded === true;
1193
1260
  group.addTool(previous);
1194
1261
  group.addTool(component);
1195
1262
  (group as any)[COMPONENT_PARENT] = parent;
@@ -1265,17 +1332,21 @@ function patchGlobalToolBorders(): void {
1265
1332
  && cached?.branchKey === branchKey
1266
1333
  && cached?.branchEpoch === _toolBranchVisualEpoch
1267
1334
  ) {
1335
+ // A cache hit skips Container.render, which is also where fullscreen
1336
+ // mouse targets are recorded. Restore the layout from that render.
1337
+ if (cached.mouseLayout) (this as { mouseLayout?: unknown }).mouseLayout = cached.mouseLayout;
1268
1338
  return cached.lines;
1269
1339
  }
1270
1340
  }
1271
1341
 
1272
1342
  const rendered = originalRender.call(this, width);
1343
+ const mouseLayout = (this as { mouseLayout?: unknown }).mouseLayout;
1273
1344
  if (!Array.isArray(rendered) || rendered.length === 0) return rendered;
1274
1345
  const todoOverlay = formatTodoOverlayLines(rendered, width);
1275
1346
  if (!isToolExecutionLike(this)) return todoOverlay;
1276
1347
  const branchCache = { branchKey: toolBranchRenderCacheKey(), branchEpoch: _toolBranchVisualEpoch };
1277
1348
  if (toolBackgroundMode === "default") {
1278
- (this as any)[TOOL_RENDER_CACHE] = { width, mode: toolBackgroundMode, lines: rendered, ...branchCache };
1349
+ (this as any)[TOOL_RENDER_CACHE] = { width, mode: toolBackgroundMode, lines: rendered, mouseLayout, ...branchCache };
1279
1350
  return rendered;
1280
1351
  }
1281
1352
 
@@ -1287,7 +1358,7 @@ function patchGlobalToolBorders(): void {
1287
1358
 
1288
1359
  const { textLines, imageLines } = splitRenderedImageBlock(rendered.slice(start, end + 1));
1289
1360
  if (imageLines.length > 0) {
1290
- (this as any)[TOOL_RENDER_CACHE] = { width, mode: toolBackgroundMode, lines: rendered, ...branchCache };
1361
+ (this as any)[TOOL_RENDER_CACHE] = { width, mode: toolBackgroundMode, lines: rendered, mouseLayout, ...branchCache };
1291
1362
  return rendered;
1292
1363
  }
1293
1364
  // Agent-family tools stay column-aligned with every other tool row — no extra
@@ -1307,7 +1378,7 @@ function patchGlobalToolBorders(): void {
1307
1378
  result = [spacerLine, ...core, ...imageLines];
1308
1379
  }
1309
1380
 
1310
- (this as any)[TOOL_RENDER_CACHE] = { width, mode: toolBackgroundMode, lines: result, ...branchCache };
1381
+ (this as any)[TOOL_RENDER_CACHE] = { width, mode: toolBackgroundMode, lines: result, mouseLayout, ...branchCache };
1311
1382
  return result;
1312
1383
  };
1313
1384
 
@@ -1379,6 +1450,96 @@ function clearToolRenderCache(value: unknown): void {
1379
1450
  if (isToolGroupComponent(parent)) parent.invalidate();
1380
1451
  }
1381
1452
 
1453
+ /** Fullscreen hit-testing reads the layout recorded by the last render. */
1454
+ function rememberMouseLayout(component: object, width: number, children: MouseLayoutEntry[]): void {
1455
+ (component as { mouseLayout?: { width: number; children: MouseLayoutEntry[] } }).mouseLayout = { width, children };
1456
+ }
1457
+
1458
+ function isPrimaryClick(event: TuiMouseEvent): boolean {
1459
+ return event.type === "click" && event.button === "left";
1460
+ }
1461
+
1462
+ function toggleExpandedComponent(component: any): boolean {
1463
+ if (!component || typeof component.setExpanded !== "function") return false;
1464
+ component.setExpanded(component.expanded !== true);
1465
+ return true;
1466
+ }
1467
+
1468
+ function requestComponentRender(component: any): void {
1469
+ const ui = component?.ui;
1470
+ if (typeof ui?.requestRender === "function") ui.requestRender();
1471
+ }
1472
+
1473
+ const GROUP_HOVER_KEY = Symbol.for("pi-claude-style-tools:group-hover");
1474
+
1475
+ function setGroupHover(group: any, rowStart: number): boolean {
1476
+ if (group[GROUP_HOVER_KEY] === rowStart) return false;
1477
+ group[GROUP_HOVER_KEY] = rowStart;
1478
+ return true;
1479
+ }
1480
+
1481
+ function mouseLayoutTarget(
1482
+ layout: { width: number; children: MouseLayoutEntry[] } | undefined,
1483
+ event: TuiMouseEvent,
1484
+ ): MouseLayoutEntry | undefined {
1485
+ if (!layout || layout.width !== event.width || event.y < 0 || event.y >= event.height) return undefined;
1486
+ return layout.children.find((entry) => event.y >= entry.top && event.y < entry.top + entry.height);
1487
+ }
1488
+
1489
+ function toggleGroupedTools(group: any, tools: any[]): { handled: true } | undefined {
1490
+ const targets = tools.filter((tool) => typeof tool?.setExpanded === "function");
1491
+ if (targets.length === 0) return undefined;
1492
+ const expanded = targets.some((tool) => tool.expanded !== true);
1493
+ for (const tool of targets) tool.setExpanded(expanded);
1494
+ if (Array.isArray(group?.tools)) group.expanded = group.tools.some((tool: any) => tool?.expanded === true);
1495
+ group.invalidate?.();
1496
+ const ui = targets.find((tool) => typeof tool?.ui?.requestRender === "function")?.ui;
1497
+ ui?.requestRender?.();
1498
+ return { handled: true };
1499
+ }
1500
+
1501
+ let hoveredClickTarget: { owner: object; clear: () => void } | undefined;
1502
+ let hoverClaimedThisMove = false;
1503
+
1504
+ function claimClickHover(owner: object, clear: () => void): void {
1505
+ hoverClaimedThisMove = true;
1506
+ if (hoveredClickTarget?.owner === owner) return;
1507
+ const previous = hoveredClickTarget;
1508
+ hoveredClickTarget = { owner, clear };
1509
+ previous?.clear();
1510
+ }
1511
+
1512
+ function patchFullscreenHoverExit(): void {
1513
+ const proto = (PiTui as any).TuiAltScreen?.prototype;
1514
+ if (!proto || proto[FULLSCREEN_HOVER_PATCH_FLAG] || typeof proto.handleMouseEvent !== "function") return;
1515
+ const originalHandleMouse = proto.handleMouseEvent;
1516
+ proto.handleMouseEvent = function patchedFullscreenMouseEvent(raw: { button: number }) {
1517
+ const isMove = (raw.button & 35) === 35;
1518
+ if (isMove) hoverClaimedThisMove = false;
1519
+ const result = originalHandleMouse.call(this, raw);
1520
+ if (isMove && !hoverClaimedThisMove && hoveredClickTarget) {
1521
+ const previous = hoveredClickTarget;
1522
+ hoveredClickTarget = undefined;
1523
+ previous.clear();
1524
+ this.requestRender();
1525
+ }
1526
+ return result;
1527
+ };
1528
+ if (typeof proto.handleViewportInput === "function") {
1529
+ const originalInput = proto.handleViewportInput;
1530
+ proto.handleViewportInput = function patchedFullscreenInput(data: string) {
1531
+ if (data === "\x1b[O" && hoveredClickTarget) {
1532
+ const previous = hoveredClickTarget;
1533
+ hoveredClickTarget = undefined;
1534
+ previous.clear();
1535
+ this.requestRender();
1536
+ }
1537
+ return originalInput.call(this, data);
1538
+ };
1539
+ }
1540
+ proto[FULLSCREEN_HOVER_PATCH_FLAG] = true;
1541
+ }
1542
+
1382
1543
  function unrefTimer(timer: ReturnType<typeof setTimeout> | null | undefined): void {
1383
1544
  (timer as any)?.unref?.();
1384
1545
  }
@@ -1393,6 +1554,7 @@ function safeInvalidate(ctx: any): void {
1393
1554
 
1394
1555
  const ASSISTANT_PATCH_FLAG = Symbol.for("pi-claude-style-tools:patched-assistant-message");
1395
1556
  const ASSISTANT_RENDER_PATCH_FLAG = Symbol.for("pi-claude-style-tools:patched-assistant-message-render");
1557
+ const ASSISTANT_MOUSE_PATCH_FLAG = Symbol.for("pi-claude-style-tools:patched-assistant-fullscreen-mouse");
1396
1558
  const TOOL_EXECUTION_PATCH_FLAG = Symbol.for("pi-claude-style-tools:patched-tool-execution");
1397
1559
 
1398
1560
  // Rendered-output cache for assistant/user/custom message components.
@@ -1450,9 +1612,8 @@ const WORKED_TURNS_KEY = "_piClaudeStyleWorkedTurns";
1450
1612
  const WORKED_DURATION_MARKER = "Turn took";
1451
1613
  const THINKING_DURATION_KEY = "_piClaudeStyleThinkingDurationMs";
1452
1614
  const THINKING_ACTIVE_KEY = "_piClaudeStyleThinkingActive";
1453
- const MIN_THINKING_SUMMARY_MS = 100;
1615
+ const MAX_REASONABLE_THINKING_DURATION_MS = 24 * 60 * 60 * 1000;
1454
1616
 
1455
- let lastThinkingBlockDurationMs: number | undefined;
1456
1617
  let thinkingBlockStartMs = 0;
1457
1618
  /** True from thinking_start until thinking_end on the current assistant stream. */
1458
1619
  let thinkingBlockInFlight = false;
@@ -1518,9 +1679,25 @@ function pluralizeTurns(n: number): string {
1518
1679
  return `${n} turn${n === 1 ? "" : "s"}`;
1519
1680
  }
1520
1681
 
1521
- function thinkingSummaryStyledText(body: string): string {
1682
+ function thinkingSummaryStyledText(body: string, hovered = false): string {
1522
1683
  // Preserve the visible thinking text column while omitting ∴ when collapsed.
1523
- return ` ${WORKED_LINE_FG}${body}${RESET}`;
1684
+ const color = hovered ? brightenAnsi(WORKED_LINE_FG) : WORKED_LINE_FG;
1685
+ return ` ${color}${body}${RESET}`;
1686
+ }
1687
+
1688
+ /** Brighten an existing color without changing its hue. */
1689
+ function brightenAnsi(baseAnsi: string): string {
1690
+ const rgb = parseAnsiRgb(baseAnsi);
1691
+ if (!rgb) return "\x1b[97m";
1692
+ const maximum = Math.max(rgb.r, rgb.g, rgb.b);
1693
+ if (maximum === 0) return baseAnsi;
1694
+ const scale = Math.min(1.12, 255 / maximum);
1695
+ const lift = (channel: number) => Math.round(channel * scale);
1696
+ return `\x1b[38;2;${lift(rgb.r)};${lift(rgb.g)};${lift(rgb.b)}m`;
1697
+ }
1698
+
1699
+ function brightenRenderedLine(line: string): string {
1700
+ return line.replace(/\x1b\[38;2;(\d+);(\d+);(\d+)m/g, (_match, r, g, b) => brightenAnsi(`\x1b[38;2;${r};${g};${b}m`));
1524
1701
  }
1525
1702
 
1526
1703
  function thinkingActiveSummaryText(): string {
@@ -1533,34 +1710,47 @@ function thoughtDurationSummaryText(ms: number): string {
1533
1710
 
1534
1711
  /** Single-line hidden thinking row — no Text paddingX or thinking symbol. */
1535
1712
  class HiddenThinkingSummary {
1536
- private summaryText: string;
1713
+ private body: string;
1714
+ private hovered = false;
1537
1715
  private cachedWidth?: number;
1716
+ private cachedHovered?: boolean;
1538
1717
  private cachedLines?: string[];
1539
1718
 
1540
- constructor(summaryText: string) {
1541
- this.summaryText = summaryText;
1719
+ constructor(body: string) {
1720
+ this.body = stripAnsi(body).trim();
1542
1721
  }
1543
1722
 
1544
1723
  setSummary(summaryText: string): void {
1545
- this.summaryText = summaryText;
1724
+ const body = stripAnsi(summaryText).trim();
1725
+ if (this.body === body) return;
1726
+ this.body = body;
1727
+ this.invalidate();
1728
+ }
1729
+
1730
+ setHovered(hovered: boolean): void {
1731
+ if (this.hovered === hovered) return;
1732
+ this.hovered = hovered;
1546
1733
  this.invalidate();
1547
1734
  }
1548
1735
 
1549
1736
  invalidate(): void {
1550
1737
  this.cachedWidth = undefined;
1738
+ this.cachedHovered = undefined;
1551
1739
  this.cachedLines = undefined;
1552
1740
  }
1553
1741
 
1554
1742
  render(width: number): string[] {
1555
- if (this.cachedLines && this.cachedWidth === width) return this.cachedLines;
1743
+ if (this.cachedLines && this.cachedWidth === width && this.cachedHovered === this.hovered) return this.cachedLines;
1556
1744
  const safeWidth = Number.isFinite(width) ? Math.max(0, Math.floor(width)) : 0;
1557
1745
  if (safeWidth <= 0) {
1558
1746
  this.cachedWidth = width;
1747
+ this.cachedHovered = this.hovered;
1559
1748
  this.cachedLines = [""];
1560
1749
  return this.cachedLines;
1561
1750
  }
1562
- const line = padRenderedLineToWidth(this.summaryText, safeWidth);
1751
+ const line = padRenderedLineToWidth(thinkingSummaryStyledText(this.body, this.hovered), safeWidth);
1563
1752
  this.cachedWidth = width;
1753
+ this.cachedHovered = this.hovered;
1564
1754
  this.cachedLines = [line];
1565
1755
  return this.cachedLines;
1566
1756
  }
@@ -1568,13 +1758,8 @@ class HiddenThinkingSummary {
1568
1758
 
1569
1759
  function getMessageThinkingDurationMs(message: any): number {
1570
1760
  const stored = (message as any)?.[THINKING_DURATION_KEY];
1571
- if (typeof stored === "number" && stored > 0) return stored;
1572
- if (typeof lastThinkingBlockDurationMs === "number" && lastThinkingBlockDurationMs > 0) {
1573
- return lastThinkingBlockDurationMs;
1574
- }
1575
- if (typeof (message as any)?.[WORKED_DURATION_KEY] === "number" && (message as any)[WORKED_DURATION_KEY] > 0) {
1576
- return (message as any)[WORKED_DURATION_KEY];
1577
- }
1761
+ if (typeof stored === "number" && Number.isFinite(stored) && stored > 0 && stored <= MAX_REASONABLE_THINKING_DURATION_MS) return stored;
1762
+ if (message && typeof message === "object" && stored !== undefined) delete (message as any)[THINKING_DURATION_KEY];
1578
1763
  let totalChars = 0;
1579
1764
  if (Array.isArray(message?.content)) {
1580
1765
  for (const block of message.content) {
@@ -2073,10 +2258,18 @@ function replaceHiddenThinkingPlaceholders(container: { children?: any[] }, mess
2073
2258
 
2074
2259
  class ThinkingParagraph {
2075
2260
  private text: string;
2261
+ private hovered = false;
2076
2262
  private cachedWidth?: number;
2077
2263
  private cachedLines?: string[];
2264
+ private cachedHovered = false;
2078
2265
  private chromeEpoch = -1;
2079
2266
 
2267
+ setHovered(hovered: boolean): void {
2268
+ if (this.hovered === hovered) return;
2269
+ this.hovered = hovered;
2270
+ this.invalidate();
2271
+ }
2272
+
2080
2273
  constructor(
2081
2274
  text: string,
2082
2275
  _markdownTheme: ConstructorParameters<typeof Markdown>[3],
@@ -2123,6 +2316,7 @@ class ThinkingParagraph {
2123
2316
  if (
2124
2317
  this.cachedLines
2125
2318
  && this.cachedWidth === width
2319
+ && this.cachedHovered === this.hovered
2126
2320
  && this.chromeEpoch === _toolBranchVisualEpoch
2127
2321
  ) {
2128
2322
  return this.cachedLines;
@@ -2144,8 +2338,9 @@ class ThinkingParagraph {
2144
2338
  return this.cachedLines;
2145
2339
  }
2146
2340
  const lines = sanitizeRenderedTextBlockLines(md.render(safeWidth - PREFIX_W), safeWidth - PREFIX_W);
2341
+ const displayLines = this.hovered ? lines.map((line) => brightenRenderedLine(line)) : lines;
2147
2342
  let symbolPlaced = false;
2148
- const rendered = lines.map((line: string) => {
2343
+ const rendered = displayLines.map((line: string) => {
2149
2344
  if (!symbolPlaced && stripAnsi(line).trim()) {
2150
2345
  symbolPlaced = true;
2151
2346
  return ` ${prefix} ${line}`;
@@ -2153,6 +2348,7 @@ class ThinkingParagraph {
2153
2348
  return ` ${line}`;
2154
2349
  }).map((line) => clampLineWidth(line, safeWidth));
2155
2350
  this.cachedWidth = width;
2351
+ this.cachedHovered = this.hovered;
2156
2352
  this.cachedLines = rendered;
2157
2353
  this.chromeEpoch = _toolBranchVisualEpoch;
2158
2354
  return rendered;
@@ -2444,8 +2640,10 @@ function patchAssistantMessages(): void {
2444
2640
  // - "full" mode: behaves like stock pi (stays collapsed).
2445
2641
  const liveMode = getThinkingMode() === "live";
2446
2642
  const thinkingCollapsed = !!(this as any).hideThinkingBlock;
2447
- const showLiveThinking = liveMode && thinkingCollapsed && isLiveThinkingMessage(this, message);
2448
- if (thinkingCollapsed && messageHasThinkingContent(message)) {
2643
+ const override = (this as any).thinkingVisibilityOverrides?.get?.(0);
2644
+ const showLiveThinking = liveMode && thinkingCollapsed && override !== true && isLiveThinkingMessage(this, message);
2645
+ const hideThinking = override ?? (thinkingCollapsed && !showLiveThinking);
2646
+ if (hideThinking && messageHasThinkingContent(message)) {
2449
2647
  // Pi wraps this in theme.italic/fg again — keep plain label for the placeholder pass.
2450
2648
  (this as any).hiddenThinkingLabel = "Thinking…";
2451
2649
  }
@@ -2459,7 +2657,7 @@ function patchAssistantMessages(): void {
2459
2657
  // Replace text-block Markdown children with DottedParagraph wrappers
2460
2658
  const container = (this as any).contentContainer;
2461
2659
  if (!container?.children) return;
2462
- if (thinkingCollapsed && !showLiveThinking && messageHasThinkingContent(message)) {
2660
+ if (hideThinking && messageHasThinkingContent(message)) {
2463
2661
  replaceHiddenThinkingPlaceholders(container, message);
2464
2662
  }
2465
2663
  const mdTheme = (this as any).markdownTheme;
@@ -2513,8 +2711,120 @@ function patchAssistantMessages(): void {
2513
2711
  proto[ASSISTANT_PATCH_FLAG] = true;
2514
2712
  }
2515
2713
 
2714
+ const THINKING_HOVER_KEY = Symbol.for("pi-claude-style-tools:thinking-hover");
2715
+
2716
+ function thinkingVisualChild(child: any): any {
2717
+ const inner = child?.child ?? child;
2718
+ if (
2719
+ inner instanceof HiddenThinkingSummary
2720
+ || inner instanceof ThinkingParagraph
2721
+ || inner?.constructor?.name === "HiddenThinkingSummary"
2722
+ || inner?.constructor?.name === "ThinkingParagraph"
2723
+ ) return inner;
2724
+ return undefined;
2725
+ }
2726
+
2727
+ function thinkingLayout(component: any, width: number): Array<{ component: any; height: number }> {
2728
+ const container = component?.contentContainer;
2729
+ const children = Array.isArray(container?.children) ? container.children : [];
2730
+ if (container?.mouseLayout?.width === width) return container.mouseLayout.children;
2731
+ return children.map((child: any) => ({ component: child, height: child.render(width).length }));
2732
+ }
2733
+
2734
+ function thinkingRowAt(component: any, event: TuiMouseEvent): any {
2735
+ let childY = 0;
2736
+ for (const entry of thinkingLayout(component, event.width)) {
2737
+ if (event.y >= childY && event.y < childY + entry.height) return thinkingVisualChild(entry.component);
2738
+ childY += entry.height;
2739
+ }
2740
+ return undefined;
2741
+ }
2742
+
2743
+ function setThinkingHover(component: any, hovered: any): boolean {
2744
+ const previous = component[THINKING_HOVER_KEY];
2745
+ if (previous === hovered) return false;
2746
+ previous?.setHovered?.(false);
2747
+ if (hovered) hovered.setHovered(true);
2748
+ component[THINKING_HOVER_KEY] = hovered;
2749
+ clearMessageRenderCache(component);
2750
+ return true;
2751
+ }
2752
+
2753
+ function patchAssistantFullscreenMouse(): void {
2754
+ const proto = AssistantMessageComponent.prototype as any;
2755
+ if (proto[ASSISTANT_MOUSE_PATCH_FLAG]) return;
2756
+ const originalHandleMouse = proto.handleMouse;
2757
+ proto.handleMouse = function patchedAssistantHandleMouse(event: TuiMouseEvent) {
2758
+ const hovered = thinkingRowAt(this, event);
2759
+ if (event.type === "move" && hovered) {
2760
+ claimClickHover(this, () => { if (setThinkingHover(this, undefined)) requestComponentRender(this); });
2761
+ }
2762
+ const hoverChanged = event.type === "move" ? setThinkingHover(this, hovered) : false;
2763
+ if (event.type === "move") return hoverChanged ? { handled: true, render: true } : undefined;
2764
+ // Claim the row before Pi's MouseRegion. Its handler restores the italic
2765
+ // "Thinking…" label while a block is streaming.
2766
+ if (isPrimaryClick(event) && hovered && this.lastMessage) {
2767
+ const overrides = this.thinkingVisibilityOverrides instanceof Map
2768
+ ? this.thinkingVisibilityOverrides
2769
+ : new Map();
2770
+ this.thinkingVisibilityOverrides = overrides;
2771
+ overrides.set(0, hovered instanceof ThinkingParagraph);
2772
+ this.updateContent(this.lastMessage);
2773
+ return { handled: true };
2774
+ }
2775
+ return typeof originalHandleMouse === "function" ? originalHandleMouse.call(this, event) : undefined;
2776
+ };
2777
+ proto[ASSISTANT_MOUSE_PATCH_FLAG] = true;
2778
+ }
2779
+
2516
2780
  const TOOL_BG_PATCH_FLAG = Symbol.for("pi-claude-style-tools:patched-tool-bg-sync");
2517
2781
 
2782
+ const TOOL_HOVER_KEY = Symbol.for("pi-claude-style-tools:tool-hover");
2783
+
2784
+ function setToolHover(component: any, hovered: boolean): boolean {
2785
+ if (component[TOOL_HOVER_KEY] === hovered) return false;
2786
+ component[TOOL_HOVER_KEY] = hovered;
2787
+ clearToolRenderCache(component);
2788
+ return true;
2789
+ }
2790
+
2791
+ function patchToolFullscreenMouse(): void {
2792
+ const proto = ToolExecutionComponent.prototype as any;
2793
+ if (proto[TOOL_MOUSE_PATCH_FLAG]) return;
2794
+ const originalHandleMouse = proto.handleMouse;
2795
+ if (typeof originalHandleMouse !== "function") return;
2796
+ const originalRender = proto.render;
2797
+ proto.render = function patchedToolHoverRender(width: number) {
2798
+ const lines = originalRender.call(this, width);
2799
+ if (this[TOOL_HOVER_KEY] !== true || !Array.isArray(lines)) return lines;
2800
+ const titleIndex = lines.findIndex((line: string) => stripAnsi(line).trim().length > 0 && !/^\s*[╭╰─│]+\s*$/.test(stripAnsi(line)));
2801
+ if (titleIndex < 0) return lines;
2802
+ const rendered = lines.slice();
2803
+ rendered[titleIndex] = brightenRenderedLine(rendered[titleIndex]);
2804
+ return rendered;
2805
+ };
2806
+ proto.handleMouse = function patchedToolHandleMouse(event: TuiMouseEvent) {
2807
+ if (event.type === "move") {
2808
+ claimClickHover(this, () => { if (setToolHover(this, false)) requestComponentRender(this); });
2809
+ return setToolHover(this, true) ? { handled: true, render: true } : undefined;
2810
+ }
2811
+ const result = originalHandleMouse.call(this, event);
2812
+ if (result) return result;
2813
+ // Self-rendered rows forward into their own content. Default-shell rows
2814
+ // reach here only when no nested region claimed the click, including the
2815
+ // blank outline padding our container patch paints around the content.
2816
+ if (!isPrimaryClick(event) || !this.result) return undefined;
2817
+ const group = this[COMPONENT_PARENT];
2818
+ if (group?.constructor?.name === "ToolGroupComponent") {
2819
+ return toggleGroupedTools(group, [this]);
2820
+ }
2821
+ if (!toggleExpandedComponent(this)) return undefined;
2822
+ requestComponentRender(this);
2823
+ return { handled: true };
2824
+ };
2825
+ proto[TOOL_MOUSE_PATCH_FLAG] = true;
2826
+ }
2827
+
2518
2828
  function patchToolExecutionBackgroundSync(): void {
2519
2829
  const proto = ToolExecutionComponent.prototype as any;
2520
2830
  if (proto[TOOL_BG_PATCH_FLAG]) return;
@@ -5274,7 +5584,6 @@ function trackThinkingBlockEvents(event: any, ctx?: any): void {
5274
5584
  if (evt.type === "thinking_start") {
5275
5585
  thinkingBlockInFlight = true;
5276
5586
  thinkingBlockStartMs = Date.now();
5277
- lastThinkingBlockDurationMs = undefined;
5278
5587
  if (message?.role === "assistant") {
5279
5588
  (message as any)[THINKING_ACTIVE_KEY] = true;
5280
5589
  delete (message as any)[THINKING_DURATION_KEY];
@@ -5284,10 +5593,14 @@ function trackThinkingBlockEvents(event: any, ctx?: any): void {
5284
5593
  }
5285
5594
  if (evt.type === "thinking_end") {
5286
5595
  thinkingBlockInFlight = false;
5287
- const duration = Math.max(0, Date.now() - thinkingBlockStartMs);
5596
+ const duration = thinkingBlockStartMs > 0
5597
+ ? Math.max(0, Date.now() - thinkingBlockStartMs)
5598
+ : undefined;
5288
5599
  if (message?.role === "assistant") delete (message as any)[THINKING_ACTIVE_KEY];
5289
- lastThinkingBlockDurationMs = duration;
5290
- if (message?.role === "assistant") (message as any)[THINKING_DURATION_KEY] = duration;
5600
+ if (typeof duration === "number" && duration <= MAX_REASONABLE_THINKING_DURATION_MS) {
5601
+ if (message?.role === "assistant") (message as any)[THINKING_DURATION_KEY] = duration;
5602
+ }
5603
+ thinkingBlockStartMs = 0;
5291
5604
  refreshThinkingChrome();
5292
5605
  return;
5293
5606
  }
@@ -5302,10 +5615,10 @@ function trackThinkingBlockEvents(event: any, ctx?: any): void {
5302
5615
  thinkingBlockInFlight = false;
5303
5616
  const duration = thinkingBlockStartMs > 0 ? Math.max(0, Date.now() - thinkingBlockStartMs) : undefined;
5304
5617
  if (message?.role === "assistant") delete (message as any)[THINKING_ACTIVE_KEY];
5305
- if (typeof duration === "number") {
5306
- lastThinkingBlockDurationMs = duration;
5618
+ if (typeof duration === "number" && duration <= MAX_REASONABLE_THINKING_DURATION_MS) {
5307
5619
  if (message?.role === "assistant") (message as any)[THINKING_DURATION_KEY] = duration;
5308
5620
  }
5621
+ thinkingBlockStartMs = 0;
5309
5622
  refreshThinkingChrome();
5310
5623
  }
5311
5624
  }
@@ -5354,6 +5667,7 @@ function registerThinkingLabels(pi: ExtensionAPI): void {
5354
5667
  // global in-flight flag set and the next message renders a stale
5355
5668
  // "Thinking..." row until its own thinking events arrive.
5356
5669
  thinkingBlockInFlight = false;
5670
+ thinkingBlockStartMs = 0;
5357
5671
  delete (message as any)[THINKING_ACTIVE_KEY];
5358
5672
  }
5359
5673
  });
@@ -5370,13 +5684,10 @@ function registerThinkingLabels(pi: ExtensionAPI): void {
5370
5684
  // at the end of the message instead of sticking into later calls.
5371
5685
  thinkingBlockInFlight = false;
5372
5686
  delete (message as any)[THINKING_ACTIVE_KEY];
5373
- if (typeof (message as any)[THINKING_DURATION_KEY] !== "number") {
5374
- const duration = thinkingBlockStartMs > 0 ? Math.max(0, Date.now() - thinkingBlockStartMs) : undefined;
5375
- if (typeof duration === "number" && duration > 0) {
5376
- lastThinkingBlockDurationMs = duration;
5687
+ if (typeof (message as any)[THINKING_DURATION_KEY] !== "number" && thinkingBlockStartMs > 0) {
5688
+ const duration = Math.max(0, Date.now() - thinkingBlockStartMs);
5689
+ if (duration <= MAX_REASONABLE_THINKING_DURATION_MS) {
5377
5690
  (message as any)[THINKING_DURATION_KEY] = duration;
5378
- } else if (typeof lastThinkingBlockDurationMs === "number" && lastThinkingBlockDurationMs > 0) {
5379
- (message as any)[THINKING_DURATION_KEY] = lastThinkingBlockDurationMs;
5380
5691
  }
5381
5692
  }
5382
5693
  thinkingBlockStartMs = 0;
@@ -6440,11 +6751,14 @@ export default function (pi: ExtensionAPI) {
6440
6751
  patchToolExecutionBackgroundSync();
6441
6752
  patchToolRenderCacheInvalidation();
6442
6753
  patchReadImageExpansion();
6754
+ patchFullscreenHoverExit();
6755
+ patchToolFullscreenMouse();
6443
6756
  patchContainerParentTracking();
6444
6757
  patchGlobalToolBorders();
6445
6758
  patchCustomMessageRender();
6446
6759
  patchUserMessageRender();
6447
6760
  patchAssistantMessages();
6761
+ patchAssistantFullscreenMouse();
6448
6762
  patchToolExecutionRenderers();
6449
6763
  applyDiffPalette();
6450
6764
  registerThinkingLabels(pi);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-claude-code-ui",
3
- "version": "1.0.83",
3
+ "version": "1.0.84",
4
4
  "description": "Claude Code-style grouped tool rows for pi with Ctrl+O/Ctrl+Shift+O previews, math rendering, and consistent built-in, MCP, and custom tool rendering",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -37,6 +37,7 @@
37
37
  ],
38
38
  "scripts": {
39
39
  "test": "node --experimental-strip-types --test scripts/bash-command.test.ts",
40
+ "test:fullscreen": "node scripts/fullscreen-mouse.test.mjs",
40
41
  "typecheck": "tsc --noEmit --target ES2022 --module NodeNext --moduleResolution NodeNext --skipLibCheck extensions/index.ts extensions/spinner.ts",
41
42
  "pack:dry-run": "npm pack --dry-run",
42
43
  "publish:dry-run": "npm publish --dry-run",