Compare commits

...

9 Commits

Author SHA1 Message Date
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 247 additions and 214 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

@@ -197,26 +197,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,52 +351,56 @@ 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}`) const el = document.getElementById(`terminal-${tabId}`)
if (el && el.offsetParent !== null) { if (el && el.style.visibility !== 'hidden' && el.style.position !== 'absolute') {
fitAddon.fit() fitAddon.fit()
} }
} }
@@ -407,35 +409,61 @@ export default function Shell({ api }) {
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) const tab = tabs.find(t => t.id === activeTab)
if (!tab) return if (!tab) return
let cancelled = false
const pending = []
const tryInit = (attempt) => { const tryInit = (attempt) => {
if (attempt > 20) return if (cancelled || attempt > 20) return
const shellCol = document.querySelector('.shell-terminal-col') const shellCol = document.querySelector('.shell-terminal-col')
if (!shellCol || shellCol.offsetParent === null) { if (!shellCol) {
setTimeout(() => tryInit(attempt + 1), 150) pending.push(setTimeout(() => tryInit(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(() => tryInit(attempt + 1), 100))
return return
} }
if (!tabsRef.current[tab.id]) { if (!tabsRef.current[tab.id]) {
initTerminal(tab.id, tab) initTerminal(tab.id, tab)
} }
requestAnimationFrame(() => { if (activeTab === tab.id) {
const entry = tabsRef.current[tab.id] requestAnimationFrame(() => {
if (entry) entry.fitAddon.fit() if (cancelled) return
}) const entry = tabsRef.current[tab.id]
if (entry) entry.fitAddon.fit()
})
}
} }
tryInit(0) tryInit(0)
return () => {
cancelled = true
pending.forEach(clearTimeout)
}
}, [activeTab, tabs, initTerminal]) }, [activeTab, tabs, initTerminal])
useEffect(() => { useEffect(() => {
@@ -444,7 +472,7 @@ export default function Shell({ api }) {
const entry = tabsRef.current[tab.id] const entry = tabsRef.current[tab.id]
if (entry) { if (entry) {
const el = document.getElementById(`terminal-${tab.id}`) const el = document.getElementById(`terminal-${tab.id}`)
if (el && el.offsetParent !== null) { if (el && el.style.visibility !== 'hidden') {
entry.fitAddon.fit() entry.fitAddon.fit()
} }
} }
@@ -453,6 +481,20 @@ export default function Shell({ api }) {
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 +549,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 +614,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)
@@ -817,7 +841,10 @@ export default function Shell({ api }) {
key={tab.id} key={tab.id}
id={`terminal-${tab.id}`} id={`terminal-${tab.id}`}
className="shell-xterm-instance" className="shell-xterm-instance"
style={{ display: activeTab === tab.id ? 'block' : 'none' }} style={activeTab === tab.id
? { visibility: 'visible', pointerEvents: 'auto' }
: { visibility: 'hidden', pointerEvents: 'none' }
}
/> />
))} ))}
</div> </div>
@@ -858,7 +885,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 +977,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 +1003,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

@@ -691,34 +691,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; }