pi-cc-extensions 0.8.48 → 0.8.49
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.
- package/README.en.md +17 -36
- package/README.md +13 -12
- package/assets/readme/welcome.webp +0 -0
- package/extensions/config/config.ts +245 -0
- package/extensions/config/panel.ts +469 -0
- package/extensions/{compact-thinking.ts → feature/compact-thinking.ts} +43 -6
- package/extensions/{markdown-enhance.ts → feature/markdown-enhance.ts} +88 -62
- package/extensions/{pi-startup-header.ts → feature/pi-startup-header.ts} +129 -56
- package/extensions/index.ts +9 -9
- package/extensions/renderer/index.ts +890 -0
- package/extensions/renderer/mouse-interaction.ts +1344 -0
- package/extensions/renderer/show-more-hint.ts +40 -0
- package/extensions/{tool-diff → renderer/tool-diff}/diff-renderer.ts +3 -2
- package/extensions/{tool-diff → renderer/tool-diff}/line-width-safety.ts +2 -1
- package/extensions/{tool-grouping.ts → renderer/tool-grouping.ts} +71 -10
- package/extensions/renderer/tool-result.ts +717 -0
- package/package.json +1 -1
- package/themes/cc-dark.json +2 -2
- package/themes/cc-light.json +2 -2
- package/extensions/claude-code-style.ts +0 -3348
- /package/extensions/{agent-autocomplete.ts → feature/agent-autocomplete.ts} +0 -0
- /package/extensions/{context.ts → feature/context.ts} +0 -0
- /package/extensions/{pi-aliases.ts → feature/pi-aliases.ts} +0 -0
- /package/extensions/{session-reference-core.ts → feature/session-reference-core.ts} +0 -0
- /package/extensions/{session-reference.ts → feature/session-reference.ts} +0 -0
- /package/extensions/{working-message.ts → feature/working-message.ts} +0 -0
- /package/extensions/{tool-diff → renderer/tool-diff}/ATTRIBUTION.md +0 -0
- /package/extensions/{tool-diff → renderer/tool-diff}/ansi-utils.ts +0 -0
- /package/extensions/{tool-diff → renderer/tool-diff}/diff-presentation.ts +0 -0
- /package/extensions/{tool-diff → renderer/tool-diff}/index.ts +0 -0
- /package/extensions/{tool-diff → renderer/tool-diff}/render-utils.ts +0 -0
- /package/extensions/{tool-diff → renderer/tool-diff}/shiki-highlight.ts +0 -0
- /package/extensions/{tool-diff → renderer/tool-diff}/types.ts +0 -0
- /package/extensions/{tool-diff → renderer/tool-diff}/write-display-utils.ts +0 -0
- /package/extensions/{tool-diff → renderer/tool-diff}/write-execution.ts +0 -0
- /package/extensions/{fullscreen-detect.ts → utils/fullscreen-detect.ts} +0 -0
- /package/extensions/{tool-loading-icon.ts → utils/tool-loading-icon.ts} +0 -0
- /package/extensions/{tool-result-sanitize.ts → utils/tool-result-sanitize.ts} +0 -0
|
@@ -0,0 +1,1344 @@
|
|
|
1
|
+
import { getKeybindings, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
2
|
+
import { hasActiveTextPreview, showTextPreview } from "../feature/context.ts";
|
|
3
|
+
import { ToolGroupComponent } from "./tool-grouping.ts";
|
|
4
|
+
import { config } from "../config/config.ts";
|
|
5
|
+
import { isLazyProxyTui } from "../utils/fullscreen-detect.ts";
|
|
6
|
+
import { setToolTuiFullscreen } from "./show-more-hint.ts";
|
|
7
|
+
import {
|
|
8
|
+
type ExpandedToolIoView,
|
|
9
|
+
getActiveIoViewFrame,
|
|
10
|
+
invalidateIoView,
|
|
11
|
+
isExpandedToolIoView,
|
|
12
|
+
type IoViewFrameState,
|
|
13
|
+
setActiveIoViewFrame,
|
|
14
|
+
type ToolIoSection,
|
|
15
|
+
} from "./tool-result.ts";
|
|
16
|
+
|
|
17
|
+
type SgrMousePacket = {
|
|
18
|
+
code: number;
|
|
19
|
+
col: number;
|
|
20
|
+
row: number;
|
|
21
|
+
final: "M" | "m";
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
type FrameToolRender = {
|
|
25
|
+
component: any;
|
|
26
|
+
lines: string[];
|
|
27
|
+
contentBoxLines: number;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** Final painted placement of one outermost tool/group row after parent layout. */
|
|
31
|
+
type FrameToolPlacement = {
|
|
32
|
+
component: any;
|
|
33
|
+
componentRow: number;
|
|
34
|
+
lineIndex: number;
|
|
35
|
+
/** Marker-stripped final line text as painted after parent layout. */
|
|
36
|
+
finalLine: string;
|
|
37
|
+
view?: ExpandedToolIoView;
|
|
38
|
+
section?: ToolIoSection;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
type InteractionRegion = {
|
|
42
|
+
kind: "collapsed-hint" | "expanded-card" | "show-more" | "scroll-bottom";
|
|
43
|
+
row: number;
|
|
44
|
+
startCol: number;
|
|
45
|
+
endCol: number;
|
|
46
|
+
component?: any;
|
|
47
|
+
view?: ExpandedToolIoView;
|
|
48
|
+
section?: ToolIoSection;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
type InteractionFrame = { regions: InteractionRegion[] };
|
|
52
|
+
|
|
53
|
+
/** Zero-width APC row marker (like pi CURSOR_MARKER); stripped before terminal output. */
|
|
54
|
+
const TOOL_FRAME_MARKER_RE = /_cc:t(\d+):(\d+)/g;
|
|
55
|
+
const TOOL_VIEW_MARKER_RE = /_cc:v(\d+):([io])/g;
|
|
56
|
+
const toolFrameMarker = (id: number, row: number) => `_cc:t${id}:${row}`;
|
|
57
|
+
|
|
58
|
+
const TOOL_MOUSE_WIDGET_KEY = "ccstyle-tool-mouse";
|
|
59
|
+
const TOOL_MOUSE_MOTION_ENABLE = "\x1b[?1003h\x1b[?1006h";
|
|
60
|
+
const TOOL_MOUSE_MOTION_DISABLE = "\x1b[?1003l";
|
|
61
|
+
const FULLSCREEN_MOTION_ENABLED = Symbol("ccstyle.fullscreen-motion-enabled");
|
|
62
|
+
const TOOL_HOVER_STATE_KEY = Symbol.for("pi.ccstyle.tool-hover-state");
|
|
63
|
+
const TOOL_MOUSE_OWNER_KEY = Symbol.for("pi.ccstyle.tool-mouse-owner");
|
|
64
|
+
const DEFAULT_TOOL_MOUSE_OWNER = {};
|
|
65
|
+
export const TOOL_MOUSE_DISABLE = "\x1b[?1006l\x1b[?1003l\x1b[?1000l";
|
|
66
|
+
const ZENTUI_PAGE_UP_INPUT = /^\x1b\[5;9(?::[12])?~$|^\x1b\[57421;9(?::[12])?u$|^\x1b\[1;6A$/;
|
|
67
|
+
const ZENTUI_PAGE_DOWN_INPUT = /^\x1b\[6;9(?::[12])?~$|^\x1b\[57422;9(?::[12])?u$|^\x1b\[1;6B$/;
|
|
68
|
+
const SCROLL_BOTTOM_SHORTCUT = "ctrl+end";
|
|
69
|
+
export let toolMouseTui: any = null;
|
|
70
|
+
let toolMouseUi: any = null;
|
|
71
|
+
let toolMouseInputUnsubscribe: (() => void) | null = null;
|
|
72
|
+
let toolMouseRenderPatchTui: any = null;
|
|
73
|
+
let toolMouseRenderPatchOriginal: ((...args: any[]) => any) | null = null;
|
|
74
|
+
let toolMouseRenderPatchWrapper: ((...args: any[]) => any) | null = null;
|
|
75
|
+
let toolMouseRenderPatchState: { active: boolean } | null = null;
|
|
76
|
+
let toolMouseRawWrite: ((data: string) => unknown) | null = null;
|
|
77
|
+
let toolMouseInstallationOwner: object | null = null;
|
|
78
|
+
let fullscreenMotionTerminal: any = null;
|
|
79
|
+
let ownsFullscreenMotion = false;
|
|
80
|
+
let scrollButtonVisible = false;
|
|
81
|
+
let scrollButtonHovered = false;
|
|
82
|
+
let scrollButtonWidget: any = null;
|
|
83
|
+
let scrollButtonSyncScheduled = false;
|
|
84
|
+
let sessionRenderTimer: ReturnType<typeof setTimeout> | null = null;
|
|
85
|
+
type SharedToolHoverState = { toolCallId: string | null };
|
|
86
|
+
function sharedToolHoverState(): SharedToolHoverState {
|
|
87
|
+
const host = globalThis as any;
|
|
88
|
+
return (host[TOOL_HOVER_STATE_KEY] ??= { toolCallId: null });
|
|
89
|
+
}
|
|
90
|
+
export let hoveredToolCallId: string | null = sharedToolHoverState().toolCallId;
|
|
91
|
+
function setHoveredToolCallId(toolCallId: string | null): void {
|
|
92
|
+
hoveredToolCallId = toolCallId;
|
|
93
|
+
sharedToolHoverState().toolCallId = toolCallId;
|
|
94
|
+
}
|
|
95
|
+
export function isToolCallHovered(toolCallId: string | null | undefined): boolean {
|
|
96
|
+
return Boolean(toolCallId && sharedToolHoverState().toolCallId === toolCallId);
|
|
97
|
+
}
|
|
98
|
+
let hoveredToolGroup: ToolGroupComponent | null = null;
|
|
99
|
+
let latestInteractionFrame: InteractionFrame = { regions: [] };
|
|
100
|
+
|
|
101
|
+
function parseSgrMousePackets(data: string): SgrMousePacket[] | null {
|
|
102
|
+
const pattern = /\x1b\[<(\d+);(\d+);(\d+)([Mm])/g;
|
|
103
|
+
const packets: SgrMousePacket[] = [];
|
|
104
|
+
let offset = 0;
|
|
105
|
+
|
|
106
|
+
for (const match of data.matchAll(pattern)) {
|
|
107
|
+
if (match.index !== offset) return null;
|
|
108
|
+
offset = match.index + match[0].length;
|
|
109
|
+
packets.push({
|
|
110
|
+
code: Number(match[1]),
|
|
111
|
+
col: Number(match[2]),
|
|
112
|
+
row: Number(match[3]),
|
|
113
|
+
final: match[4] as "M" | "m",
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return packets.length > 0 && offset === data.length ? packets : null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function isSgrLeftPress(packet: SgrMousePacket): boolean {
|
|
121
|
+
const baseButton = packet.code & ~(4 | 8 | 16 | 32);
|
|
122
|
+
return packet.final === "M" && baseButton === 0 && (packet.code & 32) === 0;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function stripTerminalSequencesPreservingLayout(value: string): string {
|
|
126
|
+
return value
|
|
127
|
+
.replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, "")
|
|
128
|
+
.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function stripTerminalSequences(value: string): string {
|
|
132
|
+
return stripTerminalSequencesPreservingLayout(value).replace(/\s+/g, " ").trim();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function isToolExecutionComponent(value: any): boolean {
|
|
136
|
+
return Boolean(
|
|
137
|
+
value &&
|
|
138
|
+
typeof value === "object" &&
|
|
139
|
+
typeof value.toolCallId === "string" &&
|
|
140
|
+
typeof value.setExpanded === "function" &&
|
|
141
|
+
typeof value.render === "function",
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function collectToolComponents(component: any, tools: any[], seen = new Set<any>()): void {
|
|
146
|
+
if (!component || typeof component !== "object" || seen.has(component)) return;
|
|
147
|
+
seen.add(component);
|
|
148
|
+
if (isToolExecutionComponent(component)) {
|
|
149
|
+
tools.push(component);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
if (Array.isArray(component.children)) {
|
|
153
|
+
for (const child of component.children) collectToolComponents(child, tools, seen);
|
|
154
|
+
}
|
|
155
|
+
try {
|
|
156
|
+
const mounted = component.getMountedRoots?.();
|
|
157
|
+
if (Array.isArray(mounted)) {
|
|
158
|
+
for (const root of mounted) collectToolComponents(root, tools, seen);
|
|
159
|
+
}
|
|
160
|
+
} catch {
|
|
161
|
+
// renderer 切换中的惰性 Proxy 可能暂时没有 mounted roots。
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function stripToolFrameMarkers(line: string): string {
|
|
166
|
+
return line.replace(TOOL_FRAME_MARKER_RE, "").replace(TOOL_VIEW_MARKER_RE, "");
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function extractToolFramePlacements(
|
|
170
|
+
lines: string[],
|
|
171
|
+
idToComponent: Map<number, any>,
|
|
172
|
+
idToView: Map<number, ExpandedToolIoView>,
|
|
173
|
+
): { lines: string[]; placements: FrameToolPlacement[] } {
|
|
174
|
+
const placements: FrameToolPlacement[] = [];
|
|
175
|
+
const cleaned = lines.map((line, lineIndex) => {
|
|
176
|
+
const toolMatches = [...line.matchAll(TOOL_FRAME_MARKER_RE)];
|
|
177
|
+
const viewMatches = [...line.matchAll(TOOL_VIEW_MARKER_RE)];
|
|
178
|
+
const finalLine = stripToolFrameMarkers(line);
|
|
179
|
+
let view: ExpandedToolIoView | undefined;
|
|
180
|
+
let section: ToolIoSection | undefined;
|
|
181
|
+
for (const match of viewMatches) {
|
|
182
|
+
const candidate = idToView.get(Number(match[1]));
|
|
183
|
+
if (!candidate) continue;
|
|
184
|
+
view = candidate;
|
|
185
|
+
section = match[2] === "i" ? "input" : "output";
|
|
186
|
+
break;
|
|
187
|
+
}
|
|
188
|
+
for (const match of toolMatches) {
|
|
189
|
+
const component = idToComponent.get(Number(match[1]));
|
|
190
|
+
if (!component) continue;
|
|
191
|
+
placements.push({
|
|
192
|
+
component,
|
|
193
|
+
componentRow: Number(match[2]),
|
|
194
|
+
lineIndex,
|
|
195
|
+
finalLine,
|
|
196
|
+
view,
|
|
197
|
+
section,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
return finalLine;
|
|
201
|
+
});
|
|
202
|
+
return { lines: cleaned, placements };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Summary markers used by Pi and ccstyle; unlike the trailing hint, these survive truncation. */
|
|
206
|
+
const COLLAPSED_TOOL_SUMMARY = /^\s*(?:↳|└|⎿|●|✓|✗|…)/;
|
|
207
|
+
|
|
208
|
+
function toolMouseInteractionActive(): boolean {
|
|
209
|
+
if (config.mode === "off") return false;
|
|
210
|
+
if (isLazyProxyTui(toolMouseTui)) return true;
|
|
211
|
+
return true;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function formatShortcut(shortcut: string): string {
|
|
215
|
+
return shortcut
|
|
216
|
+
.split("+")
|
|
217
|
+
.map((part) =>
|
|
218
|
+
part.length <= 1 ? part.toUpperCase() : `${part[0]?.toUpperCase() ?? ""}${part.slice(1)}`,
|
|
219
|
+
)
|
|
220
|
+
.join("+");
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function isScrollBottomInput(data: string): boolean {
|
|
224
|
+
return matchesKey(data, SCROLL_BOTTOM_SHORTCUT);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function wheelDirection(data: string): "up" | "down" | null {
|
|
228
|
+
const packets = parseSgrMousePackets(data);
|
|
229
|
+
for (const packet of packets ?? []) {
|
|
230
|
+
if (packet.final !== "M") continue;
|
|
231
|
+
const baseButton = packet.code & ~(4 | 8 | 16 | 32);
|
|
232
|
+
if (baseButton === 64) return "up";
|
|
233
|
+
if (baseButton === 65) return "down";
|
|
234
|
+
}
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function isScrollNavigationInput(data: string): boolean {
|
|
239
|
+
if (
|
|
240
|
+
matchesKey(data, "pageUp") ||
|
|
241
|
+
matchesKey(data, "pageDown") ||
|
|
242
|
+
ZENTUI_PAGE_UP_INPUT.test(data) ||
|
|
243
|
+
ZENTUI_PAGE_DOWN_INPUT.test(data) ||
|
|
244
|
+
// 官方 fullscreen viewport 的可滚动键(half-page/prompt/top/bottom)。
|
|
245
|
+
[
|
|
246
|
+
"tui.altScreen.pageUp",
|
|
247
|
+
"tui.altScreen.pageDown",
|
|
248
|
+
"tui.altScreen.halfPageUp",
|
|
249
|
+
"tui.altScreen.halfPageDown",
|
|
250
|
+
"tui.altScreen.previousPrompt",
|
|
251
|
+
"tui.altScreen.nextPrompt",
|
|
252
|
+
"tui.altScreen.top",
|
|
253
|
+
"tui.altScreen.bottom",
|
|
254
|
+
].some((key) => getKeybindings().matches(data, key))
|
|
255
|
+
) {
|
|
256
|
+
return true;
|
|
257
|
+
}
|
|
258
|
+
const packets = parseSgrMousePackets(data);
|
|
259
|
+
return Boolean(
|
|
260
|
+
packets?.some((packet) => {
|
|
261
|
+
const baseButton = packet.code & ~(4 | 8 | 16 | 32);
|
|
262
|
+
return packet.final === "M" && (baseButton === 64 || baseButton === 65);
|
|
263
|
+
}),
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function isAtTranscriptBottom(tui: any): boolean {
|
|
268
|
+
// 惰性 Proxy fullscreen:官方 viewport 以 isFollowingOutput 判定是否在底部。
|
|
269
|
+
if (fullscreenLazyTui(tui)) return isFullscreenAtBottom(tui);
|
|
270
|
+
return true;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function hideScrollButton(tui: any): void {
|
|
274
|
+
const changed = scrollButtonVisible || scrollButtonHovered;
|
|
275
|
+
scrollButtonVisible = false;
|
|
276
|
+
scrollButtonHovered = false;
|
|
277
|
+
if (changed) tui.requestRender?.();
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function scheduleScrollButtonSync(tui: any, data: string): void {
|
|
281
|
+
if (
|
|
282
|
+
!fullscreenLazyTui(tui) ||
|
|
283
|
+
!toolMouseInteractionActive() ||
|
|
284
|
+
!isScrollNavigationInput(data) ||
|
|
285
|
+
scrollButtonSyncScheduled
|
|
286
|
+
)
|
|
287
|
+
return;
|
|
288
|
+
scrollButtonSyncScheduled = true;
|
|
289
|
+
const previousLines = tui.previousLines;
|
|
290
|
+
const check = (attempt: number) => {
|
|
291
|
+
scrollButtonSyncScheduled = false;
|
|
292
|
+
if (toolMouseTui !== tui) return;
|
|
293
|
+
// Pi renders on its own frame timer. Inspect the resulting viewport before
|
|
294
|
+
// showing the button so empty or non-scrollable transcripts never flash it.
|
|
295
|
+
const rendered = tui.previousLines !== previousLines;
|
|
296
|
+
// fullscreen 下 isFollowingOutput 是即时状态,无需等待官方帧渲染。
|
|
297
|
+
if (!rendered && attempt < 4 && !fullscreenLazyTui(tui)) {
|
|
298
|
+
scrollButtonSyncScheduled = true;
|
|
299
|
+
const timer = setTimeout(() => check(attempt + 1), 16);
|
|
300
|
+
if (typeof timer === "object" && timer !== null && "unref" in timer) {
|
|
301
|
+
(timer as { unref: () => void }).unref();
|
|
302
|
+
}
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
const nextVisible = !isAtTranscriptBottom(tui);
|
|
306
|
+
if (nextVisible !== scrollButtonVisible) {
|
|
307
|
+
scrollButtonVisible = nextVisible;
|
|
308
|
+
tui.requestRender?.();
|
|
309
|
+
}
|
|
310
|
+
};
|
|
311
|
+
process.nextTick(() => check(0));
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function updateScrollButtonFromInput(tui: any, data: string): void {
|
|
315
|
+
if (!fullscreenLazyTui(tui) || !toolMouseInteractionActive()) return;
|
|
316
|
+
if (matchesKey(data, "enter") || matchesKey(data, "return")) hideScrollButton(tui);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function renderComponentTree(component: any, width: number): string[] {
|
|
320
|
+
if (!component || typeof component !== "object") return [];
|
|
321
|
+
try {
|
|
322
|
+
const lines = component.render?.(width);
|
|
323
|
+
if (Array.isArray(lines) && lines.length > 0) return lines;
|
|
324
|
+
} catch {
|
|
325
|
+
// Fall through to children for hidden container renderers.
|
|
326
|
+
}
|
|
327
|
+
if (!Array.isArray(component.children)) return [];
|
|
328
|
+
return component.children.flatMap((child: any) => renderComponentTree(child, width));
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
let hoveredToolIoView: ExpandedToolIoView | null = null;
|
|
332
|
+
let hoveredToolIoSection: ToolIoSection | null = null;
|
|
333
|
+
|
|
334
|
+
function collapsedHintHitbox(line: string): { startCol: number; endCol: number } | null {
|
|
335
|
+
const plain = stripTerminalSequencesPreservingLayout(line);
|
|
336
|
+
const match = /(\([^()\n]* \/ click\)|click to show more|to show more)(?=\)?\s*$)/.exec(plain);
|
|
337
|
+
if (!match?.[1]) return null;
|
|
338
|
+
const startCol = visibleWidth(plain.slice(0, match.index)) + 1;
|
|
339
|
+
return { startCol, endCol: startCol + visibleWidth(match[1]) - 1 };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function interactionRegionAt(packet: SgrMousePacket): InteractionRegion | null {
|
|
343
|
+
const matches = latestInteractionFrame.regions.filter(
|
|
344
|
+
(region) =>
|
|
345
|
+
region.row === packet.row && packet.col >= region.startCol && packet.col <= region.endCol,
|
|
346
|
+
);
|
|
347
|
+
return (
|
|
348
|
+
matches.find((region) => region.kind === "show-more") ??
|
|
349
|
+
matches.find((region) => region.kind === "scroll-bottom") ??
|
|
350
|
+
matches.find((region) => region.kind === "collapsed-hint") ??
|
|
351
|
+
matches.find((region) => region.kind === "expanded-card") ??
|
|
352
|
+
null
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function tryOpenToolIoShowMore(region: InteractionRegion): boolean {
|
|
357
|
+
const ioView = region.view;
|
|
358
|
+
const section = region.section;
|
|
359
|
+
if (!ioView || !section) return false;
|
|
360
|
+
const ui = toolMouseUi;
|
|
361
|
+
if (!ui || typeof ui.custom !== "function") {
|
|
362
|
+
ui?.notify?.("Full preview requires TUI custom UI", "warning");
|
|
363
|
+
return true;
|
|
364
|
+
}
|
|
365
|
+
const title = section === "input" ? "Tool Input" : "Tool Output";
|
|
366
|
+
const content = section === "input" ? ioView.getInputBody() : ioView.getOutputBody();
|
|
367
|
+
void showTextPreview({ ui }, title, content || "(empty)");
|
|
368
|
+
return true;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export function setHoveredToolIo(
|
|
372
|
+
view: ExpandedToolIoView | null,
|
|
373
|
+
section: ToolIoSection | null,
|
|
374
|
+
): boolean {
|
|
375
|
+
// resultRendererComponent 可能是 Text/第三方 renderer;reload 后也可能残留旧实例。
|
|
376
|
+
const nextView = isExpandedToolIoView(view) ? view : null;
|
|
377
|
+
const nextSection = nextView ? section : null;
|
|
378
|
+
if (nextView === hoveredToolIoView && nextSection === hoveredToolIoSection) return false;
|
|
379
|
+
if (isExpandedToolIoView(hoveredToolIoView)) {
|
|
380
|
+
hoveredToolIoView.setHoveredSection(null);
|
|
381
|
+
invalidateIoView(hoveredToolIoView);
|
|
382
|
+
}
|
|
383
|
+
hoveredToolIoView = nextView;
|
|
384
|
+
hoveredToolIoSection = nextSection;
|
|
385
|
+
if (nextView) {
|
|
386
|
+
nextView.setHoveredSection(nextSection);
|
|
387
|
+
invalidateIoView(nextView);
|
|
388
|
+
}
|
|
389
|
+
return true;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
export function setHoveredToolGroup(group: ToolGroupComponent | null): boolean {
|
|
393
|
+
if (group === hoveredToolGroup) return false;
|
|
394
|
+
hoveredToolGroup?.setHintHovered(false);
|
|
395
|
+
hoveredToolGroup = group;
|
|
396
|
+
group?.setHintHovered(true);
|
|
397
|
+
return true;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function updateToolSummaryHover(tui: any, packet: SgrMousePacket): void {
|
|
401
|
+
if ((packet.code & 32) === 0 || packet.final !== "M") return;
|
|
402
|
+
const region = interactionRegionAt(packet);
|
|
403
|
+
const nextScrollButtonHovered = region?.kind === "scroll-bottom";
|
|
404
|
+
const scrollButtonChanged = nextScrollButtonHovered !== scrollButtonHovered;
|
|
405
|
+
scrollButtonHovered = nextScrollButtonHovered;
|
|
406
|
+
const component = region?.component;
|
|
407
|
+
const nextToolCallId = region?.kind === "collapsed-hint" ? (component?.toolCallId ?? null) : null;
|
|
408
|
+
const nextGroup = component instanceof ToolGroupComponent ? component : null;
|
|
409
|
+
const nextIoView = region?.kind === "show-more" ? (region.view ?? null) : null;
|
|
410
|
+
const nextIoSection = region?.kind === "show-more" ? (region.section ?? null) : null;
|
|
411
|
+
const changed = nextToolCallId !== sharedToolHoverState().toolCallId;
|
|
412
|
+
setHoveredToolCallId(nextToolCallId);
|
|
413
|
+
if (
|
|
414
|
+
scrollButtonChanged ||
|
|
415
|
+
setHoveredToolIo(nextIoView, nextIoSection) ||
|
|
416
|
+
setHoveredToolGroup(nextGroup) ||
|
|
417
|
+
changed
|
|
418
|
+
)
|
|
419
|
+
tui.requestRender?.();
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function toggleToolAtMouseClick(tui: any, packet: SgrMousePacket): boolean {
|
|
423
|
+
const region = interactionRegionAt(packet);
|
|
424
|
+
if (!region) return false;
|
|
425
|
+
if (region.kind === "scroll-bottom") return false;
|
|
426
|
+
if (region.kind === "show-more") return tryOpenToolIoShowMore(region);
|
|
427
|
+
const component = region.component;
|
|
428
|
+
if (!component) return false;
|
|
429
|
+
const width = Math.max(1, Number(tui?.terminal?.columns) || 80);
|
|
430
|
+
if (region.kind === "expanded-card") {
|
|
431
|
+
component.setExpanded(false);
|
|
432
|
+
setHoveredToolCallId(null);
|
|
433
|
+
setHoveredToolGroup(null);
|
|
434
|
+
setHoveredToolIo(null, null);
|
|
435
|
+
component.invalidate?.();
|
|
436
|
+
tui.requestRender?.();
|
|
437
|
+
return true;
|
|
438
|
+
}
|
|
439
|
+
component.setExpanded(true);
|
|
440
|
+
setHoveredToolCallId(null);
|
|
441
|
+
setHoveredToolGroup(null);
|
|
442
|
+
setHoveredToolIo(null, null);
|
|
443
|
+
component.invalidate?.();
|
|
444
|
+
tui.requestRender?.();
|
|
445
|
+
return true;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function renderScrollButton(width: number, theme: any): string[] {
|
|
449
|
+
if (!scrollButtonVisible || !fullscreenLazyTui(toolMouseTui)) return [];
|
|
450
|
+
const shortcut = formatShortcut(SCROLL_BOTTOM_SHORTCUT);
|
|
451
|
+
const label = theme.fg(
|
|
452
|
+
scrollButtonHovered ? "text" : "accent",
|
|
453
|
+
`[ ↓ Back to bottom · ${shortcut} ]`,
|
|
454
|
+
);
|
|
455
|
+
const leftPad = Math.max(0, Math.floor((width - visibleWidth(label)) / 2));
|
|
456
|
+
return [`${" ".repeat(leftPad)}${truncateToWidth(label, width, "…")}`];
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** 惰性 Proxy 官方 fullscreen(TuiAltScreen)判定。 */
|
|
460
|
+
function fullscreenLazyTui(tui: any): boolean {
|
|
461
|
+
return isLazyProxyTui(tui) && tui.mode === "fullscreen";
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function officialFullscreenHasAllMotion(): boolean {
|
|
465
|
+
const term = process.env.TERM?.toLowerCase() ?? "";
|
|
466
|
+
return !(
|
|
467
|
+
process.env.TMUX !== undefined ||
|
|
468
|
+
process.env.ZELLIJ !== undefined ||
|
|
469
|
+
process.env.STY !== undefined ||
|
|
470
|
+
term.startsWith("tmux") ||
|
|
471
|
+
term.startsWith("screen")
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* hover 依赖 DECSET 1003。官方 fullscreen 在 multiplexer 下只开 1002,
|
|
477
|
+
* 因此扩展需在每个实际 renderer 上补开(Symbol 经惰性 Proxy 落到当前实例)。
|
|
478
|
+
*/
|
|
479
|
+
function ensureFullscreenToolMouseMotion(tui: any): void {
|
|
480
|
+
setToolTuiFullscreen(fullscreenLazyTui(tui));
|
|
481
|
+
if (!fullscreenLazyTui(tui)) {
|
|
482
|
+
releaseFullscreenToolMouseMotion(tui);
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
// 面板改 scrollStepLines 后,下一帧渲染即同步(restore 仍按 original 恢复)。
|
|
486
|
+
if (typeof tui.wheelScrollLines === "number" && tui.wheelScrollLines !== config.scrollStepLines) {
|
|
487
|
+
tui.wheelScrollLines = config.scrollStepLines;
|
|
488
|
+
}
|
|
489
|
+
if (
|
|
490
|
+
!toolMouseInteractionActive() ||
|
|
491
|
+
tui.mouseEnabled === false ||
|
|
492
|
+
tui.altScreenActive === false ||
|
|
493
|
+
tui[FULLSCREEN_MOTION_ENABLED]
|
|
494
|
+
) {
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
try {
|
|
498
|
+
tui.terminal?.write?.(TOOL_MOUSE_MOTION_ENABLE);
|
|
499
|
+
tui[FULLSCREEN_MOTION_ENABLED] = true;
|
|
500
|
+
fullscreenMotionTerminal = tui.terminal;
|
|
501
|
+
ownsFullscreenMotion = !officialFullscreenHasAllMotion();
|
|
502
|
+
} catch {
|
|
503
|
+
// renderer 可能正在切换或终端已经关闭。
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function releaseFullscreenToolMouseMotion(tui?: any): void {
|
|
508
|
+
try {
|
|
509
|
+
if (tui?.[FULLSCREEN_MOTION_ENABLED]) tui[FULLSCREEN_MOTION_ENABLED] = false;
|
|
510
|
+
} catch {
|
|
511
|
+
// 惰性 Proxy 可能已经切到另一个 renderer。
|
|
512
|
+
}
|
|
513
|
+
const terminal = fullscreenMotionTerminal;
|
|
514
|
+
const shouldDisable = ownsFullscreenMotion;
|
|
515
|
+
fullscreenMotionTerminal = null;
|
|
516
|
+
ownsFullscreenMotion = false;
|
|
517
|
+
try {
|
|
518
|
+
if (shouldDisable) terminal?.write?.(TOOL_MOUSE_MOTION_DISABLE);
|
|
519
|
+
} catch {
|
|
520
|
+
// renderer 可能正在切换或终端已经关闭。
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/** 官方 fullscreen:是否已跟随 transcript 底部(按钮隐藏条件)。 */
|
|
525
|
+
function isFullscreenAtBottom(tui: any): boolean {
|
|
526
|
+
const following = tui.isFollowingOutput ?? tui.getPrimaryScrollView?.()?.isFollowingEnd ?? true;
|
|
527
|
+
return Boolean(following);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const FULLSCREEN_VIEWPORT_PATCH = Symbol("ccstyle.fullscreen-viewport-patch");
|
|
531
|
+
const FULLSCREEN_WHEEL_SCROLL_ORIGINAL = Symbol("ccstyle.fullscreen-wheel-scroll-original");
|
|
532
|
+
|
|
533
|
+
/** 布局树点查询:返回 (x,y) 处最深含行 leaf box(屏幕行 → 组件局部行)。 */
|
|
534
|
+
function fullscreenLeafAt(
|
|
535
|
+
layout: any,
|
|
536
|
+
x: number,
|
|
537
|
+
y: number,
|
|
538
|
+
): { box: any; localRow: number } | null {
|
|
539
|
+
const root = layout?.root;
|
|
540
|
+
if (!root) return null;
|
|
541
|
+
let best: { box: any; localRow: number } | null = null;
|
|
542
|
+
let bestDepth = -1;
|
|
543
|
+
const visit = (box: any, depth: number) => {
|
|
544
|
+
if (!box) return;
|
|
545
|
+
const clip = box.clip;
|
|
546
|
+
if (!clip || x < clip.x || x >= clip.x + clip.width || y < clip.y || y >= clip.y + clip.height)
|
|
547
|
+
return;
|
|
548
|
+
const isLeaf = !Array.isArray(box.children) || box.children.length === 0;
|
|
549
|
+
if (
|
|
550
|
+
isLeaf &&
|
|
551
|
+
y >= box.rect.y &&
|
|
552
|
+
y < box.rect.y + Math.max(1, box.rect.height) &&
|
|
553
|
+
depth > bestDepth
|
|
554
|
+
) {
|
|
555
|
+
best = { box, localRow: Math.max(0, y - box.rect.y) };
|
|
556
|
+
bestDepth = depth;
|
|
557
|
+
}
|
|
558
|
+
for (const child of box.children ?? []) visit(child, depth + 1);
|
|
559
|
+
};
|
|
560
|
+
visit(root, 0);
|
|
561
|
+
return best;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/** leaf 自身不带 scrollView;内容宽度由最近的 scroll 祖先决定。 */
|
|
565
|
+
function fullscreenContentWidth(box: any, terminalWidth: number): number {
|
|
566
|
+
for (let current = box; current; current = current.parent) {
|
|
567
|
+
if (typeof current.scrollView?.getContentWidth === "function") {
|
|
568
|
+
return Math.max(1, current.scrollView.getContentWidth(terminalWidth));
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
return terminalWidth;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/** 点击列是否为官方滚动条列(放行官方拖动)。 */
|
|
575
|
+
function isScrollbarColumnAt(layout: any, x: number): boolean {
|
|
576
|
+
let hit = false;
|
|
577
|
+
const visit = (box: any) => {
|
|
578
|
+
if (hit || !box) return;
|
|
579
|
+
if (box.scrollView?.isScrollbarVisible && x === box.rect.x + box.rect.width - 1) {
|
|
580
|
+
hit = true;
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
for (const child of box.children ?? []) visit(child);
|
|
584
|
+
};
|
|
585
|
+
visit(layout?.root);
|
|
586
|
+
return hit;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
type ComponentRowHit = {
|
|
590
|
+
component: any;
|
|
591
|
+
row: number;
|
|
592
|
+
/** 内部工具命中时所属的展开 group;普通卡点击仍折叠整个 group。 */
|
|
593
|
+
group?: ToolGroupComponent;
|
|
594
|
+
};
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* 布局 leaf box 的组件通常是容器(documentContainer/dock 容器等),工具卡与
|
|
598
|
+
* widget 在其 children 内。按局部行遍历组件树,定位实际命中的子组件。
|
|
599
|
+
*/
|
|
600
|
+
function componentAtLocalRow(
|
|
601
|
+
component: any,
|
|
602
|
+
localRow: number,
|
|
603
|
+
width: number,
|
|
604
|
+
): ComponentRowHit | null {
|
|
605
|
+
if (component instanceof ToolGroupComponent) {
|
|
606
|
+
// 展开的 group:头两行(空行 + 头行)归 group,其余行映射到内部工具。
|
|
607
|
+
const child = component.childAtRow(localRow, width);
|
|
608
|
+
return child ? { ...child, group: component } : { component, row: localRow };
|
|
609
|
+
}
|
|
610
|
+
if (isToolExecutionComponent(component)) {
|
|
611
|
+
return { component, row: localRow };
|
|
612
|
+
}
|
|
613
|
+
if (component === scrollButtonWidget) {
|
|
614
|
+
return { component, row: localRow };
|
|
615
|
+
}
|
|
616
|
+
if (!Array.isArray(component.children)) return null;
|
|
617
|
+
let offset = 0;
|
|
618
|
+
for (const child of component.children) {
|
|
619
|
+
let lines: string[] = [];
|
|
620
|
+
try {
|
|
621
|
+
const rendered = child.render?.(width);
|
|
622
|
+
if (Array.isArray(rendered)) lines = rendered.map((line) => String(line));
|
|
623
|
+
} catch {
|
|
624
|
+
lines = [];
|
|
625
|
+
}
|
|
626
|
+
if (localRow < offset + lines.length) {
|
|
627
|
+
return (
|
|
628
|
+
componentAtLocalRow(child, localRow - offset, width) ?? {
|
|
629
|
+
component: child,
|
|
630
|
+
row: localRow - offset,
|
|
631
|
+
}
|
|
632
|
+
);
|
|
633
|
+
}
|
|
634
|
+
offset += lines.length;
|
|
635
|
+
}
|
|
636
|
+
return null;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/** fullscreen single-expand:group 作为整体,不继续递归其内部工具。 */
|
|
640
|
+
function collectFullscreenToolCards(component: any, out: any[], seen = new Set<any>()): void {
|
|
641
|
+
if (!component || typeof component !== "object" || seen.has(component)) return;
|
|
642
|
+
seen.add(component);
|
|
643
|
+
if (isToolExecutionComponent(component) || component instanceof ToolGroupComponent) {
|
|
644
|
+
out.push(component);
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
if (!Array.isArray(component.children)) return;
|
|
648
|
+
for (const child of component.children) collectFullscreenToolCards(child, out, seen);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* 官方 fullscreen 工具卡点击:collapsed hint 点击展开
|
|
653
|
+
* (有且仅保持一个展开:展开前收起其他工具卡),expanded 整卡二次点击收起,
|
|
654
|
+
* 截断头 show-more 打开全量预览;回到底部按钮 scrollToBottom。
|
|
655
|
+
* 滚动条列、含 OSC8 链接行、非工具区域放行官方。
|
|
656
|
+
*/
|
|
657
|
+
function handleFullscreenToolClick(tui: any, packet: SgrMousePacket): boolean {
|
|
658
|
+
const layout = tui.currentLayout;
|
|
659
|
+
if (!layout?.root) return false;
|
|
660
|
+
// 官方事件坐标 0-based;SGR packet 1-based。
|
|
661
|
+
const x = packet.col - 1;
|
|
662
|
+
const y = packet.row - 1;
|
|
663
|
+
if (isScrollbarColumnAt(layout, x)) return false;
|
|
664
|
+
const hit = fullscreenLeafAt(layout, x, y);
|
|
665
|
+
if (!hit) return false;
|
|
666
|
+
const width = Math.max(1, Number(tui.terminal?.columns) || 80);
|
|
667
|
+
// 布局树用 scroll 的 contentWidth 渲染内容(滚动条占用时 = width-1);
|
|
668
|
+
// 行号定位必须用同一宽度,否则换行差异导致组件行错位。
|
|
669
|
+
const contentWidth = fullscreenContentWidth(hit.box, width);
|
|
670
|
+
const target = componentAtLocalRow(hit.box.component, hit.localRow, contentWidth);
|
|
671
|
+
if (!target) return false;
|
|
672
|
+
const component = target.component;
|
|
673
|
+
const card = target.group ?? component;
|
|
674
|
+
// 回到底部按钮:按组件引用命中,不依赖渲染行缓存。
|
|
675
|
+
if (scrollButtonVisible && component === scrollButtonWidget) {
|
|
676
|
+
tui.scrollToBottom?.();
|
|
677
|
+
hideScrollButton(tui);
|
|
678
|
+
return true;
|
|
679
|
+
}
|
|
680
|
+
const line = hit.box.lines?.[hit.localRow];
|
|
681
|
+
if (typeof line !== "string" || /\x1b]8;[^;]*;/.test(line)) return false;
|
|
682
|
+
const isTool = isToolExecutionComponent(component);
|
|
683
|
+
const isGroup = component instanceof ToolGroupComponent;
|
|
684
|
+
if (!isTool && !isGroup) return false;
|
|
685
|
+
if (!component.expanded) {
|
|
686
|
+
// collapsed 仅按钮文本可展开,不能把同一行正文/留白变成点击区。
|
|
687
|
+
const hint = collapsedHintHitbox(line);
|
|
688
|
+
if (!hint || packet.col < hint.startCol || packet.col > hint.endCol) return false;
|
|
689
|
+
// single-expand:展开前收起其他已展开工具卡/group。
|
|
690
|
+
const others: any[] = [];
|
|
691
|
+
collectFullscreenToolCards(hit.box.component, others);
|
|
692
|
+
for (const other of others) {
|
|
693
|
+
if (other !== component && other.expanded) {
|
|
694
|
+
other.setExpanded(false);
|
|
695
|
+
other.invalidate?.();
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
component.setExpanded(true);
|
|
699
|
+
} else {
|
|
700
|
+
// 普通工具截断头 show-more:打开全量预览(不收起)。
|
|
701
|
+
const view = isTool ? component.resultRendererComponent : null;
|
|
702
|
+
if (isExpandedToolIoView(view)) {
|
|
703
|
+
const plain = stripTerminalSequencesPreservingLayout(line);
|
|
704
|
+
const section = view.matchShowMoreLine(plain);
|
|
705
|
+
if (section) {
|
|
706
|
+
const box = view.showMoreHitbox(plain);
|
|
707
|
+
if (box && x + 1 >= box.startCol && x + 1 <= box.endCol) {
|
|
708
|
+
return tryOpenToolIoShowMore({
|
|
709
|
+
kind: "show-more",
|
|
710
|
+
row: 0,
|
|
711
|
+
startCol: box.startCol,
|
|
712
|
+
endCol: box.endCol,
|
|
713
|
+
component,
|
|
714
|
+
view,
|
|
715
|
+
section,
|
|
716
|
+
});
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
// 整卡二次点击:内部工具仍归所属 group,保持整体展开/收起语义。
|
|
721
|
+
card.setExpanded(false);
|
|
722
|
+
}
|
|
723
|
+
// 点击后清 hover 高亮。
|
|
724
|
+
setHoveredToolCallId(null);
|
|
725
|
+
setHoveredToolGroup(null);
|
|
726
|
+
setHoveredToolIo(null, null);
|
|
727
|
+
card.invalidate?.();
|
|
728
|
+
tui.requestRender?.();
|
|
729
|
+
return true;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/** hover 与点击共用组件定位;同一布局下按容器/宽度/行缓存,避免 motion 重复渲染。 */
|
|
733
|
+
let fullscreenHoverCacheLayout: unknown = null;
|
|
734
|
+
let fullscreenHoverComponentCache = new WeakMap<object, Map<string, ComponentRowHit | null>>();
|
|
735
|
+
|
|
736
|
+
function cachedFullscreenComponentAtRow(
|
|
737
|
+
layout: any,
|
|
738
|
+
container: any,
|
|
739
|
+
row: number,
|
|
740
|
+
width: number,
|
|
741
|
+
): ComponentRowHit | null {
|
|
742
|
+
if (!container || typeof container !== "object") return null;
|
|
743
|
+
if (fullscreenHoverCacheLayout !== layout) {
|
|
744
|
+
fullscreenHoverCacheLayout = layout;
|
|
745
|
+
fullscreenHoverComponentCache = new WeakMap();
|
|
746
|
+
}
|
|
747
|
+
let rows = fullscreenHoverComponentCache.get(container);
|
|
748
|
+
if (!rows) {
|
|
749
|
+
rows = new Map();
|
|
750
|
+
fullscreenHoverComponentCache.set(container, rows);
|
|
751
|
+
}
|
|
752
|
+
const key = `${width}:${row}`;
|
|
753
|
+
if (rows.has(key)) return rows.get(key) ?? null;
|
|
754
|
+
const hit = componentAtLocalRow(container, row, width);
|
|
755
|
+
rows.set(key, hit);
|
|
756
|
+
return hit;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/** fullscreen 鼠标悬停目标。 */
|
|
760
|
+
type FullscreenHoverTarget =
|
|
761
|
+
| { kind: "button" }
|
|
762
|
+
| { kind: "group"; component: ToolGroupComponent }
|
|
763
|
+
| {
|
|
764
|
+
kind: "tool";
|
|
765
|
+
component: any;
|
|
766
|
+
view: ExpandedToolIoView | null;
|
|
767
|
+
section: ToolIoSection | null;
|
|
768
|
+
};
|
|
769
|
+
|
|
770
|
+
/**
|
|
771
|
+
* fullscreen 悬停高亮:collapsed 卡 [click to show more] hint、
|
|
772
|
+
* expanded 卡截断头 show-more、回到底部按钮。motion 不 consume,官方链照常。
|
|
773
|
+
*/
|
|
774
|
+
function handleFullscreenToolHover(tui: any, packet: SgrMousePacket): void {
|
|
775
|
+
if (packet.final !== "M") return;
|
|
776
|
+
const layout = tui.currentLayout;
|
|
777
|
+
if (!layout?.root) return;
|
|
778
|
+
const x = packet.col - 1;
|
|
779
|
+
const y = packet.row - 1;
|
|
780
|
+
let target: FullscreenHoverTarget | null = null;
|
|
781
|
+
const hit = fullscreenLeafAt(layout, x, y);
|
|
782
|
+
if (hit) {
|
|
783
|
+
const line = hit.box.lines?.[hit.localRow];
|
|
784
|
+
// 回到底部按钮:渲染行文本 + 列区间识别(零组件树开销)。
|
|
785
|
+
if (typeof line === "string" && scrollButtonVisible && line.includes("[ ↓")) {
|
|
786
|
+
const plain = stripTerminalSequencesPreservingLayout(line);
|
|
787
|
+
const idx = plain.indexOf("[ ↓");
|
|
788
|
+
if (idx >= 0 && x >= idx && x <= idx + plain.length - 1) {
|
|
789
|
+
target = { kind: "button" };
|
|
790
|
+
}
|
|
791
|
+
} else if (typeof line === "string" && !/\x1b]8;/.test(line)) {
|
|
792
|
+
const width = Math.max(1, Number(tui.terminal?.columns) || 80);
|
|
793
|
+
const contentWidth = fullscreenContentWidth(hit.box, width);
|
|
794
|
+
// 与点击共用同一定位算法,避免 hover 自建行段与真实组件树错位。
|
|
795
|
+
const componentHit = cachedFullscreenComponentAtRow(
|
|
796
|
+
layout,
|
|
797
|
+
hit.box.component,
|
|
798
|
+
hit.localRow,
|
|
799
|
+
contentWidth,
|
|
800
|
+
);
|
|
801
|
+
const component = componentHit?.component;
|
|
802
|
+
const hintBox = collapsedHintHitbox(line);
|
|
803
|
+
const overHint = Boolean(
|
|
804
|
+
hintBox && packet.col >= hintBox.startCol && packet.col <= hintBox.endCol,
|
|
805
|
+
);
|
|
806
|
+
if (component instanceof ToolGroupComponent) {
|
|
807
|
+
if (overHint) target = { kind: "group", component };
|
|
808
|
+
} else if (isToolExecutionComponent(component)) {
|
|
809
|
+
let view: ExpandedToolIoView | null = null;
|
|
810
|
+
let section: ToolIoSection | null = null;
|
|
811
|
+
if (component.expanded) {
|
|
812
|
+
const resultView = component.resultRendererComponent;
|
|
813
|
+
if (isExpandedToolIoView(resultView)) {
|
|
814
|
+
view = resultView;
|
|
815
|
+
const plain = stripTerminalSequencesPreservingLayout(line);
|
|
816
|
+
const candidate = view.matchShowMoreLine(plain);
|
|
817
|
+
if (candidate) {
|
|
818
|
+
const box = view.showMoreHitbox(plain);
|
|
819
|
+
if (box && x + 1 >= box.startCol && x + 1 <= box.endCol) {
|
|
820
|
+
section = candidate;
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
target = { kind: "tool", component, view, section };
|
|
825
|
+
} else if (overHint) {
|
|
826
|
+
target = { kind: "tool", component, view, section };
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
applyFullscreenHover(tui, target);
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
/** 悬停状态变化才触发渲染(motion 事件密集,状态不变跳过)。 */
|
|
835
|
+
function applyFullscreenHover(tui: any, target: FullscreenHoverTarget | null): void {
|
|
836
|
+
let changed = false;
|
|
837
|
+
const nextCallId =
|
|
838
|
+
target?.kind === "tool" && !target.component.expanded
|
|
839
|
+
? (target.component.toolCallId ?? null)
|
|
840
|
+
: null;
|
|
841
|
+
if (nextCallId !== sharedToolHoverState().toolCallId) {
|
|
842
|
+
setHoveredToolCallId(nextCallId);
|
|
843
|
+
changed = true;
|
|
844
|
+
}
|
|
845
|
+
const nextGroup = target?.kind === "group" ? target.component : null;
|
|
846
|
+
if (setHoveredToolGroup(nextGroup)) changed = true;
|
|
847
|
+
const nextView = target?.kind === "tool" ? target.view : null;
|
|
848
|
+
const nextSection = target?.kind === "tool" ? target.section : null;
|
|
849
|
+
if (setHoveredToolIo(nextView, nextSection)) changed = true;
|
|
850
|
+
const nextButton = target?.kind === "button";
|
|
851
|
+
if (nextButton !== scrollButtonHovered) {
|
|
852
|
+
scrollButtonHovered = nextButton;
|
|
853
|
+
changed = true;
|
|
854
|
+
}
|
|
855
|
+
if (changed) tui.requestRender?.();
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
/**
|
|
859
|
+
* 实例级包装 TuiAltScreen.handleViewportInput(惰性 Proxy 安全):
|
|
860
|
+
* 原型方法取 original(绕开 proxy 函数包装),实例 own property 装 wrapper
|
|
861
|
+
* (constructor arrow 动态查找命中)。仅在 fullscreen 且无 overlay 时先消费
|
|
862
|
+
* 工具卡左键点击,其余全部放行官方 selection/scrollbar/URL/键盘链。
|
|
863
|
+
*/
|
|
864
|
+
function patchFullscreenViewportInput(tui: any): void {
|
|
865
|
+
if (tui[FULLSCREEN_VIEWPORT_PATCH] || !isLazyProxyTui(tui)) return;
|
|
866
|
+
const proto = Object.getPrototypeOf(tui);
|
|
867
|
+
const original = proto?.handleViewportInput;
|
|
868
|
+
if (typeof original !== "function") return;
|
|
869
|
+
// 官方原生 routeWheel 已完整处理嵌套 ScrollView;只调整默认步进(config.scrollStepLines)。
|
|
870
|
+
if (typeof tui.wheelScrollLines === "number") {
|
|
871
|
+
tui[FULLSCREEN_WHEEL_SCROLL_ORIGINAL] = tui.wheelScrollLines;
|
|
872
|
+
tui.wheelScrollLines = config.scrollStepLines;
|
|
873
|
+
}
|
|
874
|
+
tui[FULLSCREEN_VIEWPORT_PATCH] = true;
|
|
875
|
+
tui.handleViewportInput = function (this: any, data: string) {
|
|
876
|
+
if (toolMouseInteractionActive() && tui.mode === "fullscreen") {
|
|
877
|
+
// 滚动输入(wheel/pageUp/end 等)后同步回到底部按钮显隐;
|
|
878
|
+
// 官方 viewport 会消费键盘,扩展监听器无法补偿,必须在这里调度。
|
|
879
|
+
scheduleScrollButtonSync(tui, data);
|
|
880
|
+
const packets = parseSgrMousePackets(data);
|
|
881
|
+
// 官方 fullscreen 会消费全部鼠标;文本预览 overlay 活动时放行给 focused
|
|
882
|
+
// custom component,使 [esc] 点击和滚轮可用。
|
|
883
|
+
if (packets && tui.hasOverlay?.() && hasActiveTextPreview()) return undefined;
|
|
884
|
+
if (packets && !tui.hasOverlay?.()) {
|
|
885
|
+
for (const packet of packets) {
|
|
886
|
+
if (isSgrLeftPress(packet) && handleFullscreenToolClick(tui, packet)) {
|
|
887
|
+
return { consume: true };
|
|
888
|
+
}
|
|
889
|
+
if ((packet.code & 32) !== 0 && packet.final === "M") {
|
|
890
|
+
handleFullscreenToolHover(tui, packet);
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
return Reflect.apply(original, this, [data]);
|
|
896
|
+
};
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
function restoreFullscreenViewportInput(tui: any): void {
|
|
900
|
+
if (!tui || !tui[FULLSCREEN_VIEWPORT_PATCH]) return;
|
|
901
|
+
const proto = Object.getPrototypeOf(tui);
|
|
902
|
+
if (typeof proto?.handleViewportInput === "function") {
|
|
903
|
+
tui.handleViewportInput = proto.handleViewportInput;
|
|
904
|
+
}
|
|
905
|
+
const originalWheelScrollLines = tui[FULLSCREEN_WHEEL_SCROLL_ORIGINAL];
|
|
906
|
+
if (typeof originalWheelScrollLines === "number") {
|
|
907
|
+
tui.wheelScrollLines = originalWheelScrollLines;
|
|
908
|
+
tui[FULLSCREEN_WHEEL_SCROLL_ORIGINAL] = undefined;
|
|
909
|
+
}
|
|
910
|
+
tui[FULLSCREEN_VIEWPORT_PATCH] = false;
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
function restoreToolMouseRenderPatch(): void {
|
|
914
|
+
if (toolMouseRenderPatchState) toolMouseRenderPatchState.active = false;
|
|
915
|
+
if (
|
|
916
|
+
toolMouseRenderPatchTui &&
|
|
917
|
+
toolMouseRenderPatchOriginal &&
|
|
918
|
+
toolMouseRenderPatchTui.doRender === toolMouseRenderPatchWrapper
|
|
919
|
+
) {
|
|
920
|
+
toolMouseRenderPatchTui.doRender = toolMouseRenderPatchOriginal;
|
|
921
|
+
}
|
|
922
|
+
toolMouseRenderPatchTui = null;
|
|
923
|
+
toolMouseRenderPatchOriginal = null;
|
|
924
|
+
toolMouseRenderPatchWrapper = null;
|
|
925
|
+
toolMouseRenderPatchState = null;
|
|
926
|
+
toolMouseRawWrite = null;
|
|
927
|
+
latestInteractionFrame = { regions: [] };
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
function buildInteractionFrame(
|
|
931
|
+
tui: any,
|
|
932
|
+
renderedTools: FrameToolRender[],
|
|
933
|
+
placements: FrameToolPlacement[],
|
|
934
|
+
): InteractionFrame {
|
|
935
|
+
const width = Math.max(1, Number(tui?.terminal?.columns) || 80);
|
|
936
|
+
// native: full buffer; map with the post-doRender previousViewportTop.
|
|
937
|
+
const lineIndexToScreenRow = (lineIndex: number) =>
|
|
938
|
+
lineIndex - (Number(tui?.previousViewportTop) || 0) + 1;
|
|
939
|
+
const visibleRows = Math.max(1, Number(tui?.terminal?.rows) || Number.POSITIVE_INFINITY);
|
|
940
|
+
const regions: InteractionRegion[] = [];
|
|
941
|
+
const renderedByComponent = new Map<any, FrameToolRender>();
|
|
942
|
+
for (const rendered of renderedTools) renderedByComponent.set(rendered.component, rendered);
|
|
943
|
+
const placementsByComponent = new Map<any, FrameToolPlacement[]>();
|
|
944
|
+
for (const placement of placements) {
|
|
945
|
+
const list = placementsByComponent.get(placement.component) ?? [];
|
|
946
|
+
list.push(placement);
|
|
947
|
+
placementsByComponent.set(placement.component, list);
|
|
948
|
+
}
|
|
949
|
+
for (const [component, componentPlacements] of placementsByComponent) {
|
|
950
|
+
const rendered = renderedByComponent.get(component);
|
|
951
|
+
if (!rendered) continue;
|
|
952
|
+
for (const placement of componentPlacements) {
|
|
953
|
+
const finalRow = lineIndexToScreenRow(placement.lineIndex);
|
|
954
|
+
if (finalRow < 1 || finalRow > visibleRows) continue;
|
|
955
|
+
// Hit columns come from the final painted line (parent may prefix/transform).
|
|
956
|
+
const line = placement.finalLine;
|
|
957
|
+
if (!component.expanded) {
|
|
958
|
+
const box = collapsedHintHitbox(line);
|
|
959
|
+
if (box && COLLAPSED_TOOL_SUMMARY.test(stripTerminalSequences(line))) {
|
|
960
|
+
regions.push({ kind: "collapsed-hint", row: finalRow, ...box, component });
|
|
961
|
+
}
|
|
962
|
+
continue;
|
|
963
|
+
}
|
|
964
|
+
if (placement.view && placement.section) {
|
|
965
|
+
const plain = stripTerminalSequencesPreservingLayout(line);
|
|
966
|
+
const box = placement.view.showMoreHitbox(plain);
|
|
967
|
+
if (box) {
|
|
968
|
+
regions.push({
|
|
969
|
+
kind: "show-more",
|
|
970
|
+
row: finalRow,
|
|
971
|
+
...box,
|
|
972
|
+
component,
|
|
973
|
+
view: placement.view,
|
|
974
|
+
section: placement.section,
|
|
975
|
+
});
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
if (!component.expanded) continue;
|
|
980
|
+
let cardStart = 0;
|
|
981
|
+
if (!(component instanceof ToolGroupComponent)) {
|
|
982
|
+
const box = component.contentBox;
|
|
983
|
+
if (!box || !Array.isArray(component.children) || !component.children.includes(box)) {
|
|
984
|
+
continue;
|
|
985
|
+
}
|
|
986
|
+
if (!rendered.contentBoxLines) continue;
|
|
987
|
+
cardStart = Math.max(0, rendered.lines.length - rendered.contentBoxLines);
|
|
988
|
+
}
|
|
989
|
+
for (const placement of componentPlacements) {
|
|
990
|
+
if (placement.componentRow < cardStart) continue;
|
|
991
|
+
const finalRow = lineIndexToScreenRow(placement.lineIndex);
|
|
992
|
+
if (finalRow >= 1 && finalRow <= visibleRows) {
|
|
993
|
+
regions.push({
|
|
994
|
+
kind: "expanded-card",
|
|
995
|
+
row: finalRow,
|
|
996
|
+
startCol: 1,
|
|
997
|
+
endCol: width,
|
|
998
|
+
component,
|
|
999
|
+
});
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
return { regions };
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
/**
|
|
1007
|
+
* 临时包装 outermost 工具/组件的 render 注入零宽 marker,返回待 restore 列表。
|
|
1008
|
+
* 调用方必须用 restoreRenderOverride 立即还原(同一次渲染内有效)。
|
|
1009
|
+
*/
|
|
1010
|
+
function wrapToolRendersForFrame(
|
|
1011
|
+
outermost: any[],
|
|
1012
|
+
renderedTools: FrameToolRender[],
|
|
1013
|
+
idToComponent: Map<number, any>,
|
|
1014
|
+
): Array<{ target: any; descriptor?: PropertyDescriptor }> {
|
|
1015
|
+
const restores: Array<{ target: any; descriptor?: PropertyDescriptor }> = [];
|
|
1016
|
+
let nextId = 0;
|
|
1017
|
+
try {
|
|
1018
|
+
for (const component of outermost) {
|
|
1019
|
+
const originalRender = component.render;
|
|
1020
|
+
if (typeof originalRender !== "function") continue;
|
|
1021
|
+
const id = nextId++;
|
|
1022
|
+
idToComponent.set(id, component);
|
|
1023
|
+
const wrappedRender = function (this: any, ...renderArgs: any[]) {
|
|
1024
|
+
let contentBoxLines = 0;
|
|
1025
|
+
const box = component.contentBox;
|
|
1026
|
+
let boxRestore: { target: any; descriptor?: PropertyDescriptor } | undefined;
|
|
1027
|
+
if (
|
|
1028
|
+
box &&
|
|
1029
|
+
Array.isArray(component.children) &&
|
|
1030
|
+
component.children.includes(box) &&
|
|
1031
|
+
typeof box.render === "function"
|
|
1032
|
+
) {
|
|
1033
|
+
const boxOriginal = box.render;
|
|
1034
|
+
const boxWrapped = function (this: any, ...boxArgs: any[]) {
|
|
1035
|
+
const boxLines = Reflect.apply(boxOriginal, this, boxArgs);
|
|
1036
|
+
if (Array.isArray(boxLines)) contentBoxLines = boxLines.length;
|
|
1037
|
+
return boxLines;
|
|
1038
|
+
};
|
|
1039
|
+
const boxDescriptor = defineRenderOverride(box, boxWrapped);
|
|
1040
|
+
if (boxDescriptor !== undefined || box.render === boxWrapped) {
|
|
1041
|
+
boxRestore = { target: box, descriptor: boxDescriptor };
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
try {
|
|
1045
|
+
const lines = Reflect.apply(originalRender, this, renderArgs);
|
|
1046
|
+
if (!Array.isArray(lines)) return lines;
|
|
1047
|
+
renderedTools.push({
|
|
1048
|
+
component,
|
|
1049
|
+
lines: lines.map((line) => String(line)),
|
|
1050
|
+
contentBoxLines,
|
|
1051
|
+
});
|
|
1052
|
+
return lines.map((line, row) => `${line}${toolFrameMarker(id, row)}`);
|
|
1053
|
+
} finally {
|
|
1054
|
+
if (boxRestore) restoreRenderOverride(boxRestore.target, boxRestore.descriptor);
|
|
1055
|
+
}
|
|
1056
|
+
};
|
|
1057
|
+
const descriptor = defineRenderOverride(component, wrappedRender);
|
|
1058
|
+
if (descriptor !== undefined || component.render === wrappedRender) {
|
|
1059
|
+
restores.push({ target: component, descriptor });
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
return restores;
|
|
1063
|
+
} catch (error) {
|
|
1064
|
+
for (const { target, descriptor } of restores.reverse()) {
|
|
1065
|
+
restoreRenderOverride(target, descriptor);
|
|
1066
|
+
}
|
|
1067
|
+
throw error;
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
function defineRenderOverride(
|
|
1072
|
+
target: any,
|
|
1073
|
+
wrapped: (...args: any[]) => any,
|
|
1074
|
+
): PropertyDescriptor | undefined {
|
|
1075
|
+
const descriptor = Object.getOwnPropertyDescriptor(target, "render");
|
|
1076
|
+
try {
|
|
1077
|
+
Object.defineProperty(
|
|
1078
|
+
target,
|
|
1079
|
+
"render",
|
|
1080
|
+
descriptor && "value" in descriptor
|
|
1081
|
+
? { ...descriptor, value: wrapped }
|
|
1082
|
+
: {
|
|
1083
|
+
configurable: true,
|
|
1084
|
+
enumerable: descriptor?.enumerable ?? false,
|
|
1085
|
+
writable: true,
|
|
1086
|
+
value: wrapped,
|
|
1087
|
+
},
|
|
1088
|
+
);
|
|
1089
|
+
return descriptor;
|
|
1090
|
+
} catch {
|
|
1091
|
+
return undefined;
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
function restoreRenderOverride(target: any, descriptor: PropertyDescriptor | undefined): void {
|
|
1096
|
+
try {
|
|
1097
|
+
if (descriptor) Object.defineProperty(target, "render", descriptor);
|
|
1098
|
+
else delete target.render;
|
|
1099
|
+
} catch {
|
|
1100
|
+
// Keep restoring siblings after a hostile descriptor change.
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
function patchToolMouseMotionAfterRender(tui: any): void {
|
|
1105
|
+
// Same tui is not enough: footer/compositor rebuild may replace doRender under us.
|
|
1106
|
+
if (
|
|
1107
|
+
toolMouseRenderPatchTui === tui &&
|
|
1108
|
+
toolMouseRenderPatchState?.active &&
|
|
1109
|
+
tui.doRender === toolMouseRenderPatchWrapper
|
|
1110
|
+
) {
|
|
1111
|
+
return;
|
|
1112
|
+
}
|
|
1113
|
+
// 0.84+ 惰性 Proxy:捕获 doRender 会解析到 wrapper 自身(无限递归),跳过。
|
|
1114
|
+
if (isLazyProxyTui(tui)) return;
|
|
1115
|
+
restoreToolMouseRenderPatch();
|
|
1116
|
+
const original = tui?.doRender;
|
|
1117
|
+
const terminal = tui?.terminal;
|
|
1118
|
+
const rawWrite = typeof terminal?.write === "function" ? terminal.write : undefined;
|
|
1119
|
+
if (typeof original !== "function") return;
|
|
1120
|
+
|
|
1121
|
+
toolMouseRawWrite = rawWrite ? (data) => Reflect.apply(rawWrite, terminal, [data]) : null;
|
|
1122
|
+
const patchState = { active: true };
|
|
1123
|
+
const wrapper = function (this: any, ...args: any[]) {
|
|
1124
|
+
if (!patchState.active) return Reflect.apply(original, this, args);
|
|
1125
|
+
const renderedTools: FrameToolRender[] = [];
|
|
1126
|
+
const idToComponent = new Map<number, any>();
|
|
1127
|
+
const frame: IoViewFrameState = {
|
|
1128
|
+
viewIds: new Map(),
|
|
1129
|
+
idToView: new Map(),
|
|
1130
|
+
nextId: 0,
|
|
1131
|
+
};
|
|
1132
|
+
const outermost: any[] = [];
|
|
1133
|
+
collectToolComponents(this, outermost);
|
|
1134
|
+
const restores = wrapToolRendersForFrame(outermost, renderedTools, idToComponent);
|
|
1135
|
+
let placements: FrameToolPlacement[] = [];
|
|
1136
|
+
const originalTuiRender = typeof this.render === "function" ? this.render : null;
|
|
1137
|
+
let tuiRenderDescriptor: PropertyDescriptor | undefined;
|
|
1138
|
+
let sawTuiRender = false;
|
|
1139
|
+
if (originalTuiRender) {
|
|
1140
|
+
const wrappedTuiRender = function (this: any, ...renderArgs: any[]) {
|
|
1141
|
+
const lines = Reflect.apply(originalTuiRender, this, renderArgs);
|
|
1142
|
+
if (!Array.isArray(lines)) return lines;
|
|
1143
|
+
sawTuiRender = true;
|
|
1144
|
+
const extracted = extractToolFramePlacements(
|
|
1145
|
+
lines.map((line) => String(line)),
|
|
1146
|
+
idToComponent,
|
|
1147
|
+
frame.idToView,
|
|
1148
|
+
);
|
|
1149
|
+
placements = extracted.placements;
|
|
1150
|
+
return extracted.lines;
|
|
1151
|
+
};
|
|
1152
|
+
tuiRenderDescriptor = defineRenderOverride(this, wrappedTuiRender);
|
|
1153
|
+
}
|
|
1154
|
+
let succeeded = false;
|
|
1155
|
+
const previousFrame = getActiveIoViewFrame();
|
|
1156
|
+
setActiveIoViewFrame(frame);
|
|
1157
|
+
try {
|
|
1158
|
+
const result = Reflect.apply(original, this, args);
|
|
1159
|
+
succeeded = true;
|
|
1160
|
+
// Test harnesses may paint via doRender without tui.render; recover markers there.
|
|
1161
|
+
if (!sawTuiRender && Array.isArray(this.previousLines)) {
|
|
1162
|
+
const extracted = extractToolFramePlacements(
|
|
1163
|
+
this.previousLines.map((line: unknown) => String(line)),
|
|
1164
|
+
idToComponent,
|
|
1165
|
+
frame.idToView,
|
|
1166
|
+
);
|
|
1167
|
+
this.previousLines = extracted.lines;
|
|
1168
|
+
placements = extracted.placements;
|
|
1169
|
+
}
|
|
1170
|
+
if (toolMouseInteractionActive()) toolMouseRawWrite?.(TOOL_MOUSE_MOTION_ENABLE);
|
|
1171
|
+
return result;
|
|
1172
|
+
} finally {
|
|
1173
|
+
setActiveIoViewFrame(previousFrame);
|
|
1174
|
+
if (originalTuiRender) restoreRenderOverride(this, tuiRenderDescriptor);
|
|
1175
|
+
for (const { target, descriptor } of restores.reverse()) {
|
|
1176
|
+
restoreRenderOverride(target, descriptor);
|
|
1177
|
+
}
|
|
1178
|
+
if (succeeded) {
|
|
1179
|
+
latestInteractionFrame = buildInteractionFrame(this, renderedTools, placements);
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
};
|
|
1183
|
+
try {
|
|
1184
|
+
tui.doRender = wrapper;
|
|
1185
|
+
} catch {
|
|
1186
|
+
toolMouseRawWrite = null;
|
|
1187
|
+
return;
|
|
1188
|
+
}
|
|
1189
|
+
toolMouseRenderPatchTui = tui;
|
|
1190
|
+
toolMouseRenderPatchOriginal = original;
|
|
1191
|
+
toolMouseRenderPatchWrapper = wrapper;
|
|
1192
|
+
toolMouseRenderPatchState = patchState;
|
|
1193
|
+
if (toolMouseInteractionActive()) toolMouseRawWrite?.(TOOL_MOUSE_MOTION_ENABLE);
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
function handleToolMouseInput(data: string): { consume: true } | undefined {
|
|
1197
|
+
if (!toolMouseTui) return undefined;
|
|
1198
|
+
// 惰性 Proxy fullscreen:鼠标由 handleViewportInput 包装消费(官方链之前),
|
|
1199
|
+
// 此处只处理键盘(鼠标事件在官方 listener 已被 consume,到不了这里)。
|
|
1200
|
+
if (fullscreenLazyTui(toolMouseTui)) {
|
|
1201
|
+
scheduleScrollButtonSync(toolMouseTui, data);
|
|
1202
|
+
if (isScrollBottomInput(data)) {
|
|
1203
|
+
toolMouseTui.scrollToBottom?.();
|
|
1204
|
+
hideScrollButton(toolMouseTui);
|
|
1205
|
+
return { consume: true };
|
|
1206
|
+
}
|
|
1207
|
+
return undefined;
|
|
1208
|
+
}
|
|
1209
|
+
updateScrollButtonFromInput(toolMouseTui, data);
|
|
1210
|
+
// Off mode restores native input: wheel keeps scrolling through Pi's normal
|
|
1211
|
+
// dispatcher, while hover/click affordances are entirely inactive.
|
|
1212
|
+
if (!toolMouseInteractionActive()) return undefined;
|
|
1213
|
+
const packets = parseSgrMousePackets(data);
|
|
1214
|
+
if (!packets) {
|
|
1215
|
+
scheduleScrollButtonSync(toolMouseTui, data);
|
|
1216
|
+
return undefined;
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
let consumed = false;
|
|
1220
|
+
for (const packet of packets) {
|
|
1221
|
+
updateToolSummaryHover(toolMouseTui, packet);
|
|
1222
|
+
if (!isSgrLeftPress(packet)) continue;
|
|
1223
|
+
if (toggleToolAtMouseClick(toolMouseTui, packet)) {
|
|
1224
|
+
consumed = true;
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
// Let scrolling, motion, release, and clicks outside tool results reach the
|
|
1229
|
+
// normal TUI input chain (including other extensions such as pi-zentui).
|
|
1230
|
+
scheduleScrollButtonSync(toolMouseTui, data);
|
|
1231
|
+
return consumed ? { consume: true } : undefined;
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
export function teardownToolMouseInteraction(
|
|
1235
|
+
owner: object = toolMouseInstallationOwner ?? DEFAULT_TOOL_MOUSE_OWNER,
|
|
1236
|
+
): void {
|
|
1237
|
+
const host = globalThis as any;
|
|
1238
|
+
if (host[TOOL_MOUSE_OWNER_KEY] && host[TOOL_MOUSE_OWNER_KEY] !== owner) return;
|
|
1239
|
+
if (sessionRenderTimer) {
|
|
1240
|
+
clearTimeout(sessionRenderTimer);
|
|
1241
|
+
sessionRenderTimer = null;
|
|
1242
|
+
}
|
|
1243
|
+
toolMouseInputUnsubscribe?.();
|
|
1244
|
+
toolMouseInputUnsubscribe = null;
|
|
1245
|
+
setHoveredToolCallId(null);
|
|
1246
|
+
setHoveredToolGroup(null);
|
|
1247
|
+
setHoveredToolIo(null, null);
|
|
1248
|
+
try {
|
|
1249
|
+
if (isLazyProxyTui(toolMouseTui)) releaseFullscreenToolMouseMotion(toolMouseTui);
|
|
1250
|
+
else toolMouseTui?.terminal?.write?.(TOOL_MOUSE_DISABLE);
|
|
1251
|
+
} catch {
|
|
1252
|
+
// The terminal may already be closed during shutdown.
|
|
1253
|
+
}
|
|
1254
|
+
try {
|
|
1255
|
+
toolMouseUi?.setWidget?.(TOOL_MOUSE_WIDGET_KEY, undefined);
|
|
1256
|
+
} catch {
|
|
1257
|
+
// The UI context may already have been reset during /reload.
|
|
1258
|
+
}
|
|
1259
|
+
restoreToolMouseRenderPatch();
|
|
1260
|
+
restoreFullscreenViewportInput(toolMouseTui);
|
|
1261
|
+
scrollButtonVisible = false;
|
|
1262
|
+
scrollButtonHovered = false;
|
|
1263
|
+
scrollButtonWidget = null;
|
|
1264
|
+
scrollButtonSyncScheduled = false;
|
|
1265
|
+
toolMouseTui = null;
|
|
1266
|
+
toolMouseUi = null;
|
|
1267
|
+
if (host[TOOL_MOUSE_OWNER_KEY] === owner) delete host[TOOL_MOUSE_OWNER_KEY];
|
|
1268
|
+
if (toolMouseInstallationOwner === owner) toolMouseInstallationOwner = null;
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
/** off 模式清理:清空 hover 与回到底部按钮状态(跨模块 rebind 统一经由此函数)。 */
|
|
1272
|
+
export function resetToolHoverState(): void {
|
|
1273
|
+
setHoveredToolCallId(null);
|
|
1274
|
+
scrollButtonVisible = false;
|
|
1275
|
+
scrollButtonHovered = false;
|
|
1276
|
+
releaseFullscreenToolMouseMotion(toolMouseTui);
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
export function installToolMouseInteraction(
|
|
1280
|
+
ctx: any,
|
|
1281
|
+
owner: object = DEFAULT_TOOL_MOUSE_OWNER,
|
|
1282
|
+
): void {
|
|
1283
|
+
teardownToolMouseInteraction(toolMouseInstallationOwner ?? owner);
|
|
1284
|
+
if (ctx?.mode !== "tui" || !ctx?.hasUI) return;
|
|
1285
|
+
if (typeof ctx.ui?.onTerminalInput !== "function" || typeof ctx.ui?.setWidget !== "function")
|
|
1286
|
+
return;
|
|
1287
|
+
|
|
1288
|
+
toolMouseInstallationOwner = owner;
|
|
1289
|
+
(globalThis as any)[TOOL_MOUSE_OWNER_KEY] = owner;
|
|
1290
|
+
setHoveredToolCallId(null);
|
|
1291
|
+
toolMouseUi = ctx.ui;
|
|
1292
|
+
// 0.84+ 的 tui 是惰性 Proxy:regular 保留原生 scrollback;fullscreen
|
|
1293
|
+
// 由官方 LayoutFrame 命中,并由扩展补齐 hover 所需的 all-motion 上报。
|
|
1294
|
+
ctx.ui.setWidget(TOOL_MOUSE_WIDGET_KEY, (tui: any, theme: any) => {
|
|
1295
|
+
toolMouseTui = tui;
|
|
1296
|
+
setToolTuiFullscreen(fullscreenLazyTui(tui));
|
|
1297
|
+
if (isLazyProxyTui(tui)) {
|
|
1298
|
+
patchFullscreenViewportInput(tui);
|
|
1299
|
+
ensureFullscreenToolMouseMotion(tui);
|
|
1300
|
+
scrollButtonWidget = {
|
|
1301
|
+
render: (width: number) => {
|
|
1302
|
+
patchFullscreenViewportInput(tui);
|
|
1303
|
+
ensureFullscreenToolMouseMotion(tui);
|
|
1304
|
+
return renderScrollButton(width, theme);
|
|
1305
|
+
},
|
|
1306
|
+
invalidate() {},
|
|
1307
|
+
};
|
|
1308
|
+
return scrollButtonWidget;
|
|
1309
|
+
}
|
|
1310
|
+
// Wrap doRender to capture the live frame for tool click/hover mapping.
|
|
1311
|
+
patchToolMouseMotionAfterRender(tui);
|
|
1312
|
+
if (toolMouseInteractionActive()) tui?.terminal?.write?.(TOOL_MOUSE_MOTION_ENABLE);
|
|
1313
|
+
const widget = {
|
|
1314
|
+
render: (width: number) => renderScrollButton(width, theme),
|
|
1315
|
+
invalidate() {},
|
|
1316
|
+
};
|
|
1317
|
+
scrollButtonWidget = widget;
|
|
1318
|
+
return widget;
|
|
1319
|
+
});
|
|
1320
|
+
toolMouseInputUnsubscribe = ctx.ui.onTerminalInput(handleToolMouseInput);
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
function refreshToolRendererComponents(tui: any): void {
|
|
1324
|
+
const tools: any[] = [];
|
|
1325
|
+
collectToolComponents(tui, tools);
|
|
1326
|
+
for (const tool of tools) tool.invalidate?.();
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
export function scheduleSessionRender(refresh?: () => void): void {
|
|
1330
|
+
const tui = toolMouseTui;
|
|
1331
|
+
if (!tui || typeof tui.requestRender !== "function") return;
|
|
1332
|
+
if (sessionRenderTimer) clearTimeout(sessionRenderTimer);
|
|
1333
|
+
// Restored transcripts are populated at different points for startup, reload,
|
|
1334
|
+
// and session replacement. Repaint after session_start and the surrounding UI
|
|
1335
|
+
// rebuild finish so messages are not left hidden until the next terminal input.
|
|
1336
|
+
sessionRenderTimer = setTimeout(() => {
|
|
1337
|
+
sessionRenderTimer = null;
|
|
1338
|
+
if (toolMouseTui !== tui) return;
|
|
1339
|
+
patchToolMouseMotionAfterRender(tui);
|
|
1340
|
+
refreshToolRendererComponents(tui);
|
|
1341
|
+
refresh?.();
|
|
1342
|
+
tui.requestRender(true);
|
|
1343
|
+
}, 0);
|
|
1344
|
+
}
|