@xynogen/pix-core 0.1.0 → 0.1.2

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,84 @@
1
+ import type { Params } from "./schema.js";
2
+ import { SENTINEL_FREEFORM } from "./schema.js";
3
+ import type { QuestionAnswer, QuestionnaireResult } from "./types.js";
4
+
5
+ // ── RPC / non-TUI fallback ─────────────────────────────────────────────
6
+ // Used when ctx.hasUI is false (headless / JSON / print mode).
7
+
8
+ export async function rpcFallback(
9
+ ui: { select: Function; input: Function },
10
+ params: Params,
11
+ ): Promise<QuestionnaireResult> {
12
+ const answers: QuestionAnswer[] = [];
13
+ let cancelled = false;
14
+
15
+ for (let i = 0; i < params.questions.length; i++) {
16
+ const q = params.questions[i]!;
17
+ const header = q.header;
18
+
19
+ if (q.multiSelect) {
20
+ const lines = q.options.map(
21
+ (o, idx) => `${idx + 1}. ${o.label} — ${o.description}`,
22
+ );
23
+ const raw = await ui.input(
24
+ `${header}: ${q.question}\n\n${lines.join("\n")}\n\nEnter numbers separated by commas:`,
25
+ "e.g. 1,3",
26
+ );
27
+ if (raw == null) {
28
+ cancelled = true;
29
+ break;
30
+ }
31
+ const indices = String(raw)
32
+ .split(",")
33
+ .map((s) => Number(s.trim()))
34
+ .filter((n) => n >= 1 && n <= q.options.length);
35
+ const selected = indices.map((n) => q.options[n - 1]?.label);
36
+ if (selected.length > 0) {
37
+ answers.push({
38
+ questionIndex: i,
39
+ question: q.question,
40
+ kind: "multi",
41
+ answer: null,
42
+ selected,
43
+ });
44
+ } else {
45
+ cancelled = true;
46
+ break;
47
+ }
48
+ } else {
49
+ const items = q.options.map((o) => `${o.label} — ${o.description}`);
50
+ items.push(SENTINEL_FREEFORM);
51
+ const chosen = await ui.select(`${header}: ${q.question}`, items);
52
+ if (chosen == null) {
53
+ cancelled = true;
54
+ break;
55
+ }
56
+ if (chosen === SENTINEL_FREEFORM) {
57
+ const text = await ui.input(q.question, "Type your answer...");
58
+ if (text == null) {
59
+ cancelled = true;
60
+ break;
61
+ }
62
+ answers.push({
63
+ questionIndex: i,
64
+ question: q.question,
65
+ kind: "custom",
66
+ answer: String(text),
67
+ });
68
+ } else {
69
+ const opt = q.options.find(
70
+ (o) =>
71
+ chosen === o.label || `${o.label} — ${o.description}` === chosen,
72
+ )!;
73
+ answers.push({
74
+ questionIndex: i,
75
+ question: q.question,
76
+ kind: "option",
77
+ answer: opt?.label ?? String(chosen),
78
+ });
79
+ }
80
+ }
81
+ }
82
+
83
+ return { answers, cancelled };
84
+ }
@@ -0,0 +1,69 @@
1
+ import { type Static, Type } from "typebox";
2
+
3
+ // ── Constants ──────────────────────────────────────────────────────────
4
+
5
+ export const MAX_QUESTIONS = 4;
6
+ export const MIN_OPTIONS = 2;
7
+ export const MAX_OPTIONS = 4;
8
+ export const MAX_HEADER_LENGTH = 16;
9
+ export const MAX_LABEL_LENGTH = 60;
10
+
11
+ export const SENTINEL_FREEFORM = "Type something.";
12
+ export const SENTINEL_CHAT = "Chat about this";
13
+ export const SENTINEL_NEXT = "Next";
14
+
15
+ export const SPLIT_PANE_MIN_WIDTH = 84;
16
+ export const SEPARATOR = " │ ";
17
+
18
+ // ── Schemas ────────────────────────────────────────────────────────────
19
+
20
+ export const OptionSchema = Type.Object({
21
+ label: Type.String({
22
+ maxLength: MAX_LABEL_LENGTH,
23
+ description: `MAX ${MAX_LABEL_LENGTH} CHARACTERS. Display text for this option. Concise (1-5 words).`,
24
+ }),
25
+ description: Type.String({
26
+ description: "Explanation of what this option means or trade-offs.",
27
+ }),
28
+ preview: Type.Optional(
29
+ Type.String({
30
+ description:
31
+ "Optional markdown preview for side-by-side layout (single-select only).",
32
+ }),
33
+ ),
34
+ });
35
+
36
+ export const QuestionSchema = Type.Object({
37
+ question: Type.String({
38
+ description: "Clear, specific question ending with ?",
39
+ }),
40
+ header: Type.String({
41
+ maxLength: MAX_HEADER_LENGTH,
42
+ description: `MAX ${MAX_HEADER_LENGTH} CHARS — short chip/tag. E.g. "Auth method", "Approach".`,
43
+ }),
44
+ options: Type.Array(OptionSchema, {
45
+ minItems: MIN_OPTIONS,
46
+ maxItems: MAX_OPTIONS,
47
+ description:
48
+ "2-4 options. 'Type something.' and 'Chat about this' are auto-appended.",
49
+ }),
50
+ multiSelect: Type.Optional(
51
+ Type.Boolean({
52
+ default: false,
53
+ description:
54
+ "Allow multiple selections. Suppresses 'Type something.' row.",
55
+ }),
56
+ ),
57
+ });
58
+
59
+ export const QuestionsSchema = Type.Array(QuestionSchema, {
60
+ minItems: 1,
61
+ maxItems: MAX_QUESTIONS,
62
+ description: "1-4 questions",
63
+ });
64
+
65
+ export const ParamsSchema = Type.Object({ questions: QuestionsSchema });
66
+
67
+ export type OptionData = Static<typeof OptionSchema>;
68
+ export type QuestionData = Static<typeof QuestionSchema>;
69
+ export type Params = Static<typeof ParamsSchema>;
@@ -34,7 +34,11 @@ describe("renderSingleSelectRows", () => {
34
34
  allowFreeform: false,
35
35
  });
36
36
 
37
- const rendered = rows.map((r) => r.line).join(" ").replace(/\s+/g, " ").trim();
37
+ const rendered = rows
38
+ .map((r) => r.line)
39
+ .join(" ")
40
+ .replace(/\s+/g, " ")
41
+ .trim();
38
42
  expect(rendered).toContain("want a plan first");
39
43
  expect(rendered).toContain("before touching code");
40
44
  expect(rows.length).toBeGreaterThan(2);
@@ -63,7 +67,12 @@ describe("renderSingleSelectRows", () => {
63
67
  });
64
68
 
65
69
  expect(rows.length).toBeLessThanOrEqual(6);
66
- expect(rows.map((r) => r.line).join(" ").replace(/\s+/g, " ")).toContain("troubleshooting");
70
+ expect(
71
+ rows
72
+ .map((r) => r.line)
73
+ .join(" ")
74
+ .replace(/\s+/g, " "),
75
+ ).toContain("troubleshooting");
67
76
  });
68
77
 
69
78
  test("does not duplicate a short word after wrapping an exact-width long word", () => {
@@ -79,15 +88,22 @@ describe("renderSingleSelectRows", () => {
79
88
  allowFreeform: false,
80
89
  });
81
90
 
82
- expect(rows.map((r) => r.line).filter((line) => line.trim() === "hi")).toHaveLength(1);
83
- expect(rows.map((r) => r.line).filter((line) => line.trim() === "aaaaaaaa")).toHaveLength(2);
91
+ expect(
92
+ rows.map((r) => r.line).filter((line) => line.trim() === "hi"),
93
+ ).toHaveLength(1);
94
+ expect(
95
+ rows.map((r) => r.line).filter((line) => line.trim() === "aaaaaaaa"),
96
+ ).toHaveLength(2);
84
97
  });
85
98
 
86
99
  test("marks selected item rows as selected in annotated output", () => {
87
100
  const rows = renderSingleSelectRows({
88
101
  options: [
89
102
  { title: "Alpha" },
90
- { title: "Beta with a very long title that should wrap to multiple lines when rendered" },
103
+ {
104
+ title:
105
+ "Beta with a very long title that should wrap to multiple lines when rendered",
106
+ },
91
107
  { title: "Gamma" },
92
108
  ],
93
109
  selectedIndex: 1,
@@ -53,7 +53,8 @@ function wrapText(text: string, width: number): string[] {
53
53
  current = "";
54
54
  for (let i = 0; i < word.length; i += width) {
55
55
  const chunk = word.slice(i, i + width);
56
- if (chunk.length === width || i + width < word.length) lines.push(chunk);
56
+ if (chunk.length === width || i + width < word.length)
57
+ lines.push(chunk);
57
58
  else current = chunk;
58
59
  }
59
60
  }
@@ -89,9 +90,15 @@ function buildItemBlocks(
89
90
  const normalizedWidth = Math.max(12, width);
90
91
  const freeformLabel = "Type something. — Enter a custom response";
91
92
  const commentToggleLabel = `${commentEnabled ? "[✓]" : "[ ]"} Add extra context after selection`;
92
- const allItems: ListItem[] = options.map((option) => ({ type: "option", option }));
93
+ const allItems: ListItem[] = options.map((option) => ({
94
+ type: "option",
95
+ option,
96
+ }));
93
97
  if (allowComment) {
94
- allItems.push({ type: "comment-toggle", option: { title: commentToggleLabel } });
98
+ allItems.push({
99
+ type: "comment-toggle",
100
+ option: { title: commentToggleLabel },
101
+ });
95
102
  }
96
103
  if (allowFreeform) {
97
104
  allItems.push({ type: "freeform", option: { title: freeformLabel } });
@@ -103,18 +110,28 @@ function buildItemBlocks(
103
110
 
104
111
  if (item.type === "comment-toggle" || item.type === "freeform") {
105
112
  const prefix = `${pointer} `;
106
- const wrapped = wrapText(item.option.title, Math.max(8, normalizedWidth - prefix.length));
113
+ const wrapped = wrapText(
114
+ item.option.title,
115
+ Math.max(8, normalizedWidth - prefix.length),
116
+ );
107
117
  wrapped.forEach((line, lineIndex) => {
108
- lines.push(padLine(lineIndex === 0 ? prefix : " ".repeat(prefix.length), line));
118
+ lines.push(
119
+ padLine(lineIndex === 0 ? prefix : " ".repeat(prefix.length), line),
120
+ );
109
121
  });
110
122
  return { itemIndex, lines };
111
123
  }
112
124
 
113
125
  const numberPrefix = `${pointer} ${itemIndex + 1}. `;
114
126
  const continuationPrefix = " ".repeat(numberPrefix.length);
115
- const titleLines = wrapText(item.option.title, Math.max(8, normalizedWidth - numberPrefix.length));
127
+ const titleLines = wrapText(
128
+ item.option.title,
129
+ Math.max(8, normalizedWidth - numberPrefix.length),
130
+ );
116
131
  titleLines.forEach((line, lineIndex) => {
117
- lines.push(padLine(lineIndex === 0 ? numberPrefix : continuationPrefix, line));
132
+ lines.push(
133
+ padLine(lineIndex === 0 ? numberPrefix : continuationPrefix, line),
134
+ );
118
135
  });
119
136
 
120
137
  if (item.option.description && !hideDescriptions) {
@@ -151,11 +168,25 @@ export function renderSingleSelectRows({
151
168
  maxRows,
152
169
  hideDescriptions,
153
170
  }: RenderSingleSelectRowsParams): AnnotatedRow[] {
154
- const itemCount = options.length + (allowComment ? 1 : 0) + (allowFreeform ? 1 : 0);
155
- const blocks = buildItemBlocks(options, width, allowFreeform, allowComment, commentEnabled, selectedIndex, hideDescriptions);
171
+ const itemCount =
172
+ options.length + (allowComment ? 1 : 0) + (allowFreeform ? 1 : 0);
173
+ const blocks = buildItemBlocks(
174
+ options,
175
+ width,
176
+ allowFreeform,
177
+ allowComment,
178
+ commentEnabled,
179
+ selectedIndex,
180
+ hideDescriptions,
181
+ );
156
182
  const allRows = flatten(blocks, selectedIndex);
157
183
 
158
- if (!Number.isFinite(maxRows) || !maxRows || maxRows <= 0 || allRows.length <= maxRows) {
184
+ if (
185
+ !Number.isFinite(maxRows) ||
186
+ !maxRows ||
187
+ maxRows <= 0 ||
188
+ allRows.length <= maxRows
189
+ ) {
159
190
  return allRows;
160
191
  }
161
192
 
@@ -180,17 +211,20 @@ export function renderSingleSelectRows({
180
211
  let usedRows = selectedBlock.lines.length;
181
212
 
182
213
  while (true) {
183
- const nextCanFit = end < blocks.length && usedRows + blocks[end]!.lines.length <= availableRows;
214
+ const nextCanFit =
215
+ end < blocks.length &&
216
+ usedRows + blocks[end]?.lines.length <= availableRows;
184
217
  if (nextCanFit) {
185
- usedRows += blocks[end]!.lines.length;
218
+ usedRows += blocks[end]?.lines.length;
186
219
  end += 1;
187
220
  continue;
188
221
  }
189
222
 
190
- const prevCanFit = start > 0 && usedRows + blocks[start - 1]!.lines.length <= availableRows;
223
+ const prevCanFit =
224
+ start > 0 && usedRows + blocks[start - 1]?.lines.length <= availableRows;
191
225
  if (prevCanFit) {
192
226
  start -= 1;
193
- usedRows += blocks[start]!.lines.length;
227
+ usedRows += blocks[start]?.lines.length;
194
228
  continue;
195
229
  }
196
230
 
@@ -0,0 +1,17 @@
1
+ // ── Answer & result types ──────────────────────────────────────────────
2
+
3
+ export type AnswerKind = "option" | "custom" | "chat" | "multi";
4
+
5
+ export interface QuestionAnswer {
6
+ questionIndex: number;
7
+ question: string;
8
+ kind: AnswerKind;
9
+ answer: string | null;
10
+ selected?: string[];
11
+ preview?: string;
12
+ }
13
+
14
+ export interface QuestionnaireResult {
15
+ answers: QuestionAnswer[];
16
+ cancelled: boolean;
17
+ }
@@ -9,8 +9,8 @@
9
9
  * checklist is seeded by the model via the tool's `set` action.
10
10
  */
11
11
 
12
- import { Type } from "typebox";
13
12
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
13
+ import { Type } from "typebox";
14
14
 
15
15
  export type TodoStatus = "pending" | "in_progress" | "done" | "blocked";
16
16
 
@@ -44,7 +44,9 @@ export default function registerTodo(pi: ExtensionAPI): void {
44
44
  function todoSummary(): string {
45
45
  if (!todos.length) return "(no todos)";
46
46
  const done = todos.filter((t) => t.status === "done").length;
47
- const lines = todos.map((t) => `${TODO_GLYPH[t.status]} ${t.id}. ${t.text}`);
47
+ const lines = todos.map(
48
+ (t) => `${TODO_GLYPH[t.status]} ${t.id}. ${t.text}`,
49
+ );
48
50
  return `Todos ${done}/${todos.length} done:\n${lines.join("\n")}`;
49
51
  }
50
52
 
@@ -101,19 +103,24 @@ export default function registerTodo(pi: ExtensionAPI): void {
101
103
  ),
102
104
  }),
103
105
  async execute(_id, params) {
106
+ // AgentToolResult now requires a `details` field. These todo results have
107
+ // no structured details, so emit `undefined` via small local helpers.
108
+ const ok = (text: string) => ({
109
+ content: [{ type: "text" as const, text }],
110
+ details: undefined,
111
+ });
112
+ const fail = (text: string) => ({
113
+ content: [{ type: "text" as const, text }],
114
+ details: undefined,
115
+ isError: true,
116
+ });
104
117
  switch (params.action) {
105
118
  case "list":
106
- return { content: [{ type: "text", text: todoSummary() }] };
119
+ return ok(todoSummary());
107
120
 
108
121
  case "set": {
109
122
  const texts = parseItems(params.items ?? "");
110
- if (!texts.length)
111
- return {
112
- content: [
113
- { type: "text", text: "set requires non-empty `items`." },
114
- ],
115
- isError: true,
116
- };
123
+ if (!texts.length) return fail("set requires non-empty `items`.");
117
124
  nextTodoId = 1;
118
125
  todos = texts.map((text) => ({
119
126
  id: nextTodoId++,
@@ -121,55 +128,35 @@ export default function registerTodo(pi: ExtensionAPI): void {
121
128
  status: "pending" as TodoStatus,
122
129
  }));
123
130
  persistTodos();
124
- return { content: [{ type: "text", text: todoSummary() }] };
131
+ return ok(todoSummary());
125
132
  }
126
133
 
127
134
  case "add": {
128
135
  const texts = parseItems(params.items ?? "");
129
- if (!texts.length)
130
- return {
131
- content: [
132
- { type: "text", text: "add requires non-empty `items`." },
133
- ],
134
- isError: true,
135
- };
136
+ if (!texts.length) return fail("add requires non-empty `items`.");
136
137
  for (const text of texts)
137
138
  todos.push({ id: nextTodoId++, text, status: "pending" });
138
139
  persistTodos();
139
- return { content: [{ type: "text", text: todoSummary() }] };
140
+ return ok(todoSummary());
140
141
  }
141
142
 
142
143
  case "update": {
143
144
  const t = todos.find((x) => x.id === params.id);
144
- if (!t)
145
- return {
146
- content: [
147
- { type: "text", text: `No todo with id ${params.id}.` },
148
- ],
149
- isError: true,
150
- };
145
+ if (!t) return fail(`No todo with id ${params.id}.`);
151
146
  if (params.status) t.status = params.status;
152
147
  if (params.text) t.text = params.text;
153
148
  persistTodos();
154
- return { content: [{ type: "text", text: todoSummary() }] };
149
+ return ok(todoSummary());
155
150
  }
156
151
 
157
152
  case "clear":
158
153
  todos = [];
159
154
  nextTodoId = 1;
160
155
  persistTodos();
161
- return { content: [{ type: "text", text: "Todos cleared." }] };
156
+ return ok("Todos cleared.");
162
157
 
163
158
  default:
164
- return {
165
- content: [
166
- {
167
- type: "text",
168
- text: `Unknown action: ${String(params.action)}`,
169
- },
170
- ],
171
- isError: true,
172
- };
159
+ return fail(`Unknown action: ${String(params.action)}`);
173
160
  }
174
161
  },
175
162
  });
@@ -6,9 +6,9 @@ import registerToolbox, {
6
6
  buildRows,
7
7
  parseTargets,
8
8
  renderList,
9
- toggleTool,
10
- type ToolRow,
11
9
  type ToggleOps,
10
+ type ToolRow,
11
+ toggleTool,
12
12
  } from "./toolbox.ts";
13
13
 
14
14
  // ─── Fixtures ───────────────────────────────────────────────────────────────
@@ -18,6 +18,7 @@ import { dirname, join } from "node:path";
18
18
  import type {
19
19
  ExtensionAPI,
20
20
  ExtensionContext,
21
+ Theme,
21
22
  ToolInfo,
22
23
  } from "@earendil-works/pi-coding-agent";
23
24
  import { DynamicBorder, getAgentDir } from "@earendil-works/pi-coding-agent";
@@ -27,10 +28,12 @@ import {
27
28
  fuzzyFilter,
28
29
  Input,
29
30
  Key,
31
+ type KeybindingsManager,
30
32
  matchesKey,
31
33
  type SelectItem,
32
34
  SelectList,
33
35
  Text,
36
+ type TUI,
34
37
  visibleWidth,
35
38
  } from "@earendil-works/pi-tui";
36
39
 
@@ -339,8 +342,12 @@ export default function registerToolbox(pi: ExtensionAPI): void {
339
342
  };
340
343
  }): Promise<void> {
341
344
  await ctx.ui.custom<null>(
342
- // biome-ignore lint: factory signature from ExtensionUIContext.custom
343
- (tui: any, theme: any, _kb: unknown, done: (r: null) => void) => {
345
+ (
346
+ tui: TUI,
347
+ theme: Theme,
348
+ _kb: KeybindingsManager,
349
+ done: (r: null) => void,
350
+ ) => {
344
351
  const accent = "accent";
345
352
  const mute = (s: string) => theme.fg("muted", s);
346
353
  const container = new Container();
@@ -9,7 +9,7 @@
9
9
  * Registers widget with id "pi-lens" to override external pi-lens widget.
10
10
  */
11
11
 
12
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
12
+ import type { ExtensionAPI, Theme } from "@earendil-works/pi-coding-agent";
13
13
  import { truncateToWidth } from "@earendil-works/pi-tui";
14
14
 
15
15
  // ─── Types ────────────────────────────────────────────────────────────────────
@@ -65,10 +65,7 @@ function recordFileTouched(filePath: string): void {
65
65
 
66
66
  // ─── Render ───────────────────────────────────────────────────────────────────
67
67
 
68
- function renderWidget(
69
- width: number,
70
- theme: { fg: (color: string, s: string) => string },
71
- ): string[] {
68
+ function renderWidget(width: number, theme: Theme): string[] {
72
69
  const w = Math.max(1, width || 80);
73
70
 
74
71
  const cyan = (s: string) => theme.fg("accent", s);
@@ -117,7 +114,7 @@ export default function (pi: ExtensionAPI) {
117
114
  if (!ctx.ui.setWidget) return;
118
115
  ctx.ui.setWidget(
119
116
  "pi-lens",
120
- (tui: { requestRender(): void }, theme: any) => {
117
+ (tui, theme: Theme) => {
121
118
  requestRenderFn = () => tui.requestRender();
122
119
  return {
123
120
  render: (width: number) => renderWidget(width, theme),
package/src/ui/footer.ts CHANGED
@@ -12,10 +12,7 @@
12
12
  */
13
13
 
14
14
  import { execFile } from "node:child_process";
15
- import { join } from "node:path";
16
15
  import { promisify } from "node:util";
17
- import { lookupModelsDev, lookupBenchmark } from "../lib/data";
18
- import type { ModelsDevModel } from "../lib/data";
19
16
  import type {
20
17
  AssistantMessage,
21
18
  AssistantMessageEvent,
@@ -25,6 +22,8 @@ import type {
25
22
  ReadonlyFooterDataProvider,
26
23
  } from "@earendil-works/pi-coding-agent";
27
24
  import { truncateToWidth } from "@earendil-works/pi-tui";
25
+ import type { ModelsDevModel } from "../lib/data";
26
+ import { lookupBenchmark, lookupModelsDev } from "../lib/data";
28
27
 
29
28
  // ─── Pure formatting helpers ─────────────────────────────────────────
30
29
 
@@ -197,7 +196,7 @@ function renderBranch(
197
196
 
198
197
  /** "<modelId> [· thinking] [· ctxK · $in/$out]" */
199
198
  function renderModel(
200
- model: { id?: string; provider?: string } | undefined,
199
+ model: { id?: string; provider?: string; name?: string } | undefined,
201
200
  thinking: string,
202
201
  theme: Theme,
203
202
  ): string {
@@ -1,14 +1,14 @@
1
- import { describe, it, expect } from "bun:test";
1
+ import { describe, expect, it } from "bun:test";
2
2
  import {
3
- shortCwd,
4
- statusIcon,
5
- renderCheck,
3
+ type CheckResult,
4
+ LABEL_WIDTH,
6
5
  LOGO_ROWS,
7
6
  PI_IGNORE_RULES,
8
- LABEL_WIDTH,
7
+ renderCheck,
8
+ shortCwd,
9
+ statusIcon,
9
10
  summariseTools,
10
11
  type Theme,
11
- type CheckResult,
12
12
  } from "./welcome.ts";
13
13
 
14
14
  const theme: Theme = {
package/src/ui/welcome.ts CHANGED
@@ -27,7 +27,10 @@
27
27
  * ✓ Ignore up to date
28
28
  */
29
29
 
30
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
30
+ import type {
31
+ ExtensionAPI,
32
+ ExtensionContext,
33
+ } from "@earendil-works/pi-coding-agent";
31
34
 
32
35
  // ─── Theme shim (same pattern as footer.ts) ───────────────────────────────────
33
36
 
@@ -283,7 +286,7 @@ export default function (pi: ExtensionAPI) {
283
286
  let dismissed = false;
284
287
  let requestRender: (() => void) | null = null;
285
288
 
286
- const dismiss = (ctx: any) => {
289
+ const dismiss = (ctx: ExtensionContext) => {
287
290
  if (dismissed) return;
288
291
  dismissed = true;
289
292
  requestRender = null;