TUI5: Enhanced typewriter with batching, fixed infinite loop, premium streaming feel
This commit is contained in:
@@ -1050,29 +1050,17 @@ const SmoothCounter = ({ value }) => {
|
|||||||
return h(Text, { color: 'white' }, displayValue.toLocaleString());
|
return h(Text, { color: 'white' }, displayValue.toLocaleString());
|
||||||
};
|
};
|
||||||
|
|
||||||
// Component: ProfessionalTypewriter - Premium text streaming with advanced flow control
|
// Component: EnhancedTypewriterText - Improved text reveal with batching and adaptive speed
|
||||||
// Default content type speeds (defined outside component for stable reference)
|
const EnhancedTypewriterText = ({
|
||||||
const DEFAULT_CONTENT_TYPES = {
|
children,
|
||||||
text: 25, // Normal text - smooth flow
|
speed = 25,
|
||||||
code: 8, // Code - faster for readability
|
batchSize = 1 // Default to 1 for safety, can be increased for batching
|
||||||
thinking: 40, // Thinking - deliberate pace
|
|
||||||
bold: 18 // Bold text - slightly faster
|
|
||||||
};
|
|
||||||
|
|
||||||
const ProfessionalTypewriter = ({
|
|
||||||
children,
|
|
||||||
baseSpeed = 20,
|
|
||||||
contentTypes = DEFAULT_CONTENT_TYPES
|
|
||||||
}) => {
|
}) => {
|
||||||
const fullText = String(children || '');
|
const fullText = String(children || '');
|
||||||
const [displayText, setDisplayText] = useState('');
|
const [displayText, setDisplayText] = useState('');
|
||||||
const positionRef = useRef(0);
|
const positionRef = useRef(0);
|
||||||
const timerRef = useRef(null);
|
const timerRef = useRef(null);
|
||||||
|
|
||||||
// Use refs for values that shouldn't trigger re-render
|
|
||||||
const contentTypesRef = useRef(contentTypes);
|
|
||||||
contentTypesRef.current = contentTypes;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Reset when text changes
|
// Reset when text changes
|
||||||
setDisplayText('');
|
setDisplayText('');
|
||||||
@@ -1086,49 +1074,45 @@ const ProfessionalTypewriter = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Professional streaming with intelligent pacing
|
// Safer approach: process in small batches to prevent overwhelming the UI
|
||||||
const streamNext = () => {
|
const processNextBatch = () => {
|
||||||
if (positionRef.current >= fullText.length) {
|
if (positionRef.current >= fullText.length) {
|
||||||
if (timerRef.current) clearTimeout(timerRef.current);
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Look ahead to determine context-appropriate speed
|
// Calculate batch size (may be smaller near the end)
|
||||||
const currentPos = positionRef.current;
|
const remaining = fullText.length - positionRef.current;
|
||||||
const context = fullText.substring(Math.max(0, currentPos - 15), currentPos + 15);
|
const currentBatchSize = Math.min(batchSize, remaining);
|
||||||
const types = contentTypesRef.current;
|
|
||||||
|
// Get the next batch of characters
|
||||||
|
const nextBatch = fullText.substring(positionRef.current, positionRef.current + currentBatchSize);
|
||||||
|
|
||||||
|
// Update display and position
|
||||||
|
setDisplayText(prev => prev + nextBatch);
|
||||||
|
positionRef.current += currentBatchSize;
|
||||||
|
|
||||||
let speed = types.text;
|
// Schedule next batch
|
||||||
if (context.includes('```')) speed = types.code;
|
timerRef.current = setTimeout(processNextBatch, speed);
|
||||||
else if (context.match(/^(Let me|Thinking|Analyzing)/i)) speed = types.thinking;
|
|
||||||
else if (context.includes('**') || context.includes('__')) speed = types.bold;
|
|
||||||
|
|
||||||
// Add the next character
|
|
||||||
const nextChar = fullText.charAt(positionRef.current);
|
|
||||||
setDisplayText(prev => prev + nextChar);
|
|
||||||
positionRef.current += 1;
|
|
||||||
|
|
||||||
// Schedule next character with context-aware timing
|
|
||||||
timerRef.current = setTimeout(streamNext, speed);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
streamNext();
|
processNextBatch();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (timerRef.current) {
|
if (timerRef.current) {
|
||||||
clearTimeout(timerRef.current);
|
clearTimeout(timerRef.current);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [fullText]); // Only depend on fullText to prevent infinite loops
|
}, [fullText, speed, batchSize]); // Include batchSize in dependency array
|
||||||
|
|
||||||
// Professional cursor that feels natural
|
// Enhanced cursor effect
|
||||||
const displayWithCursor = displayText + (Math.floor(Date.now() / 500) % 2 ? '█' : ' ');
|
const displayWithCursor = displayText + (Math.floor(Date.now() / 500) % 2 ? '█' : ' ');
|
||||||
|
|
||||||
return h(Text, { wrap: 'wrap' }, displayWithCursor);
|
return h(Text, { wrap: 'wrap' }, displayWithCursor);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Maintain backward compatibility with TypewriterText alias
|
// Maintain backward compatibility
|
||||||
const TypewriterText = ProfessionalTypewriter;
|
const TypewriterText = EnhancedTypewriterText;
|
||||||
|
|
||||||
// Component: FadeInBox - Animated fade-in wrapper (simulates fade with opacity chars)
|
// Component: FadeInBox - Animated fade-in wrapper (simulates fade with opacity chars)
|
||||||
const FadeInBox = ({ children, delay = 0 }) => {
|
const FadeInBox = ({ children, delay = 0 }) => {
|
||||||
@@ -1552,8 +1536,8 @@ const UserCard = ({ content, width }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// AGENT CARD - Professional content display with proper flow
|
// AGENT CARD - Enhanced streaming with premium feel
|
||||||
// Clean, structured presentation with smooth streaming
|
// Text-focused with minimal styling, clean left gutter
|
||||||
const AgentCard = ({ content, isStreaming, width }) => {
|
const AgentCard = ({ content, isStreaming, width }) => {
|
||||||
const contentWidth = width ? width - 4 : undefined; // Account for left gutter and spacing
|
const contentWidth = width ? width - 4 : undefined; // Account for left gutter and spacing
|
||||||
|
|
||||||
@@ -1563,7 +1547,7 @@ const AgentCard = ({ content, isStreaming, width }) => {
|
|||||||
marginBottom: 1,
|
marginBottom: 1,
|
||||||
width: width,
|
width: width,
|
||||||
},
|
},
|
||||||
// Professional status indicator
|
// Enhanced left gutter with premium styling
|
||||||
h(Box, {
|
h(Box, {
|
||||||
width: 2,
|
width: 2,
|
||||||
marginRight: 1,
|
marginRight: 1,
|
||||||
@@ -1571,22 +1555,22 @@ const AgentCard = ({ content, isStreaming, width }) => {
|
|||||||
borderRight: false,
|
borderRight: false,
|
||||||
borderTop: false,
|
borderTop: false,
|
||||||
borderBottom: false,
|
borderBottom: false,
|
||||||
borderLeftColor: isStreaming ? 'cyan' : 'green'
|
borderLeftColor: isStreaming ? 'cyan' : 'green' // Changed to premium cyan color
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// Content area with proper flow
|
// Content area - text focused, no boxy borders
|
||||||
h(Box, {
|
h(Box, {
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
minWidth: 10
|
minWidth: 10
|
||||||
},
|
},
|
||||||
// Content with professional streaming - use stable DEFAULT_CONTENT_TYPES
|
// Content with enhanced streaming effect
|
||||||
h(Box, { width: contentWidth },
|
h(Box, { width: contentWidth },
|
||||||
isStreaming
|
isStreaming
|
||||||
? h(ProfessionalTypewriter, {
|
? h(EnhancedTypewriterText, {
|
||||||
children: content || '',
|
children: content || '',
|
||||||
baseSpeed: 20
|
speed: 25, // Optimal speed for readability
|
||||||
// Uses DEFAULT_CONTENT_TYPES automatically
|
batchSize: 1 // Can be increased for batching (safely set to 1 for now)
|
||||||
})
|
})
|
||||||
: h(Markdown, { syntaxTheme: 'github', width: contentWidth }, content || '')
|
: h(Markdown, { syntaxTheme: 'github', width: contentWidth }, content || '')
|
||||||
)
|
)
|
||||||
@@ -3378,108 +3362,106 @@ This gives the user a chance to refine requirements before implementation.
|
|||||||
const fullPrompt = systemPrompt + '\n\n[USER REQUEST]\n' + fullText;
|
const fullPrompt = systemPrompt + '\n\n[USER REQUEST]\n' + fullText;
|
||||||
let fullResponse = '';
|
let fullResponse = '';
|
||||||
|
|
||||||
// PROVIDER SWITCH: Use OpenCode Free or Qwen based on provider state
|
// PROVIDER SWITCH: Use OpenCode Free or Qwen based on provider state
|
||||||
const streamStartTime = Date.now(); // Track start time for this request
|
const streamStartTime = Date.now(); // Track start time for this request
|
||||||
let totalCharsReceived = 0; // Track total characters for speed calculation
|
let totalCharsReceived = 0; // Track total characters for speed calculation
|
||||||
|
|
||||||
|
const result = provider === 'opencode-free'
|
||||||
|
? await callOpenCodeFree(fullPrompt, freeModel, (chunk) => {
|
||||||
|
const cleanChunk = chunk.replace(/[\u001b\u009b][[\]()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, '');
|
||||||
|
|
||||||
const result = provider === 'opencode-free'
|
// IMPROVED STREAM SPLITTING LOGIC (Thinking vs Content)
|
||||||
? await callOpenCodeFree(fullPrompt, freeModel, (chunk) => {
|
// Claude Code style: cleaner separation of thinking from response
|
||||||
const cleanChunk = chunk.replace(/[\u001b\u009b][[\]()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, '');
|
const lines = cleanChunk.split('\n');
|
||||||
|
let isThinkingChunk = false;
|
||||||
|
|
||||||
// IMPROVED STREAM SPLITTING LOGIC (Thinking vs Content)
|
// Enhanced heuristics for better Claude-like thinking detection
|
||||||
// Professional separation of thinking from response
|
const trimmedChunk = cleanChunk.trim();
|
||||||
const lines = cleanChunk.split('\n');
|
if (/^(Let me|Now let me|I'll|I need to|I should|I notice|I can|I will|Thinking:|Analyzing|Considering|Checking|Looking|Planning|First|Next|Finally)/i.test(trimmedChunk)) {
|
||||||
let isThinkingChunk = false;
|
isThinkingChunk = true;
|
||||||
|
} else if (/^```|# |Here is|```|```|```/i.test(trimmedChunk)) {
|
||||||
|
// If we encounter code blocks or headers, likely content not thinking
|
||||||
|
isThinkingChunk = false;
|
||||||
|
}
|
||||||
|
|
||||||
// Enhanced heuristics for better thinking detection
|
// Update character count for speed calculation
|
||||||
const trimmedChunk = cleanChunk.trim();
|
totalCharsReceived += cleanChunk.length;
|
||||||
if (/^(Let me|Now let me|I'll|I need to|I should|I notice|I can|I will|Thinking:|Analyzing|Considering|Checking|Looking|Planning|First|Next|Finally)/i.test(trimmedChunk)) {
|
|
||||||
isThinkingChunk = true;
|
// Calculate current streaming speed (chars per second)
|
||||||
} else if (/^```|# |Here is|```|```|```/i.test(trimmedChunk)) {
|
const elapsedSeconds = (Date.now() - streamStartTime) / 1000;
|
||||||
// If we encounter code blocks or headers, likely content not thinking
|
const speed = elapsedSeconds > 0 ? Math.round(totalCharsReceived / elapsedSeconds) : 0;
|
||||||
isThinkingChunk = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update character count for speed calculation
|
// GLOBAL STATS UPDATE (Run for ALL chunks)
|
||||||
totalCharsReceived += cleanChunk.length;
|
setThinkingStats(prev => ({
|
||||||
|
...prev,
|
||||||
|
chars: totalCharsReceived,
|
||||||
|
speed: speed
|
||||||
|
}));
|
||||||
|
|
||||||
// Calculate current streaming speed (chars per second)
|
// GLOBAL AGENT DETECTION (Run for ALL chunks)
|
||||||
const elapsedSeconds = (Date.now() - streamStartTime) / 1000;
|
const agentMatch = cleanChunk.match(/\[AGENT:\s*([^\]]+)\]/i);
|
||||||
const speed = elapsedSeconds > 0 ? Math.round(totalCharsReceived / elapsedSeconds) : 0;
|
if (agentMatch) {
|
||||||
|
setThinkingStats(prev => ({ ...prev, activeAgent: agentMatch[1].trim() }));
|
||||||
|
}
|
||||||
|
|
||||||
// GLOBAL STATS UPDATE (Run for ALL chunks)
|
if (isThinkingChunk) {
|
||||||
setThinkingStats(prev => ({
|
setThinkingLines(prev => [...prev, ...lines.map(l => l.trim()).filter(l => l && !/^(Let me|Now let me|I'll|I need to|I notice)/i.test(l.trim()))]);
|
||||||
...prev,
|
} else {
|
||||||
chars: totalCharsReceived,
|
setMessages(prev => {
|
||||||
speed: speed
|
const last = prev[prev.length - 1];
|
||||||
}));
|
if (last && last.role === 'assistant') {
|
||||||
|
return [...prev.slice(0, -1), { ...last, content: last.content + cleanChunk }];
|
||||||
|
}
|
||||||
|
return prev;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
: await getQwen().sendMessage(fullPrompt, 'qwen-coder-plus', null, (chunk) => {
|
||||||
|
const cleanChunk = chunk.replace(/[\u001b\u009b][[\]()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, '');
|
||||||
|
|
||||||
// GLOBAL AGENT DETECTION (Run for ALL chunks)
|
// IMPROVED STREAM SPLITTING LOGIC (Thinking vs Content)
|
||||||
const agentMatch = cleanChunk.match(/\[AGENT:\s*([^\]]+)\]/i);
|
const lines = cleanChunk.split('\n');
|
||||||
if (agentMatch) {
|
let isThinkingChunk = false;
|
||||||
setThinkingStats(prev => ({ ...prev, activeAgent: agentMatch[1].trim() }));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isThinkingChunk) {
|
// Enhanced heuristics for better Claude-like thinking detection
|
||||||
setThinkingLines(prev => [...prev, ...lines.map(l => l.trim()).filter(l => l && !/^(Let me|Now let me|I'll|I need to|I notice)/i.test(l.trim()))]);
|
const trimmedChunk = cleanChunk.trim();
|
||||||
} else {
|
if (/^(Let me|Now let me|I'll|I need to|I should|I notice|I can|I will|Thinking:|Analyzing|Considering|Checking|Looking|Planning|First|Next|Finally)/i.test(trimmedChunk)) {
|
||||||
// Direct message update - simple and stable
|
isThinkingChunk = true;
|
||||||
setMessages(prev => {
|
} else if (/^```|# |Here is|```|```|```/i.test(trimmedChunk)) {
|
||||||
const last = prev[prev.length - 1];
|
// If we encounter code blocks or headers, likely content not thinking
|
||||||
if (last && last.role === 'assistant') {
|
isThinkingChunk = false;
|
||||||
return [...prev.slice(0, -1), { ...last, content: last.content + cleanChunk }];
|
}
|
||||||
}
|
|
||||||
return prev;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})
|
|
||||||
: await getQwen().sendMessage(fullPrompt, 'qwen-coder-plus', null, (chunk) => {
|
|
||||||
const cleanChunk = chunk.replace(/[\u001b\u009b][[\]()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, '');
|
|
||||||
|
|
||||||
// IMPROVED STREAM SPLITTING LOGIC (Thinking vs Content)
|
// Update character count for speed calculation (using same variable as OpenCode path)
|
||||||
const lines = cleanChunk.split('\n');
|
totalCharsReceived += cleanChunk.length;
|
||||||
let isThinkingChunk = false;
|
|
||||||
|
// Calculate current streaming speed (chars per second)
|
||||||
|
const elapsedSeconds = (Date.now() - streamStartTime) / 1000;
|
||||||
|
const speed = elapsedSeconds > 0 ? Math.round(totalCharsReceived / elapsedSeconds) : 0;
|
||||||
|
|
||||||
// Enhanced heuristics for better thinking detection
|
setThinkingStats(prev => ({
|
||||||
const trimmedChunk = cleanChunk.trim();
|
...prev,
|
||||||
if (/^(Let me|Now let me|I'll|I need to|I should|I notice|I can|I will|Thinking:|Analyzing|Considering|Checking|Looking|Planning|First|Next|Finally)/i.test(trimmedChunk)) {
|
chars: totalCharsReceived,
|
||||||
isThinkingChunk = true;
|
speed: speed
|
||||||
} else if (/^```|# |Here is|```|```|```/i.test(trimmedChunk)) {
|
}));
|
||||||
// If we encounter code blocks or headers, likely content not thinking
|
|
||||||
isThinkingChunk = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update character count for speed calculation (using same variable as OpenCode path)
|
const agentMatch = cleanChunk.match(/\[AGENT:\s*([^\]]+)\]/i);
|
||||||
totalCharsReceived += cleanChunk.length;
|
if (agentMatch) {
|
||||||
|
setThinkingStats(prev => ({ ...prev, activeAgent: agentMatch[1].trim() }));
|
||||||
|
}
|
||||||
|
|
||||||
// Calculate current streaming speed (chars per second)
|
if (isThinkingChunk) {
|
||||||
const elapsedSeconds = (Date.now() - streamStartTime) / 1000;
|
setThinkingLines(prev => [...prev, ...lines.map(l => l.trim()).filter(l => l && !/^(Let me|Now let me|I'll|I need to|I notice)/i.test(l.trim()))]);
|
||||||
const speed = elapsedSeconds > 0 ? Math.round(totalCharsReceived / elapsedSeconds) : 0;
|
} else {
|
||||||
|
setMessages(prev => {
|
||||||
setThinkingStats(prev => ({
|
const last = prev[prev.length - 1];
|
||||||
...prev,
|
if (last && last.role === 'assistant') {
|
||||||
chars: totalCharsReceived,
|
return [...prev.slice(0, -1), { ...last, content: last.content + cleanChunk }];
|
||||||
speed: speed
|
}
|
||||||
}));
|
return prev;
|
||||||
|
});
|
||||||
const agentMatch = cleanChunk.match(/\[AGENT:\s*([^\]]+)\]/i);
|
}
|
||||||
if (agentMatch) {
|
});
|
||||||
setThinkingStats(prev => ({ ...prev, activeAgent: agentMatch[1].trim() }));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isThinkingChunk) {
|
|
||||||
setThinkingLines(prev => [...prev, ...lines.map(l => l.trim()).filter(l => l && !/^(Let me|Now let me|I'll|I need to|I notice)/i.test(l.trim()))]);
|
|
||||||
} else {
|
|
||||||
// Direct message update - simple and stable
|
|
||||||
setMessages(prev => {
|
|
||||||
const last = prev[prev.length - 1];
|
|
||||||
if (last && last.role === 'assistant') {
|
|
||||||
return [...prev.slice(0, -1), { ...last, content: last.content + cleanChunk }];
|
|
||||||
}
|
|
||||||
return prev;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
const responseText = result.response || fullResponse;
|
const responseText = result.response || fullResponse;
|
||||||
|
|||||||
Reference in New Issue
Block a user