vg-coder-cli 2.0.24 → 2.0.25

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,228 @@
1
+ /**
2
+ * Saved Commands Feature
3
+ * Manages saved terminal commands that can be quickly executed
4
+ */
5
+
6
+ let savedCommands = [];
7
+ let editingCommandId = null;
8
+
9
+ /**
10
+ * Initialize saved commands on page load
11
+ */
12
+ export async function initSavedCommands() {
13
+ await loadSavedCommands();
14
+ renderCommands();
15
+ }
16
+
17
+ /**
18
+ * Load saved commands from backend
19
+ */
20
+ async function loadSavedCommands() {
21
+ try {
22
+ const response = await fetch('/api/commands/load');
23
+ const data = await response.json();
24
+ savedCommands = data.commands || [];
25
+ } catch (error) {
26
+ console.error('Failed to load commands:', error);
27
+ savedCommands = [];
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Save commands to backend (debounced)
33
+ */
34
+ let saveTimeout = null;
35
+ async function saveCommands() {
36
+ clearTimeout(saveTimeout);
37
+ saveTimeout = setTimeout(async () => {
38
+ try {
39
+ const response = await fetch('/api/commands/save', {
40
+ method: 'POST',
41
+ headers: { 'Content-Type': 'application/json' },
42
+ body: JSON.stringify({ commands: savedCommands })
43
+ });
44
+ const data = await response.json();
45
+ if (data.success) {
46
+ console.log(`✓ Saved ${data.count} commands`);
47
+ }
48
+ } catch (error) {
49
+ console.error('Failed to save commands:', error);
50
+ }
51
+ }, 500);
52
+ }
53
+
54
+ /**
55
+ * Render commands in UI
56
+ */
57
+ function renderCommands() {
58
+ const container = document.getElementById('commands-list');
59
+ const emptyState = document.getElementById('commands-empty-state');
60
+
61
+ if (!container) return;
62
+
63
+ if (savedCommands.length === 0) {
64
+ container.innerHTML = '';
65
+ if (emptyState) emptyState.style.display = 'block';
66
+ return;
67
+ }
68
+
69
+ if (emptyState) emptyState.style.display = 'none';
70
+
71
+ container.innerHTML = savedCommands.map(cmd => `
72
+ <div class="command-card" onclick="window.runSavedCommand('${cmd.id}')">
73
+ <div class="command-card-main">
74
+ <span class="command-icon">${cmd.icon}</span>
75
+ <div class="command-info">
76
+ <div class="command-name">${escapeHtml(cmd.name)}</div>
77
+ <div class="command-text">${escapeHtml(cmd.command)}</div>
78
+ </div>
79
+ </div>
80
+ <div class="command-card-actions">
81
+ <button class="command-action-btn" onclick="window.editCommand('${cmd.id}'); event.stopPropagation();" title="Edit">
82
+ ✏️
83
+ </button>
84
+ <button class="command-action-btn" onclick="window.deleteCommand('${cmd.id}'); event.stopPropagation();" title="Delete">
85
+ 🗑️
86
+ </button>
87
+ </div>
88
+ </div>
89
+ `).join('');
90
+ }
91
+
92
+ /**
93
+ * Open add command modal
94
+ */
95
+ function openAddCommandModal() {
96
+ editingCommandId = null;
97
+ document.getElementById('modal-title').textContent = 'Add Command';
98
+ document.getElementById('command-icon').value = '🚀';
99
+ document.getElementById('command-name').value = '';
100
+ document.getElementById('command-text').value = '';
101
+ document.getElementById('command-modal').style.display = 'flex';
102
+ document.getElementById('command-name').focus();
103
+ }
104
+
105
+ /**
106
+ * Open edit command modal
107
+ */
108
+ function editCommand(id) {
109
+ const command = savedCommands.find(c => c.id === id);
110
+ if (!command) return;
111
+
112
+ editingCommandId = id;
113
+ document.getElementById('modal-title').textContent = 'Edit Command';
114
+ document.getElementById('command-icon').value = command.icon;
115
+ document.getElementById('command-name').value = command.name;
116
+ document.getElementById('command-text').value = command.command;
117
+ document.getElementById('command-modal').style.display = 'flex';
118
+ document.getElementById('command-name').focus();
119
+ }
120
+
121
+ /**
122
+ * Close command modal
123
+ */
124
+ function closeCommandModal() {
125
+ document.getElementById('command-modal').style.display = 'none';
126
+ editingCommandId = null;
127
+ }
128
+
129
+ /**
130
+ * Handle command form submit
131
+ */
132
+ function handleCommandFormSubmit(event) {
133
+ event.preventDefault();
134
+
135
+ const icon = document.getElementById('command-icon').value.trim();
136
+ const name = document.getElementById('command-name').value.trim();
137
+ const command = document.getElementById('command-text').value.trim();
138
+
139
+ if (!icon || !name || !command) return;
140
+
141
+ if (editingCommandId) {
142
+ // Edit existing
143
+ const index = savedCommands.findIndex(c => c.id === editingCommandId);
144
+ if (index !== -1) {
145
+ savedCommands[index] = { ...savedCommands[index], icon, name, command };
146
+ }
147
+ } else {
148
+ // Add new
149
+ savedCommands.push({
150
+ id: 'cmd_' + Date.now(),
151
+ icon,
152
+ name,
153
+ command
154
+ });
155
+ }
156
+
157
+ saveCommands();
158
+ renderCommands();
159
+ closeCommandModal();
160
+ }
161
+
162
+ /**
163
+ * Delete command with confirmation
164
+ */
165
+ function deleteCommand(id) {
166
+ const command = savedCommands.find(c => c.id === id);
167
+ if (!command) return;
168
+
169
+ if (confirm(`Delete command "${command.name}"?`)) {
170
+ savedCommands = savedCommands.filter(c => c.id !== id);
171
+ saveCommands();
172
+ renderCommands();
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Run saved command - open terminal and copy command to clipboard
178
+ */
179
+ function runSavedCommand(id) {
180
+ const command = savedCommands.find(c => c.id === id);
181
+ if (!command) return;
182
+
183
+ // Create new terminal
184
+ if (typeof window.createNewTerminal === 'function') {
185
+ window.createNewTerminal();
186
+ }
187
+
188
+ // Copy command to clipboard
189
+ navigator.clipboard.writeText(command.command).then(() => {
190
+ // Show toast notification
191
+ if (typeof window.showToast === 'function') {
192
+ window.showToast(`📋 Copied: ${command.command}`, 'success');
193
+ }
194
+ }).catch(err => {
195
+ console.error('Failed to copy command:', err);
196
+ if (typeof window.showToast === 'function') {
197
+ window.showToast('Failed to copy command', 'error');
198
+ }
199
+ });
200
+ }
201
+
202
+ /**
203
+ * Escape HTML to prevent XSS
204
+ */
205
+ function escapeHtml(text) {
206
+ const div = document.createElement('div');
207
+ div.textContent = text;
208
+ return div.innerHTML;
209
+ }
210
+
211
+ // Global exports for HTML onclick
212
+ window.openAddCommandModal = openAddCommandModal;
213
+ window.editCommand = editCommand;
214
+ window.closeCommandModal = closeCommandModal;
215
+ window.deleteCommand = deleteCommand;
216
+ window.runSavedCommand = runSavedCommand;
217
+
218
+ // Setup form submit handler
219
+ if (typeof document !== 'undefined') {
220
+ document.addEventListener('DOMContentLoaded', () => {
221
+ const form = document.getElementById('command-form');
222
+ if (form) {
223
+ form.addEventListener('submit', handleCommandFormSubmit);
224
+ }
225
+ });
226
+ }
227
+
228
+ export { openAddCommandModal, editCommand, deleteCommand, runSavedCommand };
@@ -46,12 +46,34 @@ export function createNewTerminal() {
46
46
  wrapper.style.left = `${400 + offset}px`;
47
47
  wrapper.style.zIndex = ++maxZIndex;
48
48
 
49
- // HTML Template - Đã thêm sự kiện onclick cho nút Minimize
49
+ // HTML Template - Đã thêm Copy Buttons
50
50
  wrapper.innerHTML = `
51
51
  <div class="terminal-header" id="header-${termId}" ondblclick="window.toggleMinimize('${termId}')">
52
52
  <div class="terminal-title-group">
53
53
  <span>>_</span> Terminal (${activeTerminals.size + 1})
54
54
  </div>
55
+
56
+ <!-- Copy Button Group -->
57
+ <div class="terminal-copy-group" id="copy-group-${termId}">
58
+ <button class="copy-btn copy-smart" onclick="window.copyTerminalLog('${termId}', 'smart')" title="Smart Copy (optimized for 3000 tokens)">
59
+ 🧠 <span class="token-badge" id="badge-smart-${termId}">0</span>
60
+ </button>
61
+ <button class="copy-btn copy-errors" onclick="window.copyTerminalLog('${termId}', 'errors')" title="Errors Only (with context)">
62
+ ⚠️ <span class="token-badge" id="badge-errors-${termId}">0</span>
63
+ </button>
64
+ <button class="copy-btn copy-recent" onclick="window.copyTerminalLog('${termId}', 'recent')" title="Recent 200 lines">
65
+ 📄 <span class="token-badge" id="badge-recent-${termId}">0</span>
66
+ </button>
67
+ <button class="copy-btn copy-all" onclick="window.copyTerminalLog('${termId}', 'all')" title="Copy All">
68
+ 📦 <span class="token-badge" id="badge-all-${termId}">0</span>
69
+ </button>
70
+ </div>
71
+
72
+ <!-- Clear Button -->
73
+ <button class="term-btn-clear" onclick="window.clearTerminal('${termId}')" title="Clear Terminal">
74
+ 🗑️
75
+ </button>
76
+
55
77
  <div class="terminal-controls">
56
78
  <button class="term-btn minimize" onclick="window.toggleMinimize('${termId}')" title="Minimize/Restore">-</button>
57
79
  <button class="term-btn maximize" onclick="window.toggleMaximize('${termId}')" title="Maximize">+</button>
@@ -111,15 +133,69 @@ export function createNewTerminal() {
111
133
  // 4. Make Draggable
112
134
  makeDraggable(wrapper, document.getElementById(`header-${termId}`));
113
135
 
114
- // 5. Store Session
115
- activeTerminals.set(termId, { term, fitAddon, element: wrapper });
136
+ // 5. Store Session with log buffer
137
+ activeTerminals.set(termId, {
138
+ term,
139
+ fitAddon,
140
+ element: wrapper,
141
+ logBuffer: [], // Local buffer for quick access
142
+ partialLine: '' // Buffer for incomplete lines
143
+ });
144
+
145
+ // 6. Setup log buffer capture and token count updates
146
+ // Listen to terminal's onData event (this fires for user input)
147
+ // We need to listen to the actual output from the backend
148
+ socket.on('terminal:data', ({ termId: dataTermId, data }) => {
149
+ if (dataTermId !== termId) return;
150
+
151
+ const session = activeTerminals.get(termId);
152
+ if (!session) return;
153
+
154
+ // Strip ANSI codes
155
+ const cleanData = data.replace(
156
+ // eslint-disable-next-line no-control-regex
157
+ /\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07|\x1b\][^\x1b]*\x1b\\/g,
158
+ ''
159
+ );
160
+
161
+ // Accumulate partial line
162
+ session.partialLine += cleanData;
163
+
164
+ // Split by newlines and process complete lines
165
+ const lines = session.partialLine.split(/\r?\n/);
166
+
167
+ // Keep the last incomplete line in partialLine
168
+ session.partialLine = lines.pop() || '';
169
+
170
+ // Add complete lines to buffer
171
+ lines.forEach(line => {
172
+ if (line.trim().length > 0) {
173
+ session.logBuffer.push(line);
174
+ // Maintain max 10000 lines
175
+ if (session.logBuffer.length > 10000) {
176
+ session.logBuffer.shift();
177
+ }
178
+ }
179
+ });
180
+
181
+ // Update token counts (debounced)
182
+ if (lines.length > 0) {
183
+ updateTokenCounts(termId);
184
+ }
185
+ });
116
186
 
117
- // 6. Init Backend Process
187
+ // 7. Init Backend Process
118
188
  socket.emit('terminal:init', {
119
189
  termId,
120
190
  cols: term.cols,
121
191
  rows: term.rows
122
192
  });
193
+
194
+ // 8. Initial token count update
195
+ setTimeout(() => updateTokenCounts(termId), 100);
196
+
197
+ // Return termId so caller can use it
198
+ return termId;
123
199
  }
124
200
 
125
201
  /**
@@ -245,8 +321,166 @@ function makeDraggable(element, handle) {
245
321
  }
246
322
  }
247
323
 
324
+ /**
325
+ * Copy terminal log with specified strategy
326
+ * @param {string} termId - Terminal ID
327
+ * @param {string} strategy - 'smart' | 'errors' | 'recent' | 'all'
328
+ */
329
+ async function copyTerminalLog(termId, strategy) {
330
+ const session = activeTerminals.get(termId);
331
+ if (!session || !session.logBuffer || session.logBuffer.length === 0) {
332
+ showToast('⚠️ No logs to copy', 'warning');
333
+ return;
334
+ }
335
+
336
+ try {
337
+ // Use SmartCopyEngine to generate content
338
+ let result;
339
+ const lines = session.logBuffer;
340
+
341
+ switch (strategy) {
342
+ case 'smart':
343
+ result = window.SmartCopyEngine.generateSmartCopy(lines, 3000);
344
+ break;
345
+ case 'errors':
346
+ result = window.SmartCopyEngine.generateErrorsOnly(lines);
347
+ break;
348
+ case 'recent':
349
+ result = window.SmartCopyEngine.generateRecent(lines, 200);
350
+ break;
351
+ case 'all':
352
+ result = window.SmartCopyEngine.generateCopyAll(lines);
353
+ break;
354
+ default:
355
+ result = window.SmartCopyEngine.generateSmartCopy(lines, 3000);
356
+ }
357
+
358
+ // Copy to clipboard
359
+ await navigator.clipboard.writeText(result.content);
360
+
361
+ // Show success toast with details
362
+ const strategyNames = {
363
+ smart: '🧠 Smart',
364
+ errors: '⚠️ Errors',
365
+ recent: '📄 Recent',
366
+ all: '📦 All'
367
+ };
368
+
369
+ let message = `✅ Copied ${result.tokens} tokens (${strategyNames[strategy]})`;
370
+
371
+ if (result.stats && result.stats.message) {
372
+ message += `\n${result.stats.message}`;
373
+ }
374
+
375
+ if (result.warning) {
376
+ message += `\n${result.warning}`;
377
+ }
378
+
379
+ showToast(message, 'success');
380
+
381
+ } catch (error) {
382
+ console.error('Copy failed:', error);
383
+ showToast('❌ Failed to copy logs', 'error');
384
+ }
385
+ }
386
+
387
+ /**
388
+ * Update token count badges for a terminal
389
+ * Debounced to avoid excessive updates
390
+ */
391
+ let updateTokenCountsTimeout = null;
392
+ function updateTokenCounts(termId) {
393
+ // Debounce updates
394
+ clearTimeout(updateTokenCountsTimeout);
395
+ updateTokenCountsTimeout = setTimeout(() => {
396
+ const session = activeTerminals.get(termId);
397
+ if (!session || !session.logBuffer) return;
398
+
399
+ const analysis = window.SmartCopyEngine.analyzeLogBuffer(session.logBuffer);
400
+
401
+ // Update badges
402
+ updateBadge(`badge-smart-${termId}`, analysis.smart.tokens);
403
+ updateBadge(`badge-errors-${termId}`, analysis.errors.tokens);
404
+ updateBadge(`badge-recent-${termId}`, analysis.recent.tokens);
405
+ updateBadge(`badge-all-${termId}`, analysis.all.tokens);
406
+ }, 500); // 500ms debounce
407
+ }
408
+
409
+ /**
410
+ * Update a single badge element
411
+ */
412
+ function updateBadge(badgeId, tokens) {
413
+ const badge = document.getElementById(badgeId);
414
+ if (badge) {
415
+ badge.textContent = formatTokenCount(tokens);
416
+ }
417
+ }
418
+
419
+ /**
420
+ * Format token count for display
421
+ */
422
+ function formatTokenCount(tokens) {
423
+ if (tokens === 0) return '0';
424
+ if (tokens < 1000) return tokens.toString();
425
+ if (tokens < 10000) return (tokens / 1000).toFixed(1) + 'k';
426
+ return Math.floor(tokens / 1000) + 'k';
427
+ }
428
+
429
+ /**
430
+ * Show toast notification
431
+ */
432
+ function showToast(message, type = 'info') {
433
+ const toast = document.getElementById('toast');
434
+ if (!toast) return;
435
+
436
+ toast.textContent = message;
437
+ toast.className = 'toast show';
438
+
439
+ if (type === 'success') {
440
+ toast.style.background = '#28a745';
441
+ } else if (type === 'error') {
442
+ toast.style.background = '#dc3545';
443
+ } else if (type === 'warning') {
444
+ toast.style.background = '#ffc107';
445
+ toast.style.color = '#000';
446
+ } else {
447
+ toast.style.background = '#007bff';
448
+ }
449
+
450
+ setTimeout(() => {
451
+ toast.classList.remove('show');
452
+ }, 3000);
453
+ }
454
+
455
+ /**
456
+ * Clear terminal display and log buffer
457
+ * @param {string} termId - Terminal ID
458
+ */
459
+ function clearTerminal(termId) {
460
+ const session = activeTerminals.get(termId);
461
+ if (!session) return;
462
+
463
+ // Clear the xterm display
464
+ session.term.clear();
465
+
466
+ // Clear the log buffer
467
+ session.logBuffer = [];
468
+ session.partialLine = '';
469
+
470
+ // Reset token counts to 0
471
+ updateBadge(`badge-smart-${termId}`, 0);
472
+ updateBadge(`badge-errors-${termId}`, 0);
473
+ updateBadge(`badge-recent-${termId}`, 0);
474
+ updateBadge(`badge-all-${termId}`, 0);
475
+
476
+ // Show toast notification
477
+ showToast('🗑️ Terminal cleared', 'info');
478
+ }
479
+
248
480
  // Global Exports for HTML onclick
249
481
  window.createNewTerminal = createNewTerminal;
250
482
  window.closeTerminal = closeTerminalUI;
251
483
  window.toggleMinimize = toggleMinimize;
252
484
  window.toggleMaximize = toggleMaximize;
485
+ window.copyTerminalLog = copyTerminalLog;
486
+ window.clearTerminal = clearTerminal;
@@ -9,6 +9,7 @@ import { initTerminal, createNewTerminal } from './features/terminal.js';
9
9
  import { initEditorTabs } from './features/editor-tabs.js';
10
10
  import { initMonaco, updateMonacoTheme } from './features/monaco-manager.js';
11
11
  import { initResizeHandler } from './features/resize.js';
12
+ import { initSavedCommands } from './features/commands.js';
12
13
 
13
14
  document.addEventListener('DOMContentLoaded', async () => {
14
15
  // Load system prompt text
@@ -43,6 +44,9 @@ document.addEventListener('DOMContentLoaded', async () => {
43
44
  // Initialize Resize Handler
44
45
  initResizeHandler();
45
46
 
47
+ // Initialize Saved Commands
48
+ initSavedCommands();
49
+
46
50
  // Set default tab to AI Assistant
47
51
  if (window.switchTab) {
48
52
  window.switchTab('ai-assistant');
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Log Utilities for Smart Log Copy Feature
3
+ * Provides ANSI stripping, token estimation, and log classification
4
+ */
5
+
6
+ /**
7
+ * Strip ANSI escape codes from text
8
+ * Removes color codes, cursor movements, and other terminal control sequences
9
+ * @param {string} text - Text with potential ANSI codes
10
+ * @returns {string} Clean text without ANSI codes
11
+ */
12
+ function stripAnsiCodes(text) {
13
+ if (!text) return '';
14
+
15
+ // Remove ANSI escape sequences
16
+ // Pattern matches: ESC [ ... m (colors, styles)
17
+ // ESC [ ... H/J/K (cursor movements, clear)
18
+ // ESC ] ... BEL/ST (operating system commands)
19
+ return text.replace(
20
+ // eslint-disable-next-line no-control-regex
21
+ /\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07|\x1b\][^\x1b]*\x1b\\/g,
22
+ ''
23
+ );
24
+ }
25
+
26
+ /**
27
+ * Estimate token count from text
28
+ * Uses approximation: 1 token ≈ 4 characters (for English/code)
29
+ * This is a simplified estimation and may vary ±20% from actual tokenization
30
+ * @param {string} text - Text to estimate
31
+ * @returns {number} Estimated token count
32
+ */
33
+ function estimateTokens(text) {
34
+ if (!text) return 0;
35
+
36
+ const chars = text.length;
37
+ const words = text.split(/\s+/).filter(w => w.length > 0).length;
38
+
39
+ // Use the maximum of two methods for better accuracy:
40
+ // Method 1: 1 token ≈ 0.75 words
41
+ // Method 2: 1 token ≈ 4 characters
42
+ const tokensFromWords = Math.ceil(words / 0.75);
43
+ const tokensFromChars = Math.ceil(chars / 4);
44
+
45
+ return Math.max(tokensFromWords, tokensFromChars);
46
+ }
47
+
48
+ /**
49
+ * Classify a log line by severity
50
+ * @param {string} line - Single log line
51
+ * @returns {'ERROR'|'WARNING'|'NORMAL'} Classification
52
+ */
53
+ function classifyLogLine(line) {
54
+ if (!line) return 'NORMAL';
55
+
56
+ const lowerLine = line.toLowerCase();
57
+
58
+ // Error patterns
59
+ const errorPatterns = [
60
+ 'error', 'err:', 'exception', 'fatal', 'failed', 'failure',
61
+ 'cannot', 'could not', 'unable to', 'not found', '404',
62
+ 'stack trace', 'traceback', 'segmentation fault', 'core dumped'
63
+ ];
64
+
65
+ // Warning patterns
66
+ const warningPatterns = [
67
+ 'warn', 'warning', 'deprecated', 'deprecation',
68
+ 'caution', 'attention', 'notice'
69
+ ];
70
+
71
+ // Check for errors first (higher priority)
72
+ if (errorPatterns.some(pattern => lowerLine.includes(pattern))) {
73
+ return 'ERROR';
74
+ }
75
+
76
+ // Then check for warnings
77
+ if (warningPatterns.some(pattern => lowerLine.includes(pattern))) {
78
+ return 'WARNING';
79
+ }
80
+
81
+ return 'NORMAL';
82
+ }
83
+
84
+ /**
85
+ * Extract error and warning lines with context
86
+ * @param {string[]} lines - Array of log lines
87
+ * @param {number} contextLines - Number of lines before/after to include (default: 2)
88
+ * @returns {Object[]} Array of {lineIndex, line, type, context}
89
+ */
90
+ function extractErrors(lines, contextLines = 2) {
91
+ const errors = [];
92
+ const includedIndices = new Set();
93
+
94
+ // First pass: identify error/warning lines
95
+ lines.forEach((line, index) => {
96
+ const type = classifyLogLine(line);
97
+ if (type === 'ERROR' || type === 'WARNING') {
98
+ // Include the error line and context
99
+ const startIndex = Math.max(0, index - contextLines);
100
+ const endIndex = Math.min(lines.length - 1, index + contextLines);
101
+
102
+ for (let i = startIndex; i <= endIndex; i++) {
103
+ includedIndices.add(i);
104
+ }
105
+
106
+ errors.push({
107
+ lineIndex: index,
108
+ line,
109
+ type,
110
+ contextStart: startIndex,
111
+ contextEnd: endIndex
112
+ });
113
+ }
114
+ });
115
+
116
+ // Second pass: build result with context
117
+ const result = [];
118
+ const sortedIndices = Array.from(includedIndices).sort((a, b) => a - b);
119
+
120
+ sortedIndices.forEach(index => {
121
+ result.push({
122
+ lineIndex: index,
123
+ line: lines[index],
124
+ type: classifyLogLine(lines[index])
125
+ });
126
+ });
127
+
128
+ return result;
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
+ function formatBytes(bytes) {
137
+ if (bytes === 0) return '0 B';
138
+ const k = 1024;
139
+ const sizes = ['B', 'KB', 'MB', 'GB'];
140
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
141
+ return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
142
+ }
143
+
144
+ // Export for Node.js
145
+ if (typeof module !== 'undefined' && module.exports) {
146
+ module.exports = {
147
+ stripAnsiCodes,
148
+ estimateTokens,
149
+ classifyLogLine,
150
+ extractErrors,
151
+ formatBytes
152
+ };
153
+ }
154
+
155
+ // Export for browser
156
+ if (typeof window !== 'undefined') {
157
+ window.LogUtils = {
158
+ stripAnsiCodes,
159
+ estimateTokens,
160
+ classifyLogLine,
161
+ extractErrors,
162
+ formatBytes
163
+ };
164
+ }