@oh-my-pi/pi-tui 15.10.7 → 15.10.9
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/CHANGELOG.md +16 -0
- package/dist/types/components/image.d.ts +2 -0
- package/dist/types/components/select-list.d.ts +8 -0
- package/package.json +3 -3
- package/src/components/editor.ts +1 -0
- package/src/components/image.ts +9 -1
- package/src/components/select-list.ts +181 -34
- package/src/tui.ts +41 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [15.10.9] - 2026-06-09
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Added a `wrapDescription` option to `SelectListLayoutOptions`. When enabled, long descriptions wrap onto continuation rows indented under the description column instead of being silently truncated. The slash-command/skill autocomplete picker now opts in so descriptions like the bundled skills' remain fully readable at normal terminal widths. `maxVisible` becomes the picker's visual row budget so the popup height stays bounded even when items wrap (a single 5-row description with `maxVisible=3` clips with the scrollbar carrying the offscreen tail). Navigation stays item-to-item, the narrow-width fallback (`width <= 40`) is unchanged, and the `ScrollView` scrollbar tracks visual rows so the thumb stays correct when items wrap unevenly. ([#2169](https://github.com/can1357/oh-my-pi/issues/2169))
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- Fixed Ghostty's first inline image in a fresh TUI session sometimes rendering as an empty placeholder block by holding the initial Kitty graphics paint until the terminal startup settle window has passed. Direct Kitty placements also keep their zero-width reservation rows non-plain so image-only transcript blocks do not collapse when blank-edge trimming runs.
|
|
14
|
+
|
|
15
|
+
## [15.10.8] - 2026-06-09
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
- Fixed TUI renders repeatedly clearing terminal scrollback after content filled the viewport. Unknown viewport probes no longer let foreground-streaming offscreen growth take the destructive `historyRebuild` path on every frame; newly appended tail rows stay reachable while stale history waits for a safe checkpoint. ([#2154](https://github.com/can1357/oh-my-pi/issues/2154))
|
|
20
|
+
|
|
5
21
|
## [15.10.6] - 2026-06-08
|
|
6
22
|
|
|
7
23
|
### Added
|
|
@@ -73,6 +73,8 @@ export declare class ImageBudget {
|
|
|
73
73
|
* repeated call (e.g. a width-change re-render) never re-sends the data.
|
|
74
74
|
*/
|
|
75
75
|
enqueueTransmit(imageId: number, sequence: string): void;
|
|
76
|
+
/** Whether a frame has image data queued but not yet written to the terminal. */
|
|
77
|
+
hasPendingTransmits(): boolean;
|
|
76
78
|
/** Transmit sequences to write before this frame's placements; clears the queue. */
|
|
77
79
|
takeTransmits(): readonly string[];
|
|
78
80
|
}
|
|
@@ -28,6 +28,14 @@ export interface SelectListLayoutOptions {
|
|
|
28
28
|
truncatePrimary?: (context: SelectListTruncatePrimaryContext) => string;
|
|
29
29
|
/** Enable type-to-filter search when the item count exceeds maxVisible. Defaults to true. */
|
|
30
30
|
overflowSearch?: boolean;
|
|
31
|
+
/**
|
|
32
|
+
* Wrap long descriptions onto continuation rows indented under the
|
|
33
|
+
* description column instead of truncating. Defaults to false so existing
|
|
34
|
+
* single-line consumers are unaffected. Navigation remains item-to-item;
|
|
35
|
+
* the scrollbar tracks visual rows so the thumb stays correct when items
|
|
36
|
+
* wrap unevenly.
|
|
37
|
+
*/
|
|
38
|
+
wrapDescription?: boolean;
|
|
31
39
|
}
|
|
32
40
|
export declare class SelectList implements Component {
|
|
33
41
|
#private;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-tui",
|
|
4
|
-
"version": "15.10.
|
|
4
|
+
"version": "15.10.9",
|
|
5
5
|
"description": "Terminal User Interface library with differential rendering for efficient text-based applications",
|
|
6
6
|
"homepage": "https://omp.sh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -37,8 +37,8 @@
|
|
|
37
37
|
"fmt": "biome format --write ."
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@oh-my-pi/pi-natives": "15.10.
|
|
41
|
-
"@oh-my-pi/pi-utils": "15.10.
|
|
40
|
+
"@oh-my-pi/pi-natives": "15.10.9",
|
|
41
|
+
"@oh-my-pi/pi-utils": "15.10.9",
|
|
42
42
|
"lru-cache": "11.5.1",
|
|
43
43
|
"marked": "^18.0.4"
|
|
44
44
|
},
|
package/src/components/editor.ts
CHANGED
package/src/components/image.ts
CHANGED
|
@@ -27,6 +27,9 @@ export interface ImageOptions {
|
|
|
27
27
|
|
|
28
28
|
const EMPTY_IDS: readonly number[] = [];
|
|
29
29
|
const EMPTY_TRANSMITS: readonly string[] = [];
|
|
30
|
+
// Direct placements reserve height with leading zero-width rows. Keep them
|
|
31
|
+
// non-plain so transcript blank-edge trimming does not collapse image-only blocks.
|
|
32
|
+
const RESERVED_IMAGE_ROW = "\x1b[0m";
|
|
30
33
|
|
|
31
34
|
/** Default count of inline images kept as live graphics before older ones fall back to text. */
|
|
32
35
|
export const DEFAULT_MAX_INLINE_IMAGES = 8;
|
|
@@ -188,6 +191,11 @@ export class ImageBudget {
|
|
|
188
191
|
this.#pendingTransmits.push(sequence);
|
|
189
192
|
}
|
|
190
193
|
|
|
194
|
+
/** Whether a frame has image data queued but not yet written to the terminal. */
|
|
195
|
+
hasPendingTransmits(): boolean {
|
|
196
|
+
return this.#pendingTransmits.length > 0;
|
|
197
|
+
}
|
|
198
|
+
|
|
191
199
|
/** Transmit sequences to write before this frame's placements; clears the queue. */
|
|
192
200
|
takeTransmits(): readonly string[] {
|
|
193
201
|
if (this.#pendingTransmits.length === 0) return EMPTY_TRANSMITS;
|
|
@@ -296,7 +304,7 @@ export class Image implements Component {
|
|
|
296
304
|
// moves the cursor back up, then emits the image sequence.
|
|
297
305
|
lines = [];
|
|
298
306
|
for (let i = 0; i < result.rows - 1; i++) {
|
|
299
|
-
lines.push(
|
|
307
|
+
lines.push(RESERVED_IMAGE_ROW);
|
|
300
308
|
}
|
|
301
309
|
const moveUp = result.rows > 1 ? `\x1b[${result.rows - 1}A` : "";
|
|
302
310
|
lines.push(moveUp + (result.sequence ?? ""));
|
|
@@ -3,7 +3,7 @@ import { getKeybindings } from "../keybindings";
|
|
|
3
3
|
import { extractPrintableText } from "../keys";
|
|
4
4
|
import type { SymbolTheme } from "../symbols";
|
|
5
5
|
import type { Component } from "../tui";
|
|
6
|
-
import { Ellipsis, padding, replaceTabs, truncateToWidth, visibleWidth } from "../utils";
|
|
6
|
+
import { Ellipsis, padding, replaceTabs, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "../utils";
|
|
7
7
|
import { ScrollView } from "./scroll-view";
|
|
8
8
|
|
|
9
9
|
const DEFAULT_PRIMARY_COLUMN_WIDTH = 32;
|
|
@@ -50,8 +50,33 @@ export interface SelectListLayoutOptions {
|
|
|
50
50
|
truncatePrimary?: (context: SelectListTruncatePrimaryContext) => string;
|
|
51
51
|
/** Enable type-to-filter search when the item count exceeds maxVisible. Defaults to true. */
|
|
52
52
|
overflowSearch?: boolean;
|
|
53
|
+
/**
|
|
54
|
+
* Wrap long descriptions onto continuation rows indented under the
|
|
55
|
+
* description column instead of truncating. Defaults to false so existing
|
|
56
|
+
* single-line consumers are unaffected. Navigation remains item-to-item;
|
|
57
|
+
* the scrollbar tracks visual rows so the thumb stays correct when items
|
|
58
|
+
* wrap unevenly.
|
|
59
|
+
*/
|
|
60
|
+
wrapDescription?: boolean;
|
|
53
61
|
}
|
|
54
62
|
|
|
63
|
+
type SelectItemLayout =
|
|
64
|
+
| {
|
|
65
|
+
kind: "description";
|
|
66
|
+
prefix: string;
|
|
67
|
+
truncatedValue: string;
|
|
68
|
+
spacing: string;
|
|
69
|
+
descriptionSingleLine: string;
|
|
70
|
+
descriptionStart: number;
|
|
71
|
+
remainingWidth: number;
|
|
72
|
+
}
|
|
73
|
+
| {
|
|
74
|
+
kind: "primary";
|
|
75
|
+
prefix: string;
|
|
76
|
+
truncatedValue: string;
|
|
77
|
+
spacing: "";
|
|
78
|
+
};
|
|
79
|
+
|
|
55
80
|
export class SelectList implements Component {
|
|
56
81
|
#filteredItems: ReadonlyArray<SelectItem>;
|
|
57
82
|
#filterQuery = "";
|
|
@@ -96,34 +121,58 @@ export class SelectList implements Component {
|
|
|
96
121
|
}
|
|
97
122
|
|
|
98
123
|
const primaryColumnWidth = this.#getPrimaryColumnWidth();
|
|
124
|
+
const wrapEnabled = this.layout.wrapDescription === true;
|
|
125
|
+
// `maxVisible` is the picker's visual row budget. For non-wrap layouts
|
|
126
|
+
// every item is one row, so the budget matches the original item count.
|
|
127
|
+
const visualBudget = this.maxVisible;
|
|
128
|
+
|
|
129
|
+
// Compute per-item visual row counts at the conservative width (i.e.
|
|
130
|
+
// assume the scrollbar column might be reserved). For non-wrap layouts
|
|
131
|
+
// every count is 1, so visualTotal == #filteredItems and overflow falls
|
|
132
|
+
// back to the original `N > maxVisible` predicate exactly.
|
|
133
|
+
const conservativeRowWidth = Math.max(0, width - 1);
|
|
134
|
+
const rowCounts = new Array<number>(this.#filteredItems.length);
|
|
135
|
+
let visualTotal = 0;
|
|
136
|
+
for (let i = 0; i < this.#filteredItems.length; i++) {
|
|
137
|
+
const item = this.#filteredItems[i];
|
|
138
|
+
if (!item) {
|
|
139
|
+
rowCounts[i] = 0;
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
rowCounts[i] = wrapEnabled ? this.#computeItemRowCount(item, conservativeRowWidth, primaryColumnWidth) : 1;
|
|
143
|
+
visualTotal += rowCounts[i];
|
|
144
|
+
}
|
|
99
145
|
|
|
100
|
-
|
|
101
|
-
const startIndex = Math.max(
|
|
102
|
-
0,
|
|
103
|
-
Math.min(this.#selectedIndex - Math.floor(this.maxVisible / 2), this.#filteredItems.length - this.maxVisible),
|
|
104
|
-
);
|
|
105
|
-
const endIndex = Math.min(startIndex + this.maxVisible, this.#filteredItems.length);
|
|
106
|
-
|
|
107
|
-
// Render visible items
|
|
108
|
-
const overflow = this.#filteredItems.length > this.maxVisible;
|
|
146
|
+
const overflow = visualTotal > visualBudget;
|
|
109
147
|
const rowWidth = Math.max(0, width - (overflow ? 1 : 0));
|
|
148
|
+
|
|
149
|
+
// Pick a window centered on the selected item that fits in visualBudget
|
|
150
|
+
// rows. Falls through to the original item-count window when every row
|
|
151
|
+
// count is 1.
|
|
152
|
+
const { startIndex, endIndex, visualOffset } = this.#pickWindow(rowCounts, visualBudget);
|
|
153
|
+
|
|
154
|
+
// Render visible items. Cap rows at the budget so a single item that
|
|
155
|
+
// wraps to more than `visualBudget` rows (pathological — e.g. a 5-row
|
|
156
|
+
// description with maxVisible=3) still keeps the popup bounded; the
|
|
157
|
+
// scrollbar carries the offscreen rows.
|
|
110
158
|
const rows: string[] = [];
|
|
111
|
-
for (let i = startIndex; i < endIndex; i++) {
|
|
159
|
+
for (let i = startIndex; i < endIndex && rows.length < visualBudget; i++) {
|
|
112
160
|
const item = this.#filteredItems[i];
|
|
113
161
|
if (!item) continue;
|
|
114
|
-
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
162
|
+
const itemRows = this.#renderItem(item, i === this.#selectedIndex, rowWidth, primaryColumnWidth);
|
|
163
|
+
for (const row of itemRows) {
|
|
164
|
+
if (rows.length >= visualBudget) break;
|
|
165
|
+
rows.push(row);
|
|
166
|
+
}
|
|
118
167
|
}
|
|
119
168
|
|
|
120
169
|
const sv = new ScrollView(rows, {
|
|
121
170
|
height: rows.length,
|
|
122
171
|
scrollbar: "auto",
|
|
123
|
-
totalRows:
|
|
172
|
+
totalRows: visualTotal,
|
|
124
173
|
theme: { track: t => this.theme.scrollInfo(t), thumb: t => this.theme.selectedPrefix(t) },
|
|
125
174
|
});
|
|
126
|
-
sv.setScrollOffset(
|
|
175
|
+
sv.setScrollOffset(visualOffset);
|
|
127
176
|
lines.push(...sv.render(width));
|
|
128
177
|
|
|
129
178
|
// Add search status when relevant (scrollbar now indicates overflow)
|
|
@@ -178,17 +227,112 @@ export class SelectList implements Component {
|
|
|
178
227
|
}
|
|
179
228
|
}
|
|
180
229
|
|
|
181
|
-
#renderItem(
|
|
230
|
+
#renderItem(item: SelectItem, isSelected: boolean, width: number, primaryColumnWidth: number): string[] {
|
|
231
|
+
const layout = this.#computeItemLayout(item, isSelected, width, primaryColumnWidth);
|
|
232
|
+
const { prefix, truncatedValue, spacing } = layout;
|
|
233
|
+
|
|
234
|
+
if (layout.kind === "description") {
|
|
235
|
+
const { descriptionSingleLine, descriptionStart, remainingWidth } = layout;
|
|
236
|
+
if (this.layout.wrapDescription) {
|
|
237
|
+
const wrapped = wrapTextWithAnsi(descriptionSingleLine, remainingWidth);
|
|
238
|
+
if (wrapped.length === 0) wrapped.push("");
|
|
239
|
+
const indent = padding(descriptionStart);
|
|
240
|
+
const first = wrapped[0] ?? "";
|
|
241
|
+
if (isSelected) {
|
|
242
|
+
const rows = [this.theme.selectedText(`${prefix}${truncatedValue}${spacing}${first}`)];
|
|
243
|
+
for (let i = 1; i < wrapped.length; i++) {
|
|
244
|
+
rows.push(this.theme.selectedText(`${indent}${wrapped[i]}`));
|
|
245
|
+
}
|
|
246
|
+
return rows;
|
|
247
|
+
}
|
|
248
|
+
const rows = [prefix + truncatedValue + this.theme.description(spacing + first)];
|
|
249
|
+
for (let i = 1; i < wrapped.length; i++) {
|
|
250
|
+
rows.push(this.theme.description(`${indent}${wrapped[i]}`));
|
|
251
|
+
}
|
|
252
|
+
return rows;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const truncatedDesc = truncateToWidth(descriptionSingleLine, remainingWidth, Ellipsis.Omit);
|
|
256
|
+
if (isSelected) {
|
|
257
|
+
return [this.theme.selectedText(`${prefix}${truncatedValue}${spacing}${truncatedDesc}`)];
|
|
258
|
+
}
|
|
259
|
+
return [prefix + truncatedValue + this.theme.description(spacing + truncatedDesc)];
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (isSelected) {
|
|
263
|
+
return [this.theme.selectedText(`${prefix}${truncatedValue}`)];
|
|
264
|
+
}
|
|
265
|
+
return [prefix + truncatedValue];
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
#computeItemRowCount(item: SelectItem, width: number, primaryColumnWidth: number): number {
|
|
269
|
+
// Selection style does not change row count; pass isSelected=false to
|
|
270
|
+
// keep the cheap path uniform for items outside the visible window.
|
|
271
|
+
const layout = this.#computeItemLayout(item, false, width, primaryColumnWidth);
|
|
272
|
+
if (layout.kind !== "description") return 1;
|
|
273
|
+
const wrapped = wrapTextWithAnsi(layout.descriptionSingleLine, layout.remainingWidth);
|
|
274
|
+
return Math.max(1, wrapped.length);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Pick a contiguous window of items containing `selectedIndex` such that
|
|
279
|
+
* their visual rows fit within `budget`. Centers the selection roughly
|
|
280
|
+
* mid-window: first expands up by ⌊budget/2⌋ rows, then fills downward,
|
|
281
|
+
* then back upward with any remaining budget. For non-wrap layouts (every
|
|
282
|
+
* `rowCounts[i] === 1`) this resolves to the same `[start, start+maxVisible)`
|
|
283
|
+
* window the prior arithmetic produced.
|
|
284
|
+
*/
|
|
285
|
+
#pickWindow(
|
|
286
|
+
rowCounts: ReadonlyArray<number>,
|
|
287
|
+
budget: number,
|
|
288
|
+
): { startIndex: number; endIndex: number; visualOffset: number } {
|
|
289
|
+
const n = rowCounts.length;
|
|
290
|
+
const selected = Math.max(0, Math.min(this.#selectedIndex, n - 1));
|
|
291
|
+
if (n === 0) return { startIndex: 0, endIndex: 0, visualOffset: 0 };
|
|
292
|
+
|
|
293
|
+
const half = Math.floor(budget / 2);
|
|
294
|
+
let lo = selected;
|
|
295
|
+
let rowsAboveSelected = 0;
|
|
296
|
+
// Step 1: expand upward up to `half` rows above the selection so it
|
|
297
|
+
// lands near the visual middle, matching the prior centering.
|
|
298
|
+
while (lo > 0 && rowsAboveSelected + (rowCounts[lo - 1] ?? 0) <= half) {
|
|
299
|
+
lo--;
|
|
300
|
+
rowsAboveSelected += rowCounts[lo] ?? 0;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Step 2: expand downward until the budget is filled. The selected
|
|
304
|
+
// item's own rows are always counted; if it alone exceeds `budget`
|
|
305
|
+
// the surplus is clipped at render time and the scrollbar carries it.
|
|
306
|
+
let hi = selected + 1;
|
|
307
|
+
let used = rowsAboveSelected + (rowCounts[selected] ?? 0);
|
|
308
|
+
while (hi < n && used + (rowCounts[hi] ?? 0) <= budget) {
|
|
309
|
+
used += rowCounts[hi] ?? 0;
|
|
310
|
+
hi++;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Step 3: if room remains (selection sat near the bottom), keep
|
|
314
|
+
// expanding upward.
|
|
315
|
+
while (lo > 0 && used + (rowCounts[lo - 1] ?? 0) <= budget) {
|
|
316
|
+
lo--;
|
|
317
|
+
used += rowCounts[lo] ?? 0;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
let visualOffset = 0;
|
|
321
|
+
for (let i = 0; i < lo; i++) visualOffset += rowCounts[i] ?? 0;
|
|
322
|
+
return { startIndex: lo, endIndex: hi, visualOffset };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
#computeItemLayout(
|
|
182
326
|
item: SelectItem,
|
|
183
327
|
isSelected: boolean,
|
|
184
328
|
width: number,
|
|
185
|
-
descriptionSingleLine: string | undefined,
|
|
186
329
|
primaryColumnWidth: number,
|
|
187
|
-
):
|
|
330
|
+
): SelectItemLayout {
|
|
188
331
|
const prefix = isSelected
|
|
189
332
|
? `${this.theme.symbols.cursor} `
|
|
190
333
|
: padding(visibleWidth(this.theme.symbols.cursor) + 1);
|
|
191
334
|
const prefixWidth = visibleWidth(prefix);
|
|
335
|
+
const descriptionSingleLine = item.description ? sanitizeSingleLine(item.description) : undefined;
|
|
192
336
|
|
|
193
337
|
if (descriptionSingleLine && width > 40) {
|
|
194
338
|
const effectivePrimaryColumnWidth = Math.max(1, Math.min(primaryColumnWidth, width - prefixWidth - 4));
|
|
@@ -200,23 +344,26 @@ export class SelectList implements Component {
|
|
|
200
344
|
const remainingWidth = width - descriptionStart - 2; // -2 for safety
|
|
201
345
|
|
|
202
346
|
if (remainingWidth > MIN_DESCRIPTION_WIDTH) {
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
347
|
+
return {
|
|
348
|
+
kind: "description",
|
|
349
|
+
prefix,
|
|
350
|
+
truncatedValue,
|
|
351
|
+
spacing,
|
|
352
|
+
descriptionSingleLine,
|
|
353
|
+
descriptionStart,
|
|
354
|
+
remainingWidth,
|
|
355
|
+
};
|
|
210
356
|
}
|
|
211
357
|
}
|
|
212
358
|
|
|
213
|
-
const
|
|
214
|
-
const truncatedValue = this.#truncatePrimary(item, isSelected,
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
359
|
+
const fallbackMax = width - prefixWidth - 2;
|
|
360
|
+
const truncatedValue = this.#truncatePrimary(item, isSelected, fallbackMax, fallbackMax);
|
|
361
|
+
return {
|
|
362
|
+
kind: "primary",
|
|
363
|
+
prefix,
|
|
364
|
+
truncatedValue,
|
|
365
|
+
spacing: "",
|
|
366
|
+
};
|
|
220
367
|
}
|
|
221
368
|
|
|
222
369
|
#getPrimaryColumnWidth(): number {
|
package/src/tui.ts
CHANGED
|
@@ -494,6 +494,10 @@ export class TUI extends Container {
|
|
|
494
494
|
// arrives (issue #2088). Coalescing every SIGWINCH inside this window into
|
|
495
495
|
// a single forced render lets the multiplexer settle first.
|
|
496
496
|
static readonly #MULTIPLEXER_RESIZE_DEBOUNCE_MS = 50;
|
|
497
|
+
// Ghostty can drop Kitty graphics commands sent during its first post-startup
|
|
498
|
+
// settle window, leaving only Unicode placeholder cells. Hold the first image
|
|
499
|
+
// paint until that window has passed; later images render normally.
|
|
500
|
+
static readonly #GHOSTTY_INITIAL_IMAGE_DELAY_MS = 100;
|
|
497
501
|
// Post-paint settle window for ConPTY hosts. The `sessionReplace` /
|
|
498
502
|
// `historyRebuild` / `overlayRebuild` intents drive `#emitFullPaint` over
|
|
499
503
|
// a transcript that overflows the viewport, scroll-pushing everything past
|
|
@@ -560,6 +564,9 @@ export class TUI extends Container {
|
|
|
560
564
|
// Caps how many inline images render as live graphics; older ones fall back
|
|
561
565
|
// to text via a purge + full redraw. Cap is configured by the host app.
|
|
562
566
|
#imageBudget = new ImageBudget(DEFAULT_MAX_INLINE_IMAGES, () => this.requestRender());
|
|
567
|
+
#ghosttyInitialImageDelayDone = false;
|
|
568
|
+
#ghosttyInitialImageDelayTimer: RenderTimer | undefined;
|
|
569
|
+
#ghosttyImageReadyAtMs = 0;
|
|
563
570
|
#clearScrollbackOnNextRender = false;
|
|
564
571
|
#forceViewportRepaintOnNextRender = false;
|
|
565
572
|
#allowUnknownViewportMutationOnNextRender = false;
|
|
@@ -889,6 +896,8 @@ export class TUI extends Container {
|
|
|
889
896
|
|
|
890
897
|
start(options?: TUIStartOptions): void {
|
|
891
898
|
this.#stopped = false;
|
|
899
|
+
this.#ghosttyInitialImageDelayDone = false;
|
|
900
|
+
this.#ghosttyImageReadyAtMs = this.#renderScheduler.now() + TUI.#GHOSTTY_INITIAL_IMAGE_DELAY_MS;
|
|
892
901
|
// A DECRQM report for mode 2026 is authoritative: enable synchronized
|
|
893
902
|
// output when the terminal reports support (upgrading conservatively
|
|
894
903
|
// defaulted-off hosts like zellij/tmux-master/foot) and disable it when
|
|
@@ -1127,6 +1136,10 @@ export class TUI extends Container {
|
|
|
1127
1136
|
this.#renderTimer.cancel();
|
|
1128
1137
|
this.#renderTimer = undefined;
|
|
1129
1138
|
}
|
|
1139
|
+
if (this.#ghosttyInitialImageDelayTimer) {
|
|
1140
|
+
this.#ghosttyInitialImageDelayTimer.cancel();
|
|
1141
|
+
this.#ghosttyInitialImageDelayTimer = undefined;
|
|
1142
|
+
}
|
|
1130
1143
|
if (this.#multiplexerResizeTimer) {
|
|
1131
1144
|
this.#multiplexerResizeTimer.cancel();
|
|
1132
1145
|
this.#multiplexerResizeTimer = undefined;
|
|
@@ -1371,6 +1384,32 @@ export class TUI extends Container {
|
|
|
1371
1384
|
}
|
|
1372
1385
|
this.#postFullPaintSettleUntilMs = 0;
|
|
1373
1386
|
}
|
|
1387
|
+
|
|
1388
|
+
#maybeDeferGhosttyInitialImagePaint(): boolean {
|
|
1389
|
+
if (this.#ghosttyInitialImageDelayDone) return false;
|
|
1390
|
+
if (TERMINAL.id !== "ghostty" || TERMINAL.imageProtocol !== ImageProtocol.Kitty) {
|
|
1391
|
+
this.#ghosttyInitialImageDelayDone = true;
|
|
1392
|
+
return false;
|
|
1393
|
+
}
|
|
1394
|
+
if (!this.#imageBudget.hasPendingTransmits()) return false;
|
|
1395
|
+
if (this.#ghosttyInitialImageDelayTimer) return true;
|
|
1396
|
+
|
|
1397
|
+
const delayMs = Math.max(0, this.#ghosttyImageReadyAtMs - this.#renderScheduler.now());
|
|
1398
|
+
if (delayMs === 0) {
|
|
1399
|
+
this.#ghosttyInitialImageDelayDone = true;
|
|
1400
|
+
return false;
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
this.#ghosttyInitialImageDelayTimer = this.#renderScheduler.scheduleRender(() => {
|
|
1404
|
+
this.#ghosttyInitialImageDelayTimer = undefined;
|
|
1405
|
+
this.#ghosttyInitialImageDelayDone = true;
|
|
1406
|
+
if (this.#stopped) return;
|
|
1407
|
+
this.#lastRenderAt = this.#renderScheduler.now();
|
|
1408
|
+
this.#doRender();
|
|
1409
|
+
if (this.#renderRequested) this.#scheduleRender();
|
|
1410
|
+
}, delayMs);
|
|
1411
|
+
return true;
|
|
1412
|
+
}
|
|
1374
1413
|
#prepareForcedRender(clearScrollback: boolean): void {
|
|
1375
1414
|
const geometryChanged =
|
|
1376
1415
|
(this.#previousWidth > 0 && this.#previousWidth !== this.terminal.columns) ||
|
|
@@ -1879,11 +1918,10 @@ export class TUI extends Container {
|
|
|
1879
1918
|
(this.#previousHeight > 0 && this.#previousHeight !== height) ||
|
|
1880
1919
|
(resizeEventOccurred && this.#previousHeight > 0);
|
|
1881
1920
|
const eagerEraseScrollbackRisk = this.#hasEagerEraseScrollbackRisk();
|
|
1882
|
-
const eagerRebuildAllowed = this.#eagerNativeScrollbackRebuild && !eagerEraseScrollbackRisk;
|
|
1883
1921
|
const focusChanged = this.#focusChangedSinceLastRender;
|
|
1884
1922
|
this.#focusChangedSinceLastRender = false;
|
|
1885
1923
|
const explicitViewportMutation = this.#allowUnknownViewportMutationOnNextRender || focusChanged;
|
|
1886
|
-
const allowUnknownViewportMutation = explicitViewportMutation
|
|
1924
|
+
const allowUnknownViewportMutation = explicitViewportMutation;
|
|
1887
1925
|
this.#allowUnknownViewportMutationOnNextRender = false;
|
|
1888
1926
|
|
|
1889
1927
|
// 3. Classify intent.
|
|
@@ -1985,6 +2023,7 @@ export class TUI extends Container {
|
|
|
1985
2023
|
// paints, so subsequent frames re-emit only the tiny placement sequence.
|
|
1986
2024
|
// `a=t` produces no display, so writing it ahead of the synchronized paint
|
|
1987
2025
|
// is artifact-free.
|
|
2026
|
+
if (this.#maybeDeferGhosttyInitialImagePaint()) return;
|
|
1988
2027
|
const imageTransmits = this.#imageBudget.takeTransmits();
|
|
1989
2028
|
if (imageTransmits.length > 0) {
|
|
1990
2029
|
let transmitBuffer = "";
|