import { useState, useRef, useEffect, useCallback, useMemo } from 'react' import { Terminal as XTerm } from '@xterm/xterm' import { FitAddon } from '@xterm/addon-fit' import { WebLinksAddon } from '@xterm/addon-web-links' import { WebglAddon } from '@xterm/addon-webgl' import { SearchAddon } from '@xterm/addon-search' import { Unicode11Addon } from '@xterm/addon-unicode11' import { ImageAddon } from '@xterm/addon-image' import { Plus, X, Monitor, Globe, ChevronDown, Pencil, Trash2, Search, Copy, Send, Eye, Bot } from 'lucide-react' import '@xterm/xterm/css/xterm.css' import { useI18n } from '../i18n' const AI_TAB_ID = 0 const MAX_TABS = 7 const SHELL_MAX_TOKENS = 100000 const TABS_STORAGE_KEY = 'muyue_shell_tabs' const TERMINAL_BUFFER_KEY = 'muyue_terminal_buffers' function renderContent(text) { const parts = [] const codeBlockRegex = /(```[\s\S]*?```)/g let match let lastIndex = 0 while ((match = codeBlockRegex.exec(text)) !== null) { if (match.index > lastIndex) { parts.push({ type: 'text', content: text.slice(lastIndex, match.index) }) } const full = match[1] const firstNewline = full.indexOf('\n') const lang = firstNewline > -1 ? full.slice(3, firstNewline).trim() : '' const code = firstNewline > -1 ? full.slice(firstNewline + 1, -3) : full.slice(3, -3) parts.push({ type: 'code', lang, content: code }) lastIndex = match.index + full.length } if (lastIndex < text.length) { const remaining = text.slice(lastIndex) const openBlock = remaining.match(/```(\w*)\n?([\s\S]*)$/) if (openBlock) { if (openBlock.index > 0) { parts.push({ type: 'text', content: remaining.slice(0, openBlock.index) }) } parts.push({ type: 'code', lang: openBlock[1] || '', content: openBlock[2] || '' }) } else { parts.push({ type: 'text', content: remaining }) } } return parts } function formatText(text) { let html = text .replace(/&/g, '&').replace(//g, '>') html = html .replace(/\*\*(.+?)\*\*/g, '$1') .replace(/`([^`]+)`/g, '$1') .replace(/^### (.+)$/gm, '

$1

') .replace(/^## (.+)$/gm, '

$1

') .replace(/^# (.+)$/gm, '

$1

') .replace(/^\s*[-*] (.+)$/gm, '
• $1
') .replace(/^\s*(\d+)[.)] (.+)$/gm, '
$1 $2
') .replace(/\n/g, '
') html = html .replace(/\s*/g, '
') .replace(/\s*( c + c).join(''); if (hex.length !== 6) return null; const r = parseInt(hex.slice(0, 2), 16); const g = parseInt(hex.slice(2, 4), 16); const b = parseInt(hex.slice(4, 6), 16); return { r, g, b }; } function toRgbString(hex) { const c = parseHexColor(hex); if (!c) return '#000000'; return `#${c.r.toString(16).padStart(2, '0')}${c.g.toString(16).padStart(2, '0')}${c.b.toString(16).padStart(2, '0')}`; } function buildSystemTheme() { const bg = getCSSVariable('--bg-base') || '#0F0D10'; const fg = getCSSVariable('--text-primary') || '#EAE0E2'; const accent = getCSSVariable('--accent-light') || '#FF1A5E'; const accentDim = getCSSVariable('--accent-dim') || '#6B2033'; const success = '#00E676'; const warning = '#FFD740'; const error = getCSSVariable('--accent-bright') || '#FF1744'; const bgSurface = getCSSVariable('--bg-surface') || bg; const bgElevated = getCSSVariable('--bg-elevated') || bgSurface; const textSecondary = getCSSVariable('--text-secondary') || fg; const textTertiary = getCSSVariable('--text-tertiary') || textSecondary; return { background: toRgbString(bg), foreground: toRgbString(fg), cursor: toRgbString(accent), cursorAccent: toRgbString(bg), selectionBackground: `${toRgbString(accentDim)}44`, selectionForeground: '#FFFFFF', black: toRgbString(bgElevated), red: toRgbString(error), green: toRgbString(success), yellow: toRgbString(warning), blue: toRgbString(getCSSVariable('--accent') || '#448AFF'), magenta: toRgbString(accent), cyan: '#00BCD4', white: toRgbString(fg), brightBlack: toRgbString(bgSurface), brightRed: toRgbString(accent), brightGreen: toRgbString(success), brightYellow: toRgbString(warning), brightBlue: toRgbString(getCSSVariable('--accent-muted') || '#82B1FF'), brightMagenta: toRgbString(getCSSVariable('--accent-soft') || '#FF80AB'), brightCyan: '#84FFFF', brightWhite: '#FFFFFF', }; } const THEMES = { system: buildSystemTheme(), default: { background: '#0A0A0C', foreground: '#EAE0E2', cursor: '#FF0033', cursorAccent: '#0A0A0C', selectionBackground: '#FF003344', selectionForeground: '#ffffff', black: '#0A0A0C', red: '#FF0033', green: '#00E676', yellow: '#FFD740', blue: '#448AFF', magenta: '#FF1A5E', cyan: '#00BCD4', white: '#EAE0E2', brightBlack: '#5A4F52', brightRed: '#FF5252', brightGreen: '#69F0AE', brightYellow: '#FFFF00', brightBlue: '#82B1FF', brightMagenta: '#FF80AB', brightCyan: '#84FFFF', brightWhite: '#FFFFFF', }, monokai: { background: '#272822', foreground: '#F8F8F2', cursor: '#F8F8F0', cursorAccent: '#272822', selectionBackground: '#F8F8F244', selectionForeground: '#ffffff', black: '#272822', red: '#F92672', green: '#A6E22E', yellow: '#E6DB74', blue: '#66D9EF', magenta: '#AE81FF', cyan: '#A1EFE4', white: '#F8F8F2', brightBlack: '#75715E', brightRed: '#F92672', brightGreen: '#A6E22E', brightYellow: '#E6DB74', brightBlue: '#66D9EF', brightMagenta: '#AE81FF', brightCyan: '#A1EFE4', brightWhite: '#F8F8F2', }, gruvbox: { background: '#282828', foreground: '#EBDBB2', cursor: '#FB4934', cursorAccent: '#282828', selectionBackground: '#EBDBB244', selectionForeground: '#ffffff', black: '#282828', red: '#CC241D', green: '#98971A', yellow: '#D79921', blue: '#458588', magenta: '#B16286', cyan: '#689D6A', white: '#EBDBB2', brightBlack: '#928374', brightRed: '#FB4934', brightGreen: '#B8BB26', brightYellow: '#FABC2A', brightBlue: '#83A598', brightMagenta: '#D3869B', brightCyan: '#8EC07C', brightWhite: '#EBDBB2', }, nord: { background: '#2E3440', foreground: '#D8DEE9', cursor: '#D8DEE9', cursorAccent: '#2E3440', selectionBackground: '#D8DEE944', selectionForeground: '#ffffff', black: '#2E3440', red: '#BF616A', green: '#A3BE8C', yellow: '#EBCB8B', blue: '#81A1C1', magenta: '#B48EAD', cyan: '#88C0D0', white: '#D8DEE9', brightBlack: '#4C566A', brightRed: '#BF616A', brightGreen: '#A3BE8C', brightYellow: '#EBCB8B', brightBlue: '#81A1C1', brightMagenta: '#B48EAD', brightCyan: '#8FBCBB', brightWhite: '#ECEFF4', }, 'solarized-dark': { background: '#002B36', foreground: '#839496', cursor: '#D33682', cursorAccent: '#002B36', selectionBackground: '#83949644', selectionForeground: '#ffffff', black: '#002B36', red: '#DC322F', green: '#859900', yellow: '#B58900', blue: '#268BD2', magenta: '#D33682', cyan: '#2AA198', white: '#FDF6E3', brightBlack: '#073642', brightRed: '#CB4B16', brightGreen: '#586E75', brightYellow: '#657B83', brightBlue: '#6C71C4', brightMagenta: '#6C71C4', brightCyan: '#93A1A1', brightWhite: '#FDF6E3', }, dracula: { background: '#282A36', foreground: '#F8F8F2', cursor: '#F8F8F2', cursorAccent: '#282A36', selectionBackground: '#F8F8F244', selectionForeground: '#ffffff', black: '#282A36', red: '#FF5555', green: '#50FA7B', yellow: '#F1FA8C', blue: '#BD93F9', magenta: '#FF79C6', cyan: '#8BE9FD', white: '#F8F8F2', brightBlack: '#6272A4', brightRed: '#FF6E6E', brightGreen: '#69FF94', brightYellow: '#FFFFA5', brightBlue: '#D6ACFF', brightMagenta: '#FF92DF', brightCyan: '#A4FFFF', brightWhite: '#FFFFFF', }, } function getTheme(themeName) { if (themeName === 'system' || themeName === 'default') { return buildSystemTheme() } return THEMES[themeName] || buildSystemTheme() } function createTerminal(container, settings = {}) { const theme = getTheme(settings.theme || 'system') const term = new XTerm({ cursorBlink: true, fontSize: settings.fontSize || 12, fontFamily: settings.fontFamily || "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', 'Menlo', monospace", theme, allowTransparency: false, scrollback: 5000, }) const fitAddon = new FitAddon() const webLinksAddon = new WebLinksAddon() const searchAddon = new SearchAddon() const unicode11Addon = new Unicode11Addon() const imageAddon = new ImageAddon() term.loadAddon(fitAddon) term.loadAddon(webLinksAddon) term.loadAddon(searchAddon) term.loadAddon(unicode11Addon) term.loadAddon(imageAddon) term.unicode.activeVersion = '11' try { const webglAddon = new WebglAddon() webglAddon.onContextLoss(() => { webglAddon.dispose() }) term.loadAddon(webglAddon) } catch (e) { console.warn('[Shell] WebGL renderer not available, using DOM fallback:', e) } term.attachCustomKeyEventHandler((e) => { if (e.type !== 'keydown') return true const ctrl = e.ctrlKey || e.metaKey const shift = e.shiftKey if (ctrl && shift && e.key === 'C') { e.preventDefault() e.stopPropagation() const selection = term.getSelection() if (selection) navigator.clipboard.writeText(selection) return false } if (ctrl && shift && e.key === 'V') { e.preventDefault() e.stopPropagation() navigator.clipboard.readText().then(text => { if (text) term.paste(text) }).catch(() => {}) return false } return true }) term.open(container) fitAddon.fit() return { term, fitAddon, searchAddon } } function connectWebSocket(term, fitAddon, initPayload, onStateChange, onFirstMessage) { const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:' const ws = new WebSocket(`${proto}//${window.location.host}/api/ws/terminal`) ws.addEventListener('open', () => { ws.send(JSON.stringify(initPayload)) const dims = fitAddon.proposeDimensions() // Envoyer resize avec dimensions minimales garanties (24x80) const rows = dims?.rows || 24 const cols = dims?.cols || 80 ws.send(JSON.stringify({ type: 'resize', rows, cols })) // Forcer un fit après l'ouverture setTimeout(() => { try { fitAddon.fit() const newDims = fitAddon.proposeDimensions() if (newDims && newDims.rows > 0 && newDims.cols > 0) { ws.send(JSON.stringify({ type: 'resize', rows: newDims.rows, cols: newDims.cols })) } } catch (e) { console.warn('[Shell] fit failed:', e) } }, 50) if (onStateChange) onStateChange(true) }) let firstMessage = true ws.addEventListener('message', (event) => { if (firstMessage) { firstMessage = false if (onFirstMessage) onFirstMessage() } try { const msg = JSON.parse(event.data) if (msg.type === 'output') { term.write(msg.data) } else if (msg.type === 'error') { term.write(`\r\n\x1b[31mError: ${msg.data}\x1b[0m\r\n`) } } catch { term.write(event.data) } }) ws.addEventListener('close', () => { term.write('\r\n\x1b[33m— Connection closed —\x1b[0m\r\n') if (onStateChange) onStateChange(false) }) ws.addEventListener('error', () => { term.write('\r\n\x1b[31m— Connection error —\x1b[0m\r\n') if (onStateChange) onStateChange(false) }) term.onResize(({ rows, cols }) => { if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'resize', rows, cols })) } }) return ws } export default function Shell({ api }) { const { t } = useI18n() const tabsRef = useRef({}) const nextIdRef = useRef(1) const settingsRef = useRef({ fontSize: 12, fontFamily: "'JetBrains Mono', 'Fira Code', monospace", theme: 'system' }) const pendingCommandsRef = useRef({}) const [tabs, setTabs] = useState(() => { try { const raw = localStorage.getItem(TABS_STORAGE_KEY) if (raw) { const parsed = JSON.parse(raw) if (Array.isArray(parsed) && parsed.length > 0 && parsed.length <= MAX_TABS) { return parsed.map((t, i) => ({ id: t.id || i + 1, name: t.name || `Tab ${i + 1}`, type: t.type || 'local', shell: t.shell || '', host: t.host, port: t.port, user: t.user, key_path: t.key_path, connected: false })) } } } catch (e) { console.warn('[Shell] Failed to parse saved tabs:', e) localStorage.removeItem(TABS_STORAGE_KEY) } return [ { id: 1, name: 'Local Shell', type: 'local', shell: '', connected: false }, ] }) const [activeTab, setActiveTab] = useState(() => { try { const raw = localStorage.getItem(TABS_STORAGE_KEY) if (raw) { const parsed = JSON.parse(raw) if (Array.isArray(parsed) && parsed.length > 0) return parsed[0]?.id || 1 } } catch {} return 1 }) const activeTabRef = useRef(activeTab) useEffect(() => { activeTabRef.current = activeTab }, [activeTab]) const [sshConnections, setSshConnections] = useState([]) const [systemTerminals, setSystemTerminals] = useState([]) const [showMenu, setShowMenu] = useState(false) const [showSshModal, setShowSshModal] = useState(false) const [editingTab, setEditingTab] = useState(null) const [editName, setEditName] = useState('') const [terminalSettings, setTerminalSettings] = useState({ fontSize: 12, fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', 'Menlo', monospace", theme: 'system', }) const [showSearch, setShowSearch] = useState(false) const [searchText, setSearchText] = useState('') const searchInputRef = useRef(null) const searchDecorationsRef = useRef(null) useEffect(() => { settingsRef.current = terminalSettings }, [terminalSettings]) const [sshForm, setSshForm] = useState({ name: '', host: '', port: 22, user: '', key_path: '', }) const [aiMessages, setAiMessages] = useState([]) const [aiInput, setAiInput] = useState('') const [aiLoading, setAiLoading] = useState(false) const [aiTokens, setAiTokens] = useState(0) const [aiAtLimit, setAiAtLimit] = useState(false) const [analyzing, setAnalyzing] = useState(false) const [showAnalysis, setShowAnalysis] = useState(false) const [analysisContent, setAnalysisContent] = useState('') const aiMessagesRef = useRef(null) const aiLoadedRef = useRef(false) const aiLoadingRef = useRef(false) useEffect(() => { aiMessagesRef.current?.scrollTo(0, aiMessagesRef.current.scrollHeight) }, [aiMessages]) useEffect(() => { api.getShellAnalysis?.().then(d => { if (d?.analysis) setAnalysisContent(d.analysis) }).catch(() => { const stored = localStorage.getItem('shell_analysis') if (stored) setAnalysisContent(stored) }) }, []) useEffect(() => { if (aiLoadedRef.current) return aiLoadedRef.current = true api.getShellChatHistory().then(d => { if (d.messages && d.messages.length > 0) { setAiMessages(d.messages) } else { setAiMessages([{ role: 'assistant', content: t('shell.aiWelcome') || 'Système Analyste prêt. Tapez /help pour les commandes.' }]) } setAiTokens(d.tokens || 0) setAiAtLimit(d.at_limit || false) }).catch(() => { setAiMessages([{ role: 'assistant', content: 'Système Analyste prêt.' }]) }) }, []) useEffect(() => { const maxId = tabs.reduce((max, t) => Math.max(max, t.id), 0) nextIdRef.current = maxId + 1 }, []) useEffect(() => { api.getTerminalSessions().then(d => { setSshConnections(d.ssh || []) setSystemTerminals(d.system || []) }).catch(() => {}) api.getConfig().then(d => { if (d.terminal) { setTerminalSettings({ fontSize: d.terminal.font_size || 12, fontFamily: d.terminal.font_family || "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', 'Menlo', monospace", theme: d.terminal.theme || 'system', }) } }).catch(() => {}) }, []) const initTerminal = useCallback((tabId, tab) => { if (tabsRef.current[tabId]) return const container = document.getElementById(`terminal-${tabId}`) if (!container) return const s = settingsRef.current const { term, fitAddon, searchAddon } = createTerminal(container, { fontSize: s.fontSize, fontFamily: s.fontFamily, theme: s.theme, }) let initPayload if (tab.type === 'ssh') { initPayload = { type: 'ssh', data: JSON.stringify({ host: tab.host, port: tab.port || 22, user: tab.user || 'root', key_path: tab.key_path || '', }), } } else { initPayload = { type: 'shell', data: tab.shell || '', } } let disposed = false const saveBuffer = () => { try { const buf = term.buffer.active const lines = [] for (let i = 0; i < buf.length; i++) { const line = buf.getLine(i) if (line) lines.push(line.translateToString(true)) } const savedBuffers = JSON.parse(sessionStorage.getItem(TERMINAL_BUFFER_KEY) || '{}') savedBuffers[tabId] = lines.join('\n') sessionStorage.setItem(TERMINAL_BUFFER_KEY, JSON.stringify(savedBuffers)) } catch (e) { console.warn('[Shell] Buffer save failed:', e) } } const onWsState = (connected) => { if (disposed) return if (!connected) saveBuffer() setTabs(prev => prev.map(t => t.id === tabId ? { ...t, connected } : t)) } const restoreBuffer = () => { try { const savedBuffers = JSON.parse(sessionStorage.getItem(TERMINAL_BUFFER_KEY) || '{}') if (savedBuffers[tabId]) { term.write('\x1b[90m— session restaurée —\x1b[0m\r\n') term.write(savedBuffers[tabId]) } } catch (e) { console.warn('[Shell] Buffer restore failed:', e) } } const ws = connectWebSocket(term, fitAddon, initPayload, onWsState, restoreBuffer) const clearBufferOnClear = () => { try { const buf = term.buffer.active const lineY = buf.length - 1 const line = buf.getLine(lineY) if (line) { const text = line.translateToString(true).trim().toLowerCase() if (text === 'clear' || text === '$ clear' || text.endsWith(' clear')) { const savedBuffers = JSON.parse(sessionStorage.getItem(TERMINAL_BUFFER_KEY) || '{}') delete savedBuffers[tabId] sessionStorage.setItem(TERMINAL_BUFFER_KEY, JSON.stringify(savedBuffers)) } } } catch (e) { console.warn('[Shell] Clear detection failed:', e) } } term.onData((data) => { if (data === '\r') clearBufferOnClear() if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'input', data })) } }) const onResize = () => { fitAddon.fit() } const resizeObserver = new ResizeObserver(onResize) resizeObserver.observe(container) window.addEventListener('resize', onResize) const bufferSaveInterval = setInterval(() => { if (!disposed) saveBuffer() }, 5000) console.log(`[Shell] initTerminal tab=${tabId} type=${tab.type} name="${tab.name}" shell="${tab.shell || '(default)'}"`) tabsRef.current[tabId] = { term, fitAddon, searchAddon, ws, resizeObserver, onResize, bufferSaveInterval, saveBuffer, disposed: () => disposed } tabsRef.current[tabId]._markDisposed = () => { disposed = true } console.log(`[Shell] initTerminal tab=${tabId} done, tabsRef keys:`, Object.keys(tabsRef.current)) const pending = pendingCommandsRef.current[tabId] if (pending && pending.length > 0) { console.log(`[Shell] Flushing ${pending.length} pending commands for tab ${tabId}`) for (const cmd of pending) { if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'input', data: cmd + '\r' })) } } delete pendingCommandsRef.current[tabId] } }, []) const initPendingTabs = useCallback(() => { for (const tab of tabsRef.current._tabList || []) { if (!tabsRef.current[tab.id]) { const container = document.getElementById(`terminal-${tab.id}`) if (container && container.offsetHeight > 0) { initTerminal(tab.id, tab) } } } requestAnimationFrame(() => { for (const tab of tabsRef.current._tabList || []) { const entry = tabsRef.current[tab.id] if (entry) entry.fitAddon.fit() } setTimeout(() => { for (const tab of tabsRef.current._tabList || []) { const entry = tabsRef.current[tab.id] if (entry) entry.fitAddon.fit() } }, 150) }) }, [initTerminal]) useEffect(() => { tabsRef.current._tabList = tabs }, [tabs]) useEffect(() => { let cancelled = false const pending = [] // Forcer le layout à se calculer const forceLayout = () => { const el = document.querySelector('.shell-terminal-col') if (el) { el.style.height = '' el.style.minHeight = '' // Forcer reflow void el.offsetHeight } } const tryInitTab = (tab, attempt) => { if (cancelled) return if (attempt > 20) { console.warn(`[Shell] max attempts reached for tab ${tab.id}`) return } forceLayout() const shellCol = document.querySelector('.shell-terminal-col') if (!shellCol) { pending.push(setTimeout(() => tryInitTab(tab, attempt + 1), 150)) return } const container = document.getElementById(`terminal-${tab.id}`) if (!container) { pending.push(setTimeout(() => tryInitTab(tab, attempt + 1), 100)) return } const rect = container.getBoundingClientRect() if (rect.height < 10 || rect.width < 10) { pending.push(setTimeout(() => tryInitTab(tab, attempt + 1), 100)) return } if (!tabsRef.current[tab.id]) { initTerminal(tab.id, tab) } // Multiple fit attempts avec délais croissants const fitAttempts = [0, 50, 100, 200, 400] fitAttempts.forEach(delay => { setTimeout(() => { if (cancelled) return const entry = tabsRef.current[tab.id] if (entry && entry.fitAddon) { try { entry.fitAddon.fit() } catch (e) { console.warn(`[Shell] fit attempt ${delay}ms failed:`, e) } } }, delay) }) } const wrapper = document.querySelector('.shell-layout')?.parentElement let observer if (wrapper) { observer = new MutationObserver(() => { if (!wrapper.classList.contains('tab-hidden') && wrapper.offsetParent !== null) { initPendingTabs() } }) observer.observe(wrapper, { attributes: true, attributeFilter: ['class'] }) } return () => { cancelled = true pending.forEach(clearTimeout) observer?.disconnect() } }, [tabs, initTerminal, initPendingTabs]) useEffect(() => { const entry = tabsRef.current[activeTab] if (entry) { requestAnimationFrame(() => { if (activeTabRef.current === activeTab) { entry.fitAddon.fit() } }) } }, [activeTab]) useEffect(() => { const iv = setInterval(() => { const wrapper = document.querySelector('.shell-layout')?.parentElement if (wrapper && wrapper.classList.contains('tab-hidden')) return const entry = tabsRef.current[activeTabRef.current] if (entry) { entry.fitAddon.fit() } }, 2000) return () => clearInterval(iv) }, [tabs]) useEffect(() => { return () => { for (const [tabId, entry] of Object.entries(tabsRef.current)) { entry._markDisposed?.() if (entry.bufferSaveInterval) clearInterval(entry.bufferSaveInterval) window.removeEventListener('resize', entry.onResize) entry.resizeObserver?.disconnect() entry.ws?.close() entry.term?.dispose() } tabsRef.current = {} } }, []) useEffect(() => { const onKey = (e) => { const ctrl = e.ctrlKey || e.metaKey if (ctrl && e.shiftKey && e.key === 'F') { const shellTab = document.querySelector('.shell-layout') if (!shellTab || shellTab.closest('.tab-hidden')) return e.preventDefault() setShowSearch(prev => !prev) return } if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return if (!e.altKey && !(e.key === 'Tab' && e.shiftKey)) return if (e.key === 'Tab' && e.shiftKey) { const shellTab = document.querySelector('.shell-layout') if (!shellTab || shellTab.closest('.tab-hidden')) return e.preventDefault() const idx = tabs.findIndex(t => t.id === activeTab) const next = (idx + 1) % tabs.length setActiveTab(tabs[next].id) return } const num = parseInt(e.key) if (num >= 1 && num <= tabs.length) { e.preventDefault() setActiveTab(tabs[num - 1].id) } } window.addEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey) }, [tabs]) useEffect(() => { if (showSearch && searchInputRef.current) { searchInputRef.current.focus() } }, [showSearch]) const handleSearchChange = useCallback((value) => { setSearchText(value) const entry = tabsRef.current[activeTabRef.current] if (!entry?.searchAddon) return if (!value) { entry.searchAddon.clearDecorations() entry.searchAddon.clearActiveDecoration() return } try { searchDecorationsRef.current = entry.searchAddon.findNext(value) } catch {} }, []) const handleSearchNext = useCallback(() => { const entry = tabsRef.current[activeTabRef.current] if (!entry?.searchAddon || !searchText) return try { entry.searchAddon.findNext(searchText) } catch {} }, [searchText]) const handleSearchPrev = useCallback(() => { const entry = tabsRef.current[activeTabRef.current] if (!entry?.searchAddon || !searchText) return try { entry.searchAddon.findPrevious(searchText) } catch {} }, [searchText]) const handleCloseSearch = useCallback(() => { setShowSearch(false) setSearchText('') const entry = tabsRef.current[activeTabRef.current] if (entry?.searchAddon) { entry.searchAddon.clearDecorations() entry.searchAddon.clearActiveDecoration() } if (entry?.term) entry.term.focus() }, []) const addLocalTab = (shell, name) => { if (tabs.length >= MAX_TABS) return const id = nextIdRef.current++ const newTab = { id, name: name || `${t('shell.localShell')} ${tabs.length}`, type: 'local', shell: shell || '', connected: false } setTabs(prev => { const next = [...prev, newTab]; localStorage.setItem(TABS_STORAGE_KEY, JSON.stringify(next.map(t => ({ id: t.id, name: t.name, type: t.type, shell: t.shell, host: t.host, port: t.port, user: t.user, key_path: t.key_path })))); return next }) setActiveTab(id) setShowMenu(false) } const addSSHTab = (conn) => { if (tabs.length >= MAX_TABS) return const id = nextIdRef.current++ const newTab = { id, name: conn.name || `${conn.user}@${conn.host}`, type: 'ssh', host: conn.host, port: conn.port || 22, user: conn.user || 'root', key_path: conn.key_path || '', connected: false, } setTabs(prev => { const next = [...prev, newTab]; localStorage.setItem(TABS_STORAGE_KEY, JSON.stringify(next.map(t => ({ id: t.id, name: t.name, type: t.type, shell: t.shell, host: t.host, port: t.port, user: t.user, key_path: t.key_path })))); return next }) setActiveTab(id) setShowMenu(false) } const closeTab = (tabId, e) => { if (e) e.stopPropagation() const entry = tabsRef.current[tabId] if (entry) { entry._markDisposed?.() entry.saveBuffer?.() if (entry.bufferSaveInterval) clearInterval(entry.bufferSaveInterval) window.removeEventListener('resize', entry.onResize) entry.resizeObserver.disconnect() entry.ws.close() entry.term.dispose() delete tabsRef.current[tabId] } try { const savedBuffers = JSON.parse(sessionStorage.getItem(TERMINAL_BUFFER_KEY) || '{}') delete savedBuffers[tabId] sessionStorage.setItem(TERMINAL_BUFFER_KEY, JSON.stringify(savedBuffers)) } catch (e) { console.warn('[Shell] Buffer cleanup failed:', e) } setTabs(prev => { if (prev.length <= 1) return prev const next = prev.filter(t => t.id !== tabId) if (activeTab === tabId && next.length > 0) { setActiveTab(next[next.length - 1].id) } return next }) // Redimensionner le nouveau tab actif setTimeout(() => { const newActiveTabId = next.length > 0 ? next[next.length - 1].id : null if (newActiveTabId) { const entry = tabsRef.current[newActiveTabId] if (entry && entry.fitAddon) { try { entry.fitAddon.fit() } catch (e) { console.warn('[Shell] fit after close failed:', e) } } } }, 100) } const startRename = (tabId, e) => { if (e) e.stopPropagation() const tab = tabs.find(t => t.id === tabId) setEditingTab(tabId) setEditName(tab.name) } const finishRename = () => { if (editName.trim() && editingTab) { setTabs(prev => prev.map(t => t.id === editingTab ? { ...t, name: editName.trim() } : t)) } setEditingTab(null) setEditName('') } const saveSSHConnection = async () => { if (!sshForm.name.trim() || !sshForm.host.trim()) return try { await api.addSSHConnection(sshForm) setSshConnections(prev => [...prev, { ...sshForm }]) setSshForm({ name: '', host: '', port: 22, user: '', key_path: '' }) setShowSshModal(false) } catch (err) { console.error(err) } } const deleteSSHConnection = async (name) => { try { await api.deleteSSHConnection(name) setSshConnections(prev => prev.filter(c => c.name !== name)) } catch (err) { console.error(err) } } const sendToTerminal = useCallback((code, tabId) => { const targetId = tabId || activeTabRef.current const entry = tabsRef.current[targetId] if (!entry) { console.warn(`[Shell] sendToTerminal: tab ${targetId} not ready. Queueing. tabsRef:`, Object.keys(tabsRef.current), 'activeTab:', activeTabRef.current, 'requested:', tabId) if (!pendingCommandsRef.current[targetId]) pendingCommandsRef.current[targetId] = [] pendingCommandsRef.current[targetId].push(code) return } if (!entry.ws || entry.ws.readyState !== WebSocket.OPEN) { console.warn(`[Shell] sendToTerminal: WS not open for tab ${targetId} (state=${entry.ws?.readyState}). Queueing.`) if (!pendingCommandsRef.current[targetId]) pendingCommandsRef.current[targetId] = [] pendingCommandsRef.current[targetId].push(code) return } console.log(`[Shell] sendToTerminal: tab ${targetId} ← ${code.length} chars`) entry.ws.send(JSON.stringify({ type: 'input', data: code + '\r' })) }, []) const focusAiTerminal = useCallback(() => { const entry = tabsRef.current[activeTabRef.current] if (entry) entry.term.focus() }, []) const _sendAiMessage = useCallback(async (text, fromEvent = false) => { if (!text || !text.trim() || aiLoadingRef.current || aiAtLimit) return const trimmed = text.trim() aiLoadingRef.current = true if (!fromEvent) { setAiInput('') setTimeout(() => focusAiTerminal(), 0) } if (trimmed === '/clear') { try { await api.clearShellChat() setAiMessages([{ role: 'assistant', content: t('shell.aiWelcome') || 'Contexte effacé. Prêt.' }]) setAiTokens(0) setAiAtLimit(false) } catch {} aiLoadingRef.current = false return } if (trimmed === '/help') { setAiMessages(prev => [...prev, { role: 'user', content: trimmed }, { role: 'assistant', content: 'Commandes disponibles:\n• /clear — Effacer la conversation\n• /help — Afficher l\'aide\n\nJe ne peux pas exécuter de code. Les blocs de code proposés peuvent être copiés ou envoyés directement au terminal actif.' } ]) aiLoadingRef.current = false return } const currentTab = activeTabRef.current console.log(`[Shell] _sendAiMessage: activeTab=${currentTab}, fromEvent=${fromEvent}, text="${trimmed.slice(0, 50)}"`) setAiMessages(prev => [...prev, { role: 'user', content: trimmed, _tabId: currentTab }]) setAiLoading(true) try { let accumulated = '' await api.sendShellChat(trimmed, {}, true, (partial) => { accumulated = partial setAiMessages(prev => { const filtered = prev.filter(m => !m._streaming) return [...filtered, { role: 'assistant', content: partial, _streaming: true, _tabId: currentTab }] }) }) setAiMessages(prev => { const filtered = prev.filter(m => !m._streaming) return [...filtered, { role: 'assistant', content: accumulated, _tabId: currentTab }] }) api.getShellChatHistory().then(d => { setAiTokens(d.tokens || 0) setAiAtLimit(d.at_limit || false) }).catch(() => {}) } catch (err) { if (err.message?.includes('context limit')) { setAiAtLimit(true) } setAiMessages(prev => [...prev.filter(m => !m._streaming), { role: 'assistant', content: `Erreur: ${err.message}` }]) } setAiLoading(false) aiLoadingRef.current = false }, [api, t, aiAtLimit, focusAiTerminal]) const handleAiSend = () => _sendAiMessage(aiInput, false) useEffect(() => { const handler = (e) => { const msg = e.detail?.message if (!msg) return setAiInput(msg) setTimeout(() => _sendAiMessage(msg, true), 100) } window.addEventListener('ask-ai-terminal', handler) return () => window.removeEventListener('ask-ai-terminal', handler) }, [_sendAiMessage]) const handleAnalyze = async () => { setAnalyzing(true) setAiMessages(prev => [...prev, { role: 'system', content: 'Analyse du système en cours...' }]) try { const d = await api.analyzeSystem() if (d.analysis) { setAnalysisContent(d.analysis) localStorage.setItem('shell_analysis', d.analysis) } setAiMessages(prev => [...prev.filter(m => m.content !== 'Analyse du système en cours...'), { role: 'system', content: 'Analyse système terminée et sauvegardée. Le contexte système est maintenant disponible.' }]) } catch (err) { setAiMessages(prev => prev.filter(m => m.content !== 'Analyse du système en cours...')) } setAnalyzing(false) } return (
{tabs.map((tab, i) => (
setActiveTab(tab.id)} onDoubleClick={(e) => startRename(tab.id, e)} > {tab.type === 'ssh' && } {tab.type === 'local' && } {editingTab === tab.id ? ( setEditName(e.target.value)} onBlur={finishRename} onKeyDown={e => { if (e.key === 'Enter') finishRename(); if (e.key === 'Escape') setEditingTab(null) }} autoFocus onClick={e => e.stopPropagation()} /> ) : ( {tab.name} )} {i + 1} {tabs.length > 1 && ( )}
))}
{tabs.length < MAX_TABS && (
{showMenu && ( <>
setShowMenu(false)} />
{t('shell.systemTerminals')}
{systemTerminals.map(st => ( ))}
{t('shell.savedConnections')}
{sshConnections.length === 0 && (
{t('shell.noConnections')}
)} {sshConnections.map(conn => (
))}
)}
)}
{showSearch && (
handleSearchChange(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') { e.shiftKey ? handleSearchPrev() : handleSearchNext() } if (e.key === 'Escape') handleCloseSearch() e.stopPropagation() }} placeholder="Rechercher..." />
)} {tabs.map(tab => (
))}
Analyste Système
= SHELL_MAX_TOKENS * 0.8 ? 'warn' : ''}`} style={{ width: `${Math.min(100, (aiTokens / SHELL_MAX_TOKENS) * 100)}%` }} />
{Math.round(aiTokens / 1000)}k/{Math.round(SHELL_MAX_TOKENS / 1000)}k
{aiMessages.map((msg, i) => ( ))} {aiLoading &&
}
setAiInput(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); e.stopPropagation(); handleAiSend() } }} placeholder={aiAtLimit ? '/clear pour continuer' : t('shell.askAi')} disabled={aiAtLimit && aiInput !== '/clear'} />
{showAnalysis && analysisContent && (
setShowAnalysis(false)}>
e.stopPropagation()}>
Analyse Système
{renderContent(analysisContent).map((part, i) => part.type === 'code' ? (
{part.lang &&
{part.lang}
}
{part.content}
) : ( ) )}
)} {showSshModal && (
setShowSshModal(false)}>
e.stopPropagation()}>
{t('shell.addConnection')}
setSshForm(f => ({ ...f, name: e.target.value }))} placeholder="prod-server" /> setSshForm(f => ({ ...f, host: e.target.value }))} placeholder="192.168.1.100" />
setSshForm(f => ({ ...f, port: parseInt(e.target.value) || 22 }))} />
setSshForm(f => ({ ...f, user: e.target.value }))} placeholder="root" />
setSshForm(f => ({ ...f, key_path: e.target.value }))} placeholder="~/.ssh/id_rsa" />
)}
) } function ShellAIMessage({ msg, sendToTerminal, terminalTabId }) { const role = msg.role === 'user' ? 'user' : msg.role === 'system' ? 'system' : 'assistant' const content = msg.content || '' if (role === 'user') { return
{content}
} if (role === 'system') { return
{content}
} const parts = renderContent(content) return (
{parts.map((part, i) => { if (part.type === 'code') { return (
{part.lang &&
{part.lang}
}
{part.content}
) } return })}
) }