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')}
)}
)
}
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
})}
)
}