Compare commits

...

15 Commits

Author SHA1 Message Date
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
Augustin
3a09e0e0c2 fix(ui): adjust global CSS styles
All checks were successful
Beta Release / beta (push) Successful in 45s
💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-24 17:38:21 +02:00
Augustin
47fa2e01bb fix(terminal): use display:none instead of visibility for tab hiding
All checks were successful
Beta Release / beta (push) Successful in 49s
Replace visibility-based hiding with display property for reliable tab
detection. Use offsetParent and offsetHeight checks instead of style
properties to properly detect hidden terminals.

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-24 17:23:54 +02:00
Augustin
401292ec5b feat(ui): refactor copy state to Set and add helper functions
All checks were successful
Beta Release / beta (push) Successful in 46s
- Change copiedIdx (number) to copiedSet (Set) for tracking multiple copied items
- Add copyCmd function to handle clipboard and timeout cleanup
- Add relativeTime function for displaying relative timestamps

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-24 17:04:38 +02:00
Augustin
199a7e409a feat(ui): add recentUnique to deduplicate recent commands in Dashboard
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 17:01:08 +02:00
Augustin
c91931f42f feat(ui): redesign recent commands display and fix terminal visibility
All checks were successful
Beta Release / beta (push) Successful in 44s
- Dashboard: add frequency bars for top commands, click-to-copy, time display
- Shell: switch from display:none to visibility:hidden for terminal containers
- CSS: restyle command list with improved hover states and copy indicators

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-24 16:53:59 +02:00
Augustin
cbbb224725 fix(shell): initialize activeTabRef with activeTab and move useEffect
All checks were successful
Beta Release / beta (push) Successful in 45s
Reorder code to follow React hooks rules - initialize ref with value
instead of null, then update via useEffect.

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-24 16:44:02 +02:00
Augustin
8d10d2182e fix(config): remove unused import, reorder hooks, and improve variable naming
All checks were successful
Beta Release / beta (push) Successful in 42s
Reorder validateKey function and useEffect to avoid referencing before definition.
Rename loop variable from 't' to 'tool' for clarity.

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-24 16:33:09 +02:00
Augustin
e9696ef82b fix(studio): add tool results serialization and improve message handling
All checks were successful
Beta Release / beta (push) Successful in 43s
- Add tool_results array to AI message content with tool_call_id, result, and is_error
- Convert cleanContent to let for potential reuse
- Reset accumulated and streaming state on tool_call events

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-24 16:22:54 +02:00
Augustin
1edd4f053a fix(shell): improve tab reference stability and command queueing
All checks were successful
Beta Release / beta (push) Successful in 47s
Add refs to track activeTab and pending commands outside render cycle.
Flush queued commands after terminal initialization completes.
Fix sendToTerminal to use stable refs instead of stale state.
Enhance debug logging for tab operations.

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-24 16:10:54 +02:00
Augustin
92f943c3e6 fix(shell): add debug logging for tab tracking and WebSocket state
All checks were successful
Beta Release / beta (push) Successful in 46s
Track which tab messages belong to via _tabId field to ensure AI
responses are sent to the correct terminal tab. Add console.log in
initTerminal, sendToTerminal for troubleshooting tab lifecycle issues.

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-24 15:53:13 +02:00
Augustin
1704b196cf fix(terminal): refactor WebSocket cleanup, buffer management, and disposal
All checks were successful
Beta Release / beta (push) Successful in 52s
- Add proper disposal tracking to prevent memory leaks
- Move terminal buffer from localStorage to sessionStorage
- Restore buffer immediately after first WS message
- Fix clear detection logic and error handling
- Add signal parameter support for abortable fetch requests

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-24 15:41:01 +02:00
Augustin
40ec493bae fix(terminal): refactor WS cleanup, improve clear detection, fix sendToTerminal
All checks were successful
Beta Release / beta (push) Successful in 49s
- Move defer cleanup after async goroutine setup to prevent premature closure
- Remove unused Password field from terminal sessions struct
- Fix line calculation in clear detection using viewportY instead of baseY
- Add onStateChange callback to connectWebSocket for connection state
- Add tabId parameter to sendToTerminal for targeted tab control
- Simplify ShellAIMessage to use specific tab for command sending

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-24 15:20:48 +02:00
Augustin
233368c954 fix: restore buffer after WebSocket init, fix clear detection, fix streaming chunks
All checks were successful
Beta Release / beta (push) Successful in 50s
- Delay buffer restoration by 300ms to avoid race condition with WebSocket init
- Read current line from terminal buffer on Enter (reliable) instead of keystroke tracking
- Fix streaming to emit full content instead of word-by-word chunks
- Fix WebSocket readyState check in sendToTerminal
- Extract and deduplicate AI message sending logic
- Fix localStorage cleanup on tab close

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-24 14:15:14 +02:00
8 changed files with 298 additions and 230 deletions

View File

@@ -4,7 +4,6 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"net/http" "net/http"
"strings"
"github.com/muyue/muyue/internal/agent" "github.com/muyue/muyue/internal/agent"
"github.com/muyue/muyue/internal/orchestrator" "github.com/muyue/muyue/internal/orchestrator"
@@ -76,12 +75,8 @@ func (ce *ChatEngine) RunWithTools(ctx context.Context, messages []orchestrator.
content := cleanThinkingTags(choice.Message.Content) content := cleanThinkingTags(choice.Message.Content)
if content != "" { if content != "" {
words := strings.Fields(content) if ce.onChunk != nil {
for _, w := range words { ce.onChunk(map[string]interface{}{"content": content})
chunk := w
if ce.onChunk != nil {
ce.onChunk(map[string]interface{}{"content": chunk})
}
} }
finalContent = content finalContent = content
} }

View File

@@ -146,13 +146,6 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
return return
} }
log.Printf("terminal: pty started successfully") log.Printf("terminal: pty started successfully")
defer func() {
ptmx.Close()
if cmd.Process != nil {
cmd.Process.Kill()
cmd.Wait()
}
}()
var once sync.Once var once sync.Once
cleanup := func() { cleanup := func() {
@@ -164,6 +157,7 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
} }
}) })
} }
defer cleanup()
go func() { go func() {
buf := make([]byte, 4096) buf := make([]byte, 4096)
@@ -171,8 +165,6 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
n, err := ptmx.Read(buf) n, err := ptmx.Read(buf)
if err != nil { if err != nil {
cleanup() cleanup()
conn.WriteMessage(websocket.CloseMessage,
websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""))
return return
} }
if err := conn.WriteJSON(wsMessage{ if err := conn.WriteJSON(wsMessage{
@@ -230,12 +222,11 @@ func (s *Server) handleTerminalSessions(w http.ResponseWriter, r *http.Request)
return return
} }
var body struct { var body struct {
Name string `json:"name"` Name string `json:"name"`
Host string `json:"host"` Host string `json:"host"`
Port int `json:"port"` Port int `json:"port"`
User string `json:"user"` User string `json:"user"`
Password string `json:"password"` KeyPath string `json:"key_path"`
KeyPath string `json:"key_path"`
} }
if err := json.NewDecoder(r.Body).Decode(&body); err != nil { if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, err.Error(), http.StatusBadRequest) writeError(w, err.Error(), http.StatusBadRequest)

View File

@@ -105,7 +105,7 @@ const api = {
}).catch(reject) }).catch(reject)
}) })
}, },
sendShellChat: (message, context = {}, stream = true, onChunk) => { sendShellChat: (message, context = {}, stream = true, onChunk, signal) => {
const payload = { const payload = {
message, message,
cwd: context.cwd || '', cwd: context.cwd || '',
@@ -120,6 +120,7 @@ const api = {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload), body: JSON.stringify(payload),
signal,
}).then(async (res) => { }).then(async (res) => {
if (!res.ok) { if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText })) const err = await res.json().catch(() => ({ error: res.statusText }))

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback } from 'react' 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' import { useI18n } from '../i18n'
const PANELS = [ const PANELS = [
@@ -311,16 +311,6 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm
const [validating, setValidating] = useState(null) const [validating, setValidating] = useState(null)
const [keyStatus, setKeyStatus] = useState({}) 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) => { const validateKey = async (p) => {
setValidating(p.name) setValidating(p.name)
try { try {
@@ -332,6 +322,16 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm
setValidating(null) 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) => { const handleValidate = async (name, apiKey, model, baseUrl) => {
setValidating(name) setValidating(name)
try { try {
@@ -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.` } })) 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 ( return (
<> <>

View File

@@ -43,7 +43,7 @@ export default function Dashboard({ api, refreshRef }) {
const [recentCmds, setRecentCmds] = useState([]) const [recentCmds, setRecentCmds] = useState([])
const [processes, setProcesses] = useState([]) const [processes, setProcesses] = useState([])
const [metrics, setMetrics] = useState(null) const [metrics, setMetrics] = useState(null)
const [copiedIdx, setCopiedIdx] = useState(-1) const [copiedSet, setCopiedSet] = useState(new Set())
const cpuRef = useRef([]) const cpuRef = useRef([])
const memRef = useRef([]) const memRef = useRef([])
const netRxRef = useRef([]) const netRxRef = useRef([])
@@ -109,6 +109,32 @@ export default function Dashboard({ api, refreshRef }) {
.map(([cmd, count]) => ({ cmd, count })) .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 ( return (
<div className="dash-grid"> <div className="dash-grid">
{/* CPU */} {/* CPU */}
@@ -197,26 +223,34 @@ export default function Dashboard({ api, refreshRef }) {
</div> </div>
{/* Recent Commands */} {/* Recent Commands */}
<div className="dash-card"> <div className="dash-card dash-cmd-card">
<div className="dash-card-head"> <div className="dash-card-head">
<span className="dash-label">Recent Commands</span> <span className="dash-label">Recent Commands</span>
<span className="dash-count">{recentUnique.length}</span>
</div> </div>
{topCmds.length > 0 && ( {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) => ( {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); }}> <div key={i} className="dash-cmd-freq-row" onClick={() => copyCmd(c.cmd, `top-${i}`)} title={c.cmd}>
<span className="dash-cmd-chip-name">{copiedIdx === i ? '✓ Copié' : c.cmd}</span> <span className="dash-cmd-freq-name">{copiedSet.has(`top-${i}`) ? '✓ Copié' : c.cmd}</span>
<span className="dash-cmd-chip-count">{c.count}×</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> </div>
)} )}
<div className="dash-cmd-list"> <div className="dash-cmd-list">
{recentCmds.length === 0 && <span className="dash-empty">No history</span>} {recentUnique.length === 0 && <span className="dash-empty">No history</span>}
{recentCmds.map((c, i) => ( {recentUnique.map((c, i) => (
<div key={i} className="dash-cmd-row" title={c.cmd}> <div key={i} className="dash-cmd-row" onClick={() => copyCmd(c.cmd, `list-${i}`)} title={c.cmd + ' · click to copy'}>
<span className="dash-cmd-shell">{c.shell}</span> <div className="dash-cmd-left">
<span className="dash-cmd-text">{c.cmd.length > 45 ? c.cmd.slice(0, 42) + '...' : c.cmd}</span> <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>
))} ))}
</div> </div>

View File

@@ -149,7 +149,7 @@ function createTerminal(container, settings = {}) {
return { term, fitAddon } return { term, fitAddon }
} }
function connectWebSocket(term, fitAddon, initPayload) { function connectWebSocket(term, fitAddon, initPayload, onStateChange, onFirstMessage) {
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:' const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const ws = new WebSocket(`${proto}//${window.location.host}/api/ws/terminal`) const ws = new WebSocket(`${proto}//${window.location.host}/api/ws/terminal`)
@@ -159,9 +159,15 @@ function connectWebSocket(term, fitAddon, initPayload) {
if (dims) { if (dims) {
ws.send(JSON.stringify({ type: 'resize', rows: dims.rows, cols: dims.cols })) ws.send(JSON.stringify({ type: 'resize', rows: dims.rows, cols: dims.cols }))
} }
if (onStateChange) onStateChange(true)
}) })
let firstMessage = true
ws.addEventListener('message', (event) => { ws.addEventListener('message', (event) => {
if (firstMessage) {
firstMessage = false
if (onFirstMessage) onFirstMessage()
}
try { try {
const msg = JSON.parse(event.data) const msg = JSON.parse(event.data)
if (msg.type === 'output') { if (msg.type === 'output') {
@@ -176,16 +182,12 @@ function connectWebSocket(term, fitAddon, initPayload) {
ws.addEventListener('close', () => { ws.addEventListener('close', () => {
term.write('\r\n\x1b[33m— Connection closed —\x1b[0m\r\n') term.write('\r\n\x1b[33m— Connection closed —\x1b[0m\r\n')
if (onStateChange) onStateChange(false)
}) })
ws.addEventListener('error', () => { ws.addEventListener('error', () => {
term.write('\r\n\x1b[31m— Connection error —\x1b[0m\r\n') term.write('\r\n\x1b[31m— Connection error —\x1b[0m\r\n')
}) if (onStateChange) onStateChange(false)
term.onData((data) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'input', data }))
}
}) })
term.onResize(({ rows, cols }) => { term.onResize(({ rows, cols }) => {
@@ -202,6 +204,7 @@ export default function Shell({ api }) {
const tabsRef = useRef({}) const tabsRef = useRef({})
const nextIdRef = useRef(1) 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: 'default' })
const pendingCommandsRef = useRef({})
const savedTabs = (() => { const savedTabs = (() => {
try { try {
@@ -225,6 +228,8 @@ export default function Shell({ api }) {
} }
return 1 return 1
}) })
const activeTabRef = useRef(activeTab)
useEffect(() => { activeTabRef.current = activeTab }, [activeTab])
const [sshConnections, setSshConnections] = useState([]) const [sshConnections, setSshConnections] = useState([])
const [systemTerminals, setSystemTerminals] = useState([]) const [systemTerminals, setSystemTerminals] = useState([])
const [showMenu, setShowMenu] = useState(false) const [showMenu, setShowMenu] = useState(false)
@@ -253,6 +258,7 @@ export default function Shell({ api }) {
const [analysisContent, setAnalysisContent] = useState('') const [analysisContent, setAnalysisContent] = useState('')
const aiMessagesRef = useRef(null) const aiMessagesRef = useRef(null)
const aiLoadedRef = useRef(false) const aiLoadedRef = useRef(false)
const aiLoadingRef = useRef(false)
useEffect(() => { useEffect(() => {
aiMessagesRef.current?.scrollTo(0, aiMessagesRef.current.scrollHeight) aiMessagesRef.current?.scrollTo(0, aiMessagesRef.current.scrollHeight)
@@ -335,15 +341,7 @@ export default function Shell({ api }) {
} }
} }
const ws = connectWebSocket(term, fitAddon, initPayload) let disposed = false
// Restore saved terminal buffer
try {
const savedBuffers = JSON.parse(localStorage.getItem(TERMINAL_BUFFER_KEY) || '{}')
if (savedBuffers[tabId]) {
term.write(savedBuffers[tabId])
}
} catch {}
const saveBuffer = () => { const saveBuffer = () => {
try { try {
@@ -353,106 +351,157 @@ export default function Shell({ api }) {
const line = buf.getLine(i) const line = buf.getLine(i)
if (line) lines.push(line.translateToString(true)) 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') savedBuffers[tabId] = lines.join('\n')
localStorage.setItem(TERMINAL_BUFFER_KEY, JSON.stringify(savedBuffers)) sessionStorage.setItem(TERMINAL_BUFFER_KEY, JSON.stringify(savedBuffers))
} catch {} } 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 const restoreBuffer = () => {
let inputBuffer = '' try {
term.onData((data) => { const savedBuffers = JSON.parse(sessionStorage.getItem(TERMINAL_BUFFER_KEY) || '{}')
if (data === '\r') { if (savedBuffers[tabId]) {
const cmd = inputBuffer.replace(/[\x1b\x00-\x1f]/g, '').trim().toLowerCase() term.write('\x1b[90m— session restaurée —\x1b[0m\r\n')
if (cmd === 'clear') { term.write(savedBuffers[tabId])
try {
const savedBuffers = JSON.parse(localStorage.getItem(TERMINAL_BUFFER_KEY) || '{}')
delete savedBuffers[tabId]
localStorage.setItem(TERMINAL_BUFFER_KEY, JSON.stringify(savedBuffers))
} catch {}
} }
inputBuffer = '' } catch (e) { console.warn('[Shell] Buffer restore failed:', e) }
} else if (data === '\x7f' || data === '\b') { }
inputBuffer = inputBuffer.slice(0, -1)
} else if (data === '\x03') { const ws = connectWebSocket(term, fitAddon, initPayload, onWsState, restoreBuffer)
inputBuffer = ''
} else if (data.length === 1 && data.charCodeAt(0) >= 32) { const clearBufferOnClear = () => {
inputBuffer += data 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 onResize = () => {
const el = document.getElementById(`terminal-${tabId}`) fitAddon.fit()
if (el && el.offsetParent !== null) {
fitAddon.fit()
}
} }
const resizeObserver = new ResizeObserver(onResize) const resizeObserver = new ResizeObserver(onResize)
resizeObserver.observe(container) resizeObserver.observe(container)
window.addEventListener('resize', onResize) 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(() => { useEffect(() => {
const tab = tabs.find(t => t.id === activeTab) let cancelled = false
if (!tab) return const pending = []
const tryInit = (attempt) => { const tryInitTab = (tab, attempt) => {
if (attempt > 20) return if (cancelled || attempt > 30) return
const shellCol = document.querySelector('.shell-terminal-col') const shellCol = document.querySelector('.shell-terminal-col')
if (!shellCol || shellCol.offsetParent === null) { if (!shellCol || shellCol.offsetParent === null) {
setTimeout(() => tryInit(attempt + 1), 150) pending.push(setTimeout(() => tryInitTab(tab, attempt + 1), 150))
return return
} }
const container = document.getElementById(`terminal-${tab.id}`) const container = document.getElementById(`terminal-${tab.id}`)
if (!container || container.offsetHeight === 0) { if (!container) {
setTimeout(() => tryInit(attempt + 1), 100) pending.push(setTimeout(() => tryInitTab(tab, attempt + 1), 100))
return
}
if (container.offsetHeight === 0) {
pending.push(setTimeout(() => tryInitTab(tab, attempt + 1), 100))
return return
} }
if (!tabsRef.current[tab.id]) { if (!tabsRef.current[tab.id]) {
initTerminal(tab.id, tab) initTerminal(tab.id, tab)
} }
requestAnimationFrame(() => { requestAnimationFrame(() => {
if (cancelled) return
const entry = tabsRef.current[tab.id] const entry = tabsRef.current[tab.id]
if (entry) entry.fitAddon.fit() if (entry) entry.fitAddon.fit()
}) })
} }
tryInit(0) for (const tab of tabs) {
}, [activeTab, tabs, initTerminal]) if (!tabsRef.current[tab.id]) {
tryInitTab(tab, 0)
}
}
return () => {
cancelled = true
pending.forEach(clearTimeout)
}
}, [tabs, initTerminal])
useEffect(() => {
const entry = tabsRef.current[activeTab]
if (entry) {
requestAnimationFrame(() => {
if (activeTabRef.current === activeTab) {
entry.fitAddon.fit()
}
})
}
}, [activeTab])
useEffect(() => { useEffect(() => {
const iv = setInterval(() => { const iv = setInterval(() => {
for (const tab of tabs) { const entry = tabsRef.current[activeTabRef.current]
const entry = tabsRef.current[tab.id] if (entry) {
if (entry) { entry.fitAddon.fit()
const el = document.getElementById(`terminal-${tab.id}`)
if (el && el.offsetParent !== null) {
entry.fitAddon.fit()
}
}
} }
}, 2000) }, 2000)
return () => clearInterval(iv) return () => clearInterval(iv)
}, [tabs]) }, [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(() => { useEffect(() => {
const onKey = (e) => { const onKey = (e) => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return
@@ -507,21 +556,27 @@ export default function Shell({ api }) {
const closeTab = (tabId, e) => { const closeTab = (tabId, e) => {
if (e) e.stopPropagation() if (e) e.stopPropagation()
const tab = tabs.find(t => t.id === tabId)
if (!tab || tabs.length <= 1) return
if (tabsRef.current[tabId]) { const entry = tabsRef.current[tabId]
const { ws, resizeObserver, onResize, term, bufferSaveInterval, saveBuffer } = tabsRef.current[tabId] if (entry) {
if (saveBuffer) saveBuffer() entry._markDisposed?.()
if (bufferSaveInterval) clearInterval(bufferSaveInterval) entry.saveBuffer?.()
window.removeEventListener('resize', onResize) if (entry.bufferSaveInterval) clearInterval(entry.bufferSaveInterval)
resizeObserver.disconnect() window.removeEventListener('resize', entry.onResize)
ws.close() entry.resizeObserver.disconnect()
term.dispose() entry.ws.close()
entry.term.dispose()
delete tabsRef.current[tabId] 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 => { setTabs(prev => {
if (prev.length <= 1) return prev
const next = prev.filter(t => t.id !== tabId) const next = prev.filter(t => t.id !== tabId)
if (activeTab === tabId && next.length > 0) { if (activeTab === tabId && next.length > 0) {
setActiveTab(next[next.length - 1].id) setActiveTab(next[next.length - 1].id)
@@ -566,129 +621,105 @@ export default function Shell({ api }) {
} }
} }
const sendToTerminal = useCallback((code) => { const sendToTerminal = useCallback((code, tabId) => {
const entry = tabsRef.current[activeTab] const targetId = tabId || activeTabRef.current
if (entry?.ws && entry.ws.readyState === WebSocket.OPEN) { const entry = tabsRef.current[targetId]
entry.ws.send(JSON.stringify({ type: 'input', data: code + '\r' })) 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 focusAiTerminal = useCallback(() => {
const entry = tabsRef.current[activeTab] const entry = tabsRef.current[activeTabRef.current]
if (entry) entry.term.focus() if (entry) entry.term.focus()
}, [activeTab]) }, [])
const handleAiSend = async () => { const _sendAiMessage = useCallback(async (text, fromEvent = false) => {
if (!aiInput.trim() || aiLoading || aiAtLimit) return if (!text || !text.trim() || aiLoadingRef.current || aiAtLimit) return
const text = aiInput.trim() const trimmed = text.trim()
setAiInput('') aiLoadingRef.current = true
focusAiTerminal()
if (text === '/clear') { if (!fromEvent) {
setAiInput('')
focusAiTerminal()
}
if (trimmed === '/clear') {
try { try {
await api.clearShellChat() await api.clearShellChat()
setAiMessages([{ role: 'assistant', content: t('shell.aiWelcome') || 'Contexte effacé. Prêt.' }]) setAiMessages([{ role: 'assistant', content: t('shell.aiWelcome') || 'Contexte effacé. Prêt.' }])
setAiTokens(0) setAiTokens(0)
setAiAtLimit(false) setAiAtLimit(false)
} catch {} } catch {}
aiLoadingRef.current = false
return return
} }
if (text === '/help') { if (trimmed === '/help') {
setAiMessages(prev => [...prev, 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.' } { 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 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) setAiLoading(true)
try { try {
let accumulated = '' let accumulated = ''
await api.sendShellChat(text, {}, true, (partial) => { await api.sendShellChat(trimmed, {}, true, (partial) => {
accumulated = partial accumulated = partial
setAiMessages(prev => { setAiMessages(prev => {
const filtered = prev.filter(m => !m._streaming) 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 => { setAiMessages(prev => {
const filtered = prev.filter(m => !m._streaming) const filtered = prev.filter(m => !m._streaming)
return [...filtered, { role: 'assistant', content: accumulated }] return [...filtered, { role: 'assistant', content: accumulated, _tabId: currentTab }]
})
// 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 }]
}) })
api.getShellChatHistory().then(d => { api.getShellChatHistory().then(d => {
setAiTokens(d.tokens || 0) setAiTokens(d.tokens || 0)
setAiAtLimit(d.at_limit || false) setAiAtLimit(d.at_limit || false)
}).catch(() => {}) }).catch(() => {})
} catch (err) { } catch (err) {
if (err.message.includes('context limit')) { if (err.message?.includes('context limit')) {
setAiAtLimit(true) setAiAtLimit(true)
} }
setAiMessages(prev => [...prev.filter(m => !m._streaming), { role: 'assistant', content: `Erreur: ${err.message}` }]) setAiMessages(prev => [...prev.filter(m => !m._streaming), { role: 'assistant', content: `Erreur: ${err.message}` }])
} }
setAiLoading(false) setAiLoading(false)
}, [api, t, aiLoading, aiAtLimit]) aiLoadingRef.current = false
}, [api, t, aiAtLimit, focusAiTerminal])
const handleAiSend = () => _sendAiMessage(aiInput, false)
useEffect(() => { useEffect(() => {
const handler = (e) => { const handler = (e) => {
const msg = e.detail?.message const msg = e.detail?.message
if (!msg) return if (!msg) return
setAiInput(msg) setAiInput(msg)
setTimeout(() => { setTimeout(() => _sendAiMessage(msg, true), 100)
handleAiSendDirect(msg)
}, 100)
} }
window.addEventListener('ask-ai-terminal', handler) window.addEventListener('ask-ai-terminal', handler)
return () => window.removeEventListener('ask-ai-terminal', handler) return () => window.removeEventListener('ask-ai-terminal', handler)
}, [handleAiSendDirect]) }, [_sendAiMessage])
const handleAnalyze = async () => { const handleAnalyze = async () => {
setAnalyzing(true) setAnalyzing(true)
@@ -816,8 +847,7 @@ export default function Shell({ api }) {
<div <div
key={tab.id} key={tab.id}
id={`terminal-${tab.id}`} id={`terminal-${tab.id}`}
className="shell-xterm-instance" className={`shell-xterm-instance${activeTab === tab.id ? ' active' : ''}`}
style={{ display: activeTab === tab.id ? 'block' : 'none' }}
/> />
))} ))}
</div> </div>
@@ -858,7 +888,7 @@ export default function Shell({ api }) {
</div> </div>
<div className="ai-panel-messages" ref={aiMessagesRef}> <div className="ai-panel-messages" ref={aiMessagesRef}>
{aiMessages.map((msg, i) => ( {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>} {aiLoading && <div style={{ textAlign: 'center', padding: 8 }}><span className="spinner" /></div>}
</div> </div>
@@ -950,7 +980,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 role = msg.role === 'user' ? 'user' : msg.role === 'system' ? 'system' : 'assistant'
const content = msg.content || '' const content = msg.content || ''
@@ -976,7 +1006,7 @@ function ShellAIMessage({ msg, sendToTerminal }) {
<button onClick={() => navigator.clipboard.writeText(part.content)} title="Copier"> <button onClick={() => navigator.clipboard.writeText(part.content)} title="Copier">
<Copy size={12} /> Copier <Copy size={12} /> Copier
</button> </button>
<button onClick={() => sendToTerminal(part.content)} title="Envoyer au terminal"> <button onClick={() => sendToTerminal(part.content, terminalTabId)} title="Envoyer au terminal">
<Send size={12} /> Terminal <Send size={12} /> Terminal
</button> </button>
</div> </div>

View File

@@ -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 ( return (
<div className={`feed-item ${msg.role}`}> <div className={`feed-item ${msg.role}`}>
@@ -532,6 +532,8 @@ export default function Studio({ api }) {
if (event && event.tool_call) { if (event && event.tool_call) {
toolCalls = [...toolCalls, { call: event.tool_call, result: null }] toolCalls = [...toolCalls, { call: event.tool_call, result: null }]
setStreamToolCalls([...toolCalls]) setStreamToolCalls([...toolCalls])
accumulated = ''
setStreaming('')
return return
} }
if (event && event.tool_result) { if (event && event.tool_result) {
@@ -558,6 +560,11 @@ export default function Studio({ api }) {
aiMsg.content = JSON.stringify({ aiMsg.content = JSON.stringify({
content: finalContent, content: finalContent,
tool_calls: toolCalls.map(tc => tc.call), 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]) setMessages(prev => [...prev, aiMsg])

View File

@@ -382,12 +382,18 @@ input::placeholder { color: var(--text-disabled); }
} }
.shell-menu-divider { height: 1px; background: var(--border); margin: 4px 6px; } .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; height: 100%; background: var(--bg); overflow: hidden; position: relative; }
.shell-xterm-instance { .shell-xterm-instance {
position: absolute; inset: 0; padding: 4px; position: absolute;
display: block !important; 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 { 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); } .connection-dot.on { background: var(--success); box-shadow: 0 0 6px var(--success); }
@@ -691,34 +697,38 @@ input::placeholder { color: var(--text-disabled); }
} }
/* Commands */ /* 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 { .dash-cmd-row {
display: flex; align-items: center; gap: 6px; display: flex; align-items: center; justify-content: space-between; gap: 8px;
padding: 3px 0; overflow: hidden; padding: 5px 8px; border-radius: var(--radius-sm);
} background: var(--bg-surface); cursor: pointer;
.dash-cmd-shell { transition: background 0.12s;
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;
} }
.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 { .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; 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-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 */ /* Services */
.dash-services { display: flex; flex-direction: column; gap: 6px; } .dash-services { display: flex; flex-direction: column; gap: 6px; }