otherwise-cli 0.1.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 (81) hide show
  1. package/README.md +193 -0
  2. package/bin/otherwise.js +5 -0
  3. package/frontend/404.html +84 -0
  4. package/frontend/assets/OpenDyslexic3-Bold-CDyRs55Y.ttf +0 -0
  5. package/frontend/assets/OpenDyslexic3-Regular-CIBXa4WE.ttf +0 -0
  6. package/frontend/assets/__vite-browser-external-BIHI7g3E.js +1 -0
  7. package/frontend/assets/conversational-worker-CeKiciGk.js +2929 -0
  8. package/frontend/assets/dictation-worker-D0aYfq8b.js +29 -0
  9. package/frontend/assets/gemini-color-CgSQmmva.png +0 -0
  10. package/frontend/assets/index-BLux5ps4.js +21 -0
  11. package/frontend/assets/index-Blh8_TEM.js +5272 -0
  12. package/frontend/assets/index-BpQ1PuKu.js +18 -0
  13. package/frontend/assets/index-Df737c8w.css +1 -0
  14. package/frontend/assets/index-xaYHL6wb.js +113 -0
  15. package/frontend/assets/ort-wasm-simd-threaded.asyncify-BynIiDiv.wasm +0 -0
  16. package/frontend/assets/ort-wasm-simd-threaded.jsep-B0T3yYHD.wasm +0 -0
  17. package/frontend/assets/transformers-tULNc5V3.js +31 -0
  18. package/frontend/assets/tts-worker-DPJWqT7N.js +2899 -0
  19. package/frontend/assets/voice-mode-worker-GzvIE_uh.js +2927 -0
  20. package/frontend/assets/worker-2d5ABSLU.js +31 -0
  21. package/frontend/banner.png +0 -0
  22. package/frontend/favicon.svg +3 -0
  23. package/frontend/google55e5ec47ee14a5f8.html +1 -0
  24. package/frontend/index.html +234 -0
  25. package/frontend/manifest.json +17 -0
  26. package/frontend/pdf.worker.min.mjs +21 -0
  27. package/frontend/robots.txt +5 -0
  28. package/frontend/sitemap.xml +27 -0
  29. package/package.json +81 -0
  30. package/src/agent/index.js +1066 -0
  31. package/src/agent/location.js +51 -0
  32. package/src/agent/prompt.js +548 -0
  33. package/src/agent/tools.js +4372 -0
  34. package/src/browser/detect.js +68 -0
  35. package/src/browser/session.js +1109 -0
  36. package/src/config.js +137 -0
  37. package/src/email/client.js +503 -0
  38. package/src/index.js +557 -0
  39. package/src/inference/anthropic.js +113 -0
  40. package/src/inference/google.js +373 -0
  41. package/src/inference/index.js +81 -0
  42. package/src/inference/ollama.js +383 -0
  43. package/src/inference/openai.js +140 -0
  44. package/src/inference/openrouter.js +378 -0
  45. package/src/inference/xai.js +200 -0
  46. package/src/logBridge.js +9 -0
  47. package/src/models.js +146 -0
  48. package/src/remote/client.js +225 -0
  49. package/src/scheduler/cron.js +243 -0
  50. package/src/server.js +3876 -0
  51. package/src/storage/db.js +1135 -0
  52. package/src/storage/supabase.js +364 -0
  53. package/src/tunnel/cloudflare.js +241 -0
  54. package/src/ui/components/App.jsx +687 -0
  55. package/src/ui/components/BrowserSelect.jsx +111 -0
  56. package/src/ui/components/FilePicker.jsx +472 -0
  57. package/src/ui/components/Header.jsx +444 -0
  58. package/src/ui/components/HelpPanel.jsx +173 -0
  59. package/src/ui/components/HistoryPanel.jsx +158 -0
  60. package/src/ui/components/MessageList.jsx +235 -0
  61. package/src/ui/components/ModelSelector.jsx +304 -0
  62. package/src/ui/components/PromptInput.jsx +515 -0
  63. package/src/ui/components/StreamingResponse.jsx +134 -0
  64. package/src/ui/components/ThinkingIndicator.jsx +365 -0
  65. package/src/ui/components/ToolExecution.jsx +714 -0
  66. package/src/ui/components/index.js +82 -0
  67. package/src/ui/context/TerminalContext.jsx +150 -0
  68. package/src/ui/context/index.js +13 -0
  69. package/src/ui/hooks/index.js +16 -0
  70. package/src/ui/hooks/useChatState.js +675 -0
  71. package/src/ui/hooks/useCommands.js +280 -0
  72. package/src/ui/hooks/useFileAttachments.js +216 -0
  73. package/src/ui/hooks/useKeyboardShortcuts.js +173 -0
  74. package/src/ui/hooks/useNotifications.js +185 -0
  75. package/src/ui/hooks/useTerminalSize.js +151 -0
  76. package/src/ui/hooks/useWebSocket.js +273 -0
  77. package/src/ui/index.js +94 -0
  78. package/src/ui/ink-runner.js +22 -0
  79. package/src/ui/utils/formatters.js +424 -0
  80. package/src/ui/utils/index.js +6 -0
  81. package/src/ui/utils/markdown.js +166 -0
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Ink UI Runner Script
3
+ * This script is invoked by tsx to run the Ink-based CLI
4
+ * It handles JSX transpilation at runtime
5
+ */
6
+
7
+ import chalk from 'chalk';
8
+ import { startInkCli } from './index.js';
9
+
10
+ // Parse command line arguments
11
+ const serverUrl = process.argv[2] || 'http://localhost:3000';
12
+ const showBanner = process.argv[3] !== 'false';
13
+ const isRemoteMode = process.argv[4] === 'remote';
14
+
15
+ // Start the Ink CLI
16
+ const instance = await startInkCli({ serverUrl, showBanner, isRemoteMode });
17
+
18
+ // Wait for the user to exit
19
+ await instance.waitUntilExit();
20
+
21
+ // Show goodbye message
22
+ console.log(chalk.dim.italic('\nGoodbye'));
@@ -0,0 +1,424 @@
1
+ /**
2
+ * Formatting utilities for the Ink UI
3
+ * Ported and enhanced from chat/renderer.js
4
+ *
5
+ * Includes responsive utilities for terminal width adaptation
6
+ */
7
+
8
+ /**
9
+ * Strip ANSI escape codes from string
10
+ * @param {string} str - String with ANSI codes
11
+ * @returns {string} - Clean string
12
+ */
13
+ export function stripAnsi(str) {
14
+ // eslint-disable-next-line no-control-regex
15
+ return str.replace(/\x1B\[[0-9;]*[a-zA-Z]/g, "");
16
+ }
17
+
18
+ /**
19
+ * Truncate string to max length with ellipsis
20
+ * @param {string} str - String to truncate
21
+ * @param {number} maxLen - Maximum length
22
+ * @returns {string} - Truncated string
23
+ */
24
+ export function truncate(str, maxLen) {
25
+ if (!str) return "";
26
+ const stripped = stripAnsi(str);
27
+ if (stripped.length <= maxLen) return str;
28
+ return str.substring(0, maxLen - 1) + "…";
29
+ }
30
+
31
+ /**
32
+ * Responsive truncation that ensures minimum visible content
33
+ * @param {string} text - Text to truncate
34
+ * @param {number} availableWidth - Available width in characters
35
+ * @param {object} options - Options
36
+ * @param {number} options.minChars - Minimum characters to show (default: 10)
37
+ * @param {string} options.ellipsis - Ellipsis character (default: '…')
38
+ * @returns {string} - Truncated text
39
+ */
40
+ export function responsiveTruncate(text, availableWidth, options = {}) {
41
+ if (!text) return "";
42
+
43
+ const { minChars = 10, ellipsis = "…" } = options;
44
+ const maxLen = Math.max(minChars, availableWidth);
45
+ const stripped = stripAnsi(text);
46
+
47
+ if (stripped.length <= maxLen) return text;
48
+
49
+ return text.substring(0, maxLen - ellipsis.length) + ellipsis;
50
+ }
51
+
52
+ /**
53
+ * Smart path truncation - keeps filename visible, truncates middle of path
54
+ * @param {string} path - File path to truncate
55
+ * @param {number} maxLen - Maximum length
56
+ * @returns {string} - Truncated path
57
+ */
58
+ export function truncatePath(path, maxLen) {
59
+ if (!path || path.length <= maxLen) return path || "";
60
+
61
+ const parts = path.split("/");
62
+ const filename = parts.pop();
63
+
64
+ // If filename alone is too long, truncate it
65
+ if (filename.length >= maxLen - 3) {
66
+ return "…" + filename.slice(-(maxLen - 1));
67
+ }
68
+
69
+ // Keep filename, truncate directory path
70
+ const remainingLen = maxLen - filename.length - 4; // "…/filename"
71
+ if (remainingLen <= 0) {
72
+ return "…/" + filename;
73
+ }
74
+
75
+ const dirPath = parts.join("/");
76
+ if (dirPath.length <= remainingLen) {
77
+ return dirPath + "/" + filename;
78
+ }
79
+
80
+ return "…" + dirPath.slice(-remainingLen) + "/" + filename;
81
+ }
82
+
83
+ /**
84
+ * Calculate proportionally scaled column widths for tables
85
+ * @param {Array<{width: number, minWidth?: number}>} columns - Column definitions
86
+ * @param {number} terminalWidth - Current terminal width
87
+ * @param {number} baseWidth - Base width the columns were designed for (default: 100)
88
+ * @returns {Array<number>} - Scaled column widths
89
+ */
90
+ export function scaleColumnWidths(columns, terminalWidth, baseWidth = 100) {
91
+ const scale = terminalWidth / baseWidth;
92
+
93
+ return columns.map((col) => {
94
+ const scaled = Math.floor(col.width * scale);
95
+ return Math.max(col.minWidth || 5, scaled);
96
+ });
97
+ }
98
+
99
+ /**
100
+ * Word wrap text to fit within a width, respecting word boundaries
101
+ * @param {string} text - Text to wrap
102
+ * @param {number} width - Maximum line width
103
+ * @returns {string[]} - Array of wrapped lines
104
+ */
105
+ export function wordWrap(text, width) {
106
+ if (!text) return [];
107
+ if (width <= 0) return [text];
108
+
109
+ const words = text.split(/\s+/);
110
+ const lines = [];
111
+ let currentLine = "";
112
+
113
+ for (const word of words) {
114
+ if (currentLine.length === 0) {
115
+ currentLine = word;
116
+ } else if (currentLine.length + 1 + word.length <= width) {
117
+ currentLine += " " + word;
118
+ } else {
119
+ lines.push(currentLine);
120
+ currentLine = word;
121
+ }
122
+ }
123
+
124
+ if (currentLine) {
125
+ lines.push(currentLine);
126
+ }
127
+
128
+ return lines;
129
+ }
130
+
131
+ /**
132
+ * Format file size in human-readable format
133
+ * @param {number} bytes - Size in bytes
134
+ * @returns {string} - Formatted size
135
+ */
136
+ export function formatSize(bytes) {
137
+ if (bytes === null || bytes === undefined) return "";
138
+ if (bytes < 1024) return `${bytes} B`;
139
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
140
+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
141
+ }
142
+
143
+ /**
144
+ * Format duration in human-readable format
145
+ * @param {number} ms - Duration in milliseconds
146
+ * @returns {string} - Formatted duration
147
+ */
148
+ export function formatDuration(ms) {
149
+ if (ms < 1000) return `${ms}ms`;
150
+ if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
151
+ const mins = Math.floor(ms / 60000);
152
+ const secs = ((ms % 60000) / 1000).toFixed(0);
153
+ return `${mins}m ${secs}s`;
154
+ }
155
+
156
+ /**
157
+ * Format elapsed time in human-readable format
158
+ * @param {number} ms - Elapsed milliseconds
159
+ * @returns {string} - Formatted time
160
+ */
161
+ export function formatElapsed(ms) {
162
+ if (ms < 1000) return "";
163
+ if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
164
+ const mins = Math.floor(ms / 60000);
165
+ const secs = Math.floor((ms % 60000) / 1000);
166
+ return `${mins}m ${secs}s`;
167
+ }
168
+
169
+ /**
170
+ * Format relative time from date string or Date object
171
+ * @param {string|Date} dateInput - Date to format
172
+ * @returns {string} - Relative time string
173
+ */
174
+ export function formatRelativeTime(dateInput) {
175
+ if (!dateInput) return "";
176
+
177
+ const date = dateInput instanceof Date ? dateInput : new Date(dateInput);
178
+ const now = new Date();
179
+ const diffMs = now - date;
180
+ const diffMins = Math.floor(diffMs / 60000);
181
+ const diffHours = Math.floor(diffMs / 3600000);
182
+ const diffDays = Math.floor(diffMs / 86400000);
183
+
184
+ if (diffMins < 1) return "just now";
185
+ if (diffMins < 60) return `${diffMins}m ago`;
186
+ if (diffHours < 24) return `${diffHours}h ago`;
187
+ if (diffDays < 7) return `${diffDays}d ago`;
188
+
189
+ return date.toLocaleDateString("en-US", {
190
+ month: "short",
191
+ day: "numeric",
192
+ year: date.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
193
+ });
194
+ }
195
+
196
+ /**
197
+ * Format character count for display
198
+ * @param {number} chars - Character count
199
+ * @returns {string} - Formatted count
200
+ */
201
+ export function formatCharCount(chars) {
202
+ if (chars > 1000) {
203
+ return `${(chars / 1000).toFixed(1)}k chars`;
204
+ }
205
+ return `${chars} chars`;
206
+ }
207
+
208
+ /**
209
+ * Format token count for display
210
+ * @param {number} tokens - Token count
211
+ * @returns {string} - Formatted count
212
+ */
213
+ export function formatTokenCount(tokens) {
214
+ if (tokens > 1000) {
215
+ return `${(tokens / 1000).toFixed(1)}k`;
216
+ }
217
+ return `${tokens}`;
218
+ }
219
+
220
+ /**
221
+ * Get friendly model name from model ID
222
+ * @param {string} modelId - Full model ID
223
+ * @returns {string} - Friendly name
224
+ */
225
+ export function getFriendlyModelName(modelId) {
226
+ if (!modelId) return "";
227
+
228
+ const modelNames = {
229
+ // Anthropic
230
+ "claude-sonnet-4-20250514": "Sonnet 4",
231
+ "claude-sonnet-4-5-20250929": "Sonnet 4.5",
232
+ "claude-opus-4-20250514": "Opus 4",
233
+ "claude-3-5-sonnet-20241022": "Sonnet 3.5",
234
+ "claude-3-5-haiku-20241022": "Haiku 3.5",
235
+ // OpenAI
236
+ "gpt-4o": "GPT-4o",
237
+ "gpt-4o-mini": "GPT-4o Mini",
238
+ o1: "o1",
239
+ "o1-mini": "o1 Mini",
240
+ "o1-preview": "o1 Preview",
241
+ "o3-mini": "o3 Mini",
242
+ // Google
243
+ "gemini-2.0-flash": "Gemini 2.0 Flash",
244
+ "gemini-2.0-flash-thinking": "Gemini 2.0 Thinking",
245
+ "gemini-1.5-pro": "Gemini 1.5 Pro",
246
+ "gemini-1.5-flash": "Gemini 1.5 Flash",
247
+ // xAI
248
+ "grok-2": "Grok 2",
249
+ "grok-2-vision": "Grok 2 Vision",
250
+ "grok-beta": "Grok Beta",
251
+ };
252
+
253
+ // Check for exact match
254
+ if (modelNames[modelId]) {
255
+ return modelNames[modelId];
256
+ }
257
+
258
+ // Check for partial match (for versioned models)
259
+ for (const [id, name] of Object.entries(modelNames)) {
260
+ if (modelId.includes(id) || id.includes(modelId)) {
261
+ return name;
262
+ }
263
+ }
264
+
265
+ // Handle Ollama models
266
+ if (modelId.startsWith("ollama:")) {
267
+ return modelId.replace("ollama:", "").split(":")[0];
268
+ }
269
+
270
+ // Fallback: clean up the model ID
271
+ return modelId
272
+ .replace(/-\d{8}$/, "") // Remove date suffix
273
+ .replace(/-/g, " ")
274
+ .replace(/\b\w/g, (l) => l.toUpperCase());
275
+ }
276
+
277
+ /**
278
+ * Tool icons mapping
279
+ */
280
+ export const TOOL_ICONS = {
281
+ // File operations
282
+ read_file: "📄",
283
+ write_file: "📝",
284
+ edit_file: "✏️",
285
+ list_dir: "📁",
286
+ list_directory: "📁",
287
+ read_dir: "📁",
288
+ search_files: "🔎",
289
+
290
+ // Shell
291
+ shell: "💻",
292
+ run_command: "💻",
293
+ execute_command: "💻",
294
+
295
+ // Web & Browser
296
+ web_search: "🔍",
297
+ fetch_url: "🌐",
298
+ browser_launch: "🌐",
299
+ browser_navigate: "🧭",
300
+ browser_read: "📖",
301
+ browser_click: "👆",
302
+ browser_type: "⌨️",
303
+ browser_close: "❌",
304
+ browser_screenshot: "📸",
305
+ deep_research: "🔬",
306
+
307
+ // Memory (chat history)
308
+ search_memory: "🧠",
309
+ read_memory: "💭",
310
+
311
+ // Communication
312
+ send_email: "📧",
313
+ check_email: "📬",
314
+
315
+ // Meta
316
+ set_title: "🏷️",
317
+ schedule_task: "⏰",
318
+ schedule_task_once: "⏰",
319
+ list_scheduled_tasks: "📋",
320
+ cancel_task: "🚫",
321
+
322
+ // Default
323
+ default: "🔧",
324
+ };
325
+
326
+ /**
327
+ * Get icon for a tool
328
+ * @param {string} toolName - Tool name
329
+ * @returns {string} - Icon
330
+ */
331
+ export function getToolIcon(toolName) {
332
+ return TOOL_ICONS[toolName] || TOOL_ICONS.default;
333
+ }
334
+
335
+ /**
336
+ * Format tool name for display
337
+ * @param {string} toolName - Tool name
338
+ * @returns {string} - Formatted name
339
+ */
340
+ export function formatToolName(toolName) {
341
+ return toolName.replace(/_/g, " ").replace(/\b\w/g, (l) => l.toUpperCase());
342
+ }
343
+
344
+ /**
345
+ * Get primary argument from tool args for display
346
+ * @param {string} toolName - Tool name
347
+ * @param {object} args - Tool arguments
348
+ * @returns {string} - Primary argument string
349
+ */
350
+ export function getPrimaryArg(toolName, args = {}) {
351
+ switch (toolName) {
352
+ case "read_file":
353
+ case "write_file":
354
+ case "edit_file":
355
+ return args.path || "";
356
+ case "list_dir":
357
+ case "read_dir":
358
+ return args.path || args.dir || ".";
359
+ case "shell":
360
+ case "run_command":
361
+ return args.command ? truncate(args.command, 50) : "";
362
+ case "web_search":
363
+ return args.query ? `"${truncate(args.query, 40)}"` : "";
364
+ case "fetch_url":
365
+ case "browser_navigate":
366
+ return args.url ? truncate(args.url, 50) : "";
367
+ case "send_email":
368
+ return args.to || "";
369
+ case "set_title":
370
+ return args.title ? `"${args.title}"` : "";
371
+ case "search_files":
372
+ return args.query ? `"${truncate(args.query, 40)}"` : "";
373
+ case "search_memory":
374
+ return args.query ? `"${truncate(args.query, 40)}"` : "";
375
+ case "read_memory":
376
+ return args.chat_id
377
+ ? `Chat #${args.chat_id}`
378
+ : args.chatId
379
+ ? `Chat #${args.chatId}`
380
+ : "";
381
+ case "deep_research":
382
+ return args.topic ? `"${truncate(args.topic, 40)}"` : "";
383
+ case "browser_screenshot":
384
+ return args.filename || "screenshot";
385
+ default:
386
+ // Try to find a sensible primary arg
387
+ const keys = Object.keys(args);
388
+ if (keys.length > 0) {
389
+ const val = args[keys[0]];
390
+ if (typeof val === "string") {
391
+ return truncate(val, 40);
392
+ }
393
+ }
394
+ return "";
395
+ }
396
+ }
397
+
398
+ /**
399
+ * Tools that produce streaming output
400
+ */
401
+ const STREAMING_TOOLS = new Set(["write_file", "edit_file"]);
402
+
403
+ /**
404
+ * Check if a tool is a streaming tool
405
+ * @param {string} toolName - Tool name
406
+ * @returns {boolean} - True if streaming
407
+ */
408
+ export function isStreamingTool(toolName) {
409
+ return STREAMING_TOOLS.has(toolName);
410
+ }
411
+
412
+ /**
413
+ * Hidden tools that shouldn't be displayed
414
+ */
415
+ const HIDDEN_TOOLS = new Set(["set_title"]);
416
+
417
+ /**
418
+ * Check if a tool should be hidden from display
419
+ * @param {string} toolName - Tool name
420
+ * @returns {boolean} - True if hidden
421
+ */
422
+ export function isHiddenTool(toolName) {
423
+ return HIDDEN_TOOLS.has(toolName);
424
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * UI utilities index
3
+ */
4
+
5
+ export * from './formatters.js';
6
+ export * from './markdown.js';
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Terminal markdown rendering utilities
3
+ * Uses marked + marked-terminal for rendering markdown in the terminal
4
+ *
5
+ * Refactored for responsive rendering - width is now dynamic based on terminal size
6
+ */
7
+
8
+ import { Marked } from 'marked';
9
+ import { markedTerminal } from 'marked-terminal';
10
+ import chalk from 'chalk';
11
+ import { highlight } from 'cli-highlight';
12
+ import { getTerminalWidth } from '../hooks/useTerminalSize.js';
13
+
14
+ /**
15
+ * Cache for marked instances by width to avoid recreating on every render
16
+ * @type {Map<number, Marked>}
17
+ */
18
+ const markedCache = new Map();
19
+
20
+ /**
21
+ * Default markdown rendering options
22
+ */
23
+ const DEFAULT_OPTIONS = {
24
+ // Colors and styles
25
+ firstHeading: chalk.bold.cyan,
26
+ heading: chalk.bold.white,
27
+ strong: chalk.bold,
28
+ em: chalk.italic,
29
+ codespan: chalk.bgGray.white,
30
+ del: chalk.strikethrough,
31
+ link: chalk.cyan,
32
+ href: chalk.cyan.underline,
33
+ code: chalk.gray,
34
+ blockquote: chalk.gray.italic,
35
+ // Options
36
+ reflowText: true,
37
+ showSectionPrefix: false,
38
+ tab: 2,
39
+ unescape: true,
40
+ };
41
+
42
+ /**
43
+ * Get or create a marked instance for a specific width
44
+ * @param {number} width - Terminal width for text reflow
45
+ * @returns {Marked} Configured marked instance
46
+ */
47
+ function getMarkedInstance(width) {
48
+ // Round width to nearest 10 to improve cache hits
49
+ const cacheKey = Math.round(width / 10) * 10;
50
+
51
+ if (markedCache.has(cacheKey)) {
52
+ return markedCache.get(cacheKey);
53
+ }
54
+
55
+ const marked = new Marked();
56
+ marked.use(markedTerminal({
57
+ ...DEFAULT_OPTIONS,
58
+ width: cacheKey,
59
+ }));
60
+
61
+ // Limit cache size to prevent memory leaks
62
+ if (markedCache.size > 10) {
63
+ const firstKey = markedCache.keys().next().value;
64
+ markedCache.delete(firstKey);
65
+ }
66
+
67
+ markedCache.set(cacheKey, marked);
68
+ return marked;
69
+ }
70
+
71
+ /**
72
+ * Render markdown to terminal-formatted text
73
+ * Width is now dynamic - either passed explicitly or read from terminal
74
+ *
75
+ * @param {string} text - Markdown text
76
+ * @param {number} [width] - Optional width override (defaults to terminal width)
77
+ * @returns {string} - Terminal-formatted text
78
+ */
79
+ export function renderMarkdown(text, width = null) {
80
+ if (!text) return '';
81
+
82
+ // Determine effective width
83
+ // Use provided width, or get current terminal width, with sensible bounds
84
+ const terminalWidth = width ?? getTerminalWidth();
85
+ const effectiveWidth = Math.max(40, Math.min(terminalWidth - 4, 120));
86
+
87
+ try {
88
+ const marked = getMarkedInstance(effectiveWidth);
89
+ const result = marked.parse(text);
90
+ // Clean up extra newlines
91
+ return result.replace(/\n{3,}/g, '\n\n').trim();
92
+ } catch (e) {
93
+ // Fallback to plain text if parsing fails
94
+ return text;
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Render markdown with explicit width from terminal context
100
+ * Use this when you have access to useTerminal() hook
101
+ *
102
+ * @param {string} text - Markdown text
103
+ * @param {number} contentWidth - Width from terminal context
104
+ * @returns {string} - Terminal-formatted text
105
+ */
106
+ export function renderMarkdownWithWidth(text, contentWidth) {
107
+ return renderMarkdown(text, contentWidth);
108
+ }
109
+
110
+ /**
111
+ * Highlight code with syntax highlighting
112
+ * @param {string} code - Code to highlight
113
+ * @param {string} language - Language for highlighting
114
+ * @returns {string} - Highlighted code
115
+ */
116
+ export function highlightCode(code, language = '') {
117
+ try {
118
+ return highlight(code, {
119
+ language: language || 'plaintext',
120
+ ignoreIllegals: true,
121
+ });
122
+ } catch (e) {
123
+ // Fallback to plain code if highlighting fails
124
+ return code;
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Format a code block with language label
130
+ * @param {string} code - Code content
131
+ * @param {string} language - Language name
132
+ * @param {number} [maxWidth] - Maximum width for the code block
133
+ * @returns {string} - Formatted code block
134
+ */
135
+ export function formatCodeBlock(code, language = '', maxWidth = null) {
136
+ const highlighted = highlightCode(code, language);
137
+ const langLabel = language ? `[${language}]` : '';
138
+
139
+ // If maxWidth provided, truncate long lines
140
+ if (maxWidth) {
141
+ const lines = highlighted.split('\n').map(line => {
142
+ if (line.length > maxWidth) {
143
+ return line.slice(0, maxWidth - 3) + '...';
144
+ }
145
+ return line;
146
+ });
147
+ return `${langLabel}\n${lines.join('\n')}`;
148
+ }
149
+
150
+ return `${langLabel}\n${highlighted}`;
151
+ }
152
+
153
+ /**
154
+ * Clear the marked cache (useful for testing or when terminal resizes dramatically)
155
+ */
156
+ export function clearMarkdownCache() {
157
+ markedCache.clear();
158
+ }
159
+
160
+ export default {
161
+ renderMarkdown,
162
+ renderMarkdownWithWidth,
163
+ highlightCode,
164
+ formatCodeBlock,
165
+ clearMarkdownCache,
166
+ };