99 HIDE_CURSOR ,
1010 SHOW_CURSOR ,
1111 MOVE_CURSOR ,
12+ SET_CURSOR_STEADY_BAR ,
13+ SET_CURSOR_DEFAULT ,
14+ DISABLE_CURSOR_BLINK ,
1215} from '../utils/terminal'
1316import { logger } from '../utils/logger'
1417
@@ -21,9 +24,6 @@ const WELCOME_MESSAGE =
2124 'Welcome to Codebuff Chat! Type your messages below and press Enter to send. This is a dedicated chat interface for conversations with your AI assistant.'
2225const QUEUE_ARROW = '↑'
2326const SEPARATOR_CHAR = '─'
24- const CURSOR_CHAR = '▋'
25- const CURSOR_BLINK_INTERVAL = 1000 // ms
26- const INACTIVITY_THRESHOLD = 2000 // ms
2727
2828// Interfaces
2929interface ChatMessage {
@@ -49,15 +49,12 @@ interface ChatState {
4949 isWaitingForResponse : boolean
5050 messageQueue : string [ ]
5151 userHasScrolled : boolean
52- lastInputTime : number
53- cursorVisible : boolean
5452 currentStreamingMessageId ?: string
5553}
5654
5755// State
5856let isInChatBuffer = false
5957let originalKeyHandlers : ( ( str : string , key : any ) => void ) [ ] = [ ]
60- let blinkInterval : NodeJS . Timeout | null = null
6158let chatState : ChatState = {
6259 messages : [ ] ,
6360 currentInput : '' ,
@@ -66,8 +63,6 @@ let chatState: ChatState = {
6663 isWaitingForResponse : false ,
6764 messageQueue : [ ] ,
6865 userHasScrolled : false ,
69- lastInputTime : Date . now ( ) ,
70- cursorVisible : true ,
7166 currentStreamingMessageId : undefined ,
7267}
7368
@@ -115,9 +110,10 @@ function calculateInputAreaHeight(metrics: TerminalMetrics): number {
115110 if ( chatState . currentInput . length === 0 ) {
116111 inputAreaHeight += 1 // Just the placeholder
117112 } else {
118- const cursor = chatState . cursorVisible ? bold ( gray ( CURSOR_CHAR ) ) : ' '
119- const inputWithCursor = chatState . currentInput + cursor
120- const wrappedInputLines = wrapLine ( inputWithCursor , metrics . contentWidth )
113+ const wrappedInputLines = wrapLine (
114+ chatState . currentInput ,
115+ metrics . contentWidth ,
116+ )
121117 inputAreaHeight += wrappedInputLines . length
122118 }
123119
@@ -181,45 +177,59 @@ function resetChatState(): void {
181177 isWaitingForResponse : false ,
182178 messageQueue : [ ] ,
183179 userHasScrolled : false ,
184- lastInputTime : Date . now ( ) ,
185- cursorVisible : true ,
186180 currentStreamingMessageId : undefined ,
187181 }
188182}
189183
190- function startCursorBlink ( ) : void {
191- if ( blinkInterval ) {
192- clearInterval ( blinkInterval )
184+ function setupRealCursor ( ) : void {
185+ // Set the real cursor to steady bar style and disable blinking
186+ // This is the actual cursor that shows where typing will occur
187+ process . stdout . write ( SET_CURSOR_STEADY_BAR )
188+ process . stdout . write ( DISABLE_CURSOR_BLINK )
189+ }
190+
191+ function restoreDefaultRealCursor ( ) : void {
192+ // Restore the real cursor to default style
193+ process . stdout . write ( SET_CURSOR_DEFAULT )
194+ }
195+
196+ function positionRealCursor ( ) : void {
197+ const metrics = getTerminalMetrics ( )
198+ const inputAreaHeight = calculateInputAreaHeight ( metrics )
199+
200+ // Calculate where the input area starts
201+ let inputLinePosition = metrics . height - inputAreaHeight
202+
203+ // Skip queue preview line if present
204+ if ( chatState . messageQueue . length > 0 ) {
205+ inputLinePosition += 1
193206 }
207+ // Skip separator line
208+ inputLinePosition += 1
194209
195- blinkInterval = setInterval ( ( ) => {
196- const now = Date . now ( )
197- const timeSinceLastInput = now - chatState . lastInputTime
210+ // Now inputLinePosition points to the actual input line
211+ if ( chatState . currentInput . length === 0 ) {
212+ // Position cursor at start of input area (after side padding)
213+ process . stdout . write (
214+ MOVE_CURSOR ( inputLinePosition , metrics . sidePadding + 1 ) ,
215+ )
216+ } else {
217+ // Calculate cursor position within the input text
218+ const wrappedInputLines = wrapLine (
219+ chatState . currentInput ,
220+ metrics . contentWidth ,
221+ )
222+ const lastLineIndex = wrappedInputLines . length - 1
223+ const lastLineLength = stringWidth ( wrappedInputLines [ lastLineIndex ] || '' )
198224
199- // Only blink if user hasn't typed recently
200- if ( timeSinceLastInput > INACTIVITY_THRESHOLD ) {
201- chatState . cursorVisible = ! chatState . cursorVisible
202- renderChat ( )
203- } else {
204- // Always show cursor when user is actively typing
205- if ( ! chatState . cursorVisible ) {
206- chatState . cursorVisible = true
207- renderChat ( )
208- }
209- }
210- } , CURSOR_BLINK_INTERVAL )
211- }
225+ const cursorRow = inputLinePosition + lastLineIndex
226+ const cursorCol = metrics . sidePadding + 1 + lastLineLength
212227
213- function stopCursorBlink ( ) : void {
214- if ( blinkInterval ) {
215- clearInterval ( blinkInterval )
216- blinkInterval = null
228+ process . stdout . write ( MOVE_CURSOR ( cursorRow , cursorCol ) )
217229 }
218- }
219230
220- function updateLastInputTime ( ) : void {
221- chatState . lastInputTime = Date . now ( )
222- chatState . cursorVisible = true // Always show cursor immediately on input
231+ // Show the real cursor
232+ process . stdout . write ( SHOW_CURSOR )
223233}
224234
225235export function isInChatMode ( ) : boolean {
@@ -238,22 +248,20 @@ export function enterChatBuffer(rl: any, onExit: () => void) {
238248 process . stdout . write ( ENTER_ALT_BUFFER )
239249 process . stdout . write ( CLEAR_SCREEN )
240250 process . stdout . write ( MOVE_CURSOR ( 1 , 1 ) )
241- process . stdout . write ( HIDE_CURSOR )
242251
243- isInChatBuffer = true
252+ // Setup the real cursor
253+ setupRealCursor ( )
244254
245- // Add welcome message
246- addMessage ( 'assistant' , WELCOME_MESSAGE , true )
255+ isInChatBuffer = true
247256
248257 // Setup key handling
249258 setupChatKeyHandler ( rl , onExit )
250259
251- // Start cursor blinking
252- startCursorBlink ( )
253-
254- // Initial render
255- updateContentLines ( )
256- renderChat ( )
260+ // Delay initial render to avoid flicker and ensure terminal is ready
261+ setTimeout ( ( ) => {
262+ addMessage ( 'assistant' , WELCOME_MESSAGE , true )
263+ positionRealCursor ( )
264+ } , 50 )
257265}
258266
259267export function exitChatBuffer ( rl : any ) {
@@ -262,7 +270,7 @@ export function exitChatBuffer(rl: any) {
262270 }
263271
264272 resetChatState ( )
265- stopCursorBlink ( )
273+ restoreDefaultRealCursor ( )
266274
267275 // Restore all original key handlers
268276 if ( originalKeyHandlers . length > 0 ) {
@@ -309,7 +317,7 @@ function startStreamingMessage(role: 'user' | 'assistant'): string {
309317 const messageId = addMessage ( role , '' , true )
310318 if ( role === 'assistant' ) {
311319 chatState . currentStreamingMessageId = messageId
312- const message = chatState . messages . find ( m => m . id === messageId )
320+ const message = chatState . messages . find ( ( m ) => m . id === messageId )
313321 if ( message ) {
314322 message . isStreaming = true
315323 }
@@ -318,11 +326,11 @@ function startStreamingMessage(role: 'user' | 'assistant'): string {
318326}
319327
320328function appendToStreamingMessage ( messageId : string , chunk : string ) : void {
321- const message = chatState . messages . find ( m => m . id === messageId )
329+ const message = chatState . messages . find ( ( m ) => m . id === messageId )
322330 if ( ! message ) return
323331
324332 const wasAtBottom = shouldAutoScroll ( )
325-
333+
326334 message . content += chunk
327335 updateContentLines ( )
328336
@@ -335,14 +343,14 @@ function appendToStreamingMessage(messageId: string, chunk: string): void {
335343}
336344
337345function finishStreamingMessage ( messageId : string ) : void {
338- const message = chatState . messages . find ( m => m . id === messageId )
346+ const message = chatState . messages . find ( ( m ) => m . id === messageId )
339347 if ( ! message ) return
340348
341349 message . isStreaming = false
342350 if ( chatState . currentStreamingMessageId === messageId ) {
343351 chatState . currentStreamingMessageId = undefined
344352 }
345-
353+
346354 updateContentLines ( )
347355 renderChat ( )
348356}
@@ -396,11 +404,12 @@ function updateContentLines() {
396404 }
397405 } )
398406
399- // Add streaming indicator for assistant messages that are currently streaming
407+ // Add fake visual cursor indicator for assistant messages that are currently streaming
408+ // This is NOT the real cursor - it's a visual character (▊) to show streaming status
400409 if ( message . isStreaming && message . role === 'assistant' ) {
401410 const indentSize = stringWidth ( prefix )
402- const streamingIndicator = ' ' . repeat ( indentSize ) + gray ( '▊' )
403- lines . push ( ' ' . repeat ( metrics . sidePadding ) + streamingIndicator )
411+ const fakeVisualCursor = ' ' . repeat ( indentSize ) + gray ( '▊' )
412+ lines . push ( ' ' . repeat ( metrics . sidePadding ) + fakeVisualCursor )
404413 }
405414
406415 if ( index < chatState . messages . length - 1 ) {
@@ -475,17 +484,17 @@ function renderChat() {
475484
476485 // Show placeholder or user input
477486 if ( chatState . currentInput . length === 0 ) {
478- // Show blinking cursor in front of placeholder text
479- const cursor = chatState . cursorVisible ? bold ( gray ( CURSOR_CHAR ) ) : ' '
480- const placeholder = `${ cursor } \x1b[2m${ gray ( PLACEHOLDER_TEXT ) } \x1b[22m`
487+ // Show placeholder text
488+ const placeholder = `\x1b[2m${ gray ( PLACEHOLDER_TEXT ) } \x1b[22m`
481489 process . stdout . write ( MOVE_CURSOR ( currentLine , 1 ) )
482490 process . stdout . write ( ' ' . repeat ( metrics . sidePadding ) + placeholder )
483491 currentLine ++
484492 } else {
485- // Show user input with cursor when typing
486- const cursor = chatState . cursorVisible ? bold ( gray ( CURSOR_CHAR ) ) : ' '
487- const inputWithCursor = chatState . currentInput + cursor
488- const wrappedInputLines = wrapLine ( inputWithCursor , metrics . contentWidth )
493+ // Show user input
494+ const wrappedInputLines = wrapLine (
495+ chatState . currentInput ,
496+ metrics . contentWidth ,
497+ )
489498
490499 wrappedInputLines . forEach ( ( line , index ) => {
491500 process . stdout . write ( MOVE_CURSOR ( currentLine , 1 ) )
@@ -498,7 +507,8 @@ function renderChat() {
498507 process . stdout . write ( MOVE_CURSOR ( metrics . height , 1 ) )
499508 process . stdout . write ( ' ' . repeat ( metrics . sidePadding ) + gray ( STATUS_TEXT ) )
500509
501- process . stdout . write ( HIDE_CURSOR )
510+ // Position the real cursor at input location
511+ positionRealCursor ( )
502512}
503513
504514function setupChatKeyHandler ( rl : any , onExit : ( ) => void ) {
@@ -534,15 +544,13 @@ function setupChatKeyHandler(rl: any, onExit: () => void) {
534544 }
535545 chatState . currentInput = ''
536546 }
537- updateLastInputTime ( )
538547 renderChat ( )
539548 return
540549 }
541550
542551 // Handle backspace
543552 if ( key && key . name === 'backspace' ) {
544553 chatState . currentInput = chatState . currentInput . slice ( 0 , - 1 )
545- updateLastInputTime ( )
546554 renderChat ( )
547555 return
548556 }
@@ -623,7 +631,6 @@ function setupChatKeyHandler(rl: any, onExit: () => void) {
623631 // Add printable characters to input
624632 if ( str && str . length === 1 && str . charCodeAt ( 0 ) >= 32 ) {
625633 chatState . currentInput += str
626- updateLastInputTime ( )
627634 renderChat ( )
628635 }
629636 } )
@@ -647,12 +654,12 @@ async function sendMessage(message: string, addToChat: boolean = true) {
647654 try {
648655 // Start streaming assistant response
649656 const assistantMessageId = startStreamingMessage ( 'assistant' )
650-
657+
651658 // Stream the response chunk by chunk
652659 await simulateStreamingResponse ( message , ( chunk ) => {
653660 appendToStreamingMessage ( assistantMessageId , chunk )
654661 } )
655-
662+
656663 // Finish streaming
657664 finishStreamingMessage ( assistantMessageId )
658665 } catch ( error ) {
@@ -700,33 +707,33 @@ async function simulateStreamingResponse(
700707 ]
701708
702709 const fullResponse = responses [ Math . floor ( Math . random ( ) * responses . length ) ]
703-
710+
704711 // Split response into words for realistic streaming
705712 const words = fullResponse . split ( ' ' )
706-
713+
707714 // Initial delay before starting to stream
708- await new Promise ( resolve => setTimeout ( resolve , 800 + Math . random ( ) * 400 ) )
709-
715+ await new Promise ( ( resolve ) => setTimeout ( resolve , 800 + Math . random ( ) * 400 ) )
716+
710717 for ( let i = 0 ; i < words . length ; i ++ ) {
711718 const word = words [ i ]
712719 const isLastWord = i === words . length - 1
713-
720+
714721 // Add space before word (except for first word)
715722 const chunk = ( i === 0 ? '' : ' ' ) + word
716723 onChunk ( chunk )
717-
724+
718725 // Variable delay between words for realistic typing
719726 if ( ! isLastWord ) {
720727 const delay = 40 + Math . random ( ) * 120 // 40-160ms between words
721- await new Promise ( resolve => setTimeout ( resolve , delay ) )
728+ await new Promise ( ( resolve ) => setTimeout ( resolve , delay ) )
722729 }
723730 }
724731}
725732
726733// Cleanup function to ensure we exit chat buffer on process termination
727734export function cleanupChatBuffer ( ) {
728735 if ( isInChatBuffer ) {
729- stopCursorBlink ( )
736+ restoreDefaultRealCursor ( )
730737 process . stdout . write ( SHOW_CURSOR )
731738 process . stdout . write ( EXIT_ALT_BUFFER )
732739 isInChatBuffer = false
0 commit comments