prism-mcp-server 20.0.6 → 20.0.7

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,470 @@
1
+ /**
2
+ * Platform Utilities — Cross-platform abstractions for Prism CLI
3
+ * ================================================================
4
+ *
5
+ * Provides OS-aware implementations for shell commands, file operations,
6
+ * and system tools. Supports macOS, Linux, and Windows 10+.
7
+ *
8
+ * Key design decisions:
9
+ * - Never hardcode paths like /tmp/ or /opt/homebrew/
10
+ * - Always auto-detect binary locations via PATH
11
+ * - Use PowerShell on Windows for complex pipelines
12
+ * - Provide graceful fallbacks on every platform
13
+ */
14
+ import { execSync } from 'child_process';
15
+ import * as os from 'os';
16
+ import * as path from 'path';
17
+ export const IS_WINDOWS = process.platform === 'win32';
18
+ export const IS_MAC = process.platform === 'darwin';
19
+ export const IS_LINUX = process.platform === 'linux';
20
+ // ---------------------------------------------------------------------------
21
+ // ANSI / Terminal
22
+ // ---------------------------------------------------------------------------
23
+ /**
24
+ * Enable ANSI escape sequence processing on Windows.
25
+ * Windows Terminal supports ANSI natively, but legacy cmd.exe and
26
+ * ConHost need ENABLE_VIRTUAL_TERMINAL_PROCESSING (0x0004) set
27
+ * on the console output handle. Node.js does NOT set this by default.
28
+ *
29
+ * Call this once at CLI startup.
30
+ */
31
+ export function enableAnsiOnWindows() {
32
+ if (!IS_WINDOWS)
33
+ return;
34
+ // Windows Terminal already supports ANSI — check via WT_SESSION env
35
+ if (process.env.WT_SESSION)
36
+ return;
37
+ try {
38
+ // Use PowerShell to flip the VT processing bit on the console
39
+ execSync(`powershell -NoProfile -Command "` +
40
+ `$k = Add-Type -MemberDefinition '` +
41
+ `[DllImport(\\\"kernel32.dll\\\")]public static extern IntPtr GetStdHandle(int h);` +
42
+ `[DllImport(\\\"kernel32.dll\\\")]public static extern bool GetConsoleMode(IntPtr h,out int m);` +
43
+ `[DllImport(\\\"kernel32.dll\\\")]public static extern bool SetConsoleMode(IntPtr h,int m);` +
44
+ `' -Name K -PassThru;` +
45
+ `$h=[K]::GetStdHandle(-11);$m=0;[K]::GetConsoleMode($h,[ref]$m);` +
46
+ `[K]::SetConsoleMode($h,$m -bor 4)"`, { stdio: 'pipe', timeout: 5000 });
47
+ }
48
+ catch {
49
+ // Silently fail — colors just won't render in legacy terminals
50
+ }
51
+ }
52
+ /** Whether the terminal likely supports ANSI colors */
53
+ export function supportsAnsi() {
54
+ if (IS_WINDOWS) {
55
+ // Windows Terminal, VS Code terminal, or ConEmu
56
+ return !!(process.env.WT_SESSION || process.env.TERM_PROGRAM || process.env.ConEmuANSI);
57
+ }
58
+ return process.stdout.isTTY === true;
59
+ }
60
+ // ---------------------------------------------------------------------------
61
+ // Temp directory — NEVER use /tmp/ directly
62
+ // ---------------------------------------------------------------------------
63
+ /** Get a cross-platform temp file path */
64
+ export function tempPath(filename) {
65
+ return path.join(os.tmpdir(), filename);
66
+ }
67
+ // ---------------------------------------------------------------------------
68
+ // Shell / Command helpers
69
+ // ---------------------------------------------------------------------------
70
+ /** The shell to use for PowerShell commands on Windows */
71
+ export function windowsShell() {
72
+ return IS_WINDOWS ? 'powershell.exe' : undefined;
73
+ }
74
+ /** Open a URL in the default browser */
75
+ export function openUrlCommand(url) {
76
+ if (IS_WINDOWS)
77
+ return `start "" "${url}"`;
78
+ if (IS_MAC)
79
+ return `open "${url}"`;
80
+ return `xdg-open "${url}"`; // Linux
81
+ }
82
+ /**
83
+ * Locate a CLI binary on any platform.
84
+ * Returns the resolved path or null if not found.
85
+ */
86
+ export function findBinary(name) {
87
+ try {
88
+ const cmd = IS_WINDOWS ? `where ${name}` : `which ${name}`;
89
+ const result = execSync(cmd, { stdio: 'pipe', timeout: 5000 }).toString().trim();
90
+ // `where` on Windows may return multiple lines — take the first
91
+ return result.split('\n')[0]?.trim() || null;
92
+ }
93
+ catch {
94
+ return null;
95
+ }
96
+ }
97
+ /**
98
+ * Resolve a CLI tool path — tries PATH first, then common install locations.
99
+ * Falls back to bare command name (relies on PATH at runtime).
100
+ */
101
+ export function resolveCli(name) {
102
+ // Try PATH first
103
+ const inPath = findBinary(name);
104
+ if (inPath)
105
+ return `"${inPath}"`;
106
+ // macOS Homebrew fallback locations
107
+ if (IS_MAC) {
108
+ const homebrewPaths = [
109
+ `/opt/homebrew/bin/${name}`,
110
+ `/usr/local/bin/${name}`,
111
+ ];
112
+ for (const p of homebrewPaths) {
113
+ try {
114
+ execSync(`test -f "${p}"`, { stdio: 'pipe' });
115
+ return `"${p}"`;
116
+ }
117
+ catch { /* not found */ }
118
+ }
119
+ }
120
+ // Windows: try common install paths
121
+ if (IS_WINDOWS) {
122
+ const winPaths = [
123
+ path.join(os.homedir(), 'AppData', 'Roaming', 'npm', `${name}.cmd`),
124
+ path.join(os.homedir(), 'AppData', 'Local', 'Programs', name, `${name}.exe`),
125
+ path.join('C:', 'Program Files', name, `${name}.exe`),
126
+ path.join(os.homedir(), 'scoop', 'shims', `${name}.exe`),
127
+ ];
128
+ for (const p of winPaths) {
129
+ try {
130
+ execSync(`if exist "${p}" echo found`, { stdio: 'pipe', shell: 'cmd.exe' });
131
+ return `"${p}"`;
132
+ }
133
+ catch { /* not found */ }
134
+ }
135
+ }
136
+ // Bare name — hope it's in PATH at runtime
137
+ return name;
138
+ }
139
+ // ---------------------------------------------------------------------------
140
+ // fetch_url — cross-platform HTML fetching
141
+ // ---------------------------------------------------------------------------
142
+ /**
143
+ * Build the shell command to fetch and strip HTML from a URL.
144
+ * Uses curl on Unix, PowerShell Invoke-WebRequest on Windows.
145
+ */
146
+ export function fetchUrlCommand(url) {
147
+ const safeUrl = url.replace(/"/g, '\\"');
148
+ if (IS_WINDOWS) {
149
+ // PowerShell pipeline: strip HTML tags, collapse whitespace
150
+ const psUrl = url.replace(/'/g, "''");
151
+ return (`powershell -NoProfile -Command "& { ` +
152
+ `$r = Invoke-WebRequest -Uri '${psUrl}' -UseBasicParsing -TimeoutSec 15; ` +
153
+ `$t = $r.Content -replace '<script[^>]*>[\\s\\S]*?</script>','' ` +
154
+ `-replace '<style[^>]*>[\\s\\S]*?</style>','' ` +
155
+ `-replace '<[^>]+>','' -replace '\\s+',' '; ` +
156
+ `$t.Substring(0, [Math]::Min($t.Length, 8000)) }"`);
157
+ }
158
+ // macOS / Linux: curl + sed pipeline
159
+ const curlBase = `curl -sL --max-time 15 --max-filesize 1048576 -H "User-Agent: Mozilla/5.0" "${safeUrl}"`;
160
+ return (`${curlBase} | ` +
161
+ `sed 's/<script[^>]*>.*<\\/script>//gi' | ` +
162
+ `sed 's/<style[^>]*>.*<\\/style>//gi' | ` +
163
+ `sed 's/<[^>]*>//g' | ` +
164
+ `tr -s '[:space:]' '\\n' | ` +
165
+ `sed '/^$/d' | head -300`);
166
+ }
167
+ // ---------------------------------------------------------------------------
168
+ // list_files — cross-platform directory listing
169
+ // ---------------------------------------------------------------------------
170
+ export function listFilesCommand(dir, maxDepth, pattern) {
171
+ if (IS_WINDOWS) {
172
+ // PowerShell: Get-ChildItem with depth and exclusions
173
+ const psDir = dir.replace(/'/g, "''");
174
+ let cmd = `powershell -NoProfile -Command "Get-ChildItem -Path '${psDir}' -Recurse -Depth ${maxDepth}`;
175
+ cmd += ` | Where-Object { $_.FullName -notmatch 'node_modules|\\.git' }`;
176
+ if (pattern) {
177
+ cmd += ` | Where-Object { $_.Name -like '${pattern.replace(/'/g, "''")}' }`;
178
+ }
179
+ cmd += ` | Select-Object -First 100 -ExpandProperty FullName"`;
180
+ return cmd;
181
+ }
182
+ // macOS / Linux: find
183
+ let cmd = `find "${dir}" -maxdepth ${maxDepth} -not -path '*/node_modules/*' -not -path '*/.git/*'`;
184
+ if (pattern) {
185
+ cmd += ` -name "${pattern}"`;
186
+ }
187
+ cmd += ' | head -100 | sort';
188
+ return cmd;
189
+ }
190
+ // ---------------------------------------------------------------------------
191
+ // search_files — cross-platform text search
192
+ // ---------------------------------------------------------------------------
193
+ export function searchFilesCommand(query, dir, maxResults, filePattern) {
194
+ const escapedQuery = query.replace(/"/g, '\\"');
195
+ // Try ripgrep first (cross-platform, fast)
196
+ if (findBinary('rg')) {
197
+ let cmd = `rg --no-heading --line-number --max-count=${maxResults}`;
198
+ if (filePattern)
199
+ cmd += ` -g "${filePattern}"`;
200
+ cmd += ` "${escapedQuery}" "${dir}" 2>${IS_WINDOWS ? 'NUL' : '/dev/null'}`;
201
+ cmd += IS_WINDOWS ? '' : ' | head -50';
202
+ return cmd;
203
+ }
204
+ // Windows fallback: findstr
205
+ if (IS_WINDOWS) {
206
+ return `findstr /S /N /I /C:"${escapedQuery}" "${dir}\\*${filePattern || '.*'}"`;
207
+ }
208
+ // Unix fallback: grep -r
209
+ let cmd = `grep -rn "${escapedQuery}" "${dir}"`;
210
+ if (filePattern)
211
+ cmd += ` --include="${filePattern}"`;
212
+ cmd += ' | head -50';
213
+ return cmd;
214
+ }
215
+ // ---------------------------------------------------------------------------
216
+ // Multimodal — cross-platform voice/camera/TTS/clipboard
217
+ // ---------------------------------------------------------------------------
218
+ /** TTS command: read text aloud */
219
+ export function ttsCommand(text, rate = 190) {
220
+ const safe = text.replace(/"/g, '\\"').slice(0, 3000);
221
+ if (IS_MAC) {
222
+ return `say -r ${rate} "${safe}"`;
223
+ }
224
+ if (IS_WINDOWS) {
225
+ // PowerShell System.Speech (built into Windows, no install needed)
226
+ const psText = safe.replace(/'/g, "''");
227
+ const psRate = Math.max(-10, Math.min(10, Math.round((rate - 150) / 25)));
228
+ return (`powershell -NoProfile -Command "` +
229
+ `Add-Type -AssemblyName System.Speech; ` +
230
+ `$s = New-Object System.Speech.Synthesis.SpeechSynthesizer; ` +
231
+ `$s.Rate = ${psRate}; ` +
232
+ `$s.Speak('${psText}')"`);
233
+ }
234
+ // Linux: try espeak-ng first (modern), then espeak, then spd-say
235
+ if (findBinary('espeak-ng')) {
236
+ return `espeak-ng -s ${rate} "${safe}"`;
237
+ }
238
+ if (findBinary('espeak')) {
239
+ return `espeak -s ${rate} "${safe}"`;
240
+ }
241
+ if (findBinary('spd-say')) {
242
+ return `spd-say "${safe}"`;
243
+ }
244
+ return `echo "TTS unavailable — install espeak-ng: sudo apt install espeak-ng"`;
245
+ }
246
+ /** Clipboard paste image to file path */
247
+ export function clipboardImageCommand(outputPath) {
248
+ if (IS_MAC) {
249
+ if (findBinary('pngpaste')) {
250
+ return { cmd: `pngpaste "${outputPath}"`, available: true };
251
+ }
252
+ return { cmd: '', available: false };
253
+ }
254
+ if (IS_WINDOWS) {
255
+ // PowerShell: save clipboard image — works on all Windows 10+
256
+ const psPath = outputPath.replace(/'/g, "''");
257
+ const ps = (`Add-Type -AssemblyName System.Windows.Forms; ` +
258
+ `$img = [System.Windows.Forms.Clipboard]::GetImage(); ` +
259
+ `if ($img) { $img.Save('${psPath}') } ` +
260
+ `else { throw 'No image in clipboard' }`);
261
+ return { cmd: `powershell -NoProfile -Command "${ps}"`, available: true };
262
+ }
263
+ // Linux: xclip → xsel → wl-paste (Wayland)
264
+ if (findBinary('xclip')) {
265
+ return { cmd: `xclip -selection clipboard -t image/png -o > "${outputPath}"`, available: true };
266
+ }
267
+ if (findBinary('wl-paste')) {
268
+ return { cmd: `wl-paste --type image/png > "${outputPath}"`, available: true };
269
+ }
270
+ return { cmd: '', available: false };
271
+ }
272
+ /**
273
+ * Camera capture command — auto-detects webcam device on each platform.
274
+ *
275
+ * macOS: imagesnap (simple, reliable)
276
+ * Windows: ffmpeg with DirectShow — auto-lists devices to find webcam name
277
+ * Linux: ffmpeg with v4l2 — uses /dev/video0 by default
278
+ */
279
+ export function cameraCaptureCommand(outputPath) {
280
+ if (IS_MAC) {
281
+ if (findBinary('imagesnap')) {
282
+ return { cmd: `imagesnap -w 1 "${outputPath}"`, available: true, waitMs: 3000 };
283
+ }
284
+ // ffmpeg fallback on macOS
285
+ if (findBinary('ffmpeg')) {
286
+ return {
287
+ cmd: `ffmpeg -f avfoundation -framerate 30 -i "0" -frames:v 1 -y "${outputPath}" 2>/dev/null`,
288
+ available: true,
289
+ waitMs: 3000,
290
+ };
291
+ }
292
+ return { cmd: '', available: false, waitMs: 0 };
293
+ }
294
+ if (IS_WINDOWS) {
295
+ if (findBinary('ffmpeg')) {
296
+ // Auto-detect webcam device name via DirectShow listing
297
+ let deviceName = 'Integrated Camera'; // sensible default
298
+ try {
299
+ const listOutput = execSync('ffmpeg -list_devices true -f dshow -i dummy 2>&1', { stdio: 'pipe', timeout: 5000, shell: 'cmd.exe' }).toString();
300
+ // Parse: DirectShow video devices — find first video device
301
+ const videoMatch = listOutput.match(/\] "([^"]+)"\s*\n.*Alternative name/);
302
+ if (videoMatch?.[1]) {
303
+ deviceName = videoMatch[1];
304
+ }
305
+ }
306
+ catch (e) {
307
+ // ffmpeg -list_devices returns exit code 1 but stderr has the data
308
+ const stderr = e?.stderr?.toString() || '';
309
+ const videoMatch = stderr.match(/\] "([^"]+)"\s*\r?\n.*Alternative name/);
310
+ if (videoMatch?.[1]) {
311
+ deviceName = videoMatch[1];
312
+ }
313
+ }
314
+ return {
315
+ cmd: `ffmpeg -f dshow -i video="${deviceName}" -frames:v 1 -y "${outputPath}" 2>NUL`,
316
+ available: true,
317
+ waitMs: 5000,
318
+ };
319
+ }
320
+ return { cmd: '', available: false, waitMs: 0 };
321
+ }
322
+ // Linux: ffmpeg with v4l2 — auto-detect video device
323
+ if (findBinary('ffmpeg')) {
324
+ let device = '/dev/video0';
325
+ try {
326
+ // Check if v4l2-ctl is available for better detection
327
+ if (findBinary('v4l2-ctl')) {
328
+ const devOutput = execSync('v4l2-ctl --list-devices 2>/dev/null', {
329
+ stdio: 'pipe',
330
+ timeout: 3000,
331
+ }).toString();
332
+ const devMatch = devOutput.match(/(\/dev\/video\d+)/);
333
+ if (devMatch?.[1])
334
+ device = devMatch[1];
335
+ }
336
+ }
337
+ catch { /* use default */ }
338
+ return {
339
+ cmd: `ffmpeg -f v4l2 -i ${device} -frames:v 1 -y "${outputPath}" 2>/dev/null`,
340
+ available: true,
341
+ waitMs: 3000,
342
+ };
343
+ }
344
+ return { cmd: '', available: false, waitMs: 0 };
345
+ }
346
+ // ---------------------------------------------------------------------------
347
+ // Voice recording — cross-platform
348
+ // ---------------------------------------------------------------------------
349
+ /** Voice recording binary path per platform */
350
+ export function avlistenPath() {
351
+ if (IS_WINDOWS) {
352
+ return path.join(os.homedir(), '.cache', 'synalux', 'avlisten.exe');
353
+ }
354
+ return path.join(os.homedir(), '.cache', 'synalux', 'avlisten');
355
+ }
356
+ /**
357
+ * Cross-platform audio recording command.
358
+ * Used as a FALLBACK when avlisten is not available.
359
+ *
360
+ * Records a WAV file that can be sent to Gemini for transcription.
361
+ * Returns { cmd, available, outputPath }.
362
+ */
363
+ export function audioRecordCommand(durationSec) {
364
+ const outPath = tempPath('prism-voice-recording.wav');
365
+ if (IS_MAC) {
366
+ // SoX (brew install sox) — most reliable
367
+ if (findBinary('sox')) {
368
+ return {
369
+ cmd: `sox -d -r 16000 -c 1 -b 16 "${outPath}" trim 0 ${durationSec}`,
370
+ available: true,
371
+ outputPath: outPath,
372
+ format: 'audio/wav',
373
+ };
374
+ }
375
+ // ffmpeg with AVFoundation
376
+ if (findBinary('ffmpeg')) {
377
+ return {
378
+ cmd: `ffmpeg -f avfoundation -i ":0" -t ${durationSec} -ar 16000 -ac 1 -y "${outPath}" 2>/dev/null`,
379
+ available: true,
380
+ outputPath: outPath,
381
+ format: 'audio/wav',
382
+ };
383
+ }
384
+ return { cmd: '', available: false, outputPath: '', format: '' };
385
+ }
386
+ if (IS_WINDOWS) {
387
+ // SoX for Windows
388
+ if (findBinary('sox')) {
389
+ return {
390
+ cmd: `sox -d -r 16000 -c 1 -b 16 "${outPath}" trim 0 ${durationSec}`,
391
+ available: true,
392
+ outputPath: outPath,
393
+ format: 'audio/wav',
394
+ };
395
+ }
396
+ // ffmpeg with DirectShow audio
397
+ if (findBinary('ffmpeg')) {
398
+ // Auto-detect microphone
399
+ let micName = 'Microphone';
400
+ try {
401
+ const listOutput = execSync('ffmpeg -list_devices true -f dshow -i dummy 2>&1', { stdio: 'pipe', timeout: 5000, shell: 'cmd.exe' }).toString();
402
+ const audioMatch = listOutput.match(/DirectShow audio devices[\s\S]*?\] "([^"]+)"/);
403
+ if (audioMatch?.[1])
404
+ micName = audioMatch[1];
405
+ }
406
+ catch (e) {
407
+ const stderr = e?.stderr?.toString() || '';
408
+ const audioMatch = stderr.match(/DirectShow audio devices[\s\S]*?\] "([^"]+)"/);
409
+ if (audioMatch?.[1])
410
+ micName = audioMatch[1];
411
+ }
412
+ return {
413
+ cmd: `ffmpeg -f dshow -i audio="${micName}" -t ${durationSec} -ar 16000 -ac 1 -y "${outPath}" 2>NUL`,
414
+ available: true,
415
+ outputPath: outPath,
416
+ format: 'audio/wav',
417
+ };
418
+ }
419
+ // PowerShell + .NET AudioEndpoints as last resort
420
+ return {
421
+ cmd: `powershell -NoProfile -Command "Add-Type -AssemblyName System.Speech; $r = New-Object System.Speech.Recognition.SpeechRecognitionEngine; $r.SetInputToDefaultAudioDevice(); $r.LoadGrammar((New-Object System.Speech.Recognition.DictationGrammar)); $result = $r.Recognize((New-Object TimeSpan(0,0,${durationSec}))); if($result){$result.Text}else{'(no speech detected)'}"`,
422
+ available: true,
423
+ outputPath: '', // returns text directly, not a file
424
+ format: 'text',
425
+ };
426
+ }
427
+ // Linux
428
+ // arecord (ALSA) — most common on Linux
429
+ if (findBinary('arecord')) {
430
+ return {
431
+ cmd: `arecord -d ${durationSec} -f cd -t wav "${outPath}" 2>/dev/null`,
432
+ available: true,
433
+ outputPath: outPath,
434
+ format: 'audio/wav',
435
+ };
436
+ }
437
+ // SoX
438
+ if (findBinary('sox')) {
439
+ return {
440
+ cmd: `sox -d -r 16000 -c 1 -b 16 "${outPath}" trim 0 ${durationSec}`,
441
+ available: true,
442
+ outputPath: outPath,
443
+ format: 'audio/wav',
444
+ };
445
+ }
446
+ // ffmpeg PulseAudio
447
+ if (findBinary('ffmpeg')) {
448
+ return {
449
+ cmd: `ffmpeg -f pulse -i default -t ${durationSec} -ar 16000 -ac 1 -y "${outPath}" 2>/dev/null`,
450
+ available: true,
451
+ outputPath: outPath,
452
+ format: 'audio/wav',
453
+ };
454
+ }
455
+ return { cmd: '', available: false, outputPath: '', format: '' };
456
+ }
457
+ /**
458
+ * Get install instructions for voice recording tools.
459
+ */
460
+ export function voiceInstallInstructions() {
461
+ if (IS_MAC)
462
+ return 'Install SoX: brew install sox';
463
+ if (IS_WINDOWS)
464
+ return 'Install SoX: choco install sox OR winget install sox OR install ffmpeg';
465
+ return 'Install arecord (ALSA): sudo apt install alsa-utils OR SoX: sudo apt install sox';
466
+ }
467
+ /** Dev null path by platform */
468
+ export function devNull() {
469
+ return IS_WINDOWS ? 'NUL' : '/dev/null';
470
+ }
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Terminal UI — ANSI Color Formatting for Prism Agent Terminal
3
+ * =============================================================
4
+ *
5
+ * Provides rich terminal output matching the Synalux VS Code extension's
6
+ * purple/cyan/green color scheme. Uses ANSI escape codes for colors,
7
+ * bold, dim, italic, and underline formatting.
8
+ */
9
+ // ---------------------------------------------------------------------------
10
+ // ANSI Escape Codes
11
+ // ---------------------------------------------------------------------------
12
+ const ESC = '\x1b[';
13
+ export const c = {
14
+ // Reset
15
+ reset: `${ESC}0m`,
16
+ // Styles
17
+ bold: `${ESC}1m`,
18
+ dim: `${ESC}2m`,
19
+ italic: `${ESC}3m`,
20
+ underline: `${ESC}4m`,
21
+ // Brand colors (256-color mode)
22
+ purple: `${ESC}38;5;141m`, // Primary brand — headers, prompts
23
+ cyan: `${ESC}38;5;81m`, // Tools, actions
24
+ green: `${ESC}38;5;114m`, // Success, code
25
+ yellow: `${ESC}38;5;221m`, // Warnings
26
+ red: `${ESC}38;5;203m`, // Errors
27
+ blue: `${ESC}38;5;75m`, // Info, links
28
+ white: `${ESC}38;5;255m`, // AI response text
29
+ gray: `${ESC}38;5;245m`, // Dim text, timestamps
30
+ orange: `${ESC}38;5;215m`, // Highlights
31
+ // Backgrounds
32
+ bgPurple: `${ESC}48;5;141m`,
33
+ bgCyan: `${ESC}48;5;81m`,
34
+ bgDim: `${ESC}48;5;236m`,
35
+ };
36
+ // ---------------------------------------------------------------------------
37
+ // Formatting Helpers
38
+ // ---------------------------------------------------------------------------
39
+ /** Calculate the visible terminal width of a string (strips ANSI, accounts for double-width chars) */
40
+ function visibleWidth(str) {
41
+ // Strip ANSI escape codes
42
+ const stripped = str.replace(/\x1b\[[0-9;]*m/g, '');
43
+ let width = 0;
44
+ for (const char of stripped) {
45
+ const cp = char.codePointAt(0) || 0;
46
+ // Surrogate pair / astral plane characters (emoji, symbols) = 2 columns
47
+ if (cp > 0xFFFF) {
48
+ width += 2;
49
+ }
50
+ else {
51
+ width += 1;
52
+ }
53
+ }
54
+ return width;
55
+ }
56
+ /** Pad a visible string to a fixed column width */
57
+ function padLine(visible, targetWidth) {
58
+ const w = visibleWidth(visible);
59
+ const padding = Math.max(0, targetWidth - w);
60
+ return visible + ' '.repeat(padding);
61
+ }
62
+ /** Print the startup header — compact VS Code-style top bar */
63
+ export function printBanner(opts) {
64
+ console.log('');
65
+ // ─── Top bar: Prism Agent CLOUD ✨ Model 👤 user PLAN ──
66
+ const cloudBadge = `${c.bgCyan}${c.bold} CLOUD ${c.reset}`;
67
+ const modelChip = opts.model ? `${c.bgDim} ✨ ${c.bold}${opts.model} ${c.reset}` : '';
68
+ const userStr = opts.name || opts.email || '';
69
+ const userChip = userStr ? ` ${c.dim}👤${c.reset} ${userStr}` : '';
70
+ const planBadge = opts.plan ? ` ${c.bgPurple}${c.bold} ${opts.plan.toUpperCase()} ${c.reset}` : '';
71
+ console.log(` ${c.bold}${c.purple}Prism Agent${c.reset} ${cloudBadge} ${modelChip}${userChip}${planBadge}`);
72
+ // ─── Sub-bar: project · cwd · tools ───────────────────────
73
+ const cwdShort = opts.cwd.length > 30 ? '...' + opts.cwd.slice(-27) : opts.cwd;
74
+ const mcpStr = opts.mcpServers ? ` ${c.dim}·${c.reset} ${opts.mcpServers} MCP` : '';
75
+ console.log(` ${c.dim}📂 ${opts.project} · 📁 ${cwdShort} · 🔧 ${opts.toolCount} tools${mcpStr}${c.reset}`);
76
+ console.log('');
77
+ }
78
+ /** Build the input prompt string — clean prompt for the text input line */
79
+ export function buildPromptStr() {
80
+ return `${c.purple}${c.bold}❯${c.reset} `;
81
+ }
82
+ /** Action button definitions with keyboard shortcuts */
83
+ export const ACTION_BUTTONS = [
84
+ { icon: '📂', label: 'Image', cmd: '/image', key: '^I' },
85
+ { icon: '📎', label: 'Paste', cmd: '/paste', key: '^P' },
86
+ { icon: '🎤', label: 'Voice', cmd: '/voice', key: '^V' },
87
+ { icon: '💬', label: 'Speak', cmd: '/speak', key: '^S' },
88
+ ];
89
+ /**
90
+ * Print the action buttons bar with keyboard shortcuts underneath.
91
+ * Example output:
92
+ * 📂 Image 📎 Paste 🎤 Voice 💬 Speak
93
+ * ^I ^P ^V ^S
94
+ */
95
+ export function printActionBar() {
96
+ // Top line: icons + labels
97
+ const topParts = ACTION_BUTTONS.map(b => `${c.bgDim} ${b.icon} ${c.cyan}${b.label}${c.reset}${c.bgDim} ${c.reset}`);
98
+ console.log(` ${topParts.join(' ')}`);
99
+ // Bottom line: keyboard shortcuts aligned under each button
100
+ const shortcutParts = ACTION_BUTTONS.map(b => {
101
+ // Pad shortcut to match button width: " icon label " = 2+label.length+2 = label.length+4
102
+ const btnWidth = b.label.length + 4; // space + emoji(2) + space + label
103
+ const pad = Math.max(0, Math.floor((btnWidth - b.key.length) / 2));
104
+ return ' '.repeat(pad) + `${c.dim}${b.key}${c.reset}` + ' '.repeat(Math.max(0, btnWidth - pad - b.key.length));
105
+ });
106
+ console.log(` ${shortcutParts.join(' ')}`);
107
+ console.log(` ${c.dim}Enter for menu · / + Tab for commands${c.reset}`);
108
+ console.log('');
109
+ }
110
+ /** Show readline-based action menu as fallback (Enter on empty line) */
111
+ export function showActionMenu(rl) {
112
+ return new Promise((resolve) => {
113
+ console.log(`\n ${c.bold}${c.purple}⚡ Actions${c.reset}`);
114
+ for (let i = 0; i < ACTION_BUTTONS.length; i++) {
115
+ const item = ACTION_BUTTONS[i];
116
+ console.log(` ${c.cyan}${c.bold}${i + 1}${c.reset} ${item.icon} ${item.label}`);
117
+ }
118
+ console.log('');
119
+ rl.question(` ${c.dim}Select (1-${ACTION_BUTTONS.length}) or Enter to cancel:${c.reset} `, (answer) => {
120
+ const num = parseInt(answer.trim(), 10);
121
+ if (num >= 1 && num <= ACTION_BUTTONS.length) {
122
+ resolve(ACTION_BUTTONS[num - 1].cmd);
123
+ }
124
+ else {
125
+ resolve(null);
126
+ }
127
+ });
128
+ });
129
+ }
130
+ /** Format a tool call for display */
131
+ export function formatToolCall(name, args) {
132
+ const argsStr = JSON.stringify(args);
133
+ const truncated = argsStr.length > 80 ? argsStr.substring(0, 77) + '...' : argsStr;
134
+ return ` ${c.cyan}${c.bold}⚡ ${name}${c.reset}${c.dim}(${truncated})${c.reset}`;
135
+ }
136
+ /** Format a tool result summary */
137
+ export function formatToolResult(name, success) {
138
+ return success
139
+ ? ` ${c.green}✓${c.reset} ${c.dim}${name} completed${c.reset}`
140
+ : ` ${c.red}✗${c.reset} ${c.dim}${name} failed${c.reset}`;
141
+ }
142
+ /** Format an AI response */
143
+ export function formatResponse(text) {
144
+ // Highlight code blocks with green
145
+ let formatted = text.replace(/```(\w+)?\n([\s\S]*?)```/g, (_, lang, code) => `${c.dim}┌─ ${lang || 'code'} ──${c.reset}\n${c.green}${code.trimEnd()}${c.reset}\n${c.dim}└──────${c.reset}`);
146
+ // Highlight inline code with cyan
147
+ formatted = formatted.replace(/`([^`]+)`/g, `${c.cyan}$1${c.reset}`);
148
+ // Highlight bold with white bold
149
+ formatted = formatted.replace(/\*\*([^*]+)\*\*/g, `${c.bold}${c.white}$1${c.reset}`);
150
+ return `\n${formatted}\n`;
151
+ }
152
+ /** Format an error */
153
+ export function formatError(message) {
154
+ return `\n${c.red}${c.bold}✗ Error:${c.reset} ${c.red}${message}${c.reset}\n`;
155
+ }
156
+ /** Format a success message */
157
+ export function formatSuccess(message) {
158
+ return `${c.green}✓${c.reset} ${message}`;
159
+ }
160
+ /** Format a warning */
161
+ export function formatWarning(message) {
162
+ return `${c.yellow}⚠${c.reset} ${c.yellow}${message}${c.reset}`;
163
+ }
164
+ /** Format the help menu */
165
+ export function printHelp() {
166
+ console.log('');
167
+ console.log(` ${c.bold}${c.purple}Commands:${c.reset}`);
168
+ console.log(` ${c.cyan}/image ${c.dim}<path> [question]${c.reset} — Analyze an image`);
169
+ console.log(` ${c.cyan}/voice ${c.dim}[seconds]${c.reset} — Record & transcribe speech`);
170
+ console.log(` ${c.cyan}/camera ${c.dim}[question]${c.reset} — Capture photo & analyze`);
171
+ console.log(` ${c.cyan}/speak${c.reset} — Toggle text-to-speech`);
172
+ console.log(` ${c.cyan}/paste${c.reset} — Paste clipboard image`);
173
+ console.log(` ${c.cyan}/search ${c.dim}<query>${c.reset} — Search Prism memory`);
174
+ console.log(` ${c.cyan}/todos${c.reset} — Show open TODOs`);
175
+ console.log(` ${c.cyan}/context${c.reset} — Show loaded context`);
176
+ console.log(` ${c.cyan}/tools${c.reset} — List available tools`);
177
+ console.log(` ${c.cyan}/exit${c.reset} — Quit`);
178
+ console.log('');
179
+ }
180
+ /** Format MCP connection status */
181
+ export function formatMcpConnect(name, tools, success) {
182
+ if (success) {
183
+ return ` ${c.green}✓${c.reset} ${c.bold}${name}${c.reset} ${c.dim}— ${tools.length} tool(s):${c.reset} ${c.cyan}${tools.join(', ')}${c.reset}`;
184
+ }
185
+ return ` ${c.red}✗${c.reset} ${c.bold}${name}${c.reset} ${c.dim}— connection failed${c.reset}`;
186
+ }
187
+ /** Format context loaded */
188
+ export function formatContextLoaded(todoCount, keywordCount) {
189
+ return formatSuccess(`Loaded ${c.bold}${todoCount}${c.reset} TODOs, ${c.bold}${keywordCount}${c.reset} keywords`);
190
+ }
191
+ /** Print thinking indicator on its own line */
192
+ export function printThinking() {
193
+ console.log(`${c.dim}${c.italic} thinking...${c.reset}`);
194
+ }
195
+ /** Clear thinking indicator — move cursor up one line and erase it */
196
+ export function clearThinking() {
197
+ process.stdout.write('\x1b[1A\x1b[2K');
198
+ }