feat: RAG, memory, plugins, lessons, file editor, split panes, Markdown rendering, PWA + UI overhaul
All checks were successful
Stable Release / stable (push) Successful in 1m34s
All checks were successful
Stable Release / stable (push) Successful in 1m34s
Major additions: - RAG pipeline (indexing, chunking, search) with sidebar upload button - Memory system with CRUD API - Plugins and lessons modules - MCP discovery and MCP server - Advanced skills (auto-create, conditional, improver) - Agent browser/image support, delegate, sessions - File editor with CodeMirror in split panes - Markdown rendering via react-markdown + KaTeX + highlight.js - Raw markdown toggle - PWA manifest + service worker - Extension UI redesign with new design tokens and studio-style chat - Pipeline API for chat streaming - Mobile responsive layout 💘 Generated with Crush Assisted-by: GLM-5.1 via Crush <crush@charm.land>
This commit is contained in:
@@ -162,6 +162,33 @@ const api = {
|
||||
}).catch(reject)
|
||||
})
|
||||
},
|
||||
ragIndex: (text, name, type) => request('/rag/index', { method: 'POST', body: JSON.stringify({ text, name, type: type || 'text' }) }),
|
||||
ragIndexFile: (file) => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
return fetch(`${API_BASE}/rag/index`, { method: 'POST', body: formData }).then(r => {
|
||||
if (!r.ok) return r.json().then(e => { throw new Error(e.error || r.statusText) })
|
||||
return r.json()
|
||||
})
|
||||
},
|
||||
ragSearch: (query, limit) => request('/rag/search', { method: 'POST', body: JSON.stringify({ query, limit: limit || 5 }) }),
|
||||
ragStatus: () => request('/rag/status'),
|
||||
ragDocuments: () => request('/rag/documents'),
|
||||
ragDelete: (id) => fetch(`${API_BASE}/rag/index/${id}`, { method: 'DELETE', headers: { 'Content-Type': 'application/json' } }).then(r => r.json()),
|
||||
pipelineFilters: () => request('/pipeline/filters'),
|
||||
pipelineToggle: (name, enabled) => request(`/pipeline/filters/${name}`, { method: 'POST', body: JSON.stringify({ enabled }) }),
|
||||
generateImage: (prompt, size, style) => request('/images/generate', { method: 'POST', body: JSON.stringify({ prompt, size: size || '1024x1024', style: style || 'vivid' }) }),
|
||||
fileRead: (path) => request(`/files/content?path=${encodeURIComponent(path)}`),
|
||||
fileWrite: (path, content) => request('/files/content', { method: 'PUT', body: JSON.stringify({ path, content }) }),
|
||||
mcpServerStatus: () => request('/mcp-server/status'),
|
||||
mcpServerStart: () => request('/mcp-server/start', { method: 'POST' }),
|
||||
mcpServerStop: () => request('/mcp-server/stop', { method: 'POST' }),
|
||||
getAgentSessions: () => request('/agent-sessions'),
|
||||
getAgentSessionOutput: (id) => request(`/agent-sessions/${encodeURIComponent(id)}`),
|
||||
getWorkspaces: () => request('/workspaces'),
|
||||
saveWorkspace: (name, layout, tabs) => request('/workspace', { method: 'POST', body: JSON.stringify({ name, layout, tabs }) }),
|
||||
getWorkspace: (name) => request(`/workspace/${encodeURIComponent(name)}`),
|
||||
deleteWorkspace: (name) => request(`/workspace/${encodeURIComponent(name)}`, { method: 'DELETE' }),
|
||||
}
|
||||
|
||||
export default api
|
||||
|
||||
262
web/src/components/FileEditor.jsx
Normal file
262
web/src/components/FileEditor.jsx
Normal file
@@ -0,0 +1,262 @@
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
|
||||
import { EditorView, keymap, lineNumbers, highlightActiveLineGutter, highlightActiveLine, drawSelection, rectangularSelection, highlightSpecialChars } from '@codemirror/view'
|
||||
import { EditorState, Compartment } from '@codemirror/state'
|
||||
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands'
|
||||
import { syntaxHighlighting, defaultHighlightStyle, bracketMatching, foldGutter, indentOnInput } from '@codemirror/language'
|
||||
import { javascript } from '@codemirror/lang-javascript'
|
||||
import { python } from '@codemirror/lang-python'
|
||||
import { go } from '@codemirror/lang-go'
|
||||
import { json } from '@codemirror/lang-json'
|
||||
import { yaml } from '@codemirror/lang-yaml'
|
||||
import { markdown } from '@codemirror/lang-markdown'
|
||||
import { oneDark } from '@codemirror/theme-one-dark'
|
||||
import { searchKeymap, highlightSelectionMatches } from '@codemirror/search'
|
||||
import { closeBrackets, closeBracketsKeymap, autocompletion, completionKeymap } from '@codemirror/autocomplete'
|
||||
import { X, Save, RotateCcw } from 'lucide-react'
|
||||
|
||||
const langExtensions = {
|
||||
javascript: () => javascript({ jsx: true }),
|
||||
typescript: () => javascript({ jsx: true, typescript: true }),
|
||||
python: () => python(),
|
||||
go: () => go(),
|
||||
json: () => json(),
|
||||
yaml: () => yaml(),
|
||||
markdown: () => markdown(),
|
||||
}
|
||||
|
||||
function getLangExtension(lang) {
|
||||
const factory = langExtensions[lang]
|
||||
if (factory) return factory()
|
||||
return []
|
||||
}
|
||||
|
||||
function createEditorTheme() {
|
||||
return EditorView.theme({
|
||||
'&': {
|
||||
fontSize: '13px',
|
||||
backgroundColor: 'var(--bg-base, #0F0D10)',
|
||||
color: 'var(--text-primary, #EAE0E2)',
|
||||
height: '100%',
|
||||
},
|
||||
'.cm-content': {
|
||||
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
|
||||
caretColor: 'var(--accent, #FF0033)',
|
||||
padding: '4px 0',
|
||||
},
|
||||
'.cm-cursor': {
|
||||
borderLeftColor: 'var(--accent, #FF0033)',
|
||||
borderLeftWidth: '2px',
|
||||
},
|
||||
'&.cm-focused .cm-selectionBackground, .cm-selectionBackground': {
|
||||
backgroundColor: 'var(--accent-dim, #6B2033) !important',
|
||||
},
|
||||
'.cm-gutters': {
|
||||
backgroundColor: 'var(--bg-surface, #161218)',
|
||||
color: 'var(--text-tertiary, #8A7A7E)',
|
||||
border: 'none',
|
||||
borderRight: '1px solid var(--border, #2A1F22)',
|
||||
},
|
||||
'.cm-activeLineGutter': {
|
||||
backgroundColor: 'var(--bg-elevated, #1C1719)',
|
||||
color: 'var(--text-secondary, #D4C4C8)',
|
||||
},
|
||||
'.cm-activeLine': {
|
||||
backgroundColor: 'rgba(255, 0, 51, 0.05)',
|
||||
},
|
||||
'.cm-matchingBracket': {
|
||||
backgroundColor: 'var(--accent-dim, #6B2033)',
|
||||
outline: '1px solid var(--accent, #FF0033)',
|
||||
color: '#fff !important',
|
||||
},
|
||||
'.cm-selectionMatch': {
|
||||
backgroundColor: 'var(--accent-dim, #6B2033)',
|
||||
},
|
||||
'.cm-foldGutter': {
|
||||
color: 'var(--text-tertiary, #8A7A7E)',
|
||||
},
|
||||
'.cm-scroller': {
|
||||
overflow: 'auto',
|
||||
},
|
||||
}, { dark: true })
|
||||
}
|
||||
|
||||
export default function FileEditor({ api, filePath, onClose }) {
|
||||
const editorRef = useRef(null)
|
||||
const viewRef = useRef(null)
|
||||
const langCompartment = useRef(new Compartment())
|
||||
const [content, setContent] = useState('')
|
||||
const [originalContent, setOriginalContent] = useState('')
|
||||
const [lang, setLang] = useState('text')
|
||||
const [dirty, setDirty] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
const [fileName, setFileName] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!filePath) return
|
||||
const name = filePath.split('/').pop()
|
||||
setFileName(name)
|
||||
|
||||
api.fileRead(filePath).then(data => {
|
||||
setContent(data.content || '')
|
||||
setOriginalContent(data.content || '')
|
||||
setLang(data.lang || 'text')
|
||||
setLoading(false)
|
||||
}).catch(err => {
|
||||
setError(err.message || 'Failed to read file')
|
||||
setLoading(false)
|
||||
})
|
||||
}, [filePath])
|
||||
|
||||
useEffect(() => {
|
||||
if (!editorRef.current || loading || viewRef.current) return
|
||||
|
||||
const customTheme = createEditorTheme()
|
||||
|
||||
const state = EditorState.create({
|
||||
doc: content,
|
||||
extensions: [
|
||||
customTheme,
|
||||
oneDark,
|
||||
lineNumbers(),
|
||||
highlightActiveLineGutter(),
|
||||
highlightSpecialChars(),
|
||||
history(),
|
||||
foldGutter(),
|
||||
drawSelection(),
|
||||
indentOnInput(),
|
||||
bracketMatching(),
|
||||
closeBrackets(),
|
||||
autocompletion(),
|
||||
rectangularSelection(),
|
||||
highlightActiveLine(),
|
||||
highlightSelectionMatches(),
|
||||
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
|
||||
keymap.of([
|
||||
...closeBracketsKeymap,
|
||||
...defaultKeymap,
|
||||
...searchKeymap,
|
||||
...historyKeymap,
|
||||
...completionKeymap,
|
||||
indentWithTab,
|
||||
{
|
||||
key: 'Mod-s',
|
||||
run: () => { handleSave() ; return true },
|
||||
},
|
||||
{
|
||||
key: 'Escape',
|
||||
run: () => { if (onClose) onClose() ; return true },
|
||||
},
|
||||
]),
|
||||
langCompartment.current.of(getLangExtension(lang)),
|
||||
EditorView.updateListener.of(update => {
|
||||
if (update.docChanged) {
|
||||
const newContent = update.state.doc.toString()
|
||||
setDirty(newContent !== originalContent)
|
||||
}
|
||||
}),
|
||||
EditorView.lineWrapping,
|
||||
],
|
||||
})
|
||||
|
||||
const view = new EditorView({
|
||||
state,
|
||||
parent: editorRef.current,
|
||||
})
|
||||
|
||||
viewRef.current = view
|
||||
|
||||
return () => {
|
||||
view.destroy()
|
||||
viewRef.current = null
|
||||
}
|
||||
}, [loading])
|
||||
|
||||
useEffect(() => {
|
||||
if (!viewRef.current || !lang) return
|
||||
try {
|
||||
viewRef.current.dispatch({
|
||||
effects: langCompartment.current.reconfigure(getLangExtension(lang)),
|
||||
})
|
||||
} catch {}
|
||||
}, [lang])
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!viewRef.current || !filePath || saving) return
|
||||
const newContent = viewRef.current.state.doc.toString()
|
||||
setSaving(true)
|
||||
try {
|
||||
await api.fileWrite(filePath, newContent)
|
||||
setOriginalContent(newContent)
|
||||
setDirty(false)
|
||||
setContent(newContent)
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
}
|
||||
setSaving(false)
|
||||
}, [filePath, saving, api])
|
||||
|
||||
const handleReload = useCallback(() => {
|
||||
if (!viewRef.current) return
|
||||
api.fileRead(filePath).then(data => {
|
||||
const doc = data.content || ''
|
||||
viewRef.current.dispatch({
|
||||
changes: { from: 0, to: viewRef.current.state.doc.length, insert: doc },
|
||||
})
|
||||
setOriginalContent(doc)
|
||||
setDirty(false)
|
||||
setContent(doc)
|
||||
setLang(data.lang || 'text')
|
||||
}).catch(err => setError(err.message))
|
||||
}, [filePath, api])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="file-editor-panel">
|
||||
<div className="file-editor-header">
|
||||
<span className="file-editor-title">Loading...</span>
|
||||
<button className="ghost sm" onClick={onClose}><X size={14} /></button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error && !content) {
|
||||
return (
|
||||
<div className="file-editor-panel">
|
||||
<div className="file-editor-header">
|
||||
<span className="file-editor-title" style={{ color: 'var(--error)' }}>{error}</span>
|
||||
<button className="ghost sm" onClick={onClose}><X size={14} /></button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="file-editor-panel">
|
||||
<div className="file-editor-header">
|
||||
<span className="file-editor-title">
|
||||
{fileName}
|
||||
{dirty && <span className="file-editor-dirty">●</span>}
|
||||
</span>
|
||||
<div className="file-editor-actions">
|
||||
<span className="file-editor-lang-badge">{lang}</span>
|
||||
<button className="ghost sm" onClick={handleReload} title="Reload">
|
||||
<RotateCcw size={13} />
|
||||
</button>
|
||||
<button
|
||||
className="sm primary"
|
||||
onClick={handleSave}
|
||||
disabled={!dirty || saving}
|
||||
>
|
||||
<Save size={13} />
|
||||
{saving ? '...' : 'Save'}
|
||||
</button>
|
||||
<button className="ghost sm" onClick={onClose}><X size={14} /></button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="file-editor-body" ref={editorRef} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -6,18 +6,21 @@ import { WebglAddon } from '@xterm/addon-webgl'
|
||||
import { SearchAddon } from '@xterm/addon-search'
|
||||
import { Unicode11Addon } from '@xterm/addon-unicode11'
|
||||
import { ImageAddon } from '@xterm/addon-image'
|
||||
import { Plus, X, Monitor, Globe, ChevronDown, Pencil, Trash2, Search, Copy, Send, Eye, Bot } from 'lucide-react'
|
||||
import { Plus, X, Monitor, Globe, ChevronDown, Pencil, Trash2, Search, Copy, Send, Eye, Bot, Columns, Rows, Maximize2 } from 'lucide-react'
|
||||
import '@xterm/xterm/css/xterm.css'
|
||||
import { useI18n } from '../i18n'
|
||||
import mermaid from 'mermaid'
|
||||
import FileEditor from './FileEditor'
|
||||
|
||||
mermaid.initialize({ startOnLoad: false, theme: 'dark', securityLevel: 'loose', fontFamily: 'var(--font-mono)' })
|
||||
|
||||
const AI_TAB_ID = 0
|
||||
const MAX_TABS = 7
|
||||
const MAX_PANES = 4
|
||||
const SHELL_MAX_TOKENS = 100000
|
||||
const SHELL_AI_COMMANDS = ['/clear', '/help', '/model', '/model change']
|
||||
const TABS_STORAGE_KEY = 'muyue_shell_tabs'
|
||||
const LAYOUT_STORAGE_KEY = 'muyue_shell_layout'
|
||||
const TERMINAL_BUFFER_KEY = 'muyue_terminal_buffers'
|
||||
|
||||
function renderContent(text) {
|
||||
@@ -477,6 +480,107 @@ export default function Shell({ api, isSudo }) {
|
||||
const _streamRafRef = useRef(null)
|
||||
const _streamPendingRef = useRef(null)
|
||||
|
||||
const [splitLayout, setSplitLayout] = useState(() => {
|
||||
try {
|
||||
const raw = localStorage.getItem(LAYOUT_STORAGE_KEY)
|
||||
if (raw) return JSON.parse(raw)
|
||||
} catch {}
|
||||
return null
|
||||
})
|
||||
const [editingFile, setEditingFile] = useState(null)
|
||||
const [agentSessions, setAgentSessions] = useState([])
|
||||
|
||||
const paneCount = useMemo(() => {
|
||||
const count = (node) => {
|
||||
if (!node) return 1
|
||||
if (node.type === 'leaf') return 1
|
||||
return count(node.children?.[0]) + count(node.children?.[1])
|
||||
}
|
||||
return count(splitLayout)
|
||||
}, [splitLayout])
|
||||
|
||||
const splitPane = useCallback((direction) => {
|
||||
if (paneCount >= MAX_PANES) return
|
||||
const activeId = activeTabRef.current
|
||||
setSplitLayout(prev => {
|
||||
if (!prev) {
|
||||
return { type: 'split', direction, ratio: 0.5, activePane: activeId, children: [
|
||||
{ type: 'leaf', tabId: activeId },
|
||||
{ type: 'leaf', tabId: null },
|
||||
]}
|
||||
}
|
||||
const clone = JSON.parse(JSON.stringify(prev))
|
||||
const findAndSplit = (node) => {
|
||||
if (node.type === 'leaf' && node.tabId === activeId) {
|
||||
return { type: 'split', direction, ratio: 0.5, activePane: activeId, children: [
|
||||
{ type: 'leaf', tabId: activeId },
|
||||
{ type: 'leaf', tabId: null },
|
||||
]}
|
||||
}
|
||||
if (node.children) {
|
||||
return { ...node, children: node.children.map(findAndSplit) }
|
||||
}
|
||||
return node
|
||||
}
|
||||
return findAndSplit(clone)
|
||||
})
|
||||
}, [paneCount])
|
||||
|
||||
const removePane = useCallback((tabId) => {
|
||||
setSplitLayout(prev => {
|
||||
if (!prev) return null
|
||||
if (prev.type === 'leaf') return null
|
||||
const clone = JSON.parse(JSON.stringify(prev))
|
||||
const removeFromTree = (node) => {
|
||||
if (node.type !== 'split') return node
|
||||
const left = node.children[0]
|
||||
const right = node.children[1]
|
||||
const leftIsTarget = left.type === 'leaf' && left.tabId === tabId
|
||||
const rightIsTarget = right.type === 'leaf' && right.tabId === tabId
|
||||
if (leftIsTarget) return removeFromTree(right)
|
||||
if (rightIsTarget) return removeFromTree(left)
|
||||
return { ...node, children: [removeFromTree(left), removeFromTree(right)] }
|
||||
}
|
||||
const result = removeFromTree(clone)
|
||||
if (result.type === 'leaf' && result.tabId === null) return null
|
||||
if (result.type === 'split' && (!result.children || result.children.length < 2)) return result.children?.[0] || null
|
||||
return result
|
||||
})
|
||||
}, [])
|
||||
|
||||
const assignPaneTab = useCallback((paneLeaf, tabId) => {
|
||||
setSplitLayout(prev => {
|
||||
if (!prev) return prev
|
||||
const clone = JSON.parse(JSON.stringify(prev))
|
||||
const assign = (node) => {
|
||||
if (node === paneLeaf) return { ...node, tabId }
|
||||
if (node.children) return { ...node, children: node.children.map(assign) }
|
||||
return node
|
||||
}
|
||||
return assign(clone)
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (splitLayout) {
|
||||
localStorage.setItem(LAYOUT_STORAGE_KEY, JSON.stringify(splitLayout))
|
||||
} else {
|
||||
localStorage.removeItem(LAYOUT_STORAGE_KEY)
|
||||
}
|
||||
}, [splitLayout])
|
||||
|
||||
useEffect(() => {
|
||||
api.getAgentSessions?.().then(d => {
|
||||
setAgentSessions(d?.sessions || [])
|
||||
}).catch(() => {})
|
||||
const iv = setInterval(() => {
|
||||
api.getAgentSessions?.().then(d => {
|
||||
setAgentSessions(d?.sessions || [])
|
||||
}).catch(() => {})
|
||||
}, 5000)
|
||||
return () => clearInterval(iv)
|
||||
}, [])
|
||||
|
||||
const _flushStreamUpdate = useCallback(() => {
|
||||
_streamRafRef.current = null
|
||||
const pending = _streamPendingRef.current
|
||||
@@ -818,6 +922,22 @@ export default function Shell({ api, isSudo }) {
|
||||
return
|
||||
}
|
||||
|
||||
if (ctrl && e.shiftKey && e.key === 'D') {
|
||||
const shellTab = document.querySelector('.shell-layout')
|
||||
if (!shellTab || shellTab.closest('.tab-hidden')) return
|
||||
e.preventDefault()
|
||||
splitPane('vertical')
|
||||
return
|
||||
}
|
||||
|
||||
if (ctrl && e.shiftKey && e.key === 'H') {
|
||||
const shellTab = document.querySelector('.shell-layout')
|
||||
if (!shellTab || shellTab.closest('.tab-hidden')) return
|
||||
e.preventDefault()
|
||||
splitPane('horizontal')
|
||||
return
|
||||
}
|
||||
|
||||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return
|
||||
if (!e.altKey && !(e.key === 'Tab' && e.shiftKey)) return
|
||||
|
||||
@@ -1318,6 +1438,27 @@ Sois concret : cite les vraies versions, les vrais chemins, les vrais nombres. L
|
||||
{zoomLevel > 0 ? '+' : ''}{zoomLevel > 0 ? zoomLevel * 2 : zoomLevel * 2}px
|
||||
</span>
|
||||
)}
|
||||
{paneCount < MAX_PANES && (
|
||||
<>
|
||||
<button className="shell-split-btn" onClick={() => splitPane('vertical')} title="Split Vertical (Ctrl+Shift+D)">
|
||||
<Columns size={14} />
|
||||
</button>
|
||||
<button className="shell-split-btn" onClick={() => splitPane('horizontal')} title="Split Horizontal (Ctrl+Shift+H)">
|
||||
<Rows size={14} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{splitLayout && (
|
||||
<button className="shell-split-btn" onClick={() => setSplitLayout(null)} title="Unsplit">
|
||||
<Maximize2 size={14} />
|
||||
</button>
|
||||
)}
|
||||
{agentSessions.length > 0 && (
|
||||
<span className="shell-agent-indicator" title={`${agentSessions.length} agent(s) actif(s)`}>
|
||||
<Bot size={12} />
|
||||
<span className="shell-agent-count">{agentSessions.length}</span>
|
||||
</span>
|
||||
)}
|
||||
{tabs.length < MAX_TABS && (
|
||||
<div className="shell-new-tab-wrapper">
|
||||
<button className="shell-new-tab-btn" onClick={() => setShowMenu(!showMenu)} title={t('shell.newTab')}>
|
||||
@@ -1384,34 +1525,59 @@ Sois concret : cite les vraies versions, les vrais chemins, les vrais nombres. L
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="shell-xterm-wrapper">
|
||||
{showSearch && (
|
||||
<div className="shell-search-bar">
|
||||
<Search size={14} className="shell-search-icon" />
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
className="shell-search-input"
|
||||
value={searchText}
|
||||
onChange={e => handleSearchChange(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') { e.shiftKey ? handleSearchPrev() : handleSearchNext() }
|
||||
if (e.key === 'Escape') handleCloseSearch()
|
||||
e.stopPropagation()
|
||||
}}
|
||||
placeholder="Rechercher..."
|
||||
/>
|
||||
<button className="shell-search-nav" onClick={handleSearchPrev} title="Précédent (Shift+Entrée)">↑</button>
|
||||
<button className="shell-search-nav" onClick={handleSearchNext} title="Suivant (Entrée)">↓</button>
|
||||
<button className="shell-search-close" onClick={handleCloseSearch}><X size={14} /></button>
|
||||
</div>
|
||||
<div className={`shell-xterm-wrapper ${splitLayout ? 'has-splits' : ''}`}>
|
||||
{editingFile && (
|
||||
<FileEditor api={api} filePath={editingFile} onClose={() => setEditingFile(null)} />
|
||||
)}
|
||||
{!editingFile && (
|
||||
splitLayout ? (
|
||||
<SplitPaneRenderer
|
||||
node={splitLayout}
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
setActiveTab={setActiveTab}
|
||||
showSearch={showSearch}
|
||||
searchText={searchText}
|
||||
searchInputRef={searchInputRef}
|
||||
handleSearchChange={handleSearchChange}
|
||||
handleSearchNext={handleSearchNext}
|
||||
handleSearchPrev={handleSearchPrev}
|
||||
handleCloseSearch={handleCloseSearch}
|
||||
removePane={removePane}
|
||||
onLayoutChange={setSplitLayout}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{showSearch && (
|
||||
<div className="shell-search-bar">
|
||||
<Search size={14} className="shell-search-icon" />
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
className="shell-search-input"
|
||||
value={searchText}
|
||||
onChange={e => handleSearchChange(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') { e.shiftKey ? handleSearchPrev() : handleSearchNext() }
|
||||
if (e.key === 'Escape') handleCloseSearch()
|
||||
e.stopPropagation()
|
||||
}}
|
||||
placeholder="Rechercher..."
|
||||
/>
|
||||
<button className="shell-search-nav" onClick={handleSearchPrev} title="Précédent (Shift+Entrée)">↑</button>
|
||||
<button className="shell-search-nav" onClick={handleSearchNext} title="Suivant (Entrée)">↓</button>
|
||||
<button className="shell-search-close" onClick={handleCloseSearch}><X size={14} /></button>
|
||||
</div>
|
||||
)}
|
||||
{tabs.map(tab => (
|
||||
<div
|
||||
key={tab.id}
|
||||
id={`terminal-${tab.id}`}
|
||||
className={`shell-xterm-instance${activeTab === tab.id ? ' active' : ''}`}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
)}
|
||||
{tabs.map(tab => (
|
||||
<div
|
||||
key={tab.id}
|
||||
id={`terminal-${tab.id}`}
|
||||
className={`shell-xterm-instance${activeTab === tab.id ? ' active' : ''}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1775,3 +1941,158 @@ const ShellAIMessage = memo(function ShellAIMessage({ msg, sendToTerminal, termi
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
function SplitPaneRenderer({ node, tabs, activeTab, setActiveTab, showSearch, searchText, searchInputRef, handleSearchChange, handleSearchNext, handleSearchPrev, handleCloseSearch, removePane, onLayoutChange }) {
|
||||
if (!node) return null
|
||||
|
||||
if (node.type === 'leaf') {
|
||||
const tabId = node.tabId
|
||||
const tab = tabId ? tabs.find(t => t.id === tabId) : null
|
||||
const isActive = activeTab === tabId
|
||||
|
||||
if (!tab && tabId !== null) {
|
||||
const fallbackTab = tabs[0]
|
||||
if (fallbackTab) {
|
||||
return (
|
||||
<div className="split-pane-leaf" onClick={() => setActiveTab(fallbackTab.id)}>
|
||||
<div id={`terminal-${fallbackTab.id}`} className={`shell-xterm-instance${activeTab === fallbackTab.id ? ' active' : ''}`} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (!tab) {
|
||||
return (
|
||||
<div className="split-pane-leaf empty">
|
||||
<div className="split-pane-empty">
|
||||
<Monitor size={24} style={{ opacity: 0.3 }} />
|
||||
<span style={{ fontSize: 12, color: 'var(--text-tertiary)', marginTop: 8 }}>
|
||||
Select a tab for this pane
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: 4, marginTop: 8 }}>
|
||||
{tabs.slice(0, 4).map(t => (
|
||||
<button key={t.id} className="sm ghost" onClick={() => { onLayoutChange(prev => assignLeafTab(prev, node, t.id)) }}>
|
||||
{t.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`split-pane-leaf ${isActive ? 'active' : ''}`} onClick={() => setActiveTab(tabId)}>
|
||||
<div className="split-pane-header">
|
||||
<span className="split-pane-title">{tab.name}</span>
|
||||
<button className="split-pane-close" onClick={(e) => { e.stopPropagation(); removePane(tabId) }}>
|
||||
<X size={10} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="split-pane-content">
|
||||
<div id={`terminal-${tabId}`} className="shell-xterm-instance active" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (node.type === 'split') {
|
||||
const dir = node.direction === 'horizontal' ? 'row' : 'column'
|
||||
|
||||
return (
|
||||
<div className={`split-pane-split ${dir}`} style={{ flex: 1 }}>
|
||||
<div className="split-pane-child" style={{ flex: node.ratio || 0.5, overflow: 'hidden' }}>
|
||||
<SplitPaneRenderer
|
||||
node={node.children?.[0]}
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
setActiveTab={setActiveTab}
|
||||
showSearch={showSearch}
|
||||
searchText={searchText}
|
||||
searchInputRef={searchInputRef}
|
||||
handleSearchChange={handleSearchChange}
|
||||
handleSearchNext={handleSearchNext}
|
||||
handleSearchPrev={handleSearchPrev}
|
||||
handleCloseSearch={handleCloseSearch}
|
||||
removePane={removePane}
|
||||
onLayoutChange={onLayoutChange}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="split-pane-resizer"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault()
|
||||
const parent = e.target.parentElement
|
||||
const startX = e.clientX
|
||||
const startY = e.clientY
|
||||
const startRatio = node.ratio || 0.5
|
||||
const isVertical = node.direction === 'vertical'
|
||||
const parentSize = isVertical ? parent.offsetWidth : parent.offsetHeight
|
||||
|
||||
const onMouseMove = (me) => {
|
||||
const delta = isVertical ? (me.clientX - startX) : (me.clientY - startY)
|
||||
const newRatio = Math.max(0.15, Math.min(0.85, startRatio + delta / parentSize))
|
||||
onLayoutChange(prev => updateSplitRatio(prev, node, newRatio))
|
||||
}
|
||||
|
||||
const onMouseUp = () => {
|
||||
document.removeEventListener('mousemove', onMouseMove)
|
||||
document.removeEventListener('mouseup', onMouseUp)
|
||||
document.body.style.cursor = ''
|
||||
document.body.style.userSelect = ''
|
||||
}
|
||||
|
||||
document.body.style.cursor = isVertical ? 'col-resize' : 'row-resize'
|
||||
document.body.style.userSelect = 'none'
|
||||
document.addEventListener('mousemove', onMouseMove)
|
||||
document.addEventListener('mouseup', onMouseUp)
|
||||
}}
|
||||
/>
|
||||
<div className="split-pane-child" style={{ flex: 1 - (node.ratio || 0.5), overflow: 'hidden' }}>
|
||||
<SplitPaneRenderer
|
||||
node={node.children?.[1]}
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
setActiveTab={setActiveTab}
|
||||
showSearch={showSearch}
|
||||
searchText={searchText}
|
||||
searchInputRef={searchInputRef}
|
||||
handleSearchChange={handleSearchChange}
|
||||
handleSearchNext={handleSearchNext}
|
||||
handleSearchPrev={handleSearchPrev}
|
||||
handleCloseSearch={handleCloseSearch}
|
||||
removePane={removePane}
|
||||
onLayoutChange={onLayoutChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function assignLeafTab(layout, leaf, tabId) {
|
||||
if (!layout) return layout
|
||||
if (layout === leaf) return { ...layout, tabId }
|
||||
if (layout.children) {
|
||||
return {
|
||||
...layout,
|
||||
children: layout.children.map(c => assignLeafTab(c, leaf, tabId)),
|
||||
}
|
||||
}
|
||||
return layout
|
||||
}
|
||||
|
||||
function updateSplitRatio(layout, targetNode, ratio) {
|
||||
if (layout === targetNode) {
|
||||
return { ...layout, ratio }
|
||||
}
|
||||
if (layout.children) {
|
||||
return {
|
||||
...layout,
|
||||
children: layout.children.map(c => updateSplitRatio(c, targetNode, ratio)),
|
||||
}
|
||||
}
|
||||
return layout
|
||||
}
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
|
||||
import { useI18n } from '../i18n'
|
||||
import mermaid from 'mermaid'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import remarkMath from 'remark-math'
|
||||
import rehypeKatex from 'rehype-katex'
|
||||
import rehypeHighlight from 'rehype-highlight'
|
||||
import 'katex/dist/katex.min.css'
|
||||
import 'highlight.js/styles/github-dark.css'
|
||||
|
||||
mermaid.initialize({ startOnLoad: false, theme: 'dark', securityLevel: 'loose', fontFamily: 'var(--font-mono)' })
|
||||
|
||||
@@ -279,6 +286,14 @@ function FeedItem({ msg, activeAgents, onModeChange, collapseHistory }) {
|
||||
|
||||
const timeStr = msg.time ? new Date(msg.time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : ''
|
||||
|
||||
const [showRawMarkdown, setShowRawMarkdown] = useState(() => {
|
||||
try { return localStorage.getItem('muyue.showRawMarkdown') === 'true' } catch { return false }
|
||||
})
|
||||
|
||||
const renderMarkdown = useCallback((content) => {
|
||||
return <MarkdownContent content={content} raw={showRawMarkdown} />
|
||||
}, [showRawMarkdown])
|
||||
|
||||
let parsedToolCalls = null
|
||||
let parsedToolResults = null
|
||||
let parsedSegments = null
|
||||
@@ -322,7 +337,7 @@ function FeedItem({ msg, activeAgents, onModeChange, collapseHistory }) {
|
||||
<span className="feed-role">{rank.label}</span>
|
||||
{timeStr && <span className="feed-time">{timeStr}</span>}
|
||||
</div>
|
||||
{msg.thinking && <ThinkingBlock content={formatText(msg.thinking)} done raw />}
|
||||
{msg.thinking && <ThinkingBlock content={msg.thinking} done raw />}
|
||||
{msg.images && msg.images.length > 0 && (
|
||||
<div className="feed-images">
|
||||
{msg.images.map((imgId, i) => (
|
||||
@@ -354,13 +369,7 @@ function FeedItem({ msg, activeAgents, onModeChange, collapseHistory }) {
|
||||
if (!c) return null
|
||||
return (
|
||||
<div key={`t${i}`} className="feed-content">
|
||||
{renderContent(c).map((part, j) =>
|
||||
part.type === 'code' ? (
|
||||
<CodeBlockWithCopy key={j} part={part} index={j} copiedIdx={copiedIdx} setCopiedIdx={setCopiedIdx} />
|
||||
) : (
|
||||
<span key={j} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} />
|
||||
)
|
||||
)}
|
||||
{renderMarkdown(c)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -408,13 +417,7 @@ function FeedItem({ msg, activeAgents, onModeChange, collapseHistory }) {
|
||||
})()}
|
||||
{cleanContent && (
|
||||
<div className="feed-content">
|
||||
{renderContent(cleanContent).map((part, i) =>
|
||||
part.type === 'code' ? (
|
||||
<CodeBlockWithCopy key={i} part={part} index={i} copiedIdx={copiedIdx} setCopiedIdx={setCopiedIdx} />
|
||||
) : (
|
||||
<span key={i} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} />
|
||||
)
|
||||
)}
|
||||
{renderMarkdown(cleanContent)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
@@ -432,13 +435,13 @@ function StreamingItem({ content, thinking, toolCalls, segments, activeAgents, o
|
||||
const [forceExpand, setForceExpand] = useState(false)
|
||||
|
||||
const renderedContent = useMemo(() => {
|
||||
if (!cleanContent) return []
|
||||
return renderContent(cleanContent)
|
||||
if (!cleanContent) return null
|
||||
return null
|
||||
}, [cleanContent])
|
||||
|
||||
const formattedThinking = useMemo(() => {
|
||||
if (!thinking) return ''
|
||||
return formatText(thinking)
|
||||
return thinking
|
||||
}, [thinking])
|
||||
|
||||
const hasOrderedSegments = segments && segments.some(s => s.type === 'tool')
|
||||
@@ -457,7 +460,7 @@ function StreamingItem({ content, thinking, toolCalls, segments, activeAgents, o
|
||||
</span>
|
||||
<span className="feed-role">{rank.label}</span>
|
||||
</div>
|
||||
{thinking && <ThinkingBlock content={formattedThinking} raw done={false} />}
|
||||
{thinking && <ThinkingBlock content={thinking} raw done={false} />}
|
||||
{hasOrderedSegments ? (
|
||||
<>
|
||||
{compress && (
|
||||
@@ -475,16 +478,9 @@ function StreamingItem({ content, thinking, toolCalls, segments, activeAgents, o
|
||||
return segments.map((seg, i) => {
|
||||
if (seg.type === 'text') {
|
||||
if (!seg.content) return null
|
||||
const parts = renderContent(seg.content)
|
||||
return (
|
||||
<div key={`t${i}`} className="feed-content">
|
||||
{parts.map((part, j) =>
|
||||
part.type === 'code' ? (
|
||||
<CodeBlockWithCopy key={j} part={part} index={j} copiedIdx={copiedIdx} setCopiedIdx={setCopiedIdx} />
|
||||
) : (
|
||||
<span key={j} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} />
|
||||
)
|
||||
)}
|
||||
<MarkdownContent content={seg.content} raw={false} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -506,13 +502,7 @@ function StreamingItem({ content, thinking, toolCalls, segments, activeAgents, o
|
||||
)}
|
||||
{cleanContent && (
|
||||
<div className="feed-content">
|
||||
{renderedContent.map((part, i) =>
|
||||
part.type === 'code' ? (
|
||||
<CodeBlockWithCopy key={i} part={part} index={i} copiedIdx={copiedIdx} setCopiedIdx={setCopiedIdx} />
|
||||
) : (
|
||||
<span key={i} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} />
|
||||
)
|
||||
)}
|
||||
<MarkdownContent content={cleanContent} raw={false} />
|
||||
<span className="studio-cursor" />
|
||||
</div>
|
||||
)}
|
||||
@@ -561,6 +551,8 @@ export default function Studio({ api }) {
|
||||
const textareaRef = useRef(null)
|
||||
const abortRef = useRef(null)
|
||||
const fileInputRef = useRef(null)
|
||||
const ragFileRef = useRef(null)
|
||||
const [ragStatus, setRagStatus] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
api.getChatHistory().then(data => {
|
||||
@@ -589,6 +581,10 @@ export default function Studio({ api }) {
|
||||
messagesEnd.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [messages, streaming, streamThinking, streamToolCalls])
|
||||
|
||||
useEffect(() => {
|
||||
api.ragStatus().then(setRagStatus).catch(() => {})
|
||||
}, [api])
|
||||
|
||||
useEffect(() => {
|
||||
const onTab = (e) => {
|
||||
if (e.key !== 'Tab') return
|
||||
@@ -671,6 +667,20 @@ export default function Studio({ api }) {
|
||||
setAttachedImages(prev => prev.filter((_, i) => i !== index))
|
||||
}, [])
|
||||
|
||||
const handleRAGFileSelect = useCallback(async (e) => {
|
||||
const files = Array.from(e.target.files || [])
|
||||
if (files.length === 0) return
|
||||
for (const file of files) {
|
||||
try {
|
||||
await api.ragIndexFile(file)
|
||||
} catch (err) {
|
||||
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'system', content: `RAG: erreur d'indexation de ${file.name}: ${err.message}`, time: new Date().toISOString() }])
|
||||
}
|
||||
}
|
||||
api.ragStatus().then(setRagStatus).catch(() => {})
|
||||
e.target.value = ''
|
||||
}, [api])
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
if (!input.trim() || loading) return
|
||||
const text = input.trim()
|
||||
@@ -1054,6 +1064,14 @@ export default function Studio({ api }) {
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleImageSelect}
|
||||
/>
|
||||
<input
|
||||
type="file"
|
||||
ref={ragFileRef}
|
||||
accept=".txt,.md,.go,.js,.ts,.py,.java,.rs,.jsx,.tsx,.json,.yaml,.yml,.csv,.html,.css,.sh,.bash,.zsh,.fish"
|
||||
multiple
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleRAGFileSelect}
|
||||
/>
|
||||
<button
|
||||
className="studio-attach-btn"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
@@ -1064,6 +1082,17 @@ export default function Studio({ api }) {
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
className="studio-attach-btn"
|
||||
onClick={() => ragFileRef.current?.click()}
|
||||
disabled={loading}
|
||||
title={ragStatus ? `RAG: ${ragStatus.documents || 0} docs, ${ragStatus.chunks || 0} chunks` : 'Ajouter un contexte RAG'}
|
||||
style={ragStatus && ragStatus.documents > 0 ? { color: 'var(--accent, #6c5ce7)', borderColor: 'var(--accent, #6c5ce7)' } : undefined}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
className="studio-attach-btn"
|
||||
onClick={() => {
|
||||
@@ -1079,6 +1108,21 @@ export default function Studio({ api }) {
|
||||
<circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
className="studio-attach-btn"
|
||||
onClick={() => {
|
||||
const next = !showRawMarkdown
|
||||
setShowRawMarkdown(next)
|
||||
try { localStorage.setItem('muyue.showRawMarkdown', String(next)) } catch {}
|
||||
}}
|
||||
disabled={loading}
|
||||
title={showRawMarkdown ? "Markdown brut: ON" : "Markdown rendu"}
|
||||
style={showRawMarkdown ? { color: 'var(--accent, #6c5ce7)', borderColor: 'var(--accent, #6c5ce7)' } : undefined}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
className="studio-attach-btn"
|
||||
onClick={() => {
|
||||
|
||||
@@ -1313,6 +1313,90 @@ input::placeholder { color: var(--text-disabled); }
|
||||
color: var(--accent-muted) !important;
|
||||
}
|
||||
|
||||
.config-ai-tools-grid {
|
||||
|
||||
/* ── KaTeX overrides ── */
|
||||
.katex { font-size: 1em; color: var(--text-primary); }
|
||||
.katex-display { margin: 12px 0; overflow-x: auto; }
|
||||
|
||||
/* ── Raw Markdown Toggle ── */
|
||||
.studio-raw-markdown {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-secondary);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
background: var(--bg);
|
||||
padding: 12px 16px;
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* ── ReactMarkdown prose styles ── */
|
||||
.feed-content > div:not(.studio-code-block):not(.studio-mermaid-container) {
|
||||
line-height: 1.6;
|
||||
}
|
||||
.feed-content h1 { font-size: 20px; font-weight: 800; color: var(--accent); margin: 16px 0 8px; }
|
||||
.feed-content h2 { font-size: 17px; font-weight: 700; color: var(--text-primary); margin: 12px 0 6px; }
|
||||
.feed-content h3 { font-size: 15px; font-weight: 700; color: var(--text-primary); margin: 10px 0 4px; }
|
||||
.feed-content h4 { font-size: 13px; font-weight: 600; color: var(--text-secondary); margin: 8px 0 3px; }
|
||||
.feed-content h5 { font-size: 12px; font-weight: 600; color: var(--text-tertiary); margin: 6px 0 2px; }
|
||||
.feed-content h6 { font-size: 11px; font-weight: 600; color: var(--text-tertiary); margin: 6px 0 2px; text-transform: uppercase; }
|
||||
.feed-content p { margin: 4px 0; }
|
||||
.feed-content ul { padding-left: 20px; margin: 4px 0; }
|
||||
.feed-content ol { padding-left: 20px; margin: 4px 0; }
|
||||
.feed-content li { margin: 2px 0; }
|
||||
.feed-content blockquote {
|
||||
border-left: 3px solid var(--accent-dim);
|
||||
padding: 4px 12px;
|
||||
margin: 8px 0;
|
||||
color: var(--text-tertiary);
|
||||
background: var(--bg-surface);
|
||||
border-radius: 0 var(--radius) var(--radius) 0;
|
||||
}
|
||||
.feed-content hr { border: none; border-top: 1px solid var(--border); margin: 12px 0; }
|
||||
.feed-content strong { color: var(--accent-light); font-weight: 700; }
|
||||
.feed-content em { color: var(--text-secondary); }
|
||||
.feed-content a { color: var(--accent); text-decoration: underline; }
|
||||
.feed-content img { max-width: 100%; border-radius: var(--radius); }
|
||||
.feed-content input[type="checkbox"] {
|
||||
margin-right: 6px;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
.feed-content del { color: var(--text-disabled); text-decoration: line-through; }
|
||||
.feed-content sup { font-size: 0.75em; color: var(--text-tertiary); vertical-align: super; }
|
||||
|
||||
/* ── highlight.js overrides for dark theme ── */
|
||||
.hljs { background: var(--bg) !important; color: var(--text-primary) !important; }
|
||||
.hljs-keyword { color: var(--accent-muted) !important; }
|
||||
.hljs-string { color: var(--success) !important; }
|
||||
.hljs-comment { color: var(--text-disabled) !important; font-style: italic; }
|
||||
.hljs-function { color: var(--accent-light) !important; }
|
||||
.hljs-number { color: var(--warning) !important; }
|
||||
|
||||
/* ── Responsive / Mobile ── */
|
||||
@media (max-width: 768px) {
|
||||
:root { --sidebar-w: 100%; --header-h: 46px; }
|
||||
.header { padding: 0 12px; gap: 8px; }
|
||||
.header-nav { margin-left: 12px; gap: 2px; }
|
||||
.nav-tab { padding: 6px 10px; font-size: 12px; }
|
||||
.header-brand { gap: 6px; }
|
||||
.header-logo { font-size: 15px; letter-spacing: 2px; }
|
||||
.studio-feed { padding: 12px 8px; }
|
||||
.studio-input-area { padding: 8px 8px 4px; }
|
||||
.feed-item { padding: 6px 8px; }
|
||||
.feed-avatar { width: 24px; height: 24px; }
|
||||
.dash-grid { grid-template-columns: 1fr; grid-template-rows: auto; height: auto; overflow: auto; }
|
||||
.dash-span-2 { grid-column: span 1; }
|
||||
.grid-2 { grid-template-columns: 1fr; }
|
||||
.split-horizontal { flex-direction: column; }
|
||||
.split-right { width: 100%; border-left: none; border-top: 1px solid var(--border); max-height: 300px; }
|
||||
.shell-ai-col { width: 100%; max-width: 100%; border-left: none; border-top: 1px solid var(--border); max-height: 50vh; }
|
||||
.config-card-row { flex-wrap: wrap; gap: 8px; }
|
||||
.config-card-label { width: 100%; }
|
||||
}
|
||||
|
||||
.config-ai-tools-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
@@ -1360,3 +1444,228 @@ input::placeholder { color: var(--text-disabled); }
|
||||
margin-bottom: 10px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* === Split Panes === */
|
||||
.shell-split-btn {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
padding: 4px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.shell-split-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--accent-dim);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.shell-agent-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
background: var(--accent-dim);
|
||||
color: var(--accent);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
animation: agent-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.shell-agent-count {
|
||||
min-width: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@keyframes agent-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.6; }
|
||||
}
|
||||
|
||||
.shell-xterm-wrapper.has-splits {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.split-pane-split {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.split-pane-split.row {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.split-pane-split.column {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.split-pane-child {
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.split-pane-resizer {
|
||||
flex-shrink: 0;
|
||||
background: var(--border);
|
||||
transition: background 0.15s;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.split-pane-split.row > .split-pane-resizer {
|
||||
width: 4px;
|
||||
cursor: col-resize;
|
||||
}
|
||||
|
||||
.split-pane-split.column > .split-pane-resizer {
|
||||
height: 4px;
|
||||
cursor: row-resize;
|
||||
}
|
||||
|
||||
.split-pane-resizer:hover,
|
||||
.split-pane-resizer:active {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.split-pane-leaf {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.split-pane-leaf.active {
|
||||
border-color: var(--accent-dim);
|
||||
}
|
||||
|
||||
.split-pane-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 2px 8px;
|
||||
background: var(--bg-surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.split-pane-title {
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.split-pane-close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-disabled);
|
||||
cursor: pointer;
|
||||
padding: 2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.split-pane-close:hover {
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.split-pane-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.split-pane-leaf.empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.split-pane-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
color: var(--text-disabled);
|
||||
}
|
||||
|
||||
/* === File Editor === */
|
||||
.file-editor-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.file-editor-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 4px 10px;
|
||||
background: var(--bg-surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.file-editor-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.file-editor-dirty {
|
||||
color: var(--accent);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.file-editor-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.file-editor-lang-badge {
|
||||
font-size: 10px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
background: var(--bg-card);
|
||||
color: var(--text-tertiary);
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.file-editor-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.file-editor-body .cm-editor {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user