Compare commits
12 Commits
v0.3.5-bet
...
v0.3.5-bet
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3cf701b002 | ||
|
|
3a09e0e0c2 | ||
|
|
47fa2e01bb | ||
|
|
401292ec5b | ||
|
|
199a7e409a | ||
|
|
c91931f42f | ||
|
|
cbbb224725 | ||
|
|
8d10d2182e | ||
|
|
e9696ef82b | ||
|
|
1edd4f053a | ||
|
|
92f943c3e6 | ||
|
|
1704b196cf |
@@ -165,8 +165,6 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
|
||||
n, err := ptmx.Read(buf)
|
||||
if err != nil {
|
||||
cleanup()
|
||||
conn.WriteMessage(websocket.CloseMessage,
|
||||
websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""))
|
||||
return
|
||||
}
|
||||
if err := conn.WriteJSON(wsMessage{
|
||||
|
||||
@@ -105,7 +105,7 @@ const api = {
|
||||
}).catch(reject)
|
||||
})
|
||||
},
|
||||
sendShellChat: (message, context = {}, stream = true, onChunk) => {
|
||||
sendShellChat: (message, context = {}, stream = true, onChunk, signal) => {
|
||||
const payload = {
|
||||
message,
|
||||
cwd: context.cwd || '',
|
||||
@@ -120,6 +120,7 @@ const api = {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
signal,
|
||||
}).then(async (res) => {
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: res.statusText }))
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { User, Brain, RefreshCw, Wrench, Monitor, AlertTriangle, X } from 'lucide-react'
|
||||
import { User, Brain, RefreshCw, Wrench, Monitor, AlertTriangle } from 'lucide-react'
|
||||
import { useI18n } from '../i18n'
|
||||
|
||||
const PANELS = [
|
||||
@@ -311,16 +311,6 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm
|
||||
const [validating, setValidating] = useState(null)
|
||||
const [keyStatus, setKeyStatus] = useState({})
|
||||
|
||||
useEffect(() => {
|
||||
providers.forEach(p => {
|
||||
if (p.apiKey && !keyStatus[p.name]) {
|
||||
validateKey(p)
|
||||
} else if (!p.apiKey) {
|
||||
setKeyStatus(prev => ({ ...prev, [p.name]: { valid: false, checked: true, error: 'Aucune clé' } }))
|
||||
}
|
||||
})
|
||||
}, [providers])
|
||||
|
||||
const validateKey = async (p) => {
|
||||
setValidating(p.name)
|
||||
try {
|
||||
@@ -332,6 +322,16 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm
|
||||
setValidating(null)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
providers.forEach(p => {
|
||||
if (p.apiKey && !keyStatus[p.name]) {
|
||||
validateKey(p)
|
||||
} else if (!p.apiKey) {
|
||||
setKeyStatus(prev => ({ ...prev, [p.name]: { valid: false, checked: true, error: 'Aucune clé' } }))
|
||||
}
|
||||
})
|
||||
}, [providers])
|
||||
|
||||
const handleValidate = async (name, apiKey, model, baseUrl) => {
|
||||
setValidating(name)
|
||||
try {
|
||||
@@ -412,7 +412,7 @@ function PanelUpdates({ updates, tools, checking, updating, needsUpdateCount, in
|
||||
window.dispatchEvent(new CustomEvent('ask-ai-terminal', { detail: { message: `Installe l'outil ${tool} sur mon système. Vérifie d'abord s'il est déjà installé, puis installe-le si nécessaire avec les commandes appropriées.` } }))
|
||||
}
|
||||
|
||||
const missingTools = tools.filter(t => !t.installed)
|
||||
const missingTools = tools.filter(tool => !tool.installed)
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -43,7 +43,7 @@ export default function Dashboard({ api, refreshRef }) {
|
||||
const [recentCmds, setRecentCmds] = useState([])
|
||||
const [processes, setProcesses] = useState([])
|
||||
const [metrics, setMetrics] = useState(null)
|
||||
const [copiedIdx, setCopiedIdx] = useState(-1)
|
||||
const [copiedSet, setCopiedSet] = useState(new Set())
|
||||
const cpuRef = useRef([])
|
||||
const memRef = useRef([])
|
||||
const netRxRef = useRef([])
|
||||
@@ -109,6 +109,32 @@ export default function Dashboard({ api, refreshRef }) {
|
||||
.map(([cmd, count]) => ({ cmd, count }))
|
||||
})()
|
||||
|
||||
const maxCount = topCmds.length > 0 ? topCmds[0].count : 1
|
||||
|
||||
const copyCmd = (cmd, key) => {
|
||||
navigator.clipboard.writeText(cmd)
|
||||
setCopiedSet(prev => new Set(prev).add(key))
|
||||
setTimeout(() => setCopiedSet(prev => { const next = new Set(prev); next.delete(key); return next }), 1500)
|
||||
}
|
||||
|
||||
const relativeTime = (ts) => {
|
||||
if (!ts) return ''
|
||||
const diff = Math.floor((Date.now() - new Date(ts).getTime()) / 1000)
|
||||
if (diff < 60) return `${diff}s`
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m`
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h`
|
||||
return `${Math.floor(diff / 86400)}d`
|
||||
}
|
||||
|
||||
const recentUnique = (() => {
|
||||
const seen = new Set()
|
||||
return recentCmds.filter(c => {
|
||||
if (seen.has(c.cmd)) return false
|
||||
seen.add(c.cmd)
|
||||
return true
|
||||
})
|
||||
})()
|
||||
|
||||
return (
|
||||
<div className="dash-grid">
|
||||
{/* CPU */}
|
||||
@@ -197,26 +223,34 @@ export default function Dashboard({ api, refreshRef }) {
|
||||
</div>
|
||||
|
||||
{/* Recent Commands */}
|
||||
<div className="dash-card">
|
||||
<div className="dash-card dash-cmd-card">
|
||||
<div className="dash-card-head">
|
||||
<span className="dash-label">Recent Commands</span>
|
||||
<span className="dash-count">{recentUnique.length}</span>
|
||||
</div>
|
||||
{topCmds.length > 0 && (
|
||||
<div className="dash-cmd-top">
|
||||
<div className="dash-cmd-freq">
|
||||
<span className="dash-cmd-freq-title">Most used</span>
|
||||
{topCmds.map((c, i) => (
|
||||
<div key={i} className={'dash-cmd-chip' + (copiedIdx === i ? ' dash-cmd-chip-copied' : '')} onClick={() => { navigator.clipboard.writeText(c.cmd); setCopiedIdx(i); setTimeout(() => setCopiedIdx(-1), 1200); }}>
|
||||
<span className="dash-cmd-chip-name">{copiedIdx === i ? '✓ Copié' : c.cmd}</span>
|
||||
<span className="dash-cmd-chip-count">{c.count}×</span>
|
||||
<div key={i} className="dash-cmd-freq-row" onClick={() => copyCmd(c.cmd, `top-${i}`)} title={c.cmd}>
|
||||
<span className="dash-cmd-freq-name">{copiedSet.has(`top-${i}`) ? '✓ Copié' : c.cmd}</span>
|
||||
<div className="dash-cmd-freq-bar-wrap">
|
||||
<div className="dash-cmd-freq-bar" style={{ width: `${(c.count / maxCount) * 100}%` }} />
|
||||
</div>
|
||||
<span className="dash-cmd-freq-count">{c.count}×</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="dash-cmd-list">
|
||||
{recentCmds.length === 0 && <span className="dash-empty">No history</span>}
|
||||
{recentCmds.map((c, i) => (
|
||||
<div key={i} className="dash-cmd-row" title={c.cmd}>
|
||||
<span className="dash-cmd-shell">{c.shell}</span>
|
||||
<span className="dash-cmd-text">{c.cmd.length > 45 ? c.cmd.slice(0, 42) + '...' : c.cmd}</span>
|
||||
{recentUnique.length === 0 && <span className="dash-empty">No history</span>}
|
||||
{recentUnique.map((c, i) => (
|
||||
<div key={i} className="dash-cmd-row" onClick={() => copyCmd(c.cmd, `list-${i}`)} title={c.cmd + ' · click to copy'}>
|
||||
<div className="dash-cmd-left">
|
||||
<span className="dash-cmd-text">{c.cmd.length > 38 ? c.cmd.slice(0, 35) + '...' : c.cmd}</span>
|
||||
<span className="dash-cmd-time">{relativeTime(c.ts)}</span>
|
||||
</div>
|
||||
<span className="dash-cmd-copy">{copiedSet.has(`list-${i}`) ? '✓' : '⎘'}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -149,7 +149,7 @@ function createTerminal(container, settings = {}) {
|
||||
return { term, fitAddon }
|
||||
}
|
||||
|
||||
function connectWebSocket(term, fitAddon, initPayload, onStateChange) {
|
||||
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`)
|
||||
|
||||
@@ -162,7 +162,12 @@ function connectWebSocket(term, fitAddon, initPayload, onStateChange) {
|
||||
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') {
|
||||
@@ -185,12 +190,6 @@ function connectWebSocket(term, fitAddon, initPayload, onStateChange) {
|
||||
if (onStateChange) onStateChange(false)
|
||||
})
|
||||
|
||||
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 }))
|
||||
@@ -205,6 +204,7 @@ 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 pendingCommandsRef = useRef({})
|
||||
|
||||
const savedTabs = (() => {
|
||||
try {
|
||||
@@ -228,6 +228,8 @@ export default function Shell({ api }) {
|
||||
}
|
||||
return 1
|
||||
})
|
||||
const activeTabRef = useRef(activeTab)
|
||||
useEffect(() => { activeTabRef.current = activeTab }, [activeTab])
|
||||
const [sshConnections, setSshConnections] = useState([])
|
||||
const [systemTerminals, setSystemTerminals] = useState([])
|
||||
const [showMenu, setShowMenu] = useState(false)
|
||||
@@ -256,6 +258,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)
|
||||
@@ -338,23 +341,7 @@ export default function Shell({ api }) {
|
||||
}
|
||||
}
|
||||
|
||||
const onWsState = (connected) => {
|
||||
if (!connected) saveBuffer()
|
||||
setTabs(prev => prev.map(t => t.id === tabId ? { ...t, connected } : t))
|
||||
}
|
||||
const ws = connectWebSocket(term, fitAddon, initPayload, onWsState)
|
||||
|
||||
// Restore saved terminal buffer after first output settles
|
||||
const restoreBuffer = () => {
|
||||
try {
|
||||
const savedBuffers = JSON.parse(localStorage.getItem(TERMINAL_BUFFER_KEY) || '{}')
|
||||
if (savedBuffers[tabId]) {
|
||||
term.write('\x1b[90m— session restaurée —\x1b[0m\r\n')
|
||||
term.write(savedBuffers[tabId])
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
setTimeout(restoreBuffer, 300)
|
||||
let disposed = false
|
||||
|
||||
const saveBuffer = () => {
|
||||
try {
|
||||
@@ -364,39 +351,56 @@ 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))
|
||||
}
|
||||
|
||||
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.viewportY + buf.cursorY
|
||||
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(localStorage.getItem(TERMINAL_BUFFER_KEY) || '{}')
|
||||
const savedBuffers = JSON.parse(sessionStorage.getItem(TERMINAL_BUFFER_KEY) || '{}')
|
||||
delete savedBuffers[tabId]
|
||||
localStorage.setItem(TERMINAL_BUFFER_KEY, JSON.stringify(savedBuffers))
|
||||
sessionStorage.setItem(TERMINAL_BUFFER_KEY, JSON.stringify(savedBuffers))
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
} catch (e) { console.warn('[Shell] Clear detection failed:', e) }
|
||||
}
|
||||
|
||||
term.onData((data) => {
|
||||
if (data === '\r') {
|
||||
clearBufferOnClear()
|
||||
if (data === '\r') clearBufferOnClear()
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'input', data }))
|
||||
}
|
||||
})
|
||||
|
||||
const onResize = () => {
|
||||
const el = document.getElementById(`terminal-${tabId}`)
|
||||
if (el && el.offsetParent !== null) {
|
||||
if (el && el.style.display !== 'none') {
|
||||
fitAddon.fit()
|
||||
}
|
||||
}
|
||||
@@ -405,36 +409,66 @@ 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)
|
||||
if (!container) {
|
||||
pending.push(setTimeout(() => tryInit(attempt + 1), 100))
|
||||
return
|
||||
}
|
||||
if (activeTabRef.current !== tab.id) return
|
||||
if (container.offsetHeight === 0 || container.style.display === 'none') {
|
||||
pending.push(setTimeout(() => tryInit(attempt + 1), 100))
|
||||
return
|
||||
}
|
||||
if (!tabsRef.current[tab.id]) {
|
||||
initTerminal(tab.id, tab)
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
if (cancelled) return
|
||||
if (activeTabRef.current !== tab.id) return
|
||||
const entry = tabsRef.current[tab.id]
|
||||
if (entry) entry.fitAddon.fit()
|
||||
})
|
||||
}
|
||||
|
||||
tryInit(0)
|
||||
}, [activeTab, tabs, initTerminal])
|
||||
return () => {
|
||||
cancelled = true
|
||||
pending.forEach(clearTimeout)
|
||||
}
|
||||
}, [activeTab, initTerminal])
|
||||
|
||||
useEffect(() => {
|
||||
const iv = setInterval(() => {
|
||||
@@ -442,7 +476,7 @@ export default function Shell({ api }) {
|
||||
const entry = tabsRef.current[tab.id]
|
||||
if (entry) {
|
||||
const el = document.getElementById(`terminal-${tab.id}`)
|
||||
if (el && el.offsetParent !== null) {
|
||||
if (el && el.style.display !== 'none') {
|
||||
entry.fitAddon.fit()
|
||||
}
|
||||
}
|
||||
@@ -451,6 +485,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
|
||||
@@ -506,29 +554,26 @@ export default function Shell({ api }) {
|
||||
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 tab = prev.find(t => t.id === tabId)
|
||||
if (!tab) return prev
|
||||
|
||||
const entry = tabsRef.current[tabId]
|
||||
if (entry) {
|
||||
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]
|
||||
}
|
||||
|
||||
// Clean buffer from localStorage
|
||||
try {
|
||||
const savedBuffers = JSON.parse(localStorage.getItem(TERMINAL_BUFFER_KEY) || '{}')
|
||||
delete savedBuffers[tabId]
|
||||
localStorage.setItem(TERMINAL_BUFFER_KEY, JSON.stringify(savedBuffers))
|
||||
} catch {}
|
||||
|
||||
const next = prev.filter(t => t.id !== tabId)
|
||||
if (activeTab === tabId && next.length > 0) {
|
||||
setActiveTab(next[next.length - 1].id)
|
||||
@@ -574,27 +619,33 @@ export default function Shell({ api }) {
|
||||
}
|
||||
|
||||
const sendToTerminal = useCallback((code, tabId) => {
|
||||
const targetId = tabId || activeTab
|
||||
const targetId = tabId || activeTabRef.current
|
||||
const entry = tabsRef.current[targetId]
|
||||
if (!entry) {
|
||||
console.warn('sendToTerminal: no terminal initialized for tab', targetId)
|
||||
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('sendToTerminal: WebSocket not ready for tab', targetId)
|
||||
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' }))
|
||||
}, [activeTab])
|
||||
}, [])
|
||||
|
||||
const focusAiTerminal = useCallback(() => {
|
||||
const entry = tabsRef.current[activeTab]
|
||||
const entry = tabsRef.current[activeTabRef.current]
|
||||
if (entry) entry.term.focus()
|
||||
}, [activeTab])
|
||||
}, [])
|
||||
|
||||
const _sendAiMessage = useCallback(async (text, fromEvent = false) => {
|
||||
if (!text || !text.trim() || aiLoading || aiAtLimit) return
|
||||
if (!text || !text.trim() || aiLoadingRef.current || aiAtLimit) return
|
||||
const trimmed = text.trim()
|
||||
aiLoadingRef.current = true
|
||||
|
||||
if (!fromEvent) {
|
||||
setAiInput('')
|
||||
@@ -608,6 +659,7 @@ export default function Shell({ api }) {
|
||||
setAiTokens(0)
|
||||
setAiAtLimit(false)
|
||||
} catch {}
|
||||
aiLoadingRef.current = false
|
||||
return
|
||||
}
|
||||
|
||||
@@ -616,10 +668,13 @@ export default function Shell({ api }) {
|
||||
{ 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: trimmed }])
|
||||
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 {
|
||||
@@ -628,26 +683,27 @@ export default function Shell({ api }) {
|
||||
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 }]
|
||||
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, focusAiTerminal])
|
||||
aiLoadingRef.current = false
|
||||
}, [api, t, aiAtLimit, focusAiTerminal])
|
||||
|
||||
const handleAiSend = () => _sendAiMessage(aiInput, false)
|
||||
|
||||
@@ -830,7 +886,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} terminalTabId={activeTab} />
|
||||
<ShellAIMessage key={i} msg={msg} sendToTerminal={sendToTerminal} terminalTabId={msg._tabId || activeTab} />
|
||||
))}
|
||||
{aiLoading && <div style={{ textAlign: 'center', padding: 8 }}><span className="spinner" /></div>}
|
||||
</div>
|
||||
|
||||
@@ -197,7 +197,7 @@ function FeedItem({ msg }) {
|
||||
)
|
||||
}
|
||||
|
||||
const cleanContent = displayContent.replace(/<think[^>]*>[\s\S]*?<\/think>/gi, '')
|
||||
let cleanContent = displayContent.replace(/<think[^>]*>[\s\S]*?<\/think>/gi, '')
|
||||
|
||||
return (
|
||||
<div className={`feed-item ${msg.role}`}>
|
||||
@@ -532,6 +532,8 @@ export default function Studio({ api }) {
|
||||
if (event && event.tool_call) {
|
||||
toolCalls = [...toolCalls, { call: event.tool_call, result: null }]
|
||||
setStreamToolCalls([...toolCalls])
|
||||
accumulated = ''
|
||||
setStreaming('')
|
||||
return
|
||||
}
|
||||
if (event && event.tool_result) {
|
||||
@@ -558,6 +560,11 @@ export default function Studio({ api }) {
|
||||
aiMsg.content = JSON.stringify({
|
||||
content: finalContent,
|
||||
tool_calls: toolCalls.map(tc => tc.call),
|
||||
tool_results: toolCalls.map(tc => ({
|
||||
tool_call_id: tc.call?.tool_call_id,
|
||||
result: tc.result?.content || '',
|
||||
is_error: tc.result?.is_error || false,
|
||||
})),
|
||||
})
|
||||
}
|
||||
setMessages(prev => [...prev, aiMsg])
|
||||
|
||||
@@ -382,12 +382,12 @@ input::placeholder { color: var(--text-disabled); }
|
||||
}
|
||||
.shell-menu-divider { height: 1px; background: var(--border); margin: 4px 6px; }
|
||||
|
||||
.shell-xterm-wrapper { flex: 1; background: var(--bg); overflow: hidden; position: relative; }
|
||||
.shell-xterm-wrapper { flex: 1; height: 100%; background: var(--bg); overflow: hidden; position: relative; }
|
||||
.shell-xterm-instance {
|
||||
position: absolute; inset: 0; padding: 4px;
|
||||
display: block !important;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
.shell-xterm-instance .xterm { height: 100%; padding: 4px; }
|
||||
.shell-xterm-instance .xterm { height: 100%; }
|
||||
|
||||
.connection-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; flex-shrink: 0; }
|
||||
.connection-dot.on { background: var(--success); box-shadow: 0 0 6px var(--success); }
|
||||
@@ -691,34 +691,38 @@ input::placeholder { color: var(--text-disabled); }
|
||||
}
|
||||
|
||||
/* Commands */
|
||||
.dash-cmd-list { display: flex; flex-direction: column; gap: 3px; max-height: 270px; overflow-y: auto; }
|
||||
.dash-cmd-card .dash-cmd-list { max-height: 220px; }
|
||||
.dash-cmd-list { display: flex; flex-direction: column; gap: 2px; overflow-y: auto; }
|
||||
.dash-cmd-row {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
padding: 3px 0; overflow: hidden;
|
||||
}
|
||||
.dash-cmd-shell {
|
||||
font-size: 9px; font-family: var(--font-mono); color: var(--text-disabled);
|
||||
background: var(--bg-input); padding: 1px 4px; border-radius: 3px;
|
||||
text-transform: uppercase; flex-shrink: 0;
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 8px;
|
||||
padding: 5px 8px; border-radius: var(--radius-sm);
|
||||
background: var(--bg-surface); cursor: pointer;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
.dash-cmd-row:hover { background: var(--accent-bg); }
|
||||
.dash-cmd-left { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; }
|
||||
.dash-cmd-text {
|
||||
font-size: 11px; font-family: var(--font-mono); color: var(--text-secondary);
|
||||
font-size: 11px; font-family: var(--font-mono); color: var(--text-primary);
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
flex: 1; min-width: 0;
|
||||
}
|
||||
.dash-cmd-time { font-size: 9px; color: var(--text-disabled); }
|
||||
.dash-cmd-copy { font-size: 13px; color: var(--text-disabled); flex-shrink: 0; }
|
||||
.dash-cmd-row:hover .dash-cmd-copy { color: var(--accent); }
|
||||
|
||||
.dash-cmd-freq { display: flex; flex-direction: column; gap: 6px; margin-bottom: 10px; padding-bottom: 10px; border-bottom: 1px solid var(--border); }
|
||||
.dash-cmd-freq-title { font-size: 10px; font-weight: 700; text-transform: uppercase; color: var(--text-disabled); letter-spacing: 0.05em; margin-bottom: 2px; }
|
||||
.dash-cmd-freq-row {
|
||||
display: flex; align-items: center; gap: 8px; cursor: pointer;
|
||||
padding: 3px 4px; border-radius: var(--radius-sm);
|
||||
transition: background 0.12s;
|
||||
}
|
||||
.dash-cmd-freq-row:hover { background: var(--accent-bg); }
|
||||
.dash-cmd-freq-name { font-size: 12px; font-weight: 600; font-family: var(--font-mono); color: var(--text-primary); width: 100px; flex-shrink: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dash-cmd-freq-bar-wrap { flex: 1; height: 6px; background: var(--bg-input); border-radius: 3px; overflow: hidden; }
|
||||
.dash-cmd-freq-bar { height: 100%; background: var(--accent); border-radius: 3px; transition: width 0.3s ease; }
|
||||
.dash-cmd-freq-count { font-size: 10px; font-family: var(--font-mono); color: var(--accent); width: 28px; text-align: right; flex-shrink: 0; }
|
||||
|
||||
.dash-cmd-top { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }
|
||||
.dash-cmd-chip {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
padding: 6px 12px; border-radius: var(--radius);
|
||||
background: var(--bg-surface); border: 1px solid var(--border);
|
||||
cursor: pointer; transition: all 0.15s;
|
||||
}
|
||||
.dash-cmd-chip:hover { border-color: var(--accent-dim); background: var(--accent-bg); }
|
||||
.dash-cmd-chip-copied { border-color: var(--accent) !important; background: var(--accent-bg) !important; }
|
||||
.dash-cmd-chip-copied .dash-cmd-chip-name { color: var(--accent); }
|
||||
.dash-cmd-chip-name { font-size: 13px; font-weight: 700; font-family: var(--font-mono); color: var(--text-primary); }
|
||||
.dash-cmd-chip-count { font-size: 10px; font-family: var(--font-mono); color: var(--accent); }
|
||||
|
||||
/* Services */
|
||||
.dash-services { display: flex; flex-direction: column; gap: 6px; }
|
||||
|
||||
Reference in New Issue
Block a user