@oh-my-pi/pi-tui 18.0.0 → 18.0.3
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 +37 -0
- package/dist/types/autocomplete.d.ts +7 -5
- package/dist/types/components/editor.d.ts +2 -4
- package/dist/types/components/image.d.ts +2 -5
- package/dist/types/components/markdown.d.ts +2 -25
- package/dist/types/components/text.d.ts +0 -1
- package/dist/types/terminal-capabilities.d.ts +10 -3
- package/dist/types/terminal.d.ts +10 -0
- package/dist/types/tui.d.ts +47 -245
- package/package.json +3 -3
- package/src/autocomplete.ts +66 -10
- package/src/components/editor.ts +137 -134
- package/src/components/image.ts +4 -6
- package/src/components/loader.ts +1 -9
- package/src/components/markdown.ts +48 -407
- package/src/components/text.ts +8 -18
- package/src/fuzzy.ts +63 -6
- package/src/stdin-buffer.ts +130 -20
- package/src/terminal-capabilities.ts +13 -2
- package/src/terminal.ts +14 -0
- package/src/tui.ts +567 -3599
package/src/components/text.ts
CHANGED
|
@@ -25,14 +25,11 @@ export class Text implements Component {
|
|
|
25
25
|
#paddingY: number; // Top/bottom padding
|
|
26
26
|
#customBgFn?: (text: string) => string;
|
|
27
27
|
#styleFn?: (text: string) => string;
|
|
28
|
-
#widthEpochRevision = 0;
|
|
29
|
-
|
|
30
28
|
#ignoreTight = false;
|
|
31
29
|
|
|
32
30
|
setIgnoreTight(ignore: boolean): this {
|
|
33
31
|
if (this.#ignoreTight === ignore) return this;
|
|
34
32
|
this.#ignoreTight = ignore;
|
|
35
|
-
this.#widthEpochRevision++;
|
|
36
33
|
this.invalidate();
|
|
37
34
|
return this;
|
|
38
35
|
}
|
|
@@ -40,7 +37,7 @@ export class Text implements Component {
|
|
|
40
37
|
// Cache for rendered output
|
|
41
38
|
#cachedText?: string;
|
|
42
39
|
#cachedWidth?: number;
|
|
43
|
-
#
|
|
40
|
+
#cachedWidthConfigEpoch?: number;
|
|
44
41
|
#cachedLines?: string[];
|
|
45
42
|
|
|
46
43
|
constructor(text: string = "", paddingX: number = 1, paddingY: number = 1, customBgFn?: (text: string) => string) {
|
|
@@ -61,23 +58,17 @@ export class Text implements Component {
|
|
|
61
58
|
this.#text = text;
|
|
62
59
|
this.#cachedText = undefined;
|
|
63
60
|
this.#cachedWidth = undefined;
|
|
64
|
-
this.#
|
|
61
|
+
this.#cachedWidthConfigEpoch = undefined;
|
|
65
62
|
this.#cachedLines = undefined;
|
|
66
|
-
this.#widthEpochRevision++;
|
|
67
63
|
return true;
|
|
68
64
|
}
|
|
69
65
|
|
|
70
|
-
getNativeScrollbackWidthEpochRevision(): number {
|
|
71
|
-
return this.#widthEpochRevision;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
66
|
setCustomBgFn(customBgFn?: (text: string) => string): void {
|
|
75
67
|
this.#customBgFn = customBgFn;
|
|
76
68
|
this.#cachedText = undefined;
|
|
77
69
|
this.#cachedWidth = undefined;
|
|
78
|
-
this.#
|
|
70
|
+
this.#cachedWidthConfigEpoch = undefined;
|
|
79
71
|
this.#cachedLines = undefined;
|
|
80
|
-
this.#widthEpochRevision++;
|
|
81
72
|
}
|
|
82
73
|
|
|
83
74
|
/**
|
|
@@ -90,16 +81,15 @@ export class Text implements Component {
|
|
|
90
81
|
this.#styleFn = styleFn;
|
|
91
82
|
this.#cachedText = undefined;
|
|
92
83
|
this.#cachedWidth = undefined;
|
|
93
|
-
this.#
|
|
84
|
+
this.#cachedWidthConfigEpoch = undefined;
|
|
94
85
|
this.#cachedLines = undefined;
|
|
95
|
-
this.#widthEpochRevision++;
|
|
96
86
|
return this;
|
|
97
87
|
}
|
|
98
88
|
|
|
99
89
|
invalidate(): void {
|
|
100
90
|
this.#cachedText = undefined;
|
|
101
91
|
this.#cachedWidth = undefined;
|
|
102
|
-
this.#
|
|
92
|
+
this.#cachedWidthConfigEpoch = undefined;
|
|
103
93
|
this.#cachedLines = undefined;
|
|
104
94
|
}
|
|
105
95
|
|
|
@@ -109,7 +99,7 @@ export class Text implements Component {
|
|
|
109
99
|
this.#cachedLines &&
|
|
110
100
|
this.#cachedText === this.#text &&
|
|
111
101
|
this.#cachedWidth === width &&
|
|
112
|
-
this.#
|
|
102
|
+
this.#cachedWidthConfigEpoch === getWidthConfigEpoch()
|
|
113
103
|
) {
|
|
114
104
|
return this.#cachedLines;
|
|
115
105
|
}
|
|
@@ -119,7 +109,7 @@ export class Text implements Component {
|
|
|
119
109
|
const result: string[] = [];
|
|
120
110
|
this.#cachedText = this.#text;
|
|
121
111
|
this.#cachedWidth = width;
|
|
122
|
-
this.#
|
|
112
|
+
this.#cachedWidthConfigEpoch = getWidthConfigEpoch();
|
|
123
113
|
this.#cachedLines = result;
|
|
124
114
|
return result;
|
|
125
115
|
}
|
|
@@ -175,7 +165,7 @@ export class Text implements Component {
|
|
|
175
165
|
// Update cache
|
|
176
166
|
this.#cachedText = this.#text;
|
|
177
167
|
this.#cachedWidth = width;
|
|
178
|
-
this.#
|
|
168
|
+
this.#cachedWidthConfigEpoch = getWidthConfigEpoch();
|
|
179
169
|
this.#cachedLines = result;
|
|
180
170
|
|
|
181
171
|
return result.length > 0 ? result : [""];
|
package/src/fuzzy.ts
CHANGED
|
@@ -42,6 +42,16 @@ const ALPHANUMERIC_SWAP_PENALTY = 5;
|
|
|
42
42
|
const COMPACT_PHRASE_BONUS = 1200;
|
|
43
43
|
const PHRASE_BONUS = 1000;
|
|
44
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Shortest needle worth scanning past its leading occurrence for.
|
|
47
|
+
*
|
|
48
|
+
* One or two characters sit mid-word in nearly every candidate — "im" is inside
|
|
49
|
+
* "experimental" — so rescanning that short would hand the word-start bonus to
|
|
50
|
+
* the whole corpus at once and reshuffle the list on the opening keystrokes of a
|
|
51
|
+
* search instead of narrowing it. Shadowing only misleads for real phrases.
|
|
52
|
+
*/
|
|
53
|
+
const MIN_SHADOW_RESCAN_LENGTH = 3;
|
|
54
|
+
|
|
45
55
|
function normalizeForSearch(value: string): string {
|
|
46
56
|
return value
|
|
47
57
|
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
|
|
@@ -172,6 +182,53 @@ function isWordBoundaryPhrase(normalized: string, index: number, length: number)
|
|
|
172
182
|
return before && after;
|
|
173
183
|
}
|
|
174
184
|
|
|
185
|
+
/**
|
|
186
|
+
* Offset of the first whole-word occurrence of `phrase` in `normalized`, or -1.
|
|
187
|
+
*
|
|
188
|
+
* A bare `indexOf` reports only the leading occurrence, so a qualifying match is
|
|
189
|
+
* dropped whenever an earlier non-qualifying one shadows it — the whole word
|
|
190
|
+
* "image" in "reimage image provider" loses to the "image" inside "reimage".
|
|
191
|
+
* Occurrences are scanned left to right, so the first qualifying hit is also the
|
|
192
|
+
* best-scoring one: the caller's position tiebreak grows with the offset.
|
|
193
|
+
*
|
|
194
|
+
* Only a hit buried inside a word can shadow. A leading hit that already starts
|
|
195
|
+
* a word is an ordinary prefix match — the query is "image" and the text says
|
|
196
|
+
* "images" — and it is scored exactly as before rather than borrowing a
|
|
197
|
+
* whole-word bonus from some later occurrence; `findCompactWordStart` treats its
|
|
198
|
+
* leading word-start hit the same way. The rescan is additionally limited to
|
|
199
|
+
* {@link MIN_SHADOW_RESCAN_LENGTH} and longer needles.
|
|
200
|
+
*/
|
|
201
|
+
function findWordBoundaryPhrase(normalized: string, phrase: string): number {
|
|
202
|
+
if (phrase.length === 0) return -1;
|
|
203
|
+
const first = normalized.indexOf(phrase);
|
|
204
|
+
if (first < 0) return -1;
|
|
205
|
+
if (isWordBoundaryPhrase(normalized, first, phrase.length)) return first;
|
|
206
|
+
if (first === 0 || normalized[first - 1] === " ") return -1;
|
|
207
|
+
if (phrase.length < MIN_SHADOW_RESCAN_LENGTH) return -1;
|
|
208
|
+
for (let at = normalized.indexOf(phrase, first + 1); at >= 0; at = normalized.indexOf(phrase, at + 1)) {
|
|
209
|
+
if (isWordBoundaryPhrase(normalized, at, phrase.length)) return at;
|
|
210
|
+
}
|
|
211
|
+
return -1;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Offset of the first occurrence of `needle` that starts a word in `index.compact`,
|
|
216
|
+
* or -1. Same shadowing hazard, and the same length floor, as
|
|
217
|
+
* {@link findWordBoundaryPhrase}.
|
|
218
|
+
*/
|
|
219
|
+
function findCompactWordStart(index: SearchIndex, needle: string): number {
|
|
220
|
+
if (needle.length === 0) return -1;
|
|
221
|
+
const { compact, compactWordStarts } = index;
|
|
222
|
+
const first = compact.indexOf(needle);
|
|
223
|
+
if (first < 0) return -1;
|
|
224
|
+
if (compactWordStarts.has(first)) return first;
|
|
225
|
+
if (needle.length < MIN_SHADOW_RESCAN_LENGTH) return -1;
|
|
226
|
+
for (let at = compact.indexOf(needle, first + 1); at >= 0; at = compact.indexOf(needle, at + 1)) {
|
|
227
|
+
if (compactWordStarts.has(at)) return at;
|
|
228
|
+
}
|
|
229
|
+
return -1;
|
|
230
|
+
}
|
|
231
|
+
|
|
175
232
|
function scoreTokenAgainstWord(token: string, word: SearchWord): FuzzyMatch | null {
|
|
176
233
|
if (word.text === token) {
|
|
177
234
|
return { matches: true, score: withPosition(-200, word.index) };
|
|
@@ -230,8 +287,8 @@ function scoreTokenDirect(token: string, index: SearchIndex): FuzzyMatch {
|
|
|
230
287
|
if (token.length === 0) return { matches: true, score: 0 };
|
|
231
288
|
|
|
232
289
|
let best: FuzzyMatch | null = null;
|
|
233
|
-
const compactIndex = index
|
|
234
|
-
if (compactIndex >= 0
|
|
290
|
+
const compactIndex = findCompactWordStart(index, token);
|
|
291
|
+
if (compactIndex >= 0) {
|
|
235
292
|
best = { matches: true, score: withPosition(-140, compactIndex) };
|
|
236
293
|
}
|
|
237
294
|
|
|
@@ -290,14 +347,14 @@ function fuzzyMatchCore(pq: PreparedQuery | null, index: SearchIndex): FuzzyMatc
|
|
|
290
347
|
}
|
|
291
348
|
|
|
292
349
|
let totalScore = 0;
|
|
293
|
-
const phraseIndex = index.normalized
|
|
294
|
-
if (phraseIndex >= 0
|
|
350
|
+
const phraseIndex = findWordBoundaryPhrase(index.normalized, pq.normalized);
|
|
351
|
+
if (phraseIndex >= 0) {
|
|
295
352
|
totalScore -= PHRASE_BONUS;
|
|
296
353
|
totalScore += phraseIndex * 0.01;
|
|
297
354
|
}
|
|
298
355
|
|
|
299
|
-
const compactPhraseIndex = index
|
|
300
|
-
if (compactPhraseIndex >= 0
|
|
356
|
+
const compactPhraseIndex = findCompactWordStart(index, pq.compact);
|
|
357
|
+
if (compactPhraseIndex >= 0) {
|
|
301
358
|
totalScore -= COMPACT_PHRASE_BONUS;
|
|
302
359
|
totalScore += compactPhraseIndex * 0.01;
|
|
303
360
|
}
|
package/src/stdin-buffer.ts
CHANGED
|
@@ -56,6 +56,17 @@ const PARTIAL_HOLD_MAX_MS = 150;
|
|
|
56
56
|
// so the string-terminator cap is generous.
|
|
57
57
|
const MAX_CSI_BYTES = 4096;
|
|
58
58
|
const MAX_STRING_SEQ_BYTES = 16 * 1024 * 1024;
|
|
59
|
+
// Torn-string discard bounds. When a capped or timeout-expired OSC/DCS/APC
|
|
60
|
+
// partial is dropped, the bytes that follow are mid-string payload (base64
|
|
61
|
+
// clipboard data, Sixel, …) up to the next ST/BEL. Replaying them as
|
|
62
|
+
// keystrokes types kilobytes of garbage into the focused component (kitty
|
|
63
|
+
// OSC 5522 image paste spam), so discard mode swallows the tail until a
|
|
64
|
+
// terminator — bounded by a byte budget for a terminator-less stream and an
|
|
65
|
+
// inactivity watchdog for a stream that simply stops.
|
|
66
|
+
const STRING_DISCARD_MAX_BYTES = 2 * MAX_STRING_SEQ_BYTES;
|
|
67
|
+
const STRING_DISCARD_INACTIVITY_MS = 1000;
|
|
68
|
+
// Partial that can only be the head of an OSC/DCS/APC string sequence.
|
|
69
|
+
const STRING_SEQ_PARTIAL = /^\x1b[\]P_]/;
|
|
59
70
|
|
|
60
71
|
// SGR mouse report bodies live between `<` and the terminating `M`/`m`.
|
|
61
72
|
// Matched only when the trailing byte is a valid terminator, so the regex
|
|
@@ -110,7 +121,8 @@ function isRawMultilineBurst(text: string): boolean {
|
|
|
110
121
|
* `end > pos` — complete sequence, exclusive end index.
|
|
111
122
|
* `-1` — incomplete, still under the per-type cap; buffer for more.
|
|
112
123
|
* `-2` — incomplete and the prefix already spans the per-type cap;
|
|
113
|
-
* the caller flushes
|
|
124
|
+
* the caller cap-flushes CSI as raw bytes; string types
|
|
125
|
+
* (OSC/DCS/APC) are dropped and their tail discarded.
|
|
114
126
|
*/
|
|
115
127
|
function resolveEscapeEnd(buffer: string, pos: number, length: number, resumeSearchFrom: number): number {
|
|
116
128
|
if (pos + 1 >= length) return -1;
|
|
@@ -211,17 +223,6 @@ function resolveEscapeEnd(buffer: string, pos: number, length: number, resumeSea
|
|
|
211
223
|
}
|
|
212
224
|
}
|
|
213
225
|
|
|
214
|
-
/**
|
|
215
|
-
* Per-type cap used to flush the incomplete prefix when `resolveEscapeEnd`
|
|
216
|
-
* returns -2. The cap keeps issue-4073's malformed streamed CSI/OSC/…
|
|
217
|
-
* bounded in both work and memory.
|
|
218
|
-
*/
|
|
219
|
-
function escapeCapFor(next: number): number {
|
|
220
|
-
// OSC/DCS/APC carry the large payloads (image paste, Sixel); CSI stays
|
|
221
|
-
// tight because real CSI keys/mouse/responses fit comfortably below 4 KiB.
|
|
222
|
-
return next === 0x5d || next === 0x50 || next === 0x5f ? MAX_STRING_SEQ_BYTES : MAX_CSI_BYTES;
|
|
223
|
-
}
|
|
224
|
-
|
|
225
226
|
/**
|
|
226
227
|
* Split accumulated buffer into complete sequences
|
|
227
228
|
*/
|
|
@@ -236,7 +237,7 @@ function parseUnmodifiedKittyPrintableCodepoint(sequence: string): number | unde
|
|
|
236
237
|
function extractCompleteSequences(
|
|
237
238
|
buffer: string,
|
|
238
239
|
resumeSearchFrom: number,
|
|
239
|
-
): { sequences: string[]; remainder: string; resumeSearchFrom: number } {
|
|
240
|
+
): { sequences: string[]; remainder: string; resumeSearchFrom: number; discardFrom?: number } {
|
|
240
241
|
const sequences: string[] = [];
|
|
241
242
|
const length = buffer.length;
|
|
242
243
|
let pos = 0;
|
|
@@ -295,8 +296,8 @@ function extractCompleteSequences(
|
|
|
295
296
|
return { sequences, remainder: buffer.slice(pos), resumeSearchFrom: 0 };
|
|
296
297
|
}
|
|
297
298
|
if (innerEnd === -2) {
|
|
298
|
-
|
|
299
|
-
const flushEnd = Math.min(length, pos +
|
|
299
|
+
// `third` is CSI/SS3 here, never a string type.
|
|
300
|
+
const flushEnd = Math.min(length, pos + MAX_CSI_BYTES);
|
|
300
301
|
sequences.push(buffer.slice(pos, flushEnd));
|
|
301
302
|
pos = flushEnd;
|
|
302
303
|
hint = 0;
|
|
@@ -331,8 +332,14 @@ function extractCompleteSequences(
|
|
|
331
332
|
}
|
|
332
333
|
if (end === -2) {
|
|
333
334
|
const next = buffer.charCodeAt(pos + 1);
|
|
334
|
-
|
|
335
|
-
|
|
335
|
+
if (next === 0x5d || next === 0x50 || next === 0x5f) {
|
|
336
|
+
// Unterminated string sequence at the cap (issue #4073 case A):
|
|
337
|
+
// the head is junk and everything after it is mid-string
|
|
338
|
+
// payload. Hand both to the caller's discard mode instead of
|
|
339
|
+
// flushing the payload as per-character typed text.
|
|
340
|
+
return { sequences, remainder: "", resumeSearchFrom: 0, discardFrom: pos };
|
|
341
|
+
}
|
|
342
|
+
const flushEnd = Math.min(length, pos + MAX_CSI_BYTES);
|
|
336
343
|
sequences.push(buffer.slice(pos, flushEnd));
|
|
337
344
|
pos = flushEnd;
|
|
338
345
|
hint = 0;
|
|
@@ -399,6 +406,10 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
|
|
|
399
406
|
#escapeSearchOffset = 0;
|
|
400
407
|
#rawPasteCandidate = "";
|
|
401
408
|
#rawPasteTimer?: NodeJS.Timeout;
|
|
409
|
+
#stringDiscardActive = false;
|
|
410
|
+
#stringDiscardBytes = 0;
|
|
411
|
+
#stringDiscardEscHeld = false;
|
|
412
|
+
#stringDiscardWatchdog?: NodeJS.Timeout;
|
|
402
413
|
|
|
403
414
|
constructor(options: StdinBufferOptions = {}) {
|
|
404
415
|
super();
|
|
@@ -422,6 +433,12 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
|
|
|
422
433
|
} else {
|
|
423
434
|
str = data;
|
|
424
435
|
}
|
|
436
|
+
if (this.#stringDiscardActive) {
|
|
437
|
+
// Mid torn-string: swallow payload up to the terminator; only what
|
|
438
|
+
// follows the terminator re-enters normal parsing.
|
|
439
|
+
str = this.#consumeStringDiscard(str);
|
|
440
|
+
if (str.length === 0) return;
|
|
441
|
+
}
|
|
425
442
|
|
|
426
443
|
if (this.#flushDeferral && this.#isFreshEscapeAfterDeferredFlush(str)) {
|
|
427
444
|
// The buffered partial already hit its flush timeout. A new escape is
|
|
@@ -500,6 +517,18 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
|
|
|
500
517
|
}
|
|
501
518
|
|
|
502
519
|
const result = extractCompleteSequences(this.#buffer, this.#escapeSearchOffset);
|
|
520
|
+
if (result.discardFrom !== undefined) {
|
|
521
|
+
const junk = this.#buffer.slice(result.discardFrom);
|
|
522
|
+
this.#buffer = "";
|
|
523
|
+
this.#escapeSearchOffset = 0;
|
|
524
|
+
for (const sequence of result.sequences) {
|
|
525
|
+
this.#emitDataSequence(sequence);
|
|
526
|
+
}
|
|
527
|
+
this.#enterStringDiscard();
|
|
528
|
+
const after = this.#consumeStringDiscard(junk);
|
|
529
|
+
if (after.length > 0) this.process(after);
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
503
532
|
this.#buffer = result.remainder;
|
|
504
533
|
this.#escapeSearchOffset = result.resumeSearchFrom;
|
|
505
534
|
|
|
@@ -662,7 +691,7 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
|
|
|
662
691
|
this.#timeout = undefined;
|
|
663
692
|
this.#flushDeferral = setTimeout(() => {
|
|
664
693
|
this.#flushDeferral = undefined;
|
|
665
|
-
this.#flushExpired();
|
|
694
|
+
this.#flushExpired(true);
|
|
666
695
|
});
|
|
667
696
|
}, this.#timeoutMs);
|
|
668
697
|
}
|
|
@@ -710,7 +739,7 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
|
|
|
710
739
|
}
|
|
711
740
|
|
|
712
741
|
/** Timeout-driven flush: hold unambiguous partials (bounded), else deliver. */
|
|
713
|
-
#flushExpired(): void {
|
|
742
|
+
#flushExpired(fromTimer = false): void {
|
|
714
743
|
if (this.#buffer.length === 0) {
|
|
715
744
|
this.#partialHoldStartMs = 0;
|
|
716
745
|
return;
|
|
@@ -723,12 +752,16 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
|
|
|
723
752
|
}
|
|
724
753
|
}
|
|
725
754
|
this.#partialHoldStartMs = 0;
|
|
726
|
-
for (const sequence of this
|
|
755
|
+
for (const sequence of this.#drainBuffered(fromTimer)) {
|
|
727
756
|
this.#emitDataSequence(sequence);
|
|
728
757
|
}
|
|
729
758
|
}
|
|
730
759
|
|
|
731
760
|
flush(): string[] {
|
|
761
|
+
return this.#drainBuffered(false);
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
#drainBuffered(discardTornString: boolean): string[] {
|
|
732
765
|
this.#clearFlushTimer();
|
|
733
766
|
|
|
734
767
|
const rawCandidate = this.#takeRawPasteCandidate();
|
|
@@ -750,16 +783,93 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
|
|
|
750
783
|
// split in `extractCompleteSequences` and deliver two ESC events.
|
|
751
784
|
if (buffered === `${ESC}${ESC}`) {
|
|
752
785
|
sequences.push(ESC, ESC);
|
|
786
|
+
} else if (isKittyProtocolActive() && STRING_SEQ_PARTIAL.test(buffered)) {
|
|
787
|
+
// Torn OSC/DCS/APC head. Under the kitty keyboard protocol no key
|
|
788
|
+
// ever arrives as a bare `\x1b]`/`\x1bP`/`\x1b_`, so this is always
|
|
789
|
+
// terminal protocol data (kitty OSC 5522 clipboard read, graphics
|
|
790
|
+
// reply) whose tail is still in flight. Never deliver the head as a
|
|
791
|
+
// key; on a timeout flush also swallow the tail up to its
|
|
792
|
+
// terminator so kilobytes of base64 are not typed into the focused
|
|
793
|
+
// component (image paste spam).
|
|
794
|
+
if (discardTornString) this.#enterStringDiscard();
|
|
753
795
|
} else {
|
|
754
796
|
sequences.push(buffered);
|
|
755
797
|
}
|
|
756
798
|
return sequences;
|
|
757
799
|
}
|
|
758
800
|
|
|
801
|
+
/** Enter torn-string discard: swallow mid-string bytes until ST/BEL. */
|
|
802
|
+
#enterStringDiscard(): void {
|
|
803
|
+
this.#stringDiscardActive = true;
|
|
804
|
+
this.#stringDiscardBytes = 0;
|
|
805
|
+
this.#stringDiscardEscHeld = false;
|
|
806
|
+
this.#armStringDiscardWatchdog();
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
#exitStringDiscard(): void {
|
|
810
|
+
this.#stringDiscardActive = false;
|
|
811
|
+
this.#stringDiscardBytes = 0;
|
|
812
|
+
this.#stringDiscardEscHeld = false;
|
|
813
|
+
if (this.#stringDiscardWatchdog) {
|
|
814
|
+
clearTimeout(this.#stringDiscardWatchdog);
|
|
815
|
+
this.#stringDiscardWatchdog = undefined;
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
/**
|
|
820
|
+
* Swallow mid-string bytes; returns the input remaining after the string
|
|
821
|
+
* terminator (empty while still inside the torn string). A trailing ESC is
|
|
822
|
+
* held across calls so an `ESC \` split between reads is still detected.
|
|
823
|
+
* The byte budget bounds a terminator-less stream; the inactivity watchdog
|
|
824
|
+
* recovers a stream that stops without ever terminating.
|
|
825
|
+
*/
|
|
826
|
+
#consumeStringDiscard(str: string): string {
|
|
827
|
+
if (this.#stringDiscardEscHeld) {
|
|
828
|
+
this.#stringDiscardEscHeld = false;
|
|
829
|
+
if (str.charCodeAt(0) === 0x5c /* \ */) {
|
|
830
|
+
this.#exitStringDiscard();
|
|
831
|
+
return str.slice(1);
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
for (let i = 0; i < str.length; i++) {
|
|
835
|
+
const code = str.charCodeAt(i);
|
|
836
|
+
if (code === 0x07 /* BEL */) {
|
|
837
|
+
this.#exitStringDiscard();
|
|
838
|
+
return str.slice(i + 1);
|
|
839
|
+
}
|
|
840
|
+
if (code === 0x1b /* ESC */) {
|
|
841
|
+
if (i + 1 === str.length) {
|
|
842
|
+
this.#stringDiscardEscHeld = true;
|
|
843
|
+
break;
|
|
844
|
+
}
|
|
845
|
+
if (str.charCodeAt(i + 1) === 0x5c /* \ */) {
|
|
846
|
+
this.#exitStringDiscard();
|
|
847
|
+
return str.slice(i + 2);
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
this.#stringDiscardBytes += str.length;
|
|
852
|
+
if (this.#stringDiscardBytes > STRING_DISCARD_MAX_BYTES) {
|
|
853
|
+
this.#exitStringDiscard();
|
|
854
|
+
return "";
|
|
855
|
+
}
|
|
856
|
+
this.#armStringDiscardWatchdog();
|
|
857
|
+
return "";
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
#armStringDiscardWatchdog(): void {
|
|
861
|
+
if (this.#stringDiscardWatchdog) clearTimeout(this.#stringDiscardWatchdog);
|
|
862
|
+
this.#stringDiscardWatchdog = setTimeout(() => {
|
|
863
|
+
this.#stringDiscardWatchdog = undefined;
|
|
864
|
+
this.#exitStringDiscard();
|
|
865
|
+
}, STRING_DISCARD_INACTIVITY_MS);
|
|
866
|
+
}
|
|
867
|
+
|
|
759
868
|
clear(): void {
|
|
760
869
|
this.#clearFlushTimer();
|
|
761
870
|
this.#clearPasteWatchdog();
|
|
762
871
|
this.#clearRawPasteTimer();
|
|
872
|
+
this.#exitStringDiscard();
|
|
763
873
|
this.#buffer = "";
|
|
764
874
|
this.#rawPasteCandidate = "";
|
|
765
875
|
this.#pasteMode = false;
|
|
@@ -105,7 +105,7 @@ export class TerminalInfo {
|
|
|
105
105
|
public readonly deccara: boolean = false,
|
|
106
106
|
readonly supportsScreenToScrollback: boolean = false,
|
|
107
107
|
/** Renders the Kitty OSC 66 text-sizing protocol (scaled spans). Kitty only. */
|
|
108
|
-
public readonly
|
|
108
|
+
public readonly supportsTextSizing: boolean = false,
|
|
109
109
|
/**
|
|
110
110
|
* Hangul Compatibility Jamo (U+3131..=U+318E) cell width. Ghostty follows
|
|
111
111
|
* UAX#11 (2 cells); Warp paints 1; "platform" keeps the OS default
|
|
@@ -557,11 +557,14 @@ export interface RuntimeTerminal extends TerminalInfo {
|
|
|
557
557
|
hyperlinks: boolean;
|
|
558
558
|
deccara: boolean;
|
|
559
559
|
supportsScreenToScrollback: boolean;
|
|
560
|
+
/** Whether OSC 66 text sizing is currently enabled. */
|
|
560
561
|
textSizing: boolean;
|
|
561
562
|
}
|
|
562
563
|
|
|
563
564
|
export const TERMINAL: RuntimeTerminal = (() => {
|
|
564
565
|
const resolved = getTerminalInfo(TERMINAL_ID).clone();
|
|
566
|
+
// Detection records support; hosts opt into OSC 66 separately.
|
|
567
|
+
resolved.textSizing = false;
|
|
565
568
|
|
|
566
569
|
const forcedImageProtocol = getForcedImageProtocol();
|
|
567
570
|
if (forcedImageProtocol !== undefined) {
|
|
@@ -618,7 +621,7 @@ export function setTerminalScreenToScrollback(enabled: boolean): void {
|
|
|
618
621
|
|
|
619
622
|
/**
|
|
620
623
|
* Enable/disable OSC 66 text-sizing at runtime. The coding-agent calls this from
|
|
621
|
-
* the `tui.textSizing` setting (gated on the terminal's static `
|
|
624
|
+
* the `tui.textSizing` setting (gated on the terminal's static `supportsTextSizing`
|
|
622
625
|
* capability); tests flip it directly to exercise the scaled-heading path.
|
|
623
626
|
*/
|
|
624
627
|
export function setTerminalTextSizing(enabled: boolean): void {
|
|
@@ -831,6 +834,14 @@ export function encodeKittyPlacementLine(options: {
|
|
|
831
834
|
export function encodeKittyDeleteImage(imageId: number): string {
|
|
832
835
|
return wrapTmuxPassthroughIfNeeded(`\x1b_Ga=d,d=I,i=${imageId},q=2\x1b\\`);
|
|
833
836
|
}
|
|
837
|
+
/**
|
|
838
|
+
* Delete every Kitty image and placement in the terminal. Used only by an
|
|
839
|
+
* explicit destructive display reset: text erases leave untracked placements
|
|
840
|
+
* painted, so per-image bookkeeping cannot guarantee a clean viewport.
|
|
841
|
+
*/
|
|
842
|
+
export function encodeKittyDeleteAllImages(): string {
|
|
843
|
+
return wrapTmuxPassthroughIfNeeded("\x1b_Ga=d,d=A,q=2\x1b\\");
|
|
844
|
+
}
|
|
834
845
|
|
|
835
846
|
/**
|
|
836
847
|
* Delete a single placement of an image (`d=i`, lowercase): removes its cells
|
package/src/terminal.ts
CHANGED
|
@@ -231,6 +231,20 @@ function registerPostmortemTerminalRestore(): void {
|
|
|
231
231
|
export function setAltScreenActive(active: boolean): void {
|
|
232
232
|
altScreenActive = active;
|
|
233
233
|
}
|
|
234
|
+
/**
|
|
235
|
+
* Route an out-of-band escape sequence (e.g. an OSC title update) through the
|
|
236
|
+
* active terminal's output path. While a TUI owns stdout, frame paints go
|
|
237
|
+
* through the off-thread write pump and can split across multiple write(2)
|
|
238
|
+
* calls; a direct main-thread `process.stdout.write` can land between two of
|
|
239
|
+
* them — mid escape sequence — and the host terminal then prints the payload
|
|
240
|
+
* as literal text at the cursor position. Returns false when no terminal has
|
|
241
|
+
* started, in which case the caller owns stdout and may write directly.
|
|
242
|
+
*/
|
|
243
|
+
export function writeThroughActiveTerminal(data: string): boolean {
|
|
244
|
+
if (!activeTerminal) return false;
|
|
245
|
+
activeTerminal.write(data);
|
|
246
|
+
return true;
|
|
247
|
+
}
|
|
234
248
|
|
|
235
249
|
const stdoutErrorHandlers = new Set<(err: Error) => void>();
|
|
236
250
|
let stdoutErrorListenerInstalled = false;
|