Skip to content

Commit 09941ba

Browse files
committed
feat: initial streaming (working quite well!)
1 parent a9c30fb commit 09941ba

File tree

1 file changed

+100
-19
lines changed

1 file changed

+100
-19
lines changed

npm-app/src/cli-handlers/chat.ts

Lines changed: 100 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ interface ChatMessage {
3131
content: string
3232
timestamp: number
3333
id: string
34+
isStreaming?: boolean
3435
}
3536

3637
interface TerminalMetrics {
@@ -50,6 +51,7 @@ interface ChatState {
5051
userHasScrolled: boolean
5152
lastInputTime: number
5253
cursorVisible: boolean
54+
currentStreamingMessageId?: string
5355
}
5456

5557
// State
@@ -66,6 +68,7 @@ let chatState: ChatState = {
6668
userHasScrolled: false,
6769
lastInputTime: Date.now(),
6870
cursorVisible: true,
71+
currentStreamingMessageId: undefined,
6972
}
7073

7174
// Cached date formatter for performance
@@ -180,6 +183,7 @@ function resetChatState(): void {
180183
userHasScrolled: false,
181184
lastInputTime: Date.now(),
182185
cursorVisible: true,
186+
currentStreamingMessageId: undefined,
183187
}
184188
}
185189

@@ -280,14 +284,15 @@ function addMessage(
280284
role: 'user' | 'assistant',
281285
content: string,
282286
forceAutoScroll: boolean = false,
283-
) {
287+
): string {
284288
const wasAtBottom = shouldAutoScroll()
285289

290+
const messageId = `${role}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
286291
chatState.messages.push({
287292
role,
288293
content,
289294
timestamp: Date.now(),
290-
id: `${role}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
295+
id: messageId,
291296
})
292297
updateContentLines()
293298

@@ -297,6 +302,49 @@ function addMessage(
297302
}
298303

299304
renderChat()
305+
return messageId
306+
}
307+
308+
function startStreamingMessage(role: 'user' | 'assistant'): string {
309+
const messageId = addMessage(role, '', true)
310+
if (role === 'assistant') {
311+
chatState.currentStreamingMessageId = messageId
312+
const message = chatState.messages.find(m => m.id === messageId)
313+
if (message) {
314+
message.isStreaming = true
315+
}
316+
}
317+
return messageId
318+
}
319+
320+
function appendToStreamingMessage(messageId: string, chunk: string): void {
321+
const message = chatState.messages.find(m => m.id === messageId)
322+
if (!message) return
323+
324+
const wasAtBottom = shouldAutoScroll()
325+
326+
message.content += chunk
327+
updateContentLines()
328+
329+
// Auto-scroll if user was at bottom or following the stream
330+
if (wasAtBottom) {
331+
scrollToBottom()
332+
}
333+
334+
renderChat()
335+
}
336+
337+
function finishStreamingMessage(messageId: string): void {
338+
const message = chatState.messages.find(m => m.id === messageId)
339+
if (!message) return
340+
341+
message.isStreaming = false
342+
if (chatState.currentStreamingMessageId === messageId) {
343+
chatState.currentStreamingMessageId = undefined
344+
}
345+
346+
updateContentLines()
347+
renderChat()
300348
}
301349

302350
function updateContentLines() {
@@ -348,6 +396,13 @@ function updateContentLines() {
348396
}
349397
})
350398

399+
// Add streaming indicator for assistant messages that are currently streaming
400+
if (message.isStreaming && message.role === 'assistant') {
401+
const indentSize = stringWidth(prefix)
402+
const streamingIndicator = ' '.repeat(indentSize) + gray('▊')
403+
lines.push(' '.repeat(metrics.sidePadding) + streamingIndicator)
404+
}
405+
351406
if (index < chatState.messages.length - 1) {
352407
lines.push('') // Add spacing between messages
353408
}
@@ -590,9 +645,16 @@ async function sendMessage(message: string, addToChat: boolean = true) {
590645
renderChat()
591646

592647
try {
593-
// TODO: Replace with actual client integration
594-
const response = await simulateAssistantResponse(message)
595-
addMessage('assistant', response, true)
648+
// Start streaming assistant response
649+
const assistantMessageId = startStreamingMessage('assistant')
650+
651+
// Stream the response chunk by chunk
652+
await simulateStreamingResponse(message, (chunk) => {
653+
appendToStreamingMessage(assistantMessageId, chunk)
654+
})
655+
656+
// Finish streaming
657+
finishStreamingMessage(assistantMessageId)
596658
} catch (error) {
597659
logger.error({ error }, 'Error sending chat message')
598660
addMessage(
@@ -623,23 +685,42 @@ async function processMessageQueue() {
623685
}
624686
}
625687

626-
// Dummy function to simulate AI response - replace with actual client integration later
627-
async function simulateAssistantResponse(message: string): Promise<string> {
628-
// Simulate processing delay
629-
await new Promise((resolve) =>
630-
setTimeout(resolve, 1000 + Math.random() * 2000),
631-
)
632-
633-
// Generate a dummy response based on the message
688+
// Simulates streaming AI response with chunked updates
689+
async function simulateStreamingResponse(
690+
message: string,
691+
onChunk: (chunk: string) => void,
692+
): Promise<void> {
693+
// Generate a response based on the message
634694
const responses = [
635-
`I understand you said: "${message}". I'm ready to help with your coding tasks!`,
636-
`Thanks for your message: "${message}". How can I assist you with your project?`,
637-
`Got it! You mentioned: "${message}". What would you like me to work on?`,
638-
`I see you're asking about: "${message}". I can help you implement this feature.`,
639-
`Regarding "${message}" - I can definitely help with that. What specific changes do you need?`,
695+
`I understand you said: "${message}". I'm ready to help with your coding tasks! Let me break this down for you:\n\n1. First, I'll analyze what you're asking for\n2. Then I'll provide a comprehensive solution\n3. Finally, I'll suggest next steps\n\nThis is a great question that touches on several important concepts. When working with ${message.toLowerCase()}, it's important to consider the broader context and how it fits into your overall project architecture.`,
696+
`Thanks for your message: "${message}". How can I assist you with your project? Let me think through this systematically:\n\n• Understanding the requirements\n• Identifying potential solutions\n• Considering best practices\n• Planning implementation steps\n\nBased on what you've described, I can help you implement this feature efficiently. Would you like me to start with a specific approach?`,
697+
`Got it! You mentioned: "${message}". What would you like me to work on? This is an interesting challenge that we can tackle together.\n\nHere's how I typically approach problems like this:\n- Analyze the current state\n- Define clear objectives\n- Break down into manageable steps\n- Implement with best practices\n\nI'm excited to help you build something great!`,
698+
`I see you're asking about: "${message}". I can help you implement this feature. Let me share some insights:\n\n**Key Considerations:**\n- Performance implications\n- Scalability factors\n- Maintenance requirements\n- User experience impact\n\nThis type of implementation typically involves several moving parts, but I can guide you through each step to ensure we build something robust and maintainable.`,
699+
`Regarding "${message}" - I can definitely help with that. What specific changes do you need? This sounds like a fascinating project!\n\nI love working on challenges like this because they often involve:\n• Creative problem-solving\n• Technical innovation\n• Thoughtful design decisions\n• Attention to detail\n\nLet's dive in and create something amazing together. What aspect would you like to focus on first?`,
640700
]
641701

642-
return responses[Math.floor(Math.random() * responses.length)]
702+
const fullResponse = responses[Math.floor(Math.random() * responses.length)]
703+
704+
// Split response into words for realistic streaming
705+
const words = fullResponse.split(' ')
706+
707+
// Initial delay before starting to stream
708+
await new Promise(resolve => setTimeout(resolve, 800 + Math.random() * 400))
709+
710+
for (let i = 0; i < words.length; i++) {
711+
const word = words[i]
712+
const isLastWord = i === words.length - 1
713+
714+
// Add space before word (except for first word)
715+
const chunk = (i === 0 ? '' : ' ') + word
716+
onChunk(chunk)
717+
718+
// Variable delay between words for realistic typing
719+
if (!isLastWord) {
720+
const delay = 40 + Math.random() * 120 // 40-160ms between words
721+
await new Promise(resolve => setTimeout(resolve, delay))
722+
}
723+
}
643724
}
644725

645726
// Cleanup function to ensure we exit chat buffer on process termination

0 commit comments

Comments
 (0)