@pi-archimedes/ui 2.10.4 → 2.11.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.
@@ -1,27 +1,28 @@
1
- import { VERSION, type Theme } from "@earendil-works/pi-coding-agent";
2
- import { getShinedLogo, TRUECOLOR, LOGO_PAD, LOGO_SETTLE_FRAME } from "./logo.js";
1
+ import { VERSION, type ScopedModel, type Theme } from "@earendil-works/pi-coding-agent";
2
+ import { getShinedLogo, LOGO_SETTLE_FRAME } from "./logo.js";
3
3
  import { loadUIConfig } from "../config.js";
4
- import { detectSection, parseSectionText, parseModelScope, formatColumns, buildItemWrapper, type ParsedSection, SECTION_KEYS } from "./sections.js";
5
- import { fetchLatestVersion, compareVersions } from "./version.js";
6
- import { stripAnsi } from "@pi-archimedes/core/text";
7
- import { Text, Spacer, Container, type TUI, truncateToWidth, visibleWidth, type Component } from "@earendil-works/pi-tui";
8
-
9
- // Symbol keys (survive hot-reload)
10
- const LISTING_REF = Symbol.for("splashscreen:listingRef");
11
- const ANIM_INTERVAL = Symbol.for("splashscreen:animInterval");
12
- const DEBOUNCE_TIMER = Symbol.for("splashscreen:debounceTimer");
13
- const PATCHED_CLEAR = Symbol.for("splashscreen:clearPatched");
14
- const PATCHED_LISTING = Symbol.for("splashscreen:listingPatched");
15
- const ORIG_ADD_CHILD = Symbol.for("splashscreen:origAddChild");
16
-
17
- // Animation constants
18
- const MAX_RENDER_WIDTH = 9999;
19
- const MIN_HEADER_LINES = 11;
20
- const REVEAL_DEBOUNCE_MS = 150;
21
- const RAMP_FRAMES = 22;
22
- const STAGGER_FRAMES = 0;
23
- const BASE_FADE_DELAY = 3;
24
- const MAX_STAGGER = BASE_FADE_DELAY + 5 * STAGGER_FRAMES;
4
+ import {
5
+ formatColumns,
6
+ RAMP_FRAMES,
7
+ MAX_STAGGER,
8
+ type ParsedSection,
9
+ } from "./sections.js";
10
+ import { visibleWidth, type TUI } from "@earendil-works/pi-tui";
11
+
12
+ // ── What this module owns ───────────────────────────────────────────────────
13
+ //
14
+ // The splash: the logo animation, the version row, and the text reveal that
15
+ // carries them in. That is all.
16
+ //
17
+ // It used to own pi's startup listing too — finding pi's resource container by
18
+ // child-order heuristics, intercepting `addChild`, swallowing pi's section
19
+ // components and re-rendering them here, plus a `console.log` patch that ate
20
+ // pi's `Model scope:` line. All of that is gone (pi 1.x alignment, Task 8): pi
21
+ // renders its own listing, `quietStartup` decides whether it shows, and
22
+ // `ctx.scopedModels` hands us the model scope directly, so there is nothing
23
+ // left to scrape and no reason to touch the console or pi's component tree.
24
+ // `ui/src/index.ts` decides *whether* to install this splash (quietStartup) and
25
+ // what sections to reveal (the model scope); this module animates them.
25
26
 
26
27
  export interface ListingRef {
27
28
  sections: ParsedSection[];
@@ -29,48 +30,65 @@ export interface ListingRef {
29
30
  revealed: boolean;
30
31
  revealedAt: number;
31
32
  scaffoldAt: number;
32
- latestVersion?: string;
33
33
  settled: boolean;
34
34
  cachedLines?: string[];
35
35
  cachedWidth?: number;
36
- cachedHeight?: number;
37
- maxHeaderHeight?: number;
38
36
  }
39
37
 
40
- // ── renderHeader() — composes logo + sections + padding ────────────────
38
+ // ── renderHeader() — composes logo + listing ───────────────────────────
41
39
 
42
- export function renderHeader(theme: Theme, ref: ListingRef, width: number, height: number): string[] {
43
- const dim = (t: string) => theme.fg("dim", t);
44
- const accent = (t: string) => theme.fg("accent", t);
40
+ /**
41
+ * The splash's own text rows, drawn in pi's shape (`formatColumns`), under the
42
+ * animated logo. Everything the header is, and nothing else.
43
+ *
44
+ * The block is exactly its content on both screens, and that is deliberate
45
+ * twice over:
46
+ *
47
+ * - a frame-bounded (fullscreen) screen allocates the height itself (ADR 0027),
48
+ * so a header padded to the terminal would be the whole viewport and the
49
+ * editor would get nothing — the reason #97/#102 sized it to content;
50
+ * - on regular screen the padding was the defect the maintainer pointed at.
51
+ * pi's header container puts a `Spacer(1)` above and below whatever header is
52
+ * installed (`interactive-mode.js:764-766`) and its resource listing opens
53
+ * with one more, so a header padded to `rows - 3` pushed pi's own listing a
54
+ * screen down and left the two halves of the intro reading as two documents.
55
+ *
56
+ * Nothing here is sized by the screen any more, which is also why the header
57
+ * factory no longer asks the renderer which screen it is on: there is no
58
+ * per-screen measurement left to take and nothing per-screen left to go stale.
59
+ */
60
+ export function renderHeader(theme: Theme, ref: ListingRef, width: number): string[] {
45
61
  const cfg = loadUIConfig();
46
62
  const logoLines = getShinedLogo(ref.frame, cfg.animationStyle, cfg.colorfulLogo);
47
63
 
48
64
  // Use cached text lines if settled (no more animations)
49
65
  let listingLines: string[];
50
66
 
51
- if (ref.settled && ref.cachedLines && ref.cachedWidth === width && ref.cachedHeight === height) {
67
+ if (ref.settled && ref.cachedLines && ref.cachedWidth === width) {
52
68
  listingLines = ref.cachedLines;
53
69
  } else {
54
- const sectionsToRender: { name: "Version" | ParsedSection["name"]; items: string[] }[] = [];
70
+ const sectionsToRender: ParsedSection[] = [];
55
71
 
56
72
  if (!ref.revealed) {
57
73
  // Logo only — sections appear together on reveal
58
74
  } else {
59
- const latest = ref.latestVersion ?? VERSION;
60
- const hasUpdate = compareVersions(latest, VERSION) > 0;
61
- const latestStr = hasUpdate ? `Latest: ${accent("v" + latest)}` : `Latest: v${latest}`;
62
- sectionsToRender.push({ name: "Version", items: [`Local: v${VERSION}`, latestStr] });
63
-
64
- // Display sections in SECTION_KEYS order, skip empty
65
- const byName = new Map(ref.sections.map(s => [s.name, s]));
66
- for (const key of SECTION_KEYS) {
67
- const sec = byName.get(key);
68
- if (sec && sec.items.length > 0) sectionsToRender.push(sec);
75
+ // The version row is ours to state; pi owns its own update check
76
+ // (`--update`, `/changelog`, `PI_SKIP_VERSION_CHECK`), so there is no
77
+ // second `Latest:` here — that used to arrive from a registry fetch on
78
+ // every start.
79
+ sectionsToRender.push({ name: "Version", items: [`v${VERSION}`] });
80
+
81
+ // Then whatever the caller revealed in. pi decides which of *its*
82
+ // sections exist; we only ever carry the model scope. Empty sections are
83
+ // "nothing loaded", skip them.
84
+ for (const sec of ref.sections) {
85
+ if (sec.items.length > 0) sectionsToRender.push(sec);
69
86
  }
70
87
  }
71
88
 
72
- const listingMaxW = Math.floor(width * 0.7);
73
- listingLines = formatColumns(sectionsToRender, theme, listingMaxW, ref);
89
+ // pi writes its listing rows across the frame, so the rows break at the
90
+ // frame edge rather than at some narrower column of ours.
91
+ listingLines = formatColumns(sectionsToRender, theme, width, ref);
74
92
 
75
93
  // Cache once text animations are done
76
94
  const textAge = ref.revealed ? ref.frame - ref.revealedAt : 0;
@@ -81,141 +99,121 @@ export function renderHeader(theme: Theme, ref: ListingRef, width: number, heigh
81
99
  ref.settled = true;
82
100
  ref.cachedLines = listingLines;
83
101
  ref.cachedWidth = width;
84
- ref.cachedHeight = height;
85
102
  }
86
103
  }
87
104
 
88
105
  // Build content: logo + separator + listing
89
106
  const contentLines: string[] = [];
90
107
 
91
- // Logo centered above listing (same center as listing text)
92
- const listingMaxW = Math.floor(width * 0.7);
93
- const listingLeftPad = Math.floor((width - listingMaxW) / 2);
108
+ // Logo centred across the frame — the top half of the intro, unchanged.
94
109
  for (const logoRow of logoLines) {
95
- const pad = Math.floor((listingMaxW - visibleWidth(logoRow)) / 2);
96
- contentLines.push(" ".repeat(LOGO_PAD) + " ".repeat(listingLeftPad + pad) + logoRow);
110
+ const pad = Math.max(0, Math.floor((width - visibleWidth(logoRow)) / 2));
111
+ contentLines.push(" ".repeat(pad) + logoRow);
97
112
  }
98
113
 
99
114
  // Separator
100
115
  contentLines.push("");
101
116
 
102
- // Listing
103
- for (const listRow of listingLines) {
104
- contentLines.push(" ".repeat(LOGO_PAD) + " ".repeat(listingLeftPad) + listRow);
105
- }
106
-
107
- // Pad top and bottom to fill height (+5 top bias for visual centering)
108
- const VERTICAL_CENTER_BIAS = 5; // Shift content slightly upward for visual balance
109
- const contentHeight = contentLines.length;
110
- const remaining = Math.max(0, height - contentHeight);
111
- const topPad = Math.floor(remaining / 2) + VERTICAL_CENTER_BIAS;
112
- const bottomPad = remaining - topPad;
113
-
114
- const result: string[] = [];
115
- for (let i = 0; i < topPad; i++) result.push("");
116
- for (const line of contentLines) result.push(line);
117
- for (let i = 0; i < bottomPad; i++) result.push("");
117
+ // Listing, at column 1: pi's rows start there, and anything indenting ours
118
+ // past it is what made the two halves look unrelated.
119
+ for (const listRow of listingLines) contentLines.push(listRow);
118
120
 
119
- return result;
121
+ return contentLines;
120
122
  }
121
123
 
122
- // ── Section text extraction ───────────────────────────────────────────────
124
+ // ── The section the splash is allowed to know about ───────────────────────
123
125
 
124
126
  /**
125
- * Read the full text of a section component.
127
+ * The session's model scope, in the shape the splash renders — the same set
128
+ * `ctx.scopedModels` reports and `/scoped-models` lists, formatted the way pi's
129
+ * own `Model scope:` line does (`id`, plus `:thinkingLevel` when the pattern
130
+ * carried one).
126
131
  *
127
- * pi ≥0.99 builds ExpandableText with constructor closures (no instance
128
- * getExpandedText() anymore) and starts collapsed — a plain render() would
129
- * return the lossy comma-joined body. setExpanded(true) (pi's own public
130
- * expansion API) makes render() return the full per-item body.
132
+ * The alternative it replaces was a `console.log` patch watching for pi to
133
+ * print that line, which both hid the line (the patch consumed it) and had no
134
+ * answer for a session where pi prints nothing.
135
+ *
136
+ * Returns `undefined` for no scope, so the section is simply absent: an empty
137
+ * `[Models]` header is pi's "nothing scoped" case, and there pi offers every
138
+ * model. A host without the property answers the same way.
131
139
  */
132
- function expandedText(component: Text): string {
133
- const setExpanded = (component as any).setExpanded;
134
- if (typeof setExpanded === "function") setExpanded.call(component, true);
135
- return stripAnsi(component.render(MAX_RENDER_WIDTH).join("\n"));
140
+ export function modelScopeSection(
141
+ scopedModels: readonly ScopedModel[] | undefined,
142
+ ): ParsedSection | undefined {
143
+ if (!Array.isArray(scopedModels)) return undefined;
144
+
145
+ const items: string[] = [];
146
+ for (const scoped of scopedModels) {
147
+ const id = scoped?.model?.id;
148
+ if (typeof id !== "string" || id === "") continue;
149
+ items.push(scoped.thinkingLevel ? `${id}:${scoped.thinkingLevel}` : id);
150
+ }
151
+ return items.length > 0 ? { name: "Models", items } : undefined;
136
152
  }
137
153
 
138
- // ── Chat container discovery ────────────────────────────────────────────
154
+ // ── The animation ───────────────────────────────────────────────────────────
139
155
 
140
- // Fragile: relies on TUI child ordering (header, chat, footer) which is an
141
- // internal layout detail of pi's InteractiveMode. If upstream changes the
142
- // child structure, this will need updating.
143
- //
144
- // Strategy (most specific → least specific):
145
- // 1. Container with "Scrollable" in constructor name
146
- // 2. Middle child — TUI layout is [headerContainer, chatContainer, footer]
147
- // This is position-stable even when chatContainer is empty (e.g. after clear)
148
- // 3. Container with most children (chat usually has the most)
149
- // 4. First Container found
150
- function findChatContainer(tui: TUI): Container | undefined {
151
- // Strategy 1: Look for Scrollable container
152
- for (const child of tui.children) {
153
- if (child instanceof Container && child.constructor.name.includes("Scrollable")) {
154
- return child;
155
- }
156
+ /**
157
+ * Sections wait this long after the header is built before they appear, so the
158
+ * logo gets its reveal frames first. The old value, kept: it is the interval
159
+ * pi's own section components used to arrive inside, and the splash looks the
160
+ * same either way.
161
+ */
162
+ const REVEAL_DEBOUNCE_MS = 150;
163
+ const FRAME_INTERVAL_MS = 16;
164
+
165
+ // One splash is on screen at a time. pi disposes the previous header before it
166
+ // builds the next one (`setExtensionHeader()` — a new session, `/reload`, or any
167
+ // `setHeader()` call), so a module pair of timers cannot leak a second
168
+ // animation — and holds no renderer: each timer closes over the `tui` it was
169
+ // started with, and the one running is the one the current header factory was
170
+ // handed (ADR 0027).
171
+ let frameTimer: ReturnType<typeof setInterval> | undefined;
172
+ let revealTimer: ReturnType<typeof setTimeout> | undefined;
173
+
174
+ /** Stop both halves of the animation (header disposed, session ended). */
175
+ export function stopStartupListing(): void {
176
+ if (frameTimer) {
177
+ clearInterval(frameTimer);
178
+ frameTimer = undefined;
156
179
  }
157
-
158
- // Strategy 2: Middle child — TUI layout is [headerContainer, chatContainer, footer]
159
- if (tui.children.length >= 3) {
160
- const middle = tui.children[1];
161
- if (middle instanceof Container) return middle;
180
+ if (revealTimer) {
181
+ clearTimeout(revealTimer);
182
+ revealTimer = undefined;
162
183
  }
163
-
164
- // Strategy 3: Find the Container with the most children (chat area)
165
- const containers = tui.children.filter((c): c is Container => c instanceof Container);
166
- if (containers.length > 1) {
167
- const largest = containers.reduce((best, c) =>
168
- c.children.length > best.children.length ? c : best,
169
- );
170
- if (largest.children.length > 0) return largest;
171
- }
172
-
173
- // Strategy 4: First Container
174
- if (containers.length > 0) return containers[0];
175
-
176
- return undefined;
177
184
  }
178
185
 
179
- // Pi 0.84+ moved resource sections from chatContainer to loadedResourcesContainer
180
- // (child of documentContainer). Find it by locating documentContainer (the first TUI
181
- // child that contains Container sub-children), then taking its second Container child.
182
- //
183
- // Pi 0.84+ TUI layout (regular mode, 7 top-level children):
184
- // documentContainer → [headerContainer(0), loadedResourcesContainer(1), chatContainer(2)]
185
- function findLoadedResourcesContainer(tui: TUI): Container | undefined {
186
- // Only run this heuristic on Pi >= 0.84.0 where loadedResourcesContainer exists.
187
- // On older Pi versions, return undefined so the caller falls back to findChatContainer().
188
- if (compareVersions(VERSION, "0.84.0") < 0) return undefined;
189
-
190
- // Find documentContainer: first tui child that has Container sub-children
191
- const docContainer = tui.children.find(
192
- (c) => c instanceof Container && c.children.some((child) => child instanceof Container)
193
- ) as Container | undefined;
194
- if (!docContainer) return undefined;
195
-
196
- const containerChildren = docContainer.children.filter(
197
- (c): c is Container => c instanceof Container
198
- );
199
- // Pi 0.84+: [headerContainer(0), loadedResourcesContainer(1), chatContainer(2)]
200
- // The index [1] assumption is load-bearing — tied to Pi 0.84+ initialization order.
201
- if (containerChildren.length < 2) return undefined;
202
- return containerChildren[1]; // loadedResourcesContainer
186
+ function startFrameTimer(tui: TUI, ref: ListingRef): void {
187
+ if (frameTimer) clearInterval(frameTimer);
188
+ frameTimer = setInterval(() => {
189
+ ref.frame++;
190
+ // Logo done and either the listing settled, or nothing is revealed and
191
+ // none is on its way — the state a quiet start leaves behind. Idle frames
192
+ // cost a full viewport redraw on fullscreen screen, so stop here rather
193
+ // than repaint forever (#92).
194
+ const nothingToAnimate = ref.settled || (!ref.revealed && !revealTimer);
195
+ if (nothingToAnimate && ref.frame >= LOGO_SETTLE_FRAME) {
196
+ clearInterval(frameTimer);
197
+ frameTimer = undefined;
198
+ return;
199
+ }
200
+ tui.requestRender();
201
+ }, FRAME_INTERVAL_MS);
203
202
  }
204
203
 
205
- export function patchStartupListing(
206
- tui: TUI,
207
- _theme: Theme,
208
- ref: ListingRef,
209
- ): void {
210
- const resourcesContainer = findLoadedResourcesContainer(tui) ?? findChatContainer(tui);
211
- if (!resourcesContainer) {
212
- console.warn("[archimedes] Could not find resources container — startup listing disabled. This may indicate a pi TUI structure change.");
213
- return;
214
- }
215
- const rc = resourcesContainer as any;
216
-
217
- // Always update ref + restart animation (critical for /reload)
218
- rc[LISTING_REF] = ref;
204
+ /**
205
+ * Install the splash for the header we were just handed: reset the animation
206
+ * ref and start the frame timer. Re-installing (a new session, `/reload`, or
207
+ * any other `setHeader()` replacement, each of which pi disposes the old header
208
+ * before it calls the factory) restarts the animation on the renderer now in
209
+ * hand and stops the previous one — a stale renderer must not keep being asked
210
+ * for frames.
211
+ *
212
+ * Sections are not passed here: call {@link revealStartupListing} with them
213
+ * (or not at all, which leaves the splash as logo + version).
214
+ */
215
+ export function patchStartupListing(tui: TUI, ref: ListingRef): void {
216
+ stopStartupListing();
219
217
  ref.frame = 0;
220
218
  ref.revealed = false;
221
219
  ref.revealedAt = 0;
@@ -223,127 +221,36 @@ export function patchStartupListing(
223
221
  ref.settled = false;
224
222
  delete ref.cachedLines;
225
223
  delete ref.cachedWidth;
226
- delete ref.maxHeaderHeight;
227
-
228
- if (rc[ANIM_INTERVAL]) clearInterval(rc[ANIM_INTERVAL]);
229
- if (rc[DEBOUNCE_TIMER]) clearTimeout(rc[DEBOUNCE_TIMER]);
230
-
231
- const interval = setInterval(() => {
232
- try {
233
- const current: ListingRef = rc[LISTING_REF];
234
- if (!current) {
235
- clearInterval(interval);
236
- return;
237
- }
238
- current.frame++;
239
- if (current.settled && current.frame >= LOGO_SETTLE_FRAME) {
240
- clearInterval(interval);
241
- rc[ANIM_INTERVAL] = null;
242
- return;
243
- }
244
- tui.requestRender();
245
- } catch {
246
- clearInterval(interval);
247
- }
248
- }, 16);
249
-
250
- rc[ANIM_INTERVAL] = interval;
251
-
252
- // Fetch latest version from npm
253
- fetchLatestVersion().then(v => {
254
- if (v) {
255
- const current: ListingRef = rc[LISTING_REF];
256
- current.latestVersion = v;
257
- // Invalidate cache so version updates on next render
258
- delete current.cachedLines;
259
- current.settled = false;
260
- }
261
- });
262
-
263
- // Patch clear() to intercept container rebuild
264
- if (!rc[PATCHED_CLEAR]) {
265
- rc[PATCHED_CLEAR] = true;
266
- const origClear = resourcesContainer.clear.bind(resourcesContainer);
267
- resourcesContainer.clear = () => {
268
- return origClear();
269
- };
270
- }
271
-
272
- // Only patch addChild once — the closure reads rc[LISTING_REF] dynamically
273
- if (rc[PATCHED_LISTING]) {
274
- resourcesContainer.clear();
275
- return;
276
- }
277
- rc[PATCHED_LISTING] = true;
278
-
279
- const origAddChild = resourcesContainer.addChild.bind(resourcesContainer);
280
- rc[ORIG_ADD_CHILD] = origAddChild; // Store for cleanup on shutdown
281
- resourcesContainer.clear();
282
-
283
- resourcesContainer.addChild = (component: Component) => {
284
- try {
285
- const currentRef: ListingRef = rc[LISTING_REF];
286
224
 
287
- if (component instanceof Text) {
288
- // pi ≥0.67.6 wraps startup sections in ExpandableText; collapsed body
289
- // is a lossy comma-joined base-name list. Parse the expanded text so
290
- // Extensions keep real names instead of "index.ts/index.js/index".
291
- // pi ≥0.99 moved the text builders into the constructor (no instance
292
- // getExpandedText()) — expandedText() forces expansion via setExpanded().
293
- const getExpanded = (component as any).getExpandedText;
294
- const plain = typeof getExpanded === "function"
295
- ? stripAnsi(getExpanded.call(component))
296
- : expandedText(component);
297
-
298
- const section = parseSectionText(plain) ?? parseModelScope(plain);
299
- if (section) {
300
- const existing = currentRef.sections.find(s => s.name === section.name);
301
- if (existing) {
302
- existing.items = [...new Set([...existing.items, ...section.items])];
303
- } else {
304
- currentRef.sections.push(section);
305
- }
306
-
307
- // Invalidate cache so late-arriving sections show up
308
- currentRef.settled = false;
309
- delete currentRef.cachedLines;
310
-
311
- if (currentRef.revealed) {
312
- // Already revealed — show new section immediately
313
- tui.requestRender();
314
- } else {
315
- // Batch initial sections — reset debounce on each arrival
316
- if (rc[DEBOUNCE_TIMER]) clearTimeout(rc[DEBOUNCE_TIMER]);
317
- rc[DEBOUNCE_TIMER] = setTimeout(() => {
318
- const ref: ListingRef = rc[LISTING_REF];
319
- ref.revealed = true;
320
- ref.revealedAt = ref.frame;
321
- ref.scaffoldAt = ref.frame;
322
- tui.requestRender();
323
- rc[DEBOUNCE_TIMER] = null;
324
- }, REVEAL_DEBOUNCE_MS);
325
- }
326
-
327
- return;
328
- }
329
-
330
- if (
331
- plain.includes("Listing all available commands") ||
332
- plain.includes("(Source: extension)") ||
333
- plain.trim().startsWith("/skill:")
334
- ) {
335
- return;
336
- }
337
- }
225
+ startFrameTimer(tui, ref);
226
+ }
338
227
 
339
- if (component instanceof Spacer && !currentRef.revealed) return;
340
- try {
341
- origAddChild(component);
342
- } catch (err) {
343
- console.error("[archimedes] Error in resourcesContainer.addChild:", err);
344
- }
345
- } catch (err) {
346
- console.error("[archimedes] Error in startup listing addChild handler:", err);
347
- }
348
- };
228
+ /**
229
+ * Reveal the splash's text half — the version row always comes with the
230
+ * header, `sections` is whatever the caller decided to show next (see
231
+ * {@link modelScopeSection}).
232
+ *
233
+ * Debounced so the logo keeps its frames, and it restarts the frame timer when
234
+ * the logo already settled before anything arrived: without the restart the
235
+ * text would be revealed into a stopped animation and fade in over frames
236
+ * nobody draws.
237
+ */
238
+ export function revealStartupListing(
239
+ tui: TUI,
240
+ ref: ListingRef,
241
+ sections: ParsedSection[],
242
+ ): void {
243
+ if (revealTimer) clearTimeout(revealTimer);
244
+ revealTimer = setTimeout(() => {
245
+ revealTimer = undefined;
246
+ ref.sections = sections;
247
+ ref.revealed = true;
248
+ ref.revealedAt = ref.frame;
249
+ ref.scaffoldAt = ref.frame;
250
+ ref.settled = false;
251
+ delete ref.cachedLines;
252
+ delete ref.cachedWidth;
253
+ tui.requestRender();
254
+ if (!frameTimer) startFrameTimer(tui, ref);
255
+ }, REVEAL_DEBOUNCE_MS);
349
256
  }