pi-cc-extensions 0.8.32 → 0.8.33

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.
@@ -9,7 +9,9 @@ import type { CompactThinkingConfig, CompactThinkingController } from "./compact
9
9
  import { showTextPreview } from "./context.ts";
10
10
  import {
11
11
  getFixedEditorScrollButtonHitbox,
12
+ getFixedEditorViewport,
12
13
  installFixedEditor,
14
+ setBeforeFixedEditorStart,
13
15
  type FixedEditorController,
14
16
  } from "./fixed-editor.ts";
15
17
  import {
@@ -65,7 +67,6 @@ export type Config = {
65
67
 
66
68
  const AGENT_DIR = process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent");
67
69
  const CONFIG_PATH = join(AGENT_DIR, "claude-code-style.json");
68
- const LEGACY_COMPACT_THINKING_CONFIG_PATH = join(AGENT_DIR, "compact-thinking.json");
69
70
 
70
71
  const DIFF_VIEW_MODES: DiffViewMode[] = ["auto", "split", "unified"];
71
72
  const DIFF_INDICATOR_MODES: DiffIndicatorMode[] = ["bars", "classic", "none"];
@@ -247,34 +248,12 @@ function loadConfig(): Config {
247
248
  const source = existsSync(CONFIG_PATH)
248
249
  ? (JSON.parse(readFileSync(CONFIG_PATH, "utf8")) as Record<string, unknown>)
249
250
  : {};
250
- let migrated = false;
251
- if (existsSync(LEGACY_COMPACT_THINKING_CONFIG_PATH)) {
252
- try {
253
- const legacy = JSON.parse(
254
- readFileSync(LEGACY_COMPACT_THINKING_CONFIG_PATH, "utf8"),
255
- ) as Record<string, unknown>;
256
- for (const key of [
257
- "useSummaryTitlesAsThinkingTitle",
258
- "previewLines",
259
- "animationIntervalMs",
260
- ] as const) {
261
- if (!(key in source) && key in legacy) {
262
- source[key] = legacy[key];
263
- migrated = true;
264
- }
265
- }
266
- } catch {
267
- // A malformed legacy file must not invalidate the ccstyle source.
268
- }
269
- }
270
251
  const normalized = normalizeConfig(source);
271
- // Persist one-time migrations while retaining existing ccstyle values.
272
252
  if (
273
- migrated ||
274
- (typeof source.enabled === "boolean" &&
275
- source.mode !== "on" &&
276
- source.mode !== "off" &&
277
- source.mode !== "compact")
253
+ typeof source.enabled === "boolean" &&
254
+ source.mode !== "on" &&
255
+ source.mode !== "off" &&
256
+ source.mode !== "compact"
278
257
  ) {
279
258
  try {
280
259
  writeFileSync(CONFIG_PATH, JSON.stringify(normalized, null, 2));
@@ -524,6 +503,8 @@ export class ExpandedToolIoView {
524
503
  private hoveredSection: ToolIoSection | null = null;
525
504
  /** Which sections currently show the [show more] affordance (after last render). */
526
505
  private truncated: { input: boolean; output: boolean } = { input: false, output: false };
506
+ /** 0-based header line indexes that carry [show more] after last render. */
507
+ private showMoreHeaderRows: { input?: number; output?: number } = {};
527
508
 
528
509
  constructor(
529
510
  theme: any,
@@ -591,6 +572,18 @@ export class ExpandedToolIoView {
591
572
  return null;
592
573
  }
593
574
 
575
+ /** Precise header rows marked for show-more hit testing (last render). */
576
+ showMoreHeaderLineIndexes(): ReadonlyArray<{ section: ToolIoSection; line: number }> {
577
+ const out: Array<{ section: ToolIoSection; line: number }> = [];
578
+ if (this.showMoreHeaderRows.input !== undefined) {
579
+ out.push({ section: "input", line: this.showMoreHeaderRows.input });
580
+ }
581
+ if (this.showMoreHeaderRows.output !== undefined) {
582
+ out.push({ section: "output", line: this.showMoreHeaderRows.output });
583
+ }
584
+ return out;
585
+ }
586
+
594
587
  /** Column range (1-based, visible cells) of [show more] on a rendered header, if present. */
595
588
  showMoreHitbox(plainLine: string): { startCol: number; endCol: number } | null {
596
589
  const line = plainLine.replace(/\x1b\[[0-9;]*m/g, "");
@@ -603,8 +596,9 @@ export class ExpandedToolIoView {
603
596
  }
604
597
 
605
598
  render(width: number): string[] {
606
- recordRenderedIoView(this);
607
- if (this.cachedLines !== undefined && this.cachedWidth === width) return this.cachedLines;
599
+ if (this.cachedLines !== undefined && this.cachedWidth === width) {
600
+ return withIoViewMarkers(this, this.cachedLines);
601
+ }
608
602
 
609
603
  const theme = this.theme;
610
604
  const safeWidth = Math.max(1, Math.floor(width));
@@ -615,6 +609,7 @@ export class ExpandedToolIoView {
615
609
  const bodyColor = this.isError ? "error" : "toolOutput";
616
610
  const lines: string[] = [];
617
611
  this.truncated = { input: false, output: false };
612
+ this.showMoreHeaderRows = {};
618
613
 
619
614
  const pushHeader = (
620
615
  corner: "├" | "└",
@@ -630,6 +625,7 @@ export class ExpandedToolIoView {
630
625
  const more = showMore
631
626
  ? theme.fg(this.hoveredSection === section ? "text" : "dim", ` ${SHOW_MORE_LABEL}`)
632
627
  : "";
628
+ if (showMore) this.showMoreHeaderRows[section] = lines.length;
633
629
  lines.push(truncateToWidth(mark + title + more, safeWidth, ""));
634
630
  };
635
631
 
@@ -717,7 +713,7 @@ export class ExpandedToolIoView {
717
713
 
718
714
  this.cachedWidth = width;
719
715
  this.cachedLines = lines;
720
- return lines;
716
+ return withIoViewMarkers(this, lines);
721
717
  }
722
718
 
723
719
  invalidate(): void {
@@ -917,17 +913,75 @@ type SgrMousePacket = {
917
913
  final: "M" | "m";
918
914
  };
919
915
 
920
- type ToolRenderHit = {
916
+ type FrameToolRender = {
917
+ component: any;
918
+ lines: string[];
919
+ contentBoxLines: number;
920
+ };
921
+
922
+ /** Final painted placement of one outermost tool/group row after parent layout. */
923
+ type FrameToolPlacement = {
921
924
  component: any;
922
- start: number;
923
- end: number;
925
+ componentRow: number;
926
+ lineIndex: number;
927
+ /** Marker-stripped final line text as painted after parent layout. */
928
+ finalLine: string;
929
+ view?: ExpandedToolIoView;
930
+ section?: ToolIoSection;
931
+ };
932
+
933
+ type InteractionRegion = {
934
+ kind: "collapsed-hint" | "expanded-card" | "show-more" | "scroll-bottom";
924
935
  row: number;
936
+ startCol: number;
937
+ endCol: number;
938
+ component?: any;
939
+ view?: ExpandedToolIoView;
940
+ section?: ToolIoSection;
941
+ };
942
+
943
+ type InteractionFrame = { regions: InteractionRegion[] };
944
+
945
+ /** Zero-width APC row marker (like pi CURSOR_MARKER); stripped before terminal output. */
946
+ const TOOL_FRAME_MARKER_RE = /_cc:t(\d+):(\d+)/g;
947
+ const TOOL_VIEW_MARKER_RE = /_cc:v(\d+):([io])/g;
948
+ const toolFrameMarker = (id: number, row: number) => `_cc:t${id}:${row}`;
949
+ const toolViewMarker = (id: number, section: ToolIoSection) =>
950
+ `_cc:v${id}:${section === "input" ? "i" : "o"}`;
951
+
952
+ /** Per-frame ExpandedToolIoView ids for unambiguous show-more hit testing. */
953
+ type IoViewFrameState = {
954
+ viewIds: Map<ExpandedToolIoView, number>;
955
+ idToView: Map<number, ExpandedToolIoView>;
956
+ nextId: number;
925
957
  };
958
+ let activeIoViewFrame: IoViewFrameState | null = null;
959
+
960
+ function frameViewId(view: ExpandedToolIoView): number | null {
961
+ if (!activeIoViewFrame) return null;
962
+ let id = activeIoViewFrame.viewIds.get(view);
963
+ if (id === undefined) {
964
+ id = activeIoViewFrame.nextId++;
965
+ activeIoViewFrame.viewIds.set(view, id);
966
+ activeIoViewFrame.idToView.set(id, view);
967
+ }
968
+ return id;
969
+ }
926
970
 
927
- type FrameToolRender = { component: any; lines: string[] };
971
+ /** cachedLines stay clean; only the returned paint copy carries show-more view markers. */
972
+ function withIoViewMarkers(view: ExpandedToolIoView, lines: string[]): string[] {
973
+ const id = frameViewId(view);
974
+ if (id === null) return lines;
975
+ // Mark by exact header row from render — never scan body text for Input/Output labels.
976
+ const marked = lines.slice();
977
+ for (const { section, line } of view.showMoreHeaderLineIndexes()) {
978
+ if (line < 0 || line >= marked.length) continue;
979
+ marked[line] = `${marked[line]}${toolViewMarker(id, section)}`;
980
+ }
981
+ return marked;
982
+ }
928
983
 
929
984
  const TOOL_MOUSE_WIDGET_KEY = "ccstyle-tool-mouse";
930
- const TOOL_MOUSE_ENABLE = "\x1b[?1000h\x1b[?1003h\x1b[?1006h";
931
985
  const TOOL_MOUSE_MOTION_ENABLE = "\x1b[?1003h\x1b[?1006h";
932
986
  const TOOL_MOUSE_DISABLE = "\x1b[?1006l\x1b[?1003l\x1b[?1000l";
933
987
  const ZENTUI_PAGE_UP_INPUT = /^\x1b\[5;9(?::[12])?~$|^\x1b\[57421;9(?::[12])?u$|^\x1b\[1;6A$/;
@@ -959,7 +1013,7 @@ let scrollButtonSyncScheduled = false;
959
1013
  let sessionRenderTimer: ReturnType<typeof setTimeout> | null = null;
960
1014
  let hoveredToolCallId: string | null = null;
961
1015
  let hoveredToolGroup: ToolGroupComponent | null = null;
962
- let frameToolRenders: FrameToolRender[] = [];
1016
+ let latestInteractionFrame: InteractionFrame = { regions: [] };
963
1017
 
964
1018
  function parseSgrMousePackets(data: string): SgrMousePacket[] | null {
965
1019
  const pattern = /\x1b\[<(\d+);(\d+);(\d+)([Mm])/g;
@@ -1005,49 +1059,6 @@ function isToolExecutionComponent(value: any): boolean {
1005
1059
  );
1006
1060
  }
1007
1061
 
1008
- function renderedLineCount(component: any, width: number, cache: WeakMap<object, number>): number {
1009
- if (!component || typeof component !== "object") return 0;
1010
- const cached = cache.get(component);
1011
- if (cached !== undefined) return cached;
1012
-
1013
- let count = 0;
1014
- try {
1015
- const lines = component.render(width);
1016
- count = Array.isArray(lines) ? lines.length : 0;
1017
- } catch {
1018
- count = 0;
1019
- }
1020
- cache.set(component, count);
1021
- return count;
1022
- }
1023
-
1024
- function collectToolRenderHits(
1025
- component: any,
1026
- start: number,
1027
- width: number,
1028
- hits: ToolRenderHit[],
1029
- cache: WeakMap<object, number>,
1030
- seen: Set<object>,
1031
- ): number {
1032
- if (!component || typeof component !== "object" || seen.has(component)) return start;
1033
- seen.add(component);
1034
-
1035
- const count = renderedLineCount(component, width, cache);
1036
- if (isToolExecutionComponent(component)) {
1037
- if (count > 0) hits.push({ component, start, end: start + count, row: 0 });
1038
- return start + count;
1039
- }
1040
-
1041
- let childStart = start;
1042
- if (Array.isArray(component.children)) {
1043
- for (const child of component.children) {
1044
- collectToolRenderHits(child, childStart, width, hits, cache, seen);
1045
- childStart += renderedLineCount(child, width, cache);
1046
- }
1047
- }
1048
- return start + count;
1049
- }
1050
-
1051
1062
  function collectToolComponents(component: any, tools: any[], seen = new Set<any>()): void {
1052
1063
  if (!component || typeof component !== "object" || seen.has(component)) return;
1053
1064
  seen.add(component);
@@ -1059,109 +1070,49 @@ function collectToolComponents(component: any, tools: any[], seen = new Set<any>
1059
1070
  for (const child of component.children) collectToolComponents(child, tools, seen);
1060
1071
  }
1061
1072
 
1062
- function fixedEditorLineMatch(rendered: string, visible: string): boolean {
1063
- return (
1064
- rendered === visible ||
1065
- (visible.length >= 8 && (rendered.includes(visible) || visible.includes(rendered)))
1066
- );
1067
- }
1068
-
1069
- function fixedEditorContextScore(
1070
- renderedLines: string[],
1071
- renderedRow: number,
1072
- visibleLines: string[],
1073
- visibleRow: number,
1074
- ): number {
1075
- let score = renderedLines[renderedRow] === visibleLines[visibleRow] ? 100 : 50;
1076
- for (const direction of [-1, 1]) {
1077
- for (let distance = 1; distance <= 4; distance++) {
1078
- const candidate = renderedLines[renderedRow + direction * distance];
1079
- const visible = visibleLines[visibleRow + direction * distance];
1080
- if (candidate === undefined || visible === undefined || candidate !== visible) break;
1081
- score += 5 - distance;
1073
+ function stripToolFrameMarkers(line: string): string {
1074
+ return line.replace(TOOL_FRAME_MARKER_RE, "").replace(TOOL_VIEW_MARKER_RE, "");
1075
+ }
1076
+
1077
+ function extractToolFramePlacements(
1078
+ lines: string[],
1079
+ idToComponent: Map<number, any>,
1080
+ idToView: Map<number, ExpandedToolIoView>,
1081
+ ): { lines: string[]; placements: FrameToolPlacement[] } {
1082
+ const placements: FrameToolPlacement[] = [];
1083
+ const cleaned = lines.map((line, lineIndex) => {
1084
+ const toolMatches = [...line.matchAll(TOOL_FRAME_MARKER_RE)];
1085
+ const viewMatches = [...line.matchAll(TOOL_VIEW_MARKER_RE)];
1086
+ const finalLine = stripToolFrameMarkers(line);
1087
+ let view: ExpandedToolIoView | undefined;
1088
+ let section: ToolIoSection | undefined;
1089
+ for (const match of viewMatches) {
1090
+ const candidate = idToView.get(Number(match[1]));
1091
+ if (!candidate) continue;
1092
+ view = candidate;
1093
+ section = match[2] === "i" ? "input" : "output";
1094
+ break;
1082
1095
  }
1083
- }
1084
- return score;
1096
+ for (const match of toolMatches) {
1097
+ const component = idToComponent.get(Number(match[1]));
1098
+ if (!component) continue;
1099
+ placements.push({
1100
+ component,
1101
+ componentRow: Number(match[2]),
1102
+ lineIndex,
1103
+ finalLine,
1104
+ view,
1105
+ section,
1106
+ });
1107
+ }
1108
+ return finalLine;
1109
+ });
1110
+ return { lines: cleaned, placements };
1085
1111
  }
1086
1112
 
1087
1113
  /** Summary markers used by Pi and ccstyle; unlike the trailing hint, these survive truncation. */
1088
1114
  const COLLAPSED_TOOL_SUMMARY = /^\s*(?:↳|└|⎿|●|✓|✗|…)/;
1089
1115
 
1090
- function findToolAtFixedEditorRow(
1091
- tui: any,
1092
- visibleRow: number,
1093
- previousLines: string[],
1094
- width: number,
1095
- ): ToolRenderHit | null {
1096
- if (visibleRow < 0 || visibleRow >= previousLines.length) return null;
1097
- const visibleLines = previousLines.map((line) => stripTerminalSequences(String(line)));
1098
- const rawClickedLine = String(previousLines[visibleRow] ?? "");
1099
- const clickedLine = visibleLines[visibleRow] ?? "";
1100
- const hasBackground = /\x1b\[(?:4[0-8]|10[0-7])(?:;[0-9;:]*)?m/.test(rawClickedLine);
1101
- if (!clickedLine && !hasBackground) return null;
1102
-
1103
- let candidates = frameToolRenders;
1104
- if (candidates.length === 0) {
1105
- const tools: any[] = [];
1106
- collectToolComponents(tui, tools);
1107
- candidates = tools.map((component) => ({
1108
- component,
1109
- lines: renderComponentTree(component, width),
1110
- }));
1111
- }
1112
- const clickedCollapsedSummary = COLLAPSED_TOOL_SUMMARY.test(clickedLine);
1113
- let best: { hit: ToolRenderHit; score: number } | null = null;
1114
- for (const { component, lines } of candidates) {
1115
- // Collapsed tools open only from their summary row; expanded tools collapse from any body row.
1116
- if (!component.expanded && !clickedCollapsedSummary) continue;
1117
- const renderedLines = lines.map((line) => stripTerminalSequences(String(line)));
1118
- for (let renderedRow = 0; renderedRow < renderedLines.length; renderedRow++) {
1119
- if (!fixedEditorLineMatch(renderedLines[renderedRow] ?? "", clickedLine)) continue;
1120
- const score = fixedEditorContextScore(renderedLines, renderedRow, visibleLines, visibleRow);
1121
- if (!best || score >= best.score) {
1122
- best = {
1123
- hit: {
1124
- component,
1125
- start: visibleRow,
1126
- end: visibleRow + renderedLines.length,
1127
- row: renderedRow,
1128
- },
1129
- score,
1130
- };
1131
- }
1132
- }
1133
- }
1134
- return best?.hit ?? null;
1135
- }
1136
-
1137
- function findToolAtScreenRow(tui: any, screenRow: number): ToolRenderHit | null {
1138
- const previousLines = Array.isArray(tui?.previousLines) ? tui.previousLines : [];
1139
- const width = Math.max(1, Number(tui?.terminal?.columns) || 80);
1140
- if (useFixedEditorFeatures(tui)) {
1141
- // Zentui replaces Pi's root render with the already-sliced visible
1142
- // transcript. previousViewportTop remains cursor bookkeeping and must not
1143
- // be added to a physical mouse row here.
1144
- return findToolAtFixedEditorRow(tui, screenRow - 1, previousLines, width);
1145
- }
1146
- const viewportTop = Number.isFinite(tui?.previousViewportTop) ? tui.previousViewportTop : 0;
1147
- const bufferRow = viewportTop + screenRow - 1;
1148
- if (bufferRow < 0 || bufferRow >= previousLines.length) return null;
1149
-
1150
- const hits: ToolRenderHit[] = [];
1151
- const cache = new WeakMap<object, number>();
1152
- collectToolRenderHits(tui, 0, width, hits, cache, new Set<object>());
1153
-
1154
- const clickedLine = stripTerminalSequences(String(previousLines[bufferRow] ?? ""));
1155
- for (const hit of hits) {
1156
- if (bufferRow < hit.start || bufferRow >= hit.end) continue;
1157
- // The stable summary marker keeps the whole row clickable even when the
1158
- // trailing keyboard/click hint was truncated (common for subagent output).
1159
- if (!hit.component.expanded && !COLLAPSED_TOOL_SUMMARY.test(clickedLine)) continue;
1160
- return { ...hit, row: bufferRow - hit.start };
1161
- }
1162
- return null;
1163
- }
1164
-
1165
1116
  function isFixedEditorTui(tui: any): boolean {
1166
1117
  const terminal = tui?.terminal;
1167
1118
  if (!terminal) return false;
@@ -1359,15 +1310,8 @@ function containsEditorLike(component: any, focused: any, seen = new Set<any>())
1359
1310
  );
1360
1311
  }
1361
1312
 
1362
- function isScrollButtonAtScreenRow(tui: any, packet: SgrMousePacket): boolean {
1363
- if (!scrollButtonVisible || !useFixedEditorFeatures(tui) || !scrollButtonWidget) return false;
1364
- const hitbox = getFixedEditorScrollButtonHitbox();
1365
- return Boolean(
1366
- hitbox &&
1367
- packet.row === hitbox.row &&
1368
- packet.col >= hitbox.startCol &&
1369
- packet.col <= hitbox.endCol,
1370
- );
1313
+ function isScrollButtonAtScreenRow(_tui: any, packet: SgrMousePacket): boolean {
1314
+ return interactionRegionAt(packet)?.kind === "scroll-bottom";
1371
1315
  }
1372
1316
 
1373
1317
  function jumpToBottomWithoutSubmit(tui: any): boolean {
@@ -1414,139 +1358,50 @@ function scheduleCollapseViewportCompensation(
1414
1358
  });
1415
1359
  }
1416
1360
 
1417
- /** toolCallId → latest expanded IO view (survives context/state identity quirks). */
1418
- const ioViewsByToolCallId = new Map<string, ExpandedToolIoView>();
1419
1361
  const ioViewInvalidators = new WeakMap<ExpandedToolIoView, () => void>();
1420
- const ioViewCollectors = new Set<Set<ExpandedToolIoView>>();
1421
- let frameIoViews = new Set<ExpandedToolIoView>();
1422
1362
  let hoveredToolIoView: ExpandedToolIoView | null = null;
1423
1363
  let hoveredToolIoSection: ToolIoSection | null = null;
1424
1364
 
1425
- function recordRenderedIoView(view: ExpandedToolIoView): void {
1426
- for (const collector of ioViewCollectors) collector.add(view);
1427
- }
1428
-
1429
1365
  function rememberIoView(context: any, view: ExpandedToolIoView): void {
1430
1366
  if (!context || typeof context !== "object") return;
1431
1367
  if (typeof context.invalidate === "function") ioViewInvalidators.set(view, context.invalidate);
1432
1368
  if (!context.state || typeof context.state !== "object") context.state = {};
1433
- const state = context.state as Record<string, unknown>;
1434
- state.ccstyleIoView = view;
1435
- const id =
1436
- (typeof context?.toolCallId === "string" && context.toolCallId) ||
1437
- (typeof context?.id === "string" && context.id) ||
1438
- (typeof state?.toolCallId === "string" && state.toolCallId) ||
1439
- undefined;
1440
- if (id) {
1441
- ioViewsByToolCallId.set(id, view);
1442
- // Bound growth in long sessions.
1443
- if (ioViewsByToolCallId.size > 200) {
1444
- const oldest = ioViewsByToolCallId.keys().next().value;
1445
- if (oldest !== undefined) ioViewsByToolCallId.delete(oldest);
1446
- }
1447
- }
1369
+ context.state.ccstyleIoView = view;
1448
1370
  }
1449
1371
 
1450
- function resolveIoViewFromTool(component: any): ExpandedToolIoView | null {
1451
- const fromState = component?.state?.ccstyleIoView;
1452
- if (isExpandedToolIoView(fromState)) return fromState;
1453
- const id =
1454
- (typeof component?.toolCallId === "string" && component.toolCallId) ||
1455
- (typeof component?.state?.toolCallId === "string" && component.state.toolCallId) ||
1456
- undefined;
1457
- if (id) {
1458
- const mapped = ioViewsByToolCallId.get(id);
1459
- if (mapped) return mapped;
1460
- }
1461
- // Some hosts keep the last result component on the tool instance.
1462
- const last = component?.lastComponent ?? component?.resultComponent ?? component?.content;
1463
- if (isExpandedToolIoView(last)) return last;
1464
- return null;
1372
+ function collapsedHintHitbox(line: string): { startCol: number; endCol: number } | null {
1373
+ const plain = stripTerminalSequencesPreservingLayout(line);
1374
+ const match = /(\([^()\n]* \/ click\)|click to show more)(?=\)?\s*$)/.exec(plain);
1375
+ if (!match?.[1]) return null;
1376
+ const startCol = visibleWidth(plain.slice(0, match.index)) + 1;
1377
+ return { startCol, endCol: startCol + visibleWidth(match[1]) - 1 };
1465
1378
  }
1466
1379
 
1467
- function resolveShowMoreAt(
1468
- tui: any,
1469
- packet: SgrMousePacket,
1470
- component?: any,
1471
- ): { view: ExpandedToolIoView; section: ToolIoSection } | null {
1472
- const plain = visibleMouseLine(tui, packet);
1473
- const resolved = resolveIoViewFromTool(component);
1474
- const renderedViews =
1475
- frameIoViews.size > 0
1476
- ? [...frameIoViews]
1477
- : [
1478
- ...(resolved ? [resolved] : []),
1479
- ...[...ioViewsByToolCallId.values()].filter((view) => view !== resolved),
1480
- ];
1481
- const preferred = resolved && renderedViews.includes(resolved) ? resolved : null;
1482
- const candidates = preferred
1483
- ? [preferred, ...renderedViews.filter((view) => view !== preferred)]
1484
- : renderedViews;
1485
- const previousLines = (Array.isArray(tui?.previousLines) ? tui.previousLines : []).map(
1486
- (line: unknown) => stripTerminalSequencesPreservingLayout(String(line)),
1380
+ function interactionRegionAt(packet: SgrMousePacket): InteractionRegion | null {
1381
+ const matches = latestInteractionFrame.regions.filter(
1382
+ (region) =>
1383
+ region.row === packet.row && packet.col >= region.startCol && packet.col <= region.endCol,
1487
1384
  );
1488
- const visibleRow = useFixedEditorFeatures(tui)
1489
- ? packet.row - 1
1490
- : (Number.isFinite(tui?.previousViewportTop) ? tui.previousViewportTop : 0) + packet.row - 1;
1491
- let best: { view: ExpandedToolIoView; section: ToolIoSection; score: number } | null = null;
1492
- for (const view of candidates) {
1493
- const section = view.matchShowMoreLine(plain);
1494
- const box = view.showMoreHitbox(plain);
1495
- if (!section || !box || packet.col < box.startCol || packet.col > box.endCol) continue;
1496
- const cachedLines = Array.isArray((view as any).cachedLines)
1497
- ? (view as any).cachedLines.map((line: unknown) =>
1498
- stripTerminalSequencesPreservingLayout(String(line)),
1499
- )
1500
- : [];
1501
- let score = view === preferred ? 1 : 0;
1502
- for (let row = 0; row < cachedLines.length; row++) {
1503
- if (!fixedEditorLineMatch(cachedLines[row] ?? "", plain)) continue;
1504
- score = Math.max(score, fixedEditorContextScore(cachedLines, row, previousLines, visibleRow));
1505
- }
1506
- if (!best || score > best.score) best = { view, section, score };
1507
- }
1508
- return best && { view: best.view, section: best.section };
1509
- }
1510
-
1511
- /**
1512
- * If the click lands on a truncated section's [show more], open the /context-style
1513
- * full-text preview instead of toggling expand/collapse.
1514
- */
1515
- function visibleMouseLine(tui: any, packet: SgrMousePacket): string {
1516
- const previousLines = Array.isArray(tui?.previousLines) ? tui.previousLines : [];
1517
- const viewportTop = useFixedEditorFeatures(tui)
1518
- ? 0
1519
- : Number.isFinite(tui?.previousViewportTop)
1520
- ? tui.previousViewportTop
1521
- : 0;
1522
- return stripTerminalSequencesPreservingLayout(
1523
- String(previousLines[viewportTop + packet.row - 1] ?? ""),
1385
+ return (
1386
+ matches.find((region) => region.kind === "show-more") ??
1387
+ matches.find((region) => region.kind === "scroll-bottom") ??
1388
+ matches.find((region) => region.kind === "collapsed-hint") ??
1389
+ matches.find((region) => region.kind === "expanded-card") ??
1390
+ null
1524
1391
  );
1525
1392
  }
1526
1393
 
1527
- function isCollapsedHintAtColumn(line: string, col: number): boolean {
1528
- // Rich diff hints place "click to show more" inside a trailing parenthesis.
1529
- const match = /(\([^()\n]* \/ click\)|click to show more)(?=\)?\s*$)/.exec(line);
1530
- if (!match?.[1] || col <= 0) return false;
1531
- const start = visibleWidth(line.slice(0, match.index)) + 1;
1532
- return col >= start && col < start + visibleWidth(match[1]);
1533
- }
1534
-
1535
- function tryOpenToolIoShowMore(tui: any, packet: SgrMousePacket, hit: ToolRenderHit): boolean {
1536
- if (!Boolean(hit.component.expanded)) return false;
1537
- const match = resolveShowMoreAt(tui, packet, hit.component);
1538
- if (!match) return false;
1539
- const { view: ioView, section } = match;
1540
-
1394
+ function tryOpenToolIoShowMore(region: InteractionRegion): boolean {
1395
+ const ioView = region.view;
1396
+ const section = region.section;
1397
+ if (!ioView || !section) return false;
1541
1398
  const ui = toolMouseUi;
1542
1399
  if (!ui || typeof ui.custom !== "function") {
1543
1400
  ui?.notify?.("Full preview requires TUI custom UI", "warning");
1544
1401
  return true;
1545
1402
  }
1546
-
1547
1403
  const title = section === "input" ? "Tool Input" : "Tool Output";
1548
1404
  const content = section === "input" ? ioView.getInputBody() : ioView.getOutputBody();
1549
- // Fire-and-forget overlay; click is still consumed.
1550
1405
  void showTextPreview({ ui }, title, content || "(empty)");
1551
1406
  return true;
1552
1407
  }
@@ -1576,31 +1431,15 @@ function setHoveredToolGroup(group: ToolGroupComponent | null): boolean {
1576
1431
 
1577
1432
  function updateToolSummaryHover(tui: any, packet: SgrMousePacket): void {
1578
1433
  if ((packet.code & 32) === 0 || packet.final !== "M") return;
1579
- const nextScrollButtonHovered = isScrollButtonAtScreenRow(tui, packet);
1434
+ const region = interactionRegionAt(packet);
1435
+ const nextScrollButtonHovered = region?.kind === "scroll-bottom";
1580
1436
  const scrollButtonChanged = nextScrollButtonHovered !== scrollButtonHovered;
1581
1437
  scrollButtonHovered = nextScrollButtonHovered;
1582
- const visibleLine = visibleMouseLine(tui, packet);
1583
- let nextToolCallId: string | null = null;
1584
- let nextGroup: ToolGroupComponent | null = null;
1585
- let nextIoView: ExpandedToolIoView | null = null;
1586
- let nextIoSection: ToolIoSection | null = null;
1587
-
1588
- if (
1589
- COLLAPSED_TOOL_SUMMARY.test(visibleLine) &&
1590
- isCollapsedHintAtColumn(visibleLine, packet.col)
1591
- ) {
1592
- const hit = findToolAtScreenRow(tui, packet.row);
1593
- nextToolCallId = hit && !hit.component.expanded ? hit.component.toolCallId : null;
1594
- nextGroup = hit?.component instanceof ToolGroupComponent ? hit.component : null;
1595
- } else if (visibleLine.includes(SHOW_MORE_LABEL)) {
1596
- const hit = findToolAtScreenRow(tui, packet.row);
1597
- const match = resolveShowMoreAt(tui, packet, hit?.component);
1598
- if (match) {
1599
- nextIoView = match.view;
1600
- nextIoSection = match.section;
1601
- }
1602
- }
1603
-
1438
+ const component = region?.component;
1439
+ const nextToolCallId = region?.kind === "collapsed-hint" ? (component?.toolCallId ?? null) : null;
1440
+ const nextGroup = component instanceof ToolGroupComponent ? component : null;
1441
+ const nextIoView = region?.kind === "show-more" ? (region.view ?? null) : null;
1442
+ const nextIoSection = region?.kind === "show-more" ? (region.section ?? null) : null;
1604
1443
  const changed = nextToolCallId !== hoveredToolCallId;
1605
1444
  hoveredToolCallId = nextToolCallId;
1606
1445
  if (
@@ -1612,49 +1451,31 @@ function updateToolSummaryHover(tui: any, packet: SgrMousePacket): void {
1612
1451
  tui.requestRender?.();
1613
1452
  }
1614
1453
 
1615
- function isExpandedToolCardHit(hit: ToolRenderHit, width: number): boolean {
1616
- if (!Boolean(hit.component.expanded)) return false;
1617
- if (hit.component instanceof ToolGroupComponent) return true;
1618
- const box = hit.component.contentBox;
1619
- if (!box || !Array.isArray(hit.component.children) || !hit.component.children.includes(box)) {
1620
- return false;
1621
- }
1622
- const boxLines = box.render?.(width);
1623
- const componentLines = renderComponentTree(hit.component, width);
1624
- if (!Array.isArray(boxLines) || boxLines.length === 0) return false;
1625
- const cardStart = componentLines.length - boxLines.length;
1626
- return hit.row >= cardStart && hit.row < componentLines.length;
1627
- }
1628
-
1629
1454
  function toggleToolAtMouseClick(tui: any, packet: SgrMousePacket): boolean {
1630
- const line = visibleMouseLine(tui, packet);
1631
- const hasShowMore = line.includes(SHOW_MORE_LABEL);
1632
-
1633
- const hit = findToolAtScreenRow(tui, packet.row);
1634
- if (!hit) return false;
1635
- const collapsedHint = isCollapsedHintAtColumn(line, packet.col);
1455
+ const region = interactionRegionAt(packet);
1456
+ if (!region) return false;
1457
+ if (region.kind === "scroll-bottom") return jumpToBottomWithoutSubmit(tui);
1458
+ if (region.kind === "show-more") return tryOpenToolIoShowMore(region);
1459
+ const component = region.component;
1460
+ if (!component) return false;
1636
1461
  const width = Math.max(1, Number(tui?.terminal?.columns) || 80);
1637
- if (Boolean(hit.component.expanded)) {
1638
- if (hasShowMore && tryOpenToolIoShowMore(tui, packet, hit)) return true;
1639
- if (!isExpandedToolCardHit(hit, width)) return false;
1640
- const previousHeight = renderComponentTree(hit.component, width).length;
1641
- hit.component.setExpanded(false);
1462
+ if (region.kind === "expanded-card") {
1463
+ const previousHeight = renderComponentTree(component, width).length;
1464
+ component.setExpanded(false);
1642
1465
  hoveredToolCallId = null;
1643
1466
  setHoveredToolGroup(null);
1644
1467
  setHoveredToolIo(null, null);
1645
- hit.component.invalidate?.();
1646
- const nextHeight = renderComponentTree(hit.component, width).length;
1468
+ component.invalidate?.();
1469
+ const nextHeight = renderComponentTree(component, width).length;
1647
1470
  tui.requestRender?.();
1648
1471
  scheduleCollapseViewportCompensation(tui, previousHeight - nextHeight, packet);
1649
1472
  return true;
1650
1473
  }
1651
- if (!collapsedHint) return false;
1652
-
1653
- hit.component.setExpanded(true);
1474
+ component.setExpanded(true);
1654
1475
  hoveredToolCallId = null;
1655
1476
  setHoveredToolGroup(null);
1656
1477
  setHoveredToolIo(null, null);
1657
- hit.component.invalidate?.();
1478
+ component.invalidate?.();
1658
1479
  tui.requestRender?.();
1659
1480
  return true;
1660
1481
  }
@@ -1758,82 +1579,248 @@ function restoreToolMouseRenderPatch(): void {
1758
1579
  toolMouseRenderPatchWrapper = null;
1759
1580
  toolMouseRenderPatchState = null;
1760
1581
  toolMouseRawWrite = null;
1761
- ioViewCollectors.clear();
1762
- frameIoViews = new Set();
1763
- frameToolRenders = [];
1582
+ latestInteractionFrame = { regions: [] };
1583
+ }
1584
+
1585
+ function buildInteractionFrame(
1586
+ tui: any,
1587
+ renderedTools: FrameToolRender[],
1588
+ placements: FrameToolPlacement[],
1589
+ ): InteractionFrame {
1590
+ const width = Math.max(1, Number(tui?.terminal?.columns) || 80);
1591
+ const fixed = useFixedEditorFeatures(tui);
1592
+ const viewport = fixed ? getFixedEditorViewport(tui) : null;
1593
+ // fixed-editor: tui.render already returned the visible root slice (screen rows).
1594
+ // native: full buffer; map with the post-doRender previousViewportTop.
1595
+ const lineIndexToScreenRow = (lineIndex: number) =>
1596
+ fixed ? lineIndex + 1 : lineIndex - (Number(tui?.previousViewportTop) || 0) + 1;
1597
+ const visibleRows = fixed
1598
+ ? (viewport?.visibleLines.length ??
1599
+ (Array.isArray(tui?.previousLines) ? tui.previousLines.length : Number.POSITIVE_INFINITY))
1600
+ : Math.max(1, Number(tui?.terminal?.rows) || Number.POSITIVE_INFINITY);
1601
+ const regions: InteractionRegion[] = [];
1602
+ const renderedByComponent = new Map<any, FrameToolRender>();
1603
+ for (const rendered of renderedTools) renderedByComponent.set(rendered.component, rendered);
1604
+ const placementsByComponent = new Map<any, FrameToolPlacement[]>();
1605
+ for (const placement of placements) {
1606
+ const list = placementsByComponent.get(placement.component) ?? [];
1607
+ list.push(placement);
1608
+ placementsByComponent.set(placement.component, list);
1609
+ }
1610
+ for (const [component, componentPlacements] of placementsByComponent) {
1611
+ const rendered = renderedByComponent.get(component);
1612
+ if (!rendered) continue;
1613
+ for (const placement of componentPlacements) {
1614
+ const finalRow = lineIndexToScreenRow(placement.lineIndex);
1615
+ if (finalRow < 1 || finalRow > visibleRows) continue;
1616
+ // Hit columns come from the final painted line (parent may prefix/transform).
1617
+ const line = placement.finalLine;
1618
+ if (!component.expanded) {
1619
+ const box = collapsedHintHitbox(line);
1620
+ if (box && COLLAPSED_TOOL_SUMMARY.test(stripTerminalSequences(line))) {
1621
+ regions.push({ kind: "collapsed-hint", row: finalRow, ...box, component });
1622
+ }
1623
+ continue;
1624
+ }
1625
+ if (placement.view && placement.section) {
1626
+ const plain = stripTerminalSequencesPreservingLayout(line);
1627
+ const box = placement.view.showMoreHitbox(plain);
1628
+ if (box) {
1629
+ regions.push({
1630
+ kind: "show-more",
1631
+ row: finalRow,
1632
+ ...box,
1633
+ component,
1634
+ view: placement.view,
1635
+ section: placement.section,
1636
+ });
1637
+ }
1638
+ }
1639
+ }
1640
+ if (!component.expanded) continue;
1641
+ let cardStart = 0;
1642
+ if (!(component instanceof ToolGroupComponent)) {
1643
+ const box = component.contentBox;
1644
+ if (!box || !Array.isArray(component.children) || !component.children.includes(box)) {
1645
+ continue;
1646
+ }
1647
+ if (!rendered.contentBoxLines) continue;
1648
+ cardStart = Math.max(0, rendered.lines.length - rendered.contentBoxLines);
1649
+ }
1650
+ for (const placement of componentPlacements) {
1651
+ if (placement.componentRow < cardStart) continue;
1652
+ const finalRow = lineIndexToScreenRow(placement.lineIndex);
1653
+ if (finalRow >= 1 && finalRow <= visibleRows) {
1654
+ regions.push({
1655
+ kind: "expanded-card",
1656
+ row: finalRow,
1657
+ startCol: 1,
1658
+ endCol: width,
1659
+ component,
1660
+ });
1661
+ }
1662
+ }
1663
+ }
1664
+ const scrollHitbox = getFixedEditorScrollButtonHitbox();
1665
+ if (scrollButtonVisible && useFixedEditorFeatures(tui) && scrollHitbox) {
1666
+ regions.push({ kind: "scroll-bottom", ...scrollHitbox });
1667
+ }
1668
+ return { regions };
1669
+ }
1670
+
1671
+ function defineRenderOverride(
1672
+ target: any,
1673
+ wrapped: (...args: any[]) => any,
1674
+ ): PropertyDescriptor | undefined {
1675
+ const descriptor = Object.getOwnPropertyDescriptor(target, "render");
1676
+ try {
1677
+ Object.defineProperty(
1678
+ target,
1679
+ "render",
1680
+ descriptor && "value" in descriptor
1681
+ ? { ...descriptor, value: wrapped }
1682
+ : {
1683
+ configurable: true,
1684
+ enumerable: descriptor?.enumerable ?? false,
1685
+ writable: true,
1686
+ value: wrapped,
1687
+ },
1688
+ );
1689
+ return descriptor;
1690
+ } catch {
1691
+ return undefined;
1692
+ }
1693
+ }
1694
+
1695
+ function restoreRenderOverride(target: any, descriptor: PropertyDescriptor | undefined): void {
1696
+ try {
1697
+ if (descriptor) Object.defineProperty(target, "render", descriptor);
1698
+ else delete target.render;
1699
+ } catch {
1700
+ // Keep restoring siblings after a hostile descriptor change.
1701
+ }
1764
1702
  }
1765
1703
 
1766
1704
  function patchToolMouseMotionAfterRender(tui: any): void {
1767
- if (!toolMouseFixedFeaturesEnabled || toolMouseRenderPatchTui === tui) return;
1705
+ // Same tui is not enough: footer/compositor rebuild may replace doRender under us.
1706
+ if (
1707
+ toolMouseRenderPatchTui === tui &&
1708
+ toolMouseRenderPatchState?.active &&
1709
+ tui.doRender === toolMouseRenderPatchWrapper
1710
+ ) {
1711
+ return;
1712
+ }
1768
1713
  restoreToolMouseRenderPatch();
1769
1714
  const original = tui?.doRender;
1770
1715
  const terminal = tui?.terminal;
1771
- let prototype = terminal && Object.getPrototypeOf(terminal);
1772
- let rawWrite: ((...args: any[]) => unknown) | undefined;
1773
- while (prototype && !rawWrite) {
1774
- const candidate = Object.getOwnPropertyDescriptor(prototype, "write")?.value;
1775
- if (typeof candidate === "function") rawWrite = candidate;
1776
- prototype = Object.getPrototypeOf(prototype);
1777
- }
1778
- if (typeof original !== "function" || !rawWrite) return;
1716
+ const rawWrite = typeof terminal?.write === "function" ? terminal.write : undefined;
1717
+ if (typeof original !== "function") return;
1779
1718
 
1780
- toolMouseRawWrite = (data) => Reflect.apply(rawWrite, terminal, [data]);
1719
+ toolMouseRawWrite = rawWrite ? (data) => Reflect.apply(rawWrite, terminal, [data]) : null;
1781
1720
  const patchState = { active: true };
1782
1721
  const wrapper = function (this: any, ...args: any[]) {
1783
1722
  if (!patchState.active) return Reflect.apply(original, this, args);
1784
- const renderedViews = new Set<ExpandedToolIoView>();
1785
1723
  const renderedTools: FrameToolRender[] = [];
1786
- const tools: any[] = [];
1787
- const restores: Array<{ component: any; descriptor?: PropertyDescriptor }> = [];
1788
- collectToolComponents(this, tools);
1789
- for (const component of tools) {
1790
- const descriptor = Object.getOwnPropertyDescriptor(component, "render");
1724
+ const idToComponent = new Map<number, any>();
1725
+ const frame: IoViewFrameState = {
1726
+ viewIds: new Map(),
1727
+ idToView: new Map(),
1728
+ nextId: 0,
1729
+ };
1730
+ const restores: Array<{ target: any; descriptor?: PropertyDescriptor }> = [];
1731
+ const outermost: any[] = [];
1732
+ collectToolComponents(this, outermost);
1733
+ let nextId = 0;
1734
+ for (const component of outermost) {
1791
1735
  const originalRender = component.render;
1792
1736
  if (typeof originalRender !== "function") continue;
1737
+ const id = nextId++;
1738
+ idToComponent.set(id, component);
1793
1739
  const wrappedRender = function (this: any, ...renderArgs: any[]) {
1794
- const lines = Reflect.apply(originalRender, this, renderArgs);
1795
- if (Array.isArray(lines)) renderedTools.push({ component, lines: [...lines] });
1796
- return lines;
1740
+ let contentBoxLines = 0;
1741
+ const box = component.contentBox;
1742
+ let boxRestore: { target: any; descriptor?: PropertyDescriptor } | undefined;
1743
+ if (
1744
+ box &&
1745
+ Array.isArray(component.children) &&
1746
+ component.children.includes(box) &&
1747
+ typeof box.render === "function"
1748
+ ) {
1749
+ const boxOriginal = box.render;
1750
+ const boxWrapped = function (this: any, ...boxArgs: any[]) {
1751
+ const boxLines = Reflect.apply(boxOriginal, this, boxArgs);
1752
+ if (Array.isArray(boxLines)) contentBoxLines = boxLines.length;
1753
+ return boxLines;
1754
+ };
1755
+ const boxDescriptor = defineRenderOverride(box, boxWrapped);
1756
+ if (boxDescriptor !== undefined || box.render === boxWrapped) {
1757
+ boxRestore = { target: box, descriptor: boxDescriptor };
1758
+ }
1759
+ }
1760
+ try {
1761
+ const lines = Reflect.apply(originalRender, this, renderArgs);
1762
+ if (!Array.isArray(lines)) return lines;
1763
+ renderedTools.push({
1764
+ component,
1765
+ lines: lines.map((line) => String(line)),
1766
+ contentBoxLines,
1767
+ });
1768
+ return lines.map((line, row) => `${line}${toolFrameMarker(id, row)}`);
1769
+ } finally {
1770
+ if (boxRestore) restoreRenderOverride(boxRestore.target, boxRestore.descriptor);
1771
+ }
1797
1772
  };
1798
- try {
1799
- Object.defineProperty(
1800
- component,
1801
- "render",
1802
- descriptor && "value" in descriptor
1803
- ? { ...descriptor, value: wrappedRender }
1804
- : {
1805
- configurable: true,
1806
- enumerable: descriptor?.enumerable ?? false,
1807
- writable: true,
1808
- value: wrappedRender,
1809
- },
1810
- );
1811
- restores.push({ component, descriptor });
1812
- } catch {
1813
- // Non-configurable render accessors cannot be observed safely.
1773
+ const descriptor = defineRenderOverride(component, wrappedRender);
1774
+ if (descriptor !== undefined || component.render === wrappedRender) {
1775
+ restores.push({ target: component, descriptor });
1814
1776
  }
1815
1777
  }
1816
- ioViewCollectors.add(renderedViews);
1778
+ let placements: FrameToolPlacement[] = [];
1779
+ const originalTuiRender = typeof this.render === "function" ? this.render : null;
1780
+ let tuiRenderDescriptor: PropertyDescriptor | undefined;
1781
+ let sawTuiRender = false;
1782
+ if (originalTuiRender) {
1783
+ const wrappedTuiRender = function (this: any, ...renderArgs: any[]) {
1784
+ const lines = Reflect.apply(originalTuiRender, this, renderArgs);
1785
+ if (!Array.isArray(lines)) return lines;
1786
+ sawTuiRender = true;
1787
+ const extracted = extractToolFramePlacements(
1788
+ lines.map((line) => String(line)),
1789
+ idToComponent,
1790
+ frame.idToView,
1791
+ );
1792
+ placements = extracted.placements;
1793
+ return extracted.lines;
1794
+ };
1795
+ tuiRenderDescriptor = defineRenderOverride(this, wrappedTuiRender);
1796
+ }
1817
1797
  let succeeded = false;
1798
+ const previousFrame = activeIoViewFrame;
1799
+ activeIoViewFrame = frame;
1818
1800
  try {
1819
1801
  const result = Reflect.apply(original, this, args);
1820
1802
  succeeded = true;
1821
- toolMouseRawWrite?.(TOOL_MOUSE_MOTION_ENABLE);
1803
+ // Test harnesses may paint via doRender without tui.render; recover markers there.
1804
+ if (!sawTuiRender && Array.isArray(this.previousLines)) {
1805
+ const extracted = extractToolFramePlacements(
1806
+ this.previousLines.map((line: unknown) => String(line)),
1807
+ idToComponent,
1808
+ frame.idToView,
1809
+ );
1810
+ this.previousLines = extracted.lines;
1811
+ placements = extracted.placements;
1812
+ }
1813
+ if (!useFixedEditorFeatures(this)) toolMouseRawWrite?.(TOOL_MOUSE_MOTION_ENABLE);
1822
1814
  return result;
1823
1815
  } finally {
1824
- for (const { component, descriptor } of restores.reverse()) {
1825
- try {
1826
- if (descriptor) Object.defineProperty(component, "render", descriptor);
1827
- else delete component.render;
1828
- } catch {
1829
- // Keep restoring the remaining instances after a hostile descriptor change.
1830
- }
1816
+ activeIoViewFrame = previousFrame;
1817
+ if (originalTuiRender) restoreRenderOverride(this, tuiRenderDescriptor);
1818
+ for (const { target, descriptor } of restores.reverse()) {
1819
+ restoreRenderOverride(target, descriptor);
1831
1820
  }
1832
1821
  if (succeeded) {
1833
- frameIoViews = renderedViews;
1834
- frameToolRenders = renderedTools;
1822
+ latestInteractionFrame = buildInteractionFrame(this, renderedTools, placements);
1835
1823
  }
1836
- ioViewCollectors.delete(renderedViews);
1837
1824
  }
1838
1825
  };
1839
1826
  try {
@@ -1846,7 +1833,7 @@ function patchToolMouseMotionAfterRender(tui: any): void {
1846
1833
  toolMouseRenderPatchOriginal = original;
1847
1834
  toolMouseRenderPatchWrapper = wrapper;
1848
1835
  toolMouseRenderPatchState = patchState;
1849
- toolMouseRawWrite(TOOL_MOUSE_MOTION_ENABLE);
1836
+ if (!useFixedEditorFeatures(tui)) toolMouseRawWrite?.(TOOL_MOUSE_MOTION_ENABLE);
1850
1837
  }
1851
1838
 
1852
1839
  function handleToolMouseInput(data: string): { consume: true } | undefined {
@@ -1894,7 +1881,7 @@ function handleToolMouseInput(data: string): { consume: true } | undefined {
1894
1881
  return consumed ? { consume: true } : undefined;
1895
1882
  }
1896
1883
 
1897
- function teardownToolMouseInteraction(): void {
1884
+ function teardownToolMouseInteraction(nextFixedEditorFeatures = false): void {
1898
1885
  if (sessionRenderTimer) {
1899
1886
  clearTimeout(sessionRenderTimer);
1900
1887
  sessionRenderTimer = null;
@@ -1905,7 +1892,9 @@ function teardownToolMouseInteraction(): void {
1905
1892
  setHoveredToolGroup(null);
1906
1893
  setHoveredToolIo(null, null);
1907
1894
  try {
1908
- toolMouseTui?.terminal?.write?.(TOOL_MOUSE_DISABLE);
1895
+ if (!toolMouseFixedFeaturesEnabled && !nextFixedEditorFeatures) {
1896
+ toolMouseTui?.terminal?.write?.(TOOL_MOUSE_DISABLE);
1897
+ }
1909
1898
  } catch {
1910
1899
  // The terminal may already be closed during shutdown.
1911
1900
  }
@@ -1934,7 +1923,7 @@ export function installToolMouseInteraction(
1934
1923
  ctx: any,
1935
1924
  fixedEditorFeatures = config.fixedEditorFeatures,
1936
1925
  ): void {
1937
- teardownToolMouseInteraction();
1926
+ teardownToolMouseInteraction(fixedEditorFeatures);
1938
1927
  if (ctx?.mode !== "tui" || !ctx?.hasUI) return;
1939
1928
  if (typeof ctx.ui?.onTerminalInput !== "function" || typeof ctx.ui?.setWidget !== "function")
1940
1929
  return;
@@ -1943,12 +1932,13 @@ export function installToolMouseInteraction(
1943
1932
  toolMouseFixedFeaturesEnabled = fixedEditorFeatures;
1944
1933
  ctx.ui.setWidget(TOOL_MOUSE_WIDGET_KEY, (tui: any, theme: any) => {
1945
1934
  toolMouseTui = tui;
1946
- if (fixedEditorFeatures) {
1947
- patchToolMouseInputCapture(tui);
1935
+ if (fixedEditorFeatures) patchToolMouseInputCapture(tui);
1936
+ // Fixed mode: wrap only after compositor install makes features live.
1937
+ if (!fixedEditorFeatures || useFixedEditorFeatures(tui)) {
1948
1938
  patchToolMouseMotionAfterRender(tui);
1949
1939
  }
1950
- // Motion reporting is required for hover in both native and fixed-editor layouts.
1951
- tui?.terminal?.write?.(fixedEditorFeatures ? TOOL_MOUSE_ENABLE : TOOL_MOUSE_MOTION_ENABLE);
1940
+ // The fixed-editor compositor owns its mouse mode; native Pi still needs motion enabled.
1941
+ if (!fixedEditorFeatures) tui?.terminal?.write?.(TOOL_MOUSE_MOTION_ENABLE);
1952
1942
  const widget = {
1953
1943
  render: (width: number) => renderScrollButton(width, theme),
1954
1944
  invalidate() {},
@@ -1975,7 +1965,10 @@ function scheduleSessionRender(refresh?: () => void): void {
1975
1965
  sessionRenderTimer = setTimeout(() => {
1976
1966
  sessionRenderTimer = null;
1977
1967
  if (toolMouseTui !== tui) return;
1978
- patchToolMouseMotionAfterRender(tui);
1968
+ // Do not pre-wrap while fixed-editor compositor install is still pending.
1969
+ if (!toolMouseFixedFeaturesEnabled || useFixedEditorFeatures(tui)) {
1970
+ patchToolMouseMotionAfterRender(tui);
1971
+ }
1979
1972
  refreshToolRendererComponents(tui);
1980
1973
  refresh?.();
1981
1974
  tui.requestRender(true);
@@ -2485,9 +2478,10 @@ function singleToolCallSummary(
2485
2478
  if (AGENT_FAMILY_TOOL_NAMES.has(toolName) && args.agent_id) {
2486
2479
  return { main: `${title} ${oneLine(args.agent_id, Infinity)}`, detail: "" };
2487
2480
  }
2488
- if (name === "agent" || name === "agents") {
2481
+ // Agents still uses the ccstyle wrapper; Agent keeps its dedicated renderer.
2482
+ if (name === "agents") {
2489
2483
  return {
2490
- main: value(name === "agent" ? "launch agent" : "launch agents", "description", "prompt"),
2484
+ main: value("launch agents", "description", "prompt"),
2491
2485
  detail: "",
2492
2486
  };
2493
2487
  }
@@ -2797,6 +2791,8 @@ const AGENT_FAMILY_TOOL_NAMES = new Set([
2797
2791
  "get_subagent_result",
2798
2792
  "steer_subagent",
2799
2793
  ]);
2794
+ // pi-subagents 等扩展为 Agent 提供专用渲染器(displayName/运行统计),ccstyle 必须保留,不能 wrap。
2795
+ const DEDICATED_RENDERER_TOOLS = new Set(["Agent"]);
2800
2796
 
2801
2797
  type ToolRenderMethods = {
2802
2798
  hasRendererDefinition: (...args: any[]) => boolean;
@@ -2885,6 +2881,7 @@ function shouldGloballyStyleTool(component: any, patch: GlobalToolRenderPatch):
2885
2881
  const toolName = String(component.toolName || definition?.name || "");
2886
2882
  const useCcstyle =
2887
2883
  patch.mode() === "on" &&
2884
+ !DEDICATED_RENDERER_TOOLS.has(toolName) &&
2888
2885
  !preservesOriginalRenderer(extensionDefinition, toolName, builtInDefinition);
2889
2886
  component[COMPONENT_TOOL_RENDER_MODE] = useCcstyle;
2890
2887
  return useCcstyle;
@@ -3129,6 +3126,18 @@ export default function (
3129
3126
  // The optional override keeps integration tests independent from the user's global config.
3130
3127
  if (configOverride) config = normalizeConfig({ ...config, ...configOverride });
3131
3128
  const fixedEditorController = installFixedEditor(pi, config.fixedEditorFeatures);
3129
+ // Unwrap mouse doRender before compositor construction captures the chain.
3130
+ setBeforeFixedEditorStart(() => {
3131
+ restoreToolMouseRenderPatch();
3132
+ });
3133
+ // Footer/compositor rebuild disposes the old chain; re-wrap the live doRender and repaint.
3134
+ fixedEditorController.onRebuild(() => {
3135
+ const tui = toolMouseTui;
3136
+ if (!tui) return;
3137
+ if (toolMouseFixedFeaturesEnabled) patchToolMouseInputCapture(tui);
3138
+ patchToolMouseMotionAfterRender(tui);
3139
+ tui.requestRender?.(true);
3140
+ });
3132
3141
  const writeExecutionMetadata = installWriteOverride(pi, new WriteExecutionMetadataStore());
3133
3142
  let installation:
3134
3143
  | {