@sayknow-cli/tui 0.3.12 → 0.3.15
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/dist/types/components/markdown.d.ts +9 -0
- package/dist/types/components/sayknow-pet.d.ts +128 -0
- package/dist/types/components/text.d.ts +9 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/terminal-capabilities.d.ts +44 -0
- package/dist/types/tui.d.ts +55 -1
- package/dist/types/utils.d.ts +23 -0
- package/package.json +3 -3
- package/src/components/image.ts +23 -4
- package/src/components/markdown.ts +216 -29
- package/src/components/sayknow-pet.ts +435 -0
- package/src/components/text.ts +60 -36
- package/src/index.ts +1 -0
- package/src/terminal-capabilities.ts +119 -4
- package/src/tui.ts +503 -67
- package/src/utils.ts +144 -8
package/src/tui.ts
CHANGED
|
@@ -9,7 +9,14 @@ import { getKeybindings } from "./keybindings";
|
|
|
9
9
|
import { isKeyRelease } from "./keys";
|
|
10
10
|
import { renderMetrics } from "./metrics";
|
|
11
11
|
import type { Terminal } from "./terminal";
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
ImageProtocol,
|
|
14
|
+
isImageProtocolForced,
|
|
15
|
+
isUnderTerminalMultiplexer,
|
|
16
|
+
setCellDimensions,
|
|
17
|
+
setTerminalImageProtocol,
|
|
18
|
+
TERMINAL,
|
|
19
|
+
} from "./terminal-capabilities";
|
|
13
20
|
import {
|
|
14
21
|
Ellipsis,
|
|
15
22
|
extractSegments,
|
|
@@ -98,6 +105,82 @@ export const CURSOR_MARKER = "\x1b_pi:c\x07";
|
|
|
98
105
|
|
|
99
106
|
export { visibleWidth };
|
|
100
107
|
|
|
108
|
+
/** Durable source identifier for a semantically anchored viewport row. */
|
|
109
|
+
export type ViewportAnchorId = string;
|
|
110
|
+
|
|
111
|
+
export interface ViewportAnchorRow {
|
|
112
|
+
id: ViewportAnchorId;
|
|
113
|
+
graphemeStart: number;
|
|
114
|
+
graphemeEnd: number;
|
|
115
|
+
cellStart: number;
|
|
116
|
+
cellEnd: number;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface ViewportAnchorRender {
|
|
120
|
+
lines: string[];
|
|
121
|
+
anchors: Array<ViewportAnchorRow | null>;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export interface ViewportAnchorProvider extends Component {
|
|
125
|
+
renderWithViewportAnchors(width: number): ViewportAnchorRender;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export interface ViewportAnchorSource {
|
|
129
|
+
id: ViewportAnchorId;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export interface ViewportAnchorSourceRenderer extends Component {
|
|
133
|
+
renderWithViewportAnchorSource(width: number, source: ViewportAnchorSource): ViewportAnchorRender;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function isViewportAnchorProvider(component: Component): component is ViewportAnchorProvider {
|
|
137
|
+
if (!("renderWithViewportAnchors" in component) || typeof component.renderWithViewportAnchors !== "function") {
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
return !(
|
|
141
|
+
component instanceof Container &&
|
|
142
|
+
component.renderWithViewportAnchors === Container.prototype.renderWithViewportAnchors &&
|
|
143
|
+
component.render !== Container.prototype.render
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function isViewportAnchorSourceRenderer(component: Component): component is ViewportAnchorSourceRenderer {
|
|
148
|
+
return (
|
|
149
|
+
"renderWithViewportAnchorSource" in component && typeof component.renderWithViewportAnchorSource === "function"
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function renderComponentWithViewportAnchors(component: Component, width: number): ViewportAnchorRender {
|
|
154
|
+
if (isViewportAnchorProvider(component)) {
|
|
155
|
+
const rendered = component.renderWithViewportAnchors(width);
|
|
156
|
+
if (rendered.anchors.length !== rendered.lines.length) {
|
|
157
|
+
throw new Error(
|
|
158
|
+
`Viewport anchor provider returned ${rendered.anchors.length} anchors for ${rendered.lines.length} lines`,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
return rendered;
|
|
162
|
+
}
|
|
163
|
+
const lines = component.render(width);
|
|
164
|
+
return { lines, anchors: lines.map(() => null) };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function renderComponentWithViewportAnchorSource(
|
|
168
|
+
component: Component,
|
|
169
|
+
width: number,
|
|
170
|
+
source: ViewportAnchorSource,
|
|
171
|
+
): ViewportAnchorRender {
|
|
172
|
+
if (!isViewportAnchorSourceRenderer(component)) {
|
|
173
|
+
throw new TypeError("Viewport anchor sources require renderer-owned row metadata");
|
|
174
|
+
}
|
|
175
|
+
const rendered = component.renderWithViewportAnchorSource(width, source);
|
|
176
|
+
if (rendered.anchors.length !== rendered.lines.length) {
|
|
177
|
+
throw new Error(
|
|
178
|
+
`Viewport anchor source renderer returned ${rendered.anchors.length} anchors for ${rendered.lines.length} lines`,
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
return rendered;
|
|
182
|
+
}
|
|
183
|
+
|
|
101
184
|
/**
|
|
102
185
|
* Anchor position for overlays
|
|
103
186
|
*/
|
|
@@ -141,7 +224,6 @@ function isTermuxSession(env: Record<string, string | undefined> = Bun.env): boo
|
|
|
141
224
|
return Boolean(env.TERMUX_VERSION);
|
|
142
225
|
}
|
|
143
226
|
|
|
144
|
-
const SKC_TMUX_LAUNCHED_ENV = "SKC_TMUX_LAUNCHED";
|
|
145
227
|
const DISABLED_ENV_VALUES = new Set(["0", "false", "off", "no"]);
|
|
146
228
|
const TRUTHY_ENV_VALUES = new Set(["1", "true", "yes", "on", "y"]);
|
|
147
229
|
|
|
@@ -155,25 +237,38 @@ function envFlagEnabled(value: string | undefined): boolean {
|
|
|
155
237
|
return normalized !== undefined && TRUTHY_ENV_VALUES.has(normalized);
|
|
156
238
|
}
|
|
157
239
|
|
|
158
|
-
function termLooksMultiplexed(value: string | undefined): boolean {
|
|
159
|
-
const term = value?.trim().toLowerCase() ?? "";
|
|
160
|
-
return term.startsWith("tmux") || term.startsWith("screen");
|
|
161
|
-
}
|
|
162
|
-
|
|
163
240
|
function isWindowsTerminalSession(env: Record<string, string | undefined> = Bun.env): boolean {
|
|
164
241
|
return envIsEnabled(env.WT_SESSION) || env.TERM_PROGRAM === "Windows_Terminal";
|
|
165
242
|
}
|
|
166
243
|
|
|
167
|
-
/**
|
|
244
|
+
/**
|
|
245
|
+
* Detect terminal multiplexers where scrollback clearing and height-change
|
|
246
|
+
* redraws are hostile. Delegates to the shared capability predicate so the
|
|
247
|
+
* renderer and graphics-protocol selection agree on what counts as a
|
|
248
|
+
* multiplexed host.
|
|
249
|
+
*/
|
|
168
250
|
function isMultiplexerSession(env: Record<string, string | undefined> = Bun.env): boolean {
|
|
169
|
-
return
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
251
|
+
return isUnderTerminalMultiplexer(env as NodeJS.ProcessEnv);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Startup sixel capability probe policy (pure; exported for tests):
|
|
256
|
+
* - Never probe when PI_FORCE_IMAGE_PROTOCOL is set — an explicit
|
|
257
|
+
* configuration (including "off") is authoritative.
|
|
258
|
+
* - Never probe inside a terminal multiplexer: tmux advertises DA1 ";4"
|
|
259
|
+
* whenever it was compiled with sixel support, regardless of whether the
|
|
260
|
+
* attached client terminal can render sixel, so a positive reply is not
|
|
261
|
+
* end-to-end evidence. Graphics under a multiplexer are strictly opt-in
|
|
262
|
+
* via PI_FORCE_IMAGE_PROTOCOL=sixel.
|
|
263
|
+
* - Probe Windows Terminal (>=1.22 renders sixel but exposes no env marker).
|
|
264
|
+
*/
|
|
265
|
+
export function shouldProbeSixelCapability(
|
|
266
|
+
env: NodeJS.ProcessEnv = Bun.env,
|
|
267
|
+
platform: NodeJS.Platform = process.platform,
|
|
268
|
+
): boolean {
|
|
269
|
+
if (isImageProtocolForced()) return false;
|
|
270
|
+
if (isUnderTerminalMultiplexer(env)) return false;
|
|
271
|
+
return platform === "win32" && Boolean(env.WT_SESSION?.trim());
|
|
177
272
|
}
|
|
178
273
|
|
|
179
274
|
function useLegacyMultiplexerFullRender(env: Record<string, string | undefined> = Bun.env): boolean {
|
|
@@ -212,6 +307,17 @@ function useViewportRepaintPath(terminal: Terminal): boolean {
|
|
|
212
307
|
});
|
|
213
308
|
}
|
|
214
309
|
|
|
310
|
+
function allowsHostNeutralOverflowRepaint(
|
|
311
|
+
terminal: Terminal,
|
|
312
|
+
env: Record<string, string | undefined> = Bun.env,
|
|
313
|
+
): boolean {
|
|
314
|
+
return (
|
|
315
|
+
terminal.isProcessTerminal === true &&
|
|
316
|
+
!isTermuxSession(env) &&
|
|
317
|
+
!(isMultiplexerSession(env) && useLegacyMultiplexerFullRender(env))
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
|
|
215
321
|
function shouldPreserveScrollbackOnFullClear(terminal: Terminal): boolean {
|
|
216
322
|
return isViewportSensitiveHost(Bun.env, process.platform, terminal.isProcessTerminal === true);
|
|
217
323
|
}
|
|
@@ -271,9 +377,10 @@ export interface OverlayHandle {
|
|
|
271
377
|
/**
|
|
272
378
|
* Container - a component that contains other components
|
|
273
379
|
*/
|
|
274
|
-
export class Container implements
|
|
380
|
+
export class Container implements ViewportAnchorProvider {
|
|
275
381
|
children: Component[] = [];
|
|
276
382
|
#disposed = false;
|
|
383
|
+
#viewportAnchorSources = new Map<Component, ViewportAnchorSource>();
|
|
277
384
|
|
|
278
385
|
addChild(component: Component): void {
|
|
279
386
|
this.children.push(component);
|
|
@@ -283,6 +390,7 @@ export class Container implements Component {
|
|
|
283
390
|
const index = this.children.indexOf(component);
|
|
284
391
|
if (index !== -1) {
|
|
285
392
|
this.children.splice(index, 1);
|
|
393
|
+
this.#viewportAnchorSources.delete(component);
|
|
286
394
|
component.dispose?.();
|
|
287
395
|
}
|
|
288
396
|
}
|
|
@@ -292,45 +400,62 @@ export class Container implements Component {
|
|
|
292
400
|
const index = this.children.indexOf(component);
|
|
293
401
|
if (index !== -1) {
|
|
294
402
|
this.children.splice(index, 1);
|
|
403
|
+
this.#viewportAnchorSources.delete(component);
|
|
295
404
|
}
|
|
296
405
|
}
|
|
297
406
|
|
|
298
407
|
clear(): void {
|
|
299
|
-
for (const child of this.children)
|
|
300
|
-
child.dispose?.();
|
|
301
|
-
}
|
|
408
|
+
for (const child of this.children) child.dispose?.();
|
|
302
409
|
this.children = [];
|
|
410
|
+
this.#viewportAnchorSources.clear();
|
|
303
411
|
}
|
|
304
412
|
|
|
305
413
|
/** Remove all children without disposing them (for detach-then-readd reuse). */
|
|
306
414
|
detachAll(): void {
|
|
307
415
|
this.children = [];
|
|
416
|
+
this.#viewportAnchorSources.clear();
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** Registers a direct child as eligible for semantic viewport anchoring. */
|
|
420
|
+
setViewportAnchorSource(component: Component, source: ViewportAnchorSource | null): void {
|
|
421
|
+
if (source !== null && !isViewportAnchorSourceRenderer(component)) {
|
|
422
|
+
throw new TypeError("Viewport anchor sources require renderer-owned row metadata");
|
|
423
|
+
}
|
|
424
|
+
if (source === null) this.#viewportAnchorSources.delete(component);
|
|
425
|
+
else this.#viewportAnchorSources.set(component, source);
|
|
308
426
|
}
|
|
309
427
|
|
|
310
428
|
dispose(): void {
|
|
311
429
|
if (this.#disposed) return;
|
|
312
430
|
this.#disposed = true;
|
|
313
|
-
for (const child of this.children)
|
|
314
|
-
|
|
315
|
-
}
|
|
431
|
+
for (const child of this.children) child.dispose?.();
|
|
432
|
+
this.#viewportAnchorSources.clear();
|
|
316
433
|
}
|
|
317
434
|
|
|
318
435
|
invalidate(): void {
|
|
319
|
-
for (const child of this.children)
|
|
320
|
-
child.invalidate?.();
|
|
321
|
-
}
|
|
436
|
+
for (const child of this.children) child.invalidate?.();
|
|
322
437
|
}
|
|
323
438
|
|
|
324
439
|
render(width: number): string[] {
|
|
440
|
+
return this.renderWithViewportAnchors(width).lines;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
renderWithViewportAnchors(width: number): ViewportAnchorRender {
|
|
325
444
|
width = Math.max(1, width);
|
|
326
445
|
const lines: string[] = [];
|
|
446
|
+
const anchors: Array<ViewportAnchorRow | null> = [];
|
|
327
447
|
for (const child of this.children) {
|
|
328
|
-
const
|
|
329
|
-
|
|
330
|
-
|
|
448
|
+
const source = this.#viewportAnchorSources.get(child);
|
|
449
|
+
const rendered =
|
|
450
|
+
source === undefined
|
|
451
|
+
? safeRenderComponentWithViewportAnchors(child, width, "container-child")
|
|
452
|
+
: safeRenderComponentWithViewportAnchorSource(child, width, source, "container-anchor-child");
|
|
453
|
+
for (let index = 0; index < rendered.lines.length; index++) {
|
|
454
|
+
lines.push(rendered.lines[index]);
|
|
455
|
+
anchors.push(rendered.anchors[index] ?? null);
|
|
331
456
|
}
|
|
332
457
|
}
|
|
333
|
-
return lines;
|
|
458
|
+
return { lines, anchors };
|
|
334
459
|
}
|
|
335
460
|
}
|
|
336
461
|
|
|
@@ -348,23 +473,58 @@ const reportedRenderErrors = new Set<string>();
|
|
|
348
473
|
* command such as `/background`). Isolate the failure: log it once, emit a
|
|
349
474
|
* visible fallback line, and keep rendering the rest of the tree.
|
|
350
475
|
*/
|
|
476
|
+
function renderFailure(component: Component, where: string, err: unknown): string[] {
|
|
477
|
+
const name = component?.constructor?.name ?? "Component";
|
|
478
|
+
const key = `${where}:${name}:${err instanceof Error ? err.message : String(err)}`;
|
|
479
|
+
if (!reportedRenderErrors.has(key)) {
|
|
480
|
+
if (reportedRenderErrors.size >= MAX_REPORTED_RENDER_ERRORS) reportedRenderErrors.clear();
|
|
481
|
+
reportedRenderErrors.add(key);
|
|
482
|
+
logger.error("Component render failed; emitting fallback line", {
|
|
483
|
+
where,
|
|
484
|
+
component: name,
|
|
485
|
+
error: err instanceof Error ? err.message : String(err),
|
|
486
|
+
stack: err instanceof Error ? err.stack : undefined,
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
return [`[render error: ${name}]`];
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
let viewportAnchorRenderFailureCount = 0;
|
|
493
|
+
|
|
351
494
|
function safeRenderComponent(component: Component, width: number, where: string): string[] {
|
|
352
495
|
try {
|
|
353
496
|
return component.render(width);
|
|
354
497
|
} catch (err) {
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
498
|
+
return renderFailure(component, where, err);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function safeRenderComponentWithViewportAnchors(
|
|
503
|
+
component: Component,
|
|
504
|
+
width: number,
|
|
505
|
+
where: string,
|
|
506
|
+
): ViewportAnchorRender {
|
|
507
|
+
try {
|
|
508
|
+
return renderComponentWithViewportAnchors(component, width);
|
|
509
|
+
} catch (err) {
|
|
510
|
+
viewportAnchorRenderFailureCount += 1;
|
|
511
|
+
const lines = renderFailure(component, where, err);
|
|
512
|
+
return { lines, anchors: lines.map(() => null) };
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function safeRenderComponentWithViewportAnchorSource(
|
|
517
|
+
component: Component,
|
|
518
|
+
width: number,
|
|
519
|
+
source: ViewportAnchorSource,
|
|
520
|
+
where: string,
|
|
521
|
+
): ViewportAnchorRender {
|
|
522
|
+
try {
|
|
523
|
+
return renderComponentWithViewportAnchorSource(component, width, source);
|
|
524
|
+
} catch (err) {
|
|
525
|
+
viewportAnchorRenderFailureCount += 1;
|
|
526
|
+
const lines = renderFailure(component, where, err);
|
|
527
|
+
return { lines, anchors: lines.map(() => null) };
|
|
368
528
|
}
|
|
369
529
|
}
|
|
370
530
|
|
|
@@ -374,6 +534,18 @@ type LineNormalizationCacheEntry = {
|
|
|
374
534
|
width: number | undefined;
|
|
375
535
|
};
|
|
376
536
|
|
|
537
|
+
type ViewportAnchorFrame = {
|
|
538
|
+
startRow: number;
|
|
539
|
+
anchors: Array<ViewportAnchorRow | null>;
|
|
540
|
+
};
|
|
541
|
+
|
|
542
|
+
type ManualViewportAnchor = {
|
|
543
|
+
id: ViewportAnchorId;
|
|
544
|
+
graphemeIndex: number;
|
|
545
|
+
cellOffset: number;
|
|
546
|
+
desiredScreenRow: number;
|
|
547
|
+
};
|
|
548
|
+
|
|
377
549
|
type TuiRenderCounterSnapshot = {
|
|
378
550
|
debugRedrawEnvReads: number;
|
|
379
551
|
debugRedrawAppendWrites: number;
|
|
@@ -386,6 +558,7 @@ type TuiRenderCounterSnapshot = {
|
|
|
386
558
|
export class TUI extends Container {
|
|
387
559
|
terminal: Terminal;
|
|
388
560
|
#previousLines: string[] = [];
|
|
561
|
+
#latestRenderedLines: string[] = [];
|
|
389
562
|
/**
|
|
390
563
|
* Raw (pre-normalization) lines from the previous frame, kept only when the
|
|
391
564
|
* virtual-viewport flag is on. Used to detect whether the off-screen prefix is
|
|
@@ -418,6 +591,11 @@ export class TUI extends Container {
|
|
|
418
591
|
#hardwareCursorRow = 0; // Actual terminal cursor row (may differ due to IME positioning)
|
|
419
592
|
#viewportTopRow = 0; // Content row currently mapped to screen row 0
|
|
420
593
|
#manualViewportTop: number | undefined;
|
|
594
|
+
#viewportAnchorComponent: Component | null = null;
|
|
595
|
+
#viewportAnchorFrame: ViewportAnchorFrame | null = null;
|
|
596
|
+
#manualViewportAnchor: ManualViewportAnchor | null = null;
|
|
597
|
+
#manualViewportFallbackAnchors: ManualViewportAnchor[] = [];
|
|
598
|
+
#reconcileMissingViewportAnchor = false;
|
|
421
599
|
#lastCursorPosition: { row: number; col: number } | null = null;
|
|
422
600
|
#sixelProbePendingDa = false;
|
|
423
601
|
#sixelProbePendingGraphics = false;
|
|
@@ -555,15 +733,100 @@ export class TUI extends Container {
|
|
|
555
733
|
this.#bottomPinnedComponent = component;
|
|
556
734
|
this.requestRender();
|
|
557
735
|
}
|
|
736
|
+
|
|
737
|
+
/** Register the direct child whose rows are eligible for semantic viewport anchoring. */
|
|
738
|
+
setViewportAnchorComponent(component: Component | null): void {
|
|
739
|
+
if (component !== null && !isViewportAnchorProvider(component)) {
|
|
740
|
+
throw new TypeError("Viewport anchor components must provide renderer-owned row metadata");
|
|
741
|
+
}
|
|
742
|
+
if (this.#viewportAnchorComponent === component) return;
|
|
743
|
+
this.#viewportAnchorComponent = component;
|
|
744
|
+
this.#viewportAnchorFrame = null;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
/** Clear manual viewport ownership before replacing the transcript identity namespace. */
|
|
748
|
+
resetViewportAnchorIntent(): void {
|
|
749
|
+
this.#manualViewportTop = undefined;
|
|
750
|
+
this.#manualViewportAnchor = null;
|
|
751
|
+
this.#manualViewportFallbackAnchors = [];
|
|
752
|
+
this.#reconcileMissingViewportAnchor = false;
|
|
753
|
+
this.#viewportAnchorFrame = null;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/** Allow one semantic-neighbor reconciliation after a definitive same-transcript rebuild. */
|
|
757
|
+
prepareViewportAnchorForTranscriptRebuild(): void {
|
|
758
|
+
if (this.#manualViewportAnchor !== null) this.#reconcileMissingViewportAnchor = true;
|
|
759
|
+
}
|
|
760
|
+
|
|
558
761
|
scrollViewportPages(direction: -1 | 1): boolean {
|
|
559
762
|
const height = this.terminal.rows;
|
|
560
763
|
const width = this.terminal.columns;
|
|
561
764
|
if (height <= 0 || width <= 0 || this.#previousLines.length === 0) return false;
|
|
562
765
|
const maxViewportTop = Math.max(0, this.#previousLines.length - height);
|
|
563
|
-
|
|
564
|
-
const
|
|
565
|
-
|
|
566
|
-
|
|
766
|
+
let currentViewportTop = Math.max(0, Math.min(maxViewportTop, this.#manualViewportTop ?? this.#viewportTopRow));
|
|
767
|
+
const frame = this.#viewportAnchorFrame;
|
|
768
|
+
if (this.#manualViewportAnchor !== null) {
|
|
769
|
+
if (frame === null) return false;
|
|
770
|
+
const resolvedViewportTop = this.#resolveManualAnchor(frame);
|
|
771
|
+
if (resolvedViewportTop === null) return false;
|
|
772
|
+
currentViewportTop = Math.max(0, Math.min(maxViewportTop, resolvedViewportTop));
|
|
773
|
+
}
|
|
774
|
+
const targetViewportTop = Math.max(
|
|
775
|
+
0,
|
|
776
|
+
Math.min(maxViewportTop, currentViewportTop + direction * Math.max(1, height - 1)),
|
|
777
|
+
);
|
|
778
|
+
if (frame !== null) {
|
|
779
|
+
const desiredScreenRow = this.#manualViewportAnchor?.desiredScreenRow ?? (direction < 0 ? 0 : height - 1);
|
|
780
|
+
const targetRow = targetViewportTop + desiredScreenRow - frame.startRow;
|
|
781
|
+
let selected: { row: number; anchor: ViewportAnchorRow } | undefined;
|
|
782
|
+
const firstCandidateRow = Math.max(0, targetViewportTop - frame.startRow);
|
|
783
|
+
const lastCandidateRow = Math.min(frame.anchors.length, targetViewportTop + height - frame.startRow);
|
|
784
|
+
for (let row = firstCandidateRow; row < lastCandidateRow; row++) {
|
|
785
|
+
const anchor = frame.anchors[row];
|
|
786
|
+
if (anchor === null) continue;
|
|
787
|
+
if (
|
|
788
|
+
selected === undefined ||
|
|
789
|
+
Math.abs(row - targetRow) < Math.abs(selected.row - targetRow) ||
|
|
790
|
+
(Math.abs(row - targetRow) === Math.abs(selected.row - targetRow) &&
|
|
791
|
+
(direction < 0 ? row < selected.row : row > selected.row))
|
|
792
|
+
)
|
|
793
|
+
selected = { row, anchor };
|
|
794
|
+
}
|
|
795
|
+
if (selected === undefined) {
|
|
796
|
+
if (this.#manualViewportAnchor !== null) return false;
|
|
797
|
+
} else {
|
|
798
|
+
this.#manualViewportAnchor = {
|
|
799
|
+
id: selected.anchor.id,
|
|
800
|
+
graphemeIndex:
|
|
801
|
+
direction < 0
|
|
802
|
+
? selected.anchor.graphemeStart
|
|
803
|
+
: Math.max(selected.anchor.graphemeStart, selected.anchor.graphemeEnd - 1),
|
|
804
|
+
cellOffset:
|
|
805
|
+
direction < 0
|
|
806
|
+
? selected.anchor.cellStart
|
|
807
|
+
: Math.max(selected.anchor.cellStart, selected.anchor.cellEnd - 1),
|
|
808
|
+
desiredScreenRow: selected.row + frame.startRow - targetViewportTop,
|
|
809
|
+
};
|
|
810
|
+
const fallbacks: ManualViewportAnchor[] = [];
|
|
811
|
+
for (let row = firstCandidateRow; row < lastCandidateRow; row++) {
|
|
812
|
+
const anchor = frame.anchors[row];
|
|
813
|
+
if (anchor === null || row === selected.row) continue;
|
|
814
|
+
fallbacks.push({
|
|
815
|
+
id: anchor.id,
|
|
816
|
+
graphemeIndex:
|
|
817
|
+
direction < 0 ? anchor.graphemeStart : Math.max(anchor.graphemeStart, anchor.graphemeEnd - 1),
|
|
818
|
+
cellOffset: direction < 0 ? anchor.cellStart : Math.max(anchor.cellStart, anchor.cellEnd - 1),
|
|
819
|
+
desiredScreenRow: row + frame.startRow - targetViewportTop,
|
|
820
|
+
});
|
|
821
|
+
}
|
|
822
|
+
fallbacks.sort(
|
|
823
|
+
(a, b) =>
|
|
824
|
+
Math.abs(a.desiredScreenRow - this.#manualViewportAnchor!.desiredScreenRow) -
|
|
825
|
+
Math.abs(b.desiredScreenRow - this.#manualViewportAnchor!.desiredScreenRow),
|
|
826
|
+
);
|
|
827
|
+
this.#manualViewportFallbackAnchors = fallbacks;
|
|
828
|
+
}
|
|
829
|
+
}
|
|
567
830
|
this.#manualViewportTop = targetViewportTop;
|
|
568
831
|
return this.#repaintViewportFromLines(
|
|
569
832
|
this.#previousLines,
|
|
@@ -572,6 +835,7 @@ export class TUI extends Container {
|
|
|
572
835
|
targetViewportTop,
|
|
573
836
|
null,
|
|
574
837
|
"manual viewport scroll",
|
|
838
|
+
this.#manualViewportAnchor !== null,
|
|
575
839
|
);
|
|
576
840
|
}
|
|
577
841
|
|
|
@@ -579,16 +843,22 @@ export class TUI extends Container {
|
|
|
579
843
|
if (this.#manualViewportTop === undefined) return false;
|
|
580
844
|
const height = this.terminal.rows;
|
|
581
845
|
const width = this.terminal.columns;
|
|
582
|
-
const
|
|
846
|
+
const liveLines = this.#latestRenderedLines;
|
|
847
|
+
const liveViewportTop = Math.max(0, liveLines.length - height);
|
|
583
848
|
this.#manualViewportTop = undefined;
|
|
584
|
-
|
|
585
|
-
|
|
849
|
+
this.#manualViewportAnchor = null;
|
|
850
|
+
this.#manualViewportFallbackAnchors = [];
|
|
851
|
+
this.#reconcileMissingViewportAnchor = false;
|
|
852
|
+
const repainted = this.#repaintViewportFromLines(
|
|
853
|
+
liveLines,
|
|
586
854
|
width,
|
|
587
855
|
height,
|
|
588
856
|
liveViewportTop,
|
|
589
857
|
this.#lastCursorPosition,
|
|
590
858
|
"manual viewport follow live",
|
|
591
859
|
);
|
|
860
|
+
if (repainted) this.#previousLines = liveLines;
|
|
861
|
+
return repainted;
|
|
592
862
|
}
|
|
593
863
|
|
|
594
864
|
/**
|
|
@@ -757,8 +1027,7 @@ export class TUI extends Container {
|
|
|
757
1027
|
|
|
758
1028
|
#querySixelSupport(): void {
|
|
759
1029
|
if (TERMINAL.imageProtocol) return;
|
|
760
|
-
if (
|
|
761
|
-
if (!Bun.env.WT_SESSION) return;
|
|
1030
|
+
if (!this.#isSixelProbeCandidate()) return;
|
|
762
1031
|
if (!process.stdin.isTTY || !process.stdout.isTTY) return;
|
|
763
1032
|
|
|
764
1033
|
this.#clearSixelProbeState();
|
|
@@ -772,6 +1041,10 @@ export class TUI extends Container {
|
|
|
772
1041
|
}, 250);
|
|
773
1042
|
}
|
|
774
1043
|
|
|
1044
|
+
#isSixelProbeCandidate(): boolean {
|
|
1045
|
+
return shouldProbeSixelCapability();
|
|
1046
|
+
}
|
|
1047
|
+
|
|
775
1048
|
#handleSixelProbeInput(data: string): InputListenerResult {
|
|
776
1049
|
if (!this.#sixelProbePendingDa && !this.#sixelProbePendingGraphics) {
|
|
777
1050
|
return undefined;
|
|
@@ -798,11 +1071,15 @@ export class TUI extends Container {
|
|
|
798
1071
|
|
|
799
1072
|
if (useDa && this.#sixelProbePendingDa) {
|
|
800
1073
|
this.#sixelProbePendingDa = false;
|
|
801
|
-
const
|
|
1074
|
+
const params = (match[1] ?? "")
|
|
802
1075
|
.split(";")
|
|
803
1076
|
.map(value => Number.parseInt(value, 10))
|
|
804
1077
|
.filter(value => Number.isFinite(value));
|
|
805
|
-
|
|
1078
|
+
// The first DA1 parameter is the device/operating class (e.g. 1,
|
|
1079
|
+
// 62, 64), not an extension attribute: `CSI ?4;6c` identifies a
|
|
1080
|
+
// VT132, it does not advertise sixel. Only the parameters after
|
|
1081
|
+
// the class carry attributes like 4 (sixel graphics).
|
|
1082
|
+
const hasSixelAttribute = params.slice(1).includes(4);
|
|
806
1083
|
if (hasSixelAttribute) {
|
|
807
1084
|
this.#sixelProbePendingGraphics = false;
|
|
808
1085
|
probeOutcome = true;
|
|
@@ -811,8 +1088,11 @@ export class TUI extends Container {
|
|
|
811
1088
|
}
|
|
812
1089
|
} else if (!useDa && this.#sixelProbePendingGraphics) {
|
|
813
1090
|
this.#sixelProbePendingGraphics = false;
|
|
1091
|
+
// XTSMGRAPHICS reply is `CSI ? 2 ; Ps ; ... S` where Ps=0 means
|
|
1092
|
+
// success and 1/2/3 are errors (tmux answers our unsupported
|
|
1093
|
+
// read with `CSI ?2;3;0S`). Only a success reply proves sixel.
|
|
814
1094
|
const status = Number.parseInt(match[1] ?? "", 10);
|
|
815
|
-
const supportsSixel =
|
|
1095
|
+
const supportsSixel = status === 0;
|
|
816
1096
|
if (supportsSixel) {
|
|
817
1097
|
this.#sixelProbePendingDa = false;
|
|
818
1098
|
probeOutcome = true;
|
|
@@ -926,6 +1206,7 @@ export class TUI extends Container {
|
|
|
926
1206
|
// focus/listener state is intentionally preserved so input routing survives
|
|
927
1207
|
// a resume.
|
|
928
1208
|
this.#previousLines = [];
|
|
1209
|
+
this.#latestRenderedLines = [];
|
|
929
1210
|
this.#previousRaw = [];
|
|
930
1211
|
this.#lineNormalizationCache.clear();
|
|
931
1212
|
this.#lineTruncationCache.clear();
|
|
@@ -962,6 +1243,7 @@ export class TUI extends Container {
|
|
|
962
1243
|
// A forced full redraw supersedes any queued input-priority render.
|
|
963
1244
|
this.#inputRenderPending = false;
|
|
964
1245
|
this.#previousLines = [];
|
|
1246
|
+
this.#latestRenderedLines = [];
|
|
965
1247
|
this.#previousRaw = [];
|
|
966
1248
|
this.#lineNormalizationCache.clear();
|
|
967
1249
|
this.#lineTruncationCache.clear();
|
|
@@ -1547,7 +1829,11 @@ export class TUI extends Container {
|
|
|
1547
1829
|
return lines;
|
|
1548
1830
|
}
|
|
1549
1831
|
|
|
1550
|
-
#padBeforeBottomPinnedComponent(
|
|
1832
|
+
#padBeforeBottomPinnedComponent(
|
|
1833
|
+
lines: string[],
|
|
1834
|
+
height: number,
|
|
1835
|
+
renderedChildren: Map<Component, string[]>,
|
|
1836
|
+
): string[] {
|
|
1551
1837
|
const component = this.#bottomPinnedComponent;
|
|
1552
1838
|
if (component === null || lines.length >= height) return lines;
|
|
1553
1839
|
|
|
@@ -1562,7 +1848,7 @@ export class TUI extends Container {
|
|
|
1562
1848
|
|
|
1563
1849
|
let pinnedLineCount = 0;
|
|
1564
1850
|
for (let i = pinnedStart; i < this.children.length; i++) {
|
|
1565
|
-
pinnedLineCount +=
|
|
1851
|
+
pinnedLineCount += (renderedChildren.get(this.children[i]) ?? []).length;
|
|
1566
1852
|
}
|
|
1567
1853
|
|
|
1568
1854
|
const blankRows = height - lines.length;
|
|
@@ -1571,6 +1857,55 @@ export class TUI extends Container {
|
|
|
1571
1857
|
padded.splice(insertAt, 0, ...Array.from({ length: blankRows }, () => ""));
|
|
1572
1858
|
return padded;
|
|
1573
1859
|
}
|
|
1860
|
+
#resolveManualAnchor(frame: ViewportAnchorFrame): number | null {
|
|
1861
|
+
const anchor = this.#manualViewportAnchor;
|
|
1862
|
+
if (anchor === null) return null;
|
|
1863
|
+
const row = frame.anchors.findIndex(
|
|
1864
|
+
candidate =>
|
|
1865
|
+
candidate !== null &&
|
|
1866
|
+
candidate.id === anchor.id &&
|
|
1867
|
+
candidate.graphemeStart <= anchor.graphemeIndex &&
|
|
1868
|
+
anchor.graphemeIndex < candidate.graphemeEnd &&
|
|
1869
|
+
candidate.cellStart <= anchor.cellOffset &&
|
|
1870
|
+
anchor.cellOffset < candidate.cellEnd,
|
|
1871
|
+
);
|
|
1872
|
+
return row < 0 ? null : Math.max(0, frame.startRow + row - anchor.desiredScreenRow);
|
|
1873
|
+
}
|
|
1874
|
+
|
|
1875
|
+
#resolvePreparedManualAnchor(frame: ViewportAnchorFrame): number | null {
|
|
1876
|
+
const previous = this.#manualViewportAnchor;
|
|
1877
|
+
for (const fallback of this.#manualViewportFallbackAnchors) {
|
|
1878
|
+
this.#manualViewportAnchor = fallback;
|
|
1879
|
+
const resolved = this.#resolveManualAnchor(frame);
|
|
1880
|
+
if (resolved !== null) return resolved;
|
|
1881
|
+
}
|
|
1882
|
+
const targetRow = Math.max(
|
|
1883
|
+
0,
|
|
1884
|
+
Math.min(
|
|
1885
|
+
frame.anchors.length - 1,
|
|
1886
|
+
(this.#manualViewportTop ?? 0) + (previous?.desiredScreenRow ?? 0) - frame.startRow,
|
|
1887
|
+
),
|
|
1888
|
+
);
|
|
1889
|
+
let selectedRow = -1;
|
|
1890
|
+
for (let distance = 0; distance < frame.anchors.length; distance++) {
|
|
1891
|
+
for (const row of [targetRow - distance, targetRow + distance]) {
|
|
1892
|
+
if (row < 0 || row >= frame.anchors.length || frame.anchors[row] === null) continue;
|
|
1893
|
+
selectedRow = row;
|
|
1894
|
+
break;
|
|
1895
|
+
}
|
|
1896
|
+
if (selectedRow >= 0) break;
|
|
1897
|
+
}
|
|
1898
|
+
const selected = selectedRow >= 0 ? frame.anchors[selectedRow] : null;
|
|
1899
|
+
if (selected === null) return null;
|
|
1900
|
+
this.#manualViewportAnchor = {
|
|
1901
|
+
id: selected.id,
|
|
1902
|
+
graphemeIndex: selected.graphemeStart,
|
|
1903
|
+
cellOffset: selected.cellStart,
|
|
1904
|
+
desiredScreenRow: previous?.desiredScreenRow ?? 0,
|
|
1905
|
+
};
|
|
1906
|
+
return this.#resolveManualAnchor(frame);
|
|
1907
|
+
}
|
|
1908
|
+
|
|
1574
1909
|
#repaintViewportFromLines(
|
|
1575
1910
|
lines: string[],
|
|
1576
1911
|
width: number,
|
|
@@ -1578,9 +1913,10 @@ export class TUI extends Container {
|
|
|
1578
1913
|
viewportTop: number,
|
|
1579
1914
|
cursorPos: { row: number; col: number } | null,
|
|
1580
1915
|
reason: string,
|
|
1916
|
+
allowPastLiveBottom = false,
|
|
1581
1917
|
): boolean {
|
|
1582
1918
|
if (height <= 0 || width <= 0) return false;
|
|
1583
|
-
const maxViewportTop = Math.max(0, lines.length - height);
|
|
1919
|
+
const maxViewportTop = Math.max(0, lines.length - (allowPastLiveBottom ? 1 : height));
|
|
1584
1920
|
const nextViewportTop = Math.max(0, Math.min(maxViewportTop, viewportTop));
|
|
1585
1921
|
const currentScreenRow = Math.max(0, Math.min(height - 1, this.#hardwareCursorRow - this.#viewportTopRow));
|
|
1586
1922
|
let buffer = "\x1b[?2026h";
|
|
@@ -1642,13 +1978,27 @@ export class TUI extends Container {
|
|
|
1642
1978
|
return targetScreenRow - currentScreenRow;
|
|
1643
1979
|
};
|
|
1644
1980
|
|
|
1645
|
-
// Render
|
|
1981
|
+
// Render direct children once so the registered transcript component retains row ownership.
|
|
1646
1982
|
const renderTreeStart = renderMetrics.now();
|
|
1647
|
-
|
|
1983
|
+
const renderedLines: string[] = [];
|
|
1984
|
+
const renderedChildren = new Map<Component, string[]>();
|
|
1985
|
+
let anchorFrame: ViewportAnchorFrame | null = null;
|
|
1986
|
+
const anchorRenderFailureCountBefore = viewportAnchorRenderFailureCount;
|
|
1987
|
+
for (const child of this.children) {
|
|
1988
|
+
const rendered = safeRenderComponentWithViewportAnchors(child, width, "tui-child");
|
|
1989
|
+
renderedChildren.set(child, rendered.lines);
|
|
1990
|
+
if (child === this.#viewportAnchorComponent && rendered.anchors.some(anchor => anchor !== null)) {
|
|
1991
|
+
anchorFrame = { startRow: renderedLines.length, anchors: rendered.anchors };
|
|
1992
|
+
}
|
|
1993
|
+
for (const line of rendered.lines) renderedLines.push(line);
|
|
1994
|
+
}
|
|
1995
|
+
const anchorRenderFailed = viewportAnchorRenderFailureCount !== anchorRenderFailureCountBefore;
|
|
1996
|
+
let newLines = renderedLines;
|
|
1997
|
+
this.#viewportAnchorFrame = anchorFrame;
|
|
1648
1998
|
if (renderMetrics.enabled) renderMetrics.recordHelper("renderTree", renderMetrics.now() - renderTreeStart);
|
|
1649
1999
|
|
|
1650
2000
|
if (this.#bottomPinnedComponent !== null && height > 0) {
|
|
1651
|
-
newLines = this.#padBeforeBottomPinnedComponent(newLines, height);
|
|
2001
|
+
newLines = this.#padBeforeBottomPinnedComponent(newLines, height, renderedChildren);
|
|
1652
2002
|
}
|
|
1653
2003
|
|
|
1654
2004
|
// Composite overlays into the rendered lines (before differential compare)
|
|
@@ -1719,20 +2069,78 @@ export class TUI extends Container {
|
|
|
1719
2069
|
renderMetrics.recordLineCount("measured", total - diffStart);
|
|
1720
2070
|
if (usedWindowNormalize) renderMetrics.recordLineCount("offscreenScan", diffStart);
|
|
1721
2071
|
}
|
|
2072
|
+
this.#latestRenderedLines = newLines;
|
|
1722
2073
|
|
|
1723
2074
|
if (this.#manualViewportTop !== undefined) {
|
|
1724
|
-
|
|
1725
|
-
|
|
2075
|
+
let resolvedAnchorTop = anchorFrame === null ? null : this.#resolveManualAnchor(anchorFrame);
|
|
2076
|
+
if (
|
|
2077
|
+
this.#manualViewportAnchor !== null &&
|
|
2078
|
+
resolvedAnchorTop === null &&
|
|
2079
|
+
this.#reconcileMissingViewportAnchor
|
|
2080
|
+
) {
|
|
2081
|
+
resolvedAnchorTop = anchorFrame === null ? null : this.#resolvePreparedManualAnchor(anchorFrame);
|
|
2082
|
+
this.#reconcileMissingViewportAnchor = false;
|
|
2083
|
+
if (resolvedAnchorTop === null) {
|
|
2084
|
+
this.#manualViewportAnchor = null;
|
|
2085
|
+
this.#manualViewportFallbackAnchors = [];
|
|
2086
|
+
}
|
|
2087
|
+
}
|
|
2088
|
+
if (this.#manualViewportAnchor !== null && resolvedAnchorTop === null) {
|
|
2089
|
+
if (anchorRenderFailed) {
|
|
2090
|
+
// Keep semantic intent armed for recovery, but render the diagnostic frame
|
|
2091
|
+
// instead of masking a provider failure behind stale transcript content.
|
|
2092
|
+
this.#repaintViewportFromLines(
|
|
2093
|
+
newLines,
|
|
2094
|
+
width,
|
|
2095
|
+
height,
|
|
2096
|
+
this.#manualViewportTop,
|
|
2097
|
+
null,
|
|
2098
|
+
"failed semantic viewport render",
|
|
2099
|
+
true,
|
|
2100
|
+
);
|
|
2101
|
+
this.#previousLines = newLines;
|
|
2102
|
+
this.#previousWidth = width;
|
|
2103
|
+
this.#previousHeight = height;
|
|
2104
|
+
return;
|
|
2105
|
+
}
|
|
2106
|
+
// A formerly valid semantic target is temporarily absent (provider removal,
|
|
2107
|
+
// replacement, eviction, or object deletion). Keep the last resolved frame
|
|
2108
|
+
// instead of silently reinterpreting manual intent as a numeric viewport.
|
|
2109
|
+
const retainedLines = this.#previousLines.length > 0 ? this.#previousLines : newLines;
|
|
2110
|
+
this.#repaintViewportFromLines(
|
|
2111
|
+
retainedLines,
|
|
2112
|
+
width,
|
|
2113
|
+
height,
|
|
2114
|
+
this.#manualViewportTop,
|
|
2115
|
+
null,
|
|
2116
|
+
"unresolved semantic viewport render",
|
|
2117
|
+
true,
|
|
2118
|
+
);
|
|
2119
|
+
this.#previousWidth = width;
|
|
2120
|
+
this.#previousHeight = height;
|
|
2121
|
+
return;
|
|
2122
|
+
}
|
|
2123
|
+
const nextViewportTop = resolvedAnchorTop ?? this.#manualViewportTop;
|
|
2124
|
+
if (
|
|
2125
|
+
this.#previousWidth === width &&
|
|
2126
|
+
this.#previousHeight === height &&
|
|
2127
|
+
nextViewportTop === this.#manualViewportTop &&
|
|
2128
|
+
newLines.length === this.#previousLines.length &&
|
|
2129
|
+
newLines.every((line, index) => line === this.#previousLines[index])
|
|
2130
|
+
) {
|
|
2131
|
+
return;
|
|
2132
|
+
}
|
|
1726
2133
|
this.#manualViewportTop = nextViewportTop;
|
|
1727
|
-
|
|
2134
|
+
this.#reconcileMissingViewportAnchor = false;
|
|
1728
2135
|
if (
|
|
1729
2136
|
this.#repaintViewportFromLines(
|
|
1730
2137
|
newLines,
|
|
1731
2138
|
width,
|
|
1732
2139
|
height,
|
|
1733
2140
|
nextViewportTop,
|
|
1734
|
-
|
|
2141
|
+
null,
|
|
1735
2142
|
"manual viewport render",
|
|
2143
|
+
this.#manualViewportAnchor !== null,
|
|
1736
2144
|
)
|
|
1737
2145
|
) {
|
|
1738
2146
|
this.#previousLines = newLines;
|
|
@@ -1878,7 +2286,11 @@ export class TUI extends Container {
|
|
|
1878
2286
|
// Configurable via setClearOnShrink() or PI_CLEAR_ON_SHRINK=0 env var
|
|
1879
2287
|
if (this.#clearOnShrink && newLines.length < this.#previousLines.length && this.overlayStack.length === 0) {
|
|
1880
2288
|
logRedraw(`clearOnShrink (prev=${this.#previousLines.length}, new=${newLines.length})`);
|
|
1881
|
-
if (
|
|
2289
|
+
if (
|
|
2290
|
+
useViewportRepaintPath(this.terminal) ||
|
|
2291
|
+
((this.#previousLines.length > height || newLines.length > height) &&
|
|
2292
|
+
allowsHostNeutralOverflowRepaint(this.terminal))
|
|
2293
|
+
) {
|
|
1882
2294
|
viewportRepaint(`clearOnShrink (prev=${this.#previousLines.length}, new=${newLines.length})`);
|
|
1883
2295
|
} else {
|
|
1884
2296
|
fullRender(true, "clearOnShrink");
|
|
@@ -1981,7 +2393,12 @@ export class TUI extends Container {
|
|
|
1981
2393
|
// back to live.
|
|
1982
2394
|
if (firstChanged < prevViewportTop) {
|
|
1983
2395
|
logRedraw(`firstChanged < viewportTop (${firstChanged} < ${prevViewportTop})`);
|
|
1984
|
-
if (
|
|
2396
|
+
if (
|
|
2397
|
+
useViewportRepaintPath(this.terminal) ||
|
|
2398
|
+
(newLines.length <= this.#previousLines.length &&
|
|
2399
|
+
(this.#previousLines.length > height || newLines.length > height) &&
|
|
2400
|
+
allowsHostNeutralOverflowRepaint(this.terminal))
|
|
2401
|
+
) {
|
|
1985
2402
|
viewportRepaint(`firstChanged < viewportTop (${firstChanged} < ${prevViewportTop})`);
|
|
1986
2403
|
return;
|
|
1987
2404
|
}
|
|
@@ -2163,11 +2580,30 @@ export class TUI extends Container {
|
|
|
2163
2580
|
return { seq, toRow: targetRow };
|
|
2164
2581
|
}
|
|
2165
2582
|
|
|
2583
|
+
/**
|
|
2584
|
+
* Register an emitter whose escape payload is appended to every render
|
|
2585
|
+
* write (inside its own synchronized-output block, cursor saved/restored).
|
|
2586
|
+
* Used for absolute-positioned overlays such as pixel-image pets that live
|
|
2587
|
+
* outside the line-based component model. Return null to emit nothing.
|
|
2588
|
+
*/
|
|
2589
|
+
setPostRenderEmitter(emitter: (() => string | null) | undefined): void {
|
|
2590
|
+
this.#postRenderEmitter = emitter;
|
|
2591
|
+
}
|
|
2592
|
+
|
|
2593
|
+
#postRenderEmitter: (() => string | null) | undefined;
|
|
2594
|
+
|
|
2166
2595
|
#writeRenderBufferAndReanchorImeCursor(
|
|
2167
2596
|
buffer: string,
|
|
2168
2597
|
cursorPos: { row: number; col: number } | null,
|
|
2169
2598
|
totalLines: number,
|
|
2170
2599
|
): boolean {
|
|
2600
|
+
const overlay = this.#postRenderEmitter?.();
|
|
2601
|
+
if (overlay) {
|
|
2602
|
+
// DECSC/DECRC keep the hardware cursor stable; the dedicated
|
|
2603
|
+
// synchronized block prevents visible tearing while the overlay
|
|
2604
|
+
// area is cleared and redrawn.
|
|
2605
|
+
buffer += `\x1b[?2026h\x1b7${overlay}\x1b8\x1b[?2026l`;
|
|
2606
|
+
}
|
|
2171
2607
|
if (!this.#writeTerminal(buffer)) return false;
|
|
2172
2608
|
if (!this.#imeCursorActive) return true;
|
|
2173
2609
|
return this.#writeCursorPosition(cursorPos, totalLines);
|