@xynogen/pix-pretty 1.16.1 → 1.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/package.json +2 -2
- package/src/confirm.ts +3 -2
- package/src/gate-overlay.test.ts +3 -2
- package/src/gate-overlay.ts +8 -5
- package/src/modal-frame.test.ts +9 -5
- package/src/modal-frame.ts +35 -14
- package/src/progress.ts +9 -3
- package/src/utils.test.ts +52 -0
- package/src/utils.ts +51 -8
package/README.md
CHANGED
|
@@ -74,6 +74,8 @@ Configuration is read from **`~/.pi/agent/pix.json`** (the unified config file o
|
|
|
74
74
|
"icons": "nerd", // nerd | unicode | ascii
|
|
75
75
|
"maxPreviewLines": 80,
|
|
76
76
|
"maxRenderLines": 150,
|
|
77
|
+
"maxRenderWidth": "65%", // modal frame width; percentage or columns
|
|
78
|
+
"maxRenderHeight": "80%", // pagination threshold; percentage or rows
|
|
77
79
|
"maxHighlightChars": 80000,
|
|
78
80
|
"cacheLimit": 128,
|
|
79
81
|
"diff": {
|
|
@@ -84,7 +86,7 @@ Configuration is read from **`~/.pi/agent/pix.json`** (the unified config file o
|
|
|
84
86
|
}
|
|
85
87
|
```
|
|
86
88
|
|
|
87
|
-
Syntax highlighting, diffs, and tool surfaces use the active Pi theme. Color overrides do not live in `pix.json`.
|
|
89
|
+
Syntax highlighting, diffs, and tool surfaces use the active Pi theme. Color overrides do not live in `pix.json`. `/pix` exposes modal width and height under **Pretty**; percentage choices move in 5% steps.
|
|
88
90
|
|
|
89
91
|
### Environment Variables (override `pix.json`)
|
|
90
92
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xynogen/pix-pretty",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.17.0",
|
|
4
4
|
"description": "Enhanced tool output rendering with syntax highlighting, file icons, tree views, diff rendering, and FFF search",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -60,7 +60,7 @@
|
|
|
60
60
|
},
|
|
61
61
|
"dependencies": {
|
|
62
62
|
"@xynogen/pix-data": "^0.4.3",
|
|
63
|
-
"@xynogen/pix-runtime": "^0.
|
|
63
|
+
"@xynogen/pix-runtime": "^0.7.0",
|
|
64
64
|
"chalk": "^4.1.2",
|
|
65
65
|
"cli-highlight": "^2.1.11",
|
|
66
66
|
"@ff-labs/fff-node": "^0.5.2",
|
package/src/confirm.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
frameModal,
|
|
11
11
|
MIN_PERMISSION_MODAL_HEIGHT,
|
|
12
12
|
ModalPager,
|
|
13
|
+
modalOverlayOptions,
|
|
13
14
|
modalWidth,
|
|
14
15
|
selectListTheme,
|
|
15
16
|
terminalModalHeight,
|
|
@@ -37,7 +38,7 @@ export interface ConfirmUI {
|
|
|
37
38
|
kb: KeybindingsManager,
|
|
38
39
|
done: (v: T) => void,
|
|
39
40
|
) => CustomComponent,
|
|
40
|
-
opts?: { overlay?: boolean; overlayOptions?:
|
|
41
|
+
opts?: { overlay?: boolean; overlayOptions?: ReturnType<typeof modalOverlayOptions> },
|
|
41
42
|
): Promise<T | undefined>;
|
|
42
43
|
}
|
|
43
44
|
|
|
@@ -156,7 +157,7 @@ export function confirmOverlay(ui: ConfirmUI, opts: ConfirmOptions): Promise<boo
|
|
|
156
157
|
},
|
|
157
158
|
};
|
|
158
159
|
},
|
|
159
|
-
{ overlay: true, overlayOptions:
|
|
160
|
+
{ overlay: true, overlayOptions: modalOverlayOptions() },
|
|
160
161
|
).then((result) => {
|
|
161
162
|
if (timer !== undefined) clearTimeout(timer);
|
|
162
163
|
resolve(result ?? false);
|
package/src/gate-overlay.test.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, expect, jest, test } from "bun:test";
|
|
2
2
|
import { type OverlayUI, showOverlay } from "./gate-overlay.ts";
|
|
3
|
+
import { modalOverlayOptions } from "./modal-frame.ts";
|
|
3
4
|
|
|
4
5
|
// ── Mock host ─────────────────────────────────────────────────────────────────
|
|
5
6
|
//
|
|
@@ -60,7 +61,7 @@ const ENTER = "\r";
|
|
|
60
61
|
const DOWN = "\x1b[B";
|
|
61
62
|
|
|
62
63
|
describe("showOverlay — confirm mode", () => {
|
|
63
|
-
test("
|
|
64
|
+
test("uses configured global overlay bounds", async () => {
|
|
64
65
|
let options: unknown;
|
|
65
66
|
await showOverlay(
|
|
66
67
|
makeUI(
|
|
@@ -71,7 +72,7 @@ describe("showOverlay — confirm mode", () => {
|
|
|
71
72
|
),
|
|
72
73
|
{ mode: "confirm", title: "T" },
|
|
73
74
|
);
|
|
74
|
-
expect(options).toEqual({ overlay: true, overlayOptions:
|
|
75
|
+
expect(options).toEqual({ overlay: true, overlayOptions: modalOverlayOptions() });
|
|
75
76
|
});
|
|
76
77
|
|
|
77
78
|
test("selecting the approve choice (first) returns approved", async () => {
|
package/src/gate-overlay.ts
CHANGED
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
* pix-pretty/gate-overlay — shared permission dialog component.
|
|
3
3
|
*
|
|
4
4
|
* One component, two modes:
|
|
5
|
-
* "confirm" — SelectList only. Used by pix-gate
|
|
6
|
-
*
|
|
5
|
+
* "confirm" — SelectList only. Used by pix-gate (command gating) and pix-ssh
|
|
6
|
+
* (host confirm when no password is missing).
|
|
7
|
+
* "sudo" — SelectList → masked password input. Used by pix-sudo and pix-ssh
|
|
8
|
+
* (SSH login + remote sudo password entry).
|
|
7
9
|
*
|
|
8
10
|
* Both modes share: rounded modal frame (╭─╮╰─╯), solid bg, accent border,
|
|
9
11
|
* title, body lines, optional countdown. Same visual style as pix-ask.
|
|
@@ -11,7 +13,7 @@
|
|
|
11
13
|
* Design goals:
|
|
12
14
|
* - Pure function — no side effects, no global state.
|
|
13
15
|
* - Fully unit-testable: inject a mock `ui` to drive inputs deterministically.
|
|
14
|
-
* - Single source of truth for the overlay look across pix-gate and pix-
|
|
16
|
+
* - Single source of truth for the overlay look across pix-gate, pix-sudo, and pix-ssh.
|
|
15
17
|
*/
|
|
16
18
|
|
|
17
19
|
import { Input, type SelectItem, SelectList } from "@earendil-works/pi-tui";
|
|
@@ -20,6 +22,7 @@ import {
|
|
|
20
22
|
MIN_PERMISSION_MODAL_HEIGHT,
|
|
21
23
|
type ModalPageKeybindings,
|
|
22
24
|
ModalPager,
|
|
25
|
+
modalOverlayOptions,
|
|
23
26
|
modalWidth,
|
|
24
27
|
selectListTheme,
|
|
25
28
|
terminalModalHeight,
|
|
@@ -109,7 +112,7 @@ export interface OverlayUI {
|
|
|
109
112
|
kb: unknown,
|
|
110
113
|
done: (v: T) => void,
|
|
111
114
|
) => OverlayComponent,
|
|
112
|
-
opts?: { overlay?: boolean; overlayOptions?:
|
|
115
|
+
opts?: { overlay?: boolean; overlayOptions?: ReturnType<typeof modalOverlayOptions> },
|
|
113
116
|
): Promise<T | undefined>;
|
|
114
117
|
}
|
|
115
118
|
|
|
@@ -387,7 +390,7 @@ export function showOverlay(ui: OverlayUI, config: OverlayConfig): Promise<Overl
|
|
|
387
390
|
},
|
|
388
391
|
};
|
|
389
392
|
},
|
|
390
|
-
{ overlay: true, overlayOptions:
|
|
393
|
+
{ overlay: true, overlayOptions: modalOverlayOptions() },
|
|
391
394
|
).then((result) => {
|
|
392
395
|
resolve(result ?? { action: "denied" });
|
|
393
396
|
});
|
package/src/modal-frame.test.ts
CHANGED
|
@@ -27,8 +27,10 @@ function render(offset = 0): ModalFrameResult {
|
|
|
27
27
|
});
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
test("modalHeight
|
|
30
|
+
test("modalHeight resolves configured percentages and rows without exceeding the terminal", () => {
|
|
31
31
|
expect(modalHeight(40)).toBe(32);
|
|
32
|
+
expect(modalHeight(40, "50%")).toBe(20);
|
|
33
|
+
expect(modalHeight(40, 12)).toBe(12);
|
|
32
34
|
expect(modalHeight(10)).toBe(8);
|
|
33
35
|
expect(modalHeight(1)).toBe(1);
|
|
34
36
|
});
|
|
@@ -38,12 +40,14 @@ test("modalHeight is defensive about junk row counts", () => {
|
|
|
38
40
|
expect(modalHeight(Number.POSITIVE_INFINITY)).toBe(19);
|
|
39
41
|
expect(modalHeight(0)).toBe(1);
|
|
40
42
|
expect(modalHeight(-5)).toBe(1);
|
|
41
|
-
expect(modalHeight(40, 100)).toBe(40);
|
|
43
|
+
expect(modalHeight(40, "100%")).toBe(40);
|
|
42
44
|
});
|
|
43
45
|
|
|
44
|
-
test("modalWidth
|
|
45
|
-
expect(modalWidth(200)).toBe(
|
|
46
|
-
expect(modalWidth(50)).toBe(
|
|
46
|
+
test("modalWidth resolves configured percentages and columns without exceeding available width", () => {
|
|
47
|
+
expect(modalWidth(200)).toBe(200);
|
|
48
|
+
expect(modalWidth(200, "50%")).toBe(100);
|
|
49
|
+
expect(modalWidth(200, 88)).toBe(88);
|
|
50
|
+
expect(modalWidth(50)).toBe(50);
|
|
47
51
|
expect(modalWidth(10)).toBe(10);
|
|
48
52
|
expect(modalWidth(1)).toBe(1);
|
|
49
53
|
});
|
package/src/modal-frame.ts
CHANGED
|
@@ -16,6 +16,8 @@ import {
|
|
|
16
16
|
visibleWidth,
|
|
17
17
|
wrapTextWithAnsi,
|
|
18
18
|
} from "@earendil-works/pi-tui";
|
|
19
|
+
import { config } from "@xynogen/pix-runtime/config";
|
|
20
|
+
import { prettySection, type RenderSize } from "@xynogen/pix-runtime/sections";
|
|
19
21
|
|
|
20
22
|
export { truncateToWidth, visibleWidth, wrapTextWithAnsi };
|
|
21
23
|
|
|
@@ -24,18 +26,36 @@ export { truncateToWidth, visibleWidth, wrapTextWithAnsi };
|
|
|
24
26
|
// ── Constants ─────────────────────────────────────────────────────────────────
|
|
25
27
|
|
|
26
28
|
const MIN_WIDTH = 40;
|
|
27
|
-
const MAX_WIDTH = 96;
|
|
28
|
-
const MARGIN = 4;
|
|
29
29
|
/** 2 border cols + 2 padding spaces */
|
|
30
30
|
const CHROME = 4;
|
|
31
31
|
|
|
32
32
|
// ── Width ─────────────────────────────────────────────────────────────────────
|
|
33
33
|
|
|
34
|
-
|
|
35
|
-
|
|
34
|
+
function resolveRenderSize(limit: RenderSize, available: number): number {
|
|
35
|
+
if (typeof limit === "number") return Math.floor(limit);
|
|
36
|
+
return Math.floor((available * Number.parseFloat(limit)) / 100);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Resolve modal width without exceeding available render width. */
|
|
40
|
+
export function modalWidth(termWidth: number, limit: RenderSize = "100%"): number {
|
|
36
41
|
const available = Number.isFinite(termWidth) ? Math.max(1, Math.floor(termWidth)) : MIN_WIDTH;
|
|
37
|
-
|
|
38
|
-
|
|
42
|
+
return Math.max(1, Math.min(available, resolveRenderSize(limit, available)));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Native overlay options matching shared modal size contract. */
|
|
46
|
+
export function modalOverlayOptions(): {
|
|
47
|
+
anchor: "center";
|
|
48
|
+
width: RenderSize;
|
|
49
|
+
maxHeight: RenderSize;
|
|
50
|
+
margin: 2;
|
|
51
|
+
} {
|
|
52
|
+
const pretty = config(prettySection);
|
|
53
|
+
return {
|
|
54
|
+
anchor: "center",
|
|
55
|
+
width: pretty.maxRenderWidth,
|
|
56
|
+
maxHeight: pretty.maxRenderHeight,
|
|
57
|
+
margin: 2,
|
|
58
|
+
};
|
|
39
59
|
}
|
|
40
60
|
|
|
41
61
|
// ── Frame ─────────────────────────────────────────────────────────────────────
|
|
@@ -185,25 +205,26 @@ export function frameLines(opts: FrameOptions): string[] {
|
|
|
185
205
|
|
|
186
206
|
// ── Height ────────────────────────────────────────────────────────────────────
|
|
187
207
|
|
|
188
|
-
export const DEFAULT_MODAL_HEIGHT_PERCENT = 80;
|
|
189
208
|
/** Fail-closed floor for ordinary overlays. Compare against modalHeight(), not raw rows. */
|
|
190
209
|
export const MIN_MODAL_HEIGHT = 6;
|
|
191
210
|
/** Fail-closed floor for permission overlays. Compare against modalHeight(), not raw rows. */
|
|
192
211
|
export const MIN_PERMISSION_MODAL_HEIGHT = 12;
|
|
193
212
|
|
|
194
|
-
/** Rows a modal may occupy
|
|
195
|
-
export function modalHeight(
|
|
213
|
+
/** Rows a modal may occupy before paging, never more than terminal has. */
|
|
214
|
+
export function modalHeight(
|
|
215
|
+
terminalRows: number,
|
|
216
|
+
limit: RenderSize = config(prettySection).maxRenderHeight,
|
|
217
|
+
): number {
|
|
196
218
|
const rows = Number.isFinite(terminalRows) ? Math.max(1, Math.floor(terminalRows)) : 24;
|
|
197
|
-
|
|
198
|
-
return Math.max(1, Math.min(rows, Math.floor(rows * ratio)));
|
|
219
|
+
return Math.max(1, Math.min(rows, resolveRenderSize(limit, rows)));
|
|
199
220
|
}
|
|
200
221
|
|
|
201
|
-
/** Current modal budget. Pass host TUI rows when available; stdout is
|
|
222
|
+
/** Current modal budget. Pass host TUI rows when available; stdout is fallback. */
|
|
202
223
|
export function terminalModalHeight(
|
|
203
224
|
terminalRows = process.stdout.rows ?? 24,
|
|
204
|
-
|
|
225
|
+
limit: RenderSize = config(prettySection).maxRenderHeight,
|
|
205
226
|
): number {
|
|
206
|
-
return modalHeight(terminalRows,
|
|
227
|
+
return modalHeight(terminalRows, limit);
|
|
207
228
|
}
|
|
208
229
|
|
|
209
230
|
/** Rows available for variable body content: total − borders − pinned rows. */
|
package/src/progress.ts
CHANGED
|
@@ -13,7 +13,13 @@
|
|
|
13
13
|
* p.close(); // releases input, removes overlay
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
frameModal,
|
|
18
|
+
MIN_MODAL_HEIGHT,
|
|
19
|
+
modalOverlayOptions,
|
|
20
|
+
modalWidth,
|
|
21
|
+
terminalModalHeight,
|
|
22
|
+
} from "./modal-frame.js";
|
|
17
23
|
import { SPINNER } from "./widget-format.js";
|
|
18
24
|
|
|
19
25
|
interface ProgressTheme {
|
|
@@ -36,7 +42,7 @@ export interface ProgressUI {
|
|
|
36
42
|
kb: unknown,
|
|
37
43
|
done: (v: T) => void,
|
|
38
44
|
) => ProgressComponent,
|
|
39
|
-
opts?: { overlay?: boolean; overlayOptions?:
|
|
45
|
+
opts?: { overlay?: boolean; overlayOptions?: ReturnType<typeof modalOverlayOptions> },
|
|
40
46
|
): Promise<T | undefined>;
|
|
41
47
|
}
|
|
42
48
|
|
|
@@ -97,7 +103,7 @@ export function openProgress(ui: ProgressUI, title: string, accent = "accent"):
|
|
|
97
103
|
handleInput: () => {},
|
|
98
104
|
};
|
|
99
105
|
},
|
|
100
|
-
{ overlay: true, overlayOptions:
|
|
106
|
+
{ overlay: true, overlayOptions: modalOverlayOptions() },
|
|
101
107
|
);
|
|
102
108
|
|
|
103
109
|
return {
|
package/src/utils.test.ts
CHANGED
|
@@ -35,12 +35,14 @@ class MockTextComponent {
|
|
|
35
35
|
// regression that recomputes every frame (the pre-memo CPU bug) fails loudly.
|
|
36
36
|
class CountingTextComponent {
|
|
37
37
|
static setCalls = 0;
|
|
38
|
+
static renderCalls = 0;
|
|
38
39
|
private text = "";
|
|
39
40
|
setText(value: string): void {
|
|
40
41
|
CountingTextComponent.setCalls++;
|
|
41
42
|
this.text = value;
|
|
42
43
|
}
|
|
43
44
|
render(): string[] {
|
|
45
|
+
CountingTextComponent.renderCalls++;
|
|
44
46
|
return this.text.split("\n");
|
|
45
47
|
}
|
|
46
48
|
invalidate(): void {}
|
|
@@ -99,6 +101,56 @@ describe("viewportText", () => {
|
|
|
99
101
|
text.render(20); // still cached
|
|
100
102
|
expect(CountingTextComponent.setCalls).toBe(1);
|
|
101
103
|
});
|
|
104
|
+
|
|
105
|
+
it("idle frames (same text+width) do not re-call the inner render", () => {
|
|
106
|
+
CountingTextComponent.renderCalls = 0;
|
|
107
|
+
const text = viewportText(CountingTextComponent);
|
|
108
|
+
text.setText("line one\nline two");
|
|
109
|
+
text.render(20);
|
|
110
|
+
text.render(20);
|
|
111
|
+
text.render(20);
|
|
112
|
+
// One inner render for the width; spinner ticks reuse the memoized output.
|
|
113
|
+
expect(CountingTextComponent.renderCalls).toBe(1);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("re-calls inner render after content changes", () => {
|
|
117
|
+
CountingTextComponent.renderCalls = 0;
|
|
118
|
+
const text = viewportText(CountingTextComponent);
|
|
119
|
+
text.setText("first");
|
|
120
|
+
text.render(20);
|
|
121
|
+
text.setText("second"); // content changed → render output stale
|
|
122
|
+
text.render(20);
|
|
123
|
+
expect(CountingTextComponent.renderCalls).toBe(2);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("invalidate() drops the render memo so a same-width frame recomputes (theme change)", () => {
|
|
127
|
+
CountingTextComponent.renderCalls = 0;
|
|
128
|
+
const text = viewportText(CountingTextComponent);
|
|
129
|
+
text.setText("body");
|
|
130
|
+
text.render(20);
|
|
131
|
+
text.render(20); // memo hit
|
|
132
|
+
expect(CountingTextComponent.renderCalls).toBe(1);
|
|
133
|
+
text.invalidate(); // theme/style changed → output stale even at same width
|
|
134
|
+
text.render(20); // must recompute, not serve pre-invalidate output
|
|
135
|
+
expect(CountingTextComponent.renderCalls).toBe(2);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("streaming append reuses already-fitted lines and fits only the new tail", () => {
|
|
139
|
+
const text = viewportText(MockTextComponent);
|
|
140
|
+
text.setText("aaaaaaaaaa\nbbbbbbbbbb");
|
|
141
|
+
expect(text.render(5).map(plain)).toEqual(["aaaaa", "bbbbb"]);
|
|
142
|
+
// Append (streaming). Old lines fit identically; new line appears.
|
|
143
|
+
text.setText("aaaaaaaaaa\nbbbbbbbbbb\ncccccccccc");
|
|
144
|
+
expect(text.render(5).map(plain)).toEqual(["aaaaa", "bbbbb", "ccccc"]);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("width change re-fits all lines (line cache is width-scoped, no stale serve)", () => {
|
|
148
|
+
const text = viewportText(MockTextComponent);
|
|
149
|
+
text.setText("abcdefghij");
|
|
150
|
+
expect(text.render(5).map(plain)).toEqual(["abcde"]);
|
|
151
|
+
// Must NOT serve the stale 5-wide fit for the same raw line at a new width.
|
|
152
|
+
expect(text.render(8).map(plain)).toEqual(["abcdefgh"]);
|
|
153
|
+
});
|
|
102
154
|
});
|
|
103
155
|
|
|
104
156
|
// Strip ANSI escapes so assertions test content, not color codes.
|
package/src/utils.ts
CHANGED
|
@@ -71,18 +71,33 @@ type ViewportComponent = {
|
|
|
71
71
|
|
|
72
72
|
class ViewportText implements TextComponentLike, ViewportComponent {
|
|
73
73
|
private text = "";
|
|
74
|
-
// Pi re-renders every frame (spinner/streaming).
|
|
75
|
-
//
|
|
76
|
-
//
|
|
74
|
+
// Pi re-renders every frame (spinner/streaming). Two-level cache:
|
|
75
|
+
// 1. per-line: `truncateToWidth` result keyed by raw line + width. Streaming
|
|
76
|
+
// only appends to the tail, so already-fitted lines hit the cache and
|
|
77
|
+
// only the new/changed lines pay the pi-tui width cost (was O(total
|
|
78
|
+
// lines) per chunk, now O(new lines)).
|
|
79
|
+
// 2. per-blob: skip re-joining + re-setText'ing the inner component when
|
|
80
|
+
// neither text nor width changed (idle frames, spinner ticks).
|
|
77
81
|
private fittedWidth = -1;
|
|
78
82
|
private fittedText = "";
|
|
83
|
+
private lineCache = new Map<string, string>();
|
|
84
|
+
private lineCacheWidth = -1;
|
|
85
|
+
// Render-output memo. The inner component's render is a pure function of
|
|
86
|
+
// (fittedText, width), so on an unchanged frame (spinner tick, idle) we skip
|
|
87
|
+
// calling into pi-tui AND skip the fallback split — otherwise both fired
|
|
88
|
+
// every single frame even when nothing changed. The returned array is shared
|
|
89
|
+
// by reference; pi-tui's render contract is read-only (the host joins/prints
|
|
90
|
+
// the lines, never mutates), so we don't defensively copy per frame.
|
|
91
|
+
private rendered: string[] = [];
|
|
92
|
+
private renderedWidth = -1;
|
|
79
93
|
|
|
80
94
|
constructor(private readonly component: TextComponentLike) {}
|
|
81
95
|
|
|
82
96
|
setText(value: string): void {
|
|
83
97
|
if (value === this.text) return;
|
|
84
98
|
this.text = value;
|
|
85
|
-
this.fittedWidth = -1; // invalidate memo
|
|
99
|
+
this.fittedWidth = -1; // invalidate blob memo (line cache stays valid)
|
|
100
|
+
this.renderedWidth = -1; // invalidate render-output memo
|
|
86
101
|
}
|
|
87
102
|
|
|
88
103
|
getText(): string {
|
|
@@ -91,17 +106,45 @@ class ViewportText implements TextComponentLike, ViewportComponent {
|
|
|
91
106
|
|
|
92
107
|
render(width: number): string[] {
|
|
93
108
|
if (width !== this.fittedWidth) {
|
|
94
|
-
|
|
95
|
-
.
|
|
96
|
-
.
|
|
109
|
+
if (width !== this.lineCacheWidth) {
|
|
110
|
+
this.lineCache.clear(); // width changed → every line must re-fit
|
|
111
|
+
this.lineCacheWidth = width;
|
|
112
|
+
}
|
|
113
|
+
const cache = this.lineCache;
|
|
114
|
+
const lines = this.text.split("\n");
|
|
115
|
+
// Bound the cache so a long streaming log can't grow it without limit.
|
|
116
|
+
// The working set is the visible line count; a generous multiple keeps
|
|
117
|
+
// steady-state hits while capping worst-case memory. Reset when exceeded
|
|
118
|
+
// rather than LRU-evicting — simpler, and a width-stable frame refills it.
|
|
119
|
+
if (cache.size > 4 * lines.length + 256) cache.clear();
|
|
120
|
+
this.fittedText = lines
|
|
121
|
+
.map((line) => {
|
|
122
|
+
let fitted = cache.get(line);
|
|
123
|
+
if (fitted === undefined) {
|
|
124
|
+
fitted = truncateToWidth(line, width, "");
|
|
125
|
+
cache.set(line, fitted);
|
|
126
|
+
}
|
|
127
|
+
return fitted;
|
|
128
|
+
})
|
|
97
129
|
.join("\n");
|
|
98
130
|
this.fittedWidth = width;
|
|
131
|
+
this.renderedWidth = -1; // fitted text rebuilt → render output is stale
|
|
99
132
|
this.component.setText(this.fittedText);
|
|
100
133
|
}
|
|
101
|
-
|
|
134
|
+
if (width !== this.renderedWidth) {
|
|
135
|
+
this.rendered = this.component.render?.(width) ?? this.fittedText.split("\n");
|
|
136
|
+
this.renderedWidth = width;
|
|
137
|
+
}
|
|
138
|
+
return this.rendered;
|
|
102
139
|
}
|
|
103
140
|
|
|
104
141
|
invalidate(): void {
|
|
142
|
+
// invalidate = "output stale for reasons other than (text,width)" — theme /
|
|
143
|
+
// style change. Fitting is pure on (text,width) so fittedText/lineCache stay
|
|
144
|
+
// valid, but the RENDER output (colors) may differ, so drop the render memo;
|
|
145
|
+
// otherwise a same-width frame after invalidate would serve pre-invalidate
|
|
146
|
+
// output forever.
|
|
147
|
+
this.renderedWidth = -1;
|
|
105
148
|
this.component.invalidate?.();
|
|
106
149
|
}
|
|
107
150
|
}
|