/** * Enhanced Chat Input Component * CodeNomad-style sophisticated prompt input * * Features: * - Expandable textarea (2-15 lines desktop, 2-4 mobile) * - Attachment system (files, images, long text paste) * - Draft persistence (session-aware localStorage) * - History navigation (↑↓ arrows) * - Unified picker (@files, /commands) * - Shell mode (! prefix) * - Token/char count */ class EnhancedChatInput { constructor(containerId) { this.container = document.getElementById(containerId); if (!this.container) { console.error('[ChatInput] Container not found:', containerId); return; } this.state = { value: '', attachments: [], drafts: new Map(), history: [], historyIndex: -1, shellMode: false, isMobile: this.detectMobile() }; this.loadDrafts(); this.loadHistory(); this.initialize(); } detectMobile() { return window.innerWidth < 640 || 'ontouchstart' in window; } initialize() { // Get existing textarea const existingInput = this.container.querySelector('#chat-input'); if (!existingInput) { console.error('[ChatInput] #chat-input not found'); return; } // Wrap existing input with enhanced UI const wrapper = existingInput.parentElement; wrapper.className = 'chat-input-wrapper-enhanced'; // Insert attachment chips container before the input const chipsContainer = document.createElement('div'); chipsContainer.className = 'attachment-chips'; chipsContainer.id = 'attachment-chips'; wrapper.insertBefore(chipsContainer, existingInput); // Update textarea attributes existingInput.setAttribute('rows', '1'); existingInput.setAttribute('data-auto-expand', 'true'); this.textarea = existingInput; this.chipsContainer = chipsContainer; // Mobile viewport state this.state.viewportHeight = window.innerHeight; this.state.keyboardVisible = false; this.state.initialViewportHeight = window.innerHeight; this.setupEventListeners(); this.setupKeyboardDetection(); this.loadCurrentDraft(); } setupKeyboardDetection() { if (!this.state.isMobile) return; // Detect virtual keyboard by tracking viewport changes let resizeTimeout; window.addEventListener('resize', () => { clearTimeout(resizeTimeout); resizeTimeout = setTimeout(() => { this.handleViewportChange(); }, 100); }); // Also listen to visual viewport API (better for mobile keyboards) if (window.visualViewport) { window.visualViewport.addEventListener('resize', () => { this.handleViewportChange(); }); } } handleViewportChange() { const currentHeight = window.innerHeight; const initialHeight = this.state.initialViewportHeight; const heightDiff = initialHeight - currentHeight; // If viewport shrank by more than 150px, keyboard is likely visible const keyboardVisible = heightDiff > 150; if (keyboardVisible !== this.state.keyboardVisible) { this.state.keyboardVisible = keyboardVisible; console.log(`[ChatInput] Keyboard ${keyboardVisible ? 'visible' : 'hidden'}`); // Re-calculate max lines when keyboard state changes this.autoExpand(); } this.state.viewportHeight = currentHeight; } calculateMaxLines() { if (!this.state.isMobile) { return 15; // Desktop default } // Mobile: Calculate based on available viewport height const viewportHeight = this.state.viewportHeight; const keyboardHeight = this.state.keyboardVisible ? (this.state.initialViewportHeight - viewportHeight) : 0; // Available height for input area (rough estimate) // Leave space for: header (~60px), tabs (~50px), messages area, attachments const availableHeight = viewportHeight - keyboardHeight - 200; // 200px for UI chrome // Line height is approximately 24px const lineHeight = 24; const maxLines = Math.floor(availableHeight / lineHeight); // Clamp between 2 and 4 lines for mobile return Math.max(2, Math.min(4, maxLines)); } setupEventListeners() { if (!this.textarea) return; // Auto-expand on input this.textarea.addEventListener('input', () => { this.autoExpand(); this.saveDraft(); this.checkTriggers(); this.updateCharCount(); }); // Handle paste events this.textarea.addEventListener('paste', (e) => this.handlePaste(e)); // Handle keyboard shortcuts this.textarea.addEventListener('keydown', (e) => { // History navigation with ↑↓ if (e.key === 'ArrowUp' && !e.shiftKey) { this.navigateHistory(-1); e.preventDefault(); } else if (e.key === 'ArrowDown' && !e.shiftKey) { this.navigateHistory(1); e.preventDefault(); } // Send with Enter (Shift+Enter for newline) else if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); this.send(); } // Detect shell mode (!) else if (e.key === '!' && this.textarea.selectionStart === 0) { this.state.shellMode = true; this.updatePlaceholder(); } }); // Handle file attachment button const attachBtn = this.container.querySelector('.btn-icon[title="Attach file"], .btn-attach'); if (attachBtn) { attachBtn.addEventListener('click', () => this.attachFile()); } } autoExpand() { if (!this.textarea) return; const maxLines = this.calculateMaxLines(); const lineHeight = 24; // pixels const padding = 12; // padding this.textarea.style.height = 'auto'; const newHeight = this.textarea.scrollHeight; const minHeight = lineHeight + padding * 2; const maxHeight = lineHeight * maxLines + padding * 2; if (newHeight < minHeight) { this.textarea.style.height = `${minHeight}px`; } else if (newHeight > maxHeight) { this.textarea.style.height = `${maxHeight}px`; this.textarea.style.overflowY = 'auto'; } else { this.textarea.style.height = `${newHeight}px`; } } handlePaste(event) { const items = event.clipboardData?.items; if (!items) return; // Check for images for (const item of items) { if (item.type.startsWith('image/')) { event.preventDefault(); const file = item.getAsFile(); this.attachImageFile(file); return; } } // Check for long text paste const pastedText = event.clipboardData.getData('text'); if (pastedText) { const lines = pastedText.split('\n').length; const chars = pastedText.length; if (chars > 150 || lines > 3) { event.preventDefault(); this.addPastedText(pastedText); } } } attachFile() { const input = document.createElement('input'); input.type = 'file'; input.multiple = true; input.accept = '*/*'; input.onchange = async (e) => { const files = e.target.files; for (const file of files) { if (file.type.startsWith('image/')) { await this.attachImageFile(file); } else { await this.attachTextFile(file); } } }; input.click(); } async attachImageFile(file) { const reader = new FileReader(); reader.onload = (e) => { const attachment = { id: Date.now() + Math.random(), type: 'image', name: file.name, size: file.size, data: e.target.result }; this.state.attachments.push(attachment); this.renderAttachments(); this.saveDraft(); }; reader.readAsDataURL(file); } async attachTextFile(file) { const text = await file.text(); const attachment = { id: Date.now() + Math.random(), type: 'file', name: file.name, size: file.size, content: text }; this.state.attachments.push(attachment); this.renderAttachments(); this.saveDraft(); } addPastedText(text) { const attachment = { id: Date.now() + Math.random(), type: 'pasted', label: `pasted #${this.state.attachments.filter(a => a.type === 'pasted').length + 1}`, content: text, chars: text.length, lines: text.split('\n').length }; this.state.attachments.push(attachment); this.renderAttachments(); this.saveDraft(); } removeAttachment(id) { this.state.attachments = this.state.attachments.filter(a => a.id !== id); this.renderAttachments(); this.saveDraft(); } renderAttachments() { if (!this.chipsContainer) return; if (this.state.attachments.length === 0) { this.chipsContainer.innerHTML = ''; return; } this.chipsContainer.innerHTML = this.state.attachments.map(a => { if (a.type === 'image') { return `
${a.name}
`; } else if (a.type === 'file') { return `
📄 ${this.escapeHtml(a.name)}
`; } else if (a.type === 'pasted') { return `
📋 ${this.escapeHtml(a.label)} ${a.chars} chars, ${a.lines} lines
`; } return ''; }).join(''); // Add click handlers this.chipsContainer.querySelectorAll('.chip-remove').forEach(btn => { btn.addEventListener('click', (e) => { const chip = e.target.closest('.attachment-chip'); if (chip) { this.removeAttachment(parseFloat(chip.dataset.id)); } }); }); } checkTriggers() { if (!this.textarea) return; const value = this.textarea.value; const cursorPos = this.textarea.selectionStart; // Check for @ trigger (file mentions) const atMatch = value.substring(0, cursorPos).match(/@(\w*)$/); if (atMatch && atMatch[0].length > 1) { console.log('[ChatInput] File mention triggered:', atMatch[1]); // TODO: Show file picker } // Check for / trigger (slash commands) const slashMatch = value.substring(0, cursorPos).match(/\/(\w*)$/); if (slashMatch && slashMatch[0].length > 1) { console.log('[ChatInput] Command triggered:', slashMatch[1]); // TODO: Show command picker } } navigateHistory(direction) { if (this.state.history.length === 0) return; let newIndex; if (direction === -1) { newIndex = Math.min(this.state.historyIndex + 1, this.state.history.length - 1); } else { newIndex = Math.max(this.state.historyIndex - 1, -1); } this.state.historyIndex = newIndex; if (newIndex === -1) { this.textarea.value = this.state.value; } else { const index = this.state.history.length - 1 - newIndex; this.textarea.value = this.state.history[index]; } this.autoExpand(); } // Session-aware draft storage getDraftKey() { const sessionId = this.getCurrentSessionId(); return `claude-ide.drafts.${sessionId}`; } saveDraft() { const sessionId = this.getCurrentSessionId(); if (!sessionId) return; const draft = { value: this.textarea.value, attachments: this.state.attachments, timestamp: Date.now(), sessionId: sessionId }; this.state.drafts.set(sessionId, draft); try { localStorage.setItem(this.getDraftKey(), JSON.stringify(draft)); // Clean up old drafts from other sessions this.cleanupOldDrafts(sessionId); } catch (e) { console.error('[ChatInput] Failed to save draft:', e); } } cleanupOldDrafts(currentSessionId) { try { const allKeys = Object.keys(localStorage); const draftKeys = allKeys.filter(k => k.startsWith('claude-ide.drafts.')); // Keep only recent drafts (last 5 sessions) const drafts = draftKeys.map(key => { try { return { key, data: JSON.parse(localStorage.getItem(key)) }; } catch { return null; } }).filter(d => d && d.data.sessionId !== currentSessionId); // Sort by timestamp drafts.sort((a, b) => b.data.timestamp - a.data.timestamp); // Remove old drafts beyond 5 drafts.slice(5).forEach(d => { localStorage.removeItem(d.key); }); } catch (e) { console.error('[ChatInput] Failed to cleanup drafts:', e); } } loadDrafts() { try { const allKeys = Object.keys(localStorage); const draftKeys = allKeys.filter(k => k.startsWith('claude-ide.drafts.')); draftKeys.forEach(key => { try { const draft = JSON.parse(localStorage.getItem(key)); if (draft && draft.sessionId) { this.state.drafts.set(draft.sessionId, draft); } } catch (e) { // Skip invalid drafts } }); } catch (e) { console.error('[ChatInput] Failed to load drafts:', e); } } loadCurrentDraft() { const sessionId = this.getCurrentSessionId(); if (!sessionId) return; const draft = this.state.drafts.get(sessionId); if (draft) { this.textarea.value = draft.value || ''; this.state.attachments = draft.attachments || []; this.renderAttachments(); this.autoExpand(); // Show restore notification if draft is old (> 5 minutes) const age = Date.now() - draft.timestamp; if (age > 5 * 60 * 1000 && draft.value) { this.showDraftRestoreNotification(); } } } showDraftRestoreNotification() { if (typeof showToast === 'function') { showToast('Draft restored from previous session', 'info', 3000); } } clearDraft() { const sessionId = this.getCurrentSessionId(); if (sessionId) { this.state.drafts.delete(sessionId); localStorage.removeItem(this.getDraftKey()); } } saveHistory() { const value = this.textarea.value.trim(); if (!value) return; this.state.history.push(value); this.state.historyIndex = -1; // Limit history to 100 items if (this.state.history.length > 100) { this.state.history.shift(); } localStorage.setItem('chat-history', JSON.stringify(this.state.history)); } loadHistory() { try { const stored = localStorage.getItem('chat-history'); if (stored) { this.state.history = JSON.parse(stored); } } catch (e) { console.error('[ChatInput] Failed to load history:', e); } } getCurrentSessionId() { return window.attachedSessionId || window.currentSessionId || null; } updatePlaceholder() { if (!this.textarea) return; if (this.state.shellMode) { this.textarea.placeholder = 'Shell mode: enter shell command... (Enter to send)'; } else { this.textarea.placeholder = 'Type your message to Claude Code... (@ for files, / for commands, Enter to send)'; } } updateCharCount() { const value = this.textarea.value; const charCountEl = this.container.querySelector('#char-count'); if (charCountEl) { charCountEl.textContent = `${value.length} chars`; } // Token count (rough estimation: 1 token ≈ 4 chars) const tokenCountEl = this.container.querySelector('#token-usage'); if (tokenCountEl) { const tokens = Math.ceil(value.length / 4); tokenCountEl.textContent = `${tokens} tokens`; } } send() { const content = this.textarea.value.trim(); const hasAttachments = this.state.attachments.length > 0; if (!content && !hasAttachments) return; // Get the send button and trigger click const sendBtn = this.container.querySelector('.btn-send, .btn-primary[onclick*="sendChatMessage"]'); if (sendBtn) { sendBtn.click(); } else if (typeof sendChatMessage === 'function') { // Call the function directly sendChatMessage(); } // Save to history this.saveHistory(); // Clear input this.textarea.value = ''; this.state.attachments = []; this.state.shellMode = false; this.renderAttachments(); this.clearDraft(); this.autoExpand(); this.updatePlaceholder(); this.updateCharCount(); } escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } destroy() { this.saveDraft(); this.state = null; } } // Global instance let enhancedChatInput = null; // Initialize when DOM is ready function initEnhancedChatInput() { enhancedChatInput = new EnhancedChatInput('chat-input-container'); } // Export to window if (typeof window !== 'undefined') { window.EnhancedChatInput = EnhancedChatInput; window.enhancedChatInput = null; // Auto-initialize if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => { initEnhancedChatInput(); window.enhancedChatInput = enhancedChatInput; }); } else { initEnhancedChatInput(); window.enhancedChatInput = enhancedChatInput; } } // Export for use in other scripts if (typeof module !== 'undefined' && module.exports) { module.exports = { EnhancedChatInput }; }