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.
- package/README.md +193 -0
- package/bin/otherwise.js +5 -0
- package/frontend/404.html +84 -0
- package/frontend/assets/OpenDyslexic3-Bold-CDyRs55Y.ttf +0 -0
- package/frontend/assets/OpenDyslexic3-Regular-CIBXa4WE.ttf +0 -0
- package/frontend/assets/__vite-browser-external-BIHI7g3E.js +1 -0
- package/frontend/assets/conversational-worker-CeKiciGk.js +2929 -0
- package/frontend/assets/dictation-worker-D0aYfq8b.js +29 -0
- package/frontend/assets/gemini-color-CgSQmmva.png +0 -0
- package/frontend/assets/index-BLux5ps4.js +21 -0
- package/frontend/assets/index-Blh8_TEM.js +5272 -0
- package/frontend/assets/index-BpQ1PuKu.js +18 -0
- package/frontend/assets/index-Df737c8w.css +1 -0
- package/frontend/assets/index-xaYHL6wb.js +113 -0
- package/frontend/assets/ort-wasm-simd-threaded.asyncify-BynIiDiv.wasm +0 -0
- package/frontend/assets/ort-wasm-simd-threaded.jsep-B0T3yYHD.wasm +0 -0
- package/frontend/assets/transformers-tULNc5V3.js +31 -0
- package/frontend/assets/tts-worker-DPJWqT7N.js +2899 -0
- package/frontend/assets/voice-mode-worker-GzvIE_uh.js +2927 -0
- package/frontend/assets/worker-2d5ABSLU.js +31 -0
- package/frontend/banner.png +0 -0
- package/frontend/favicon.svg +3 -0
- package/frontend/google55e5ec47ee14a5f8.html +1 -0
- package/frontend/index.html +234 -0
- package/frontend/manifest.json +17 -0
- package/frontend/pdf.worker.min.mjs +21 -0
- package/frontend/robots.txt +5 -0
- package/frontend/sitemap.xml +27 -0
- package/package.json +81 -0
- package/src/agent/index.js +1066 -0
- package/src/agent/location.js +51 -0
- package/src/agent/prompt.js +548 -0
- package/src/agent/tools.js +4372 -0
- package/src/browser/detect.js +68 -0
- package/src/browser/session.js +1109 -0
- package/src/config.js +137 -0
- package/src/email/client.js +503 -0
- package/src/index.js +557 -0
- package/src/inference/anthropic.js +113 -0
- package/src/inference/google.js +373 -0
- package/src/inference/index.js +81 -0
- package/src/inference/ollama.js +383 -0
- package/src/inference/openai.js +140 -0
- package/src/inference/openrouter.js +378 -0
- package/src/inference/xai.js +200 -0
- package/src/logBridge.js +9 -0
- package/src/models.js +146 -0
- package/src/remote/client.js +225 -0
- package/src/scheduler/cron.js +243 -0
- package/src/server.js +3876 -0
- package/src/storage/db.js +1135 -0
- package/src/storage/supabase.js +364 -0
- package/src/tunnel/cloudflare.js +241 -0
- package/src/ui/components/App.jsx +687 -0
- package/src/ui/components/BrowserSelect.jsx +111 -0
- package/src/ui/components/FilePicker.jsx +472 -0
- package/src/ui/components/Header.jsx +444 -0
- package/src/ui/components/HelpPanel.jsx +173 -0
- package/src/ui/components/HistoryPanel.jsx +158 -0
- package/src/ui/components/MessageList.jsx +235 -0
- package/src/ui/components/ModelSelector.jsx +304 -0
- package/src/ui/components/PromptInput.jsx +515 -0
- package/src/ui/components/StreamingResponse.jsx +134 -0
- package/src/ui/components/ThinkingIndicator.jsx +365 -0
- package/src/ui/components/ToolExecution.jsx +714 -0
- package/src/ui/components/index.js +82 -0
- package/src/ui/context/TerminalContext.jsx +150 -0
- package/src/ui/context/index.js +13 -0
- package/src/ui/hooks/index.js +16 -0
- package/src/ui/hooks/useChatState.js +675 -0
- package/src/ui/hooks/useCommands.js +280 -0
- package/src/ui/hooks/useFileAttachments.js +216 -0
- package/src/ui/hooks/useKeyboardShortcuts.js +173 -0
- package/src/ui/hooks/useNotifications.js +185 -0
- package/src/ui/hooks/useTerminalSize.js +151 -0
- package/src/ui/hooks/useWebSocket.js +273 -0
- package/src/ui/index.js +94 -0
- package/src/ui/ink-runner.js +22 -0
- package/src/ui/utils/formatters.js +424 -0
- package/src/ui/utils/index.js +6 -0
- 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,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
|
+
};
|