All checks were successful
Beta Release / beta (push) Successful in 45s
- New ShellConvStore with persistent history (shell_conversation.json) - 100k token limit — input grays out, must /clear to continue - Commands limited to /clear and /help only - Shell AI has NO tools — read-only analysis, never executes code - "Analyste Système" panel with system analysis button - System analysis uses Studio AI to write system_analysis.md, prepended as context on every conversation start - Code blocks show "Copier" and "Terminal" buttons to copy or send code directly to the active terminal via WebSocket - Token bar shows usage with warning at 80% 💘 Generated with Crush Assisted-by: GLM-5.1 via Crush <crush@charm.land>
724 lines
26 KiB
JavaScript
724 lines
26 KiB
JavaScript
import { useState, useRef, useEffect, useCallback } from 'react'
|
|
import { Terminal as XTerm } from '@xterm/xterm'
|
|
import { FitAddon } from '@xterm/addon-fit'
|
|
import { WebLinksAddon } from '@xterm/addon-web-links'
|
|
import { Plus, X, Monitor, Globe, ChevronDown, Pencil, Trash2, Search, Copy, Send } from 'lucide-react'
|
|
import '@xterm/xterm/css/xterm.css'
|
|
import { useI18n } from '../i18n'
|
|
|
|
const MAX_TABS = 7
|
|
const SHELL_MAX_TOKENS = 100000
|
|
|
|
const THEMES = {
|
|
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) {
|
|
return THEMES[themeName] || THEMES.default
|
|
}
|
|
|
|
function createTerminal(container, settings = {}) {
|
|
const theme = getTheme(settings.theme || 'default')
|
|
const term = new XTerm({
|
|
cursorBlink: true,
|
|
fontSize: settings.fontSize || 14,
|
|
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()
|
|
term.loadAddon(fitAddon)
|
|
term.loadAddon(webLinksAddon)
|
|
term.open(container)
|
|
fitAddon.fit()
|
|
|
|
return { term, fitAddon }
|
|
}
|
|
|
|
function connectWebSocket(term, fitAddon, initPayload) {
|
|
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()
|
|
if (dims) {
|
|
ws.send(JSON.stringify({ type: 'resize', rows: dims.rows, cols: dims.cols }))
|
|
}
|
|
})
|
|
|
|
ws.addEventListener('message', (event) => {
|
|
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')
|
|
})
|
|
|
|
ws.addEventListener('error', () => {
|
|
term.write('\r\n\x1b[31m— Connection error —\x1b[0m\r\n')
|
|
})
|
|
|
|
term.onData((data) => {
|
|
if (ws.readyState === WebSocket.OPEN) {
|
|
ws.send(JSON.stringify({ type: 'input', data }))
|
|
}
|
|
})
|
|
|
|
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 [tabs, setTabs] = useState([
|
|
{ id: 1, name: 'Local Shell', type: 'local', shell: '', connected: false },
|
|
])
|
|
const [activeTab, setActiveTab] = useState(1)
|
|
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: 14,
|
|
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', 'Menlo', monospace",
|
|
theme: 'default',
|
|
})
|
|
|
|
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 aiMessagesRef = useRef(null)
|
|
const aiLoadedRef = useRef(false)
|
|
|
|
useEffect(() => {
|
|
aiMessagesRef.current?.scrollTo(0, aiMessagesRef.current.scrollHeight)
|
|
}, [aiMessages])
|
|
|
|
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(() => {
|
|
api.getTerminalSessions().then(d => {
|
|
setSshConnections(d.ssh || [])
|
|
setSystemTerminals(d.system || [])
|
|
}).catch(() => {})
|
|
api.getConfig().then(d => {
|
|
if (d.terminal) {
|
|
setTerminalSettings({
|
|
fontSize: d.terminal.font_size || 14,
|
|
fontFamily: d.terminal.font_family || "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', 'Menlo', monospace",
|
|
theme: d.terminal.theme || 'default',
|
|
})
|
|
}
|
|
}).catch(() => {})
|
|
}, [])
|
|
|
|
const initTerminal = useCallback((tabId, tab) => {
|
|
if (tabsRef.current[tabId]) return
|
|
|
|
const container = document.getElementById(`terminal-${tabId}`)
|
|
if (!container) return
|
|
|
|
const { term, fitAddon } = createTerminal(container, {
|
|
fontSize: terminalSettings.fontSize,
|
|
fontFamily: terminalSettings.fontFamily,
|
|
theme: terminalSettings.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 || '',
|
|
}
|
|
}
|
|
|
|
const ws = connectWebSocket(term, fitAddon, initPayload)
|
|
|
|
ws.onopen = () => {
|
|
setTabs(prev => prev.map(t => t.id === tabId ? { ...t, connected: true } : t))
|
|
}
|
|
|
|
ws.onclose = () => {
|
|
setTabs(prev => prev.map(t => t.id === tabId ? { ...t, connected: false } : t))
|
|
}
|
|
|
|
ws.onerror = () => {
|
|
setTabs(prev => prev.map(t => t.id === tabId ? { ...t, connected: false } : t))
|
|
}
|
|
|
|
const onResize = () => {
|
|
const el = document.getElementById(`terminal-${tabId}`)
|
|
if (el && el.offsetParent !== null) {
|
|
fitAddon.fit()
|
|
}
|
|
}
|
|
|
|
const resizeObserver = new ResizeObserver(onResize)
|
|
resizeObserver.observe(container)
|
|
window.addEventListener('resize', onResize)
|
|
|
|
tabsRef.current[tabId] = { term, fitAddon, ws, resizeObserver, onResize }
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
const tab = tabs.find(t => t.id === activeTab)
|
|
if (!tab) return
|
|
|
|
const container = document.getElementById(`terminal-${tab.id}`)
|
|
if (!container) return
|
|
|
|
if (!tabsRef.current[tab.id]) {
|
|
const timer = setTimeout(() => {
|
|
initTerminal(tab.id, tab)
|
|
requestAnimationFrame(() => {
|
|
const entry = tabsRef.current[tab.id]
|
|
if (entry) entry.fitAddon.fit()
|
|
})
|
|
}, 100)
|
|
return () => clearTimeout(timer)
|
|
} else {
|
|
requestAnimationFrame(() => {
|
|
const entry = tabsRef.current[tab.id]
|
|
if (entry) entry.fitAddon.fit()
|
|
})
|
|
}
|
|
}, [activeTab, tabs, initTerminal])
|
|
|
|
useEffect(() => {
|
|
const onKey = (e) => {
|
|
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return
|
|
if (!e.altKey) 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])
|
|
|
|
const addLocalTab = (shell, name) => {
|
|
if (tabs.length >= MAX_TABS) return
|
|
const id = nextIdRef.current++
|
|
const newTab = { id, name: name || `${t('shell.localShell')} ${tabs.length + 1}`, type: 'local', shell: shell || '', connected: false }
|
|
setTabs(prev => [...prev, newTab])
|
|
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 => [...prev, newTab])
|
|
setActiveTab(id)
|
|
setShowMenu(false)
|
|
}
|
|
|
|
const closeTab = (tabId, e) => {
|
|
if (e) e.stopPropagation()
|
|
if (tabs.length <= 1) return
|
|
|
|
if (tabsRef.current[tabId]) {
|
|
const { ws, resizeObserver, onResize, term } = tabsRef.current[tabId]
|
|
window.removeEventListener('resize', onResize)
|
|
resizeObserver.disconnect()
|
|
ws.close()
|
|
term.dispose()
|
|
delete tabsRef.current[tabId]
|
|
}
|
|
|
|
setTabs(prev => {
|
|
const next = prev.filter(t => t.id !== tabId)
|
|
if (activeTab === tabId && next.length > 0) {
|
|
setActiveTab(next[next.length - 1].id)
|
|
}
|
|
return next
|
|
})
|
|
}
|
|
|
|
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) => {
|
|
const tab = tabs.find(t => t.id === activeTab)
|
|
if (!tab) return
|
|
const entry = tabsRef.current[tab.id]
|
|
if (!entry?.ws || entry.ws.readyState !== WebSocket.OPEN) return
|
|
entry.ws.send(JSON.stringify({ type: 'input', data: code + '\r' }))
|
|
}, [tabs, activeTab])
|
|
|
|
const handleAiSend = async () => {
|
|
if (!aiInput.trim() || aiLoading || aiAtLimit) return
|
|
const text = aiInput.trim()
|
|
setAiInput('')
|
|
|
|
if (text === '/clear') {
|
|
try {
|
|
await api.clearShellChat()
|
|
setAiMessages([{ role: 'assistant', content: t('shell.aiWelcome') || 'Contexte effacé. Prêt.' }])
|
|
setAiTokens(0)
|
|
setAiAtLimit(false)
|
|
} catch {}
|
|
return
|
|
}
|
|
|
|
if (text === '/help') {
|
|
setAiMessages(prev => [...prev,
|
|
{ role: 'user', content: text },
|
|
{ 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.' }
|
|
])
|
|
return
|
|
}
|
|
|
|
setAiMessages(prev => [...prev, { role: 'user', content: text }])
|
|
setAiLoading(true)
|
|
|
|
try {
|
|
let accumulated = ''
|
|
await api.sendShellChat(text, {}, true, (partial) => {
|
|
accumulated = partial
|
|
setAiMessages(prev => {
|
|
const filtered = prev.filter(m => !m._streaming)
|
|
return [...filtered, { role: 'assistant', content: partial, _streaming: true }]
|
|
})
|
|
})
|
|
|
|
setAiMessages(prev => {
|
|
const filtered = prev.filter(m => !m._streaming)
|
|
return [...filtered, { role: 'assistant', content: accumulated }]
|
|
})
|
|
// Refresh token count
|
|
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)
|
|
}
|
|
|
|
const handleAnalyze = async () => {
|
|
setAnalyzing(true)
|
|
setAiMessages(prev => [...prev, { role: 'system', content: 'Analyse du système en cours...' }])
|
|
try {
|
|
const d = await api.analyzeSystem()
|
|
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 (
|
|
<div className="shell-layout">
|
|
<div className="shell-terminal-col">
|
|
<div className="shell-tabs-bar">
|
|
<div className="shell-tabs">
|
|
{tabs.map((tab, i) => (
|
|
<div
|
|
key={tab.id}
|
|
className={`shell-tab ${activeTab === tab.id ? 'active' : ''}`}
|
|
onClick={() => setActiveTab(tab.id)}
|
|
onDoubleClick={(e) => startRename(tab.id, e)}
|
|
>
|
|
<span className={`connection-dot ${tab.connected ? 'on' : 'off'}`} />
|
|
{tab.type === 'ssh' && <Globe size={12} />}
|
|
{tab.type === 'local' && <Monitor size={12} />}
|
|
{editingTab === tab.id ? (
|
|
<input
|
|
className="shell-tab-rename"
|
|
value={editName}
|
|
onChange={e => setEditName(e.target.value)}
|
|
onBlur={finishRename}
|
|
onKeyDown={e => { if (e.key === 'Enter') finishRename(); if (e.key === 'Escape') setEditingTab(null) }}
|
|
autoFocus
|
|
onClick={e => e.stopPropagation()}
|
|
/>
|
|
) : (
|
|
<span className="shell-tab-name">{tab.name}</span>
|
|
)}
|
|
<span className="shell-tab-index">{i + 1}</span>
|
|
{tabs.length > 1 && (
|
|
<button
|
|
className="shell-tab-close"
|
|
onClick={(e) => closeTab(tab.id, e)}
|
|
title={t('shell.closeTab')}
|
|
>
|
|
<X size={12} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<div className="shell-tab-actions">
|
|
{tabs.length < MAX_TABS && (
|
|
<div className="shell-new-tab-wrapper">
|
|
<button className="shell-new-tab-btn" onClick={() => setShowMenu(!showMenu)} title={t('shell.newTab')}>
|
|
<Plus size={16} />
|
|
<ChevronDown size={12} />
|
|
</button>
|
|
{showMenu && (
|
|
<>
|
|
<div className="shell-menu-overlay" onClick={() => setShowMenu(false)} />
|
|
<div className="shell-new-tab-menu">
|
|
<div className="shell-menu-label">{t('shell.systemTerminals')}</div>
|
|
{systemTerminals.map(st => (
|
|
<button
|
|
key={st.name}
|
|
className="shell-menu-item"
|
|
onClick={() => addLocalTab(st.shell, st.name)}
|
|
>
|
|
<Monitor size={14} />
|
|
<span>{st.name}</span>
|
|
<span className="shell-menu-item-sub">{st.shell}</span>
|
|
</button>
|
|
))}
|
|
<div className="shell-menu-divider" />
|
|
<div className="shell-menu-label">{t('shell.savedConnections')}</div>
|
|
{sshConnections.length === 0 && (
|
|
<div className="shell-menu-empty">{t('shell.noConnections')}</div>
|
|
)}
|
|
{sshConnections.map(conn => (
|
|
<div key={conn.name} className="shell-menu-item-row">
|
|
<button
|
|
className="shell-menu-item"
|
|
onClick={() => addSSHTab(conn)}
|
|
>
|
|
<Globe size={14} />
|
|
<span>{conn.name}</span>
|
|
<span className="shell-menu-item-sub">{conn.user}@{conn.host}:{conn.port}</span>
|
|
</button>
|
|
<button
|
|
className="shell-menu-item-icon"
|
|
onClick={(e) => { e.stopPropagation(); deleteSSHConnection(conn.name) }}
|
|
title={t('shell.deleteConnection')}
|
|
>
|
|
<Trash2 size={12} />
|
|
</button>
|
|
</div>
|
|
))}
|
|
<div className="shell-menu-divider" />
|
|
<button className="shell-menu-item accent" onClick={() => { setShowSshModal(true); setShowMenu(false) }}>
|
|
<Plus size={14} />
|
|
<span>{t('shell.addConnection')}</span>
|
|
</button>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="shell-xterm-wrapper">
|
|
{tabs.map(tab => (
|
|
<div
|
|
key={tab.id}
|
|
id={`terminal-${tab.id}`}
|
|
className="shell-xterm-instance"
|
|
style={{ display: activeTab === tab.id ? 'block' : 'none' }}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="shell-ai-col">
|
|
<div className="ai-panel-header">
|
|
<span>Analyste Système</span>
|
|
<button
|
|
className="shell-analyze-btn"
|
|
onClick={handleAnalyze}
|
|
disabled={analyzing}
|
|
title="Analyser le système"
|
|
>
|
|
<Search size={13} />
|
|
{analyzing ? '...' : 'Analyser'}
|
|
</button>
|
|
</div>
|
|
<div className="shell-ai-token-bar">
|
|
<div className="shell-ai-token-track">
|
|
<div
|
|
className={`shell-ai-token-fill ${aiTokens >= SHELL_MAX_TOKENS * 0.8 ? 'warn' : ''}`}
|
|
style={{ width: `${Math.min(100, (aiTokens / SHELL_MAX_TOKENS) * 100)}%` }}
|
|
/>
|
|
</div>
|
|
<span className="shell-ai-token-text">{Math.round(aiTokens / 1000)}k/{Math.round(SHELL_MAX_TOKENS / 1000)}k</span>
|
|
</div>
|
|
<div className="ai-panel-messages" ref={aiMessagesRef}>
|
|
{aiMessages.map((msg, i) => (
|
|
<ShellAIMessage key={i} msg={msg} sendToTerminal={sendToTerminal} />
|
|
))}
|
|
{aiLoading && <div style={{ textAlign: 'center', padding: 8 }}><span className="spinner" /></div>}
|
|
</div>
|
|
<div className="ai-panel-input">
|
|
<input
|
|
value={aiInput}
|
|
onChange={e => setAiInput(e.target.value)}
|
|
onKeyDown={e => e.key === 'Enter' && handleAiSend()}
|
|
placeholder={aiAtLimit ? '/clear pour continuer' : t('shell.askAi')}
|
|
disabled={aiAtLimit && aiInput !== '/clear'}
|
|
/>
|
|
<button className="sm" onClick={handleAiSend} disabled={(!aiInput.trim() && !aiAtLimit) || (aiAtLimit && aiInput !== '/clear')}>{t('shell.send')}</button>
|
|
</div>
|
|
</div>
|
|
|
|
{showSshModal && (
|
|
<div className="shell-modal-overlay" onClick={() => setShowSshModal(false)}>
|
|
<div className="shell-modal" onClick={e => e.stopPropagation()}>
|
|
<div className="shell-modal-header">{t('shell.addConnection')}</div>
|
|
<div className="shell-modal-body">
|
|
<label className="shell-modal-label">{t('shell.connectionName')}</label>
|
|
<input
|
|
value={sshForm.name}
|
|
onChange={e => setSshForm(f => ({ ...f, name: e.target.value }))}
|
|
placeholder="prod-server"
|
|
/>
|
|
<label className="shell-modal-label">{t('shell.host')}</label>
|
|
<input
|
|
value={sshForm.host}
|
|
onChange={e => setSshForm(f => ({ ...f, host: e.target.value }))}
|
|
placeholder="192.168.1.100"
|
|
/>
|
|
<div className="shell-modal-row">
|
|
<div className="shell-modal-field">
|
|
<label className="shell-modal-label">{t('shell.port')}</label>
|
|
<input
|
|
type="number"
|
|
value={sshForm.port}
|
|
onChange={e => setSshForm(f => ({ ...f, port: parseInt(e.target.value) || 22 }))}
|
|
/>
|
|
</div>
|
|
<div className="shell-modal-field">
|
|
<label className="shell-modal-label">{t('shell.user')}</label>
|
|
<input
|
|
value={sshForm.user}
|
|
onChange={e => setSshForm(f => ({ ...f, user: e.target.value }))}
|
|
placeholder="root"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<label className="shell-modal-label">{t('shell.keyPath')} ({t('shell.local')})</label>
|
|
<input
|
|
value={sshForm.key_path}
|
|
onChange={e => setSshForm(f => ({ ...f, key_path: e.target.value }))}
|
|
placeholder="~/.ssh/id_rsa"
|
|
/>
|
|
</div>
|
|
<div className="shell-modal-footer">
|
|
<button className="ghost" onClick={() => setShowSshModal(false)}>{t('shell.cancel')}</button>
|
|
<button className="primary" onClick={saveSSHConnection}>{t('shell.save')}</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function ShellAIMessage({ msg, sendToTerminal }) {
|
|
const role = msg.role === 'user' ? 'user' : msg.role === 'system' ? 'system' : 'assistant'
|
|
const parts = parseMarkdown(msg.content || '')
|
|
|
|
return (
|
|
<div className={`ai-message ${role}`}>
|
|
{parts.map((part, i) => {
|
|
if (part.type === 'code') {
|
|
return (
|
|
<div key={i} className="shell-code-block">
|
|
{part.lang && <div className="shell-code-lang">{part.lang}</div>}
|
|
<pre><code>{part.code}</code></pre>
|
|
<div className="shell-code-actions">
|
|
<button onClick={() => navigator.clipboard.writeText(part.code)} title="Copier">
|
|
<Copy size={12} /> Copier
|
|
</button>
|
|
<button onClick={() => sendToTerminal(part.code)} title="Envoyer au terminal">
|
|
<Send size={12} /> Terminal
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
return <span key={i}>{part.text}</span>
|
|
})}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function parseMarkdown(text) {
|
|
const parts = []
|
|
const regex = /```(\w*)\n([\s\S]*?)```/g
|
|
let last = 0
|
|
let match
|
|
while ((match = regex.exec(text)) !== null) {
|
|
if (match.index > last) {
|
|
parts.push({ type: 'text', text: text.slice(last, match.index) })
|
|
}
|
|
parts.push({ type: 'code', lang: match[1] || '', code: match[2].replace(/\n$/, '') })
|
|
last = match.index + match[0].length
|
|
}
|
|
if (last < text.length) {
|
|
parts.push({ type: 'text', text: text.slice(last) })
|
|
}
|
|
return parts.length > 0 ? parts : [{ type: 'text', text }]
|
|
}
|