fix: restore buffer after WebSocket init, fix clear detection, fix streaming chunks
- 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>
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -337,13 +337,17 @@ 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 {}
|
||||
// Restore saved terminal buffer after first output settles
|
||||
const restoreBuffer = () => {
|
||||
try {
|
||||
const savedBuffers = JSON.parse(localStorage.getItem(TERMINAL_BUFFER_KEY) || '{}')
|
||||
if (savedBuffers[tabId]) {
|
||||
term.write('\x1b[90m— session restaurée —\x1b[0m\r\n')
|
||||
term.write(savedBuffers[tabId])
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
setTimeout(restoreBuffer, 300)
|
||||
|
||||
const saveBuffer = () => {
|
||||
try {
|
||||
@@ -362,24 +366,30 @@ export default function Shell({ api }) {
|
||||
const bufferSaveInterval = setInterval(saveBuffer, 5000)
|
||||
|
||||
// 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 {
|
||||
// We read the current line from the terminal buffer on Enter
|
||||
// instead of trying to reconstruct from keystrokes (unreliable with history, ANSI, etc.)
|
||||
const clearBufferOnClear = () => {
|
||||
try {
|
||||
const buf = term.buffer.active
|
||||
const lineY = buf.baseY + buf.cursorY
|
||||
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(localStorage.getItem(TERMINAL_BUFFER_KEY) || '{}')
|
||||
delete savedBuffers[tabId]
|
||||
localStorage.setItem(TERMINAL_BUFFER_KEY, JSON.stringify(savedBuffers))
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
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 {}
|
||||
}
|
||||
|
||||
// Hook into onData to detect Enter for clear detection
|
||||
// The connectWebSocket already registered its own onData for WS forwarding,
|
||||
// this one is purely for clear detection
|
||||
term.onData((data) => {
|
||||
if (data === '\r') {
|
||||
clearBufferOnClear()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -507,21 +517,30 @@ 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()
|
||||
delete tabsRef.current[tabId]
|
||||
}
|
||||
|
||||
setTabs(prev => {
|
||||
if (prev.length <= 1) return prev
|
||||
const tab = prev.find(t => t.id === tabId)
|
||||
if (!tab) return prev
|
||||
|
||||
const entry = tabsRef.current[tabId]
|
||||
if (entry) {
|
||||
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]
|
||||
}
|
||||
|
||||
// Clean buffer from localStorage
|
||||
try {
|
||||
const savedBuffers = JSON.parse(localStorage.getItem(TERMINAL_BUFFER_KEY) || '{}')
|
||||
delete savedBuffers[tabId]
|
||||
localStorage.setItem(TERMINAL_BUFFER_KEY, JSON.stringify(savedBuffers))
|
||||
} catch {}
|
||||
|
||||
const next = prev.filter(t => t.id !== tabId)
|
||||
if (activeTab === tabId && next.length > 0) {
|
||||
setActiveTab(next[next.length - 1].id)
|
||||
@@ -568,9 +587,15 @@ 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' }))
|
||||
if (!entry) {
|
||||
console.warn('sendToTerminal: no terminal initialized for tab', activeTab)
|
||||
return
|
||||
}
|
||||
if (!entry.ws || entry.ws.readyState !== WebSocket.OPEN) {
|
||||
console.warn('sendToTerminal: WebSocket not ready for tab', activeTab)
|
||||
return
|
||||
}
|
||||
entry.ws.send(JSON.stringify({ type: 'input', data: code + '\r' }))
|
||||
}, [activeTab])
|
||||
|
||||
const focusAiTerminal = useCallback(() => {
|
||||
@@ -578,13 +603,16 @@ export default function Shell({ api }) {
|
||||
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() || aiLoading || aiAtLimit) return
|
||||
const trimmed = text.trim()
|
||||
|
||||
if (text === '/clear') {
|
||||
if (!fromEvent) {
|
||||
setAiInput('')
|
||||
focusAiTerminal()
|
||||
}
|
||||
|
||||
if (trimmed === '/clear') {
|
||||
try {
|
||||
await api.clearShellChat()
|
||||
setAiMessages([{ role: 'assistant', content: t('shell.aiWelcome') || 'Contexte effacé. Prêt.' }])
|
||||
@@ -594,65 +622,20 @@ export default function Shell({ api }) {
|
||||
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.' }
|
||||
])
|
||||
return
|
||||
}
|
||||
|
||||
setAiMessages(prev => [...prev, { role: 'user', content: text }])
|
||||
setAiMessages(prev => [...prev, { role: 'user', content: trimmed }])
|
||||
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 }]
|
||||
})
|
||||
// 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) => {
|
||||
await api.sendShellChat(trimmed, {}, true, (partial) => {
|
||||
accumulated = partial
|
||||
setAiMessages(prev => {
|
||||
const filtered = prev.filter(m => !m._streaming)
|
||||
@@ -675,20 +658,20 @@ export default function Shell({ api }) {
|
||||
setAiMessages(prev => [...prev.filter(m => !m._streaming), { role: 'assistant', content: `Erreur: ${err.message}` }])
|
||||
}
|
||||
setAiLoading(false)
|
||||
}, [api, t, aiLoading, aiAtLimit])
|
||||
}, [api, t, aiLoading, 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)
|
||||
|
||||
Reference in New Issue
Block a user