Compare commits
30 Commits
v0.3.5-bet
...
v0.3.5-bet
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de52f4ebd6 | ||
|
|
98ff0dd578 | ||
|
|
9a1ff6e8dc | ||
|
|
034b9ee0e4 | ||
|
|
c1b1fc653f | ||
|
|
50ca75180c | ||
|
|
b8aa935bec | ||
|
|
5627ddd2ce | ||
|
|
d27872572a | ||
|
|
7d0f807fb0 | ||
|
|
cbf623b98b | ||
|
|
b85ebb8e54 | ||
|
|
7cc206dc20 | ||
|
|
bf8c0fd380 | ||
|
|
08dc1fd53b | ||
|
|
13e937a11b | ||
|
|
3cf701b002 | ||
|
|
3a09e0e0c2 | ||
|
|
47fa2e01bb | ||
|
|
401292ec5b | ||
|
|
199a7e409a | ||
|
|
c91931f42f | ||
|
|
cbbb224725 | ||
|
|
8d10d2182e | ||
|
|
e9696ef82b | ||
|
|
1edd4f053a | ||
|
|
92f943c3e6 | ||
|
|
1704b196cf | ||
|
|
40ec493bae | ||
|
|
233368c954 |
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/muyue/muyue/internal/agent"
|
||||
"github.com/muyue/muyue/internal/orchestrator"
|
||||
@@ -76,12 +75,8 @@ func (ce *ChatEngine) RunWithTools(ctx context.Context, messages []orchestrator.
|
||||
content := cleanThinkingTags(choice.Message.Content)
|
||||
|
||||
if content != "" {
|
||||
words := strings.Fields(content)
|
||||
for _, w := range words {
|
||||
chunk := w
|
||||
if ce.onChunk != nil {
|
||||
ce.onChunk(map[string]interface{}{"content": chunk})
|
||||
}
|
||||
if ce.onChunk != nil {
|
||||
ce.onChunk(map[string]interface{}{"content": content})
|
||||
}
|
||||
finalContent = content
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -146,13 +146,6 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
log.Printf("terminal: pty started successfully")
|
||||
defer func() {
|
||||
ptmx.Close()
|
||||
if cmd.Process != nil {
|
||||
cmd.Process.Kill()
|
||||
cmd.Wait()
|
||||
}
|
||||
}()
|
||||
|
||||
var once sync.Once
|
||||
cleanup := func() {
|
||||
@@ -164,6 +157,7 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
})
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
go func() {
|
||||
buf := make([]byte, 4096)
|
||||
@@ -171,8 +165,6 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
|
||||
n, err := ptmx.Read(buf)
|
||||
if err != nil {
|
||||
cleanup()
|
||||
conn.WriteMessage(websocket.CloseMessage,
|
||||
websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""))
|
||||
return
|
||||
}
|
||||
if err := conn.WriteJSON(wsMessage{
|
||||
@@ -230,12 +222,11 @@ func (s *Server) handleTerminalSessions(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
User string `json:"user"`
|
||||
Password string `json:"password"`
|
||||
KeyPath string `json:"key_path"`
|
||||
Name string `json:"name"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
User string `json:"user"`
|
||||
KeyPath string `json:"key_path"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, err.Error(), http.StatusBadRequest)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -105,7 +105,7 @@ const api = {
|
||||
}).catch(reject)
|
||||
})
|
||||
},
|
||||
sendShellChat: (message, context = {}, stream = true, onChunk) => {
|
||||
sendShellChat: (message, context = {}, stream = true, onChunk, signal) => {
|
||||
const payload = {
|
||||
message,
|
||||
cwd: context.cwd || '',
|
||||
@@ -120,6 +120,7 @@ const api = {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
signal,
|
||||
}).then(async (res) => {
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: res.statusText }))
|
||||
|
||||
@@ -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') },
|
||||
],
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { User, Brain, RefreshCw, Wrench, Monitor, AlertTriangle, X } from 'lucide-react'
|
||||
import { User, Brain, RefreshCw, Wrench, Monitor, AlertTriangle } from 'lucide-react'
|
||||
import { useI18n } from '../i18n'
|
||||
|
||||
const PANELS = [
|
||||
@@ -311,16 +311,6 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm
|
||||
const [validating, setValidating] = useState(null)
|
||||
const [keyStatus, setKeyStatus] = useState({})
|
||||
|
||||
useEffect(() => {
|
||||
providers.forEach(p => {
|
||||
if (p.apiKey && !keyStatus[p.name]) {
|
||||
validateKey(p)
|
||||
} else if (!p.apiKey) {
|
||||
setKeyStatus(prev => ({ ...prev, [p.name]: { valid: false, checked: true, error: 'Aucune clé' } }))
|
||||
}
|
||||
})
|
||||
}, [providers])
|
||||
|
||||
const validateKey = async (p) => {
|
||||
setValidating(p.name)
|
||||
try {
|
||||
@@ -332,6 +322,16 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm
|
||||
setValidating(null)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
providers.forEach(p => {
|
||||
if (p.apiKey && !keyStatus[p.name]) {
|
||||
validateKey(p)
|
||||
} else if (!p.apiKey) {
|
||||
setKeyStatus(prev => ({ ...prev, [p.name]: { valid: false, checked: true, error: 'Aucune clé' } }))
|
||||
}
|
||||
})
|
||||
}, [providers])
|
||||
|
||||
const handleValidate = async (name, apiKey, model, baseUrl) => {
|
||||
setValidating(name)
|
||||
try {
|
||||
@@ -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">
|
||||
@@ -412,7 +412,7 @@ function PanelUpdates({ updates, tools, checking, updating, needsUpdateCount, in
|
||||
window.dispatchEvent(new CustomEvent('ask-ai-terminal', { detail: { message: `Installe l'outil ${tool} sur mon système. Vérifie d'abord s'il est déjà installé, puis installe-le si nécessaire avec les commandes appropriées.` } }))
|
||||
}
|
||||
|
||||
const missingTools = tools.filter(t => !t.installed)
|
||||
const missingTools = tools.filter(tool => !tool.installed)
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -43,7 +43,7 @@ export default function Dashboard({ api, refreshRef }) {
|
||||
const [recentCmds, setRecentCmds] = useState([])
|
||||
const [processes, setProcesses] = useState([])
|
||||
const [metrics, setMetrics] = useState(null)
|
||||
const [copiedIdx, setCopiedIdx] = useState(-1)
|
||||
const [copiedSet, setCopiedSet] = useState(new Set())
|
||||
const cpuRef = useRef([])
|
||||
const memRef = useRef([])
|
||||
const netRxRef = useRef([])
|
||||
@@ -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']
|
||||
|
||||
@@ -109,6 +109,32 @@ export default function Dashboard({ api, refreshRef }) {
|
||||
.map(([cmd, count]) => ({ cmd, count }))
|
||||
})()
|
||||
|
||||
const maxCount = topCmds.length > 0 ? topCmds[0].count : 1
|
||||
|
||||
const copyCmd = (cmd, key) => {
|
||||
navigator.clipboard.writeText(cmd)
|
||||
setCopiedSet(prev => new Set(prev).add(key))
|
||||
setTimeout(() => setCopiedSet(prev => { const next = new Set(prev); next.delete(key); return next }), 1500)
|
||||
}
|
||||
|
||||
const relativeTime = (ts) => {
|
||||
if (!ts) return ''
|
||||
const diff = Math.floor((Date.now() - new Date(ts).getTime()) / 1000)
|
||||
if (diff < 60) return `${diff}s`
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m`
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h`
|
||||
return `${Math.floor(diff / 86400)}d`
|
||||
}
|
||||
|
||||
const recentUnique = (() => {
|
||||
const seen = new Set()
|
||||
return recentCmds.filter(c => {
|
||||
if (seen.has(c.cmd)) return false
|
||||
seen.add(c.cmd)
|
||||
return true
|
||||
})
|
||||
})()
|
||||
|
||||
return (
|
||||
<div className="dash-grid">
|
||||
{/* CPU */}
|
||||
@@ -160,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>
|
||||
|
||||
@@ -197,26 +223,34 @@ export default function Dashboard({ api, refreshRef }) {
|
||||
</div>
|
||||
|
||||
{/* Recent Commands */}
|
||||
<div className="dash-card">
|
||||
<div className="dash-card dash-cmd-card">
|
||||
<div className="dash-card-head">
|
||||
<span className="dash-label">Recent Commands</span>
|
||||
<span className="dash-count">{recentUnique.length}</span>
|
||||
</div>
|
||||
{topCmds.length > 0 && (
|
||||
<div className="dash-cmd-top">
|
||||
<div className="dash-cmd-freq">
|
||||
<span className="dash-cmd-freq-title">Most used</span>
|
||||
{topCmds.map((c, i) => (
|
||||
<div key={i} className={'dash-cmd-chip' + (copiedIdx === i ? ' dash-cmd-chip-copied' : '')} onClick={() => { navigator.clipboard.writeText(c.cmd); setCopiedIdx(i); setTimeout(() => setCopiedIdx(-1), 1200); }}>
|
||||
<span className="dash-cmd-chip-name">{copiedIdx === i ? '✓ Copié' : c.cmd}</span>
|
||||
<span className="dash-cmd-chip-count">{c.count}×</span>
|
||||
<div key={i} className="dash-cmd-freq-row" onClick={() => copyCmd(c.cmd, `top-${i}`)} title={c.cmd}>
|
||||
<span className="dash-cmd-freq-name">{copiedSet.has(`top-${i}`) ? '✓ Copié' : c.cmd}</span>
|
||||
<div className="dash-cmd-freq-bar-wrap">
|
||||
<div className="dash-cmd-freq-bar" style={{ width: `${(c.count / maxCount) * 100}%` }} />
|
||||
</div>
|
||||
<span className="dash-cmd-freq-count">{c.count}×</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="dash-cmd-list">
|
||||
{recentCmds.length === 0 && <span className="dash-empty">No history</span>}
|
||||
{recentCmds.map((c, i) => (
|
||||
<div key={i} className="dash-cmd-row" title={c.cmd}>
|
||||
<span className="dash-cmd-shell">{c.shell}</span>
|
||||
<span className="dash-cmd-text">{c.cmd.length > 45 ? c.cmd.slice(0, 42) + '...' : c.cmd}</span>
|
||||
{recentUnique.length === 0 && <span className="dash-empty">No history</span>}
|
||||
{recentUnique.map((c, i) => (
|
||||
<div key={i} className="dash-cmd-row" onClick={() => copyCmd(c.cmd, `list-${i}`)} title={c.cmd + ' · click to copy'}>
|
||||
<div className="dash-cmd-left">
|
||||
<span className="dash-cmd-text">{c.cmd.length > 38 ? c.cmd.slice(0, 35) + '...' : c.cmd}</span>
|
||||
<span className="dash-cmd-time">{relativeTime(c.ts)}</span>
|
||||
</div>
|
||||
<span className="dash-cmd-copy">{copiedSet.has(`list-${i}`) ? '✓' : '⎘'}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
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 { 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 +68,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 +189,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,25 +210,68 @@ 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()
|
||||
|
||||
return { term, fitAddon }
|
||||
}
|
||||
|
||||
function connectWebSocket(term, fitAddon, initPayload) {
|
||||
function connectWebSocket(term, fitAddon, initPayload, onStateChange, onFirstMessage) {
|
||||
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const ws = new WebSocket(`${proto}//${window.location.host}/api/ws/terminal`)
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
let firstMessage = true
|
||||
ws.addEventListener('message', (event) => {
|
||||
if (firstMessage) {
|
||||
firstMessage = false
|
||||
if (onFirstMessage) onFirstMessage()
|
||||
}
|
||||
try {
|
||||
const msg = JSON.parse(event.data)
|
||||
if (msg.type === 'output') {
|
||||
@@ -176,16 +286,12 @@ function connectWebSocket(term, fitAddon, initPayload) {
|
||||
|
||||
ws.addEventListener('close', () => {
|
||||
term.write('\r\n\x1b[33m— Connection closed —\x1b[0m\r\n')
|
||||
if (onStateChange) onStateChange(false)
|
||||
})
|
||||
|
||||
ws.addEventListener('error', () => {
|
||||
term.write('\r\n\x1b[31m— Connection error —\x1b[0m\r\n')
|
||||
})
|
||||
|
||||
term.onData((data) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'input', data }))
|
||||
}
|
||||
if (onStateChange) onStateChange(false)
|
||||
})
|
||||
|
||||
term.onResize(({ rows, cols }) => {
|
||||
@@ -201,30 +307,48 @@ 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)
|
||||
useEffect(() => { activeTabRef.current = activeTab }, [activeTab])
|
||||
const [sshConnections, setSshConnections] = useState([])
|
||||
const [systemTerminals, setSystemTerminals] = useState([])
|
||||
const [showMenu, setShowMenu] = useState(false)
|
||||
@@ -234,7 +358,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])
|
||||
@@ -253,6 +377,7 @@ export default function Shell({ api }) {
|
||||
const [analysisContent, setAnalysisContent] = useState('')
|
||||
const aiMessagesRef = useRef(null)
|
||||
const aiLoadedRef = useRef(false)
|
||||
const aiLoadingRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
aiMessagesRef.current?.scrollTo(0, aiMessagesRef.current.scrollHeight)
|
||||
@@ -298,7 +423,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(() => {})
|
||||
@@ -335,15 +460,7 @@ export default function Shell({ api }) {
|
||||
}
|
||||
}
|
||||
|
||||
const ws = connectWebSocket(term, fitAddon, initPayload)
|
||||
|
||||
// Restore saved terminal buffer
|
||||
try {
|
||||
const savedBuffers = JSON.parse(localStorage.getItem(TERMINAL_BUFFER_KEY) || '{}')
|
||||
if (savedBuffers[tabId]) {
|
||||
term.write(savedBuffers[tabId])
|
||||
}
|
||||
} catch {}
|
||||
let disposed = false
|
||||
|
||||
const saveBuffer = () => {
|
||||
try {
|
||||
@@ -353,106 +470,222 @@ export default function Shell({ api }) {
|
||||
const line = buf.getLine(i)
|
||||
if (line) lines.push(line.translateToString(true))
|
||||
}
|
||||
const savedBuffers = JSON.parse(localStorage.getItem(TERMINAL_BUFFER_KEY) || '{}')
|
||||
const savedBuffers = JSON.parse(sessionStorage.getItem(TERMINAL_BUFFER_KEY) || '{}')
|
||||
savedBuffers[tabId] = lines.join('\n')
|
||||
localStorage.setItem(TERMINAL_BUFFER_KEY, JSON.stringify(savedBuffers))
|
||||
} catch {}
|
||||
sessionStorage.setItem(TERMINAL_BUFFER_KEY, JSON.stringify(savedBuffers))
|
||||
} catch (e) { console.warn('[Shell] Buffer save failed:', e) }
|
||||
}
|
||||
|
||||
const bufferSaveInterval = setInterval(saveBuffer, 5000)
|
||||
const onWsState = (connected) => {
|
||||
if (disposed) return
|
||||
if (!connected) saveBuffer()
|
||||
setTabs(prev => prev.map(t => t.id === tabId ? { ...t, connected } : t))
|
||||
}
|
||||
|
||||
// Detect clear command to wipe saved buffer
|
||||
let inputBuffer = ''
|
||||
term.onData((data) => {
|
||||
if (data === '\r') {
|
||||
const cmd = inputBuffer.replace(/[\x1b\x00-\x1f]/g, '').trim().toLowerCase()
|
||||
if (cmd === 'clear') {
|
||||
try {
|
||||
const savedBuffers = JSON.parse(localStorage.getItem(TERMINAL_BUFFER_KEY) || '{}')
|
||||
delete savedBuffers[tabId]
|
||||
localStorage.setItem(TERMINAL_BUFFER_KEY, JSON.stringify(savedBuffers))
|
||||
} catch {}
|
||||
const restoreBuffer = () => {
|
||||
try {
|
||||
const savedBuffers = JSON.parse(sessionStorage.getItem(TERMINAL_BUFFER_KEY) || '{}')
|
||||
if (savedBuffers[tabId]) {
|
||||
term.write('\x1b[90m— session restaurée —\x1b[0m\r\n')
|
||||
term.write(savedBuffers[tabId])
|
||||
}
|
||||
inputBuffer = ''
|
||||
} else if (data === '\x7f' || data === '\b') {
|
||||
inputBuffer = inputBuffer.slice(0, -1)
|
||||
} else if (data === '\x03') {
|
||||
inputBuffer = ''
|
||||
} else if (data.length === 1 && data.charCodeAt(0) >= 32) {
|
||||
inputBuffer += data
|
||||
} catch (e) { console.warn('[Shell] Buffer restore failed:', e) }
|
||||
}
|
||||
|
||||
const ws = connectWebSocket(term, fitAddon, initPayload, onWsState, restoreBuffer)
|
||||
|
||||
const clearBufferOnClear = () => {
|
||||
try {
|
||||
const buf = term.buffer.active
|
||||
const lineY = buf.length - 1
|
||||
const line = buf.getLine(lineY)
|
||||
if (line) {
|
||||
const text = line.translateToString(true).trim().toLowerCase()
|
||||
if (text === 'clear' || text === '$ clear' || text.endsWith(' clear')) {
|
||||
const savedBuffers = JSON.parse(sessionStorage.getItem(TERMINAL_BUFFER_KEY) || '{}')
|
||||
delete savedBuffers[tabId]
|
||||
sessionStorage.setItem(TERMINAL_BUFFER_KEY, JSON.stringify(savedBuffers))
|
||||
}
|
||||
}
|
||||
} catch (e) { console.warn('[Shell] Clear detection failed:', e) }
|
||||
}
|
||||
|
||||
term.onData((data) => {
|
||||
if (data === '\r') clearBufferOnClear()
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'input', data }))
|
||||
}
|
||||
})
|
||||
|
||||
ws.onopen = () => {
|
||||
setTabs(prev => prev.map(t => t.id === tabId ? { ...t, connected: true } : t))
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
saveBuffer()
|
||||
setTabs(prev => prev.map(t => t.id === tabId ? { ...t, connected: false } : t))
|
||||
}
|
||||
|
||||
ws.onerror = () => {
|
||||
setTabs(prev => prev.map(t => t.id === tabId ? { ...t, connected: false } : t))
|
||||
}
|
||||
|
||||
const onResize = () => {
|
||||
const el = document.getElementById(`terminal-${tabId}`)
|
||||
if (el && el.offsetParent !== null) {
|
||||
fitAddon.fit()
|
||||
}
|
||||
fitAddon.fit()
|
||||
}
|
||||
|
||||
const resizeObserver = new ResizeObserver(onResize)
|
||||
resizeObserver.observe(container)
|
||||
window.addEventListener('resize', onResize)
|
||||
|
||||
tabsRef.current[tabId] = { term, fitAddon, ws, resizeObserver, onResize, bufferSaveInterval, saveBuffer }
|
||||
const bufferSaveInterval = setInterval(() => { if (!disposed) saveBuffer() }, 5000)
|
||||
|
||||
console.log(`[Shell] initTerminal tab=${tabId} type=${tab.type} name="${tab.name}" shell="${tab.shell || '(default)'}"`)
|
||||
tabsRef.current[tabId] = { term, fitAddon, ws, resizeObserver, onResize, bufferSaveInterval, saveBuffer, disposed: () => disposed }
|
||||
tabsRef.current[tabId]._markDisposed = () => { disposed = true }
|
||||
console.log(`[Shell] initTerminal tab=${tabId} done, tabsRef keys:`, Object.keys(tabsRef.current))
|
||||
|
||||
const pending = pendingCommandsRef.current[tabId]
|
||||
if (pending && pending.length > 0) {
|
||||
console.log(`[Shell] Flushing ${pending.length} pending commands for tab ${tabId}`)
|
||||
for (const cmd of pending) {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'input', data: cmd + '\r' }))
|
||||
}
|
||||
}
|
||||
delete pendingCommandsRef.current[tabId]
|
||||
}
|
||||
}, [])
|
||||
|
||||
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])
|
||||
|
||||
const tryInit = (attempt) => {
|
||||
if (attempt > 20) return
|
||||
useEffect(() => {
|
||||
tabsRef.current._tabList = tabs
|
||||
}, [tabs])
|
||||
|
||||
useEffect(() => {
|
||||
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
|
||||
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) {
|
||||
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) {
|
||||
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(() => {
|
||||
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)
|
||||
}, [activeTab, tabs, initTerminal])
|
||||
const wrapper = document.querySelector('.shell-layout')?.parentElement
|
||||
let observer
|
||||
if (wrapper) {
|
||||
observer = new MutationObserver(() => {
|
||||
if (!wrapper.classList.contains('tab-hidden') && wrapper.offsetParent !== null) {
|
||||
initPendingTabs()
|
||||
}
|
||||
})
|
||||
observer.observe(wrapper, { attributes: true, attributeFilter: ['class'] })
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
pending.forEach(clearTimeout)
|
||||
observer?.disconnect()
|
||||
}
|
||||
}, [tabs, initTerminal, initPendingTabs])
|
||||
|
||||
useEffect(() => {
|
||||
const entry = tabsRef.current[activeTab]
|
||||
if (entry) {
|
||||
requestAnimationFrame(() => {
|
||||
if (activeTabRef.current === activeTab) {
|
||||
entry.fitAddon.fit()
|
||||
}
|
||||
})
|
||||
}
|
||||
}, [activeTab])
|
||||
|
||||
useEffect(() => {
|
||||
const iv = setInterval(() => {
|
||||
for (const tab of tabs) {
|
||||
const entry = tabsRef.current[tab.id]
|
||||
if (entry) {
|
||||
const el = document.getElementById(`terminal-${tab.id}`)
|
||||
if (el && el.offsetParent !== null) {
|
||||
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)
|
||||
}, [tabs])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
for (const [tabId, entry] of Object.entries(tabsRef.current)) {
|
||||
entry._markDisposed?.()
|
||||
if (entry.bufferSaveInterval) clearInterval(entry.bufferSaveInterval)
|
||||
window.removeEventListener('resize', entry.onResize)
|
||||
entry.resizeObserver?.disconnect()
|
||||
entry.ws?.close()
|
||||
entry.term?.dispose()
|
||||
}
|
||||
tabsRef.current = {}
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e) => {
|
||||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return
|
||||
@@ -507,27 +740,46 @@ export default function Shell({ api }) {
|
||||
|
||||
const closeTab = (tabId, e) => {
|
||||
if (e) e.stopPropagation()
|
||||
const tab = tabs.find(t => t.id === tabId)
|
||||
if (!tab || tabs.length <= 1) return
|
||||
|
||||
if (tabsRef.current[tabId]) {
|
||||
const { ws, resizeObserver, onResize, term, bufferSaveInterval, saveBuffer } = tabsRef.current[tabId]
|
||||
if (saveBuffer) saveBuffer()
|
||||
if (bufferSaveInterval) clearInterval(bufferSaveInterval)
|
||||
window.removeEventListener('resize', onResize)
|
||||
resizeObserver.disconnect()
|
||||
ws.close()
|
||||
term.dispose()
|
||||
const entry = tabsRef.current[tabId]
|
||||
if (entry) {
|
||||
entry._markDisposed?.()
|
||||
entry.saveBuffer?.()
|
||||
if (entry.bufferSaveInterval) clearInterval(entry.bufferSaveInterval)
|
||||
window.removeEventListener('resize', entry.onResize)
|
||||
entry.resizeObserver.disconnect()
|
||||
entry.ws.close()
|
||||
entry.term.dispose()
|
||||
delete tabsRef.current[tabId]
|
||||
}
|
||||
|
||||
try {
|
||||
const savedBuffers = JSON.parse(sessionStorage.getItem(TERMINAL_BUFFER_KEY) || '{}')
|
||||
delete savedBuffers[tabId]
|
||||
sessionStorage.setItem(TERMINAL_BUFFER_KEY, JSON.stringify(savedBuffers))
|
||||
} catch (e) { console.warn('[Shell] Buffer cleanup failed:', e) }
|
||||
|
||||
setTabs(prev => {
|
||||
if (prev.length <= 1) return prev
|
||||
const next = prev.filter(t => t.id !== tabId)
|
||||
if (activeTab === tabId && next.length > 0) {
|
||||
setActiveTab(next[next.length - 1].id)
|
||||
}
|
||||
return next
|
||||
})
|
||||
|
||||
// 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) => {
|
||||
@@ -566,129 +818,105 @@ export default function Shell({ api }) {
|
||||
}
|
||||
}
|
||||
|
||||
const sendToTerminal = useCallback((code) => {
|
||||
const entry = tabsRef.current[activeTab]
|
||||
if (entry?.ws && entry.ws.readyState === WebSocket.OPEN) {
|
||||
entry.ws.send(JSON.stringify({ type: 'input', data: code + '\r' }))
|
||||
const sendToTerminal = useCallback((code, tabId) => {
|
||||
const targetId = tabId || activeTabRef.current
|
||||
const entry = tabsRef.current[targetId]
|
||||
if (!entry) {
|
||||
console.warn(`[Shell] sendToTerminal: tab ${targetId} not ready. Queueing. tabsRef:`, Object.keys(tabsRef.current), 'activeTab:', activeTabRef.current, 'requested:', tabId)
|
||||
if (!pendingCommandsRef.current[targetId]) pendingCommandsRef.current[targetId] = []
|
||||
pendingCommandsRef.current[targetId].push(code)
|
||||
return
|
||||
}
|
||||
}, [activeTab])
|
||||
if (!entry.ws || entry.ws.readyState !== WebSocket.OPEN) {
|
||||
console.warn(`[Shell] sendToTerminal: WS not open for tab ${targetId} (state=${entry.ws?.readyState}). Queueing.`)
|
||||
if (!pendingCommandsRef.current[targetId]) pendingCommandsRef.current[targetId] = []
|
||||
pendingCommandsRef.current[targetId].push(code)
|
||||
return
|
||||
}
|
||||
console.log(`[Shell] sendToTerminal: tab ${targetId} ← ${code.length} chars`)
|
||||
entry.ws.send(JSON.stringify({ type: 'input', data: code + '\r' }))
|
||||
}, [])
|
||||
|
||||
const focusAiTerminal = useCallback(() => {
|
||||
const entry = tabsRef.current[activeTab]
|
||||
const entry = tabsRef.current[activeTabRef.current]
|
||||
if (entry) entry.term.focus()
|
||||
}, [activeTab])
|
||||
}, [])
|
||||
|
||||
const handleAiSend = async () => {
|
||||
if (!aiInput.trim() || aiLoading || aiAtLimit) return
|
||||
const text = aiInput.trim()
|
||||
setAiInput('')
|
||||
focusAiTerminal()
|
||||
const _sendAiMessage = useCallback(async (text, fromEvent = false) => {
|
||||
if (!text || !text.trim() || aiLoadingRef.current || aiAtLimit) return
|
||||
const trimmed = text.trim()
|
||||
aiLoadingRef.current = true
|
||||
|
||||
if (text === '/clear') {
|
||||
if (!fromEvent) {
|
||||
setAiInput('')
|
||||
setTimeout(() => focusAiTerminal(), 0)
|
||||
}
|
||||
|
||||
if (trimmed === '/clear') {
|
||||
try {
|
||||
await api.clearShellChat()
|
||||
setAiMessages([{ role: 'assistant', content: t('shell.aiWelcome') || 'Contexte effacé. Prêt.' }])
|
||||
setAiTokens(0)
|
||||
setAiAtLimit(false)
|
||||
} catch {}
|
||||
aiLoadingRef.current = false
|
||||
return
|
||||
}
|
||||
|
||||
if (text === '/help') {
|
||||
if (trimmed === '/help') {
|
||||
setAiMessages(prev => [...prev,
|
||||
{ role: 'user', content: text },
|
||||
{ role: 'user', content: trimmed },
|
||||
{ role: 'assistant', content: 'Commandes disponibles:\n• /clear — Effacer la conversation\n• /help — Afficher l\'aide\n\nJe ne peux pas exécuter de code. Les blocs de code proposés peuvent être copiés ou envoyés directement au terminal actif.' }
|
||||
])
|
||||
aiLoadingRef.current = false
|
||||
return
|
||||
}
|
||||
|
||||
setAiMessages(prev => [...prev, { role: 'user', content: text }])
|
||||
const currentTab = activeTabRef.current
|
||||
console.log(`[Shell] _sendAiMessage: activeTab=${currentTab}, fromEvent=${fromEvent}, text="${trimmed.slice(0, 50)}"`)
|
||||
setAiMessages(prev => [...prev, { role: 'user', content: trimmed, _tabId: currentTab }])
|
||||
setAiLoading(true)
|
||||
|
||||
try {
|
||||
let accumulated = ''
|
||||
await api.sendShellChat(text, {}, true, (partial) => {
|
||||
await api.sendShellChat(trimmed, {}, true, (partial) => {
|
||||
accumulated = partial
|
||||
setAiMessages(prev => {
|
||||
const filtered = prev.filter(m => !m._streaming)
|
||||
return [...filtered, { role: 'assistant', content: partial, _streaming: true }]
|
||||
return [...filtered, { role: 'assistant', content: partial, _streaming: true, _tabId: currentTab }]
|
||||
})
|
||||
})
|
||||
|
||||
setAiMessages(prev => {
|
||||
const filtered = prev.filter(m => !m._streaming)
|
||||
return [...filtered, { role: 'assistant', content: accumulated }]
|
||||
})
|
||||
// Refresh token count
|
||||
api.getShellChatHistory().then(d => {
|
||||
setAiTokens(d.tokens || 0)
|
||||
setAiAtLimit(d.at_limit || false)
|
||||
}).catch(() => {})
|
||||
} catch (err) {
|
||||
if (err.message.includes('context limit')) {
|
||||
setAiAtLimit(true)
|
||||
}
|
||||
setAiMessages(prev => [...prev.filter(m => !m._streaming), { role: 'assistant', content: `Erreur: ${err.message}` }])
|
||||
}
|
||||
setAiLoading(false)
|
||||
}
|
||||
|
||||
const handleAiSendDirect = useCallback(async (text) => {
|
||||
if (!text || aiLoading || aiAtLimit) return
|
||||
setAiInput('')
|
||||
|
||||
if (text === '/clear') {
|
||||
try {
|
||||
await api.clearShellChat()
|
||||
setAiMessages([{ role: 'assistant', content: t('shell.aiWelcome') || 'Contexte effacé. Prêt.' }])
|
||||
setAiTokens(0)
|
||||
setAiAtLimit(false)
|
||||
} catch {}
|
||||
return
|
||||
}
|
||||
|
||||
setAiMessages(prev => [...prev, { role: 'user', content: text }])
|
||||
setAiLoading(true)
|
||||
|
||||
try {
|
||||
let accumulated = ''
|
||||
await api.sendShellChat(text, {}, true, (partial) => {
|
||||
accumulated = partial
|
||||
setAiMessages(prev => {
|
||||
const filtered = prev.filter(m => !m._streaming)
|
||||
return [...filtered, { role: 'assistant', content: partial, _streaming: true }]
|
||||
})
|
||||
})
|
||||
|
||||
setAiMessages(prev => {
|
||||
const filtered = prev.filter(m => !m._streaming)
|
||||
return [...filtered, { role: 'assistant', content: accumulated }]
|
||||
return [...filtered, { role: 'assistant', content: accumulated, _tabId: currentTab }]
|
||||
})
|
||||
api.getShellChatHistory().then(d => {
|
||||
setAiTokens(d.tokens || 0)
|
||||
setAiAtLimit(d.at_limit || false)
|
||||
}).catch(() => {})
|
||||
} catch (err) {
|
||||
if (err.message.includes('context limit')) {
|
||||
if (err.message?.includes('context limit')) {
|
||||
setAiAtLimit(true)
|
||||
}
|
||||
setAiMessages(prev => [...prev.filter(m => !m._streaming), { role: 'assistant', content: `Erreur: ${err.message}` }])
|
||||
}
|
||||
setAiLoading(false)
|
||||
}, [api, t, aiLoading, aiAtLimit])
|
||||
aiLoadingRef.current = false
|
||||
}, [api, t, aiAtLimit, focusAiTerminal])
|
||||
|
||||
const handleAiSend = () => _sendAiMessage(aiInput, false)
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e) => {
|
||||
const msg = e.detail?.message
|
||||
if (!msg) return
|
||||
setAiInput(msg)
|
||||
setTimeout(() => {
|
||||
handleAiSendDirect(msg)
|
||||
}, 100)
|
||||
setTimeout(() => _sendAiMessage(msg, true), 100)
|
||||
}
|
||||
window.addEventListener('ask-ai-terminal', handler)
|
||||
return () => window.removeEventListener('ask-ai-terminal', handler)
|
||||
}, [handleAiSendDirect])
|
||||
}, [_sendAiMessage])
|
||||
|
||||
const handleAnalyze = async () => {
|
||||
setAnalyzing(true)
|
||||
@@ -816,8 +1044,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>
|
||||
@@ -858,7 +1085,7 @@ export default function Shell({ api }) {
|
||||
</div>
|
||||
<div className="ai-panel-messages" ref={aiMessagesRef}>
|
||||
{aiMessages.map((msg, i) => (
|
||||
<ShellAIMessage key={i} msg={msg} sendToTerminal={sendToTerminal} />
|
||||
<ShellAIMessage key={i} msg={msg} sendToTerminal={sendToTerminal} terminalTabId={msg._tabId || activeTab} />
|
||||
))}
|
||||
{aiLoading && <div style={{ textAlign: 'center', padding: 8 }}><span className="spinner" /></div>}
|
||||
</div>
|
||||
@@ -866,7 +1093,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'}
|
||||
/>
|
||||
@@ -950,7 +1177,7 @@ export default function Shell({ api }) {
|
||||
)
|
||||
}
|
||||
|
||||
function ShellAIMessage({ msg, sendToTerminal }) {
|
||||
function ShellAIMessage({ msg, sendToTerminal, terminalTabId }) {
|
||||
const role = msg.role === 'user' ? 'user' : msg.role === 'system' ? 'system' : 'assistant'
|
||||
const content = msg.content || ''
|
||||
|
||||
@@ -976,7 +1203,7 @@ function ShellAIMessage({ msg, sendToTerminal }) {
|
||||
<button onClick={() => navigator.clipboard.writeText(part.content)} title="Copier">
|
||||
<Copy size={12} /> Copier
|
||||
</button>
|
||||
<button onClick={() => sendToTerminal(part.content)} title="Envoyer au terminal">
|
||||
<button onClick={() => sendToTerminal(part.content, terminalTabId)} title="Envoyer au terminal">
|
||||
<Send size={12} /> Terminal
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -197,7 +197,7 @@ function FeedItem({ msg }) {
|
||||
)
|
||||
}
|
||||
|
||||
const cleanContent = displayContent.replace(/<think[^>]*>[\s\S]*?<\/think>/gi, '')
|
||||
let cleanContent = displayContent.replace(/<think[^>]*>[\s\S]*?<\/think>/gi, '')
|
||||
|
||||
return (
|
||||
<div className={`feed-item ${msg.role}`}>
|
||||
@@ -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(() => {
|
||||
@@ -532,6 +532,8 @@ export default function Studio({ api }) {
|
||||
if (event && event.tool_call) {
|
||||
toolCalls = [...toolCalls, { call: event.tool_call, result: null }]
|
||||
setStreamToolCalls([...toolCalls])
|
||||
accumulated = ''
|
||||
setStreaming('')
|
||||
return
|
||||
}
|
||||
if (event && event.tool_result) {
|
||||
@@ -558,6 +560,11 @@ export default function Studio({ api }) {
|
||||
aiMsg.content = JSON.stringify({
|
||||
content: finalContent,
|
||||
tool_calls: toolCalls.map(tc => tc.call),
|
||||
tool_results: toolCalls.map(tc => ({
|
||||
tool_call_id: tc.call?.tool_call_id,
|
||||
result: tc.result?.content || '',
|
||||
is_error: tc.result?.is_error || false,
|
||||
})),
|
||||
})
|
||||
}
|
||||
setMessages(prev => [...prev, aiMsg])
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
position: absolute; inset: 0; padding: 4px;
|
||||
display: block !important;
|
||||
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;
|
||||
@@ -691,34 +697,38 @@ input::placeholder { color: var(--text-disabled); }
|
||||
}
|
||||
|
||||
/* Commands */
|
||||
.dash-cmd-list { display: flex; flex-direction: column; gap: 3px; max-height: 270px; overflow-y: auto; }
|
||||
.dash-cmd-card .dash-cmd-list { max-height: 220px; }
|
||||
.dash-cmd-list { display: flex; flex-direction: column; gap: 2px; overflow-y: auto; }
|
||||
.dash-cmd-row {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
padding: 3px 0; overflow: hidden;
|
||||
}
|
||||
.dash-cmd-shell {
|
||||
font-size: 9px; font-family: var(--font-mono); color: var(--text-disabled);
|
||||
background: var(--bg-input); padding: 1px 4px; border-radius: 3px;
|
||||
text-transform: uppercase; flex-shrink: 0;
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 8px;
|
||||
padding: 5px 8px; border-radius: var(--radius-sm);
|
||||
background: var(--bg-surface); cursor: pointer;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
.dash-cmd-row:hover { background: var(--accent-bg); }
|
||||
.dash-cmd-left { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; }
|
||||
.dash-cmd-text {
|
||||
font-size: 11px; font-family: var(--font-mono); color: var(--text-secondary);
|
||||
font-size: 11px; font-family: var(--font-mono); color: var(--text-primary);
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
flex: 1; min-width: 0;
|
||||
}
|
||||
.dash-cmd-time { font-size: 9px; color: var(--text-disabled); }
|
||||
.dash-cmd-copy { font-size: 13px; color: var(--text-disabled); flex-shrink: 0; }
|
||||
.dash-cmd-row:hover .dash-cmd-copy { color: var(--accent); }
|
||||
|
||||
.dash-cmd-freq { display: flex; flex-direction: column; gap: 6px; margin-bottom: 10px; padding-bottom: 10px; border-bottom: 1px solid var(--border); }
|
||||
.dash-cmd-freq-title { font-size: 10px; font-weight: 700; text-transform: uppercase; color: var(--text-disabled); letter-spacing: 0.05em; margin-bottom: 2px; }
|
||||
.dash-cmd-freq-row {
|
||||
display: flex; align-items: center; gap: 8px; cursor: pointer;
|
||||
padding: 3px 4px; border-radius: var(--radius-sm);
|
||||
transition: background 0.12s;
|
||||
}
|
||||
.dash-cmd-freq-row:hover { background: var(--accent-bg); }
|
||||
.dash-cmd-freq-name { font-size: 12px; font-weight: 600; font-family: var(--font-mono); color: var(--text-primary); width: 100px; flex-shrink: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dash-cmd-freq-bar-wrap { flex: 1; height: 6px; background: var(--bg-input); border-radius: 3px; overflow: hidden; }
|
||||
.dash-cmd-freq-bar { height: 100%; background: var(--accent); border-radius: 3px; transition: width 0.3s ease; }
|
||||
.dash-cmd-freq-count { font-size: 10px; font-family: var(--font-mono); color: var(--accent); width: 28px; text-align: right; flex-shrink: 0; }
|
||||
|
||||
.dash-cmd-top { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }
|
||||
.dash-cmd-chip {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
padding: 6px 12px; border-radius: var(--radius);
|
||||
background: var(--bg-surface); border: 1px solid var(--border);
|
||||
cursor: pointer; transition: all 0.15s;
|
||||
}
|
||||
.dash-cmd-chip:hover { border-color: var(--accent-dim); background: var(--accent-bg); }
|
||||
.dash-cmd-chip-copied { border-color: var(--accent) !important; background: var(--accent-bg) !important; }
|
||||
.dash-cmd-chip-copied .dash-cmd-chip-name { color: var(--accent); }
|
||||
.dash-cmd-chip-name { font-size: 13px; font-weight: 700; font-family: var(--font-mono); color: var(--text-primary); }
|
||||
.dash-cmd-chip-count { font-size: 10px; font-family: var(--font-mono); color: var(--accent); }
|
||||
|
||||
/* Services */
|
||||
.dash-services { display: flex; flex-direction: column; gap: 6px; }
|
||||
@@ -1048,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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user