|
|
|
|
@@ -149,7 +149,7 @@ function createTerminal(container, settings = {}) {
|
|
|
|
|
return { term, fitAddon }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function connectWebSocket(term, fitAddon, initPayload) {
|
|
|
|
|
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`)
|
|
|
|
|
|
|
|
|
|
@@ -159,9 +159,15 @@ function connectWebSocket(term, fitAddon, initPayload) {
|
|
|
|
|
if (dims) {
|
|
|
|
|
ws.send(JSON.stringify({ type: 'resize', rows: dims.rows, cols: dims.cols }))
|
|
|
|
|
}
|
|
|
|
|
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') {
|
|
|
|
|
@@ -176,16 +182,12 @@ function connectWebSocket(term, fitAddon, initPayload) {
|
|
|
|
|
|
|
|
|
|
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')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
term.onData((data) => {
|
|
|
|
|
if (ws.readyState === WebSocket.OPEN) {
|
|
|
|
|
ws.send(JSON.stringify({ type: 'input', data }))
|
|
|
|
|
}
|
|
|
|
|
if (onStateChange) onStateChange(false)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
term.onResize(({ rows, cols }) => {
|
|
|
|
|
@@ -202,6 +204,10 @@ export default function Shell({ api }) {
|
|
|
|
|
const tabsRef = useRef({})
|
|
|
|
|
const nextIdRef = useRef(1)
|
|
|
|
|
const settingsRef = useRef({ fontSize: 12, fontFamily: "'JetBrains Mono', 'Fira Code', monospace", theme: 'default' })
|
|
|
|
|
const activeTabRef = useRef(activeTab)
|
|
|
|
|
const pendingCommandsRef = useRef({})
|
|
|
|
|
|
|
|
|
|
useEffect(() => { activeTabRef.current = activeTab }, [activeTab])
|
|
|
|
|
|
|
|
|
|
const savedTabs = (() => {
|
|
|
|
|
try {
|
|
|
|
|
@@ -253,6 +259,7 @@ export default function Shell({ api }) {
|
|
|
|
|
const [analysisContent, setAnalysisContent] = useState('')
|
|
|
|
|
const aiMessagesRef = useRef(null)
|
|
|
|
|
const aiLoadedRef = useRef(false)
|
|
|
|
|
const aiLoadingRef = useRef(false)
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
aiMessagesRef.current?.scrollTo(0, aiMessagesRef.current.scrollHeight)
|
|
|
|
|
@@ -335,15 +342,7 @@ export default function Shell({ api }) {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const ws = connectWebSocket(term, fitAddon, initPayload)
|
|
|
|
|
|
|
|
|
|
// Restore saved terminal buffer
|
|
|
|
|
try {
|
|
|
|
|
const savedBuffers = JSON.parse(localStorage.getItem(TERMINAL_BUFFER_KEY) || '{}')
|
|
|
|
|
if (savedBuffers[tabId]) {
|
|
|
|
|
term.write(savedBuffers[tabId])
|
|
|
|
|
}
|
|
|
|
|
} catch {}
|
|
|
|
|
let disposed = false
|
|
|
|
|
|
|
|
|
|
const saveBuffer = () => {
|
|
|
|
|
try {
|
|
|
|
|
@@ -353,49 +352,53 @@ export default function Shell({ api }) {
|
|
|
|
|
const line = buf.getLine(i)
|
|
|
|
|
if (line) lines.push(line.translateToString(true))
|
|
|
|
|
}
|
|
|
|
|
const savedBuffers = JSON.parse(localStorage.getItem(TERMINAL_BUFFER_KEY) || '{}')
|
|
|
|
|
const savedBuffers = JSON.parse(sessionStorage.getItem(TERMINAL_BUFFER_KEY) || '{}')
|
|
|
|
|
savedBuffers[tabId] = lines.join('\n')
|
|
|
|
|
localStorage.setItem(TERMINAL_BUFFER_KEY, JSON.stringify(savedBuffers))
|
|
|
|
|
} catch {}
|
|
|
|
|
sessionStorage.setItem(TERMINAL_BUFFER_KEY, JSON.stringify(savedBuffers))
|
|
|
|
|
} catch (e) { console.warn('[Shell] Buffer save failed:', e) }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const bufferSaveInterval = setInterval(saveBuffer, 5000)
|
|
|
|
|
const onWsState = (connected) => {
|
|
|
|
|
if (disposed) return
|
|
|
|
|
if (!connected) saveBuffer()
|
|
|
|
|
setTabs(prev => prev.map(t => t.id === tabId ? { ...t, connected } : t))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Detect clear command to wipe saved buffer
|
|
|
|
|
let inputBuffer = ''
|
|
|
|
|
term.onData((data) => {
|
|
|
|
|
if (data === '\r') {
|
|
|
|
|
const cmd = inputBuffer.replace(/[\x1b\x00-\x1f]/g, '').trim().toLowerCase()
|
|
|
|
|
if (cmd === 'clear') {
|
|
|
|
|
const restoreBuffer = () => {
|
|
|
|
|
try {
|
|
|
|
|
const savedBuffers = JSON.parse(localStorage.getItem(TERMINAL_BUFFER_KEY) || '{}')
|
|
|
|
|
delete savedBuffers[tabId]
|
|
|
|
|
localStorage.setItem(TERMINAL_BUFFER_KEY, JSON.stringify(savedBuffers))
|
|
|
|
|
} catch {}
|
|
|
|
|
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])
|
|
|
|
|
}
|
|
|
|
|
inputBuffer = ''
|
|
|
|
|
} else if (data === '\x7f' || data === '\b') {
|
|
|
|
|
inputBuffer = inputBuffer.slice(0, -1)
|
|
|
|
|
} else if (data === '\x03') {
|
|
|
|
|
inputBuffer = ''
|
|
|
|
|
} else if (data.length === 1 && data.charCodeAt(0) >= 32) {
|
|
|
|
|
inputBuffer += data
|
|
|
|
|
} 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 }))
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
ws.onopen = () => {
|
|
|
|
|
setTabs(prev => prev.map(t => t.id === tabId ? { ...t, connected: true } : t))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ws.onclose = () => {
|
|
|
|
|
saveBuffer()
|
|
|
|
|
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) {
|
|
|
|
|
@@ -407,35 +410,59 @@ export default function Shell({ api }) {
|
|
|
|
|
resizeObserver.observe(container)
|
|
|
|
|
window.addEventListener('resize', onResize)
|
|
|
|
|
|
|
|
|
|
tabsRef.current[tabId] = { term, fitAddon, ws, resizeObserver, onResize, bufferSaveInterval, saveBuffer }
|
|
|
|
|
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, 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]
|
|
|
|
|
}
|
|
|
|
|
}, [])
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const tab = tabs.find(t => t.id === activeTab)
|
|
|
|
|
if (!tab) return
|
|
|
|
|
|
|
|
|
|
let cancelled = false
|
|
|
|
|
const pending = []
|
|
|
|
|
|
|
|
|
|
const tryInit = (attempt) => {
|
|
|
|
|
if (attempt > 20) return
|
|
|
|
|
if (cancelled || attempt > 20) return
|
|
|
|
|
const shellCol = document.querySelector('.shell-terminal-col')
|
|
|
|
|
if (!shellCol || shellCol.offsetParent === null) {
|
|
|
|
|
setTimeout(() => tryInit(attempt + 1), 150)
|
|
|
|
|
pending.push(setTimeout(() => tryInit(attempt + 1), 150))
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
const container = document.getElementById(`terminal-${tab.id}`)
|
|
|
|
|
if (!container || container.offsetHeight === 0) {
|
|
|
|
|
setTimeout(() => tryInit(attempt + 1), 100)
|
|
|
|
|
pending.push(setTimeout(() => tryInit(attempt + 1), 100))
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if (!tabsRef.current[tab.id]) {
|
|
|
|
|
initTerminal(tab.id, tab)
|
|
|
|
|
}
|
|
|
|
|
requestAnimationFrame(() => {
|
|
|
|
|
if (cancelled) return
|
|
|
|
|
const entry = tabsRef.current[tab.id]
|
|
|
|
|
if (entry) entry.fitAddon.fit()
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
tryInit(0)
|
|
|
|
|
return () => {
|
|
|
|
|
cancelled = true
|
|
|
|
|
pending.forEach(clearTimeout)
|
|
|
|
|
}
|
|
|
|
|
}, [activeTab, tabs, initTerminal])
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
@@ -453,6 +480,20 @@ export default function Shell({ api }) {
|
|
|
|
|
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) => {
|
|
|
|
|
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return
|
|
|
|
|
@@ -507,21 +548,27 @@ export default function Shell({ api }) {
|
|
|
|
|
|
|
|
|
|
const closeTab = (tabId, e) => {
|
|
|
|
|
if (e) e.stopPropagation()
|
|
|
|
|
const tab = tabs.find(t => t.id === tabId)
|
|
|
|
|
if (!tab || tabs.length <= 1) return
|
|
|
|
|
|
|
|
|
|
if (tabsRef.current[tabId]) {
|
|
|
|
|
const { ws, resizeObserver, onResize, term, bufferSaveInterval, saveBuffer } = tabsRef.current[tabId]
|
|
|
|
|
if (saveBuffer) saveBuffer()
|
|
|
|
|
if (bufferSaveInterval) clearInterval(bufferSaveInterval)
|
|
|
|
|
window.removeEventListener('resize', onResize)
|
|
|
|
|
resizeObserver.disconnect()
|
|
|
|
|
ws.close()
|
|
|
|
|
term.dispose()
|
|
|
|
|
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)
|
|
|
|
|
@@ -566,129 +613,105 @@ export default function Shell({ api }) {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const sendToTerminal = useCallback((code) => {
|
|
|
|
|
const entry = tabsRef.current[activeTab]
|
|
|
|
|
if (entry?.ws && entry.ws.readyState === WebSocket.OPEN) {
|
|
|
|
|
entry.ws.send(JSON.stringify({ type: 'input', data: code + '\r' }))
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}, [activeTab])
|
|
|
|
|
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[activeTab]
|
|
|
|
|
const entry = tabsRef.current[activeTabRef.current]
|
|
|
|
|
if (entry) entry.term.focus()
|
|
|
|
|
}, [activeTab])
|
|
|
|
|
}, [])
|
|
|
|
|
|
|
|
|
|
const handleAiSend = async () => {
|
|
|
|
|
if (!aiInput.trim() || aiLoading || aiAtLimit) return
|
|
|
|
|
const text = aiInput.trim()
|
|
|
|
|
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('')
|
|
|
|
|
focusAiTerminal()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (text === '/clear') {
|
|
|
|
|
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 (text === '/help') {
|
|
|
|
|
if (trimmed === '/help') {
|
|
|
|
|
setAiMessages(prev => [...prev,
|
|
|
|
|
{ role: 'user', content: text },
|
|
|
|
|
{ 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
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setAiMessages(prev => [...prev, { role: 'user', content: text }])
|
|
|
|
|
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(text, {}, true, (partial) => {
|
|
|
|
|
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 }]
|
|
|
|
|
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 }]
|
|
|
|
|
})
|
|
|
|
|
// 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 handleAiSendDirect = useCallback(async (text) => {
|
|
|
|
|
if (!text || aiLoading || aiAtLimit) return
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 }]
|
|
|
|
|
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')) {
|
|
|
|
|
if (err.message?.includes('context limit')) {
|
|
|
|
|
setAiAtLimit(true)
|
|
|
|
|
}
|
|
|
|
|
setAiMessages(prev => [...prev.filter(m => !m._streaming), { role: 'assistant', content: `Erreur: ${err.message}` }])
|
|
|
|
|
}
|
|
|
|
|
setAiLoading(false)
|
|
|
|
|
}, [api, t, aiLoading, aiAtLimit])
|
|
|
|
|
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(() => {
|
|
|
|
|
handleAiSendDirect(msg)
|
|
|
|
|
}, 100)
|
|
|
|
|
setTimeout(() => _sendAiMessage(msg, true), 100)
|
|
|
|
|
}
|
|
|
|
|
window.addEventListener('ask-ai-terminal', handler)
|
|
|
|
|
return () => window.removeEventListener('ask-ai-terminal', handler)
|
|
|
|
|
}, [handleAiSendDirect])
|
|
|
|
|
}, [_sendAiMessage])
|
|
|
|
|
|
|
|
|
|
const handleAnalyze = async () => {
|
|
|
|
|
setAnalyzing(true)
|
|
|
|
|
@@ -858,7 +881,7 @@ export default function Shell({ api }) {
|
|
|
|
|
</div>
|
|
|
|
|
<div className="ai-panel-messages" ref={aiMessagesRef}>
|
|
|
|
|
{aiMessages.map((msg, i) => (
|
|
|
|
|
<ShellAIMessage key={i} msg={msg} sendToTerminal={sendToTerminal} />
|
|
|
|
|
<ShellAIMessage key={i} msg={msg} sendToTerminal={sendToTerminal} terminalTabId={msg._tabId || activeTab} />
|
|
|
|
|
))}
|
|
|
|
|
{aiLoading && <div style={{ textAlign: 'center', padding: 8 }}><span className="spinner" /></div>}
|
|
|
|
|
</div>
|
|
|
|
|
@@ -950,7 +973,7 @@ export default function Shell({ api }) {
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function ShellAIMessage({ msg, sendToTerminal }) {
|
|
|
|
|
function ShellAIMessage({ msg, sendToTerminal, terminalTabId }) {
|
|
|
|
|
const role = msg.role === 'user' ? 'user' : msg.role === 'system' ? 'system' : 'assistant'
|
|
|
|
|
const content = msg.content || ''
|
|
|
|
|
|
|
|
|
|
@@ -976,7 +999,7 @@ function ShellAIMessage({ msg, sendToTerminal }) {
|
|
|
|
|
<button onClick={() => navigator.clipboard.writeText(part.content)} title="Copier">
|
|
|
|
|
<Copy size={12} /> Copier
|
|
|
|
|
</button>
|
|
|
|
|
<button onClick={() => sendToTerminal(part.content)} title="Envoyer au terminal">
|
|
|
|
|
<button onClick={() => sendToTerminal(part.content, terminalTabId)} title="Envoyer au terminal">
|
|
|
|
|
<Send size={12} /> Terminal
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|