From 34358645de5d4c0963a6f879ab59ff20cdd23d18 Mon Sep 17 00:00:00 2001 From: Jiayuan Date: Sat, 31 Jan 2026 17:44:15 +0800 Subject: [PATCH] fix(cli): handle terminal line wrapping in autocomplete input When input exceeds terminal width, the text wraps to new lines but the old code only cleared one line, causing screen flashing and duplicate lines. Now tracks input line count and cursor position across wrapped lines for proper rendering. Co-Authored-By: Claude Opus 4.5 --- src/agent/autocomplete.ts | 82 ++++++++++++++++++++++++++------------- 1 file changed, 54 insertions(+), 28 deletions(-) diff --git a/src/agent/autocomplete.ts b/src/agent/autocomplete.ts index 18ed9a54..302e078b 100644 --- a/src/agent/autocomplete.ts +++ b/src/agent/autocomplete.ts @@ -24,14 +24,16 @@ export interface AutocompleteConfig { // ANSI escape codes const ESC = "\x1b"; const CLEAR_LINE = `${ESC}[2K`; -const CURSOR_UP = (n: number) => `${ESC}[${n}A`; -const CURSOR_DOWN = (n: number) => `${ESC}[${n}B`; +const CURSOR_UP = (n: number) => (n > 0 ? `${ESC}[${n}A` : ""); const CURSOR_TO_COL = (n: number) => `${ESC}[${n}G`; const DIM = `${ESC}[2m`; const RESET = `${ESC}[0m`; const INVERSE = `${ESC}[7m`; -const HIDE_CURSOR = `${ESC}[?25l`; const SHOW_CURSOR = `${ESC}[?25h`; +const SAVE_CURSOR = `${ESC}[s`; +const RESTORE_CURSOR = `${ESC}[u`; +const CLEAR_TO_END = `${ESC}[J`; +const CURSOR_DOWN = (n: number) => (n > 0 ? `${ESC}[${n}B` : ""); /** * Read a line with real-time autocomplete dropdown @@ -47,7 +49,7 @@ export function autocompleteInput(config: AutocompleteConfig): Promise { let cursorPos = 0; let suggestions: AutocompleteOption[] = []; let selectedIndex = -1; - let displayedLines = 0; + let initialized = false; // Enable raw mode if (stdin.isTTY) { @@ -56,7 +58,6 @@ export function autocompleteInput(config: AutocompleteConfig): Promise { readline.emitKeypressEvents(stdin); const cleanup = () => { - clearSuggestions(); stdout.write(SHOW_CURSOR); if (stdin.isTTY) { stdin.setRawMode(false); @@ -65,15 +66,40 @@ export function autocompleteInput(config: AutocompleteConfig): Promise { }; const render = () => { - // Clear previous suggestions - clearSuggestions(); + if (!initialized) { + // First render - save cursor position as anchor + stdout.write(SAVE_CURSOR); + initialized = true; + } else { + // Restore to anchor and clear everything after it + stdout.write(RESTORE_CURSOR); + stdout.write(CLEAR_TO_END); + // Re-save in case terminal scrolled + stdout.write(SAVE_CURSOR); + } - // Render input line - stdout.write(`\r${CLEAR_LINE}${prompt}${input}`); + // Write prompt and input + stdout.write(`${prompt}${input}`); - // Position cursor - const cursorCol = prompt.length + cursorPos + 1; - stdout.write(CURSOR_TO_COL(cursorCol)); + // Calculate cursor position accounting for line wrapping + const termWidth = stdout.columns || 80; + const cursorOffset = prompt.length + cursorPos; + + // Handle edge case: when cursor is exactly at line boundary, + // show it at end of current line, not start of next line + let cursorRow: number; + let cursorCol: number; + if (cursorOffset > 0 && cursorOffset % termWidth === 0) { + cursorRow = cursorOffset / termWidth - 1; + cursorCol = termWidth; + } else { + cursorRow = Math.floor(cursorOffset / termWidth); + cursorCol = (cursorOffset % termWidth) + 1; + } + + // Calculate total lines for suggestions positioning + const totalLength = prompt.length + input.length; + const totalLines = Math.ceil(totalLength / termWidth) || 1; // Get and display suggestions if input starts with / if (input.startsWith("/") && input.length > 1) { @@ -85,8 +111,9 @@ export function autocompleteInput(config: AutocompleteConfig): Promise { selectedIndex = suggestions.length - 1; } + // Move to end of input text before showing suggestions + // Cursor is currently at end of text, just go to new line stdout.write("\n"); - displayedLines = suggestions.length; for (let i = 0; i < suggestions.length; i++) { const opt = suggestions[i]!; @@ -102,33 +129,32 @@ export function autocompleteInput(config: AutocompleteConfig): Promise { } } - // Move cursor back up to input line - if (displayedLines > 0) { - stdout.write(CURSOR_UP(displayedLines)); - } + // Move cursor back up to input line (accounting for wrapped lines) + const linesFromEnd = totalLines - 1 - cursorRow; + stdout.write(CURSOR_UP(suggestions.length + linesFromEnd)); stdout.write(CURSOR_TO_COL(cursorCol)); } } else { suggestions = []; selectedIndex = -1; } - }; - const clearSuggestions = () => { - if (displayedLines > 0) { - // Move down and clear each line - for (let i = 0; i < displayedLines; i++) { - stdout.write(`\n${CLEAR_LINE}`); - } - // Move back up - stdout.write(CURSOR_UP(displayedLines)); - displayedLines = 0; + // Position cursor for wrapped text + // After writing, cursor is at end of text. Move to correct position. + // Go back to start of input block, then move to target row/col + const endRow = totalLines - 1; + if (endRow > cursorRow) { + stdout.write(CURSOR_UP(endRow - cursorRow)); } + stdout.write(CURSOR_TO_COL(cursorCol)); }; const submit = (value: string) => { + // Clear suggestions before submitting + stdout.write(RESTORE_CURSOR); + stdout.write(CLEAR_TO_END); + stdout.write(`${prompt}${value}\n`); cleanup(); - stdout.write("\n"); resolve(value); };