Skip to content

Commit 1c4c140

Browse files
committed
feat(chat): implement hardware cursor controls to anchor input and
stabilize rendering. This improves UX by keeping the input cursor steady as content wraps and reduces visual jitter. 🤖 Generated with Codebuff Co-Authored-By: Codebuff <noreply@codebuff.com>
1 parent 09941ba commit 1c4c140

File tree

2 files changed

+96
-80
lines changed

2 files changed

+96
-80
lines changed

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

Lines changed: 87 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ import {
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'
1316
import { 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.'
2225
const QUEUE_ARROW = '↑'
2326
const SEPARATOR_CHAR = '─'
24-
const CURSOR_CHAR = '▋'
25-
const CURSOR_BLINK_INTERVAL = 1000 // ms
26-
const INACTIVITY_THRESHOLD = 2000 // ms
2727

2828
// Interfaces
2929
interface 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
5856
let isInChatBuffer = false
5957
let originalKeyHandlers: ((str: string, key: any) => void)[] = []
60-
let blinkInterval: NodeJS.Timeout | null = null
6158
let 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

225235
export 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

259267
export 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

320328
function 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

337345
function 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

504514
function 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
727734
export 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

npm-app/src/utils/terminal.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,15 @@ export const SHOW_CURSOR = '\x1b[?25h'
1313
// Cursor movement
1414
export const MOVE_CURSOR = (row: number, col: number) => `\x1b[${row};${col}H`
1515

16+
// Cursor style control (DECSCUSR)
17+
export const SET_CURSOR_STEADY_BLOCK = '\x1b[2 q'
18+
export const SET_CURSOR_STEADY_BAR = '\x1b[6 q'
19+
export const SET_CURSOR_DEFAULT = '\x1b[0 q'
20+
21+
// Disable cursor blinking (DEC Private Mode 12)
22+
export const DISABLE_CURSOR_BLINK = '\x1b[?12l'
23+
export const ENABLE_CURSOR_BLINK = '\x1b[?12h'
24+
1625
// Alternative cursor control sequences (used in spinner)
1726
export const HIDE_CURSOR_ALT = '\u001B[?25l'
1827
export const SHOW_CURSOR_ALT = '\u001B[?25h'

0 commit comments

Comments
 (0)