@rosetears/aili-pi 0.1.0 → 0.1.1
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.md +7 -1
- package/THIRD_PARTY_NOTICES.md +11 -0
- package/extensions/header/index.ts +92 -0
- package/extensions/matrix/index.ts +375 -0
- package/extensions/zentui/config.ts +1014 -0
- package/extensions/zentui/extension-status.ts +96 -0
- package/extensions/zentui/fixed-editor/cluster.ts +98 -0
- package/extensions/zentui/fixed-editor/compositor.ts +719 -0
- package/extensions/zentui/fixed-editor/index.ts +223 -0
- package/extensions/zentui/fixed-editor/input.ts +85 -0
- package/extensions/zentui/fixed-editor/pi-compat.ts +296 -0
- package/extensions/zentui/fixed-editor/selection.ts +217 -0
- package/extensions/zentui/fixed-editor/terminal-modes.ts +75 -0
- package/extensions/zentui/fixed-editor/types.ts +37 -0
- package/extensions/zentui/footer-format.ts +279 -0
- package/extensions/zentui/footer.ts +595 -0
- package/extensions/zentui/format.ts +434 -0
- package/extensions/zentui/git.ts +384 -0
- package/extensions/zentui/gradient.ts +70 -0
- package/extensions/zentui/icons.ts +252 -0
- package/extensions/zentui/index.ts +577 -0
- package/extensions/zentui/live-context.ts +75 -0
- package/extensions/zentui/package-version.ts +650 -0
- package/extensions/zentui/project-refresh.ts +104 -0
- package/extensions/zentui/project-state.ts +59 -0
- package/extensions/zentui/prototype-patch-registry.ts +111 -0
- package/extensions/zentui/runtime.ts +841 -0
- package/extensions/zentui/selector-border.ts +77 -0
- package/extensions/zentui/session-lifecycle.ts +60 -0
- package/extensions/zentui/settings-command.ts +897 -0
- package/extensions/zentui/state.ts +55 -0
- package/extensions/zentui/style.ts +332 -0
- package/extensions/zentui/thinking-message.ts +159 -0
- package/extensions/zentui/tool-execution.ts +189 -0
- package/extensions/zentui/ui.ts +618 -0
- package/extensions/zentui/user-message.ts +252 -0
- package/licenses/pi-sakura-cyberdeck-MIT.txt +21 -0
- package/licenses/pi-zentui-MIT.txt +21 -0
- package/manifests/provenance.json +11 -0
- package/manifests/sbom.json +17 -1
- package/notices/pi-sakura-cyberdeck-NOTICE.txt +6 -0
- package/package.json +11 -2
- package/src/runtime/doctor.ts +1 -1
- package/src/runtime/registry.ts +1 -1
- package/src/runtime/rem-head.txt +38 -0
- package/themes/rem-cyberdeck.json +32 -0
|
@@ -0,0 +1,719 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TerminalSplitCompositor — pins editor/footer at the bottom of the terminal
|
|
3
|
+
* while the transcript scrolls above, using terminal scroll regions + alt screen.
|
|
4
|
+
*
|
|
5
|
+
* This patches Pi's internal TUI methods. It is inherently fragile across Pi
|
|
6
|
+
* versions. All patches include capability checks and silent fallback.
|
|
7
|
+
*
|
|
8
|
+
* Adapted from @tifan/pi-fixed-editor (MIT) by Tifan Dwi Avianto, which was
|
|
9
|
+
* itself adapted from pi-powerline-footer (MIT) by Nico Bailon.
|
|
10
|
+
*
|
|
11
|
+
* @internal
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { copyToClipboard } from "@earendil-works/pi-coding-agent";
|
|
15
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
16
|
+
|
|
17
|
+
import { renderCluster } from "./cluster.js";
|
|
18
|
+
import { clampScrollOffset, parseKeyboardScroll, parseMouseEvent } from "./input.js";
|
|
19
|
+
import type {
|
|
20
|
+
PiFixedEditorCapabilities,
|
|
21
|
+
PiMethodCapability,
|
|
22
|
+
PiRenderableCapability,
|
|
23
|
+
} from "./pi-compat.js";
|
|
24
|
+
import { highlightSelection, SelectionState } from "./selection.js";
|
|
25
|
+
import {
|
|
26
|
+
CLEAR_LINE,
|
|
27
|
+
cursorTo,
|
|
28
|
+
DISABLE_ALT_SCROLL,
|
|
29
|
+
DISABLE_AUTOWRAP,
|
|
30
|
+
DISABLE_MOUSE,
|
|
31
|
+
ENABLE_ALT_SCROLL,
|
|
32
|
+
ENABLE_AUTOWRAP,
|
|
33
|
+
ENABLE_MOUSE_SGR,
|
|
34
|
+
ENTER_ALT_SCREEN,
|
|
35
|
+
EXIT_ALT_SCREEN,
|
|
36
|
+
emergencyTerminalReset,
|
|
37
|
+
HIDE_CURSOR,
|
|
38
|
+
RESET_SCROLL_REGION,
|
|
39
|
+
SHOW_CURSOR,
|
|
40
|
+
SYNC_BEGIN,
|
|
41
|
+
SYNC_END,
|
|
42
|
+
setScrollRegion,
|
|
43
|
+
} from "./terminal-modes.js";
|
|
44
|
+
import type { ClusterRender, CompositorConfig } from "./types.js";
|
|
45
|
+
|
|
46
|
+
function replaceMethod(
|
|
47
|
+
capability: PiMethodCapability,
|
|
48
|
+
method: (...args: unknown[]) => unknown,
|
|
49
|
+
): void {
|
|
50
|
+
const descriptor = capability.ownDescriptor;
|
|
51
|
+
Object.defineProperty(capability.target, capability.key, {
|
|
52
|
+
...(descriptor ?? { configurable: true, enumerable: false, writable: true }),
|
|
53
|
+
value: method,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function restoreMethod(capability: PiMethodCapability): void {
|
|
58
|
+
if (capability.ownDescriptor) {
|
|
59
|
+
Object.defineProperty(capability.target, capability.key, capability.ownDescriptor);
|
|
60
|
+
} else {
|
|
61
|
+
Reflect.deleteProperty(capability.target, capability.key);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function hideRenderable(capability: PiRenderableCapability | null): void {
|
|
66
|
+
if (!capability) return;
|
|
67
|
+
Object.defineProperty(capability.target, "render", {
|
|
68
|
+
...(capability.ownDescriptor ?? { configurable: true, enumerable: false, writable: true }),
|
|
69
|
+
value: () => [],
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function restoreRenderable(capability: PiRenderableCapability | null): void {
|
|
74
|
+
if (!capability) return;
|
|
75
|
+
if (capability.ownDescriptor) {
|
|
76
|
+
Object.defineProperty(capability.target, "render", capability.ownDescriptor);
|
|
77
|
+
} else {
|
|
78
|
+
Reflect.deleteProperty(capability.target, "render");
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function sanitizeLine(line: string, width: number): string {
|
|
83
|
+
return visibleWidth(line) > width ? truncateToWidth(line, width, "", true) : line;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Replace full-screen clears with clears limited to the scrollable transcript. */
|
|
87
|
+
function constrainScreenClears(data: string, scrollBottom: number): string {
|
|
88
|
+
let injected = false;
|
|
89
|
+
return data.replace(/\x1b\[(?:2|3)J/g, () => {
|
|
90
|
+
if (injected) return "";
|
|
91
|
+
injected = true;
|
|
92
|
+
let replacement = "";
|
|
93
|
+
for (let row = 1; row <= scrollBottom; row++) {
|
|
94
|
+
replacement += cursorTo(row, 1) + CLEAR_LINE;
|
|
95
|
+
}
|
|
96
|
+
return replacement + cursorTo(1, 1);
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const STARTUP_RESOURCE_LABELS = new Set(["[Skills]", "[Prompts]", "[Extensions]", "[Themes]"]);
|
|
101
|
+
const ANSI_PATTERN = /\x1b\[[0-?]*[ -/]*[@-~]/g;
|
|
102
|
+
const OSC_PATTERN = /\x1b\][^\x07]*(?:\x07|\x1b\\)/g;
|
|
103
|
+
|
|
104
|
+
function plainLine(line: string): string {
|
|
105
|
+
return line.replace(OSC_PATTERN, "").replace(ANSI_PATTERN, "").trim();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function findStartupResourceStart(lines: string[]): number {
|
|
109
|
+
const matches = lines
|
|
110
|
+
.map((line, index) => ({ text: plainLine(line), index }))
|
|
111
|
+
.filter(({ text }) => STARTUP_RESOURCE_LABELS.has(text));
|
|
112
|
+
return matches.length >= 2 ? (matches[0]?.index ?? -1) : -1;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export class TerminalSplitCompositor {
|
|
116
|
+
private readonly capabilities: PiFixedEditorCapabilities;
|
|
117
|
+
private readonly getConfig: () => CompositorConfig;
|
|
118
|
+
private inputListener:
|
|
119
|
+
| ((data: string) => { consume?: boolean; data?: string } | undefined)
|
|
120
|
+
| null = null;
|
|
121
|
+
private inputListenerDisposer: (() => void) | null = null;
|
|
122
|
+
private emergencyCleanup: (() => void) | null = null;
|
|
123
|
+
|
|
124
|
+
private installed = false;
|
|
125
|
+
private disposed = false;
|
|
126
|
+
private terminalModesEntered = false;
|
|
127
|
+
private writing = false;
|
|
128
|
+
private renderingCluster = false;
|
|
129
|
+
private checkingOverlay = false;
|
|
130
|
+
|
|
131
|
+
private scrollOffset = 0;
|
|
132
|
+
private maxScrollOffset = 0;
|
|
133
|
+
private lastRootLineCount = 0;
|
|
134
|
+
|
|
135
|
+
/** Root lines from last renderScrollableRoot — used for selection text extraction. */
|
|
136
|
+
private rootLines: string[] = [];
|
|
137
|
+
/** Absolute start index of visible window in rootLines. */
|
|
138
|
+
private visibleRootStart = 0;
|
|
139
|
+
/** Height of the scrollable region in last render. */
|
|
140
|
+
private visibleScrollableRows = 0;
|
|
141
|
+
|
|
142
|
+
/** Selection state for app-level drag-to-select. */
|
|
143
|
+
private readonly selection = new SelectionState();
|
|
144
|
+
/** Timer for right-click context menu mouse reporting pause. */
|
|
145
|
+
private mouseResumeTimer: ReturnType<typeof setTimeout> | null = null;
|
|
146
|
+
private cursorVisible = true;
|
|
147
|
+
|
|
148
|
+
private readonly onCopy: (() => void) | null;
|
|
149
|
+
private readonly onDismissNotice: (() => void) | null;
|
|
150
|
+
|
|
151
|
+
private cachedClusterRender: { width: number; rows: number; render: ClusterRender } | null = null;
|
|
152
|
+
private paintedCluster: {
|
|
153
|
+
width: number;
|
|
154
|
+
rawRows: number;
|
|
155
|
+
startRow: number;
|
|
156
|
+
lines: string[];
|
|
157
|
+
} | null = null;
|
|
158
|
+
/** Incremented whenever Pi's renderer actually writes through the compositor. */
|
|
159
|
+
private writeRevision = 0;
|
|
160
|
+
|
|
161
|
+
constructor(
|
|
162
|
+
capabilities: PiFixedEditorCapabilities,
|
|
163
|
+
getConfig: () => CompositorConfig,
|
|
164
|
+
onCopy?: () => void,
|
|
165
|
+
onDismissNotice?: () => void,
|
|
166
|
+
) {
|
|
167
|
+
this.capabilities = capabilities;
|
|
168
|
+
this.getConfig = getConfig;
|
|
169
|
+
this.onCopy = onCopy ?? null;
|
|
170
|
+
this.onDismissNotice = onDismissNotice ?? null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
install(): boolean {
|
|
174
|
+
if (this.installed) return true;
|
|
175
|
+
if (this.disposed) return false;
|
|
176
|
+
const cluster = this.capabilities.cluster;
|
|
177
|
+
try {
|
|
178
|
+
for (const component of [
|
|
179
|
+
cluster.status,
|
|
180
|
+
cluster.aboveWidget,
|
|
181
|
+
cluster.editor,
|
|
182
|
+
cluster.belowWidget,
|
|
183
|
+
cluster.footer,
|
|
184
|
+
]) {
|
|
185
|
+
hideRenderable(component);
|
|
186
|
+
}
|
|
187
|
+
Object.defineProperty(this.capabilities.terminal, "rows", {
|
|
188
|
+
configurable: true,
|
|
189
|
+
get: () => this.getScrollableRows(),
|
|
190
|
+
});
|
|
191
|
+
replaceMethod(this.capabilities.renderMethod, (width) =>
|
|
192
|
+
this.renderScrollableRoot(Number(width)),
|
|
193
|
+
);
|
|
194
|
+
replaceMethod(this.capabilities.doRenderMethod, () => {
|
|
195
|
+
this.cachedClusterRender = null;
|
|
196
|
+
const revisionBeforeRender = this.writeRevision;
|
|
197
|
+
try {
|
|
198
|
+
this.callOriginalDoRender();
|
|
199
|
+
// terminal.write() already paints the pinned cluster. Repaint only when
|
|
200
|
+
// Pi produced no terminal output (for example, a footer-only update).
|
|
201
|
+
if (this.writeRevision === revisionBeforeRender) this.requestRepaint();
|
|
202
|
+
} catch {
|
|
203
|
+
// If doRender throws, the original write may already have happened.
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
replaceMethod(this.capabilities.writeMethod, (data) => this.write(String(data)));
|
|
207
|
+
|
|
208
|
+
this.inputListener = (data) => this.handleInput(data);
|
|
209
|
+
const inputListenerDisposer = this.capabilities.addInputListener(this.inputListener);
|
|
210
|
+
if (typeof inputListenerDisposer !== "function") {
|
|
211
|
+
throw new TypeError("Invalid input listener disposer");
|
|
212
|
+
}
|
|
213
|
+
this.inputListenerDisposer = inputListenerDisposer as () => void;
|
|
214
|
+
this.emergencyCleanup = () => {
|
|
215
|
+
if (!this.disposed) this.restoreForExit();
|
|
216
|
+
};
|
|
217
|
+
process.once("exit", this.emergencyCleanup);
|
|
218
|
+
|
|
219
|
+
this.terminalModesEntered = true;
|
|
220
|
+
this.callOriginalWrite(
|
|
221
|
+
SYNC_BEGIN +
|
|
222
|
+
ENTER_ALT_SCREEN +
|
|
223
|
+
DISABLE_ALT_SCROLL +
|
|
224
|
+
(this.getConfig().mouseScroll ? ENABLE_MOUSE_SGR : DISABLE_MOUSE) +
|
|
225
|
+
SYNC_END,
|
|
226
|
+
);
|
|
227
|
+
// The alternate screen starts blank. Reset Pi's differential-render state
|
|
228
|
+
// so its first pinned render paints every row at the new layout positions.
|
|
229
|
+
this.resetTuiRenderState();
|
|
230
|
+
this.installed = true;
|
|
231
|
+
} catch {
|
|
232
|
+
this.rollbackInstallation();
|
|
233
|
+
return false;
|
|
234
|
+
}
|
|
235
|
+
try {
|
|
236
|
+
this.capabilities.requestRender?.(true);
|
|
237
|
+
} catch {}
|
|
238
|
+
return true;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
dispose(): void {
|
|
242
|
+
if (this.disposed) return;
|
|
243
|
+
this.disposed = true;
|
|
244
|
+
if (!this.installed) return;
|
|
245
|
+
this.clearInputListener();
|
|
246
|
+
if (this.mouseResumeTimer) {
|
|
247
|
+
clearTimeout(this.mouseResumeTimer);
|
|
248
|
+
this.mouseResumeTimer = null;
|
|
249
|
+
}
|
|
250
|
+
if (this.emergencyCleanup) {
|
|
251
|
+
process.removeListener("exit", this.emergencyCleanup);
|
|
252
|
+
this.emergencyCleanup = null;
|
|
253
|
+
}
|
|
254
|
+
this.restorePatchedCapabilities();
|
|
255
|
+
this.restoreForExit();
|
|
256
|
+
this.terminalModesEntered = false;
|
|
257
|
+
this.installed = false;
|
|
258
|
+
try {
|
|
259
|
+
this.capabilities.requestRender?.(true);
|
|
260
|
+
} catch {}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
private rollbackInstallation(): void {
|
|
264
|
+
this.clearInputListener();
|
|
265
|
+
if (this.emergencyCleanup) {
|
|
266
|
+
process.removeListener("exit", this.emergencyCleanup);
|
|
267
|
+
this.emergencyCleanup = null;
|
|
268
|
+
}
|
|
269
|
+
this.restorePatchedCapabilities();
|
|
270
|
+
if (this.terminalModesEntered) this.restoreForExit();
|
|
271
|
+
this.terminalModesEntered = false;
|
|
272
|
+
this.installed = false;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
private clearInputListener(): void {
|
|
276
|
+
const listener = this.inputListener;
|
|
277
|
+
const disposer = this.inputListenerDisposer;
|
|
278
|
+
this.inputListener = null;
|
|
279
|
+
this.inputListenerDisposer = null;
|
|
280
|
+
let disposed = false;
|
|
281
|
+
if (disposer) {
|
|
282
|
+
try {
|
|
283
|
+
disposer();
|
|
284
|
+
disposed = true;
|
|
285
|
+
} catch {}
|
|
286
|
+
}
|
|
287
|
+
if (!disposed && listener) {
|
|
288
|
+
try {
|
|
289
|
+
this.capabilities.removeInputListener(listener);
|
|
290
|
+
} catch {}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
private restorePatchedCapabilities(): void {
|
|
295
|
+
restoreMethod(this.capabilities.writeMethod);
|
|
296
|
+
restoreMethod(this.capabilities.doRenderMethod);
|
|
297
|
+
restoreMethod(this.capabilities.renderMethod);
|
|
298
|
+
for (const component of [
|
|
299
|
+
this.capabilities.cluster.status,
|
|
300
|
+
this.capabilities.cluster.aboveWidget,
|
|
301
|
+
this.capabilities.cluster.editor,
|
|
302
|
+
this.capabilities.cluster.belowWidget,
|
|
303
|
+
this.capabilities.cluster.footer,
|
|
304
|
+
]) {
|
|
305
|
+
restoreRenderable(component);
|
|
306
|
+
}
|
|
307
|
+
if (this.capabilities.rowsOwnDescriptor) {
|
|
308
|
+
Object.defineProperty(
|
|
309
|
+
this.capabilities.terminal,
|
|
310
|
+
"rows",
|
|
311
|
+
this.capabilities.rowsOwnDescriptor,
|
|
312
|
+
);
|
|
313
|
+
} else {
|
|
314
|
+
Reflect.deleteProperty(this.capabilities.terminal, "rows");
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
private callOriginalWrite(data: string): void {
|
|
319
|
+
Reflect.apply(this.capabilities.writeMethod.method, this.capabilities.terminal, [data]);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
private resetTuiRenderState(): void {
|
|
323
|
+
for (const [key, value] of [
|
|
324
|
+
["previousLines", []],
|
|
325
|
+
["previousWidth", 0],
|
|
326
|
+
["previousHeight", 0],
|
|
327
|
+
["maxLinesRendered", 0],
|
|
328
|
+
] as const) {
|
|
329
|
+
try {
|
|
330
|
+
Reflect.set(this.capabilities.tui, key, value);
|
|
331
|
+
} catch {}
|
|
332
|
+
}
|
|
333
|
+
this.paintedCluster = null;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
private callOriginalDoRender(): void {
|
|
337
|
+
Reflect.apply(this.capabilities.doRenderMethod.method, this.capabilities.tui, []);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
private callOriginalRender(width: number): string[] {
|
|
341
|
+
return Reflect.apply(this.capabilities.renderMethod.method, this.capabilities.tui, [
|
|
342
|
+
width,
|
|
343
|
+
]) as string[];
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
private getRawRows(): number {
|
|
347
|
+
return Math.max(2, this.capabilities.readRawRows());
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
private getClusterRender(width: number, rawRows: number): ClusterRender {
|
|
351
|
+
if (this.cachedClusterRender?.width === width && this.cachedClusterRender?.rows === rawRows) {
|
|
352
|
+
return this.cachedClusterRender.render;
|
|
353
|
+
}
|
|
354
|
+
const wasRendering = this.renderingCluster;
|
|
355
|
+
this.renderingCluster = true;
|
|
356
|
+
try {
|
|
357
|
+
const render = renderCluster(this.capabilities.cluster, width, rawRows);
|
|
358
|
+
this.cachedClusterRender = { width, rows: rawRows, render };
|
|
359
|
+
return render;
|
|
360
|
+
} finally {
|
|
361
|
+
this.renderingCluster = wasRendering;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
private getScrollableRows(): number {
|
|
366
|
+
if (
|
|
367
|
+
this.disposed ||
|
|
368
|
+
this.writing ||
|
|
369
|
+
this.renderingCluster ||
|
|
370
|
+
this.checkingOverlay ||
|
|
371
|
+
this.hasVisibleOverlay()
|
|
372
|
+
) {
|
|
373
|
+
return this.getRawRows();
|
|
374
|
+
}
|
|
375
|
+
const rawRows = this.getRawRows();
|
|
376
|
+
const width = Math.max(1, this.capabilities.getColumns() || 80);
|
|
377
|
+
const cluster = this.getClusterRender(width, rawRows);
|
|
378
|
+
return Math.max(1, rawRows - cluster.lines.length);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
private hasVisibleOverlay(): boolean {
|
|
382
|
+
if (this.checkingOverlay) return false;
|
|
383
|
+
this.checkingOverlay = true;
|
|
384
|
+
try {
|
|
385
|
+
return this.capabilities.hasVisibleOverlay();
|
|
386
|
+
} finally {
|
|
387
|
+
this.checkingOverlay = false;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
private renderScrollableRoot(width: number): string[] {
|
|
392
|
+
if (this.disposed) return this.callOriginalRender(width);
|
|
393
|
+
|
|
394
|
+
if (this.hasVisibleOverlay()) return this.callOriginalRender(width);
|
|
395
|
+
|
|
396
|
+
const rawRows = this.getRawRows();
|
|
397
|
+
const cluster = this.getClusterRender(Math.max(1, width), rawRows);
|
|
398
|
+
const scrollableRows = Math.max(1, rawRows - cluster.lines.length);
|
|
399
|
+
|
|
400
|
+
const lines = this.callOriginalRender(Math.max(1, width));
|
|
401
|
+
|
|
402
|
+
// Adjust scroll offset when new content arrives while scrolled up.
|
|
403
|
+
if (
|
|
404
|
+
this.scrollOffset > 0 &&
|
|
405
|
+
this.lastRootLineCount > 0 &&
|
|
406
|
+
lines.length > this.lastRootLineCount
|
|
407
|
+
) {
|
|
408
|
+
this.scrollOffset += lines.length - this.lastRootLineCount;
|
|
409
|
+
}
|
|
410
|
+
this.lastRootLineCount = lines.length;
|
|
411
|
+
this.maxScrollOffset = Math.max(0, lines.length - scrollableRows);
|
|
412
|
+
this.scrollOffset = clampScrollOffset(this.scrollOffset, this.maxScrollOffset);
|
|
413
|
+
|
|
414
|
+
const start = Math.max(0, lines.length - scrollableRows - this.scrollOffset);
|
|
415
|
+
const visible = lines.slice(start, start + scrollableRows);
|
|
416
|
+
const missingRows = scrollableRows - visible.length;
|
|
417
|
+
const resourceStart = this.scrollOffset === 0 ? findStartupResourceStart(visible) : -1;
|
|
418
|
+
if (resourceStart >= 0) {
|
|
419
|
+
let trailingBlankRows = 0;
|
|
420
|
+
while (
|
|
421
|
+
trailingBlankRows < visible.length - resourceStart &&
|
|
422
|
+
plainLine(visible[visible.length - 1 - trailingBlankRows] ?? "") === ""
|
|
423
|
+
) {
|
|
424
|
+
trailingBlankRows += 1;
|
|
425
|
+
}
|
|
426
|
+
if (trailingBlankRows > 0) visible.splice(visible.length - trailingBlankRows);
|
|
427
|
+
// Keep the centered welcome art in place while moving every flexible
|
|
428
|
+
// blank row before Pi's resource inventory, directly above the editor.
|
|
429
|
+
visible.splice(resourceStart, 0, ...Array(missingRows + trailingBlankRows).fill(""));
|
|
430
|
+
} else if (missingRows > 0) {
|
|
431
|
+
visible.push(...Array(missingRows).fill(""));
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// Store for selection mapping and text extraction.
|
|
435
|
+
this.rootLines = lines;
|
|
436
|
+
this.visibleRootStart = start;
|
|
437
|
+
this.visibleScrollableRows = scrollableRows;
|
|
438
|
+
|
|
439
|
+
// Apply selection highlight to visible lines.
|
|
440
|
+
return visible.map((line, i) => highlightSelection(line, start + i, this.selection));
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
private handleInput(data: string): { consume?: boolean; data?: string } | undefined {
|
|
444
|
+
if (this.disposed || this.hasVisibleOverlay()) return undefined;
|
|
445
|
+
this.onDismissNotice?.();
|
|
446
|
+
|
|
447
|
+
const mouseScroll = this.getConfig().mouseScroll;
|
|
448
|
+
if (mouseScroll) {
|
|
449
|
+
const mouseEv = parseMouseEvent(data);
|
|
450
|
+
if (mouseEv) {
|
|
451
|
+
this.handleMouseEvent(mouseEv);
|
|
452
|
+
return { consume: true };
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const keyboard = parseKeyboardScroll(data);
|
|
457
|
+
if (!keyboard) return undefined;
|
|
458
|
+
|
|
459
|
+
if (keyboard.action === "jumpBottom") {
|
|
460
|
+
this.scrollOffset = 0;
|
|
461
|
+
this.selection.clear();
|
|
462
|
+
this.capabilities.requestRender?.();
|
|
463
|
+
return undefined; // Let Enter propagate to the editor.
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const rawRows = this.getRawRows();
|
|
467
|
+
const scrollableRows = Math.max(
|
|
468
|
+
1,
|
|
469
|
+
rawRows - this.getClusterRender(this.capabilities.getColumns() || 80, rawRows).lines.length,
|
|
470
|
+
);
|
|
471
|
+
|
|
472
|
+
if (keyboard.action === "pageUp") {
|
|
473
|
+
const before = this.scrollOffset;
|
|
474
|
+
this.selection.clear();
|
|
475
|
+
this.scrollBy(scrollableRows);
|
|
476
|
+
return this.scrollOffset !== before ? { consume: true } : undefined;
|
|
477
|
+
}
|
|
478
|
+
if (keyboard.action === "pageDown") {
|
|
479
|
+
const before = this.scrollOffset;
|
|
480
|
+
this.selection.clear();
|
|
481
|
+
this.scrollBy(-scrollableRows);
|
|
482
|
+
return this.scrollOffset !== before ? { consume: true } : undefined;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
return { consume: true };
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
private handleMouseEvent(ev: { button: string; action: string; col: number; row: number }): void {
|
|
489
|
+
// Wheel scroll.
|
|
490
|
+
if (ev.button === "wheel-up" && ev.action === "press") {
|
|
491
|
+
this.selection.clear();
|
|
492
|
+
this.scrollBy(3);
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
if (ev.button === "wheel-down" && ev.action === "press") {
|
|
496
|
+
this.selection.clear();
|
|
497
|
+
this.scrollBy(-3);
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// Right-click: pause mouse reporting for native context menu.
|
|
502
|
+
if (ev.button === "right" && ev.action === "press") {
|
|
503
|
+
const selectedText = this.selection.active
|
|
504
|
+
? this.selection.getSelectedText(this.rootLines)
|
|
505
|
+
: "";
|
|
506
|
+
if (selectedText) {
|
|
507
|
+
void copyToClipboard(selectedText);
|
|
508
|
+
}
|
|
509
|
+
this.selection.clear();
|
|
510
|
+
this.pauseMouseReporting();
|
|
511
|
+
this.capabilities.requestRender?.();
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// Only left button is used for drag-select.
|
|
516
|
+
if (ev.button !== "left") return;
|
|
517
|
+
|
|
518
|
+
// Ignore clicks in the cluster region (below scrollable area).
|
|
519
|
+
if (ev.row > this.visibleScrollableRows) return;
|
|
520
|
+
|
|
521
|
+
// Map screen row to transcript line index.
|
|
522
|
+
const lineIndex = this.visibleRootStart + ev.row - 1;
|
|
523
|
+
const col = Math.max(0, ev.col - 1);
|
|
524
|
+
|
|
525
|
+
if (ev.action === "press") {
|
|
526
|
+
this.selection.start(lineIndex, col);
|
|
527
|
+
this.capabilities.requestRender?.();
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
if (ev.action === "drag" && this.selection.isDragging) {
|
|
531
|
+
this.selection.extend(lineIndex, col + 1);
|
|
532
|
+
this.capabilities.requestRender?.();
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
if (ev.action === "release" && this.selection.isDragging) {
|
|
536
|
+
this.selection.extend(lineIndex, col + 1);
|
|
537
|
+
this.selection.setDragging(false);
|
|
538
|
+
const text = this.selection.getSelectedText(this.rootLines);
|
|
539
|
+
this.selection.clear();
|
|
540
|
+
this.capabilities.requestRender?.();
|
|
541
|
+
if (text) {
|
|
542
|
+
void copyToClipboard(text);
|
|
543
|
+
if (this.getConfig().copyNotice) this.onCopy?.();
|
|
544
|
+
}
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/** Temporarily disable mouse reporting so the terminal's native context menu works. */
|
|
550
|
+
private pauseMouseReporting(): void {
|
|
551
|
+
if (this.mouseResumeTimer) clearTimeout(this.mouseResumeTimer);
|
|
552
|
+
this.callOriginalWrite(SYNC_BEGIN + DISABLE_MOUSE + SYNC_END);
|
|
553
|
+
this.mouseResumeTimer = setTimeout(() => {
|
|
554
|
+
this.mouseResumeTimer = null;
|
|
555
|
+
if (!this.disposed) {
|
|
556
|
+
this.callOriginalWrite(SYNC_BEGIN + ENABLE_MOUSE_SGR + SYNC_END);
|
|
557
|
+
}
|
|
558
|
+
}, 1200);
|
|
559
|
+
if (typeof this.mouseResumeTimer === "object" && "unref" in this.mouseResumeTimer) {
|
|
560
|
+
(this.mouseResumeTimer as { unref: () => void }).unref();
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
private scrollBy(delta: number): void {
|
|
565
|
+
const next = clampScrollOffset(this.scrollOffset + delta, this.maxScrollOffset);
|
|
566
|
+
if (next === this.scrollOffset) return;
|
|
567
|
+
this.scrollOffset = next;
|
|
568
|
+
this.capabilities.requestRender?.();
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
private paintCluster(
|
|
572
|
+
cluster: ClusterRender,
|
|
573
|
+
rawRows: number,
|
|
574
|
+
width: number,
|
|
575
|
+
force = false,
|
|
576
|
+
): string {
|
|
577
|
+
if (cluster.lines.length === 0) {
|
|
578
|
+
this.paintedCluster = null;
|
|
579
|
+
return "";
|
|
580
|
+
}
|
|
581
|
+
const startRow = Math.max(1, rawRows - cluster.lines.length + 1);
|
|
582
|
+
const lines = cluster.lines.map((line) => sanitizeLine(line ?? "", width));
|
|
583
|
+
const previous = this.paintedCluster;
|
|
584
|
+
const sameLayout =
|
|
585
|
+
!force &&
|
|
586
|
+
previous?.width === width &&
|
|
587
|
+
previous.rawRows === rawRows &&
|
|
588
|
+
previous.startRow === startRow &&
|
|
589
|
+
previous.lines.length === lines.length;
|
|
590
|
+
let buf = RESET_SCROLL_REGION;
|
|
591
|
+
|
|
592
|
+
if (sameLayout && previous) {
|
|
593
|
+
for (let i = 0; i < lines.length; i++) {
|
|
594
|
+
if (previous.lines[i] === lines[i]) continue;
|
|
595
|
+
const clear = visibleWidth(lines[i] ?? "") < width ? CLEAR_LINE : "";
|
|
596
|
+
buf += cursorTo(startRow + i, 1) + clear + lines[i];
|
|
597
|
+
}
|
|
598
|
+
} else {
|
|
599
|
+
const clearStart = previous ? Math.min(previous.startRow, startRow) : startRow;
|
|
600
|
+
const clearEnd = previous
|
|
601
|
+
? Math.max(previous.startRow + previous.lines.length - 1, startRow + lines.length - 1)
|
|
602
|
+
: startRow + lines.length - 1;
|
|
603
|
+
for (let row = clearStart; row <= clearEnd; row++) {
|
|
604
|
+
const index = row - startRow;
|
|
605
|
+
const line = index >= 0 && index < lines.length ? lines[index] : "";
|
|
606
|
+
const clear = visibleWidth(line) < width ? CLEAR_LINE : "";
|
|
607
|
+
buf += cursorTo(row, 1) + clear + line;
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
this.paintedCluster = { width, rawRows, startRow, lines };
|
|
612
|
+
if (cluster.cursor) {
|
|
613
|
+
buf += cursorTo(startRow + cluster.cursor.row, Math.max(1, cluster.cursor.col + 1));
|
|
614
|
+
if (!this.cursorVisible) {
|
|
615
|
+
buf += SHOW_CURSOR;
|
|
616
|
+
this.cursorVisible = true;
|
|
617
|
+
}
|
|
618
|
+
} else if (this.cursorVisible) {
|
|
619
|
+
buf += HIDE_CURSOR;
|
|
620
|
+
this.cursorVisible = false;
|
|
621
|
+
}
|
|
622
|
+
return buf;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* Restore the cursor to the row Pi's differential renderer expects.
|
|
627
|
+
*
|
|
628
|
+
* `setScrollRegion` (DECSTBM) homes the cursor to row 1, col 1, but Pi's
|
|
629
|
+
* `doRender` emits *relative* cursor moves (CUU/CUD/`\r`) computed from
|
|
630
|
+
* its tracked `hardwareCursorRow`. Without repositioning, a sparse
|
|
631
|
+
* differential update (e.g. one selection-highlighted line) is written
|
|
632
|
+
* at the wrong row because the relative move departs from (1,1)
|
|
633
|
+
* instead of the tracked row.
|
|
634
|
+
*/
|
|
635
|
+
private syncTuiCursor(scrollBottom: number): string {
|
|
636
|
+
const { hardwareCursorRow, previousViewportTop: viewportTop } =
|
|
637
|
+
this.capabilities.getCursorBookkeeping();
|
|
638
|
+
const row = Math.max(1, Math.min(scrollBottom, hardwareCursorRow - viewportTop + 1));
|
|
639
|
+
return cursorTo(row, 1);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
private requestRepaint(): void {
|
|
643
|
+
if (this.disposed || this.hasVisibleOverlay()) return;
|
|
644
|
+
const rawRows = this.getRawRows();
|
|
645
|
+
const width = Math.max(1, this.capabilities.getColumns() || 80);
|
|
646
|
+
const cluster = this.getClusterRender(width, rawRows);
|
|
647
|
+
if (cluster.lines.length === 0) return;
|
|
648
|
+
this.callOriginalWrite(
|
|
649
|
+
SYNC_BEGIN +
|
|
650
|
+
DISABLE_AUTOWRAP +
|
|
651
|
+
this.paintCluster(cluster, rawRows, width) +
|
|
652
|
+
ENABLE_AUTOWRAP +
|
|
653
|
+
(this.getConfig().mouseScroll ? ENABLE_MOUSE_SGR : DISABLE_MOUSE) +
|
|
654
|
+
SYNC_END,
|
|
655
|
+
);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
private write(data: string): void {
|
|
659
|
+
if (this.disposed || this.writing) {
|
|
660
|
+
this.callOriginalWrite(data);
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
if (this.hasVisibleOverlay()) {
|
|
664
|
+
this.paintedCluster = null;
|
|
665
|
+
this.callOriginalWrite(data);
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
668
|
+
this.writeRevision += 1;
|
|
669
|
+
this.writing = true;
|
|
670
|
+
try {
|
|
671
|
+
const rawRows = this.getRawRows();
|
|
672
|
+
const width = Math.max(1, this.capabilities.getColumns() || 80);
|
|
673
|
+
const cluster = this.getClusterRender(width, rawRows);
|
|
674
|
+
const reservedRows = cluster.lines.length;
|
|
675
|
+
if (reservedRows === 0 || rawRows <= 2) {
|
|
676
|
+
this.callOriginalWrite(data);
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
const scrollBottom = Math.max(1, rawRows - reservedRows);
|
|
680
|
+
const safeData = constrainScreenClears(data, scrollBottom);
|
|
681
|
+
this.callOriginalWrite(
|
|
682
|
+
SYNC_BEGIN +
|
|
683
|
+
DISABLE_AUTOWRAP +
|
|
684
|
+
setScrollRegion(1, scrollBottom) +
|
|
685
|
+
this.syncTuiCursor(scrollBottom) +
|
|
686
|
+
safeData +
|
|
687
|
+
this.paintCluster(cluster, rawRows, width) +
|
|
688
|
+
ENABLE_AUTOWRAP +
|
|
689
|
+
(this.getConfig().mouseScroll ? ENABLE_MOUSE_SGR : DISABLE_MOUSE) +
|
|
690
|
+
SYNC_END,
|
|
691
|
+
);
|
|
692
|
+
} finally {
|
|
693
|
+
this.writing = false;
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
private restoreTerminalState(): void {
|
|
698
|
+
this.callOriginalWrite(
|
|
699
|
+
SYNC_BEGIN +
|
|
700
|
+
RESET_SCROLL_REGION +
|
|
701
|
+
DISABLE_MOUSE +
|
|
702
|
+
ENABLE_ALT_SCROLL +
|
|
703
|
+
EXIT_ALT_SCREEN +
|
|
704
|
+
SHOW_CURSOR +
|
|
705
|
+
SYNC_END,
|
|
706
|
+
);
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
private restoreForExit(): void {
|
|
710
|
+
try {
|
|
711
|
+
this.restoreTerminalState();
|
|
712
|
+
} catch {
|
|
713
|
+
// Process-exit cleanup cannot report errors and must not throw.
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
/** Export for the emergency reset test. */
|
|
719
|
+
export { emergencyTerminalReset };
|