omegon 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.
Files changed (160) hide show
  1. package/.gitattributes +3 -0
  2. package/AGENTS.md +16 -0
  3. package/LICENSE +15 -0
  4. package/README.md +289 -0
  5. package/bin/pi.mjs +30 -0
  6. package/extensions/00-secrets/index.ts +1126 -0
  7. package/extensions/01-auth/auth.ts +401 -0
  8. package/extensions/01-auth/index.ts +289 -0
  9. package/extensions/auto-compact.ts +42 -0
  10. package/extensions/bootstrap/deps.ts +291 -0
  11. package/extensions/bootstrap/index.ts +811 -0
  12. package/extensions/chronos/chronos.sh +487 -0
  13. package/extensions/chronos/index.ts +148 -0
  14. package/extensions/cleave/assessment.ts +754 -0
  15. package/extensions/cleave/bridge.ts +31 -0
  16. package/extensions/cleave/conflicts.ts +250 -0
  17. package/extensions/cleave/dispatcher.ts +808 -0
  18. package/extensions/cleave/guardrails.ts +426 -0
  19. package/extensions/cleave/index.ts +3121 -0
  20. package/extensions/cleave/lifecycle-emitter.ts +20 -0
  21. package/extensions/cleave/openspec.ts +811 -0
  22. package/extensions/cleave/planner.ts +260 -0
  23. package/extensions/cleave/review.ts +579 -0
  24. package/extensions/cleave/skills.ts +355 -0
  25. package/extensions/cleave/types.ts +261 -0
  26. package/extensions/cleave/workspace.ts +861 -0
  27. package/extensions/cleave/worktree.ts +243 -0
  28. package/extensions/core-renderers.ts +253 -0
  29. package/extensions/dashboard/context-gauge.ts +58 -0
  30. package/extensions/dashboard/file-watch.ts +14 -0
  31. package/extensions/dashboard/footer.ts +1145 -0
  32. package/extensions/dashboard/git.ts +185 -0
  33. package/extensions/dashboard/index.ts +478 -0
  34. package/extensions/dashboard/memory-audit.ts +34 -0
  35. package/extensions/dashboard/overlay-data.ts +705 -0
  36. package/extensions/dashboard/overlay.ts +365 -0
  37. package/extensions/dashboard/render-utils.ts +54 -0
  38. package/extensions/dashboard/types.ts +191 -0
  39. package/extensions/dashboard/uri-helper.ts +45 -0
  40. package/extensions/debug.ts +69 -0
  41. package/extensions/defaults.ts +282 -0
  42. package/extensions/design-tree/dashboard-state.ts +161 -0
  43. package/extensions/design-tree/design-card.ts +362 -0
  44. package/extensions/design-tree/index.ts +2130 -0
  45. package/extensions/design-tree/lifecycle-emitter.ts +41 -0
  46. package/extensions/design-tree/tree.ts +1607 -0
  47. package/extensions/design-tree/types.ts +163 -0
  48. package/extensions/distill.ts +127 -0
  49. package/extensions/effort/index.ts +395 -0
  50. package/extensions/effort/tiers.ts +146 -0
  51. package/extensions/effort/types.ts +105 -0
  52. package/extensions/lib/git-state.ts +227 -0
  53. package/extensions/lib/local-models.ts +157 -0
  54. package/extensions/lib/model-preferences.ts +51 -0
  55. package/extensions/lib/model-routing.ts +720 -0
  56. package/extensions/lib/operator-fallback.ts +205 -0
  57. package/extensions/lib/operator-profile.ts +360 -0
  58. package/extensions/lib/slash-command-bridge.ts +253 -0
  59. package/extensions/lib/typebox-helpers.ts +16 -0
  60. package/extensions/local-inference/index.ts +727 -0
  61. package/extensions/mcp-bridge/README.md +220 -0
  62. package/extensions/mcp-bridge/index.ts +951 -0
  63. package/extensions/mcp-bridge/lib.ts +365 -0
  64. package/extensions/mcp-bridge/mcp.json +3 -0
  65. package/extensions/mcp-bridge/package.json +11 -0
  66. package/extensions/model-budget.ts +752 -0
  67. package/extensions/offline-driver.ts +403 -0
  68. package/extensions/openspec/archive-gate.ts +164 -0
  69. package/extensions/openspec/branch-cleanup.ts +64 -0
  70. package/extensions/openspec/dashboard-state.ts +50 -0
  71. package/extensions/openspec/index.ts +1917 -0
  72. package/extensions/openspec/lifecycle-emitter.ts +65 -0
  73. package/extensions/openspec/lifecycle-files.ts +70 -0
  74. package/extensions/openspec/lifecycle.ts +50 -0
  75. package/extensions/openspec/reconcile.ts +187 -0
  76. package/extensions/openspec/spec.ts +1385 -0
  77. package/extensions/openspec/types.ts +98 -0
  78. package/extensions/project-memory/DESIGN-global-mind.md +198 -0
  79. package/extensions/project-memory/README.md +202 -0
  80. package/extensions/project-memory/api-types.ts +382 -0
  81. package/extensions/project-memory/compaction-policy.ts +29 -0
  82. package/extensions/project-memory/core.ts +164 -0
  83. package/extensions/project-memory/embeddings.ts +230 -0
  84. package/extensions/project-memory/extraction-v2.ts +861 -0
  85. package/extensions/project-memory/factstore.ts +2177 -0
  86. package/extensions/project-memory/index.ts +3459 -0
  87. package/extensions/project-memory/injection-metrics.ts +91 -0
  88. package/extensions/project-memory/jsonl-io.ts +12 -0
  89. package/extensions/project-memory/lifecycle.ts +331 -0
  90. package/extensions/project-memory/migration.ts +293 -0
  91. package/extensions/project-memory/package.json +9 -0
  92. package/extensions/project-memory/sci-renderers.ts +7 -0
  93. package/extensions/project-memory/template.ts +103 -0
  94. package/extensions/project-memory/triggers.ts +52 -0
  95. package/extensions/project-memory/types.ts +102 -0
  96. package/extensions/render/composition/fonts/Inter-Bold.ttf +0 -0
  97. package/extensions/render/composition/fonts/Inter-Regular.ttf +0 -0
  98. package/extensions/render/composition/fonts/Tomorrow-Bold.ttf +0 -0
  99. package/extensions/render/composition/fonts/Tomorrow-Regular.ttf +0 -0
  100. package/extensions/render/composition/package-lock.json +534 -0
  101. package/extensions/render/composition/package.json +22 -0
  102. package/extensions/render/composition/render.mjs +246 -0
  103. package/extensions/render/composition/test-comp.tsx +87 -0
  104. package/extensions/render/composition/types.ts +24 -0
  105. package/extensions/render/excalidraw/UPSTREAM.md +81 -0
  106. package/extensions/render/excalidraw/elements.ts +764 -0
  107. package/extensions/render/excalidraw/index.ts +66 -0
  108. package/extensions/render/excalidraw/types.ts +223 -0
  109. package/extensions/render/excalidraw-renderer/pyproject.toml +8 -0
  110. package/extensions/render/excalidraw-renderer/render_excalidraw.py +182 -0
  111. package/extensions/render/excalidraw-renderer/render_template.html +59 -0
  112. package/extensions/render/index.ts +830 -0
  113. package/extensions/render/native-diagrams/index.ts +57 -0
  114. package/extensions/render/native-diagrams/motifs.ts +542 -0
  115. package/extensions/render/native-diagrams/raster.ts +8 -0
  116. package/extensions/render/native-diagrams/scene.ts +75 -0
  117. package/extensions/render/native-diagrams/spec.ts +204 -0
  118. package/extensions/render/native-diagrams/svg.ts +116 -0
  119. package/extensions/sci-ui.ts +304 -0
  120. package/extensions/session-log.ts +174 -0
  121. package/extensions/shared-state.ts +146 -0
  122. package/extensions/spinner-verbs.ts +91 -0
  123. package/extensions/style.ts +281 -0
  124. package/extensions/terminal-title.ts +191 -0
  125. package/extensions/tool-profile/index.ts +291 -0
  126. package/extensions/tool-profile/profiles.ts +290 -0
  127. package/extensions/types.d.ts +9 -0
  128. package/extensions/vault/index.ts +185 -0
  129. package/extensions/version-check.ts +90 -0
  130. package/extensions/view/index.ts +859 -0
  131. package/extensions/view/uri-resolver.ts +148 -0
  132. package/extensions/web-search/index.ts +182 -0
  133. package/extensions/web-search/providers.ts +121 -0
  134. package/extensions/web-ui/index.ts +110 -0
  135. package/extensions/web-ui/server.ts +265 -0
  136. package/extensions/web-ui/state.ts +462 -0
  137. package/extensions/web-ui/static/index.html +145 -0
  138. package/extensions/web-ui/types.ts +284 -0
  139. package/package.json +76 -0
  140. package/prompts/init.md +75 -0
  141. package/prompts/new-repo.md +54 -0
  142. package/prompts/oci-login.md +56 -0
  143. package/prompts/status.md +50 -0
  144. package/settings.json +4 -0
  145. package/skills/cleave/SKILL.md +218 -0
  146. package/skills/git/SKILL.md +209 -0
  147. package/skills/git/_reference/ci-validation.md +204 -0
  148. package/skills/oci/SKILL.md +338 -0
  149. package/skills/openspec/SKILL.md +346 -0
  150. package/skills/pi-extensions/SKILL.md +191 -0
  151. package/skills/pi-tui/SKILL.md +517 -0
  152. package/skills/python/SKILL.md +189 -0
  153. package/skills/rust/SKILL.md +268 -0
  154. package/skills/security/SKILL.md +206 -0
  155. package/skills/style/SKILL.md +264 -0
  156. package/skills/typescript/SKILL.md +225 -0
  157. package/skills/vault/SKILL.md +102 -0
  158. package/themes/alpharius-legacy.json +85 -0
  159. package/themes/alpharius.conf +59 -0
  160. package/themes/alpharius.json +88 -0
@@ -0,0 +1,859 @@
1
+ /**
2
+ * /view and /edit — Inline file viewer and editor launcher for pi TUI
3
+ *
4
+ * /view renders files inline with syntax highlighting, image rendering, etc.
5
+ * /edit opens files in $EDITOR (vim, nvim, etc.)
6
+ *
7
+ * Supported formats:
8
+ * Images: jpg, jpeg, png, gif, webp, svg, bmp, tiff, ico, heic
9
+ * Documents: pdf, docx, xlsx, pptx, odt, epub, html, csv, tsv, rtf
10
+ * Diagrams: D2 (.d2)
11
+ * Data: json, yaml, xml, toml
12
+ * Text: md, txt, and any text file (syntax-highlighted)
13
+ *
14
+ * Dependencies:
15
+ * - poppler (pdftotext, pdftoppm) — PDF rendering
16
+ * - pandoc — document conversion
17
+ * - d2 — D2 diagram rendering
18
+ */
19
+
20
+ import { execSync, execFileSync, spawnSync } from "node:child_process";
21
+ // Note: execSync retained solely for hasCmd() which takes hardcoded strings only
22
+ import {
23
+ existsSync, readFileSync, statSync, mkdtempSync,
24
+ readdirSync, accessSync, constants,
25
+ } from "node:fs";
26
+ import { basename, extname, resolve, join, relative } from "node:path";
27
+ import { tmpdir } from "node:os";
28
+ import type { ExtensionAPI } from "@cwilson613/pi-coding-agent";
29
+ import { Type } from "@sinclair/typebox";
30
+ import { resolveUri, loadConfig, osc8Link } from "./uri-resolver.js";
31
+ import { getMdservePort } from "../vault/index.ts";
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Format classification
35
+ // ---------------------------------------------------------------------------
36
+
37
+ const IMAGE_EXTS = new Set([
38
+ ".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp",
39
+ ".tiff", ".tif", ".ico", ".heic", ".avif",
40
+ ]);
41
+ const SVG_EXTS = new Set([".svg"]);
42
+ const PDF_EXTS = new Set([".pdf"]);
43
+ const PANDOC_EXTS = new Set([
44
+ ".docx", ".xlsx", ".pptx", ".odt", ".epub",
45
+ ".html", ".htm", ".rtf", ".rst", ".textile",
46
+ ".mediawiki", ".org", ".opml", ".csv", ".tsv", ".bib",
47
+ ]);
48
+ const DIAGRAM_EXTS = new Set([".d2"]);
49
+ const DATA_EXTS = new Set([".json", ".yaml", ".yml", ".xml", ".toml"]);
50
+ const MARKDOWN_EXTS = new Set([".md", ".markdown", ".mdx"]);
51
+
52
+ // Files that should open in $EDITOR rather than inline view
53
+ const EDITABLE_EXTS = new Set([
54
+ ...DATA_EXTS, ...MARKDOWN_EXTS,
55
+ ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs",
56
+ ".py", ".rs", ".go", ".c", ".cpp", ".h", ".hpp",
57
+ ".java", ".kt", ".swift", ".rb", ".lua", ".zig",
58
+ ".sh", ".bash", ".zsh", ".fish",
59
+ ".sql", ".css", ".scss", ".less",
60
+ ".tf", ".hcl", ".nix", ".dhall",
61
+ ".txt", ".cfg", ".ini", ".conf", ".env",
62
+ ".makefile", ".dockerfile",
63
+ ]);
64
+
65
+ type FileKind = "image" | "svg" | "pdf" | "pandoc" | "diagram" | "data" | "markdown" | "text" | "binary";
66
+
67
+ function classifyFile(filePath: string): FileKind {
68
+ const ext = extname(filePath).toLowerCase();
69
+ if (IMAGE_EXTS.has(ext)) return "image";
70
+ if (SVG_EXTS.has(ext)) return "svg";
71
+ if (PDF_EXTS.has(ext)) return "pdf";
72
+ if (PANDOC_EXTS.has(ext)) return "pandoc";
73
+ if (DIAGRAM_EXTS.has(ext)) return "diagram";
74
+ if (DATA_EXTS.has(ext)) return "data";
75
+ if (MARKDOWN_EXTS.has(ext)) return "markdown";
76
+
77
+ // Check if file is text by reading first chunk
78
+ try {
79
+ const buf = Buffer.alloc(512);
80
+ const fd = require("node:fs").openSync(filePath, "r");
81
+ const bytesRead = require("node:fs").readSync(fd, buf, 0, 512, 0);
82
+ require("node:fs").closeSync(fd);
83
+ // If the first 512 bytes contain a null byte, treat as binary
84
+ for (let i = 0; i < bytesRead; i++) {
85
+ if (buf[i] === 0) return "binary";
86
+ }
87
+ } catch { /* treat as text */ }
88
+ return "text";
89
+ }
90
+
91
+ // ---------------------------------------------------------------------------
92
+ // Utilities
93
+ // ---------------------------------------------------------------------------
94
+
95
+ function hasCmd(cmd: string): boolean {
96
+ try {
97
+ execSync(`which ${cmd}`, { stdio: "ignore" });
98
+ return true;
99
+ } catch {
100
+ return false;
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Run a command with argument array — no shell interpolation, safe for untrusted paths.
106
+ */
107
+ function runSafe(cmd: string, args: string[], opts?: { timeout?: number }): string {
108
+ return execFileSync(cmd, args, {
109
+ encoding: "utf-8",
110
+ maxBuffer: 10 * 1024 * 1024,
111
+ timeout: opts?.timeout ?? 30_000,
112
+ }).trim();
113
+ }
114
+
115
+ function fileSizeStr(bytes: number): string {
116
+ if (bytes < 1024) return `${bytes}B`;
117
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
118
+ if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
119
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)}GB`;
120
+ }
121
+
122
+ function mimeFromExt(ext: string): string {
123
+ const map: Record<string, string> = {
124
+ ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
125
+ ".png": "image/png", ".gif": "image/gif",
126
+ ".webp": "image/webp", ".bmp": "image/bmp",
127
+ ".tiff": "image/tiff", ".tif": "image/tiff",
128
+ ".ico": "image/x-icon", ".heic": "image/heic",
129
+ ".avif": "image/avif", ".svg": "image/svg+xml",
130
+ };
131
+ return map[ext.toLowerCase()] ?? "image/png";
132
+ }
133
+
134
+ function modifiedAgo(mtime: Date): string {
135
+ const diff = Date.now() - mtime.getTime();
136
+ const secs = Math.floor(diff / 1000);
137
+ if (secs < 60) return "just now";
138
+ const mins = Math.floor(secs / 60);
139
+ if (mins < 60) return `${mins}m ago`;
140
+ const hours = Math.floor(mins / 60);
141
+ if (hours < 24) return `${hours}h ago`;
142
+ const days = Math.floor(hours / 24);
143
+ if (days < 30) return `${days}d ago`;
144
+ return mtime.toLocaleDateString();
145
+ }
146
+
147
+ function fileHeader(filePath: string, icon: string, extra?: string, uri?: string): string {
148
+ const stat = statSync(filePath);
149
+ const name = basename(filePath);
150
+ const label = uri ? osc8Link(uri, `${icon} ${name}`) : `${icon} ${name}`;
151
+ const parts = [
152
+ label,
153
+ fileSizeStr(stat.size),
154
+ `modified ${modifiedAgo(stat.mtime)}`,
155
+ ];
156
+ if (extra) parts.push(extra);
157
+ return parts.join(" · ");
158
+ }
159
+
160
+ // ---------------------------------------------------------------------------
161
+ // Content type for results
162
+ // ---------------------------------------------------------------------------
163
+
164
+ type ContentPart =
165
+ | { type: "text"; text: string }
166
+ | { type: "image"; data: string; mimeType: string };
167
+
168
+ interface ViewResult {
169
+ content: ContentPart[];
170
+ details: Record<string, unknown>;
171
+ }
172
+
173
+ // ---------------------------------------------------------------------------
174
+ // Scale presets
175
+ // ---------------------------------------------------------------------------
176
+
177
+ /** Named scale presets: compact → fill the terminal width generously */
178
+ const SCALE_PRESETS: Record<string, { widthCells: number; label: string }> = {
179
+ "compact": { widthCells: 60, label: "compact" },
180
+ "normal": { widthCells: 120, label: "normal" },
181
+ "large": { widthCells: 200, label: "large" },
182
+ "full": { widthCells: 999, label: "full" }, // effectively unlimited — capped by terminal width
183
+ };
184
+
185
+ /**
186
+ * Parse a scale argument from user input.
187
+ * Accepts: "2x", "3x", "compact", "normal", "large", "full", or a raw number.
188
+ * Returns maxWidthCells value, or undefined if not a scale arg.
189
+ */
190
+ function parseScale(arg: string): { widthCells: number; label: string } | undefined {
191
+ const lower = arg.toLowerCase();
192
+ if (SCALE_PRESETS[lower]) return SCALE_PRESETS[lower];
193
+
194
+ // Numeric multiplier: "2x", "3x", "1.5x"
195
+ const mMatch = lower.match(/^(\d+(?:\.\d+)?)x$/);
196
+ if (mMatch) {
197
+ const multiplier = parseFloat(mMatch[1]);
198
+ return { widthCells: Math.round(120 * multiplier), label: `${multiplier}x` };
199
+ }
200
+
201
+ // Raw number of columns
202
+ if (/^\d+$/.test(arg)) {
203
+ const cols = parseInt(arg, 10);
204
+ if (cols >= 20 && cols <= 2000) return { widthCells: cols, label: `${cols} cols` };
205
+ }
206
+
207
+ return undefined;
208
+ }
209
+
210
+ // ---------------------------------------------------------------------------
211
+ // Last-viewed image state (for /zoom)
212
+ // ---------------------------------------------------------------------------
213
+
214
+ /** Tracks the most recently viewed image for /zoom */
215
+ let lastViewedImage: { data: string; mimeType: string; path: string; header: string } | undefined;
216
+
217
+ // ---------------------------------------------------------------------------
218
+ // Image dimensions (best-effort via sips on macOS or file command)
219
+ // ---------------------------------------------------------------------------
220
+
221
+ function getImageDims(filePath: string): string | undefined {
222
+ try {
223
+ const out = runSafe("sips", ["-g", "pixelWidth", "-g", "pixelHeight", filePath]);
224
+ const w = out.match(/pixelWidth:\s+(\d+)/)?.[1];
225
+ const h = out.match(/pixelHeight:\s+(\d+)/)?.[1];
226
+ if (w && h) return `${w}×${h}`;
227
+ } catch { /* ignore */ }
228
+ try {
229
+ const out = runSafe("file", [filePath]);
230
+ const m = out.match(/(\d+)\s*x\s*(\d+)/);
231
+ if (m) return `${m[1]}×${m[2]}`;
232
+ } catch { /* ignore */ }
233
+ return undefined;
234
+ }
235
+
236
+ // ---------------------------------------------------------------------------
237
+ // Renderers
238
+ // ---------------------------------------------------------------------------
239
+
240
+ function viewImage(filePath: string, uri?: string): ViewResult {
241
+ const data = readFileSync(filePath).toString("base64");
242
+ const ext = extname(filePath).toLowerCase();
243
+ const mime = mimeFromExt(ext);
244
+ const dims = getImageDims(filePath);
245
+ const header = fileHeader(filePath, "📷", dims, uri);
246
+
247
+ // Stash for /zoom
248
+ lastViewedImage = { data, mimeType: mime, path: filePath, header };
249
+
250
+ return {
251
+ content: [
252
+ { type: "text", text: header },
253
+ { type: "image", data, mimeType: mime },
254
+ ],
255
+ details: { kind: "image", path: filePath, dimensions: dims },
256
+ };
257
+ }
258
+
259
+ function viewSvg(filePath: string, uri?: string): ViewResult {
260
+ const tmp = mkdtempSync(join(tmpdir(), "pi-view-"));
261
+ const outPng = join(tmp, "out.png");
262
+ let converted = false;
263
+
264
+ if (hasCmd("rsvg-convert")) {
265
+ try { runSafe("rsvg-convert", [filePath, "-o", outPng]); converted = true; } catch {}
266
+ }
267
+ if (!converted) {
268
+ try {
269
+ runSafe("sips", ["-s", "format", "png", filePath, "--out", outPng]);
270
+ converted = existsSync(outPng) && statSync(outPng).size > 0;
271
+ } catch {}
272
+ }
273
+
274
+ if (converted && existsSync(outPng)) {
275
+ const data = readFileSync(outPng).toString("base64");
276
+ const header = fileHeader(filePath, "🎨", "SVG → PNG", uri);
277
+ lastViewedImage = { data, mimeType: "image/png", path: filePath, header };
278
+ return {
279
+ content: [
280
+ { type: "text", text: header },
281
+ { type: "image", data, mimeType: "image/png" },
282
+ ],
283
+ details: { kind: "svg", path: filePath, rendered: true },
284
+ };
285
+ }
286
+
287
+ // Fall back to source with syntax highlighting
288
+ const src = readFileSync(filePath, "utf-8");
289
+ const preview = src.length > 5000 ? src.slice(0, 5000) + "\n… (truncated)" : src;
290
+ return {
291
+ content: [{ type: "text", text: `${fileHeader(filePath, "🎨", "SVG source", uri)}\n\n\`\`\`xml\n${preview}\n\`\`\`` }],
292
+ details: { kind: "svg", path: filePath, rendered: false },
293
+ };
294
+ }
295
+
296
+ function viewPdf(filePath: string, page?: number, uri?: string): ViewResult {
297
+ const content: ContentPart[] = [];
298
+
299
+ // Page count
300
+ let pageCount = 0;
301
+ try {
302
+ const info = runSafe("pdfinfo", [filePath]);
303
+ const m = info.match(/Pages:\s+(\d+)/);
304
+ if (m) pageCount = parseInt(m[1], 10);
305
+ } catch {}
306
+
307
+ const extra = pageCount > 0 ? `${pageCount} pages` : undefined;
308
+ content.push({ type: "text", text: fileHeader(filePath, "📄", extra, uri) });
309
+
310
+ if (hasCmd("pdftoppm")) {
311
+ const tmp = mkdtempSync(join(tmpdir(), "pi-view-pdf-"));
312
+ const first = page ?? 1;
313
+ const last = page ?? Math.min(pageCount || 1, 3);
314
+
315
+ try {
316
+ runSafe("pdftoppm", ["-png", "-r", "200", "-f", String(first), "-l", String(last), filePath, join(tmp, "page")]);
317
+ const pages = readdirSync(tmp).filter(f => f.endsWith(".png")).sort();
318
+
319
+ for (let i = 0; i < pages.length; i++) {
320
+ const pageNum = first + i;
321
+ content.push({ type: "text", text: `\n── Page ${pageNum} ${"─".repeat(40)}` });
322
+ const data = readFileSync(join(tmp, pages[i])).toString("base64");
323
+ content.push({ type: "image", data, mimeType: "image/png" });
324
+ // Stash last page for /zoom
325
+ lastViewedImage = { data, mimeType: "image/png", path: filePath, header: fileHeader(filePath, "📄", `page ${pageNum}`) };
326
+ }
327
+
328
+ if (!page && pageCount > 3) {
329
+ content.push({ type: "text", text: `\n📑 Showing pages 1–3 of ${pageCount}. Use \`/view ${basename(filePath)} <page>\` for a specific page.` });
330
+ }
331
+ } catch {
332
+ appendPdfText(filePath, content);
333
+ }
334
+ } else if (hasCmd("pdftotext")) {
335
+ appendPdfText(filePath, content);
336
+ } else {
337
+ content.push({ type: "text", text: "\n⚠️ Install poppler for PDF rendering: `brew install poppler`" });
338
+ }
339
+
340
+ return { content, details: { kind: "pdf", path: filePath, pages: pageCount } };
341
+ }
342
+
343
+ function appendPdfText(filePath: string, content: ContentPart[]) {
344
+ try {
345
+ const text = runSafe("pdftotext", ["-layout", filePath, "-"]);
346
+ const preview = text.length > 8000 ? text.slice(0, 8000) + "\n… (truncated)" : text;
347
+ content.push({ type: "text", text: `\n\`\`\`\n${preview}\n\`\`\`` });
348
+ } catch {
349
+ content.push({ type: "text", text: "\n(Could not extract PDF text)" });
350
+ }
351
+ }
352
+
353
+ function viewPandoc(filePath: string, uri?: string): ViewResult {
354
+ const name = basename(filePath);
355
+
356
+ if (!hasCmd("pandoc")) {
357
+ const raw = readFileSync(filePath, "utf-8");
358
+ const preview = raw.length > 5000 ? raw.slice(0, 5000) + "\n… (truncated)" : raw;
359
+ return {
360
+ content: [{ type: "text", text: `${fileHeader(filePath, "📝", undefined, uri)}\n⚠️ Install pandoc for rich rendering\n\n${preview}` }],
361
+ details: { kind: "pandoc", path: filePath, converted: false },
362
+ };
363
+ }
364
+
365
+ const ext = extname(filePath).toLowerCase();
366
+ const formatMap: Record<string, string> = {
367
+ ".docx": "docx", ".xlsx": "csv", ".pptx": "pptx",
368
+ ".odt": "odt", ".epub": "epub", ".html": "html", ".htm": "html",
369
+ ".rtf": "rtf", ".rst": "rst", ".textile": "textile",
370
+ ".mediawiki": "mediawiki", ".org": "org", ".opml": "opml",
371
+ ".csv": "csv", ".tsv": "tsv", ".bib": "biblatex",
372
+ };
373
+ const fmt = formatMap[ext] ?? ext.slice(1);
374
+
375
+ try {
376
+ const md = runSafe("pandoc", ["-f", fmt, "-t", "gfm", "--wrap=none", filePath], { timeout: 15_000 });
377
+ const preview = md.length > 10000 ? md.slice(0, 10000) + "\n\n… (truncated)" : md;
378
+ return {
379
+ content: [{ type: "text", text: `${fileHeader(filePath, "📝", fmt.toUpperCase(), uri)}\n\n${preview}` }],
380
+ details: { kind: "pandoc", path: filePath, converted: true, format: fmt },
381
+ };
382
+ } catch (e: any) {
383
+ return {
384
+ content: [{ type: "text", text: `${fileHeader(filePath, "📝", undefined, uri)} — conversion failed: ${e.message?.slice(0, 200)}` }],
385
+ details: { kind: "pandoc", path: filePath, converted: false, error: e.message },
386
+ };
387
+ }
388
+ }
389
+
390
+ function viewDiagram(filePath: string, uri?: string): ViewResult {
391
+ const src = readFileSync(filePath, "utf-8");
392
+
393
+ if (hasCmd("d2")) {
394
+ const tmp = mkdtempSync(join(tmpdir(), "pi-view-d2-"));
395
+ const outPng = join(tmp, "diagram.png");
396
+ try {
397
+ runSafe("d2", ["--theme", "200", "--layout", "elk", "--pad", "40", filePath, outPng], { timeout: 15_000 });
398
+ if (existsSync(outPng) && statSync(outPng).size > 0) {
399
+ const data = readFileSync(outPng).toString("base64");
400
+ const header = fileHeader(filePath, "📊", "D2", uri);
401
+ lastViewedImage = { data, mimeType: "image/png", path: filePath, header };
402
+ return {
403
+ content: [
404
+ { type: "text", text: header },
405
+ { type: "image", data, mimeType: "image/png" },
406
+ ],
407
+ details: { kind: "diagram", path: filePath, rendered: true },
408
+ };
409
+ }
410
+ } catch {}
411
+ }
412
+
413
+ return {
414
+ content: [{ type: "text", text: `${fileHeader(filePath, "📊", "D2 source", uri)}\n\n\`\`\`d2\n${src}\n\`\`\`` }],
415
+ details: { kind: "diagram", path: filePath, rendered: false },
416
+ };
417
+ }
418
+
419
+ function viewText(filePath: string, lang?: string, uri?: string): ViewResult {
420
+ const ext = extname(filePath).toLowerCase();
421
+ const raw = readFileSync(filePath, "utf-8");
422
+ const lineCount = raw.split("\n").length;
423
+ const preview = raw.length > 15000 ? raw.slice(0, 15000) + "\n… (truncated)" : raw;
424
+
425
+ const language = lang ?? guessLang(filePath);
426
+ const fence = language ? `\`\`\`${language}` : "```";
427
+
428
+ return {
429
+ content: [{ type: "text", text: `${fileHeader(filePath, "📄", `${lineCount} lines · ${language || "text"}`, uri)}\n\n${fence}\n${preview}\n\`\`\`` }],
430
+ details: { kind: "text", path: filePath, language: language || undefined, lines: lineCount },
431
+ };
432
+ }
433
+
434
+ function viewMarkdown(filePath: string, uri?: string): ViewResult {
435
+ const raw = readFileSync(filePath, "utf-8");
436
+ const lineCount = raw.split("\n").length;
437
+ const preview = raw.length > 15000 ? raw.slice(0, 15000) + "\n\n… (truncated)" : raw;
438
+
439
+ return {
440
+ content: [{ type: "text", text: `${fileHeader(filePath, "📝", `${lineCount} lines · markdown`, uri)}\n\n${preview}` }],
441
+ details: { kind: "markdown", path: filePath, lines: lineCount },
442
+ };
443
+ }
444
+
445
+ function viewBinary(filePath: string, uri?: string): ViewResult {
446
+ const stat = statSync(filePath);
447
+ let fileType = "binary";
448
+ try {
449
+ fileType = runSafe("file", ["-b", filePath]).slice(0, 120);
450
+ } catch {}
451
+
452
+ return {
453
+ content: [{ type: "text", text: `${fileHeader(filePath, "📦", fileType, uri)}\n\n(Binary file — cannot display inline)` }],
454
+ details: { kind: "binary", path: filePath, fileType },
455
+ };
456
+ }
457
+
458
+ function viewDirectory(absPath: string): ViewResult {
459
+ const entries = readdirSync(absPath, { withFileTypes: true })
460
+ .sort((a, b) => {
461
+ // Directories first, then by name
462
+ if (a.isDirectory() !== b.isDirectory()) return a.isDirectory() ? -1 : 1;
463
+ return a.name.localeCompare(b.name);
464
+ });
465
+
466
+ const lines: string[] = [];
467
+ let dirs = 0, files = 0;
468
+ for (const e of entries) {
469
+ if (e.isDirectory()) {
470
+ dirs++;
471
+ lines.push(` 📁 ${e.name}/`);
472
+ } else {
473
+ files++;
474
+ const stat = statSync(join(absPath, e.name));
475
+ lines.push(` 📄 ${e.name} ${fileSizeStr(stat.size)}`);
476
+ }
477
+ }
478
+
479
+ const summary = [dirs > 0 ? `${dirs} dirs` : null, files > 0 ? `${files} files` : null]
480
+ .filter(Boolean).join(", ");
481
+
482
+ return {
483
+ content: [{ type: "text", text: `📁 ${basename(absPath)}/ (${summary})\n\n${lines.join("\n")}` }],
484
+ details: { kind: "directory", path: absPath, dirs, files },
485
+ };
486
+ }
487
+
488
+ // ---------------------------------------------------------------------------
489
+ // Language detection
490
+ // ---------------------------------------------------------------------------
491
+
492
+ const LANG_MAP: Record<string, string> = {
493
+ ".ts": "typescript", ".tsx": "typescript", ".mts": "typescript", ".cts": "typescript",
494
+ ".js": "javascript", ".jsx": "javascript", ".mjs": "javascript", ".cjs": "javascript",
495
+ ".py": "python", ".pyw": "python",
496
+ ".rs": "rust",
497
+ ".go": "go",
498
+ ".c": "c", ".h": "c",
499
+ ".cpp": "cpp", ".cc": "cpp", ".cxx": "cpp", ".hpp": "cpp",
500
+ ".java": "java",
501
+ ".kt": "kotlin", ".kts": "kotlin",
502
+ ".swift": "swift",
503
+ ".rb": "ruby",
504
+ ".lua": "lua",
505
+ ".zig": "zig",
506
+ ".sh": "bash", ".bash": "bash", ".zsh": "zsh", ".fish": "fish",
507
+ ".sql": "sql",
508
+ ".css": "css", ".scss": "scss", ".less": "less",
509
+ ".html": "html", ".htm": "html",
510
+ ".xml": "xml", ".xsl": "xml", ".xsd": "xml",
511
+ ".json": "json", ".jsonc": "json",
512
+ ".yaml": "yaml", ".yml": "yaml",
513
+ ".toml": "toml",
514
+ ".ini": "ini", ".cfg": "ini", ".conf": "ini",
515
+ ".dockerfile": "dockerfile",
516
+ ".tf": "hcl", ".hcl": "hcl",
517
+ ".nix": "nix",
518
+ ".md": "markdown", ".markdown": "markdown", ".mdx": "markdown",
519
+ ".r": "r", ".R": "r",
520
+ ".pl": "perl", ".pm": "perl",
521
+ ".ex": "elixir", ".exs": "elixir",
522
+ ".erl": "erlang",
523
+ ".hs": "haskell",
524
+ ".ml": "ocaml", ".mli": "ocaml",
525
+ ".clj": "clojure", ".cljs": "clojure",
526
+ ".scala": "scala",
527
+ ".dart": "dart",
528
+ ".vim": "vim",
529
+ ".proto": "protobuf",
530
+ ".graphql": "graphql", ".gql": "graphql",
531
+ };
532
+
533
+ const FILENAME_MAP: Record<string, string> = {
534
+ "Makefile": "makefile", "makefile": "makefile", "GNUmakefile": "makefile",
535
+ "Dockerfile": "dockerfile",
536
+ "Jenkinsfile": "groovy",
537
+ "Vagrantfile": "ruby",
538
+ "Rakefile": "ruby",
539
+ "Gemfile": "ruby",
540
+ ".gitignore": "gitignore",
541
+ ".dockerignore": "gitignore",
542
+ ".env": "bash",
543
+ ".bashrc": "bash", ".bash_profile": "bash", ".zshrc": "zsh",
544
+ };
545
+
546
+ function guessLang(filePath: string): string {
547
+ const name = basename(filePath);
548
+ if (FILENAME_MAP[name]) return FILENAME_MAP[name];
549
+ const ext = extname(filePath).toLowerCase();
550
+ return LANG_MAP[ext] ?? "";
551
+ }
552
+
553
+ // ---------------------------------------------------------------------------
554
+ // Main dispatcher
555
+ // ---------------------------------------------------------------------------
556
+
557
+ function viewFile(filePath: string, page?: number, options?: { mdservePort?: number }): ViewResult {
558
+ const absPath = resolve(filePath);
559
+ if (!existsSync(absPath)) {
560
+ return {
561
+ content: [{ type: "text", text: `❌ File not found: ${filePath}` }],
562
+ details: { error: "not_found", path: filePath },
563
+ };
564
+ }
565
+
566
+ if (statSync(absPath).isDirectory()) return viewDirectory(absPath);
567
+
568
+ const config = loadConfig();
569
+ const uri = resolveUri(absPath, { mdservePort: options?.mdservePort, config });
570
+
571
+ const kind = classifyFile(absPath);
572
+ switch (kind) {
573
+ case "image": return viewImage(absPath, uri);
574
+ case "svg": return viewSvg(absPath, uri);
575
+ case "pdf": return viewPdf(absPath, page, uri);
576
+ case "pandoc": return viewPandoc(absPath, uri);
577
+ case "diagram": return viewDiagram(absPath, uri);
578
+ case "data": return viewText(absPath, undefined, uri);
579
+ case "markdown": return viewMarkdown(absPath, uri);
580
+ case "binary": return viewBinary(absPath, uri);
581
+ case "text": return viewText(absPath, undefined, uri);
582
+ }
583
+ }
584
+
585
+ // ---------------------------------------------------------------------------
586
+ // Extension
587
+ // ---------------------------------------------------------------------------
588
+
589
+ export default function (pi: ExtensionAPI) {
590
+
591
+ // ------------------------------------------------------------------
592
+ // /view command
593
+ // ------------------------------------------------------------------
594
+ pi.registerCommand("view", {
595
+ description: "View files inline — images, PDFs, docs, diagrams, code. Scale: /view file.png large|full|2x",
596
+ getArgumentCompletions: (prefix: string) => {
597
+ const parts = prefix.split(/\s+/);
598
+ if (parts.length <= 1) return null; // file path — no completions
599
+ // Second+ arg — offer scale presets
600
+ const scalePrefix = parts[parts.length - 1].toLowerCase();
601
+ const presets = ["compact", "normal", "large", "full", "2x", "3x"];
602
+ const filtered = presets.filter(p => p.startsWith(scalePrefix));
603
+ return filtered.length > 0
604
+ ? filtered.map(p => ({ value: `${parts.slice(0, -1).join(" ")} ${p}`, label: p }))
605
+ : null;
606
+ },
607
+ handler: async (args, ctx) => {
608
+ if (!args?.trim()) {
609
+ ctx.ui.notify("Usage: /view <file> [page|scale]\n Scale: compact, normal (default), large, full, 2x, 3x", "warning");
610
+ return;
611
+ }
612
+
613
+ const parts = args.trim().split(/\s+/);
614
+ const filePath = parts[0];
615
+ let page: number | undefined;
616
+ let scale: { widthCells: number; label: string } | undefined;
617
+
618
+ // Parse remaining args — could be page number or scale
619
+ for (let i = 1; i < parts.length; i++) {
620
+ const s = parseScale(parts[i]);
621
+ if (s) { scale = s; continue; }
622
+ const n = parseInt(parts[i], 10);
623
+ if (!isNaN(n) && n > 0 && n < 10000) { page = n; }
624
+ }
625
+
626
+ const mdservePort = getMdservePort() ?? undefined;
627
+ const result = viewFile(filePath, page, { mdservePort });
628
+ const textParts = result.content.filter(c => c.type === "text").map(c => (c as any).text).join("\n");
629
+ const imageParts = result.content.filter(c => c.type === "image");
630
+
631
+ const scaleLabel = scale?.label;
632
+ const scaleWidth = scale?.widthCells;
633
+
634
+ pi.sendMessage({
635
+ customType: "view",
636
+ content: textParts + (scaleLabel ? `\n📐 Scale: ${scaleLabel}` : ""),
637
+ display: true,
638
+ details: {
639
+ ...result.details,
640
+ images: imageParts.length > 0 ? imageParts : undefined,
641
+ ...(scaleWidth ? { maxWidthCells: scaleWidth } : {}),
642
+ },
643
+ });
644
+ },
645
+ });
646
+
647
+ // ------------------------------------------------------------------
648
+ // /edit command
649
+ // ------------------------------------------------------------------
650
+ pi.registerCommand("edit", {
651
+ description: "Open file in $EDITOR (vim, nvim, etc.)",
652
+ handler: async (args, ctx) => {
653
+ const rawArgs = (args ?? "").trim();
654
+ if (!rawArgs) {
655
+ ctx.ui.notify("Usage: /edit <file> [+line]", "warning");
656
+ return;
657
+ }
658
+
659
+ // Parse: /edit file.ts +42 or /edit +42 file.ts
660
+ const parts = rawArgs.split(/\s+/);
661
+ let filePath: string | undefined;
662
+ let lineNum: string | undefined;
663
+
664
+ for (const p of parts) {
665
+ if (p.startsWith("+") && /^\+\d+$/.test(p)) {
666
+ lineNum = p;
667
+ } else if (!filePath) {
668
+ filePath = p;
669
+ }
670
+ }
671
+
672
+ if (!filePath) {
673
+ ctx.ui.notify("Usage: /edit <file> [+line]", "warning");
674
+ return;
675
+ }
676
+
677
+ const absPath = resolve(filePath);
678
+ if (!existsSync(absPath)) {
679
+ // Create new file — that's a valid editor use case
680
+ ctx.ui.notify(`Creating new file: ${filePath}`, "info");
681
+ }
682
+
683
+ const editor = process.env.EDITOR || process.env.VISUAL || "vim";
684
+ const editorArgs: string[] = [];
685
+
686
+ // Pass +line to editors that support it (vim, nvim, nano, emacs, code, etc.)
687
+ if (lineNum) editorArgs.push(lineNum);
688
+ editorArgs.push(absPath);
689
+
690
+ const result = spawnSync(editor, editorArgs, {
691
+ stdio: "inherit",
692
+ env: process.env,
693
+ });
694
+
695
+ if (result.status === 0) {
696
+ ctx.ui.notify(`✓ Closed ${basename(absPath)}`, "info");
697
+ } else if (result.error) {
698
+ ctx.ui.notify(`Editor error: ${(result.error as Error).message}`, "error");
699
+ }
700
+ },
701
+ });
702
+
703
+ // ------------------------------------------------------------------
704
+ // Custom message renderer for /view output
705
+ // ------------------------------------------------------------------
706
+ pi.registerMessageRenderer("view", (message, options, theme) => {
707
+ const tui = require("@cwilson613/pi-tui");
708
+ const { Container, Text, Image, Markdown, Spacer } = tui;
709
+
710
+ let piAgent: any;
711
+ try { piAgent = require("@cwilson613/pi-coding-agent"); } catch { piAgent = null; }
712
+
713
+ const container = new Container();
714
+
715
+ // Render text content as themed markdown
716
+ if (message.content) {
717
+ const mdTheme = piAgent?.getMarkdownTheme?.() ?? undefined;
718
+ const md = new Markdown(message.content as string, 1, 0, mdTheme);
719
+ container.addChild(md);
720
+ }
721
+
722
+ // Render images inline
723
+ const images = (message.details as any)?.images;
724
+ const maxWidth = (message.details as any)?.maxWidthCells ?? 120;
725
+ if (images && Array.isArray(images)) {
726
+ for (const img of images) {
727
+ try {
728
+ const imageTheme = { fallbackColor: (s: string) => theme.fg("warning", s) };
729
+ const image = new Image(img.data, img.mimeType, imageTheme, {
730
+ maxWidthCells: maxWidth,
731
+ });
732
+ container.addChild(image);
733
+ } catch {
734
+ container.addChild(new Text(
735
+ theme.fg("warning", " ⚠️ Image rendering not supported in this terminal"),
736
+ 1, 0,
737
+ ));
738
+ }
739
+ }
740
+ // Hint for /zoom if there are images
741
+ container.addChild(new Text(
742
+ theme.fg("dim", " /zoom to expand · /view <file> large|full|2x to rescale"),
743
+ 1, 0,
744
+ ));
745
+ }
746
+
747
+ return container;
748
+ });
749
+
750
+ // ------------------------------------------------------------------
751
+ // /zoom command — expand last image in fullscreen overlay
752
+ // ------------------------------------------------------------------
753
+ pi.registerCommand("zoom", {
754
+ description: "Expand last viewed image to fill the terminal. /zoom [scale]",
755
+ getArgumentCompletions: (prefix: string) => {
756
+ const presets = ["compact", "normal", "large", "full", "2x", "3x"];
757
+ const filtered = presets.filter(p => p.startsWith(prefix.toLowerCase()));
758
+ return filtered.length > 0 ? filtered.map(p => ({ value: p, label: p })) : null;
759
+ },
760
+ handler: async (args, ctx) => {
761
+ if (!lastViewedImage) {
762
+ ctx.ui.notify("No image to zoom. Use /view <file> first.", "warning");
763
+ return;
764
+ }
765
+
766
+ const scaleArg = (args ?? "").trim();
767
+ const scale = scaleArg ? parseScale(scaleArg) : undefined;
768
+ const maxWidth = scale?.widthCells ?? 999; // Default: fill terminal
769
+
770
+ await ctx.ui.custom((tui: any, theme: any, _kb: any, done: (r: void) => void) => {
771
+ const piTui = require("@cwilson613/pi-tui");
772
+ const { Container, Text, Image: ImageComp, Spacer, DynamicBorder, getEditorKeybindings } = piTui;
773
+
774
+ const container = new Container();
775
+ container.addChild(new DynamicBorder());
776
+ container.addChild(new Spacer(1));
777
+ container.addChild(new Text(
778
+ theme.fg("accent", ` 🔍 ${lastViewedImage!.header}`),
779
+ 1, 0,
780
+ ));
781
+ container.addChild(new Spacer(1));
782
+
783
+ try {
784
+ const imageTheme = { fallbackColor: (s: string) => theme.fg("warning", s) };
785
+ const image = new ImageComp(
786
+ lastViewedImage!.data,
787
+ lastViewedImage!.mimeType,
788
+ imageTheme,
789
+ { maxWidthCells: maxWidth },
790
+ );
791
+ container.addChild(image);
792
+ } catch {
793
+ container.addChild(new Text(
794
+ theme.fg("warning", " ⚠️ Image rendering not supported"),
795
+ 1, 0,
796
+ ));
797
+ }
798
+
799
+ container.addChild(new Spacer(1));
800
+ container.addChild(new Text(
801
+ theme.fg("dim", " Press Escape or q to close"),
802
+ 1, 0,
803
+ ));
804
+ container.addChild(new Spacer(1));
805
+ container.addChild(new DynamicBorder());
806
+
807
+ (container as any).handleInput = (keyData: string) => {
808
+ const kb = getEditorKeybindings();
809
+ if (kb.matches(keyData, "selectCancel") || keyData === "q" || keyData === "Q") {
810
+ done(undefined as any);
811
+ }
812
+ };
813
+
814
+ return container;
815
+ }, {
816
+ overlay: true,
817
+ overlayOptions: {
818
+ width: "100%",
819
+ maxHeight: "100%",
820
+ anchor: "center",
821
+ margin: 0,
822
+ },
823
+ });
824
+ },
825
+ });
826
+
827
+ // ------------------------------------------------------------------
828
+ // view tool — LLM can show files inline
829
+ // ------------------------------------------------------------------
830
+ pi.registerTool({
831
+ name: "view",
832
+ label: "View",
833
+ description:
834
+ "View a file inline in the terminal with rich rendering. " +
835
+ "Images (jpg/png/gif/webp/svg) render graphically. " +
836
+ "PDFs render as page images. " +
837
+ "Documents (docx/xlsx/pptx/epub) convert to markdown via pandoc. " +
838
+ "Code files get syntax highlighting. " +
839
+ "For PDFs, specify a page number to view a specific page.",
840
+ promptSnippet: "View files inline with rich rendering (images, PDFs, docs, code)",
841
+ parameters: Type.Object({
842
+ path: Type.String({ description: "Path to the file to view" }),
843
+ page: Type.Optional(Type.Number({ description: "Page number for PDFs (default: first 3 pages)" })),
844
+ scale: Type.Optional(Type.Number({ description: "Device scale factor (default: 2)" })),
845
+ }),
846
+ async execute(toolCallId, params, signal, onUpdate, ctx) {
847
+ const filePath = params.path.startsWith("@") ? params.path.slice(1) : params.path;
848
+ const mdservePort = getMdservePort() ?? undefined;
849
+ const result = viewFile(filePath, params.page, { mdservePort });
850
+
851
+ // Apply scale if provided (multiply the default 120 cell width)
852
+ if (params.scale && params.scale > 0) {
853
+ result.details.maxWidthCells = Math.round(120 * params.scale);
854
+ }
855
+
856
+ return result;
857
+ },
858
+ });
859
+ }