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.
- package/.gitattributes +3 -0
- package/AGENTS.md +16 -0
- package/LICENSE +15 -0
- package/README.md +289 -0
- package/bin/pi.mjs +30 -0
- package/extensions/00-secrets/index.ts +1126 -0
- package/extensions/01-auth/auth.ts +401 -0
- package/extensions/01-auth/index.ts +289 -0
- package/extensions/auto-compact.ts +42 -0
- package/extensions/bootstrap/deps.ts +291 -0
- package/extensions/bootstrap/index.ts +811 -0
- package/extensions/chronos/chronos.sh +487 -0
- package/extensions/chronos/index.ts +148 -0
- package/extensions/cleave/assessment.ts +754 -0
- package/extensions/cleave/bridge.ts +31 -0
- package/extensions/cleave/conflicts.ts +250 -0
- package/extensions/cleave/dispatcher.ts +808 -0
- package/extensions/cleave/guardrails.ts +426 -0
- package/extensions/cleave/index.ts +3121 -0
- package/extensions/cleave/lifecycle-emitter.ts +20 -0
- package/extensions/cleave/openspec.ts +811 -0
- package/extensions/cleave/planner.ts +260 -0
- package/extensions/cleave/review.ts +579 -0
- package/extensions/cleave/skills.ts +355 -0
- package/extensions/cleave/types.ts +261 -0
- package/extensions/cleave/workspace.ts +861 -0
- package/extensions/cleave/worktree.ts +243 -0
- package/extensions/core-renderers.ts +253 -0
- package/extensions/dashboard/context-gauge.ts +58 -0
- package/extensions/dashboard/file-watch.ts +14 -0
- package/extensions/dashboard/footer.ts +1145 -0
- package/extensions/dashboard/git.ts +185 -0
- package/extensions/dashboard/index.ts +478 -0
- package/extensions/dashboard/memory-audit.ts +34 -0
- package/extensions/dashboard/overlay-data.ts +705 -0
- package/extensions/dashboard/overlay.ts +365 -0
- package/extensions/dashboard/render-utils.ts +54 -0
- package/extensions/dashboard/types.ts +191 -0
- package/extensions/dashboard/uri-helper.ts +45 -0
- package/extensions/debug.ts +69 -0
- package/extensions/defaults.ts +282 -0
- package/extensions/design-tree/dashboard-state.ts +161 -0
- package/extensions/design-tree/design-card.ts +362 -0
- package/extensions/design-tree/index.ts +2130 -0
- package/extensions/design-tree/lifecycle-emitter.ts +41 -0
- package/extensions/design-tree/tree.ts +1607 -0
- package/extensions/design-tree/types.ts +163 -0
- package/extensions/distill.ts +127 -0
- package/extensions/effort/index.ts +395 -0
- package/extensions/effort/tiers.ts +146 -0
- package/extensions/effort/types.ts +105 -0
- package/extensions/lib/git-state.ts +227 -0
- package/extensions/lib/local-models.ts +157 -0
- package/extensions/lib/model-preferences.ts +51 -0
- package/extensions/lib/model-routing.ts +720 -0
- package/extensions/lib/operator-fallback.ts +205 -0
- package/extensions/lib/operator-profile.ts +360 -0
- package/extensions/lib/slash-command-bridge.ts +253 -0
- package/extensions/lib/typebox-helpers.ts +16 -0
- package/extensions/local-inference/index.ts +727 -0
- package/extensions/mcp-bridge/README.md +220 -0
- package/extensions/mcp-bridge/index.ts +951 -0
- package/extensions/mcp-bridge/lib.ts +365 -0
- package/extensions/mcp-bridge/mcp.json +3 -0
- package/extensions/mcp-bridge/package.json +11 -0
- package/extensions/model-budget.ts +752 -0
- package/extensions/offline-driver.ts +403 -0
- package/extensions/openspec/archive-gate.ts +164 -0
- package/extensions/openspec/branch-cleanup.ts +64 -0
- package/extensions/openspec/dashboard-state.ts +50 -0
- package/extensions/openspec/index.ts +1917 -0
- package/extensions/openspec/lifecycle-emitter.ts +65 -0
- package/extensions/openspec/lifecycle-files.ts +70 -0
- package/extensions/openspec/lifecycle.ts +50 -0
- package/extensions/openspec/reconcile.ts +187 -0
- package/extensions/openspec/spec.ts +1385 -0
- package/extensions/openspec/types.ts +98 -0
- package/extensions/project-memory/DESIGN-global-mind.md +198 -0
- package/extensions/project-memory/README.md +202 -0
- package/extensions/project-memory/api-types.ts +382 -0
- package/extensions/project-memory/compaction-policy.ts +29 -0
- package/extensions/project-memory/core.ts +164 -0
- package/extensions/project-memory/embeddings.ts +230 -0
- package/extensions/project-memory/extraction-v2.ts +861 -0
- package/extensions/project-memory/factstore.ts +2177 -0
- package/extensions/project-memory/index.ts +3459 -0
- package/extensions/project-memory/injection-metrics.ts +91 -0
- package/extensions/project-memory/jsonl-io.ts +12 -0
- package/extensions/project-memory/lifecycle.ts +331 -0
- package/extensions/project-memory/migration.ts +293 -0
- package/extensions/project-memory/package.json +9 -0
- package/extensions/project-memory/sci-renderers.ts +7 -0
- package/extensions/project-memory/template.ts +103 -0
- package/extensions/project-memory/triggers.ts +52 -0
- package/extensions/project-memory/types.ts +102 -0
- package/extensions/render/composition/fonts/Inter-Bold.ttf +0 -0
- package/extensions/render/composition/fonts/Inter-Regular.ttf +0 -0
- package/extensions/render/composition/fonts/Tomorrow-Bold.ttf +0 -0
- package/extensions/render/composition/fonts/Tomorrow-Regular.ttf +0 -0
- package/extensions/render/composition/package-lock.json +534 -0
- package/extensions/render/composition/package.json +22 -0
- package/extensions/render/composition/render.mjs +246 -0
- package/extensions/render/composition/test-comp.tsx +87 -0
- package/extensions/render/composition/types.ts +24 -0
- package/extensions/render/excalidraw/UPSTREAM.md +81 -0
- package/extensions/render/excalidraw/elements.ts +764 -0
- package/extensions/render/excalidraw/index.ts +66 -0
- package/extensions/render/excalidraw/types.ts +223 -0
- package/extensions/render/excalidraw-renderer/pyproject.toml +8 -0
- package/extensions/render/excalidraw-renderer/render_excalidraw.py +182 -0
- package/extensions/render/excalidraw-renderer/render_template.html +59 -0
- package/extensions/render/index.ts +830 -0
- package/extensions/render/native-diagrams/index.ts +57 -0
- package/extensions/render/native-diagrams/motifs.ts +542 -0
- package/extensions/render/native-diagrams/raster.ts +8 -0
- package/extensions/render/native-diagrams/scene.ts +75 -0
- package/extensions/render/native-diagrams/spec.ts +204 -0
- package/extensions/render/native-diagrams/svg.ts +116 -0
- package/extensions/sci-ui.ts +304 -0
- package/extensions/session-log.ts +174 -0
- package/extensions/shared-state.ts +146 -0
- package/extensions/spinner-verbs.ts +91 -0
- package/extensions/style.ts +281 -0
- package/extensions/terminal-title.ts +191 -0
- package/extensions/tool-profile/index.ts +291 -0
- package/extensions/tool-profile/profiles.ts +290 -0
- package/extensions/types.d.ts +9 -0
- package/extensions/vault/index.ts +185 -0
- package/extensions/version-check.ts +90 -0
- package/extensions/view/index.ts +859 -0
- package/extensions/view/uri-resolver.ts +148 -0
- package/extensions/web-search/index.ts +182 -0
- package/extensions/web-search/providers.ts +121 -0
- package/extensions/web-ui/index.ts +110 -0
- package/extensions/web-ui/server.ts +265 -0
- package/extensions/web-ui/state.ts +462 -0
- package/extensions/web-ui/static/index.html +145 -0
- package/extensions/web-ui/types.ts +284 -0
- package/package.json +76 -0
- package/prompts/init.md +75 -0
- package/prompts/new-repo.md +54 -0
- package/prompts/oci-login.md +56 -0
- package/prompts/status.md +50 -0
- package/settings.json +4 -0
- package/skills/cleave/SKILL.md +218 -0
- package/skills/git/SKILL.md +209 -0
- package/skills/git/_reference/ci-validation.md +204 -0
- package/skills/oci/SKILL.md +338 -0
- package/skills/openspec/SKILL.md +346 -0
- package/skills/pi-extensions/SKILL.md +191 -0
- package/skills/pi-tui/SKILL.md +517 -0
- package/skills/python/SKILL.md +189 -0
- package/skills/rust/SKILL.md +268 -0
- package/skills/security/SKILL.md +206 -0
- package/skills/style/SKILL.md +264 -0
- package/skills/typescript/SKILL.md +225 -0
- package/skills/vault/SKILL.md +102 -0
- package/themes/alpharius-legacy.json +85 -0
- package/themes/alpharius.conf +59 -0
- 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
|
+
}
|