Compare commits

..

13 Commits

Author SHA1 Message Date
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
Augustin
d27872572a fix(dashboard): show MiMo quota instead of ZAI on dashboard
All checks were successful
Beta Release / beta (push) Successful in 47s
Replace Z.AI quota display with MiMo provider in the API Quota card.
ZAI is now a hidden fallback and should not appear in the dashboard.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-24 21:28:22 +02:00
Augustin
7d0f807fb0 feat(ai): add Xiaomi MiMo provider, ZAI as last-resort fallback
All checks were successful
Beta Release / beta (push) Successful in 57s
Add MiMo-V2.5-Pro from Xiaomi Token Plan as a new AI provider with
base URL https://token-plan-ams.xiaomimimo.com/v1. The /model change
command now switches between MiniMax and MiMo only. ZAI is always
placed last in the fallback chain as the provider of ultimate resort.
Config panel shows MiniMax and MiMo cards.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-24 21:22:34 +02:00
Augustin
cbf623b98b fix(terminal): use absolute positioning for content panels
All checks were successful
Beta Release / beta (push) Successful in 50s
height:100% on .content>div fails because .content uses flex:1
without explicit height. Switch to position:absolute;inset:0 which
correctly fills the content area and gives xterm proper container
dimensions for fitAddon.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-24 21:13:20 +02:00
Augustin
b85ebb8e54 feat(terminal): add Ctrl+Shift+C/V copy/paste shortcuts
All checks were successful
Beta Release / beta (push) Successful in 48s
xterm captures all keyboard input which prevents standard clipboard
operations. Add custom key handler to intercept Ctrl+Shift+C for
copy (selection) and Ctrl+Shift+V for paste, without interfering
with Ctrl+C (SIGINT) or browser devtools shortcut.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-24 21:10:24 +02:00
Augustin
7cc206dc20 fix(shell): prevent Enter in AI chat from leaking to terminal
All checks were successful
Beta Release / beta (push) Successful in 48s
Stop propagation of Enter keydown in AI input and defer terminal
focus to next event loop tick to prevent xterm from capturing the
same key event.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-24 21:07:36 +02:00
Augustin
bf8c0fd380 fix(terminal): improve terminal dimensions and fit timing
All checks were successful
Beta Release / beta (push) Successful in 47s
Use min-height:0 on xterm-wrapper (flex child) instead of height:100%
to properly fill available space in flex layout. Add delayed fit()
calls after initialization to let the layout stabilize before
calculating terminal cell dimensions.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-24 20:35:49 +02:00
Augustin
08dc1fd53b fix(terminal): detect shell tab visibility via MutationObserver
All checks were successful
Beta Release / beta (push) Successful in 49s
Shell is always mounted inside a display:none parent when the app
loads on a different tab. Added MutationObserver on the wrapper to
detect when the shell tab becomes visible and initialize/fit all
pending terminals at that moment. Removed attempt limit so retries
continue until the tab is actually shown.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-24 20:28:02 +02:00
Augustin
13e937a11b fix(terminal): init all tabs on load, fix excessive zoom
All checks were successful
Beta Release / beta (push) Successful in 46s
Use visibility:hidden instead of display:none for inactive terminal tabs
so xterm containers retain their dimensions. This allows all terminals
to initialize independently and prevents fitAddon from miscalculating
cell sizes on zero-height containers.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-24 20:13:21 +02:00
Augustin
3cf701b002 fix(terminal): improve tab visibility checks and positioning
All checks were successful
Beta Release / beta (push) Successful in 48s
- Add null check for container before accessing offsetHeight
- Validate activeTabRef during initialization and fit operations
- Check for display:none as visibility indicator
- Simplify useEffect dependency array
- Use absolute positioning for terminal wrapper/instance

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-24 17:59:48 +02:00
11 changed files with 378 additions and 136 deletions

View File

@@ -530,6 +530,11 @@ func (s *Server) handleProvidersQuota(w http.ResponseWriter, r *http.Request) {
}
}
}
case "mimo":
q.Healthy = p.APIKey != ""
if p.APIKey == "" {
q.Error = "no API key"
}
case "claude", "anthropic":
// Claude Code n'a pas d'API externe, vérifier l'installation
claudePath := "/usr/bin/claude"

View File

@@ -269,6 +269,12 @@ func Default() *MuyueConfig {
BaseURL: "https://api.minimax.io/v1",
Active: true,
},
{
Name: "mimo",
Model: "MiMo-V2.5-Pro",
BaseURL: "https://token-plan-ams.xiaomimimo.com/v1",
Active: false,
},
{
Name: "zai",
Model: "glm",

View File

@@ -476,6 +476,8 @@ func getProviderBaseURL(name string) string {
return "https://api.openai.com/v1"
case "zai":
return "https://api.z.ai/v1"
case "mimo":
return "https://token-plan-ams.xiaomimimo.com/v1"
default:
return ""
}
@@ -503,11 +505,19 @@ func (o *Orchestrator) sendWithFallback(reqBody ChatRequest, baseURLOverride str
if o.provider != nil {
providerOrder = append(providerOrder, o.provider)
}
var zaiProvider *config.AIProvider
for _, p := range providers {
if o.provider == nil || p.Name != o.provider.Name {
providerOrder = append(providerOrder, p)
if p.Name == "zai" {
zaiProvider = p
} else {
providerOrder = append(providerOrder, p)
}
}
}
if zaiProvider != nil {
providerOrder = append(providerOrder, zaiProvider)
}
var lastErr error
var triedProviders []string

View File

@@ -92,6 +92,8 @@ export default function App() {
{ keys: `${layout.keys.shift}+${layout.keys.enter}`, desc: t('statusbar.newLine') },
],
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.enter, desc: t('statusbar.runCommand') },
{ keys: `${layout.keys.up}/${layout.keys.down}`, desc: t('statusbar.commandHistory') },
],

View File

@@ -343,7 +343,7 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm
setValidating(null)
}
const displayed = providers.filter(p => p.name === 'minimax' || p.name === 'zai')
const displayed = providers.filter(p => p.name === 'minimax' || p.name === 'mimo')
return (
<div className="config-providers-list">

View File

@@ -91,7 +91,7 @@ export default function Dashboard({ api, refreshRef }) {
}, [loadData, refreshRef])
const minimax = (quota || []).find(p => p.name === 'minimax')
const zai = (quota || []).find(p => p.name === 'zai')
const mimo = (quota || []).find(p => p.name === 'mimo')
const EXCLUDE_CMDS = ['ls', 'cd', 'pwd', 'clear', 'exit', 'history', 'cat', 'echo', 'grep', 'export', 'alias', 'unalias', 'set', 'unset', 'source', '.', 'fg', 'bg', 'jobs', 'wait', 'true', 'false', 'yes', 'sleep', 'date', 'whoami', 'id', 'uname', 'hostname', 'uptime', 'df', 'free', 'top', 'htop', 'nano', 'vi', 'vim', 'less', 'more', 'tail', 'head', 'man', 'info', 'which', 'whereis', 'type', 'command', 'hash', 'builtin', 'help']
@@ -186,22 +186,22 @@ export default function Dashboard({ api, refreshRef }) {
<span className="dash-quota-val" style={{ color: 'var(--text-tertiary)' }}>{minimax.error || 'no data'}</span>
</div>
)}
{zai && zai.data?.models?.map((m, i) => (
{mimo && mimo.data?.models?.map((m, i) => (
<div key={i} className="dash-quota-row">
<span className="dash-quota-name">{String(m.model)}</span>
<span className="dash-quota-name">{String(m.model).replace('MiMo-', '')}</span>
<div className="dash-bar">
<div className="dash-bar-fill" style={{ width: `${Math.min(100, (m.used / m.total) * 100)}%` }} />
</div>
<span className="dash-quota-val">{m.used}/{m.total}</span>
</div>
))}
{zai && !zai.data?.models?.length && (
{mimo && !mimo.data?.models?.length && (
<div className="dash-quota-row">
<span className="dash-quota-name">Z.AI</span>
<span className="dash-quota-val" style={{ color: 'var(--text-tertiary)' }}>{zai.error || 'no data'}</span>
<span className="dash-quota-name">MiMo</span>
<span className="dash-quota-val" style={{ color: 'var(--text-tertiary)' }}>{mimo.error || (mimo.healthy ? '✓ configured' : 'no key')}</span>
</div>
)}
{!minimax && !zai && <span className="dash-quota-val" style={{ color: 'var(--text-tertiary)' }}>No providers</span>}
{!minimax && !mimo && <span className="dash-quota-val" style={{ color: 'var(--text-tertiary)' }}>No providers</span>}
</div>
</div>

View File

@@ -1,73 +1,68 @@
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
// === 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 formatText(text) {
let html = text
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
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 };
}
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/>')
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')}`;
}
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
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 +120,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,
@@ -143,6 +141,32 @@ function createTerminal(container, settings = {}) {
const webLinksAddon = new WebLinksAddon()
term.loadAddon(fitAddon)
term.loadAddon(webLinksAddon)
term.attachCustomKeyEventHandler((e) => {
if (e.type !== 'keydown') return true
const ctrl = e.ctrlKey || e.metaKey
const shift = e.shiftKey
if (ctrl && shift && e.key === 'C') {
e.preventDefault()
e.stopPropagation()
const selection = term.getSelection()
if (selection) navigator.clipboard.writeText(selection)
return false
}
if (ctrl && shift && e.key === 'V') {
e.preventDefault()
e.stopPropagation()
navigator.clipboard.readText().then(text => {
if (text) term.paste(text)
}).catch(() => {})
return false
}
return true
})
term.open(container)
fitAddon.fit()
@@ -156,9 +180,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)
})
@@ -203,29 +238,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 {}
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
} 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 1
})
const activeTabRef = useRef(activeTab)
@@ -239,7 +289,7 @@ 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',
})
useEffect(() => { settingsRef.current = terminalSettings }, [terminalSettings])
@@ -304,7 +354,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(() => {})
@@ -399,10 +449,7 @@ export default function Shell({ api }) {
})
const onResize = () => {
const el = document.getElementById(`terminal-${tabId}`)
if (el && el.style.display !== 'none') {
fitAddon.fit()
}
fitAddon.fit()
}
const resizeObserver = new ResizeObserver(onResize)
@@ -428,52 +475,129 @@ export default function Shell({ api }) {
}
}, [])
useEffect(() => {
const tab = tabs.find(t => t.id === activeTab)
if (!tab) return
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 tryInit = (attempt) => {
if (cancelled || attempt > 20) return
// 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
if (attempt > 20) {
console.warn(`[Shell] max attempts reached for tab ${tab.id}`)
return
}
forceLayout()
const shellCol = document.querySelector('.shell-terminal-col')
if (!shellCol || shellCol.offsetParent === null) {
pending.push(setTimeout(() => tryInit(attempt + 1), 150))
if (!shellCol) {
pending.push(setTimeout(() => tryInitTab(tab, attempt + 1), 150))
return
}
const container = document.getElementById(`terminal-${tab.id}`)
if (!container || container.offsetHeight === 0) {
pending.push(setTimeout(() => tryInit(attempt + 1), 100))
if (!container) {
pending.push(setTimeout(() => tryInitTab(tab, attempt + 1), 100))
return
}
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(() => {
if (cancelled) return
const entry = tabsRef.current[tab.id]
if (entry) entry.fitAddon.fit()
// 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 && entry.fitAddon) {
try {
entry.fitAddon.fit()
} catch (e) { console.warn(`[Shell] fit attempt ${delay}ms failed:`, e) }
}
}, delay)
})
}
tryInit(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()
}
}, [activeTab, tabs, initTerminal])
}, [tabs, initTerminal, initPendingTabs])
useEffect(() => {
const entry = tabsRef.current[activeTab]
if (entry) {
requestAnimationFrame(() => {
if (activeTabRef.current === activeTab) {
entry.fitAddon.fit()
}
})
}
}, [activeTab])
useEffect(() => {
const iv = setInterval(() => {
for (const tab of tabs) {
const entry = tabsRef.current[tab.id]
if (entry) {
const el = document.getElementById(`terminal-${tab.id}`)
if (el && el.style.display !== 'none') {
entry.fitAddon.fit()
}
}
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)
@@ -574,6 +698,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) => {
@@ -643,7 +780,7 @@ export default function Shell({ api }) {
if (!fromEvent) {
setAiInput('')
focusAiTerminal()
setTimeout(() => focusAiTerminal(), 0)
}
if (trimmed === '/clear') {
@@ -838,8 +975,7 @@ export default function Shell({ api }) {
<div
key={tab.id}
id={`terminal-${tab.id}`}
className="shell-xterm-instance"
style={{ display: activeTab === tab.id ? 'block' : 'none' }}
className={`shell-xterm-instance${activeTab === tab.id ? ' active' : ''}`}
/>
))}
</div>
@@ -888,7 +1024,7 @@ export default function Shell({ api }) {
<input
value={aiInput}
onChange={e => setAiInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleAiSend()}
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); e.stopPropagation(); handleAiSend() } }}
placeholder={aiAtLimit ? '/clear pour continuer' : t('shell.askAi')}
disabled={aiAtLimit && aiInput !== '/clear'}
/>

View File

@@ -452,15 +452,15 @@ export default function Studio({ api }) {
api.getProviders().then(data => {
const providers = data.providers || []
const minimax = providers.find(p => p.name.toUpperCase() === 'MINIMAX')
const zai = providers.find(p => p.name.toUpperCase() === 'ZAI')
if (!minimax || !zai) {
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: 'MiniMax et ZAI doivent être configurés pour utiliser `/model change`.', time: new Date().toISOString() }])
const mimo = providers.find(p => p.name.toUpperCase() === 'MIMO')
if (!minimax || !mimo) {
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: 'MiniMax et MiMo doivent être configurés pour utiliser `/model change`.', time: new Date().toISOString() }])
return
}
const active = providers.find(p => p.active)
const activeName = active ? active.name.toUpperCase() : ''
const switchTo = activeName === 'MINIMAX' ? 'ZAI' : 'MINIMAX'
const target = switchTo === 'MINIMAX' ? minimax : zai
const switchTo = activeName === 'MINIMAX' ? 'MIMO' : 'MINIMAX'
const target = switchTo === 'MINIMAX' ? minimax : mimo
api.saveProvider({ name: target.name, active: true }).then(() => {
setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: `✓ Provider changé: **${target.name}** (${target.model})`, time: new Date().toISOString() }])
}).catch(() => {

View File

@@ -16,6 +16,8 @@ const en = {
switchWindow: 'Switch window',
sendMessage: 'Send message',
newLine: 'New line',
copy: 'Copy',
paste: 'Paste',
runCommand: 'Run command',
commandHistory: 'Command history',
},

View File

@@ -16,6 +16,8 @@ const fr = {
switchWindow: 'Changer de fen\u00eatre',
sendMessage: 'Envoyer le message',
newLine: 'Nouvelle ligne',
copy: 'Copier',
paste: 'Coller',
runCommand: 'Ex\u00e9cuter',
commandHistory: 'Historique',
},

View File

@@ -155,7 +155,7 @@ input::placeholder { color: var(--text-disabled); }
.header-clock { font-family: var(--font-mono); font-size: 12px; color: var(--accent); font-weight: 600; }
.content { flex: 1; overflow: hidden; position: relative; }
.content > div { height: 100%; }
.content > div { position: absolute; inset: 0; overflow: hidden; }
.tab-hidden { display: none; }
.statusbar {
@@ -276,8 +276,8 @@ input::placeholder { color: var(--text-disabled); }
.sidebar-tab:hover { background: var(--bg-card); color: var(--text-primary); }
.sidebar-tab.active { background: var(--accent); color: #fff; font-weight: 600; }
.shell-layout { display: flex; height: 100%; }
.shell-terminal-col { flex: 1; display: flex; flex-direction: column; min-width: 0; min-height: 0; overflow: hidden; }
.shell-layout { display: flex; height: 100%; overflow: hidden; }
.shell-terminal-col { flex: 1; display: flex; flex-direction: column; min-width: 0; overflow: hidden; }
.shell-tabs-bar {
display: flex; align-items: center; background: var(--bg-surface);
@@ -382,12 +382,18 @@ input::placeholder { color: var(--text-disabled); }
}
.shell-menu-divider { height: 1px; background: var(--border); margin: 4px 6px; }
.shell-xterm-wrapper { flex: 1; background: var(--bg); overflow: hidden; position: relative; }
.shell-xterm-wrapper { flex: 1; min-height: 0; background: var(--bg); overflow: hidden; position: relative; }
.shell-xterm-instance {
height: 100%;
padding: 4px;
position: absolute;
inset: 0;
visibility: hidden;
pointer-events: none;
}
.shell-xterm-instance .xterm { height: 100%; padding: 4px; }
.shell-xterm-instance.active {
visibility: visible;
pointer-events: auto;
}
.shell-xterm-instance .xterm { height: 100%; }
.connection-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; flex-shrink: 0; }
.connection-dot.on { background: var(--success); box-shadow: 0 0 6px var(--success); }
@@ -396,7 +402,7 @@ input::placeholder { color: var(--text-disabled); }
.shell-tab.ai-tab .shell-tab-name { color: var(--accent); }
.shell-tab.ai-tab { border-bottom-color: var(--accent); }
.shell-ai-col { width: 320px; border-left: 1px solid var(--border); background: var(--bg-surface); display: flex; flex-direction: column; flex-shrink: 0; }
.shell-ai-col { width: 320px; max-width: 320px; border-left: 1px solid var(--border); background: var(--bg-surface); display: flex; flex-direction: column; flex-shrink: 0; overflow: hidden; }
.ai-panel-header { padding: 12px 16px; border-bottom: 1px solid var(--border); font-weight: 700; font-size: 13px; color: var(--accent); display: flex; align-items: center; justify-content: space-between; }
.shell-analyze-btn {
display: flex; align-items: center; gap: 4px;
@@ -1052,3 +1058,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;
}