Compare commits

..

9 Commits

Author SHA1 Message Date
Augustin
436d5c6149 feat(shell): add Ctrl+/- zoom and display all shortcuts in footer
All checks were successful
Beta Release / beta (push) Successful in 48s
- Ctrl+/Ctrl-/Ctrl+0 to zoom in/out/reset terminal font size
- Zoom badge indicator in tab bar
- All shell shortcuts now shown in statusbar footer
- Added i18n labels for search, zoom, switch tab, next tab

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-24 22:28:15 +02:00
Augustin
5a9edc076e fix(deps): upgrade @xterm/xterm to 6.1.0-beta.203 for addon compatibility
All checks were successful
Beta Release / beta (push) Successful in 49s
The addon-web-links registerApcHandler API requires xterm >= 6.1.0.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-24 22:19:12 +02:00
Augustin
5bdc7a6429 fix(shell): enable allowProposedApi for Unicode11 addon
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 22:16:23 +02:00
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
8 changed files with 325 additions and 9 deletions

1
web/.npmrc Normal file
View File

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

48
web/package-lock.json generated
View File

@@ -7,8 +7,12 @@
"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/xterm": "^6.0.0",
"@xterm/addon-webgl": "^0.20.0-beta.202",
"@xterm/xterm": "^6.1.0-beta.203",
"lucide-react": "^1.8.0",
"react": "^19.2.5",
"react-dom": "^19.2.5"
@@ -406,16 +410,52 @@
"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",
"integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==",
"version": "6.1.0-beta.203",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.203.tgz",
"integrity": "sha512-Ctqf05M6fPWZkfKxC4hy2+PP5P2BlVnJLbIsXZMpkCz/MjJvcf5OwwsGkq+nzhFDuojSX+rc2RxIetLONUBGqw==",
"license": "MIT",
"workspaces": [
"addons/*"

View File

@@ -9,8 +9,12 @@
},
"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/xterm": "^6.0.0",
"@xterm/addon-webgl": "^0.20.0-beta.202",
"@xterm/xterm": "^6.1.0-beta.203",
"lucide-react": "^1.8.0",
"react": "^19.2.5",
"react-dom": "^19.2.5"

View File

@@ -94,6 +94,10 @@ export default function App() {
shell: [
{ keys: `${layout.keys.ctrl}+${layout.keys.shift}+C`, desc: t('statusbar.copy') },
{ keys: `${layout.keys.ctrl}+${layout.keys.shift}+V`, desc: t('statusbar.paste') },
{ keys: `${layout.keys.ctrl}+F`, desc: t('statusbar.search') },
{ keys: `${layout.keys.ctrl}+/Ctrl`, desc: t('statusbar.zoom') },
{ keys: `Alt+1-7`, desc: t('statusbar.switchTab') },
{ keys: `${layout.keys.shift}+Tab`, desc: t('statusbar.nextTab') },
{ keys: layout.keys.enter, desc: t('statusbar.runCommand') },
{ keys: `${layout.keys.up}/${layout.keys.down}`, desc: t('statusbar.commandHistory') },
],

View File

@@ -1,4 +1,77 @@
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 { 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'
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
html = html
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/`([^`]+)`/g, '<code class="inline-code">$1</code>')
.replace(/^### (.+)$/gm, '<h4 class="msg-h4">$1</h4>')
.replace(/^## (.+)$/gm, '<h3 class="msg-h3">$1</h3>')
.replace(/^# (.+)$/gm, '<h2 class="msg-h2">$1</h2>')
.replace(/^\s*[-*] (.+)$/gm, '<div class="msg-bullet">• $1</div>')
.replace(/^\s*(\d+)[.)] (.+)$/gm, '<div class="msg-step"><span class="msg-step-num">$1</span> $2</div>')
.replace(/\n/g, '<br/>')
html = html
.replace(/<br\/>\s*<br\/>/g, '<br/>')
.replace(/<br\/>\s*(<h[234]|<div class="msg-)/g, '$1')
.replace(/(<\/h[234]|<\/div>)\s*<br\/>/g, '$1')
.replace(/\s+on\w+=["'][^"']*["']/gi, '')
.replace(/javascript:/gi, '')
.replace(/data:/gi, '')
return html
}
// === Style thème système pour xterm ===
function getCSSVariable(varName) {
if (typeof document === 'undefined') return null;
@@ -130,6 +203,7 @@ function createTerminal(container, settings = {}) {
const theme = getTheme(settings.theme || 'system')
const term = new XTerm({
cursorBlink: true,
allowProposedApi: true,
fontSize: settings.fontSize || 12,
fontFamily: settings.fontFamily || "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', 'Menlo', monospace",
theme,
@@ -139,8 +213,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
@@ -164,13 +255,34 @@ function createTerminal(container, settings = {}) {
return false
}
if (ctrl && (e.key === '=' || e.key === '+')) {
e.preventDefault()
e.stopPropagation()
window.dispatchEvent(new CustomEvent('shell-zoom', { detail: 1 }))
return false
}
if (ctrl && e.key === '-') {
e.preventDefault()
e.stopPropagation()
window.dispatchEvent(new CustomEvent('shell-zoom', { detail: -1 }))
return false
}
if (ctrl && e.key === '0') {
e.preventDefault()
e.stopPropagation()
window.dispatchEvent(new CustomEvent('shell-zoom', { detail: 0 }))
return false
}
return true
})
term.open(container)
fitAddon.fit()
return { term, fitAddon }
return { term, fitAddon, searchAddon }
}
function connectWebSocket(term, fitAddon, initPayload, onStateChange, onFirstMessage) {
@@ -292,8 +404,40 @@ export default function Shell({ api }) {
theme: 'system',
})
const [showSearch, setShowSearch] = useState(false)
const [searchText, setSearchText] = useState('')
const searchInputRef = useRef(null)
const searchDecorationsRef = useRef(null)
const [zoomLevel, setZoomLevel] = useState(0)
const baseFontSizeRef = useRef(12)
useEffect(() => { settingsRef.current = terminalSettings }, [terminalSettings])
useEffect(() => {
baseFontSizeRef.current = terminalSettings.fontSize || 12
}, [terminalSettings.fontSize])
useEffect(() => {
const handler = (e) => {
const direction = e.detail
setZoomLevel(prev => {
let next
if (direction === 0) next = 0
else next = Math.max(-8, Math.min(10, prev + direction))
const newSize = baseFontSizeRef.current + next * 2
for (const entry of Object.values(tabsRef.current)) {
if (entry.term && !entry.term._disposed) {
entry.term.options.fontSize = newSize
try { entry.fitAddon.fit() } catch {}
}
}
return next
})
}
window.addEventListener('shell-zoom', handler)
return () => window.removeEventListener('shell-zoom', handler)
}, [])
const [sshForm, setSshForm] = useState({
name: '', host: '', port: 22, user: '', key_path: '',
})
@@ -367,7 +511,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,
@@ -459,7 +603,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))
@@ -619,6 +763,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
@@ -642,6 +795,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++
@@ -911,6 +1107,11 @@ export default function Shell({ api }) {
</div>
<div className="shell-tab-actions">
{zoomLevel !== 0 && (
<span className="shell-zoom-badge">
{zoomLevel > 0 ? '+' : ''}{zoomLevel > 0 ? zoomLevel * 2 : zoomLevel * 2}px
</span>
)}
{tabs.length < MAX_TABS && (
<div className="shell-new-tab-wrapper">
<button className="shell-new-tab-btn" onClick={() => setShowMenu(!showMenu)} title={t('shell.newTab')}>
@@ -971,6 +1172,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

@@ -18,6 +18,10 @@ const en = {
newLine: 'New line',
copy: 'Copy',
paste: 'Paste',
search: 'Search',
zoom: 'Zoom +/',
switchTab: 'Switch tab',
nextTab: 'Next tab',
runCommand: 'Run command',
commandHistory: 'Command history',
},

View File

@@ -18,6 +18,10 @@ const fr = {
newLine: 'Nouvelle ligne',
copy: 'Copier',
paste: 'Coller',
search: 'Rechercher',
zoom: 'Zoom +/\u2212',
switchTab: 'Changer d\u2019onglet',
nextTab: 'Onglet suivant',
runCommand: 'Ex\u00e9cuter',
commandHistory: 'Historique',
},

View File

@@ -329,6 +329,14 @@ input::placeholder { color: var(--text-disabled); }
.shell-tab-actions { display: flex; align-items: center; gap: 4px; flex-shrink: 0; }
.shell-zoom-badge {
font-size: 10px; font-family: var(--font-mono); font-weight: 600;
color: var(--accent); background: var(--accent-bg);
padding: 2px 6px; border-radius: 3px;
border: 1px solid var(--accent-dim);
white-space: nowrap;
}
.shell-new-tab-wrapper { position: relative; }
.shell-new-tab-btn {
display: flex; align-items: center; gap: 2px;
@@ -383,6 +391,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;