@trim21/personal-pi-extensions 0.0.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,485 @@
1
+ /**
2
+ * Enhanced Read Tool Extension
3
+ *
4
+ * Overrides the built-in `read` tool with additional features inspired by
5
+ * opencode's read implementation:
6
+ *
7
+ * - Directory listing: When the path is a directory, lists its entries
8
+ * with "/" suffix for directories.
9
+ * - "Did you mean?" suggestions: When a file is not found, searches the
10
+ * parent directory for similarly-named files.
11
+ * - Binary file detection: Rejects binary files by extension and content
12
+ * sampling before handing them to the LLM.
13
+ * - Structured output: Uses <path>, <type>, <content>/<entries> XML tags
14
+ * to help the LLM parse output.
15
+ * - Image support: Detects and serves images as base64 attachments.
16
+ *
17
+ * Install:
18
+ * cp enhanced-read.ts ~/.pi/agent/extensions/
19
+ *
20
+ * Or for project-local:
21
+ * cp enhanced-read.ts .pi/extensions/
22
+ */
23
+
24
+ import { constants } from "node:fs";
25
+ import { access, open, readdir, readFile, stat } from "node:fs/promises";
26
+ import { basename, dirname, isAbsolute, resolve as resolvePath, sep } from "node:path";
27
+ import type { ImageContent, TextContent } from "@earendil-works/pi-ai";
28
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
29
+ import { Type } from "typebox";
30
+
31
+ const DEFAULT_MAX_LINES = 2000;
32
+ const DEFAULT_MAX_BYTES = 50 * 1024;
33
+ const SAMPLE_BYTES = 4096;
34
+
35
+ const SUPPORTED_IMAGE_MIMES = new Set([
36
+ "image/jpeg",
37
+ "image/png",
38
+ "image/gif",
39
+ "image/webp",
40
+ "image/bmp",
41
+ ]);
42
+
43
+ const BINARY_EXTENSIONS = new Set([
44
+ ".zip",
45
+ ".tar",
46
+ ".gz",
47
+ ".exe",
48
+ ".dll",
49
+ ".so",
50
+ ".class",
51
+ ".jar",
52
+ ".war",
53
+ ".7z",
54
+ ".doc",
55
+ ".docx",
56
+ ".xls",
57
+ ".xlsx",
58
+ ".ppt",
59
+ ".pptx",
60
+ ".odt",
61
+ ".ods",
62
+ ".odp",
63
+ ".bin",
64
+ ".dat",
65
+ ".obj",
66
+ ".o",
67
+ ".a",
68
+ ".lib",
69
+ ".wasm",
70
+ ".pyc",
71
+ ".pyo",
72
+ ]);
73
+
74
+ const IMAGE_SIGNATURES: Array<{
75
+ signature: Uint8Array | ((buf: Uint8Array) => boolean);
76
+ mimeType: string;
77
+ }> = [
78
+ { signature: new Uint8Array([0xff, 0xd8, 0xff]), mimeType: "image/jpeg" },
79
+ {
80
+ signature: new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
81
+ mimeType: "image/png",
82
+ },
83
+ {
84
+ signature(buf) {
85
+ return startsWithAscii(buf, 0, "GIF");
86
+ },
87
+ mimeType: "image/gif",
88
+ },
89
+ {
90
+ signature(buf) {
91
+ return startsWithAscii(buf, 0, "RIFF") && startsWithAscii(buf, 8, "WEBP");
92
+ },
93
+ mimeType: "image/webp",
94
+ },
95
+ {
96
+ signature(buf) {
97
+ return (
98
+ startsWithAscii(buf, 0, "BM") &&
99
+ buf.length >= 30 &&
100
+ (buf[28] ?? 0) === 1 &&
101
+ [1, 4, 8, 16, 24, 32].includes(buf[28 + 1] ?? 0)
102
+ );
103
+ },
104
+ mimeType: "image/bmp",
105
+ },
106
+ ];
107
+
108
+ function startsWithAscii(buf: Uint8Array, offset: number, text: string): boolean {
109
+ if (buf.length < offset + text.length) return false;
110
+ for (let i = 0; i < text.length; i++) {
111
+ if (buf[offset + i] !== text.charCodeAt(i)) return false;
112
+ }
113
+ return true;
114
+ }
115
+
116
+ function detectImageMimeType(buffer: Uint8Array): string | null {
117
+ for (const { signature, mimeType } of IMAGE_SIGNATURES) {
118
+ if (typeof signature === "function" ? signature(buffer) : startsWith(buffer, signature)) {
119
+ return mimeType;
120
+ }
121
+ }
122
+ return null;
123
+ }
124
+
125
+ function startsWith(buffer: Uint8Array, bytes: Uint8Array): boolean {
126
+ if (buffer.length < bytes.length) return false;
127
+ return bytes.every((b, i) => buffer[i] === b);
128
+ }
129
+
130
+ async function detectImageMimeTypeFromFile(filePath: string): Promise<string | null> {
131
+ try {
132
+ const fileHandle = await open(filePath, "r");
133
+ try {
134
+ const buf = Buffer.alloc(SAMPLE_BYTES);
135
+ const { bytesRead } = await fileHandle.read(buf, 0, SAMPLE_BYTES, 0);
136
+ return detectImageMimeType(buf.subarray(0, bytesRead));
137
+ } finally {
138
+ await fileHandle.close();
139
+ }
140
+ } catch {
141
+ return null;
142
+ }
143
+ }
144
+
145
+ function isBinaryExtension(filePath: string): boolean {
146
+ const dotIndex = filePath.lastIndexOf(".");
147
+ if (dotIndex < 0) return false;
148
+ return BINARY_EXTENSIONS.has(filePath.slice(dotIndex).toLowerCase());
149
+ }
150
+
151
+ function isBinaryFileBySample(sample: Uint8Array): boolean {
152
+ if (sample.length === 0) return false;
153
+ let nonPrintableCount = 0;
154
+ for (const byte of sample) {
155
+ if (byte === 0) return true;
156
+ if (byte < 9 || (byte > 13 && byte < 32)) nonPrintableCount++;
157
+ }
158
+ return nonPrintableCount / sample.length > 0.3;
159
+ }
160
+
161
+ function formatSize(bytes: number): string {
162
+ if (bytes < 1024) return `${bytes}B`;
163
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
164
+ return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
165
+ }
166
+
167
+ interface TruncationResult {
168
+ content: string;
169
+ truncated: boolean;
170
+ truncatedBy: "lines" | "bytes" | null;
171
+ totalLines: number;
172
+ totalBytes: number;
173
+ outputLines: number;
174
+ outputBytes: number;
175
+ lastLinePartial: boolean;
176
+ firstLineExceedsLimit: boolean;
177
+ maxLines: number;
178
+ maxBytes: number;
179
+ }
180
+
181
+ function truncateHead(
182
+ content: string,
183
+ maxLines: number = DEFAULT_MAX_LINES,
184
+ maxBytes: number = DEFAULT_MAX_BYTES,
185
+ ): TruncationResult {
186
+ const lines = content ? content.split("\n") : [];
187
+ if (content.endsWith("\n")) lines.pop();
188
+ const totalLines = lines.length;
189
+ const totalBytes = Buffer.byteLength(content, "utf-8");
190
+
191
+ if (totalLines <= maxLines && totalBytes <= maxBytes) {
192
+ return {
193
+ content,
194
+ truncated: false,
195
+ truncatedBy: null,
196
+ totalLines,
197
+ totalBytes,
198
+ outputLines: totalLines,
199
+ outputBytes: totalBytes,
200
+ lastLinePartial: false,
201
+ firstLineExceedsLimit: false,
202
+ maxLines,
203
+ maxBytes,
204
+ };
205
+ }
206
+
207
+ const firstLineBytes = lines.length > 0 ? Buffer.byteLength(lines[0], "utf-8") : 0;
208
+ if (firstLineBytes > maxBytes) {
209
+ return {
210
+ content: "",
211
+ truncated: true,
212
+ truncatedBy: "bytes",
213
+ totalLines,
214
+ totalBytes,
215
+ outputLines: 0,
216
+ outputBytes: 0,
217
+ lastLinePartial: false,
218
+ firstLineExceedsLimit: true,
219
+ maxLines,
220
+ maxBytes,
221
+ };
222
+ }
223
+
224
+ const outputLinesArr: string[] = [];
225
+ let outputBytesCount = 0;
226
+ let truncatedBy: "lines" | "bytes" = "lines";
227
+
228
+ for (let i = 0; i < lines.length && i < maxLines; i++) {
229
+ const lineBytes = Buffer.byteLength(lines[i], "utf-8") + (i > 0 ? 1 : 0);
230
+ if (outputBytesCount + lineBytes > maxBytes) {
231
+ truncatedBy = "bytes";
232
+ break;
233
+ }
234
+ outputLinesArr.push(lines[i]);
235
+ outputBytesCount += lineBytes;
236
+ }
237
+
238
+ if (outputLinesArr.length >= maxLines && outputBytesCount <= maxBytes) {
239
+ truncatedBy = "lines";
240
+ }
241
+
242
+ const outputContent = outputLinesArr.join("\n");
243
+ return {
244
+ content: outputContent,
245
+ truncated: true,
246
+ truncatedBy,
247
+ totalLines,
248
+ totalBytes,
249
+ outputLines: outputLinesArr.length,
250
+ outputBytes: Buffer.byteLength(outputContent, "utf-8"),
251
+ lastLinePartial: false,
252
+ firstLineExceedsLimit: false,
253
+ maxLines,
254
+ maxBytes,
255
+ };
256
+ }
257
+
258
+ async function didYouMean(filePath: string): Promise<string> {
259
+ const dir = dirname(filePath);
260
+ const base = basename(filePath);
261
+
262
+ let items: string[];
263
+ try {
264
+ items = await readdir(dir);
265
+ } catch {
266
+ return "";
267
+ }
268
+
269
+ const candidates = items
270
+ .filter(
271
+ (item) =>
272
+ item.toLowerCase().includes(base.toLowerCase()) ||
273
+ base.toLowerCase().includes(item.toLowerCase()),
274
+ )
275
+ .slice(0, 3)
276
+ .map((item) => `${dir}${sep}${item}`);
277
+
278
+ if (candidates.length > 0) {
279
+ return `\n\nDid you mean one of these?\n${candidates.join("\n")}`;
280
+ }
281
+ return "";
282
+ }
283
+
284
+ async function formatDirectoryEntries(dirPath: string): Promise<string[]> {
285
+ const items = await readdir(dirPath);
286
+ const results: string[] = [];
287
+
288
+ for (const item of items) {
289
+ let isDir = false;
290
+ try {
291
+ const s = await stat(`${dirPath}${sep}${item}`);
292
+ isDir = s.isDirectory();
293
+ } catch {
294
+ // Use name as-is if stat fails
295
+ }
296
+ results.push(item + (isDir ? "/" : ""));
297
+ }
298
+
299
+ results.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
300
+ return results;
301
+ }
302
+
303
+ export default function (pi: ExtensionAPI) {
304
+ pi.registerTool({
305
+ name: "read",
306
+ label: "read",
307
+ description: `Read the contents of a file. Supports text files and images (jpg, png, gif, webp). Images are sent as attachments. For text files, output is truncated to ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). Use offset/limit for large files. When you need the full file, continue with offset until complete.`,
308
+ promptSnippet: "Read file contents",
309
+ promptGuidelines: ["Use read to examine files instead of cat or sed."],
310
+ parameters: Type.Object({
311
+ filePath: Type.String({ description: "The absolute path to the file or directory to read" }),
312
+ offset: Type.Optional(
313
+ Type.Number({ description: "The line number to start reading from (1-indexed)" }),
314
+ ),
315
+ limit: Type.Optional(
316
+ Type.Number({ description: "The maximum number of lines to read (defaults to 2000)" }),
317
+ ),
318
+ }),
319
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
320
+ const {
321
+ filePath: rawPath,
322
+ offset,
323
+ limit,
324
+ } = params as { filePath: string; offset?: number; limit?: number };
325
+
326
+ const absolutePath = isAbsolute(rawPath) ? rawPath : resolvePath(ctx.cwd, rawPath);
327
+
328
+ if (signal?.aborted) {
329
+ throw new Error("Operation aborted");
330
+ }
331
+
332
+ // Check if path exists
333
+ let fileStat: Awaited<ReturnType<typeof stat>>;
334
+ try {
335
+ fileStat = await stat(absolutePath);
336
+ } catch {
337
+ const suggestion = await didYouMean(absolutePath);
338
+ return {
339
+ content: [
340
+ { type: "text", text: `File not found: ${absolutePath}${suggestion}` },
341
+ ] as TextContent[],
342
+ details: undefined,
343
+ };
344
+ }
345
+
346
+ // --- Directory listing ---
347
+ if (fileStat.isDirectory()) {
348
+ const entries = await formatDirectoryEntries(absolutePath);
349
+ const limitVal = limit ?? DEFAULT_MAX_LINES;
350
+ const offsetVal = offset ?? 1;
351
+ const start = offsetVal <= 0 ? 0 : offsetVal - 1;
352
+ const sliced = entries.slice(start, start + limitVal);
353
+ const totalEntries = entries.length;
354
+ const truncated = start + sliced.length < totalEntries;
355
+
356
+ let output = `<path>${absolutePath}</path>\n`;
357
+ output += `<type>directory</type>\n`;
358
+ output += `<entries>\n`;
359
+ output += sliced.join("\n");
360
+ if (truncated) {
361
+ const next = offsetVal + sliced.length;
362
+ output += `\n(Showing ${sliced.length} of ${totalEntries} entries. Use offset=${next} to continue.)`;
363
+ } else {
364
+ output += `\n(${totalEntries} entries)`;
365
+ }
366
+ output += `\n</entries>`;
367
+
368
+ return {
369
+ content: [{ type: "text", text: output }] as TextContent[],
370
+ details: undefined,
371
+ };
372
+ }
373
+
374
+ // --- File read ---
375
+ let content: (TextContent | ImageContent)[];
376
+ let details: { truncation?: TruncationResult } | undefined;
377
+
378
+ // Check accessibility
379
+ try {
380
+ await access(absolutePath, constants.R_OK);
381
+ } catch {
382
+ return {
383
+ content: [{ type: "text", text: `File not readable: ${absolutePath}` }] as TextContent[],
384
+ details: undefined,
385
+ };
386
+ }
387
+
388
+ // Check for images
389
+ const mimeType = await detectImageMimeTypeFromFile(absolutePath);
390
+ if (mimeType && SUPPORTED_IMAGE_MIMES.has(mimeType)) {
391
+ const buffer = await readFile(absolutePath);
392
+ const base64 = buffer.toString("base64");
393
+ content = [
394
+ { type: "text", text: `[Image: ${mimeType}, ${formatSize(buffer.length)}]` },
395
+ { type: "image", data: base64, mimeType } as ImageContent,
396
+ ];
397
+ return { content, details: undefined };
398
+ }
399
+
400
+ // Read text content
401
+ const buffer = await readFile(absolutePath);
402
+ const sample = buffer.subarray(0, SAMPLE_BYTES);
403
+
404
+ // Binary file detection
405
+ if (isBinaryExtension(absolutePath) || isBinaryFileBySample(sample)) {
406
+ return {
407
+ content: [
408
+ { type: "text", text: `Cannot read binary file: ${absolutePath}` },
409
+ ] as TextContent[],
410
+ details: undefined,
411
+ };
412
+ }
413
+
414
+ const textContent = buffer.toString("utf-8");
415
+ const allLines = textContent.split("\n");
416
+ const totalFileLines = allLines.length;
417
+
418
+ // Apply offset
419
+ const startLine = offset ? Math.max(0, offset - 1) : 0;
420
+ const startLineDisplay = startLine + 1;
421
+
422
+ if (startLine >= allLines.length) {
423
+ return {
424
+ content: [
425
+ {
426
+ type: "text",
427
+ text: `Offset ${offset} is beyond end of file (${allLines.length} lines total)`,
428
+ },
429
+ ] as TextContent[],
430
+ details: undefined,
431
+ };
432
+ }
433
+
434
+ // Apply user-specified limit or default truncation
435
+ let selectedContent: string;
436
+ let userLimitedLines: number | undefined;
437
+
438
+ if (limit !== undefined) {
439
+ const endLine = Math.min(startLine + limit, allLines.length);
440
+ selectedContent = allLines.slice(startLine, endLine).join("\n");
441
+ userLimitedLines = endLine - startLine;
442
+ } else {
443
+ selectedContent = allLines.slice(startLine).join("\n");
444
+ }
445
+
446
+ // Apply byte/line truncation
447
+ const truncation = truncateHead(selectedContent);
448
+ let outputText: string;
449
+
450
+ const endLineDisplay = startLineDisplay + truncation.outputLines - 1;
451
+
452
+ if (truncation.firstLineExceedsLimit) {
453
+ const firstLineSize = formatSize(Buffer.byteLength(allLines[startLine], "utf-8"));
454
+ outputText = `<path>${absolutePath}</path>\n<type>file</type>\n`;
455
+ outputText += `[Line ${startLineDisplay} is ${firstLineSize}, exceeds ${formatSize(DEFAULT_MAX_BYTES)} limit. Use bash to read this line.]`;
456
+ details = { truncation };
457
+ } else {
458
+ const header = `<path>${absolutePath}</path>\n<type>file</type>\n<content>\n`;
459
+ const footer = "\n</content>";
460
+ if (truncation.truncated) {
461
+ const nextOffset = endLineDisplay + 1;
462
+ if (truncation.truncatedBy === "lines") {
463
+ outputText = `${header}${truncation.content}\n\n(Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines}. Use offset=${nextOffset} to continue.)${footer}`;
464
+ } else {
465
+ outputText = `${header}${truncation.content}\n\n(Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines} (${formatSize(DEFAULT_MAX_BYTES)} limit). Use offset=${nextOffset} to continue.)${footer}`;
466
+ }
467
+ details = { truncation };
468
+ } else if (
469
+ userLimitedLines !== undefined &&
470
+ startLine + userLimitedLines < allLines.length
471
+ ) {
472
+ const remaining = allLines.length - (startLine + userLimitedLines);
473
+ const nextOffset = startLine + userLimitedLines + 1;
474
+ outputText = `${header}${truncation.content}\n\n(${remaining} more lines in file. Use offset=${nextOffset} to continue.)${footer}`;
475
+ } else {
476
+ outputText = `${header}${truncation.content}\n\n(End of file - total ${totalFileLines} lines)${footer}`;
477
+ }
478
+ }
479
+
480
+ content = [{ type: "text", text: outputText }] as TextContent[];
481
+
482
+ return { content, details };
483
+ },
484
+ });
485
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Enhanced Write Tool Extension
3
+ *
4
+ * Overrides the built-in `write` tool with opencode-compatible parameter names.
5
+ *
6
+ * - Uses `filePath` (opencode) instead of `path` (pi built-in)
7
+ * - Creates parent directories automatically
8
+ * - Serialises writes to the same file via mutation queue
9
+ *
10
+ * Install:
11
+ * cp enhanced-write.ts ~/.pi/agent/extensions/
12
+ *
13
+ * Or for project-local:
14
+ * cp enhanced-write.ts .pi/extensions/
15
+ */
16
+
17
+ import { mkdir, writeFile } from "node:fs/promises";
18
+ import { dirname, resolve as resolvePath } from "node:path";
19
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
20
+ import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
21
+ import { Type } from "typebox";
22
+
23
+ export default function (pi: ExtensionAPI) {
24
+ pi.registerTool({
25
+ name: "write",
26
+ label: "write",
27
+ description:
28
+ "Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.",
29
+ promptSnippet: "Create or overwrite files",
30
+ promptGuidelines: ["Use write only for new files or complete rewrites."],
31
+ parameters: Type.Object({
32
+ filePath: Type.String({
33
+ description: "The absolute path to the file to write (must be absolute, not relative)",
34
+ }),
35
+ content: Type.String({ description: "The content to write to the file" }),
36
+ }),
37
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
38
+ const { filePath: rawPath, content } = params as { filePath: string; content: string };
39
+ const absolutePath = resolvePath(ctx.cwd, rawPath);
40
+ const dir = dirname(absolutePath);
41
+
42
+ return withFileMutationQueue(absolutePath, async () => {
43
+ const throwIfAborted = () => {
44
+ if (signal?.aborted) throw new Error("Operation aborted");
45
+ };
46
+
47
+ throwIfAborted();
48
+ await mkdir(dir, { recursive: true });
49
+ throwIfAborted();
50
+ await writeFile(absolutePath, content, "utf-8");
51
+ throwIfAborted();
52
+
53
+ return {
54
+ content: [{ type: "text", text: `Wrote file successfully: ${absolutePath}` }] as Array<{
55
+ type: "text";
56
+ text: string;
57
+ }>,
58
+ details: undefined,
59
+ };
60
+ });
61
+ },
62
+ });
63
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Todo Pendant Extension
3
+ *
4
+ * Intercepts `todo` tool results and renders the task list as a widget
5
+ * above the editor in Pendant's UI. Compatible with pi's built-in todo
6
+ * tool's four-status task model (pending / in_progress / completed / deleted).
7
+ *
8
+ * Usage:
9
+ * pi -e src/todo-pendant.ts
10
+ */
11
+
12
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Types matching pi's built-in todo tool TaskDetails
16
+ // ---------------------------------------------------------------------------
17
+
18
+ type TaskStatus = "pending" | "in_progress" | "completed" | "deleted";
19
+
20
+ interface Task {
21
+ id: number;
22
+ subject: string;
23
+ description?: string;
24
+ activeForm?: string;
25
+ status: TaskStatus;
26
+ blockedBy?: number[];
27
+ owner?: string;
28
+ metadata?: Record<string, unknown>;
29
+ }
30
+
31
+ interface TaskDetails {
32
+ action: string;
33
+ params: Record<string, unknown>;
34
+ tasks: Task[];
35
+ nextId: number;
36
+ error?: string;
37
+ }
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // Helpers
41
+ // ---------------------------------------------------------------------------
42
+
43
+ const STATUS_MARK = {
44
+ pending: " ",
45
+ in_progress: " ",
46
+ deleted: " ",
47
+ completed: "x",
48
+ } as const;
49
+
50
+ function formatTaskLine(t: Task): string {
51
+ const mark = STATUS_MARK[t.status];
52
+ const form = t.status === "in_progress" && t.activeForm ? ` (${t.activeForm})` : "";
53
+ let line = `- [${mark}] #${t.id} ${t.subject}${form}`;
54
+ if (t.blockedBy?.length) {
55
+ line += ` ⛓ ${t.blockedBy.map((id) => `#${id}`).join(", ")}`;
56
+ }
57
+ return line;
58
+ }
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // Extension
62
+ // ---------------------------------------------------------------------------
63
+
64
+ export default function (pi: ExtensionAPI) {
65
+ pi.on("tool_result", (event, ctx) => {
66
+ if (event.toolName !== "todo") return;
67
+
68
+ const details = event.details as TaskDetails | undefined;
69
+ if (!details?.tasks?.length) {
70
+ ctx.ui.setWidget("todo-pendant", undefined);
71
+ return;
72
+ }
73
+
74
+ const visible = details.tasks.filter((t) => t.status !== "deleted");
75
+ if (visible.length === 0) {
76
+ ctx.ui.setWidget("todo-pendant", undefined);
77
+ return;
78
+ }
79
+
80
+ ctx.ui.setWidget("todo-pendant", [...visible.map(formatTaskLine)]);
81
+ });
82
+ }