pi-aside 0.0.0-stage → 0.2.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/src/observe.ts ADDED
@@ -0,0 +1,278 @@
1
+ // The observation (docs/design.md §4.1): the request the main session actually sent (P), the transcript it was
2
+ // converted from (T), the frozen recipe, readiness and usage. One logical agent request L at a time.
3
+
4
+ import type { Payload } from "./payload.ts";
5
+
6
+ /** The frozen conversion inputs of one main request. */
7
+ export interface Recipe {
8
+ provider: string;
9
+ modelId: string;
10
+ api: string;
11
+ /** Effective reasoning level, undefined when thinking is off. */
12
+ reasoning: string | undefined;
13
+ sessionId: string;
14
+ transport: string | undefined;
15
+ thinkingBudgets: unknown;
16
+ blockImages: boolean;
17
+ /** The selection was a virtual model: the physical model comes from the provider stream. */
18
+ virtual: boolean;
19
+ }
20
+
21
+ export type ObservationState = "open" | "promoted" | "ambiguous" | "failed";
22
+
23
+ export interface ObservedUsage {
24
+ input: number;
25
+ output: number;
26
+ cacheRead: number;
27
+ cacheWrite: number;
28
+ }
29
+
30
+ export class Observation {
31
+ readonly seq: number;
32
+ /** T: the transcript as it reached conversion (after the handlers before pi-aside). */
33
+ readonly transcript: unknown[];
34
+ /** The session leaf when the request was prepared: G is what the branch gained after it. */
35
+ readonly leafId: string | null;
36
+ readonly recipe: Recipe;
37
+ readonly openedAt = Date.now();
38
+ state: ObservationState = "open";
39
+ reason: string | undefined;
40
+ /** H, R and S are ignored after L closes (on S, an error, the end of the run or a session event). */
41
+ closed = false;
42
+ /** The main assistant message ended, or the run did: readiness can no longer change. */
43
+ ended = false;
44
+ headerRef: Record<string, unknown> | undefined;
45
+ headerCount = 0;
46
+ attempts: unknown[] = [];
47
+ /** P is the payload the handler chain returned (the tap), not the R event's object. */
48
+ tapped = false;
49
+ /** P, cloned on promotion. */
50
+ payload: Payload | undefined;
51
+ /** Header names observed on the promoted call (values are never kept). */
52
+ headerNames: string[] = [];
53
+ ready = false;
54
+ usage: ObservedUsage | undefined;
55
+ private listeners = new Set<() => void>();
56
+
57
+ constructor(seq: number, transcript: unknown[], leafId: string | null, recipe: Recipe) {
58
+ this.seq = seq;
59
+ this.transcript = transcript;
60
+ this.leafId = leafId;
61
+ this.recipe = recipe;
62
+ }
63
+
64
+ /**
65
+ * Cacheable prefix the main response reported: cache read plus cache write. OpenAI APIs report no cache writes
66
+ * (the provider caches the prompt itself), so there it is the whole prompt.
67
+ */
68
+ get cacheablePrefix(): number | undefined {
69
+ if (!this.usage) return undefined;
70
+ if (this.recipe.api.startsWith("openai-")) return this.usage.input + this.usage.cacheRead;
71
+ return this.usage.cacheRead + this.usage.cacheWrite;
72
+ }
73
+
74
+ /** Whether waiting can still change this observation's usability. */
75
+ get pending(): boolean {
76
+ if (this.state === "open") return !this.closed;
77
+ if (this.state === "promoted") return !this.ready && !this.ended;
78
+ return false;
79
+ }
80
+
81
+ get usable(): boolean {
82
+ return this.state === "promoted" && this.ready && this.payload !== undefined;
83
+ }
84
+
85
+ onChange(listener: () => void): () => void {
86
+ this.listeners.add(listener);
87
+ return () => this.listeners.delete(listener);
88
+ }
89
+
90
+ changed(): void {
91
+ for (const listener of [...this.listeners]) listener();
92
+ }
93
+
94
+ fail(state: "ambiguous" | "failed", reason: string): void {
95
+ if (this.state === "open") {
96
+ this.state = state;
97
+ this.reason = reason;
98
+ }
99
+ this.closed = true;
100
+ this.ended = true;
101
+ this.changed();
102
+ }
103
+ }
104
+
105
+ /** Tracks the latest logical request and the latest usable observation. */
106
+ export class Observer {
107
+ private seq = 0;
108
+ latest: Observation | undefined;
109
+ /** The latest observation that became usable. */
110
+ lastUsable: Observation | undefined;
111
+ private generation = 0;
112
+ private listeners = new Set<() => void>();
113
+
114
+ /** Bumped on every reset; asides started before a reset are discarded. */
115
+ get epoch(): number {
116
+ return this.generation;
117
+ }
118
+
119
+ onChange(listener: () => void): () => void {
120
+ this.listeners.add(listener);
121
+ return () => this.listeners.delete(listener);
122
+ }
123
+
124
+ private notify(): void {
125
+ for (const listener of [...this.listeners]) listener();
126
+ }
127
+
128
+ reset(): void {
129
+ this.latest?.fail("failed", "session changed");
130
+ this.latest = undefined;
131
+ this.lastUsable = undefined;
132
+ this.generation++;
133
+ this.notify();
134
+ }
135
+
136
+ /** `context_with_system`: opens L. */
137
+ open(transcript: unknown[], leafId: string | null, recipe: Recipe): Observation {
138
+ const previous = this.latest;
139
+ if (previous && previous.pending) previous.fail("failed", "superseded by a newer request");
140
+ const observation = new Observation(++this.seq, transcript, leafId, recipe);
141
+ observation.onChange(() => this.notify());
142
+ this.latest = observation;
143
+ this.notify();
144
+ return observation;
145
+ }
146
+
147
+ /** `before_provider_headers`. */
148
+ headers(headers: Record<string, unknown>): void {
149
+ const l = this.latest;
150
+ if (!l || l.closed || l.state !== "open") return;
151
+ l.headerCount++;
152
+ if (l.headerCount > 1) {
153
+ l.fail("ambiguous", "a second stream call started before the response");
154
+ return;
155
+ }
156
+ l.headerRef = headers;
157
+ }
158
+
159
+ /** `before_provider_request`: one wire attempt, kept by reference. */
160
+ request(payload: unknown): void {
161
+ const l = this.latest;
162
+ if (!l || l.closed || l.state !== "open") return;
163
+ if (l.headerCount === 0) {
164
+ l.fail("ambiguous", "a request without its stream call");
165
+ return;
166
+ }
167
+ l.attempts.push(payload);
168
+ }
169
+
170
+ /** The final-payload tap: the payload the handler chain returned for the latest attempt. */
171
+ finalPayload(payload: unknown): void {
172
+ const l = this.latest;
173
+ if (!l || l.closed || l.state !== "open" || l.attempts.length === 0) return;
174
+ l.attempts[l.attempts.length - 1] = payload;
175
+ l.tapped = true;
176
+ }
177
+
178
+ /** `after_provider_response`: promotes the latest attempt to P. */
179
+ response(status: number): void {
180
+ const l = this.latest;
181
+ if (!l || l.closed || l.state !== "open") return;
182
+ if (l.headerCount === 0 || l.attempts.length === 0) {
183
+ l.fail("ambiguous", "a response without its request");
184
+ return;
185
+ }
186
+ if (status < 200 || status >= 300) return;
187
+ const attempt = l.attempts.at(-1);
188
+ try {
189
+ l.payload = structuredClone(attempt) as Payload;
190
+ } catch {
191
+ l.fail("failed", "the request could not be copied");
192
+ return;
193
+ }
194
+ if (typeof l.payload !== "object" || l.payload === null || Array.isArray(l.payload)) {
195
+ l.payload = undefined;
196
+ l.fail("failed", "the request is not a JSON object");
197
+ return;
198
+ }
199
+ l.headerNames = l.headerRef ? Object.keys(l.headerRef).filter((k) => l.headerRef![k] != null) : [];
200
+ l.headerRef = undefined;
201
+ l.attempts = [];
202
+ l.state = "promoted";
203
+ l.closed = true;
204
+ l.changed();
205
+ }
206
+
207
+ /** `cache_warming_decision`: a warmer replay inside an open L makes it ambiguous. */
208
+ warming(): void {
209
+ const l = this.latest;
210
+ if (l && !l.closed && l.state === "open") l.fail("ambiguous", "a cache warmer replay overlapped the request");
211
+ }
212
+
213
+ /** The main assistant message started streaming. Anthropic reports the prompt's cache counts at the start. */
214
+ assistantUpdate(usage?: ObservedUsage): void {
215
+ const l = this.latest;
216
+ if (!l || l.state !== "promoted" || l.ready) return;
217
+ if (usage && !l.usage && usage.cacheRead + usage.cacheWrite > 0) l.usage = usage;
218
+ l.ready = true;
219
+ this.lastUsable = l;
220
+ l.changed();
221
+ }
222
+
223
+ /** The main assistant message ended (completed, failed or aborted). */
224
+ assistantEnd(usage: ObservedUsage | undefined, stopReason: string | undefined): void {
225
+ const l = this.latest;
226
+ if (!l) return;
227
+ if (l.state === "promoted") {
228
+ if (usage) l.usage = usage;
229
+ if (!l.ready && stopReason !== "error" && stopReason !== "aborted") {
230
+ l.ready = true;
231
+ this.lastUsable = l;
232
+ }
233
+ l.closed = true;
234
+ l.ended = true;
235
+ l.changed();
236
+ return;
237
+ }
238
+ if (l.state === "open") l.fail("failed", stopReason === "aborted" ? "the request was aborted" : "the request failed");
239
+ }
240
+
241
+ /** `agent_end`, or any other end of the run. */
242
+ end(): void {
243
+ const l = this.latest;
244
+ if (!l) return;
245
+ if (l.state === "open") l.fail("failed", "the run ended without a response");
246
+ else if (!l.ended) {
247
+ l.closed = true;
248
+ l.ended = true;
249
+ l.changed();
250
+ }
251
+ }
252
+
253
+ /** Wait until the latest observation is no longer pending, or the signal aborts. */
254
+ async settle(signal?: AbortSignal): Promise<void> {
255
+ while (this.latest?.pending) {
256
+ if (signal?.aborted) return;
257
+ const l = this.latest;
258
+ await new Promise<void>((resolve) => {
259
+ const off = l.onChange(done);
260
+ const offAll = this.onChange(done);
261
+ const onAbort = () => done();
262
+ signal?.addEventListener("abort", onAbort, { once: true });
263
+ function done() {
264
+ off();
265
+ offAll();
266
+ signal?.removeEventListener("abort", onAbort);
267
+ resolve();
268
+ }
269
+ });
270
+ }
271
+ }
272
+
273
+ /** The observation an aside should fork from: the latest if usable, else the last usable one. */
274
+ choose(): Observation | undefined {
275
+ if (this.latest?.usable) return this.latest;
276
+ return this.lastUsable?.usable ? this.lastUsable : undefined;
277
+ }
278
+ }
package/src/panel.ts ADDED
@@ -0,0 +1,252 @@
1
+ // What the user sees (docs/ux.md §3.2–§3.5): the answer panel above the editor, the hint and the handoff ghost
2
+ // text below it, and the `/aside` reader overlay. None of them takes focus except the reader, which is an explicit
3
+ // modal closed with Esc.
4
+
5
+ import { matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
6
+ import { parseReply } from "./reply.ts";
7
+ import type { TurnView } from "./thread.ts";
8
+
9
+ // biome-ignore lint: Pi's theme is used structurally.
10
+ type Theme = any;
11
+
12
+ export const PANEL_LINES = 10;
13
+ /** Lines of the model's thinking shown while no answer text has arrived. */
14
+ export const THINKING_LINES = 3;
15
+ export const MONEY_THRESHOLD = 0.25;
16
+ export const HINT = "↵ asks on the side · corrections still reach the agent";
17
+ /** On the panel's lower edge while the editor is empty: Enter there puts the panel away (docs/ux.md §3.2). */
18
+ export const HIDE_HINT = "↵ hides";
19
+
20
+ /** Pi's own loader frames. */
21
+ export const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
22
+ export const SPIN_MS = 80;
23
+
24
+ export function spinnerFrame(now: number): string {
25
+ return SPINNER[Math.floor(now / SPIN_MS) % SPINNER.length]!;
26
+ }
27
+
28
+ export interface PanelState {
29
+ turns: TurnView[];
30
+ /** One dim line instead of the frame. */
31
+ collapsed: boolean;
32
+ /** Not shown at all (`/aside` still rereads the thread). */
33
+ hidden?: boolean;
34
+ /** Whether the editor is empty: the lower edge then shows `HIDE_HINT`. */
35
+ editorEmpty?: boolean;
36
+ /** Pi's "more" toggle (`app.tools.expand`, ctrl+o) is on: the answer gets up to `maxLines` lines. */
37
+ expanded?: boolean;
38
+ /** Lines an expanded answer may take (from the terminal height). */
39
+ maxLines?: number;
40
+ /** The key that toggles "more", as Pi shows it (`ctrl+o`). */
41
+ moreKey?: string;
42
+ /** Set when the route check sent the text to the agent. */
43
+ sentToAgent?: { text: string; waiting: boolean };
44
+ }
45
+
46
+ /** Markdown to terminal lines; `thinking` text is rendered dim and italic. */
47
+ export type MarkdownRenderer = (text: string, width: number, style?: "thinking") => string[];
48
+
49
+ function wrap(text: string, width: number): string[] {
50
+ const out: string[] = [];
51
+ for (const line of text.split("\n")) {
52
+ if (line === "") out.push("");
53
+ else out.push(...wrapTextWithAnsi(line, Math.max(10, width)));
54
+ }
55
+ return out;
56
+ }
57
+
58
+ /** Plain wrapping, for tests and when Pi's Markdown component is unavailable. */
59
+ export const plainText: MarkdownRenderer = (text, width) => wrap(text, width);
60
+
61
+ function formatCost(cost: number): string {
62
+ return `$${cost.toFixed(2)}`;
63
+ }
64
+
65
+ /** The dim money line of docs/ux.md §3.5, or undefined on the normal (cheap) path. */
66
+ export function moneyLine(turn: TurnView): string | undefined {
67
+ if (turn.cost < MONEY_THRESHOLD) return undefined;
68
+ if (turn.tier === "rebuilt") return `rebuilt context · ${formatCost(turn.cost)}${turn.reason ? ` · ${turn.reason}` : ""}`;
69
+ if (turn.written > 0) return `main cache had expired · ${formatCost(turn.cost)}`;
70
+ return undefined;
71
+ }
72
+
73
+ function elapsed(turn: TurnView, now: number): string {
74
+ const seconds = Math.floor((now - turn.startedAt) / 1000);
75
+ return seconds >= 1 ? ` · ${seconds}s` : "";
76
+ }
77
+
78
+ /** The end of a long thinking text, cut at a paragraph or line start so that its Markdown stays whole. */
79
+ function thinkingTail(text: string): string {
80
+ const trimmed = text.trimEnd();
81
+ if (trimmed.length <= 600) return trimmed;
82
+ const tail = trimmed.slice(-600);
83
+ const cut = tail.indexOf("\n");
84
+ return cut >= 0 && cut < tail.length - 1 ? tail.slice(cut + 1) : tail;
85
+ }
86
+
87
+ /**
88
+ * The live line of an answer in progress: a spinner, what is happening and for how long, and while the model thinks,
89
+ * the last lines of its thinking. Shown until the answer's first words, and during a tool call.
90
+ */
91
+ export function progressLines(turn: TurnView, width: number, theme: Theme, md: MarkdownRenderer, now: number): string[] {
92
+ const spin = theme.fg("accent", spinnerFrame(now));
93
+ const dim = (s: string) => theme.fg("dim", s);
94
+ if (turn.activity) return [`${spin} ${dim(`${turn.activity}${elapsed(turn, now)}`)}`];
95
+ if (turn.thinking?.trim()) {
96
+ const tail = md(thinkingTail(turn.thinking), Math.max(10, width - 2), "thinking").filter((line) => line.trim() !== "");
97
+ return [`${spin} ${dim(`thinking${elapsed(turn, now)}`)}`, ...tail.slice(-THINKING_LINES).map((line) => ` ${line}`)];
98
+ }
99
+ return [`${spin} ${dim(`working${elapsed(turn, now)}`)}`];
100
+ }
101
+
102
+ /** Body lines of the panel for its current state (without the frame). */
103
+ export function panelBody(state: PanelState, width: number, theme: Theme, md: MarkdownRenderer = plainText, now = Date.now()): string[] {
104
+ const turn = state.turns.at(-1);
105
+ if (!turn) return [];
106
+ const dim = (s: string) => theme.fg("dim", s);
107
+ if (state.sentToAgent) {
108
+ return [dim(`↪ sent to the agent · it reads as ${state.sentToAgent.waiting ? "a request" : "a correction"}`)];
109
+ }
110
+ const lines: string[] = [];
111
+ const parsed = parseReply(turn.raw, turn.done);
112
+ const body = parsed.body ? md(parsed.body, width) : [];
113
+ const limit = state.expanded ? Math.max(PANEL_LINES, state.maxLines ?? PANEL_LINES) : PANEL_LINES;
114
+ if (!turn.done) {
115
+ lines.push(...body.slice(-limit));
116
+ if (turn.activity || body.length === 0) lines.push(...progressLines(turn, width, theme, md, now));
117
+ } else {
118
+ if (body.length > limit) {
119
+ lines.push(...body.slice(0, limit));
120
+ const more = !state.expanded && state.moreKey ? `${state.moreKey} more` : "/aside to read all";
121
+ lines.push(dim(`… ${more}`));
122
+ } else {
123
+ lines.push(...body);
124
+ }
125
+ if (turn.aborted) lines.push(dim("stopped"));
126
+ if (turn.error) lines.push(theme.fg("error", `aside failed: ${turn.error}`));
127
+ }
128
+ const money = moneyLine(turn);
129
+ if (money) lines.push(dim(money));
130
+ return lines;
131
+ }
132
+
133
+ /**
134
+ * The framed panel (rounded box with the question as its title, a spinner before it while the answer streams, and
135
+ * `↵ hides` on the lower edge while the editor is empty), one dim line when collapsed, nothing when hidden.
136
+ */
137
+ export function renderPanel(state: PanelState, width: number, theme: Theme, md: MarkdownRenderer = plainText, now = Date.now()): string[] {
138
+ const turn = state.turns.at(-1);
139
+ if (!turn || state.hidden) return [];
140
+ const border = (s: string) => theme.fg("borderMuted", s);
141
+ const question = turn.question.replace(/\s+/g, " ");
142
+ const running = !turn.done && !state.sentToAgent;
143
+ if (state.collapsed) {
144
+ const mark = running ? theme.fg("accent", spinnerFrame(now)) : theme.fg("dim", "▸");
145
+ return [truncateToWidth(`${mark} ${theme.fg("dim", question)}`, width)];
146
+ }
147
+ const inner = Math.max(10, width - 4);
148
+ // The spinner sits in the title while the answer streams, and on the progress line before it (never both).
149
+ const progress = !!turn.activity || parseReply(turn.raw, turn.done).body === "";
150
+ const spin = running && !progress ? `${spinnerFrame(now)} ` : "";
151
+ const title = truncateToWidth(` ${spin}${question} `, Math.max(4, width - 6), "…");
152
+ const top = border("╭─") + theme.fg("muted", title) + border(`${"─".repeat(Math.max(0, width - 3 - visibleWidth(title)))}╮`);
153
+ const out = [truncateToWidth(top, width)];
154
+ for (const line of panelBody(state, inner, theme, md, now)) {
155
+ const text = truncateToWidth(line.replace(/\s+$/u, ""), inner);
156
+ out.push(`${border("│")} ${text}${" ".repeat(Math.max(0, inner - visibleWidth(text)))} ${border("│")}`);
157
+ }
158
+ const hint = state.editorEmpty ? ` ${HIDE_HINT} ` : "";
159
+ if (hint && width >= visibleWidth(hint) + 6) {
160
+ out.push(border(`╰${"─".repeat(Math.max(0, width - 3 - visibleWidth(hint)))}`) + theme.fg("dim", hint) + border("─╯"));
161
+ } else {
162
+ out.push(border(`╰${"─".repeat(Math.max(0, width - 2))}╯`));
163
+ }
164
+ return out;
165
+ }
166
+
167
+ /** Below the editor: the hint before an Enter that asks on the side, or the handoff ghost text. */
168
+ export function renderBelow(input: { hint: boolean; ghost: string | undefined }, width: number, theme: Theme): string[] {
169
+ const lines: string[] = [];
170
+ if (input.ghost) lines.push(truncateToWidth(theme.fg("dim", `⇥ ${input.ghost} (tab accepts)`), width));
171
+ if (input.hint) lines.push(truncateToWidth(theme.fg("dim", HINT), width));
172
+ return lines;
173
+ }
174
+
175
+ export interface ReaderTurn {
176
+ question: string;
177
+ answer: string;
178
+ detail: string;
179
+ }
180
+
181
+ /** The `/aside` reader: every question and answer of the latest thread, opened at the latest question; Esc closes. */
182
+ export class Reader {
183
+ private offset = 0;
184
+ private lastHeight = 20;
185
+
186
+ constructor(
187
+ private readonly turns: () => ReaderTurn[],
188
+ private readonly theme: Theme,
189
+ private readonly done: () => void,
190
+ private readonly requestRender: () => void,
191
+ private readonly rows: () => number,
192
+ private readonly md: MarkdownRenderer = plainText,
193
+ ) {}
194
+
195
+ /** Where the latest question starts: the reader opens there, like a conversation opens at its end. */
196
+ private latestStart = 0;
197
+ private opened = false;
198
+
199
+ private lines(width: number): string[] {
200
+ const out: string[] = [];
201
+ const inner = Math.max(10, width - 2);
202
+ for (const turn of this.turns()) {
203
+ this.latestStart = out.length;
204
+ out.push(...wrap(this.theme.fg("accent", `? ${turn.question}`), inner));
205
+ out.push("");
206
+ out.push(...this.md(turn.answer, inner).map((line) => line.replace(/\s+$/u, "")));
207
+ out.push(...wrap(this.theme.fg("dim", turn.detail), inner));
208
+ out.push("");
209
+ }
210
+ return out;
211
+ }
212
+
213
+ render(width: number): string[] {
214
+ const height = Math.max(5, Math.floor(this.rows() * 0.8) - 2);
215
+ this.lastHeight = height;
216
+ const all = this.lines(width - 2);
217
+ const max = Math.max(0, all.length - height);
218
+ if (!this.opened) {
219
+ this.opened = true;
220
+ this.offset = this.latestStart;
221
+ }
222
+ if (this.offset > max) this.offset = max;
223
+ const view = all.slice(this.offset, this.offset + height);
224
+ const border = (s: string) => this.theme.fg("borderMuted", s);
225
+ const title = " aside ";
226
+ const out = [border("╭─") + this.theme.fg("muted", title) + border(`${"─".repeat(Math.max(0, width - 3 - title.length))}╮`)];
227
+ for (const line of view) {
228
+ const text = truncateToWidth(line, width - 4);
229
+ out.push(`${border("│")} ${text}${" ".repeat(Math.max(0, width - 4 - visibleWidth(text)))} ${border("│")}`);
230
+ }
231
+ const footer = ` ↑↓ scroll · esc close ${all.length > height ? `· ${this.offset + 1}–${Math.min(all.length, this.offset + height)} of ${all.length} ` : ""}`;
232
+ out.push(border("╰") + this.theme.fg("dim", footer) + border(`${"─".repeat(Math.max(0, width - 2 - visibleWidth(footer)))}╯`));
233
+ return out;
234
+ }
235
+
236
+ handleInput(data: string): void {
237
+ if (matchesKey(data, "escape") || data === "q") {
238
+ this.done();
239
+ return;
240
+ }
241
+ const page = Math.max(1, this.lastHeight - 2);
242
+ if (matchesKey(data, "up") || data === "k") this.offset = Math.max(0, this.offset - 1);
243
+ else if (matchesKey(data, "down") || data === "j") this.offset += 1;
244
+ else if (matchesKey(data, "pageUp")) this.offset = Math.max(0, this.offset - page);
245
+ else if (matchesKey(data, "pageDown") || data === " ") this.offset += page;
246
+ else if (matchesKey(data, "home")) this.offset = 0;
247
+ else if (matchesKey(data, "end")) this.offset = Number.MAX_SAFE_INTEGER;
248
+ this.requestRender();
249
+ }
250
+
251
+ invalidate(): void {}
252
+ }