Compare commits

...

10 Commits

Author SHA1 Message Date
Augustin
5a0480bae0 fix(ci): add .npmrc with legacy-peer-deps for xterm addon resolution
All checks were successful
Beta Release / beta (push) Successful in 49s
💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-24 22:12:05 +02:00
Augustin
80de4dd523 feat(shell): integrate Hyper-like terminal technologies (WebGL, search, unicode11, image)
Some checks failed
Beta Release / beta (push) Failing after 20s
Add xterm addons from Vercel Hyper terminal: WebGL renderer with DOM
fallback, search bar (Ctrl+Shift+F), Unicode 11 grapheme support, and
inline image protocol. All existing functionality preserved.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-24 22:10:15 +02:00
Augustin
de52f4ebd6 fix(shell): restore all missing imports, constants, and utility functions
All checks were successful
Beta Release / beta (push) Successful in 49s
- Restore xterm imports (Terminal, FitAddon, WebLinksAddon)
- Restore all lucide-react icons (Globe, X, Plus, ChevronDown, etc.)
- Restore module-level constants (AI_TAB_ID, MAX_TABS, SHELL_MAX_TOKENS,
  TABS_STORAGE_KEY, TERMINAL_BUFFER_KEY)
- Restore renderContent() and formatText() utility functions
- Add @xterm/xterm CSS import
- Remove duplicate constants from inside Shell component

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-24 22:02:36 +02:00
Augustin
98ff0dd578 fix(shell): add missing Monitor import from lucide-react
All checks were successful
Beta Release / beta (push) Successful in 47s
💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-24 21:56:32 +02:00
Augustin
9a1ff6e8dc fix(shell): restore missing MAX_TABS, TABS_STORAGE_KEY, TERMINAL_BUFFER_KEY constants
All checks were successful
Beta Release / beta (push) Successful in 48s
💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-24 21:53:38 +02:00
Augustin
034b9ee0e4 fix(shell): add missing useI18n import
All checks were successful
Beta Release / beta (push) Successful in 45s
💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-24 21:51:54 +02:00
Augustin
c1b1fc653f fix(shell): remove stray 'impo' typo causing ReferenceError
All checks were successful
Beta Release / beta (push) Successful in 44s
💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-24 21:50:12 +02:00
Augustin
50ca75180c fix(terminal): improve dimensions handling and add system theme for xterm
All checks were successful
Beta Release / beta (push) Successful in 47s
💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-24 21:43:10 +02:00
Augustin
b8aa935bec fix(shell): resolve savedTabs undefined ReferenceError in activeTab init
All checks were successful
Beta Release / beta (push) Successful in 50s
💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-24 21:36:25 +02:00
Augustin
5627ddd2ce fix(terminal): improve dimension calculation and tab init reliability
All checks were successful
Beta Release / beta (push) Successful in 48s
- Guarantee minimum 24x80 dimensions on WebSocket open
- Force reflow before init attempts
- Multiple fit attempts with increasing delays (0/50/100/200/400ms)
- Validate saved tabs structure from localStorage
- Resize active tab after closing another tab

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-24 21:30:07 +02:00
5 changed files with 416 additions and 40 deletions

1
web/.npmrc Normal file
View File

@@ -0,0 +1 @@
legacy-peer-deps=true

40
web/package-lock.json generated
View File

@@ -7,7 +7,11 @@
"name": "muyue-web",
"dependencies": {
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-image": "^0.10.0-beta.203",
"@xterm/addon-search": "^0.17.0-beta.203",
"@xterm/addon-unicode11": "^0.10.0-beta.203",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/addon-webgl": "^0.20.0-beta.202",
"@xterm/xterm": "^6.0.0",
"lucide-react": "^1.8.0",
"react": "^19.2.5",
@@ -406,12 +410,48 @@
"integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==",
"license": "MIT"
},
"node_modules/@xterm/addon-image": {
"version": "0.10.0-beta.203",
"resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.203.tgz",
"integrity": "sha512-1hRy7/jYCYvUhc6GYu177EdsW44QQQHsq71Odvo6cEhHKEEoqFsrOnLpe9WuNWZXgqpCwy2Cnp6FepHm960Eiw==",
"license": "MIT",
"peerDependencies": {
"@xterm/xterm": "^6.1.0-beta.203"
}
},
"node_modules/@xterm/addon-search": {
"version": "0.17.0-beta.203",
"resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.17.0-beta.203.tgz",
"integrity": "sha512-agxzh30h4L82kjGlTwWEsaXnXzOuMIAm80+zcNElFL/hHuT/nLvcwRng+s7RzOWNNLG3pB4jbTHqbBaM+nW8mg==",
"license": "MIT",
"peerDependencies": {
"@xterm/xterm": "^6.1.0-beta.203"
}
},
"node_modules/@xterm/addon-unicode11": {
"version": "0.10.0-beta.203",
"resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.10.0-beta.203.tgz",
"integrity": "sha512-KqMOqqpeEPQw5TQLb8jNHPESjZSwenFzhBPNA1g2zcPY5JtZ15pFzzoFxXdzS5LYmdYxexpd8s2ianf8WmQKyg==",
"license": "MIT",
"peerDependencies": {
"@xterm/xterm": "^6.1.0-beta.203"
}
},
"node_modules/@xterm/addon-web-links": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.12.0.tgz",
"integrity": "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==",
"license": "MIT"
},
"node_modules/@xterm/addon-webgl": {
"version": "0.20.0-beta.202",
"resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.202.tgz",
"integrity": "sha512-GCh0QlUv77XX8cJt8/7AVdDUNFpa1f6MGX/skhciu5ZRK88hR1m8T+8MZ3FYfddLV6phY0ksmiO9ErC0R+7G/A==",
"license": "MIT",
"peerDependencies": {
"@xterm/xterm": "^6.1.0-beta.203"
}
},
"node_modules/@xterm/xterm": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",

View File

@@ -9,7 +9,11 @@
},
"dependencies": {
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-image": "^0.10.0-beta.203",
"@xterm/addon-search": "^0.17.0-beta.203",
"@xterm/addon-unicode11": "^0.10.0-beta.203",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/addon-webgl": "^0.20.0-beta.202",
"@xterm/xterm": "^6.0.0",
"lucide-react": "^1.8.0",
"react": "^19.2.5",

View File

@@ -1,11 +1,16 @@
import { useState, useRef, useEffect, useCallback } from 'react'
import { useState, useRef, useEffect, useCallback, useMemo } 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 { 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 '@xterm/xterm/css/xterm.css'
import { useI18n } from '../i18n'
const AI_TAB_ID = 0
const MAX_TABS = 7
const SHELL_MAX_TOKENS = 100000
const TABS_STORAGE_KEY = 'muyue_shell_tabs'
@@ -67,7 +72,70 @@ function formatText(text) {
return html
}
// === Style thème système pour xterm ===
function getCSSVariable(varName) {
if (typeof document === 'undefined') return null;
return getComputedStyle(document.documentElement).getPropertyValue(varName).trim() || null;
}
function parseHexColor(hex) {
if (!hex || hex.startsWith('var(')) return null;
hex = hex.replace('#', '');
if (hex.length === 3) hex = hex.split('').map(c => c + c).join('');
if (hex.length !== 6) return null;
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
return { r, g, b };
}
function toRgbString(hex) {
const c = parseHexColor(hex);
if (!c) return '#000000';
return `#${c.r.toString(16).padStart(2, '0')}${c.g.toString(16).padStart(2, '0')}${c.b.toString(16).padStart(2, '0')}`;
}
function buildSystemTheme() {
const bg = getCSSVariable('--bg-base') || '#0F0D10';
const fg = getCSSVariable('--text-primary') || '#EAE0E2';
const accent = getCSSVariable('--accent-light') || '#FF1A5E';
const accentDim = getCSSVariable('--accent-dim') || '#6B2033';
const success = '#00E676';
const warning = '#FFD740';
const error = getCSSVariable('--accent-bright') || '#FF1744';
const bgSurface = getCSSVariable('--bg-surface') || bg;
const bgElevated = getCSSVariable('--bg-elevated') || bgSurface;
const textSecondary = getCSSVariable('--text-secondary') || fg;
const textTertiary = getCSSVariable('--text-tertiary') || textSecondary;
return {
background: toRgbString(bg),
foreground: toRgbString(fg),
cursor: toRgbString(accent),
cursorAccent: toRgbString(bg),
selectionBackground: `${toRgbString(accentDim)}44`,
selectionForeground: '#FFFFFF',
black: toRgbString(bgElevated),
red: toRgbString(error),
green: toRgbString(success),
yellow: toRgbString(warning),
blue: toRgbString(getCSSVariable('--accent') || '#448AFF'),
magenta: toRgbString(accent),
cyan: '#00BCD4',
white: toRgbString(fg),
brightBlack: toRgbString(bgSurface),
brightRed: toRgbString(accent),
brightGreen: toRgbString(success),
brightYellow: toRgbString(warning),
brightBlue: toRgbString(getCSSVariable('--accent-muted') || '#82B1FF'),
brightMagenta: toRgbString(getCSSVariable('--accent-soft') || '#FF80AB'),
brightCyan: '#84FFFF',
brightWhite: '#FFFFFF',
};
}
const THEMES = {
system: buildSystemTheme(),
default: {
background: '#0A0A0C', foreground: '#EAE0E2', cursor: '#FF0033',
cursorAccent: '#0A0A0C', selectionBackground: '#FF003344', selectionForeground: '#ffffff',
@@ -125,11 +193,14 @@ const THEMES = {
}
function getTheme(themeName) {
return THEMES[themeName] || THEMES.default
if (themeName === 'system' || themeName === 'default') {
return buildSystemTheme()
}
return THEMES[themeName] || buildSystemTheme()
}
function createTerminal(container, settings = {}) {
const theme = getTheme(settings.theme || 'default')
const theme = getTheme(settings.theme || 'system')
const term = new XTerm({
cursorBlink: true,
fontSize: settings.fontSize || 12,
@@ -141,8 +212,25 @@ function createTerminal(container, settings = {}) {
const fitAddon = new FitAddon()
const webLinksAddon = new WebLinksAddon()
const searchAddon = new SearchAddon()
const unicode11Addon = new Unicode11Addon()
const imageAddon = new ImageAddon()
term.loadAddon(fitAddon)
term.loadAddon(webLinksAddon)
term.loadAddon(searchAddon)
term.loadAddon(unicode11Addon)
term.loadAddon(imageAddon)
term.unicode.activeVersion = '11'
try {
const webglAddon = new WebglAddon()
webglAddon.onContextLoss(() => { webglAddon.dispose() })
term.loadAddon(webglAddon)
} catch (e) {
console.warn('[Shell] WebGL renderer not available, using DOM fallback:', e)
}
term.attachCustomKeyEventHandler((e) => {
if (e.type !== 'keydown') return true
@@ -172,7 +260,7 @@ function createTerminal(container, settings = {}) {
term.open(container)
fitAddon.fit()
return { term, fitAddon }
return { term, fitAddon, searchAddon }
}
function connectWebSocket(term, fitAddon, initPayload, onStateChange, onFirstMessage) {
@@ -182,9 +270,20 @@ function connectWebSocket(term, fitAddon, initPayload, onStateChange, onFirstMes
ws.addEventListener('open', () => {
ws.send(JSON.stringify(initPayload))
const dims = fitAddon.proposeDimensions()
if (dims) {
ws.send(JSON.stringify({ type: 'resize', rows: dims.rows, cols: dims.cols }))
// Envoyer resize avec dimensions minimales garanties (24x80)
const rows = dims?.rows || 24
const cols = dims?.cols || 80
ws.send(JSON.stringify({ type: 'resize', rows, cols }))
// Forcer un fit après l'ouverture
setTimeout(() => {
try {
fitAddon.fit()
const newDims = fitAddon.proposeDimensions()
if (newDims && newDims.rows > 0 && newDims.cols > 0) {
ws.send(JSON.stringify({ type: 'resize', rows: newDims.rows, cols: newDims.cols }))
}
} catch (e) { console.warn('[Shell] fit failed:', e) }
}, 50)
if (onStateChange) onStateChange(true)
})
@@ -229,29 +328,44 @@ 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 settingsRef = useRef({ fontSize: 12, fontFamily: "'JetBrains Mono', 'Fira Code', monospace", theme: 'system' })
const pendingCommandsRef = useRef({})
const savedTabs = (() => {
const [tabs, setTabs] = useState(() => {
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 }))
if (Array.isArray(parsed) && parsed.length > 0 && parsed.length <= MAX_TABS) {
return parsed.map((t, i) => ({
id: t.id || i + 1,
name: t.name || `Tab ${i + 1}`,
type: t.type || 'local',
shell: t.shell || '',
host: t.host,
port: t.port,
user: t.user,
key_path: t.key_path,
connected: false
}))
}
}
} catch (e) {
console.warn('[Shell] Failed to parse saved tabs:', e)
localStorage.removeItem(TABS_STORAGE_KEY)
}
return [
{ id: 1, name: 'Local Shell', type: 'local', shell: '', connected: false },
]
})
const [activeTab, setActiveTab] = useState(() => {
try {
const raw = localStorage.getItem(TABS_STORAGE_KEY)
if (raw) {
const parsed = JSON.parse(raw)
if (Array.isArray(parsed) && parsed.length > 0) return parsed[0]?.id || 1
}
} 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)
@@ -265,9 +379,14 @@ export default function Shell({ api }) {
const [terminalSettings, setTerminalSettings] = useState({
fontSize: 12,
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', 'Menlo', monospace",
theme: 'default',
theme: 'system',
})
const [showSearch, setShowSearch] = useState(false)
const [searchText, setSearchText] = useState('')
const searchInputRef = useRef(null)
const searchDecorationsRef = useRef(null)
useEffect(() => { settingsRef.current = terminalSettings }, [terminalSettings])
const [sshForm, setSshForm] = useState({
@@ -330,7 +449,7 @@ export default function Shell({ api }) {
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',
theme: d.terminal.theme || 'system',
})
}
}).catch(() => {})
@@ -343,7 +462,7 @@ export default function Shell({ api }) {
if (!container) return
const s = settingsRef.current
const { term, fitAddon } = createTerminal(container, {
const { term, fitAddon, searchAddon } = createTerminal(container, {
fontSize: s.fontSize,
fontFamily: s.fontFamily,
theme: s.theme,
@@ -435,7 +554,7 @@ export default function Shell({ api }) {
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] = { term, fitAddon, searchAddon, 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))
@@ -482,36 +601,60 @@ export default function Shell({ api }) {
let cancelled = false
const pending = []
// Forcer le layout à se calculer
const forceLayout = () => {
const el = document.querySelector('.shell-terminal-col')
if (el) {
el.style.height = ''
el.style.minHeight = ''
// Forcer reflow
void el.offsetHeight
}
}
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))
if (attempt > 20) {
console.warn(`[Shell] max attempts reached for tab ${tab.id}`)
return
}
forceLayout()
const shellCol = document.querySelector('.shell-terminal-col')
if (!shellCol) {
pending.push(setTimeout(() => tryInitTab(tab, attempt + 1), 150))
return
}
const container = document.getElementById(`terminal-${tab.id}`)
if (!container) {
pending.push(setTimeout(() => tryInitTab(tab, attempt + 1), 100))
return
}
if (container.offsetHeight === 0) {
const rect = container.getBoundingClientRect()
if (rect.height < 10 || rect.width < 10) {
pending.push(setTimeout(() => tryInitTab(tab, attempt + 1), 100))
return
}
if (!tabsRef.current[tab.id]) {
initTerminal(tab.id, tab)
}
requestAnimationFrame(() => {
// Multiple fit attempts avec délais croissants
const fitAttempts = [0, 50, 100, 200, 400]
fitAttempts.forEach(delay => {
setTimeout(() => {
if (cancelled) return
const entry = tabsRef.current[tab.id]
if (entry) {
if (entry && entry.fitAddon) {
try {
entry.fitAddon.fit()
setTimeout(() => { if (!cancelled) entry.fitAddon.fit() }, 100)
} catch (e) { console.warn(`[Shell] fit attempt ${delay}ms failed:`, e) }
}
}, delay)
})
if (!tabsRef.current[tab.id]) {
tryInitTab(tab, 0)
}
}
const wrapper = document.querySelector('.shell-layout')?.parentElement
@@ -571,6 +714,15 @@ export default function Shell({ api }) {
useEffect(() => {
const onKey = (e) => {
const ctrl = e.ctrlKey || e.metaKey
if (ctrl && e.shiftKey && e.key === 'F') {
const shellTab = document.querySelector('.shell-layout')
if (!shellTab || shellTab.closest('.tab-hidden')) return
e.preventDefault()
setShowSearch(prev => !prev)
return
}
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return
if (!e.altKey && !(e.key === 'Tab' && e.shiftKey)) return
@@ -594,6 +746,49 @@ export default function Shell({ api }) {
return () => window.removeEventListener('keydown', onKey)
}, [tabs])
useEffect(() => {
if (showSearch && searchInputRef.current) {
searchInputRef.current.focus()
}
}, [showSearch])
const handleSearchChange = useCallback((value) => {
setSearchText(value)
const entry = tabsRef.current[activeTabRef.current]
if (!entry?.searchAddon) return
if (!value) {
entry.searchAddon.clearDecorations()
entry.searchAddon.clearActiveDecoration()
return
}
try {
searchDecorationsRef.current = entry.searchAddon.findNext(value)
} catch {}
}, [])
const handleSearchNext = useCallback(() => {
const entry = tabsRef.current[activeTabRef.current]
if (!entry?.searchAddon || !searchText) return
try { entry.searchAddon.findNext(searchText) } catch {}
}, [searchText])
const handleSearchPrev = useCallback(() => {
const entry = tabsRef.current[activeTabRef.current]
if (!entry?.searchAddon || !searchText) return
try { entry.searchAddon.findPrevious(searchText) } catch {}
}, [searchText])
const handleCloseSearch = useCallback(() => {
setShowSearch(false)
setSearchText('')
const entry = tabsRef.current[activeTabRef.current]
if (entry?.searchAddon) {
entry.searchAddon.clearDecorations()
entry.searchAddon.clearActiveDecoration()
}
if (entry?.term) entry.term.focus()
}, [])
const addLocalTab = (shell, name) => {
if (tabs.length >= MAX_TABS) return
const id = nextIdRef.current++
@@ -650,6 +845,19 @@ export default function Shell({ api }) {
}
return next
})
// Redimensionner le nouveau tab actif
setTimeout(() => {
const newActiveTabId = next.length > 0 ? next[next.length - 1].id : null
if (newActiveTabId) {
const entry = tabsRef.current[newActiveTabId]
if (entry && entry.fitAddon) {
try {
entry.fitAddon.fit()
} catch (e) { console.warn('[Shell] fit after close failed:', e) }
}
}
}, 100)
}
const startRename = (tabId, e) => {
@@ -910,6 +1118,26 @@ export default function Shell({ api }) {
</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>
)}
{tabs.map(tab => (
<div
key={tab.id}

View File

@@ -383,6 +383,36 @@ input::placeholder { color: var(--text-disabled); }
.shell-menu-divider { height: 1px; background: var(--border); margin: 4px 6px; }
.shell-xterm-wrapper { flex: 1; min-height: 0; background: var(--bg); overflow: hidden; position: relative; }
.shell-search-bar {
position: absolute; top: 8px; right: 12px; z-index: 20;
display: flex; align-items: center; gap: 4px;
background: var(--bg-elevated); border: 1px solid var(--border);
border-radius: var(--radius); padding: 4px 6px;
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
}
.shell-search-icon { color: var(--text-tertiary); flex-shrink: 0; }
.shell-search-input {
width: 200px; font-size: 12px; padding: 3px 6px; border-radius: 4px;
background: var(--bg-input); color: var(--text-primary); border: 1px solid var(--border);
font-family: var(--font-mono); outline: none;
}
.shell-search-input:focus { border-color: var(--accent); }
.shell-search-nav {
display: flex; align-items: center; justify-content: center;
width: 24px; height: 24px; border-radius: 4px;
background: transparent; border: 1px solid var(--border);
color: var(--text-tertiary); cursor: pointer; font-size: 12px;
padding: 0; transition: all 0.1s;
}
.shell-search-nav:hover { background: var(--bg-hover); color: var(--text-primary); border-color: var(--accent-dark); }
.shell-search-close {
display: flex; align-items: center; justify-content: center;
width: 24px; height: 24px; border-radius: 4px;
background: transparent; border: none;
color: var(--text-disabled); cursor: pointer; padding: 0;
}
.shell-search-close:hover { color: var(--accent); }
.shell-xterm-instance {
position: absolute;
inset: 0;
@@ -1058,3 +1088,76 @@ input::placeholder { color: var(--text-disabled); }
word-break: break-word;
background: var(--bg);
}
/* === XTerm Custom Styling === */
/* Styles for xterm.js integrated with Muyue theme */
.shell-xterm-instance .xterm {
padding: 4px 8px;
}
.shell-xterm-instance .xterm-viewport {
background-color: var(--bg-base) !important;
}
.shell-xterm-instance .xterm-screen {
background-color: var(--bg-base);
}
/* Scrollbar styling for xterm */
.shell-xterm-instance .xterm-viewport::-webkit-scrollbar {
width: 8px;
}
.shell-xterm-instance .xterm-viewport::-webkit-scrollbar-track {
background: var(--bg-surface);
}
.shell-xterm-instance .xterm-viewport::-webkit-scrollbar-thumb {
background: var(--accent-dim);
border-radius: 4px;
}
.shell-xterm-instance .xterm-viewport::-webkit-scrollbar-thumb:hover {
background: var(--accent-dark);
}
/* Selection styling */
.shell-xterm-instance .xterm-selection {
background: var(--accent-dim) !important;
}
/* Focus ring styling */
.shell-xterm-instance .xterm:focus .xterm-helper-text-container {
box-shadow: none;
}
/* Ensure consistent font rendering */
.shell-xterm-instance .xterm .xterm-char-measure-element {
font-family: var(--font-mono) !important;
}
/* Bell animation styling */
.shell-xterm-instance .xterm-bell {
animation: xterm-bell-flash 0.3s ease-out;
}
@keyframes xterm-bell-flash {
0% { opacity: 1; }
50% { opacity: 0.5; }
100% { opacity: 0; }
}
/* Cursor styling */
.shell-xterm-instance .xterm-cursor {
outline: none !important;
}
/* Link styling for web links addon */
.shell-xterm-instance .xterm-link {
color: var(--accent-light) !important;
text-decoration: underline;
}
.shell-xterm-instance .xterm-link:hover {
color: var(--accent-muted) !important;
}