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, Eye } from 'lucide-react' import '@xterm/xterm/css/xterm.css' import { useI18n } from '../i18n' 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*( { ws.send(JSON.stringify(initPayload)) const dims = fitAddon.proposeDimensions() 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') { 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: 'default' }) const pendingCommandsRef = useRef({}) const savedTabs = (() => { try { const raw = localStorage.getItem(TABS_STORAGE_KEY) if (raw) { const parsed = JSON.parse(raw) if (Array.isArray(parsed) && parsed.length > 0) { return parsed.map(t => ({ ...t, connected: false })) } } } catch {} return null })() const [tabs, setTabs] = useState(savedTabs || [ { id: 1, name: 'Local Shell', type: 'local', shell: '', connected: false }, ]) const [activeTab, setActiveTab] = useState(() => { if (savedTabs) { return savedTabs[0]?.id || 1 } 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: 'default', }) 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 || 'default', }) } }).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 } = 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, 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 = [] const tryInitTab = (tab, attempt) => { if (cancelled) return const shellCol = document.querySelector('.shell-terminal-col') if (!shellCol || shellCol.offsetParent === null) { pending.push(setTimeout(() => tryInitTab(tab, attempt + 1), 200)) return } const container = document.getElementById(`terminal-${tab.id}`) if (!container) { pending.push(setTimeout(() => tryInitTab(tab, attempt + 1), 100)) return } if (container.offsetHeight === 0) { pending.push(setTimeout(() => tryInitTab(tab, 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() setTimeout(() => { if (!cancelled) entry.fitAddon.fit() }, 100) } }) if (!tabsRef.current[tab.id]) { tryInitTab(tab, 0) } } 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) => { 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]) 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 }) } 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('') focusAiTerminal() } 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 => (
))}
)}
)}
{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 => e.key === 'Enter' && handleAiSend()} placeholder={aiAtLimit ? '/clear pour continuer' : t('shell.askAi')} disabled={aiAtLimit && aiInput !== '/clear'} />
{showAnalysis && analysisContent && (
setShowAnalysis(false)}>
e.stopPropagation()}>
Analyse Système
{renderContent(analysisContent).map((part, i) => part.type === 'code' ? (
{part.lang &&
{part.lang}
}
{part.content}
) : ( ) )}
)} {showSshModal && (
setShowSshModal(false)}>
e.stopPropagation()}>
{t('shell.addConnection')}
setSshForm(f => ({ ...f, name: e.target.value }))} placeholder="prod-server" /> setSshForm(f => ({ ...f, host: e.target.value }))} placeholder="192.168.1.100" />
setSshForm(f => ({ ...f, port: parseInt(e.target.value) || 22 }))} />
setSshForm(f => ({ ...f, user: e.target.value }))} placeholder="root" />
setSshForm(f => ({ ...f, key_path: e.target.value }))} placeholder="~/.ssh/id_rsa" />
)}
) } function ShellAIMessage({ msg, sendToTerminal, terminalTabId }) { const role = msg.role === 'user' ? 'user' : msg.role === 'system' ? 'system' : 'assistant' const content = msg.content || '' if (role === 'user') { return
{content}
} if (role === 'system') { return
{content}
} const parts = renderContent(content) return (
{parts.map((part, i) => { if (part.type === 'code') { return (
{part.lang &&
{part.lang}
}
{part.content}
) } return })}
) }