pi-claude-code-ui 1.0.83 → 1.0.85

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.
@@ -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";
@@ -52,6 +70,7 @@ import {
52
70
  formatBashDuration,
53
71
  getLastBashOutputLine,
54
72
  } from "./bash-command";
73
+ import { getUserSettingsPath } from "./settings-path";
55
74
 
56
75
  const RESET = "\x1b[0m";
57
76
  const TRANSPARENT_BG = "\x1b[49m";
@@ -71,6 +90,8 @@ const TOOL_RENDER_CACHE = Symbol.for("pi-claude-style-tools:tool-render-cache");
71
90
  const COMPONENT_PARENT = Symbol.for("pi-claude-style-tools:component-parent");
72
91
  const PARENT_TRACKING_PATCH_FLAG = Symbol.for("pi-claude-style-tools:patched-parent-tracking");
73
92
  const TOOL_CACHE_PATCH_FLAG = Symbol.for("pi-claude-style-tools:patched-tool-cache-invalidation");
93
+ const TOOL_MOUSE_PATCH_FLAG = Symbol.for("pi-claude-style-tools:patched-tool-fullscreen-mouse");
94
+ const FULLSCREEN_HOVER_PATCH_FLAG = Symbol.for("pi-claude-style-tools:patched-fullscreen-hover");
74
95
  const TOOL_IMAGE_EXPAND_PATCH_FLAG = Symbol.for("pi-claude-style-tools:patched-read-image-expansion");
75
96
  const CUSTOM_MESSAGE_PATCH_FLAG = Symbol.for("pi-claude-style-tools:patched-custom-message-render");
76
97
  const USER_MESSAGE_PATCH_FLAG = Symbol.for("pi-claude-style-tools:patched-user-message-render");
@@ -147,7 +168,7 @@ function readSettings(): SettingsFile {
147
168
  return _settingsCache.value;
148
169
  }
149
170
  const cwdPath = `${process.cwd()}/.pi/settings.json`;
150
- const homePath = `${process.env.HOME ?? ""}/.pi/settings.json`;
171
+ const homePath = getUserSettingsPath();
151
172
  const merged: SettingsFile = {};
152
173
  for (const path of [cwdPath, homePath]) {
153
174
  try {
@@ -175,9 +196,9 @@ function bustSpinnerSettingsCache(): void {
175
196
  function writeSettingsKey(key: string, value: unknown): void {
176
197
  _settingsCache = null; // invalidate cache on write
177
198
  const home = process.env.HOME ?? "";
178
- if (!home) return;
179
- const dir = `${home}/.pi`;
180
- const path = `${dir}/settings.json`;
199
+ if (!home && !process.env.PI_CODING_AGENT_DIR) return;
200
+ const path = getUserSettingsPath();
201
+ const dir = dirname(path);
181
202
  let settings: Record<string, unknown> = {};
182
203
  try {
183
204
  if (existsSync(path)) settings = JSON.parse(readFileSync(path, "utf8")) ?? {};
@@ -506,8 +527,8 @@ function getThinkingMode(): ThinkingMode {
506
527
 
507
528
  function isAssistantThinkingComplete(comp: any, message: any): boolean {
508
529
  if (!message || message.role !== "assistant") return false;
509
- if (typeof message[THINKING_DURATION_KEY] === "number") return true;
510
- if (message[THINKING_ACTIVE_KEY]) return false;
530
+ if (getThinkingDurationMetadata(message) !== undefined) return true;
531
+ if (hasThinkingActiveMetadata(message)) return false;
511
532
  // Providers keep stopReason "pending" for the whole stream ("deferred" while
512
533
  // a deferred call is unresolved); both are in-flight sentinels, never
513
534
  // completion signals. Without this, live-thinking detection would depend on
@@ -534,7 +555,7 @@ function isAssistantThinkingComplete(comp: any, message: any): boolean {
534
555
  function isLiveThinkingMessage(comp: any, message: any): boolean {
535
556
  if (!message || message.role !== "assistant") return false;
536
557
  if (isAssistantThinkingComplete(comp, message)) return false;
537
- if ((message as any)[THINKING_ACTIVE_KEY]) return true;
558
+ if (hasThinkingActiveMetadata(message)) return true;
538
559
  if (Array.isArray(message.content)) {
539
560
  return message.content.some((b: any) => b?.type === "thinking" && typeof b?.thinking === "string" && b.thinking.trim());
540
561
  }
@@ -880,7 +901,8 @@ function isGroupableTool(value: unknown): value is InstanceType<typeof ToolExecu
880
901
 
881
902
  class ToolGroupComponent extends Container {
882
903
  private tools: any[] = [];
883
- private expanded = false;
904
+ expanded = false;
905
+ private mouseLayout?: { width: number; children: MouseLayoutEntry[] };
884
906
  // Memoize full group output. Grouped history is the long-chat bottleneck:
885
907
  // each warm frame used to re-render every child tool, re-branch lines, and
886
908
  // re-clamp every row even when nothing changed.
@@ -933,6 +955,8 @@ class ToolGroupComponent extends Container {
933
955
  addTool(tool: any): void {
934
956
  ACTIVE_TOOL_GROUPS.add(this);
935
957
  this.tools.push(tool);
958
+ setToolHover(tool, false);
959
+ if (hoveredClickTarget?.owner === tool) hoveredClickTarget = undefined;
936
960
  tool[COMPONENT_PARENT] = this;
937
961
  // Don't cascade invalidate into every child — only drop our own cache.
938
962
  // Child tools already rebuild via their own updateDisplay path.
@@ -948,7 +972,7 @@ class ToolGroupComponent extends Container {
948
972
  }
949
973
 
950
974
  setExpanded(expanded: boolean): void {
951
- if (this.expanded === expanded) return;
975
+ if (this.expanded === expanded && this.tools.every((tool) => tool.expanded === expanded)) return;
952
976
  this.expanded = expanded;
953
977
  for (const tool of this.tools) tool.setExpanded?.(expanded);
954
978
  this.clearRenderCache();
@@ -962,8 +986,27 @@ class ToolGroupComponent extends Container {
962
986
  this.clearRenderCache();
963
987
  }
964
988
 
989
+ handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
990
+ const target = mouseLayoutTarget(this.mouseLayout, event);
991
+ if (event.type === "move") {
992
+ const row = target?.top ?? -1;
993
+ claimClickHover(this, () => { if (setGroupHover(this, -1)) requestComponentRender(this); });
994
+ return setGroupHover(this, row) ? { handled: true, render: true } : undefined;
995
+ }
996
+ if (isPrimaryClick(event) && target) {
997
+ const targets = target.component
998
+ ? Array.isArray(target.component) ? target.component : [target.component]
999
+ : this.tools;
1000
+ return toggleGroupedTools(this, targets);
1001
+ }
1002
+ return undefined;
1003
+ }
1004
+
965
1005
  render(width: number): string[] {
966
- if (this.tools.length === 0) return [];
1006
+ if (this.tools.length === 0) {
1007
+ rememberMouseLayout(this, width, []);
1008
+ return [];
1009
+ }
967
1010
  const safeWidth = Number.isFinite(width) ? Math.max(1, Math.floor(width)) : 1;
968
1011
  // Fast path: settled groups with a valid memo skip ALL child walks.
969
1012
  // Child mutations mark dirty via clearToolRenderCache → invalidate().
@@ -975,7 +1018,7 @@ class ToolGroupComponent extends Container {
975
1018
  && this.cachedMode === toolBackgroundMode
976
1019
  && this.cachedExpanded === this.expanded
977
1020
  ) {
978
- return this.cachedLines;
1021
+ return applyGroupHover(this.cachedLines, this.mouseLayout, (this as any)[GROUP_HOVER_KEY]);
979
1022
  }
980
1023
 
981
1024
  const status = this.statusSnapshot();
@@ -988,6 +1031,12 @@ class ToolGroupComponent extends Container {
988
1031
  const label = getToolGroupLabel(this.tools);
989
1032
  const names = groupedName ? "" : formatToolNameList(this.tools);
990
1033
  const overall: ToolStatus = status.error > 0 ? "error" : status.pending > 0 ? "pending" : "success";
1034
+ const mouseChildren: MouseLayoutEntry[] = [];
1035
+ let mouseY = 0;
1036
+ const recordLines = (rowLines: string[], target?: any) => {
1037
+ mouseChildren.push({ component: target, height: rowLines.length, top: mouseY });
1038
+ mouseY += rowLines.length;
1039
+ };
991
1040
  // Group header breathes only when every pending member is Agent-family;
992
1041
  // mixed groups keep the ordinary on/off light.
993
1042
  const pendingTools = this.tools.filter((tool) => getToolStatusForGroup(tool) === "pending");
@@ -1006,13 +1055,17 @@ class ToolGroupComponent extends Container {
1006
1055
  if (collapseToSingleRow) {
1007
1056
  const entry = { tools: this.tools, name: groupedName, subject };
1008
1057
  const entryLine = getCollapsedToolEntryLine(entry, Math.max(1, safeWidth - 3));
1009
- lines.push(clampLineWidth(
1058
+ const row = [clampLineWidth(
1010
1059
  ` ${light} ${entryLine}${toolOutputDetailHint(undefined as any, false, true)}`,
1011
1060
  safeWidth,
1012
- ));
1061
+ )];
1062
+ recordLines(row, undefined);
1063
+ lines.push(...row);
1013
1064
  } else {
1014
1065
  const summary = ` ${light} ${summaryLabel} ${countsText}${names ? ` ${TRANSPARENT_RESET}• ${names}` : ""}${toolOutputDetailHint(undefined as any, this.expanded, true)}`;
1015
- lines.push(" ".repeat(safeWidth), clampLineWidth(summary, safeWidth));
1066
+ const header = [" ".repeat(safeWidth), clampLineWidth(summary, safeWidth)];
1067
+ recordLines(header, undefined);
1068
+ lines.push(...header);
1016
1069
  const childWidth = Math.max(1, safeWidth - 6);
1017
1070
  if (this.expanded) {
1018
1071
  for (let index = 0; index < total; index++) {
@@ -1025,7 +1078,9 @@ class ToolGroupComponent extends Container {
1025
1078
  getToolStatusForGroup(tool),
1026
1079
  { agentBreathe: isAgentFamilyToolName(getToolName(tool)) },
1027
1080
  );
1028
- for (const line of branched) lines.push(clampLineWidth(line, safeWidth));
1081
+ const row = branched.map((line) => clampLineWidth(line, safeWidth));
1082
+ recordLines(row, tool);
1083
+ lines.push(...row);
1029
1084
  }
1030
1085
  } else {
1031
1086
  const entries = collapseRepeatedToolEntries(this.tools);
@@ -1039,10 +1094,13 @@ class ToolGroupComponent extends Container {
1039
1094
  getToolGroupOverallStatus(entry.tools),
1040
1095
  { agentBreathe: entry.tools.every((tool) => isAgentFamilyToolName(getToolName(tool))) },
1041
1096
  );
1042
- for (const line of branched) lines.push(clampLineWidth(line, safeWidth));
1097
+ const row = branched.map((line) => clampLineWidth(line, safeWidth));
1098
+ recordLines(row, entry.tools.length === 1 ? entry.tools[0] : entry.tools);
1099
+ lines.push(...row);
1043
1100
  }
1044
1101
  }
1045
1102
  }
1103
+ rememberMouseLayout(this, safeWidth, mouseChildren);
1046
1104
 
1047
1105
  // Final clamp already applied per-line above; avoid a second full pass.
1048
1106
  if (canCache) {
@@ -1058,10 +1116,20 @@ class ToolGroupComponent extends Container {
1058
1116
  } else {
1059
1117
  this.clearRenderCache();
1060
1118
  }
1061
- return lines;
1119
+ return applyGroupHover(lines, this.mouseLayout, (this as any)[GROUP_HOVER_KEY]);
1062
1120
  }
1063
1121
  }
1064
1122
 
1123
+ function applyGroupHover(lines: string[], layout: { children: MouseLayoutEntry[] } | undefined, hoverStart: unknown): string[] {
1124
+ if (typeof hoverStart !== "number" || hoverStart < 0 || !layout) return lines;
1125
+ const entry = layout.children.find((candidate) => candidate.top === hoverStart);
1126
+ if (!entry) return lines;
1127
+ const rendered = lines.slice();
1128
+ const line = entry.top + (entry.component ? 0 : Math.max(0, entry.height - 1));
1129
+ if (line < rendered.length) rendered[line] = brightenRenderedLine(rendered[line]);
1130
+ return rendered;
1131
+ }
1132
+
1065
1133
  function isToolGroupComponent(value: unknown): value is ToolGroupComponent {
1066
1134
  return value instanceof ToolGroupComponent;
1067
1135
  }
@@ -1132,7 +1200,7 @@ function isThinkingOnlyAssistantComponent(comp: unknown): comp is InstanceType<t
1132
1200
  if (hasText) return false;
1133
1201
  const hasToolCalls = msg.content.some((c: any) => c?.type === "toolCall");
1134
1202
  if (hasToolCalls) return false;
1135
- if ((comp as any).isStreaming === true || msg[THINKING_ACTIVE_KEY]) return false;
1203
+ if ((comp as any).isStreaming === true || hasThinkingActiveMetadata(msg)) return false;
1136
1204
  return true;
1137
1205
  }
1138
1206
 
@@ -1160,10 +1228,13 @@ function maybeMergeConsecutiveThinkingMessages(parent: any): void {
1160
1228
  const mergedDuration = durA + durB;
1161
1229
 
1162
1230
  const nextThinkingBlocks = nextMsg.content.filter((c: any) => c?.type === "thinking");
1163
- curMsg.content.push(...nextThinkingBlocks);
1164
- curMsg[THINKING_DURATION_KEY] = mergedDuration;
1231
+ const mergedMessage = {
1232
+ ...curMsg,
1233
+ content: [...curMsg.content, ...nextThinkingBlocks],
1234
+ [THINKING_DURATION_KEY]: mergedDuration,
1235
+ };
1165
1236
 
1166
- (current as any).updateContent(curMsg);
1237
+ (current as any).updateContent(mergedMessage);
1167
1238
 
1168
1239
  const removeCount = nextIdx - i;
1169
1240
  children.splice(i + 1, removeCount);
@@ -1189,7 +1260,7 @@ function maybeGroupToolComponent(parent: any, component: any): void {
1189
1260
  }
1190
1261
  if (isGroupableTool(previous)) {
1191
1262
  const group = new ToolGroupComponent();
1192
- group.setExpanded(Boolean((previous as any).expanded));
1263
+ group.expanded = (previous as any).expanded === true || (component as any).expanded === true;
1193
1264
  group.addTool(previous);
1194
1265
  group.addTool(component);
1195
1266
  (group as any)[COMPONENT_PARENT] = parent;
@@ -1265,17 +1336,21 @@ function patchGlobalToolBorders(): void {
1265
1336
  && cached?.branchKey === branchKey
1266
1337
  && cached?.branchEpoch === _toolBranchVisualEpoch
1267
1338
  ) {
1339
+ // A cache hit skips Container.render, which is also where fullscreen
1340
+ // mouse targets are recorded. Restore the layout from that render.
1341
+ if (cached.mouseLayout) (this as { mouseLayout?: unknown }).mouseLayout = cached.mouseLayout;
1268
1342
  return cached.lines;
1269
1343
  }
1270
1344
  }
1271
1345
 
1272
1346
  const rendered = originalRender.call(this, width);
1347
+ const mouseLayout = (this as { mouseLayout?: unknown }).mouseLayout;
1273
1348
  if (!Array.isArray(rendered) || rendered.length === 0) return rendered;
1274
1349
  const todoOverlay = formatTodoOverlayLines(rendered, width);
1275
1350
  if (!isToolExecutionLike(this)) return todoOverlay;
1276
1351
  const branchCache = { branchKey: toolBranchRenderCacheKey(), branchEpoch: _toolBranchVisualEpoch };
1277
1352
  if (toolBackgroundMode === "default") {
1278
- (this as any)[TOOL_RENDER_CACHE] = { width, mode: toolBackgroundMode, lines: rendered, ...branchCache };
1353
+ (this as any)[TOOL_RENDER_CACHE] = { width, mode: toolBackgroundMode, lines: rendered, mouseLayout, ...branchCache };
1279
1354
  return rendered;
1280
1355
  }
1281
1356
 
@@ -1287,7 +1362,7 @@ function patchGlobalToolBorders(): void {
1287
1362
 
1288
1363
  const { textLines, imageLines } = splitRenderedImageBlock(rendered.slice(start, end + 1));
1289
1364
  if (imageLines.length > 0) {
1290
- (this as any)[TOOL_RENDER_CACHE] = { width, mode: toolBackgroundMode, lines: rendered, ...branchCache };
1365
+ (this as any)[TOOL_RENDER_CACHE] = { width, mode: toolBackgroundMode, lines: rendered, mouseLayout, ...branchCache };
1291
1366
  return rendered;
1292
1367
  }
1293
1368
  // Agent-family tools stay column-aligned with every other tool row — no extra
@@ -1307,7 +1382,7 @@ function patchGlobalToolBorders(): void {
1307
1382
  result = [spacerLine, ...core, ...imageLines];
1308
1383
  }
1309
1384
 
1310
- (this as any)[TOOL_RENDER_CACHE] = { width, mode: toolBackgroundMode, lines: result, ...branchCache };
1385
+ (this as any)[TOOL_RENDER_CACHE] = { width, mode: toolBackgroundMode, lines: result, mouseLayout, ...branchCache };
1311
1386
  return result;
1312
1387
  };
1313
1388
 
@@ -1379,6 +1454,96 @@ function clearToolRenderCache(value: unknown): void {
1379
1454
  if (isToolGroupComponent(parent)) parent.invalidate();
1380
1455
  }
1381
1456
 
1457
+ /** Fullscreen hit-testing reads the layout recorded by the last render. */
1458
+ function rememberMouseLayout(component: object, width: number, children: MouseLayoutEntry[]): void {
1459
+ (component as { mouseLayout?: { width: number; children: MouseLayoutEntry[] } }).mouseLayout = { width, children };
1460
+ }
1461
+
1462
+ function isPrimaryClick(event: TuiMouseEvent): boolean {
1463
+ return event.type === "click" && event.button === "left";
1464
+ }
1465
+
1466
+ function toggleExpandedComponent(component: any): boolean {
1467
+ if (!component || typeof component.setExpanded !== "function") return false;
1468
+ component.setExpanded(component.expanded !== true);
1469
+ return true;
1470
+ }
1471
+
1472
+ function requestComponentRender(component: any): void {
1473
+ const ui = component?.ui;
1474
+ if (typeof ui?.requestRender === "function") ui.requestRender();
1475
+ }
1476
+
1477
+ const GROUP_HOVER_KEY = Symbol.for("pi-claude-style-tools:group-hover");
1478
+
1479
+ function setGroupHover(group: any, rowStart: number): boolean {
1480
+ if (group[GROUP_HOVER_KEY] === rowStart) return false;
1481
+ group[GROUP_HOVER_KEY] = rowStart;
1482
+ return true;
1483
+ }
1484
+
1485
+ function mouseLayoutTarget(
1486
+ layout: { width: number; children: MouseLayoutEntry[] } | undefined,
1487
+ event: TuiMouseEvent,
1488
+ ): MouseLayoutEntry | undefined {
1489
+ if (!layout || layout.width !== event.width || event.y < 0 || event.y >= event.height) return undefined;
1490
+ return layout.children.find((entry) => event.y >= entry.top && event.y < entry.top + entry.height);
1491
+ }
1492
+
1493
+ function toggleGroupedTools(group: any, tools: any[]): { handled: true } | undefined {
1494
+ const targets = tools.filter((tool) => typeof tool?.setExpanded === "function");
1495
+ if (targets.length === 0) return undefined;
1496
+ const expanded = targets.some((tool) => tool.expanded !== true);
1497
+ for (const tool of targets) tool.setExpanded(expanded);
1498
+ if (Array.isArray(group?.tools)) group.expanded = group.tools.some((tool: any) => tool?.expanded === true);
1499
+ group.invalidate?.();
1500
+ const ui = targets.find((tool) => typeof tool?.ui?.requestRender === "function")?.ui;
1501
+ ui?.requestRender?.();
1502
+ return { handled: true };
1503
+ }
1504
+
1505
+ let hoveredClickTarget: { owner: object; clear: () => void } | undefined;
1506
+ let hoverClaimedThisMove = false;
1507
+
1508
+ function claimClickHover(owner: object, clear: () => void): void {
1509
+ hoverClaimedThisMove = true;
1510
+ if (hoveredClickTarget?.owner === owner) return;
1511
+ const previous = hoveredClickTarget;
1512
+ hoveredClickTarget = { owner, clear };
1513
+ previous?.clear();
1514
+ }
1515
+
1516
+ function patchFullscreenHoverExit(): void {
1517
+ const proto = (PiTui as any).TuiAltScreen?.prototype;
1518
+ if (!proto || proto[FULLSCREEN_HOVER_PATCH_FLAG] || typeof proto.handleMouseEvent !== "function") return;
1519
+ const originalHandleMouse = proto.handleMouseEvent;
1520
+ proto.handleMouseEvent = function patchedFullscreenMouseEvent(raw: { button: number }) {
1521
+ const isMove = (raw.button & 35) === 35;
1522
+ if (isMove) hoverClaimedThisMove = false;
1523
+ const result = originalHandleMouse.call(this, raw);
1524
+ if (isMove && !hoverClaimedThisMove && hoveredClickTarget) {
1525
+ const previous = hoveredClickTarget;
1526
+ hoveredClickTarget = undefined;
1527
+ previous.clear();
1528
+ this.requestRender();
1529
+ }
1530
+ return result;
1531
+ };
1532
+ if (typeof proto.handleViewportInput === "function") {
1533
+ const originalInput = proto.handleViewportInput;
1534
+ proto.handleViewportInput = function patchedFullscreenInput(data: string) {
1535
+ if (data === "\x1b[O" && hoveredClickTarget) {
1536
+ const previous = hoveredClickTarget;
1537
+ hoveredClickTarget = undefined;
1538
+ previous.clear();
1539
+ this.requestRender();
1540
+ }
1541
+ return originalInput.call(this, data);
1542
+ };
1543
+ }
1544
+ proto[FULLSCREEN_HOVER_PATCH_FLAG] = true;
1545
+ }
1546
+
1382
1547
  function unrefTimer(timer: ReturnType<typeof setTimeout> | null | undefined): void {
1383
1548
  (timer as any)?.unref?.();
1384
1549
  }
@@ -1393,6 +1558,7 @@ function safeInvalidate(ctx: any): void {
1393
1558
 
1394
1559
  const ASSISTANT_PATCH_FLAG = Symbol.for("pi-claude-style-tools:patched-assistant-message");
1395
1560
  const ASSISTANT_RENDER_PATCH_FLAG = Symbol.for("pi-claude-style-tools:patched-assistant-message-render");
1561
+ const ASSISTANT_MOUSE_PATCH_FLAG = Symbol.for("pi-claude-style-tools:patched-assistant-fullscreen-mouse");
1396
1562
  const TOOL_EXECUTION_PATCH_FLAG = Symbol.for("pi-claude-style-tools:patched-tool-execution");
1397
1563
 
1398
1564
  // Rendered-output cache for assistant/user/custom message components.
@@ -1450,9 +1616,48 @@ const WORKED_TURNS_KEY = "_piClaudeStyleWorkedTurns";
1450
1616
  const WORKED_DURATION_MARKER = "Turn took";
1451
1617
  const THINKING_DURATION_KEY = "_piClaudeStyleThinkingDurationMs";
1452
1618
  const THINKING_ACTIVE_KEY = "_piClaudeStyleThinkingActive";
1453
- const MIN_THINKING_SUMMARY_MS = 100;
1619
+ const MAX_REASONABLE_THINKING_DURATION_MS = 24 * 60 * 60 * 1000;
1620
+
1621
+ // Presentation metadata belongs to the renderer, not the canonical session
1622
+ // messages. Weak collections keep live state available to the TUI without
1623
+ // changing the objects Pi persists or projects for compaction replay.
1624
+ const thinkingDurationByMessage = new WeakMap<object, number>();
1625
+ const thinkingActiveMessages = new WeakSet<object>();
1626
+ const workedStartByMessage = new WeakMap<object, number>();
1627
+ const workedDurationByMessage = new WeakMap<object, { duration: number; sessionTotal?: number; turns?: number }>();
1628
+
1629
+ function getThinkingDurationMetadata(message: any): number | undefined {
1630
+ if (!message || typeof message !== "object") return undefined;
1631
+ const stored = thinkingDurationByMessage.get(message) ?? message[THINKING_DURATION_KEY];
1632
+ return typeof stored === "number"
1633
+ && Number.isFinite(stored)
1634
+ && stored > 0
1635
+ && stored <= MAX_REASONABLE_THINKING_DURATION_MS
1636
+ ? stored
1637
+ : undefined;
1638
+ }
1639
+
1640
+ function hasThinkingActiveMetadata(message: any): boolean {
1641
+ return !!message
1642
+ && typeof message === "object"
1643
+ && (thinkingActiveMessages.has(message) || message[THINKING_ACTIVE_KEY] === true);
1644
+ }
1645
+
1646
+ function workedMetadataForMessage(message: any): { duration: number; sessionTotal?: number; turns?: number } | undefined {
1647
+ if (!message || typeof message !== "object") return undefined;
1648
+ const metadata = workedDurationByMessage.get(message);
1649
+ if (metadata) return metadata;
1650
+ const duration = message[WORKED_DURATION_KEY];
1651
+ if (typeof duration !== "number" || !Number.isFinite(duration) || duration < 0) return undefined;
1652
+ const sessionTotal = message[WORKED_SESSION_TOTAL_KEY];
1653
+ const turns = message[WORKED_TURNS_KEY];
1654
+ return {
1655
+ duration,
1656
+ sessionTotal: typeof sessionTotal === "number" ? sessionTotal : undefined,
1657
+ turns: typeof turns === "number" ? turns : undefined,
1658
+ };
1659
+ }
1454
1660
 
1455
- let lastThinkingBlockDurationMs: number | undefined;
1456
1661
  let thinkingBlockStartMs = 0;
1457
1662
  /** True from thinking_start until thinking_end on the current assistant stream. */
1458
1663
  let thinkingBlockInFlight = false;
@@ -1518,9 +1723,25 @@ function pluralizeTurns(n: number): string {
1518
1723
  return `${n} turn${n === 1 ? "" : "s"}`;
1519
1724
  }
1520
1725
 
1521
- function thinkingSummaryStyledText(body: string): string {
1726
+ function thinkingSummaryStyledText(body: string, hovered = false): string {
1522
1727
  // Preserve the visible thinking text column while omitting ∴ when collapsed.
1523
- return ` ${WORKED_LINE_FG}${body}${RESET}`;
1728
+ const color = hovered ? brightenAnsi(WORKED_LINE_FG) : WORKED_LINE_FG;
1729
+ return ` ${color}${body}${RESET}`;
1730
+ }
1731
+
1732
+ /** Brighten an existing color without changing its hue. */
1733
+ function brightenAnsi(baseAnsi: string): string {
1734
+ const rgb = parseAnsiRgb(baseAnsi);
1735
+ if (!rgb) return "\x1b[97m";
1736
+ const maximum = Math.max(rgb.r, rgb.g, rgb.b);
1737
+ if (maximum === 0) return baseAnsi;
1738
+ const scale = Math.min(1.12, 255 / maximum);
1739
+ const lift = (channel: number) => Math.round(channel * scale);
1740
+ return `\x1b[38;2;${lift(rgb.r)};${lift(rgb.g)};${lift(rgb.b)}m`;
1741
+ }
1742
+
1743
+ function brightenRenderedLine(line: string): string {
1744
+ return line.replace(/\x1b\[38;2;(\d+);(\d+);(\d+)m/g, (_match, r, g, b) => brightenAnsi(`\x1b[38;2;${r};${g};${b}m`));
1524
1745
  }
1525
1746
 
1526
1747
  function thinkingActiveSummaryText(): string {
@@ -1533,48 +1754,55 @@ function thoughtDurationSummaryText(ms: number): string {
1533
1754
 
1534
1755
  /** Single-line hidden thinking row — no Text paddingX or thinking symbol. */
1535
1756
  class HiddenThinkingSummary {
1536
- private summaryText: string;
1757
+ private body: string;
1758
+ private hovered = false;
1537
1759
  private cachedWidth?: number;
1760
+ private cachedHovered?: boolean;
1538
1761
  private cachedLines?: string[];
1539
1762
 
1540
- constructor(summaryText: string) {
1541
- this.summaryText = summaryText;
1763
+ constructor(body: string) {
1764
+ this.body = stripAnsi(body).trim();
1542
1765
  }
1543
1766
 
1544
1767
  setSummary(summaryText: string): void {
1545
- this.summaryText = summaryText;
1768
+ const body = stripAnsi(summaryText).trim();
1769
+ if (this.body === body) return;
1770
+ this.body = body;
1771
+ this.invalidate();
1772
+ }
1773
+
1774
+ setHovered(hovered: boolean): void {
1775
+ if (this.hovered === hovered) return;
1776
+ this.hovered = hovered;
1546
1777
  this.invalidate();
1547
1778
  }
1548
1779
 
1549
1780
  invalidate(): void {
1550
1781
  this.cachedWidth = undefined;
1782
+ this.cachedHovered = undefined;
1551
1783
  this.cachedLines = undefined;
1552
1784
  }
1553
1785
 
1554
1786
  render(width: number): string[] {
1555
- if (this.cachedLines && this.cachedWidth === width) return this.cachedLines;
1787
+ if (this.cachedLines && this.cachedWidth === width && this.cachedHovered === this.hovered) return this.cachedLines;
1556
1788
  const safeWidth = Number.isFinite(width) ? Math.max(0, Math.floor(width)) : 0;
1557
1789
  if (safeWidth <= 0) {
1558
1790
  this.cachedWidth = width;
1791
+ this.cachedHovered = this.hovered;
1559
1792
  this.cachedLines = [""];
1560
1793
  return this.cachedLines;
1561
1794
  }
1562
- const line = padRenderedLineToWidth(this.summaryText, safeWidth);
1795
+ const line = padRenderedLineToWidth(thinkingSummaryStyledText(this.body, this.hovered), safeWidth);
1563
1796
  this.cachedWidth = width;
1797
+ this.cachedHovered = this.hovered;
1564
1798
  this.cachedLines = [line];
1565
1799
  return this.cachedLines;
1566
1800
  }
1567
1801
  }
1568
1802
 
1569
1803
  function getMessageThinkingDurationMs(message: any): number {
1570
- 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
- }
1804
+ const stored = getThinkingDurationMetadata(message);
1805
+ if (stored !== undefined) return stored;
1578
1806
  let totalChars = 0;
1579
1807
  if (Array.isArray(message?.content)) {
1580
1808
  for (const block of message.content) {
@@ -1595,9 +1823,6 @@ function hiddenThinkingSummaryForMessage(message: any, comp?: any): string {
1595
1823
  return thinkingActiveSummaryText();
1596
1824
  }
1597
1825
  const durationMs = getMessageThinkingDurationMs(message);
1598
- if (message && typeof message === "object") {
1599
- (message as any)[THINKING_DURATION_KEY] = durationMs;
1600
- }
1601
1826
  return thoughtDurationSummaryText(durationMs);
1602
1827
  }
1603
1828
 
@@ -1778,13 +2003,20 @@ function patchTerminalWriteTagScrubber(): void {
1778
2003
 
1779
2004
  function replaceInlineMath(text: string): string {
1780
2005
  if (!hasInlineMathMarkers(text)) return text;
1781
- const withParens = text.replace(/\\\(([\s\S]*?)\\\)/g, (_match, body: string) => {
1782
- return codeSpan(formatMathForDisplay(body, false));
1783
- });
1784
- return withParens.replace(/(^|[^\\])\$([^\n$]{1,200})\$/g, (match, prefix: string, body: string) => {
1785
- if (!looksLikeInlineMath(body)) return match;
1786
- return `${prefix}${codeSpan(formatMathForDisplay(body, false))}`;
1787
- });
2006
+ const transform = (segment: string): string => {
2007
+ const withParens = segment.replace(/\\\(([\s\S]*?)\\\)/g, (_match, body: string) => {
2008
+ return codeSpan(formatMathForDisplay(body, false));
2009
+ });
2010
+ return withParens.replace(/(^|[^\\])\$([^\n$]{1,200})\$/g, (match, prefix: string, body: string) => {
2011
+ if (!looksLikeInlineMath(body)) return match;
2012
+ return `${prefix}${codeSpan(formatMathForDisplay(body, false))}`;
2013
+ });
2014
+ };
2015
+ // Markdown code spans and fenced blocks are literal source. Apply math
2016
+ // formatting only to prose segments so shell variables such as `$base` and
2017
+ // `$scope` remain byte-for-byte unchanged in displayed/copied commands.
2018
+ const parts = text.split(/(`{3,}[\s\S]*?`{3,}|`[^`\n]*`)/g);
2019
+ return parts.map((part, index) => index % 2 === 1 ? part : transform(part)).join("");
1788
2020
  }
1789
2021
 
1790
2022
  interface MathBlock {
@@ -1873,16 +2105,30 @@ function shouldFormatStandaloneMath(text: string): boolean {
1873
2105
  return !/[.!?]\s/.test(plain) && plain.length <= 240;
1874
2106
  }
1875
2107
 
1876
- function appendMarkdownSegment(segments: ParagraphSegment[], text: string, theme: MarkdownThemeLike): void {
2108
+ // Options Pi hands to the Markdown components it builds, including the markdown
2109
+ // transformers (Pi's own Mermaid renderer, extension transformers, ...). Paragraph
2110
+ // wrappers rebuild those components, so they have to forward the options.
2111
+ type MarkdownOptions = ConstructorParameters<typeof Markdown>[5]
2112
+
2113
+ function appendMarkdownSegment(
2114
+ segments: ParagraphSegment[],
2115
+ text: string,
2116
+ theme: MarkdownThemeLike,
2117
+ options?: MarkdownOptions,
2118
+ ): void {
1877
2119
  if (!text.trim()) return;
1878
2120
  const normalized = shouldFormatStandaloneMath(text) ? formatMathForDisplay(text, false) : replaceInlineMath(text);
1879
- segments.push({ kind: "markdown", md: new Markdown(normalized, 0, 0, theme) });
2121
+ segments.push({ kind: "markdown", md: new Markdown(normalized, 0, 0, theme, undefined, options) });
1880
2122
  }
1881
2123
 
1882
- function buildParagraphSegments(text: string, theme: MarkdownThemeLike): ParagraphSegment[] {
2124
+ function buildParagraphSegments(
2125
+ text: string,
2126
+ theme: MarkdownThemeLike,
2127
+ options?: MarkdownOptions,
2128
+ ): ParagraphSegment[] {
1883
2129
  const segments: ParagraphSegment[] = [];
1884
2130
  if (!hasDisplayMathMarkers(text)) {
1885
- appendMarkdownSegment(segments, text, theme);
2131
+ appendMarkdownSegment(segments, text, theme, options);
1886
2132
  return segments;
1887
2133
  }
1888
2134
  const scanLoose = shouldScanLooseBracketMath(text);
@@ -1890,12 +2136,12 @@ function buildParagraphSegments(text: string, theme: MarkdownThemeLike): Paragra
1890
2136
  while (cursor < text.length) {
1891
2137
  const next = findNextDisplayMathBlock(text, cursor, scanLoose);
1892
2138
  if (!next) break;
1893
- appendMarkdownSegment(segments, text.slice(cursor, next.index), theme);
2139
+ appendMarkdownSegment(segments, text.slice(cursor, next.index), theme, options);
1894
2140
  const raw = text.slice(next.contentStart, next.contentEnd).trim();
1895
2141
  if (raw) segments.push({ kind: "math", raw });
1896
2142
  cursor = next.endIndex;
1897
2143
  }
1898
- appendMarkdownSegment(segments, text.slice(cursor), theme);
2144
+ appendMarkdownSegment(segments, text.slice(cursor), theme, options);
1899
2145
  return segments;
1900
2146
  }
1901
2147
 
@@ -1982,9 +2228,9 @@ class DottedParagraph {
1982
2228
  private cachedWidth?: number;
1983
2229
  private cachedLines?: string[];
1984
2230
 
1985
- constructor(text: string, markdownTheme: MarkdownThemeLike) {
2231
+ constructor(text: string, markdownTheme: MarkdownThemeLike, options?: MarkdownOptions) {
1986
2232
  this.markdownTheme = copySafeMarkdownTheme(markdownTheme);
1987
- this.segments = buildParagraphSegments(stripTransientMagicContextTags(text), this.markdownTheme);
2233
+ this.segments = buildParagraphSegments(stripTransientMagicContextTags(text), this.markdownTheme, options);
1988
2234
  }
1989
2235
 
1990
2236
  invalidate(): void {
@@ -2073,16 +2319,29 @@ function replaceHiddenThinkingPlaceholders(container: { children?: any[] }, mess
2073
2319
 
2074
2320
  class ThinkingParagraph {
2075
2321
  private text: string;
2322
+ private hovered = false;
2076
2323
  private cachedWidth?: number;
2077
2324
  private cachedLines?: string[];
2325
+ private cachedHovered = false;
2078
2326
  private chromeEpoch = -1;
2327
+ private options?: MarkdownOptions;
2328
+
2329
+ setHovered(hovered: boolean): void {
2330
+ if (this.hovered === hovered) return;
2331
+ this.hovered = hovered;
2332
+ this.invalidate();
2333
+ }
2079
2334
 
2080
2335
  constructor(
2081
2336
  text: string,
2082
2337
  _markdownTheme: ConstructorParameters<typeof Markdown>[3],
2083
2338
  _defaultTextStyle?: ConstructorParameters<typeof Markdown>[4],
2339
+ options?: MarkdownOptions,
2084
2340
  ) {
2085
- this.text = stripTransientMagicContextTags(text);
2341
+ // Prefix only the renderer's private copy. The assistant message itself is
2342
+ // canonical session data and must remain untouched for compaction replay.
2343
+ this.text = prefixThinkingLine(stripTransientMagicContextTags(text), undefined);
2344
+ this.options = options;
2086
2345
  }
2087
2346
 
2088
2347
  private thinkingMarkdown(): InstanceType<typeof Markdown> {
@@ -2110,7 +2369,7 @@ class ThinkingParagraph {
2110
2369
  italic: false,
2111
2370
  color: (s: string) => `${DIM_FG}${s}`,
2112
2371
  };
2113
- return new Markdown(this.text, 0, 0, plainTheme, plainStyle);
2372
+ return new Markdown(this.text, 0, 0, plainTheme, plainStyle, this.options);
2114
2373
  }
2115
2374
 
2116
2375
  invalidate(): void {
@@ -2123,6 +2382,7 @@ class ThinkingParagraph {
2123
2382
  if (
2124
2383
  this.cachedLines
2125
2384
  && this.cachedWidth === width
2385
+ && this.cachedHovered === this.hovered
2126
2386
  && this.chromeEpoch === _toolBranchVisualEpoch
2127
2387
  ) {
2128
2388
  return this.cachedLines;
@@ -2144,8 +2404,9 @@ class ThinkingParagraph {
2144
2404
  return this.cachedLines;
2145
2405
  }
2146
2406
  const lines = sanitizeRenderedTextBlockLines(md.render(safeWidth - PREFIX_W), safeWidth - PREFIX_W);
2407
+ const displayLines = this.hovered ? lines.map((line) => brightenRenderedLine(line)) : lines;
2147
2408
  let symbolPlaced = false;
2148
- const rendered = lines.map((line: string) => {
2409
+ const rendered = displayLines.map((line: string) => {
2149
2410
  if (!symbolPlaced && stripAnsi(line).trim()) {
2150
2411
  symbolPlaced = true;
2151
2412
  return ` ${prefix} ${line}`;
@@ -2153,6 +2414,7 @@ class ThinkingParagraph {
2153
2414
  return ` ${line}`;
2154
2415
  }).map((line) => clampLineWidth(line, safeWidth));
2155
2416
  this.cachedWidth = width;
2417
+ this.cachedHovered = this.hovered;
2156
2418
  this.cachedLines = rendered;
2157
2419
  this.chromeEpoch = _toolBranchVisualEpoch;
2158
2420
  return rendered;
@@ -2444,8 +2706,10 @@ function patchAssistantMessages(): void {
2444
2706
  // - "full" mode: behaves like stock pi (stays collapsed).
2445
2707
  const liveMode = getThinkingMode() === "live";
2446
2708
  const thinkingCollapsed = !!(this as any).hideThinkingBlock;
2447
- const showLiveThinking = liveMode && thinkingCollapsed && isLiveThinkingMessage(this, message);
2448
- if (thinkingCollapsed && messageHasThinkingContent(message)) {
2709
+ const override = (this as any).thinkingVisibilityOverrides?.get?.(0);
2710
+ const showLiveThinking = liveMode && thinkingCollapsed && override !== true && isLiveThinkingMessage(this, message);
2711
+ const hideThinking = override ?? (thinkingCollapsed && !showLiveThinking);
2712
+ if (hideThinking && messageHasThinkingContent(message)) {
2449
2713
  // Pi wraps this in theme.italic/fg again — keep plain label for the placeholder pass.
2450
2714
  (this as any).hiddenThinkingLabel = "Thinking…";
2451
2715
  }
@@ -2459,7 +2723,7 @@ function patchAssistantMessages(): void {
2459
2723
  // Replace text-block Markdown children with DottedParagraph wrappers
2460
2724
  const container = (this as any).contentContainer;
2461
2725
  if (!container?.children) return;
2462
- if (thinkingCollapsed && !showLiveThinking && messageHasThinkingContent(message)) {
2726
+ if (hideThinking && messageHasThinkingContent(message)) {
2463
2727
  replaceHiddenThinkingPlaceholders(container, message);
2464
2728
  }
2465
2729
  const mdTheme = (this as any).markdownTheme;
@@ -2469,17 +2733,18 @@ function patchAssistantMessages(): void {
2469
2733
  if (isMarkdownComponent(inner)) {
2470
2734
  const text = (inner as any).text;
2471
2735
  if (!text) continue;
2736
+ const options = (inner as any).options as MarkdownOptions;
2472
2737
  const isThinking = !!(inner as any).defaultTextStyle?.italic;
2473
2738
  if (isThinking) {
2474
2739
  const style = (inner as any).defaultTextStyle;
2475
- const replacement = new ThinkingParagraph(text, mdTheme, style);
2740
+ const replacement = new ThinkingParagraph(text, mdTheme, style, options);
2476
2741
  if ((child as any)?.child !== undefined) {
2477
2742
  (child as any).child = replacement;
2478
2743
  } else {
2479
2744
  container.children[i] = replacement;
2480
2745
  }
2481
2746
  } else {
2482
- const replacement = new DottedParagraph(text, mdTheme);
2747
+ const replacement = new DottedParagraph(text, mdTheme, options);
2483
2748
  if ((child as any)?.child !== undefined) {
2484
2749
  (child as any).child = replacement;
2485
2750
  } else {
@@ -2488,9 +2753,10 @@ function patchAssistantMessages(): void {
2488
2753
  }
2489
2754
  }
2490
2755
  }
2491
- const explicitDuration = (message as any)[WORKED_DURATION_KEY];
2492
- const explicitSessionTotal = (message as any)[WORKED_SESSION_TOTAL_KEY];
2493
- const explicitTurns = (message as any)[WORKED_TURNS_KEY];
2756
+ const workedMetadata = workedMetadataForMessage(message);
2757
+ const explicitDuration = workedMetadata?.duration;
2758
+ const explicitSessionTotal = workedMetadata?.sessionTotal;
2759
+ const explicitTurns = workedMetadata?.turns;
2494
2760
  // The "Turn took" line must only appear once the stream has truly closed.
2495
2761
  // `message.stopReason === "stop"` is not a safe "finished" signal here because
2496
2762
  // providers may initialize a live message with that value. The `message_end`
@@ -2513,8 +2779,120 @@ function patchAssistantMessages(): void {
2513
2779
  proto[ASSISTANT_PATCH_FLAG] = true;
2514
2780
  }
2515
2781
 
2782
+ const THINKING_HOVER_KEY = Symbol.for("pi-claude-style-tools:thinking-hover");
2783
+
2784
+ function thinkingVisualChild(child: any): any {
2785
+ const inner = child?.child ?? child;
2786
+ if (
2787
+ inner instanceof HiddenThinkingSummary
2788
+ || inner instanceof ThinkingParagraph
2789
+ || inner?.constructor?.name === "HiddenThinkingSummary"
2790
+ || inner?.constructor?.name === "ThinkingParagraph"
2791
+ ) return inner;
2792
+ return undefined;
2793
+ }
2794
+
2795
+ function thinkingLayout(component: any, width: number): Array<{ component: any; height: number }> {
2796
+ const container = component?.contentContainer;
2797
+ const children = Array.isArray(container?.children) ? container.children : [];
2798
+ if (container?.mouseLayout?.width === width) return container.mouseLayout.children;
2799
+ return children.map((child: any) => ({ component: child, height: child.render(width).length }));
2800
+ }
2801
+
2802
+ function thinkingRowAt(component: any, event: TuiMouseEvent): any {
2803
+ let childY = 0;
2804
+ for (const entry of thinkingLayout(component, event.width)) {
2805
+ if (event.y >= childY && event.y < childY + entry.height) return thinkingVisualChild(entry.component);
2806
+ childY += entry.height;
2807
+ }
2808
+ return undefined;
2809
+ }
2810
+
2811
+ function setThinkingHover(component: any, hovered: any): boolean {
2812
+ const previous = component[THINKING_HOVER_KEY];
2813
+ if (previous === hovered) return false;
2814
+ previous?.setHovered?.(false);
2815
+ if (hovered) hovered.setHovered(true);
2816
+ component[THINKING_HOVER_KEY] = hovered;
2817
+ clearMessageRenderCache(component);
2818
+ return true;
2819
+ }
2820
+
2821
+ function patchAssistantFullscreenMouse(): void {
2822
+ const proto = AssistantMessageComponent.prototype as any;
2823
+ if (proto[ASSISTANT_MOUSE_PATCH_FLAG]) return;
2824
+ const originalHandleMouse = proto.handleMouse;
2825
+ proto.handleMouse = function patchedAssistantHandleMouse(event: TuiMouseEvent) {
2826
+ const hovered = thinkingRowAt(this, event);
2827
+ if (event.type === "move" && hovered) {
2828
+ claimClickHover(this, () => { if (setThinkingHover(this, undefined)) requestComponentRender(this); });
2829
+ }
2830
+ const hoverChanged = event.type === "move" ? setThinkingHover(this, hovered) : false;
2831
+ if (event.type === "move") return hoverChanged ? { handled: true, render: true } : undefined;
2832
+ // Claim the row before Pi's MouseRegion. Its handler restores the italic
2833
+ // "Thinking…" label while a block is streaming.
2834
+ if (isPrimaryClick(event) && hovered && this.lastMessage) {
2835
+ const overrides = this.thinkingVisibilityOverrides instanceof Map
2836
+ ? this.thinkingVisibilityOverrides
2837
+ : new Map();
2838
+ this.thinkingVisibilityOverrides = overrides;
2839
+ overrides.set(0, hovered instanceof ThinkingParagraph);
2840
+ this.updateContent(this.lastMessage);
2841
+ return { handled: true };
2842
+ }
2843
+ return typeof originalHandleMouse === "function" ? originalHandleMouse.call(this, event) : undefined;
2844
+ };
2845
+ proto[ASSISTANT_MOUSE_PATCH_FLAG] = true;
2846
+ }
2847
+
2516
2848
  const TOOL_BG_PATCH_FLAG = Symbol.for("pi-claude-style-tools:patched-tool-bg-sync");
2517
2849
 
2850
+ const TOOL_HOVER_KEY = Symbol.for("pi-claude-style-tools:tool-hover");
2851
+
2852
+ function setToolHover(component: any, hovered: boolean): boolean {
2853
+ if (component[TOOL_HOVER_KEY] === hovered) return false;
2854
+ component[TOOL_HOVER_KEY] = hovered;
2855
+ clearToolRenderCache(component);
2856
+ return true;
2857
+ }
2858
+
2859
+ function patchToolFullscreenMouse(): void {
2860
+ const proto = ToolExecutionComponent.prototype as any;
2861
+ if (proto[TOOL_MOUSE_PATCH_FLAG]) return;
2862
+ const originalHandleMouse = proto.handleMouse;
2863
+ if (typeof originalHandleMouse !== "function") return;
2864
+ const originalRender = proto.render;
2865
+ proto.render = function patchedToolHoverRender(width: number) {
2866
+ const lines = originalRender.call(this, width);
2867
+ if (this[TOOL_HOVER_KEY] !== true || !Array.isArray(lines)) return lines;
2868
+ const titleIndex = lines.findIndex((line: string) => stripAnsi(line).trim().length > 0 && !/^\s*[╭╰─│]+\s*$/.test(stripAnsi(line)));
2869
+ if (titleIndex < 0) return lines;
2870
+ const rendered = lines.slice();
2871
+ rendered[titleIndex] = brightenRenderedLine(rendered[titleIndex]);
2872
+ return rendered;
2873
+ };
2874
+ proto.handleMouse = function patchedToolHandleMouse(event: TuiMouseEvent) {
2875
+ if (event.type === "move") {
2876
+ claimClickHover(this, () => { if (setToolHover(this, false)) requestComponentRender(this); });
2877
+ return setToolHover(this, true) ? { handled: true, render: true } : undefined;
2878
+ }
2879
+ const result = originalHandleMouse.call(this, event);
2880
+ if (result) return result;
2881
+ // Self-rendered rows forward into their own content. Default-shell rows
2882
+ // reach here only when no nested region claimed the click, including the
2883
+ // blank outline padding our container patch paints around the content.
2884
+ if (!isPrimaryClick(event) || !this.result) return undefined;
2885
+ const group = this[COMPONENT_PARENT];
2886
+ if (group?.constructor?.name === "ToolGroupComponent") {
2887
+ return toggleGroupedTools(group, [this]);
2888
+ }
2889
+ if (!toggleExpandedComponent(this)) return undefined;
2890
+ requestComponentRender(this);
2891
+ return { handled: true };
2892
+ };
2893
+ proto[TOOL_MOUSE_PATCH_FLAG] = true;
2894
+ }
2895
+
2518
2896
  function patchToolExecutionBackgroundSync(): void {
2519
2897
  const proto = ToolExecutionComponent.prototype as any;
2520
2898
  if (proto[TOOL_BG_PATCH_FLAG]) return;
@@ -3465,6 +3843,10 @@ function bashCollapsedLimit(): number {
3465
3843
  return typeof value === "number" && Number.isFinite(value) && value >= 0 ? Math.floor(value) : 10;
3466
3844
  }
3467
3845
 
3846
+ function bashOutputMode(): "opencode" | "summary" | "preview" {
3847
+ return getMode(readSettings().bashOutputMode, ["opencode", "summary", "preview"] as const, "opencode");
3848
+ }
3849
+
3468
3850
  function bashCommandPreviewLimit(): number {
3469
3851
  const value = readSettings().bashCommandPreviewLines;
3470
3852
  return typeof value === "number" && Number.isFinite(value) && value >= 0 ? Math.floor(value) : 8;
@@ -5274,20 +5656,23 @@ function trackThinkingBlockEvents(event: any, ctx?: any): void {
5274
5656
  if (evt.type === "thinking_start") {
5275
5657
  thinkingBlockInFlight = true;
5276
5658
  thinkingBlockStartMs = Date.now();
5277
- lastThinkingBlockDurationMs = undefined;
5278
5659
  if (message?.role === "assistant") {
5279
- (message as any)[THINKING_ACTIVE_KEY] = true;
5280
- delete (message as any)[THINKING_DURATION_KEY];
5660
+ thinkingActiveMessages.add(message);
5661
+ thinkingDurationByMessage.delete(message);
5281
5662
  }
5282
5663
  refreshThinkingChrome();
5283
5664
  return;
5284
5665
  }
5285
5666
  if (evt.type === "thinking_end") {
5286
5667
  thinkingBlockInFlight = false;
5287
- const duration = Math.max(0, Date.now() - thinkingBlockStartMs);
5288
- 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;
5668
+ const duration = thinkingBlockStartMs > 0
5669
+ ? Math.max(0, Date.now() - thinkingBlockStartMs)
5670
+ : undefined;
5671
+ if (message?.role === "assistant") thinkingActiveMessages.delete(message);
5672
+ if (typeof duration === "number" && duration <= MAX_REASONABLE_THINKING_DURATION_MS && message?.role === "assistant") {
5673
+ thinkingDurationByMessage.set(message, duration);
5674
+ }
5675
+ thinkingBlockStartMs = 0;
5291
5676
  refreshThinkingChrome();
5292
5677
  return;
5293
5678
  }
@@ -5297,15 +5682,15 @@ function trackThinkingBlockEvents(event: any, ctx?: any): void {
5297
5682
  // run normally. Any non-thinking stream event on the same assistant message
5298
5683
  // means thinking is no longer the live activity: freeze the elapsed time into
5299
5684
  // a "Thought for Xs" duration so the row always resolves.
5300
- if ((message as any)?.[THINKING_ACTIVE_KEY] || thinkingBlockInFlight) {
5685
+ if (hasThinkingActiveMetadata(message) || thinkingBlockInFlight) {
5301
5686
  if (evt.type === "text_start" || evt.type === "text_delta" || evt.type === "toolcall_start" || evt.type === "toolcall_end") {
5302
5687
  thinkingBlockInFlight = false;
5303
5688
  const duration = thinkingBlockStartMs > 0 ? Math.max(0, Date.now() - thinkingBlockStartMs) : undefined;
5304
- if (message?.role === "assistant") delete (message as any)[THINKING_ACTIVE_KEY];
5305
- if (typeof duration === "number") {
5306
- lastThinkingBlockDurationMs = duration;
5307
- if (message?.role === "assistant") (message as any)[THINKING_DURATION_KEY] = duration;
5689
+ if (message?.role === "assistant") thinkingActiveMessages.delete(message);
5690
+ if (typeof duration === "number" && duration <= MAX_REASONABLE_THINKING_DURATION_MS && message?.role === "assistant") {
5691
+ thinkingDurationByMessage.set(message, duration);
5308
5692
  }
5693
+ thinkingBlockStartMs = 0;
5309
5694
  refreshThinkingChrome();
5310
5695
  }
5311
5696
  }
@@ -5318,11 +5703,9 @@ function registerThinkingLabels(pi: ExtensionAPI): void {
5318
5703
  if (theme) applyThemePaletteIfNeeded(theme);
5319
5704
  const message = event?.message;
5320
5705
  if (!message || message.role !== "assistant" || !Array.isArray(message.content)) return;
5321
- for (const block of message.content) {
5322
- if (block && block.type === "thinking" && typeof block.thinking === "string") {
5323
- block.thinking = prefixThinkingLine(block.thinking, theme);
5324
- }
5325
- }
5706
+ // Thinking labels are applied by ThinkingParagraph while rendering. Never
5707
+ // rewrite the assistant message object: context/compaction consumers need
5708
+ // its canonical thinking content unchanged.
5326
5709
  };
5327
5710
  pi.on("before_agent_start", async () => {
5328
5711
  // Start once per top-level request. Steering/follow-up messages can be
@@ -5348,13 +5731,14 @@ function registerThinkingLabels(pi: ExtensionAPI): void {
5348
5731
  }
5349
5732
  if (message?.role === "assistant") {
5350
5733
  currentAssistantMessageStartMs = Date.now();
5351
- (message as any)[WORKED_START_KEY] = currentAssistantMessageStartMs;
5734
+ workedStartByMessage.set(message, currentAssistantMessageStartMs);
5352
5735
  // A new assistant message starts a fresh thinking lifecycle. Without
5353
5736
  // this, a missing thinking_end on the previous message leaves the
5354
5737
  // global in-flight flag set and the next message renders a stale
5355
5738
  // "Thinking..." row until its own thinking events arrive.
5356
5739
  thinkingBlockInFlight = false;
5357
- delete (message as any)[THINKING_ACTIVE_KEY];
5740
+ thinkingBlockStartMs = 0;
5741
+ thinkingActiveMessages.delete(message);
5358
5742
  }
5359
5743
  });
5360
5744
  pi.on("message_update", async (event, ctx) => {
@@ -5369,30 +5753,22 @@ function registerThinkingLabels(pi: ExtensionAPI): void {
5369
5753
  // into its "Thought for Xs" duration here so the row always resolves
5370
5754
  // at the end of the message instead of sticking into later calls.
5371
5755
  thinkingBlockInFlight = false;
5372
- 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;
5377
- (message as any)[THINKING_DURATION_KEY] = duration;
5378
- } else if (typeof lastThinkingBlockDurationMs === "number" && lastThinkingBlockDurationMs > 0) {
5379
- (message as any)[THINKING_DURATION_KEY] = lastThinkingBlockDurationMs;
5380
- }
5756
+ thinkingActiveMessages.delete(message);
5757
+ if (getThinkingDurationMetadata(message) === undefined && thinkingBlockStartMs > 0) {
5758
+ const duration = Math.max(0, Date.now() - thinkingBlockStartMs);
5759
+ if (duration <= MAX_REASONABLE_THINKING_DURATION_MS) thinkingDurationByMessage.set(message, duration);
5381
5760
  }
5382
5761
  thinkingBlockStartMs = 0;
5383
5762
  const started = typeof currentAgentWorkStartMs === "number"
5384
5763
  ? currentAgentWorkStartMs
5385
- : typeof (message as any)[WORKED_START_KEY] === "number"
5386
- ? (message as any)[WORKED_START_KEY]
5387
- : currentAssistantMessageStartMs;
5764
+ : workedStartByMessage.get(message)
5765
+ ?? (typeof (message as any)[WORKED_START_KEY] === "number" ? (message as any)[WORKED_START_KEY] : currentAssistantMessageStartMs);
5388
5766
  const isFinalAssistantMessage = message.stopReason === "stop";
5389
5767
  if (started !== undefined && isFinalAssistantMessage) {
5390
5768
  const durationMs = Date.now() - started;
5391
5769
  const sessionTotalMs = typeof sessionStartMs === "number" ? Date.now() - sessionStartMs : undefined;
5392
5770
  const turns = userTurnCount > 0 ? userTurnCount : undefined;
5393
- (message as any)[WORKED_DURATION_KEY] = durationMs;
5394
- if (typeof sessionTotalMs === "number") (message as any)[WORKED_SESSION_TOTAL_KEY] = sessionTotalMs;
5395
- if (typeof turns === "number") (message as any)[WORKED_TURNS_KEY] = turns;
5771
+ workedDurationByMessage.set(message, { duration: durationMs, sessionTotal: sessionTotalMs, turns });
5396
5772
  // Duration metadata drives the assistant component's TUI-only status line.
5397
5773
  // Message content stays presentation-neutral for persistence and consumers.
5398
5774
  }
@@ -5440,17 +5816,10 @@ function registerThinkingLabels(pi: ExtensionAPI): void {
5440
5816
  sessionStartMs = sessionStartMs === undefined ? earliest : Math.min(sessionStartMs, earliest);
5441
5817
  }
5442
5818
  if (userCount > userTurnCount) userTurnCount = userCount;
5443
- for (const msg of messages) {
5444
- if (!msg || msg.role !== "assistant" || !Array.isArray(msg.content)) continue;
5445
- for (const block of msg.content) {
5446
- if (block && block.type === "thinking" && typeof block.thinking === "string") {
5447
- block.thinking = stripThinkingPresentationArtifacts(block.thinking);
5448
- }
5449
- if (block && block.type === "text" && typeof block.text === "string") {
5450
- block.text = stripWorkedDurationLine(block.text);
5451
- }
5452
- }
5453
- }
5819
+ // Do not normalize or strip message content here. The context event is also
5820
+ // consumed by compaction/replay integrations that compare these objects
5821
+ // against the persisted session projection. Presentation cleanup belongs in
5822
+ // the renderer and must not mutate canonical history.
5454
5823
  });
5455
5824
  }
5456
5825
 
@@ -6440,11 +6809,14 @@ export default function (pi: ExtensionAPI) {
6440
6809
  patchToolExecutionBackgroundSync();
6441
6810
  patchToolRenderCacheInvalidation();
6442
6811
  patchReadImageExpansion();
6812
+ patchFullscreenHoverExit();
6813
+ patchToolFullscreenMouse();
6443
6814
  patchContainerParentTracking();
6444
6815
  patchGlobalToolBorders();
6445
6816
  patchCustomMessageRender();
6446
6817
  patchUserMessageRender();
6447
6818
  patchAssistantMessages();
6819
+ patchAssistantFullscreenMouse();
6448
6820
  patchToolExecutionRenderers();
6449
6821
  applyDiffPalette();
6450
6822
  registerThinkingLabels(pi);
@@ -6870,6 +7242,8 @@ export default function (pi: ExtensionAPI) {
6870
7242
  name: "read",
6871
7243
  label: "read",
6872
7244
  description: readTool.description,
7245
+ promptSnippet: (readTool as any).promptSnippet,
7246
+ promptGuidelines: (readTool as any).promptGuidelines,
6873
7247
  parameters: readTool.parameters,
6874
7248
  async execute(toolCallId, params, signal, onUpdate) {
6875
7249
  return readTool.execute(toolCallId, params, signal, onUpdate);
@@ -6925,6 +7299,8 @@ export default function (pi: ExtensionAPI) {
6925
7299
  name: "bash",
6926
7300
  label: "bash",
6927
7301
  description: bashTool.description,
7302
+ promptSnippet: (bashTool as any).promptSnippet,
7303
+ promptGuidelines: (bashTool as any).promptGuidelines,
6928
7304
  parameters: bashTool.parameters,
6929
7305
  async execute(toolCallId, params, signal, onUpdate) {
6930
7306
  return bashTool.execute(toolCallId, params, signal, onUpdate);
@@ -6982,9 +7358,18 @@ export default function (pi: ExtensionAPI) {
6982
7358
  let text = exitCode === null || exitCode === 0 ? theme.fg("success", "Done") : theme.fg("error", `Exit ${exitCode}`);
6983
7359
  text += theme.fg("muted", ` (${nonEmpty.total} lines)`);
6984
7360
  if (details?.truncation?.truncated) text += theme.fg("warning", " [truncated]");
7361
+ const mode = bashOutputMode();
7362
+ if (mode === "summary") return makeText(ctx.lastComponent, withBranch(text, theme));
6985
7363
  const persistentPreview = shouldPreserveBashPreview(ctx) ? buildPersistentBashPreview(nonEmpty.lines, theme) : "";
6986
- if (!expanded && persistentPreview) return makeText(ctx.lastComponent, withBranch(`${text}${toolOutputDetailHint(theme, expanded)}\n${persistentPreview}`, theme));
6987
- if (!expanded && nonEmpty.total > 0) return makeText(ctx.lastComponent, withBranch(`${text}${toolOutputDetailHint(theme, expanded)}`, theme));
7364
+ if (!expanded && mode === "preview") {
7365
+ const previewLines = nonEmpty.lines.slice(0, bashCollapsedLimit());
7366
+ const preview = previewLines.length > 0
7367
+ ? buildPreviewText(previewLines, false, theme, bashCollapsedLimit(), nonEmpty.total, (line) => theme.fg("dim", line || " "))
7368
+ : "";
7369
+ const suffix = toolOutputDetailHint(theme, expanded);
7370
+ return makeText(ctx.lastComponent, withBranch(`${text}${suffix}${preview ? `\n${preview}` : ""}`, theme));
7371
+ }
7372
+ if (!expanded && mode === "opencode" && nonEmpty.total > 0) return makeText(ctx.lastComponent, withBranch(`${text}${toolOutputDetailHint(theme, expanded)}`, theme));
6988
7373
  if (!expanded) return makeText(ctx.lastComponent, withBranch(text, theme));
6989
7374
  const collapsed = bashCollapsedLimit();
6990
7375
  if (rewrite) text += `\n${formatRtkRewriteDetails(rewrite, theme)}`;
@@ -6998,6 +7383,8 @@ export default function (pi: ExtensionAPI) {
6998
7383
  name: "grep",
6999
7384
  label: "grep",
7000
7385
  description: grepTool.description,
7386
+ promptSnippet: (grepTool as any).promptSnippet,
7387
+ promptGuidelines: (grepTool as any).promptGuidelines,
7001
7388
  parameters: grepTool.parameters,
7002
7389
  async execute(toolCallId, params, signal, onUpdate) {
7003
7390
  return grepTool.execute(toolCallId, params, signal, onUpdate);
@@ -7038,6 +7425,8 @@ export default function (pi: ExtensionAPI) {
7038
7425
  name: "find",
7039
7426
  label: "find",
7040
7427
  description: findTool.description,
7428
+ promptSnippet: (findTool as any).promptSnippet,
7429
+ promptGuidelines: (findTool as any).promptGuidelines,
7041
7430
  parameters: findTool.parameters,
7042
7431
  async execute(toolCallId, params, signal, onUpdate) {
7043
7432
  return findTool.execute(toolCallId, params, signal, onUpdate);
@@ -7089,6 +7478,8 @@ export default function (pi: ExtensionAPI) {
7089
7478
  name: "ls",
7090
7479
  label: "ls",
7091
7480
  description: lsTool.description,
7481
+ promptSnippet: (lsTool as any).promptSnippet,
7482
+ promptGuidelines: (lsTool as any).promptGuidelines,
7092
7483
  parameters: lsTool.parameters,
7093
7484
  async execute(toolCallId, params, signal, onUpdate) {
7094
7485
  return lsTool.execute(toolCallId, params, signal, onUpdate);
@@ -7140,6 +7531,8 @@ export default function (pi: ExtensionAPI) {
7140
7531
  name: "write",
7141
7532
  label: "write",
7142
7533
  description: writeTool.description,
7534
+ promptSnippet: (writeTool as any).promptSnippet,
7535
+ promptGuidelines: (writeTool as any).promptGuidelines,
7143
7536
  parameters: writeTool.parameters,
7144
7537
  async execute(toolCallId, params, signal, onUpdate, _ctx) {
7145
7538
  const fp = params.path ?? (params as any).file_path ?? "";
@@ -7255,6 +7648,8 @@ export default function (pi: ExtensionAPI) {
7255
7648
  name: "edit",
7256
7649
  label: "edit",
7257
7650
  description: editTool.description,
7651
+ promptSnippet: (editTool as any).promptSnippet,
7652
+ promptGuidelines: (editTool as any).promptGuidelines,
7258
7653
  parameters: editTool.parameters,
7259
7654
  async execute(toolCallId, params, signal, onUpdate, _ctx) {
7260
7655
  const fp = params.path ?? (params as any).file_path ?? "";