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>
This commit is contained in:
Augustin
2026-04-24 14:15:14 +02:00
parent 00118f0803
commit 233368c954
2 changed files with 81 additions and 103 deletions

View File

@@ -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
}

View File

@@ -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)