@ian-pascoe/pi-advisor 0.4.0 → 0.6.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.
@@ -0,0 +1,649 @@
1
+ import {
2
+ DynamicBorder,
3
+ ExtensionEditorComponent,
4
+ getSelectListTheme,
5
+ getSettingsListTheme,
6
+ type KeybindingsManager,
7
+ } from "@earendil-works/pi-coding-agent";
8
+ import {
9
+ fuzzyFilter,
10
+ getKeybindings,
11
+ Input,
12
+ SelectList,
13
+ SettingsList,
14
+ type Component,
15
+ type SelectItem,
16
+ type SettingItem,
17
+ type TUI,
18
+ type TuiMouseEvent,
19
+ type TuiMouseEventResult,
20
+ } from "@earendil-works/pi-tui";
21
+ import { Value } from "typebox/value";
22
+ import { formatAdvisorOption, type AdvisorRenderTheme } from "./advisor-rendering.js";
23
+ import {
24
+ advisorOptionKey,
25
+ advisorOptionKeys,
26
+ advisorSettingScopeSchema,
27
+ parseAdvisorOptions,
28
+ type AdvisorChange,
29
+ type AdvisorOptions,
30
+ type AdvisorSettingScope,
31
+ type AdvisorSettingSource,
32
+ } from "./advisor-settings.js";
33
+
34
+ /** Authored options at each writable scope. */
35
+ export type AdvisorScopedOptions = { [Scope in AdvisorSettingScope]: AdvisorOptions };
36
+
37
+ /** Everything the menu displays, read fresh after each change. */
38
+ export interface AdvisorMenuView {
39
+ /** Themed live status lines shown above the settings. */
40
+ headline: readonly string[];
41
+ paused: boolean;
42
+ /** Writable scopes; project only when trusted. */
43
+ scopes: readonly AdvisorSettingScope[];
44
+ /** Effective options after scope precedence. */
45
+ settings: AdvisorOptions;
46
+ sources: Readonly<Partial<Record<keyof AdvisorOptions, AdvisorSettingSource>>>;
47
+ authored: Readonly<Partial<AdvisorScopedOptions>>;
48
+ /** `provider/id` names of selectable models. */
49
+ models: readonly string[];
50
+ /** Tool names known to the observed session. */
51
+ tools: readonly string[];
52
+ }
53
+
54
+ /** The extension's settings authority behind the menu. */
55
+ export interface AdvisorMenuHost {
56
+ view(): AdvisorMenuView;
57
+ /** Validate, persist, and apply one change; rejects with a user-facing message. */
58
+ apply(scope: AdvisorSettingScope, change: AdvisorChange): Promise<void>;
59
+ /** Retry a Paused Advisor with its current settings. */
60
+ resume(): Promise<void>;
61
+ }
62
+
63
+ /** Native UI collaborators supplied by `ctx.ui.custom`. */
64
+ export interface AdvisorMenuUi {
65
+ tui: TUI;
66
+ keybindings: KeybindingsManager;
67
+ theme: AdvisorRenderTheme;
68
+ externalEditorCommand?: string;
69
+ }
70
+
71
+ const thinkingLevels = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
72
+ /** Options edited by cycling the selected scope's own value; `inherit` removes it. */
73
+ const cycleValues = {
74
+ enabled: ["inherit", "on", "off"],
75
+ includeSubagents: ["inherit", "on", "off"],
76
+ thinkingLevel: ["inherit", ...thinkingLevels],
77
+ } as const satisfies Record<string, readonly string[]>;
78
+ type CycleKey = keyof typeof cycleValues;
79
+
80
+ function isCycleKey(key: keyof AdvisorOptions): key is CycleKey {
81
+ return key === "enabled" || key === "includeSubagents" || key === "thinkingLevel";
82
+ }
83
+
84
+ /** A cycled option's value as shown and cycled; undefined when absent. */
85
+ function cycleValue(key: CycleKey, options: AdvisorOptions): string | undefined {
86
+ if (key === "thinkingLevel") return options.thinkingLevel;
87
+ const value = options[key];
88
+ return value === undefined ? undefined : value ? "on" : "off";
89
+ }
90
+ const descriptions = {
91
+ enabled: "Review this session's work",
92
+ includeSubagents: "Also review Minimal Subagents Child Agents",
93
+ prompt: "Advisor Prompt; replaces the whole prompt",
94
+ model: "Advisor model; inherit follows the observed agent",
95
+ thinkingLevel: "Advisor thinking level; inherit follows the observed agent",
96
+ allowedTools: "Tools the Advisor may call (Tool Grant)",
97
+ catchUpThreshold: "Review Backlog that starts a Catch-up Wait; a positive integer or off",
98
+ reviewTimeoutMs: "Deadline for each Review, in seconds",
99
+ maxToolCalls: "Investigative tool calls per Review",
100
+ maxCorrectiveTurns: "Automatic Corrective Turns per request",
101
+ maxFindingsPerReview: "Findings accepted from one Review (1–32)",
102
+ maxNitsPerRequest: "Nits delivered per request; further Nits are dropped (0 delivers none)",
103
+ seedBudgetTokens:
104
+ "Token budget for the Context Seed; auto is a quarter of the Advisor model's context window, at most 100k",
105
+ reviewEvery:
106
+ "When Reviews run: every turn, every N turns, or once per request; a tool error reviews at once",
107
+ maxSessionTokens:
108
+ "Advisor Session size that triggers native compaction; auto is half the Advisor model's context window, at most 200k",
109
+ } satisfies Record<keyof AdvisorOptions, string>;
110
+ const inheritRow = "\u0000inherit";
111
+ const inputHints = {
112
+ catchUpThreshold: "a positive integer, off, or inherit",
113
+ reviewTimeoutMs: "seconds, or inherit",
114
+ maxToolCalls: "a number, or inherit",
115
+ maxCorrectiveTurns: "a number, or inherit",
116
+ maxFindingsPerReview: "a number from 1 to 32, or inherit",
117
+ maxNitsPerRequest: "a number, or inherit",
118
+ seedBudgetTokens: "a token count, auto, or inherit",
119
+ reviewEvery: "turn, request, a number of turns, or inherit",
120
+ maxSessionTokens: "a token count, auto, or inherit",
121
+ } as const;
122
+ const listActions = [
123
+ "tui.select.up",
124
+ "tui.select.down",
125
+ "tui.select.confirm",
126
+ "tui.select.cancel",
127
+ ] as const;
128
+
129
+ /** Convert one typed or selected menu value into a validated change. */
130
+ function parseAdvisorMenuValue(
131
+ key: keyof AdvisorOptions,
132
+ text: string,
133
+ scope: AdvisorSettingScope,
134
+ ): AdvisorChange {
135
+ const value = text.trim();
136
+ if (value === "inherit") return { action: "inherit", key };
137
+ if (value === "") throw new Error(`Enter a value for ${key}, or inherit`);
138
+ const patch = (() => {
139
+ switch (key) {
140
+ case "enabled":
141
+ case "includeSubagents":
142
+ return { [key]: value === "on" ? true : value === "off" ? false : value };
143
+ case "catchUpThreshold":
144
+ return { catchUpThreshold: value === "off" ? value : Number(value) };
145
+ case "reviewTimeoutMs":
146
+ return { reviewTimeoutMs: Math.round(Number(value) * 1_000) };
147
+ case "seedBudgetTokens":
148
+ return { seedBudgetTokens: value === "auto" ? value : Number(value) };
149
+ case "maxSessionTokens":
150
+ return { maxSessionTokens: value === "auto" ? value : Number(value) };
151
+ case "reviewEvery":
152
+ return {
153
+ reviewEvery: value === "turn" || value === "request" ? value : Number(value),
154
+ };
155
+ case "maxToolCalls":
156
+ case "maxCorrectiveTurns":
157
+ case "maxFindingsPerReview":
158
+ case "maxNitsPerRequest":
159
+ return { [key]: Number(value) };
160
+ default:
161
+ return { [key]: value };
162
+ }
163
+ })();
164
+ return { action: "set", key, patch: parseAdvisorOptions(patch, scope) };
165
+ }
166
+
167
+ function errorText(cause: unknown): string {
168
+ return cause instanceof Error ? cause.message : String(cause);
169
+ }
170
+
171
+ /** Single-line value entry with inline validation. */
172
+ class ValueInput implements Component {
173
+ private readonly input: Input;
174
+ private error: string | undefined;
175
+
176
+ constructor(
177
+ private readonly title: string,
178
+ private readonly hint: string,
179
+ private readonly theme: AdvisorRenderTheme,
180
+ submit: (text: string) => void,
181
+ cancel: () => void,
182
+ ) {
183
+ this.input = new Input({ placeholder: hint });
184
+ this.input.focused = true;
185
+ this.input.onSubmit = (text) => {
186
+ try {
187
+ submit(text);
188
+ } catch (cause) {
189
+ this.error = errorText(cause);
190
+ }
191
+ };
192
+ this.input.onEscape = cancel;
193
+ }
194
+ handleInput(data: string): void {
195
+ this.error = undefined;
196
+ this.input.handleInput(data);
197
+ }
198
+ render(width: number): string[] {
199
+ return [
200
+ this.theme.bold(this.title),
201
+ ...this.input.render(width),
202
+ this.error ? this.theme.fg("error", `✖ ${this.error}`) : this.theme.fg("dim", this.hint),
203
+ ];
204
+ }
205
+ invalidate(): void {
206
+ this.input.invalidate();
207
+ }
208
+ }
209
+
210
+ /** Fuzzy-searchable model list with an inherit choice. */
211
+ class ModelPicker implements Component {
212
+ private readonly input = new Input({ placeholder: "type to search" });
213
+ private list: SelectList;
214
+
215
+ constructor(
216
+ private readonly models: readonly string[],
217
+ private readonly choose: (value: string) => void,
218
+ private readonly cancel: () => void,
219
+ ) {
220
+ this.input.focused = true;
221
+ this.list = this.createList("");
222
+ }
223
+ private createList(query: string): SelectList {
224
+ const items: SelectItem[] = ["inherit", ...this.models].map((value) => ({
225
+ value,
226
+ label: value,
227
+ }));
228
+ const list = new SelectList(
229
+ query ? fuzzyFilter(items, query, (item) => item.value) : items,
230
+ 10,
231
+ getSelectListTheme(),
232
+ );
233
+ list.onSelect = (item) => this.choose(item.value);
234
+ list.onCancel = this.cancel;
235
+ return list;
236
+ }
237
+ handleInput(data: string): void {
238
+ const bindings = getKeybindings();
239
+ if (listActions.some((action) => bindings.matches(data, action))) {
240
+ this.list.handleInput(data);
241
+ return;
242
+ }
243
+ this.input.handleInput(data);
244
+ this.list = this.createList(this.input.getValue());
245
+ }
246
+ render(width: number): string[] {
247
+ return [...this.input.render(width), ...this.list.render(width)];
248
+ }
249
+ invalidate(): void {
250
+ this.input.invalidate();
251
+ this.list.invalidate();
252
+ }
253
+ }
254
+
255
+ /** Choose between editing the prompt in Pi's editor component and inheriting it. */
256
+ class PromptChooser implements Component {
257
+ private readonly list: SelectList;
258
+ private editor: ExtensionEditorComponent | undefined;
259
+ private error: string | undefined;
260
+
261
+ /** `submit` throws a user-facing message for an invalid prompt; the editor stays open. */
262
+ constructor(
263
+ ui: AdvisorMenuUi,
264
+ prompt: string,
265
+ submit: (text: string) => void,
266
+ inherit: () => void,
267
+ cancel: () => void,
268
+ ) {
269
+ this.list = new SelectList(
270
+ [
271
+ { value: "edit", label: "Edit…" },
272
+ { value: "inherit", label: "inherit" },
273
+ ],
274
+ 2,
275
+ getSelectListTheme(),
276
+ );
277
+ this.list.onCancel = cancel;
278
+ this.list.onSelect = (item) => {
279
+ if (item.value === "inherit") {
280
+ inherit();
281
+ return;
282
+ }
283
+ this.editor = new ExtensionEditorComponent(
284
+ ui.tui,
285
+ ui.keybindings,
286
+ "Advisor Prompt",
287
+ prompt,
288
+ (text) => {
289
+ try {
290
+ submit(text);
291
+ } catch (cause) {
292
+ this.error = ui.theme.fg("error", `✖ ${errorText(cause)}`);
293
+ }
294
+ },
295
+ cancel,
296
+ undefined,
297
+ ui.externalEditorCommand,
298
+ );
299
+ this.editor.focused = true;
300
+ };
301
+ }
302
+ handleInput(data: string): void {
303
+ this.error = undefined;
304
+ (this.editor ?? this.list).handleInput(data);
305
+ }
306
+ render(width: number): string[] {
307
+ const lines = (this.editor ?? this.list).render(width);
308
+ return this.error ? [...lines, this.error] : lines;
309
+ }
310
+ invalidate(): void {
311
+ (this.editor ?? this.list).invalidate();
312
+ }
313
+ }
314
+
315
+ /** Menu row ids that are not Advisor options. */
316
+ const actionRows = { resume: "resume", scope: "scope" } as const;
317
+
318
+ /** `/advisor` settings menu built from Pi's native settings list. */
319
+ export class AdvisorSettingsMenu implements Component {
320
+ private scope: AdvisorSettingScope = "session";
321
+ private view: AdvisorMenuView;
322
+ private rows: SettingItem[] = [];
323
+ private list: SettingsList;
324
+ private error: string | undefined;
325
+ private lastRow: string = actionRows.scope;
326
+ /** Edits run one at a time so each reads the result of the previous one. */
327
+ private pending: Promise<void> = Promise.resolve();
328
+ private submenuOpen = false;
329
+ private rebuildPending = false;
330
+ private readonly border: DynamicBorder;
331
+
332
+ constructor(
333
+ private readonly host: AdvisorMenuHost,
334
+ private readonly ui: AdvisorMenuUi,
335
+ private readonly done: () => void,
336
+ ) {
337
+ this.view = host.view();
338
+ this.border = new DynamicBorder((text) => ui.theme.fg("border", text));
339
+ this.list = this.createList();
340
+ }
341
+
342
+ /** Resolves once every edit started so far has been applied or rejected. */
343
+ settled(): Promise<void> {
344
+ return this.pending;
345
+ }
346
+
347
+ /** Re-read the host after external state changes, such as a Review starting. */
348
+ refresh(): void {
349
+ const paused = this.view.paused;
350
+ try {
351
+ this.view = this.host.view();
352
+ } catch (cause) {
353
+ // Keep the last good view; an unreadable settings file is reported inline.
354
+ this.error = errorText(cause);
355
+ return;
356
+ }
357
+ if (!this.view.scopes.includes(this.scope)) this.scope = "session";
358
+ for (const row of this.rows) Object.assign(row, this.createRow(row.id));
359
+ // Adding or removing Resume rebuilds the list; never discard an open submenu to do it.
360
+ if (paused !== this.view.paused) {
361
+ if (this.submenuOpen) this.rebuildPending = true;
362
+ else this.rebuild();
363
+ }
364
+ }
365
+
366
+ private rebuild(): void {
367
+ this.rebuildPending = false;
368
+ this.list = this.createList();
369
+ this.list.selectItem(this.view.paused ? actionRows.resume : this.lastRow);
370
+ }
371
+
372
+ private createList(): SettingsList {
373
+ const ids = [
374
+ ...(this.view.paused ? [actionRows.resume] : []),
375
+ actionRows.scope,
376
+ ...advisorOptionKeys,
377
+ ];
378
+ this.rows = ids.map((id) => this.createRow(id));
379
+ return new SettingsList(
380
+ this.rows,
381
+ this.rows.length,
382
+ getSettingsListTheme(),
383
+ (id, value) => this.change(id, value),
384
+ this.done,
385
+ );
386
+ }
387
+
388
+ private createRow(id: string): SettingItem {
389
+ if (id === actionRows.resume)
390
+ return {
391
+ id,
392
+ label: "Resume",
393
+ currentValue: "retry",
394
+ values: ["retry"],
395
+ description: "Retry the Paused Advisor with its current settings",
396
+ };
397
+ if (id === actionRows.scope)
398
+ return {
399
+ id,
400
+ label: "Scope",
401
+ currentValue: this.scope,
402
+ values: [...this.view.scopes],
403
+ description: "Where edits are written",
404
+ };
405
+ return this.optionRow(advisorOptionKey(id));
406
+ }
407
+
408
+ /** Track an open submenu so a rebuild waits for it to close. */
409
+ private submenu(
410
+ id: string,
411
+ open: (close: () => void) => Component,
412
+ ): NonNullable<SettingItem["submenu"]> {
413
+ return (_value, done) => {
414
+ this.submenuOpen = true;
415
+ this.lastRow = id;
416
+ return open(() => {
417
+ this.submenuOpen = false;
418
+ done();
419
+ if (this.rebuildPending) this.rebuild();
420
+ });
421
+ };
422
+ }
423
+
424
+ private optionRow(key: keyof AdvisorOptions): SettingItem {
425
+ const source = this.view.sources[key] ?? "default";
426
+ const settings = this.view.settings;
427
+ const row = {
428
+ id: key,
429
+ label: source === "default" ? key : `${key} [${source}]`,
430
+ description: descriptions[key],
431
+ };
432
+ switch (key) {
433
+ case "enabled":
434
+ case "includeSubagents":
435
+ case "thinkingLevel":
436
+ // The row shows and cycles this scope's own value, so every state is reachable.
437
+ return {
438
+ ...row,
439
+ label: key,
440
+ currentValue: this.cycleDisplay(key),
441
+ values: [...cycleValues[key]],
442
+ };
443
+ case "model":
444
+ return {
445
+ ...row,
446
+ currentValue: settings.model ?? "inherit",
447
+ submenu: this.submenu(
448
+ key,
449
+ (close) =>
450
+ new ModelPicker(
451
+ this.view.models,
452
+ (value) => {
453
+ this.apply(parseAdvisorMenuValue(key, value, this.scope));
454
+ close();
455
+ },
456
+ close,
457
+ ),
458
+ ),
459
+ };
460
+ case "allowedTools":
461
+ return {
462
+ ...row,
463
+ currentValue: formatAdvisorOption(settings, key),
464
+ submenu: this.submenu(key, (close) => this.toolChecklist(close)),
465
+ };
466
+ case "prompt":
467
+ return {
468
+ ...row,
469
+ currentValue: formatAdvisorOption(settings, key),
470
+ submenu: this.submenu(
471
+ key,
472
+ (close) =>
473
+ new PromptChooser(
474
+ this.ui,
475
+ settings.prompt ?? "",
476
+ (text) => {
477
+ // Throws for an invalid prompt, keeping the editor open.
478
+ this.apply({
479
+ action: "set",
480
+ key,
481
+ patch: parseAdvisorOptions({ prompt: text }, this.scope),
482
+ });
483
+ close();
484
+ },
485
+ () => {
486
+ this.apply({ action: "inherit", key });
487
+ close();
488
+ },
489
+ close,
490
+ ),
491
+ ),
492
+ };
493
+ default:
494
+ return {
495
+ ...row,
496
+ currentValue: formatAdvisorOption(settings, key),
497
+ submenu: this.submenu(
498
+ key,
499
+ (close) =>
500
+ new ValueInput(
501
+ key,
502
+ inputHints[key],
503
+ this.ui.theme,
504
+ (text) => {
505
+ // Throws for invalid input, keeping the field open with the message.
506
+ this.apply(parseAdvisorMenuValue(key, text, this.scope));
507
+ close();
508
+ },
509
+ close,
510
+ ),
511
+ ),
512
+ };
513
+ }
514
+ }
515
+
516
+ private toolChecklist(close: () => void): Component {
517
+ const granted = this.view.settings.allowedTools ?? [];
518
+ const names = [
519
+ ...this.view.tools,
520
+ ...granted.filter((name) => !this.view.tools.includes(name)),
521
+ ];
522
+ const rows: SettingItem[] = [
523
+ ...names.map((name) => ({
524
+ id: name,
525
+ label: this.view.tools.includes(name) ? name : `${name} (unavailable)`,
526
+ currentValue: granted.includes(name) ? "on" : "off",
527
+ values: ["on", "off"],
528
+ })),
529
+ { id: inheritRow, label: "inherit", currentValue: "", values: ["inherit"] },
530
+ ];
531
+ return new SettingsList(
532
+ rows,
533
+ Math.min(rows.length, 12),
534
+ getSettingsListTheme(),
535
+ (id, value) => {
536
+ if (id === inheritRow) {
537
+ this.apply({ action: "inherit", key: "allowedTools" });
538
+ close();
539
+ return;
540
+ }
541
+ const scope = this.scope;
542
+ // Read the grant when this edit runs, after any earlier toggle has been applied.
543
+ this.run(() => {
544
+ const current = this.host.view().settings.allowedTools ?? [];
545
+ const allowedTools =
546
+ value === "on"
547
+ ? [...current.filter((name) => name !== id), id]
548
+ : current.filter((name) => name !== id);
549
+ return this.host.apply(scope, {
550
+ action: "set",
551
+ key: "allowedTools",
552
+ patch: parseAdvisorOptions({ allowedTools }, scope),
553
+ });
554
+ });
555
+ },
556
+ close,
557
+ { enableSearch: rows.length > 12 },
558
+ );
559
+ }
560
+
561
+ /** This scope's own value, or what it inherits; notes when another scope overrides it. */
562
+ private cycleDisplay(key: CycleKey): string {
563
+ const own = cycleValue(key, this.view.authored[this.scope] ?? {});
564
+ const effective = cycleValue(key, this.view.settings);
565
+ const source = this.view.sources[key] ?? "default";
566
+ const inEffect = effective === undefined ? "observed agent" : `${effective} · ${source}`;
567
+ if (own === undefined) return `inherit (${inEffect})`;
568
+ return source === this.scope ? own : `${own} (overridden: ${inEffect})`;
569
+ }
570
+
571
+ private change(id: string, value: string): void {
572
+ if (id === actionRows.resume) {
573
+ this.run(() => this.host.resume());
574
+ return;
575
+ }
576
+ if (id === actionRows.scope) {
577
+ if (Value.Check(advisorSettingScopeSchema, value)) this.scope = value;
578
+ this.lastRow = id;
579
+ this.refresh();
580
+ return;
581
+ }
582
+ this.lastRow = id;
583
+ const key = advisorOptionKey(id);
584
+ if (!isCycleKey(key)) return;
585
+ // Cycle from this scope's own value. The list proposes values[0] because its display
586
+ // text is not one of the values, so ignore its suggestion.
587
+ const values = cycleValues[key];
588
+ const own = cycleValue(key, this.view.authored[this.scope] ?? {}) ?? "inherit";
589
+ const next =
590
+ values[(values.findIndex((option) => option === own) + 1) % values.length] ?? "inherit";
591
+ let change: AdvisorChange;
592
+ try {
593
+ change = parseAdvisorMenuValue(key, next, this.scope);
594
+ } catch (cause) {
595
+ this.error = errorText(cause);
596
+ this.refresh();
597
+ return;
598
+ }
599
+ this.apply(change);
600
+ // The list already overwrote the row with its proposal; show the current value until
601
+ // the write settles and refresh() shows the new one.
602
+ const row = this.rows.find((item) => item.id === id);
603
+ if (row) row.currentValue = this.cycleDisplay(key);
604
+ }
605
+
606
+ /** Apply at the current scope (Pi settings semantics); the list refreshes once it settles. */
607
+ private apply(change: AdvisorChange): void {
608
+ const scope = this.scope;
609
+ this.run(() => this.host.apply(scope, change));
610
+ }
611
+
612
+ private run(task: () => Promise<void>): void {
613
+ this.error = undefined;
614
+ this.pending = this.pending
615
+ .then(task)
616
+ .catch((cause: unknown) => {
617
+ this.error = errorText(cause);
618
+ })
619
+ .finally(() => {
620
+ this.refresh();
621
+ this.ui.tui.requestRender();
622
+ });
623
+ }
624
+
625
+ handleInput(data: string): void {
626
+ this.list.handleInput(data);
627
+ }
628
+
629
+ handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
630
+ return this.list.handleMouse(event);
631
+ }
632
+
633
+ render(width: number): string[] {
634
+ const { theme } = this.ui;
635
+ return [
636
+ ...this.border.render(width),
637
+ ` ${theme.bold("Advisor settings")}`,
638
+ ...this.view.headline.map((line) => ` ${line}`),
639
+ "",
640
+ ...this.list.render(width),
641
+ ...(this.error ? [theme.fg("error", ` ✖ ${this.error}`)] : []),
642
+ ...this.border.render(width),
643
+ ];
644
+ }
645
+
646
+ invalidate(): void {
647
+ this.list.invalidate();
648
+ }
649
+ }