@oh-my-pi/pi-tui 16.4.4 → 16.4.5
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 +11 -0
- package/dist/types/autocomplete.d.ts +14 -0
- package/dist/types/fuzzy.d.ts +16 -0
- package/dist/types/tui.d.ts +10 -0
- package/package.json +3 -3
- package/src/autocomplete.ts +32 -1
- package/src/components/editor.ts +6 -12
- package/src/components/loader.ts +9 -5
- package/src/fuzzy.ts +32 -4
- package/src/tui.ts +153 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [16.4.5] - 2026-07-11
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Added `FuzzyText`, a prepared fuzzy-match handle that builds the search index once and matches many queries against it, optimizing performance for large corpora like session or transcript searches.
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- Fixed an issue where the mid-prompt `/` autocomplete popup lingered indefinitely on non-path and non-skill tokens. Autocomplete matching is now properly gated to explicit skill namespaces, queries, and prefixes, preventing stale popups from incorrectly rewriting input on Tab or Enter.
|
|
14
|
+
- Fixed idle Loader animation driving the full TUI render pipeline on every spinner tick by directly rewriting the Loader's visible rows when geometry is unchanged, reducing idle render work while preserving fallback repaint paths ([#5192](https://github.com/can1357/oh-my-pi/issues/5192)).
|
|
15
|
+
|
|
5
16
|
## [16.4.1] - 2026-07-10
|
|
6
17
|
|
|
7
18
|
### Added
|
|
@@ -74,6 +74,20 @@ export interface AutocompleteProvider {
|
|
|
74
74
|
}
|
|
75
75
|
type CommandEntry = SlashCommand | AutocompleteItem;
|
|
76
76
|
export declare function scoreCommandTextMatch(lowerPrefix: string, lowerTarget: string): number;
|
|
77
|
+
/**
|
|
78
|
+
* Whether a mid-prompt slash token (`prose … /tok`) is skill-shaped enough to
|
|
79
|
+
* surface `name` in the skill popup. Deliberately stricter than submitted
|
|
80
|
+
* slash-command matching: a stray `/word` in running prose must not keep the
|
|
81
|
+
* popup alive through fuzzy name/description hits, so a token only matches as
|
|
82
|
+
* - a prefix of the `skill:` namespace (incl. the bare `/` entry point),
|
|
83
|
+
* - an explicit `skill:…` query (full fuzzy name/description search), or
|
|
84
|
+
* - a prefix of the skill's bare name (`/hum` → `skill:humanizer`).
|
|
85
|
+
* Anything else yields no items, letting the caller fall through to path
|
|
86
|
+
* completion or close the popup. Shared with the editor's accept-time
|
|
87
|
+
* staleness guard so Tab/Enter never accepts a skill the refreshed popup
|
|
88
|
+
* would no longer show.
|
|
89
|
+
*/
|
|
90
|
+
export declare function midPromptSkillTokenMatches(lowerToken: string, name: string, description?: string): boolean;
|
|
77
91
|
export declare class CombinedAutocompleteProvider implements AutocompleteProvider {
|
|
78
92
|
#private;
|
|
79
93
|
constructor(commands?: CommandEntry[], basePath?: string);
|
package/dist/types/fuzzy.d.ts
CHANGED
|
@@ -16,6 +16,22 @@ export interface FuzzyFilterResult<T> {
|
|
|
16
16
|
score: number;
|
|
17
17
|
}
|
|
18
18
|
export declare function fuzzyMatch(query: string, text: string): FuzzyMatch;
|
|
19
|
+
/**
|
|
20
|
+
* A text prepared once for repeated fuzzy matching.
|
|
21
|
+
*
|
|
22
|
+
* `fuzzyMatch` builds a search index per call; the module cache only admits
|
|
23
|
+
* texts up to {@link MAX_CACHED_TEXT_LEN}, so long corpora (session or
|
|
24
|
+
* transcript search) rebuild the index on every keystroke — the dominant cost
|
|
25
|
+
* when a selector re-filters a stable candidate list as the user types. Build
|
|
26
|
+
* one `FuzzyText` per candidate and call {@link match} per query instead; the
|
|
27
|
+
* index lives exactly as long as the caller's reference.
|
|
28
|
+
*/
|
|
29
|
+
export declare class FuzzyText {
|
|
30
|
+
#private;
|
|
31
|
+
constructor(text: string);
|
|
32
|
+
/** Match `query` (space-separated tokens; all must match) against the prepared text. */
|
|
33
|
+
match(query: string): FuzzyMatch;
|
|
34
|
+
}
|
|
19
35
|
/**
|
|
20
36
|
* Filter and sort items by fuzzy match quality (best matches first).
|
|
21
37
|
* Supports space-separated tokens: all tokens must match.
|
package/dist/types/tui.d.ts
CHANGED
|
@@ -394,4 +394,14 @@ export declare class TUI extends Container {
|
|
|
394
394
|
* cheaper.
|
|
395
395
|
*/
|
|
396
396
|
requestComponentRender(component: Component): void;
|
|
397
|
+
/**
|
|
398
|
+
* Rewrite a quiet, visible component segment directly.
|
|
399
|
+
*
|
|
400
|
+
* Loader-style animation changes one already-positioned segment at a fixed
|
|
401
|
+
* size. When the current frame geometry is still valid, rewrite just those
|
|
402
|
+
* rows and update the diff baseline instead of scheduling a full render
|
|
403
|
+
* cycle. Unsafe states fall back to `requestComponentRender()`, preserving
|
|
404
|
+
* the ordinary renderer as the correctness path.
|
|
405
|
+
*/
|
|
406
|
+
requestDirectWrite(component: Component): void;
|
|
397
407
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-tui",
|
|
4
|
-
"version": "16.4.
|
|
4
|
+
"version": "16.4.5",
|
|
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": "16.4.
|
|
41
|
-
"@oh-my-pi/pi-utils": "16.4.
|
|
40
|
+
"@oh-my-pi/pi-natives": "16.4.5",
|
|
41
|
+
"@oh-my-pi/pi-utils": "16.4.5",
|
|
42
42
|
"lru-cache": "11.5.1",
|
|
43
43
|
"marked": "^18.0.5"
|
|
44
44
|
},
|
package/src/autocomplete.ts
CHANGED
|
@@ -359,9 +359,40 @@ function hasPromptTextBeforeSlash(
|
|
|
359
359
|
return textBeforeCursor.slice(0, slashStart).trim() !== "";
|
|
360
360
|
}
|
|
361
361
|
|
|
362
|
+
const SKILL_NAMESPACE = "skill:";
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Whether a mid-prompt slash token (`prose … /tok`) is skill-shaped enough to
|
|
366
|
+
* surface `name` in the skill popup. Deliberately stricter than submitted
|
|
367
|
+
* slash-command matching: a stray `/word` in running prose must not keep the
|
|
368
|
+
* popup alive through fuzzy name/description hits, so a token only matches as
|
|
369
|
+
* - a prefix of the `skill:` namespace (incl. the bare `/` entry point),
|
|
370
|
+
* - an explicit `skill:…` query (full fuzzy name/description search), or
|
|
371
|
+
* - a prefix of the skill's bare name (`/hum` → `skill:humanizer`).
|
|
372
|
+
* Anything else yields no items, letting the caller fall through to path
|
|
373
|
+
* completion or close the popup. Shared with the editor's accept-time
|
|
374
|
+
* staleness guard so Tab/Enter never accepts a skill the refreshed popup
|
|
375
|
+
* would no longer show.
|
|
376
|
+
*/
|
|
377
|
+
export function midPromptSkillTokenMatches(lowerToken: string, name: string, description?: string): boolean {
|
|
378
|
+
if (SKILL_NAMESPACE.startsWith(lowerToken)) return true;
|
|
379
|
+
const lowerName = name.toLowerCase();
|
|
380
|
+
if (lowerToken.startsWith(SKILL_NAMESPACE)) {
|
|
381
|
+
if (scoreCommandTextMatch(lowerToken, lowerName) > 0) return true;
|
|
382
|
+
return !!description && scoreCommandTextMatch(lowerToken, description.toLowerCase()) > 0;
|
|
383
|
+
}
|
|
384
|
+
return lowerName.startsWith(SKILL_NAMESPACE) && lowerName.slice(SKILL_NAMESPACE.length).startsWith(lowerToken);
|
|
385
|
+
}
|
|
386
|
+
|
|
362
387
|
function buildMidPromptSkillCompletions(commands: CommandEntry[], lowerPrefix: string): AutocompleteItem[] {
|
|
363
388
|
return buildSlashCommandCompletions(
|
|
364
|
-
commands.filter(cmd =>
|
|
389
|
+
commands.filter(cmd => {
|
|
390
|
+
const name = getCommandName(cmd);
|
|
391
|
+
return (
|
|
392
|
+
name?.startsWith(SKILL_NAMESPACE) &&
|
|
393
|
+
midPromptSkillTokenMatches(lowerPrefix, name, getStaticCommandDescription(cmd))
|
|
394
|
+
);
|
|
395
|
+
}),
|
|
365
396
|
lowerPrefix,
|
|
366
397
|
);
|
|
367
398
|
}
|
package/src/components/editor.ts
CHANGED
|
@@ -3,7 +3,7 @@ import {
|
|
|
3
3
|
type AutocompleteProvider,
|
|
4
4
|
findLeadingSlashCommandStart,
|
|
5
5
|
findTrailingSlashCommandStart,
|
|
6
|
-
|
|
6
|
+
midPromptSkillTokenMatches,
|
|
7
7
|
} from "../autocomplete";
|
|
8
8
|
import { BracketedPasteHandler, decodeReencodedPasteControls } from "../bracketed-paste";
|
|
9
9
|
import { getKeybindings, type KeybindingsManager } from "../keybindings";
|
|
@@ -2911,13 +2911,12 @@ export class Editor implements Component, Focusable {
|
|
|
2911
2911
|
// Guard the timing window where the popup was built for an earlier
|
|
2912
2912
|
// query (e.g. bare `/`) and the user typed further characters before
|
|
2913
2913
|
// the 100 ms debounced refresh fired: accept the stale skill only
|
|
2914
|
-
// when the
|
|
2915
|
-
//
|
|
2916
|
-
//
|
|
2914
|
+
// when the refreshed popup would still surface it (same gate as
|
|
2915
|
+
// buildMidPromptSkillCompletions). `tmp` after a bare slash
|
|
2916
|
+
// therefore falls through to file completion instead of rewriting
|
|
2917
|
+
// the user's `/tmp` to `/skill:…`.
|
|
2917
2918
|
const lowerToken = token.slice(1).toLowerCase();
|
|
2918
|
-
if (
|
|
2919
|
-
if (item.description && scoreCommandTextMatch(lowerToken, item.description.toLowerCase()) > 0)
|
|
2920
|
-
return true;
|
|
2919
|
+
if (midPromptSkillTokenMatches(lowerToken, item.value, item.description)) return true;
|
|
2921
2920
|
}
|
|
2922
2921
|
}
|
|
2923
2922
|
return false;
|
|
@@ -3050,11 +3049,6 @@ export class Editor implements Component, Focusable {
|
|
|
3050
3049
|
await this.#tryTriggerAutocomplete();
|
|
3051
3050
|
}
|
|
3052
3051
|
|
|
3053
|
-
/*
|
|
3054
|
-
https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19536643416/job/559322883
|
|
3055
|
-
17 this job fails with https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19
|
|
3056
|
-
536643416/job/55932288317 havea look at .gi
|
|
3057
|
-
*/
|
|
3058
3052
|
async #forceFileAutocomplete(explicitTab: boolean = false): Promise<void> {
|
|
3059
3053
|
if (!this.#autocompleteProvider) return;
|
|
3060
3054
|
|
package/src/components/loader.ts
CHANGED
|
@@ -93,11 +93,15 @@ export class Loader extends Text {
|
|
|
93
93
|
const frame = this.#frames[this.#currentFrame];
|
|
94
94
|
const text = `${this.spinnerColorFn(frame)} ${this.messageColorFn(this.message)}`;
|
|
95
95
|
if (this.setText(text) && this.#ui) {
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
this.#ui.
|
|
96
|
+
// Direct write: a loader tick changes only this component, so the TUI
|
|
97
|
+
// can update the already-positioned rows without driving the full
|
|
98
|
+
// compose/prepare/diff pipeline. Lightweight test stubs may not carry
|
|
99
|
+
// the newer API; keep their legacy component-scoped path working.
|
|
100
|
+
if (typeof this.#ui.requestDirectWrite === "function") {
|
|
101
|
+
this.#ui.requestDirectWrite(this);
|
|
102
|
+
} else {
|
|
103
|
+
this.#ui.requestComponentRender(this);
|
|
104
|
+
}
|
|
101
105
|
}
|
|
102
106
|
}
|
|
103
107
|
}
|
package/src/fuzzy.ts
CHANGED
|
@@ -280,12 +280,11 @@ function prepareQuery(query: string): PreparedQuery | null {
|
|
|
280
280
|
return { normalized, tokens: normalized.split(" "), compact: normalized.replaceAll(" ", "") };
|
|
281
281
|
}
|
|
282
282
|
|
|
283
|
-
function fuzzyMatchCore(pq: PreparedQuery | null,
|
|
283
|
+
function fuzzyMatchCore(pq: PreparedQuery | null, index: SearchIndex): FuzzyMatch {
|
|
284
284
|
if (pq === null) {
|
|
285
285
|
return { matches: true, score: 0 };
|
|
286
286
|
}
|
|
287
287
|
|
|
288
|
-
const index = buildSearchIndex(text);
|
|
289
288
|
if (index.words.length === 0) {
|
|
290
289
|
return { matches: false, score: 0 };
|
|
291
290
|
}
|
|
@@ -315,7 +314,32 @@ function fuzzyMatchCore(pq: PreparedQuery | null, text: string): FuzzyMatch {
|
|
|
315
314
|
}
|
|
316
315
|
|
|
317
316
|
export function fuzzyMatch(query: string, text: string): FuzzyMatch {
|
|
318
|
-
|
|
317
|
+
const pq = prepareQuery(query);
|
|
318
|
+
if (pq === null) return { matches: true, score: 0 };
|
|
319
|
+
return fuzzyMatchCore(pq, buildSearchIndex(text));
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* A text prepared once for repeated fuzzy matching.
|
|
324
|
+
*
|
|
325
|
+
* `fuzzyMatch` builds a search index per call; the module cache only admits
|
|
326
|
+
* texts up to {@link MAX_CACHED_TEXT_LEN}, so long corpora (session or
|
|
327
|
+
* transcript search) rebuild the index on every keystroke — the dominant cost
|
|
328
|
+
* when a selector re-filters a stable candidate list as the user types. Build
|
|
329
|
+
* one `FuzzyText` per candidate and call {@link match} per query instead; the
|
|
330
|
+
* index lives exactly as long as the caller's reference.
|
|
331
|
+
*/
|
|
332
|
+
export class FuzzyText {
|
|
333
|
+
readonly #index: SearchIndex;
|
|
334
|
+
|
|
335
|
+
constructor(text: string) {
|
|
336
|
+
this.#index = buildUncachedSearchIndex(text);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** Match `query` (space-separated tokens; all must match) against the prepared text. */
|
|
340
|
+
match(query: string): FuzzyMatch {
|
|
341
|
+
return fuzzyMatchCore(prepareQuery(query), this.#index);
|
|
342
|
+
}
|
|
319
343
|
}
|
|
320
344
|
|
|
321
345
|
/**
|
|
@@ -327,10 +351,14 @@ export function fuzzyRank<T>(items: T[], query: string, getText: (item: T) => st
|
|
|
327
351
|
return items.map(item => ({ item, score: 0 }));
|
|
328
352
|
}
|
|
329
353
|
|
|
354
|
+
// A non-blank query that normalizes to empty (pure punctuation) matches
|
|
355
|
+
// everything with score 0, but still calls getText per item — consumers rely
|
|
356
|
+
// on its side effects (see fuzzy-cache.test.ts).
|
|
330
357
|
const pq = prepareQuery(query);
|
|
331
358
|
const results: FuzzyFilterResult<T>[] = [];
|
|
332
359
|
for (const item of items) {
|
|
333
|
-
const
|
|
360
|
+
const text = getText(item);
|
|
361
|
+
const match = pq === null ? { matches: true, score: 0 } : fuzzyMatchCore(pq, buildSearchIndex(text));
|
|
334
362
|
if (match.matches) {
|
|
335
363
|
results.push({ item, score: match.score });
|
|
336
364
|
}
|
package/src/tui.ts
CHANGED
|
@@ -1835,6 +1835,159 @@ export class TUI extends Container {
|
|
|
1835
1835
|
this.#requestOrdinaryRender();
|
|
1836
1836
|
}
|
|
1837
1837
|
|
|
1838
|
+
/**
|
|
1839
|
+
* Rewrite a quiet, visible component segment directly.
|
|
1840
|
+
*
|
|
1841
|
+
* Loader-style animation changes one already-positioned segment at a fixed
|
|
1842
|
+
* size. When the current frame geometry is still valid, rewrite just those
|
|
1843
|
+
* rows and update the diff baseline instead of scheduling a full render
|
|
1844
|
+
* cycle. Unsafe states fall back to `requestComponentRender()`, preserving
|
|
1845
|
+
* the ordinary renderer as the correctness path.
|
|
1846
|
+
*/
|
|
1847
|
+
requestDirectWrite(component: Component): void {
|
|
1848
|
+
if (this.#stopped) return;
|
|
1849
|
+
if (
|
|
1850
|
+
this.#renderRequested ||
|
|
1851
|
+
this.#postFullPaintSettleTimer !== undefined ||
|
|
1852
|
+
this.#postFullPaintSettleUntilMs > 0
|
|
1853
|
+
) {
|
|
1854
|
+
this.requestComponentRender(component);
|
|
1855
|
+
return;
|
|
1856
|
+
}
|
|
1857
|
+
|
|
1858
|
+
const width = this.terminal.columns;
|
|
1859
|
+
const height = this.terminal.rows;
|
|
1860
|
+
if (!this.#hasEverRendered || this.#resizeEventPending) {
|
|
1861
|
+
this.requestComponentRender(component);
|
|
1862
|
+
return;
|
|
1863
|
+
}
|
|
1864
|
+
if (width !== this.#previousWidth || height !== this.#previousHeight || width !== this.#composeWidth) {
|
|
1865
|
+
this.requestComponentRender(component);
|
|
1866
|
+
return;
|
|
1867
|
+
}
|
|
1868
|
+
if (this.#clearScrollbackOnNextRender || this.#forceViewportRepaintOnNextRender) {
|
|
1869
|
+
this.requestComponentRender(component);
|
|
1870
|
+
return;
|
|
1871
|
+
}
|
|
1872
|
+
if (this.overlayStack.length > 0 || this.#altActive || !this.#imageBudget.quiescent) {
|
|
1873
|
+
this.requestComponentRender(component);
|
|
1874
|
+
return;
|
|
1875
|
+
}
|
|
1876
|
+
|
|
1877
|
+
const children = this.children;
|
|
1878
|
+
const segments = this.#frameSegments;
|
|
1879
|
+
if (segments.length !== children.length) {
|
|
1880
|
+
this.requestComponentRender(component);
|
|
1881
|
+
return;
|
|
1882
|
+
}
|
|
1883
|
+
for (let i = 0; i < children.length; i++) {
|
|
1884
|
+
if (segments[i]!.component !== children[i]) {
|
|
1885
|
+
this.requestComponentRender(component);
|
|
1886
|
+
return;
|
|
1887
|
+
}
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1890
|
+
const root = this.#resolveComponentRoot(component);
|
|
1891
|
+
if (root === null) {
|
|
1892
|
+
this.requestComponentRender(component);
|
|
1893
|
+
return;
|
|
1894
|
+
}
|
|
1895
|
+
const segmentIndex = segments.findIndex(segment => segment.component === root);
|
|
1896
|
+
if (segmentIndex === -1) {
|
|
1897
|
+
this.requestComponentRender(component);
|
|
1898
|
+
return;
|
|
1899
|
+
}
|
|
1900
|
+
const segment = segments[segmentIndex]!;
|
|
1901
|
+
const fullyLiveUncommittedSegment = segment.liveLocalStart === 0 && segment.start >= this.#committedRows;
|
|
1902
|
+
if (
|
|
1903
|
+
(segment.liveLocalStart !== undefined && !fullyLiveUncommittedSegment) ||
|
|
1904
|
+
segment.start < this.#committedRows
|
|
1905
|
+
) {
|
|
1906
|
+
this.requestComponentRender(component);
|
|
1907
|
+
return;
|
|
1908
|
+
}
|
|
1909
|
+
|
|
1910
|
+
const windowTop = Math.max(this.#committedRows, this.#composedFrame.length - height, 0);
|
|
1911
|
+
if (windowTop !== this.#windowTopRow) {
|
|
1912
|
+
this.requestComponentRender(component);
|
|
1913
|
+
return;
|
|
1914
|
+
}
|
|
1915
|
+
const screenStart = segment.start - windowTop;
|
|
1916
|
+
if (screenStart < 0 || screenStart + segment.rowCount > height) {
|
|
1917
|
+
this.requestComponentRender(component);
|
|
1918
|
+
return;
|
|
1919
|
+
}
|
|
1920
|
+
|
|
1921
|
+
const nextLines = root.render(width);
|
|
1922
|
+
if (nextLines.length !== segment.rowCount) {
|
|
1923
|
+
this.requestComponentRender(component);
|
|
1924
|
+
return;
|
|
1925
|
+
}
|
|
1926
|
+
for (const line of nextLines) {
|
|
1927
|
+
if (line.includes(CURSOR_MARKER)) {
|
|
1928
|
+
this.requestComponentRender(component);
|
|
1929
|
+
return;
|
|
1930
|
+
}
|
|
1931
|
+
}
|
|
1932
|
+
|
|
1933
|
+
let firstChanged = -1;
|
|
1934
|
+
let lastChanged = -1;
|
|
1935
|
+
const previousWindow = this.#previousWindow;
|
|
1936
|
+
for (let i = 0; i < nextLines.length; i++) {
|
|
1937
|
+
const frameRow = segment.start + i;
|
|
1938
|
+
const raw = nextLines[i]!;
|
|
1939
|
+
const prepared = this.#prepareLine(raw, width);
|
|
1940
|
+
this.#composedFrame[frameRow] = raw;
|
|
1941
|
+
this.#preparedMeta[frameRow] = prepared;
|
|
1942
|
+
this.#preparedFrame[frameRow] = prepared.line;
|
|
1943
|
+
if (previousWindow[screenStart + i] === prepared.line) continue;
|
|
1944
|
+
previousWindow[screenStart + i] = prepared.line;
|
|
1945
|
+
if (firstChanged === -1) firstChanged = i;
|
|
1946
|
+
lastChanged = i;
|
|
1947
|
+
}
|
|
1948
|
+
segments[segmentIndex] = { ...segment, lines: nextLines };
|
|
1949
|
+
this.#preparedValidRows = Math.max(this.#preparedValidRows, segment.start + nextLines.length);
|
|
1950
|
+
this.#renderStablePrefixRows = Math.min(this.#renderStablePrefixRows, segment.start);
|
|
1951
|
+
|
|
1952
|
+
let cursorPos: { row: number; col: number } | null = null;
|
|
1953
|
+
for (let i = this.#frameCursorMarkers.length - 1; i >= 0; i--) {
|
|
1954
|
+
const marker = this.#frameCursorMarkers[i]!;
|
|
1955
|
+
if (marker.row >= windowTop) {
|
|
1956
|
+
cursorPos = marker;
|
|
1957
|
+
break;
|
|
1958
|
+
}
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1961
|
+
if (firstChanged === -1) {
|
|
1962
|
+
this.#writeCursorPosition(cursorPos, this.#composedFrame.length);
|
|
1963
|
+
this.#previousWidth = width;
|
|
1964
|
+
this.#previousHeight = height;
|
|
1965
|
+
return;
|
|
1966
|
+
}
|
|
1967
|
+
|
|
1968
|
+
const currentScreenRow = Math.max(0, Math.min(height - 1, this.#hardwareCursorRow - windowTop));
|
|
1969
|
+
const targetScreenRow = screenStart + firstChanged;
|
|
1970
|
+
const rowDelta = targetScreenRow - currentScreenRow;
|
|
1971
|
+
let buffer = this.#paintBeginSequence;
|
|
1972
|
+
if (rowDelta > 0) buffer += `\x1b[${rowDelta}B`;
|
|
1973
|
+
else if (rowDelta < 0) buffer += `\x1b[${-rowDelta}A`;
|
|
1974
|
+
buffer += "\r";
|
|
1975
|
+
for (let i = firstChanged; i <= lastChanged; i++) {
|
|
1976
|
+
if (i > firstChanged) buffer += "\r\n";
|
|
1977
|
+
buffer += this.#lineRewriteSequence(this.#preparedFrame[segment.start + i] ?? "", width);
|
|
1978
|
+
}
|
|
1979
|
+
const cursorControl = this.#cursorControlSequence(
|
|
1980
|
+
cursorPos,
|
|
1981
|
+
this.#composedFrame.length,
|
|
1982
|
+
segment.start + lastChanged,
|
|
1983
|
+
);
|
|
1984
|
+
buffer += cursorControl.seq;
|
|
1985
|
+
buffer += this.#paintEndSequence;
|
|
1986
|
+
this.terminal.write(buffer);
|
|
1987
|
+
this.#windowTopRow = windowTop;
|
|
1988
|
+
this.#commit(this.#composedFrame, previousWindow, width, height, cursorControl);
|
|
1989
|
+
}
|
|
1990
|
+
|
|
1838
1991
|
/** Ordinary (non-forced) scheduling shared by full and component-scoped requests. */
|
|
1839
1992
|
#requestOrdinaryRender(): void {
|
|
1840
1993
|
// Coalesce non-forced renders inside the post-full-paint ConPTY settle
|