opencode-summary 0.0.0-stage → 0.1.1

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/tui.tsx ADDED
@@ -0,0 +1,558 @@
1
+ import { Plugin } from "@opencode/plugin/tui"
2
+ import { SyntaxStyle, TextAttributes, type ScrollBoxRenderable, type TextareaRenderable } from "@opentui/core"
3
+ import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
4
+ import { createMemo, createResource, createSignal, For, onCleanup, onMount, Show } from "solid-js"
5
+ import { spawn } from "node:child_process"
6
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises"
7
+ import { homedir } from "node:os"
8
+ import path from "node:path"
9
+ import * as lib from "./lib"
10
+ import { GUARD } from "./prompt"
11
+
12
+ const INSET = 3
13
+ const paths = lib.promptPaths(process.env, homedir())
14
+ type Mode = "summary" | "prompt" | "edit"
15
+
16
+ export default Plugin.define({
17
+ id: "opencode-summary",
18
+ setup(context) {
19
+ const { client, ui } = context
20
+ const theme = () => context.theme.surface("dialog")
21
+ const syntax = SyntaxStyle.fromStyles(
22
+ {
23
+ default: { fg: context.theme.markdown.text },
24
+ "markup.heading": { fg: context.theme.markdown.heading, bold: true },
25
+ "markup.strong": { fg: context.theme.markdown.strong, bold: true },
26
+ "markup.italic": { fg: context.theme.markdown.emphasis, italic: true },
27
+ "markup.list": { fg: context.theme.markdown.listItem },
28
+ "markup.quote": { fg: context.theme.markdown.blockQuote, italic: true },
29
+ "markup.raw": { fg: context.theme.markdown.code },
30
+ "markup.link": { fg: context.theme.markdown.link, underline: true },
31
+ },
32
+ context.renderer.nativeScene,
33
+ )
34
+ const toastError = (cause: unknown) => ui.toast.show({ variant: "error", message: lib.errorMessage(cause) })
35
+
36
+ async function readMetadata(sessionID: string): Promise<lib.Metadata> {
37
+ return ((await client.session.get({ sessionID })).metadata ?? {}) as lib.Metadata
38
+ }
39
+
40
+ /** Read-merge-write; `undefined` removes the summary. */
41
+ async function writeSummary(sessionID: string, record: lib.SummaryRecord | undefined): Promise<void> {
42
+ const current = await readMetadata(sessionID)
43
+ const metadata = record ? lib.withSummary(current, record) : lib.withoutSummary(current)
44
+ await client.session.update({ sessionID, metadata: metadata as never })
45
+ }
46
+
47
+ async function loadSummary(sessionID: string): Promise<lib.SummaryRecord | undefined> {
48
+ const record = lib.readSummary(await readMetadata(sessionID))
49
+ if (!record) return undefined
50
+ const check: lib.ValidityCheck = await client.session.message
51
+ .get({ sessionID, messageID: record.lastMessageID })
52
+ .then(
53
+ () => ({ found: true }) as const,
54
+ (error: unknown) => ({ found: false, notFound: lib.isMessageNotFound(error) }) as const,
55
+ )
56
+ if (lib.validityAction(check) === "keep") return record
57
+ await writeSummary(sessionID, undefined)
58
+ return undefined
59
+ }
60
+
61
+ async function blocked(sessionID: string): Promise<lib.BlockReason | undefined> {
62
+ return lib.blockReason({
63
+ status: context.data.session.status(sessionID),
64
+ inFlight: inFlight(sessionID),
65
+ })
66
+ }
67
+
68
+ const [tick, setTick] = createSignal(0)
69
+ let pending: ReturnType<typeof setTimeout> | undefined
70
+ const bump = () => {
71
+ clearTimeout(pending)
72
+ pending = setTimeout(() => setTick((n) => n + 1), 150)
73
+ }
74
+ const stops = (
75
+ [
76
+ "session.inbox.delivered",
77
+ "session.metadata.updated",
78
+ "session.revert.committed",
79
+ "session.revert.staged",
80
+ "session.revert.cleared",
81
+ "session.execution.started",
82
+ "session.execution.succeeded",
83
+ "session.execution.failed",
84
+ "session.execution.interrupted",
85
+ ] as const
86
+ ).map((type) => context.data.on(type, bump))
87
+
88
+ const [flight, setFlight] = context.storage.memory("inflight", { initial: { sessions: [] as string[] } })
89
+ const inFlight = (sessionID: string) => flight.sessions.includes(sessionID)
90
+ const [modalFor, setModalFor] = createSignal<string>()
91
+
92
+ async function countTurns(sessionID: string, lastMessageID: string): Promise<lib.Turns> {
93
+ const page = await client.message.list({ sessionID, type: "user", order: "desc", limit: lib.TURN_LIMIT })
94
+ return lib.turnsSince(page.data.map((message) => message.id), lastMessageID)
95
+ }
96
+
97
+ async function loadView(sessionID: string) {
98
+ const record = await loadSummary(sessionID)
99
+ return record ? { record, turns: await countTurns(sessionID, record.lastMessageID) } : undefined
100
+ }
101
+
102
+ async function readOverride(): Promise<string | undefined> {
103
+ return readFile(paths.override, "utf8").catch((error: NodeJS.ErrnoException) =>
104
+ error.code === "ENOENT" ? undefined : Promise.reject(error),
105
+ )
106
+ }
107
+ const loadPrompt = async () => lib.resolvePrompt(await readOverride())
108
+
109
+ async function applyEdited(edited: string, previous: string | undefined): Promise<void> {
110
+ switch (lib.saveAction(edited)) {
111
+ case "noop":
112
+ if (previous === undefined) await rm(paths.override, { force: true })
113
+ else await writeFile(paths.override, previous)
114
+ return void ui.toast.show({ variant: "warning", message: "Empty prompt - not saved" })
115
+ case "delete":
116
+ await rm(paths.override, { force: true })
117
+ return void ui.toast.show({ message: "Prompt matches default - override removed" })
118
+ case "write":
119
+ await mkdir(path.dirname(paths.override), { recursive: true })
120
+ await writeFile(paths.override, edited.trim() + "\n")
121
+ return void ui.toast.show({ variant: "success", message: "Prompt saved" })
122
+ }
123
+ }
124
+
125
+ function spawnEditor(command: string, file: string): Promise<void> {
126
+ const [bin, ...args] = command.split(" ")
127
+ return new Promise((resolve, reject) => {
128
+ const child = spawn(bin!, [...args, file], { stdio: "inherit" })
129
+ child.on("error", reject)
130
+ child.on("exit", (code, signal) =>
131
+ code === 0 ? resolve() : reject(new Error(`Editor exited with ${signal ? `signal ${signal}` : `code ${code}`}`)),
132
+ )
133
+ })
134
+ }
135
+
136
+ const externalEditor = () => process.env.VISUAL || process.env.EDITOR
137
+
138
+ async function editWithEditor(sessionID: string, editor: string): Promise<void> {
139
+ const previous = await readOverride()
140
+ await mkdir(path.dirname(paths.override), { recursive: true })
141
+ if (previous === undefined) await writeFile(paths.override, (await loadPrompt()).text + "\n")
142
+ await context.renderer.suspend()
143
+ try {
144
+ await spawnEditor(editor, paths.override)
145
+ } catch (cause) {
146
+ toastError(cause)
147
+ return
148
+ } finally {
149
+ await context.renderer.resume()
150
+ }
151
+ await applyEdited((await readOverride()) ?? "", previous)
152
+ openModal(sessionID, "prompt")
153
+ }
154
+
155
+ async function resetPrompt(sessionID: string, reopen = true): Promise<void> {
156
+ const confirmed = await ui.dialog.confirm({
157
+ title: "Reset summary prompt?",
158
+ message: `Delete ${ui.format.path(paths.override)} and use the default prompt.`,
159
+ label: { confirm: "Reset", cancel: "Cancel" },
160
+ })
161
+ if (confirmed) {
162
+ await rm(paths.override, { force: true })
163
+ ui.toast.show({ message: "Prompt reset to default" })
164
+ }
165
+ if (reopen) openModal(sessionID, "prompt")
166
+ }
167
+
168
+ /** Returns true when a new record was written. */
169
+ async function generate(sessionID: string): Promise<boolean> {
170
+ const prompt = (await loadPrompt()).text
171
+ const [info, page] = await Promise.all([
172
+ client.session.get({ sessionID }),
173
+ client.message.list({ sessionID, order: "desc", limit: lib.TURN_LIMIT }),
174
+ ])
175
+ // A staged revert hides messages from revert.messageID on; cap lastMessageID at the boundary.
176
+ const lastMessageID = lib.lastVisibleID(page.data.map((message) => message.id), info.revert?.messageID)
177
+ if (!lastMessageID) {
178
+ ui.toast.show({ variant: "warning", message: "Nothing to summarize yet" })
179
+ return false
180
+ }
181
+ // No abort signal on session.generate: race a timeout so a hung call cannot keep the session "in flight" forever.
182
+ let timer: ReturnType<typeof setTimeout> | undefined
183
+ const timeout = new Promise<never>((_, reject) => {
184
+ timer = setTimeout(() => reject(new Error("Summary timed out")), lib.GENERATE_TIMEOUT_MS)
185
+ })
186
+ const result = await Promise.race([client.session.generate({ sessionID, prompt: lib.buildRequest(prompt) }), timeout])
187
+ .catch((cause: unknown) => (toastError(cause), undefined))
188
+ .finally(() => clearTimeout(timer))
189
+ if (!result) return false
190
+ if (lib.isBlank(result.text)) {
191
+ ui.toast.show({ variant: "error", message: "Summary came back empty" })
192
+ return false
193
+ }
194
+ await writeSummary(sessionID, {
195
+ text: result.text.trim(),
196
+ generatedAt: Date.now(),
197
+ promptId: lib.promptId(prompt),
198
+ lastMessageID,
199
+ })
200
+ return true
201
+ }
202
+
203
+ const [settings, setSettings] = context.storage.store("settings", { initial: { indicator: true } })
204
+
205
+ function copyText(text: string) {
206
+ if (!context.renderer.copyToClipboardOSC52(text))
207
+ ui.toast.show({ variant: "warning", message: "Copy not supported by this terminal" })
208
+ else ui.toast.show({ variant: "success", message: "Summary copied" })
209
+ }
210
+
211
+ function currentSession(): string | undefined {
212
+ const route = ui.router.current()
213
+ if (route.type === "session") return route.sessionID
214
+ ui.toast.show({ variant: "warning", message: "Open a session first" })
215
+ return undefined
216
+ }
217
+
218
+ async function deleteSummary(sessionID: string): Promise<void> {
219
+ if (!(await loadSummary(sessionID))) return void ui.toast.show({ message: "No summary to delete" })
220
+ const confirmed = await ui.dialog.confirm({
221
+ title: "Delete summary?",
222
+ message: "Remove this session's stored summary.",
223
+ label: { confirm: "Delete", cancel: "Cancel" },
224
+ })
225
+ if (!confirmed) return
226
+ await writeSummary(sessionID, undefined)
227
+ ui.toast.show({ message: "Summary deleted" })
228
+ bump()
229
+ }
230
+
231
+ async function startGenerate(sessionID: string): Promise<void> {
232
+ const reason = await blocked(sessionID)
233
+ if (reason) return void ui.toast.show({ variant: "warning", message: lib.blockMessage(reason) })
234
+ setFlight((draft) => void draft.sessions.push(sessionID))
235
+ try {
236
+ const written = await generate(sessionID)
237
+ if (written && modalFor() !== sessionID) ui.toast.show({ variant: "success", message: "Summary ready", sessionID })
238
+ } catch (cause) {
239
+ toastError(cause)
240
+ } finally {
241
+ setFlight((draft) => void (draft.sessions = draft.sessions.filter((id) => id !== sessionID)))
242
+ bump()
243
+ }
244
+ }
245
+
246
+ function Spinner(props: { label: string }) {
247
+ const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
248
+ const [i, setI] = createSignal(0)
249
+ const timer = setInterval(() => setI((n) => (n + 1) % frames.length), 80)
250
+ onCleanup(() => clearInterval(timer))
251
+ return <text fg={context.theme.hue.interactive[200]}>{`${frames[i()]} ${props.label}`}</text>
252
+ }
253
+
254
+ function FooterStatus(props: { sessionID?: string }) {
255
+ const [view] = createResource(
256
+ () => (props.sessionID ? ([props.sessionID, tick()] as const) : undefined),
257
+ ([sessionID]) => loadView(sessionID).catch(() => undefined),
258
+ )
259
+ return (
260
+ <Show when={props.sessionID}>
261
+ {(sessionID) => (
262
+ <Show when={!inFlight(sessionID())} fallback={<Spinner label="/summary" />}>
263
+ <Show when={settings.indicator && view()}>
264
+ {(current) => <text fg={context.theme.text.muted}>{lib.turnLabel(current().turns)}</text>}
265
+ </Show>
266
+ </Show>
267
+ )}
268
+ </Show>
269
+ )
270
+ }
271
+
272
+ ui.slot({ append: "prompt.footer.status", render: (props) => <FooterStatus sessionID={props.sessionID} /> })
273
+
274
+ function Hints(props: { items: [title: string, key: string][] }) {
275
+ return (
276
+ <box flexDirection="row" gap={2}>
277
+ <For each={props.items}>{([title, key]) => <text fg={theme().text.muted}>{`${title} ${key}`}</text>}</For>
278
+ </box>
279
+ )
280
+ }
281
+
282
+ function PromptEditor(props: { initial: string; height: number; onDone: (saved: boolean) => void }) {
283
+ const [target, setTarget] = createSignal<TextareaRenderable>()
284
+ let textarea: TextareaRenderable | undefined
285
+ const save = async () => {
286
+ const previous = await readOverride()
287
+ await applyEdited(textarea?.plainText ?? "", previous).then(() => props.onDone(true), toastError)
288
+ }
289
+ context.keymap.layer(() => ({
290
+ mode: "modal",
291
+ priority: 1,
292
+ target,
293
+ enabled: target() !== undefined,
294
+ commands: [
295
+ { bind: "ctrl+s", title: "Save prompt", group: "Dialog", run: () => void save() },
296
+ { bind: "escape", title: "Cancel edit", group: "Dialog", run: () => props.onDone(false) },
297
+ ],
298
+ }))
299
+ onMount(() => setTimeout(() => textarea && !textarea.isDestroyed && textarea.focus(), 1))
300
+ return (
301
+ <box gap={1}>
302
+ <text fg={theme().text.muted}>{`fixed: ${GUARD}`}</text>
303
+ <textarea
304
+ ref={(element: TextareaRenderable) => ((textarea = element), setTarget(element))}
305
+ initialValue={props.initial}
306
+ height={props.height}
307
+ wrapMode="word"
308
+ textColor={theme().text.formfield.base}
309
+ focusedTextColor={theme().text.formfield.base}
310
+ cursorColor={theme().text.base}
311
+ />
312
+ <Hints items={[["save", "ctrl+s"], ["cancel", "esc"]]} />
313
+ </box>
314
+ )
315
+ }
316
+
317
+ function SummaryDialog(props: { sessionID: string; mode: Mode }) {
318
+ const dimensions = useTerminalDimensions()
319
+ const maxHeight = createMemo(() => Math.max(8, Math.floor(dimensions().height * 0.6)))
320
+ const [mode, setMode] = createSignal<Mode>(props.mode)
321
+ const [view] = createResource(() => [props.sessionID, tick()] as const, ([id]) => loadView(id).catch(toastError))
322
+ const [reason] = createResource(() => [props.sessionID, tick()] as const, ([id]) => blocked(id).catch(() => undefined))
323
+ const [prompt, { refetch: refetchPrompt }] = createResource(() => [mode(), tick()] as const, () => loadPrompt())
324
+ const record = () => view()?.record
325
+ const busy = () => inFlight(props.sessionID)
326
+ const stale = () => view() && prompt() && view()!.record.promptId !== lib.promptId(prompt()!.text)
327
+ let scroll: ScrollBoxRenderable | undefined
328
+
329
+ const run = () => void startGenerate(props.sessionID)
330
+ const copy = () => {
331
+ const text = record()?.text
332
+ if (text) copyText(text)
333
+ }
334
+ const edit = () => {
335
+ const editor = externalEditor()
336
+ if (editor) void editWithEditor(props.sessionID, editor).catch(toastError)
337
+ else setMode("edit")
338
+ }
339
+
340
+ context.keymap.layer(() => ({
341
+ mode: "modal",
342
+ enabled: mode() !== "edit",
343
+ commands: [
344
+ { bind: "g", title: "Generate summary", group: "Dialog", run },
345
+ { bind: "v", title: "Toggle prompt view", group: "Dialog", run: () => setMode((m) => (m === "summary" ? "prompt" : "summary")) },
346
+ ],
347
+ }))
348
+ context.keymap.layer(() => ({
349
+ mode: "modal",
350
+ enabled: mode() === "summary",
351
+ commands: [{ bind: "c", title: "Copy summary", group: "Dialog", run: copy }],
352
+ }))
353
+ context.keymap.layer(() => ({
354
+ mode: "modal",
355
+ enabled: mode() === "prompt",
356
+ commands: [
357
+ { bind: "e", title: "Edit prompt", group: "Dialog", run: edit },
358
+ { bind: "r", title: "Reset prompt", group: "Dialog", run: () => void resetPrompt(props.sessionID).catch(toastError) },
359
+ ],
360
+ }))
361
+
362
+ useKeyboard((event) => {
363
+ if (!scroll || mode() === "edit") return
364
+ if (event.name === "up" || event.name === "k") return scroll.scrollBy(-1)
365
+ if (event.name === "down" || event.name === "j") return scroll.scrollBy(1)
366
+ if (event.name === "pageup") return scroll.scrollBy(-maxHeight())
367
+ if (event.name === "pagedown") return scroll.scrollBy(maxHeight())
368
+ if (event.name === "home") return scroll.scrollTo(0)
369
+ if (event.name === "end") return scroll.scrollTo(scroll.scrollHeight)
370
+ })
371
+
372
+ const summaryBody = () => (
373
+ <Show when={record()}>
374
+ {(current) => (
375
+ <scrollbox
376
+ ref={(element: ScrollBoxRenderable) => (scroll = element)}
377
+ maxHeight={maxHeight()}
378
+ scrollbarOptions={{ visible: false }}
379
+ >
380
+ <markdown content={current().text} syntaxStyle={syntax} conceal fg={theme().markdown.text} />
381
+ </scrollbox>
382
+ )}
383
+ </Show>
384
+ )
385
+
386
+ return (
387
+ <box paddingLeft={INSET} paddingRight={INSET} paddingBottom={1} gap={1}>
388
+ <box flexDirection="row" justifyContent="space-between">
389
+ <text attributes={TextAttributes.BOLD} fg={theme().text.base}>/summary</text>
390
+ <text fg={theme().text.muted}>esc</text>
391
+ </box>
392
+ <text fg={theme().text.muted}>
393
+ {busy() ? "Generating…" : view() ? lib.generatedLine(view()!.record, Date.now(), view()!.turns) : "No summary yet - press g to generate"}
394
+ </text>
395
+ <Show when={mode() === "edit" && prompt()}>
396
+ <PromptEditor
397
+ initial={prompt()!.text}
398
+ height={Math.max(6, maxHeight() - 4)}
399
+ onDone={(saved) => {
400
+ setMode("prompt")
401
+ if (saved) void refetchPrompt()
402
+ }}
403
+ />
404
+ </Show>
405
+ <Show when={mode() === "prompt"}>
406
+ <scrollbox ref={(element: ScrollBoxRenderable) => (scroll = element)} maxHeight={maxHeight()} scrollbarOptions={{ visible: false }}>
407
+ <box gap={1}>
408
+ <text fg={theme().text.muted}>{`fixed: ${GUARD}`}</text>
409
+ <text fg={theme().text.base}>{prompt()?.text ?? ""}</text>
410
+ <text fg={theme().text.muted}>
411
+ {prompt()?.source === "override" ? `source: override (${ui.format.path(paths.override)})` : "source: default (prompt.ts)"}
412
+ </text>
413
+ </box>
414
+ </scrollbox>
415
+ </Show>
416
+ <Show when={mode() === "summary"}>{summaryBody()}</Show>
417
+ <Show when={mode() === "summary" && stale()}>
418
+ <text fg={theme().text.muted}>Generated with a different prompt</text>
419
+ </Show>
420
+ <Show when={mode() !== "edit"}>
421
+ <Hints
422
+ items={[
423
+ [reason() ? `generate (${reason()})` : "generate", "g"],
424
+ [mode() === "summary" ? "prompt" : "summary", "v"],
425
+ ...(mode() === "prompt" ? ([["edit", "e"], ["reset", "r"]] as [string, string][]) : []),
426
+ ...(mode() === "summary" ? ([["copy", "c"]] as [string, string][]) : []),
427
+ ["scroll", "j/k"],
428
+ ]}
429
+ />
430
+ </Show>
431
+ </box>
432
+ )
433
+ }
434
+
435
+ function openModal(sessionID: string, mode: Mode = "summary") {
436
+ setModalFor(sessionID)
437
+ ui.dialog.show(() => <SummaryDialog sessionID={sessionID} mode={mode} />, () => setModalFor(undefined))
438
+ ui.dialog.set({ size: "large", centered: true })
439
+ }
440
+
441
+ ui.slot({
442
+ append: "app",
443
+ render() {
444
+ context.keymap.layer(() => ({
445
+ mode: "global",
446
+ commands: [
447
+ {
448
+ id: "summary.open",
449
+ title: "Open summary modal",
450
+ description: "View or generate a summary of this session",
451
+ group: "Summary",
452
+ palette: true,
453
+ slash: { name: "summary" },
454
+ run() {
455
+ const sessionID = currentSession()
456
+ if (sessionID) openModal(sessionID)
457
+ },
458
+ },
459
+ {
460
+ id: "summary.generate",
461
+ title: "Generate summary",
462
+ description: "Generate a summary without opening the modal",
463
+ group: "Summary",
464
+ palette: true,
465
+ run() {
466
+ const sessionID = currentSession()
467
+ if (sessionID) void startGenerate(sessionID)
468
+ },
469
+ },
470
+ {
471
+ id: "summary.copy",
472
+ title: "Copy summary",
473
+ description: "Copy the stored summary to the clipboard",
474
+ group: "Summary",
475
+ palette: true,
476
+ run() {
477
+ const sessionID = currentSession()
478
+ if (!sessionID) return
479
+ void loadSummary(sessionID).then(
480
+ (record) => (record ? copyText(record.text) : ui.toast.show({ message: "No summary yet" })),
481
+ toastError,
482
+ )
483
+ },
484
+ },
485
+ {
486
+ id: "summary.delete",
487
+ title: "Delete summary",
488
+ description: "Remove the stored summary of this session",
489
+ group: "Summary",
490
+ palette: true,
491
+ run() {
492
+ const sessionID = currentSession()
493
+ if (sessionID) void deleteSummary(sessionID).catch(toastError)
494
+ },
495
+ },
496
+ {
497
+ id: "summary.prompt.view",
498
+ title: "View summary prompt",
499
+ description: "Show the prompt used for summaries",
500
+ group: "Summary",
501
+ palette: true,
502
+ run() {
503
+ const sessionID = currentSession()
504
+ if (sessionID) openModal(sessionID, "prompt")
505
+ },
506
+ },
507
+ {
508
+ id: "summary.prompt.edit",
509
+ title: "Edit summary prompt",
510
+ description: "Edit the prompt used for summaries",
511
+ group: "Summary",
512
+ palette: true,
513
+ run() {
514
+ const sessionID = currentSession()
515
+ if (!sessionID) return
516
+ const editor = externalEditor()
517
+ if (editor) void editWithEditor(sessionID, editor).catch(toastError)
518
+ else openModal(sessionID, "edit")
519
+ },
520
+ },
521
+ {
522
+ id: "summary.prompt.reset",
523
+ title: "Reset summary prompt",
524
+ description: "Restore the default summary prompt",
525
+ group: "Summary",
526
+ palette: true,
527
+ run() {
528
+ const sessionID = currentSession()
529
+ if (sessionID) void resetPrompt(sessionID, false).catch(toastError)
530
+ },
531
+ },
532
+ {
533
+ id: "summary.indicator.toggle",
534
+ title: "Toggle summary footer indicator",
535
+ description: "Show or hide the turns-ago indicator in the prompt footer",
536
+ group: "Summary",
537
+ palette: true,
538
+ run() {
539
+ const next = !settings.indicator
540
+ void setSettings((draft) => void (draft.indicator = next)).then(
541
+ () => ui.toast.show({ message: next ? "Footer indicator shown" : "Footer indicator hidden" }),
542
+ toastError,
543
+ )
544
+ },
545
+ },
546
+ ],
547
+ }))
548
+ return null
549
+ },
550
+ })
551
+
552
+ return () => {
553
+ clearTimeout(pending)
554
+ stops.forEach((stop) => stop())
555
+ void context.renderer.idle().then(() => syntax.destroy())
556
+ }
557
+ },
558
+ })