From cff858e5841668eb60f7b2ccd27f3bd38c09c4f3 Mon Sep 17 00:00:00 2001 From: Dhruva Reddy Date: Wed, 22 Apr 2026 20:58:15 -0700 Subject: [PATCH] fix(call): properly clear wrapped partial transcripts in TTY MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The live partial-transcript overwrite in `handleControlMessage` used `\r` + spaces + `\r` to repaint each partial in place. `\r` only moves the cursor to column 0 of the *current* terminal row, so when a partial is long enough to wrap (very common for assistant utterances and any narrow terminal), only the bottom row of the wrapped block is cleared. Every previous wrapped row stays on screen, and each subsequent partial paints another wrapping block on top — producing the duplicated stack of `šŸ¤– Assistant: ... Seller C` lines reported during a recent simulated call. Replace the broken sequence with `readline.cursorTo` / `readline.clearLine` / `readline.moveCursor` driven by an estimated display width that accounts for emoji and CJK glyphs being 2 cells wide. Apply the same clear before printing speech-update, call-ended, ws.onclose, and SIGINT/SIGTERM cleanup messages so they don't print over a half-rendered partial. Also gate live partial overwrites on `process.stdout.isTTY`: when the output is being piped (CI logs, `tee`, etc.), `\r` was producing literal carriage returns and concatenated lines. In non-TTY mode we now skip partials entirely and only print finals — one transcript per line, clean logs. No public API change. `tsc --noEmit` and `npm test` (33/33) pass. --- src/call.ts | 91 ++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 76 insertions(+), 15 deletions(-) diff --git a/src/call.ts b/src/call.ts index a0a5787..7079dc8 100644 --- a/src/call.ts +++ b/src/call.ts @@ -435,7 +435,9 @@ async function connectWebSocket( // Graceful shutdown const cleanup = () => { - console.log("\nšŸ‘‹ Ending call..."); + clearWrittenLine(process.stdout, lastTranscript); + lastTranscript = ""; + console.log("šŸ‘‹ Ending call..."); if (micStream) { micStream.stop(); } @@ -508,12 +510,69 @@ async function connectWebSocket( }; ws.onclose = (event) => { - console.log(`\nšŸ““ Call ended (code: ${event.code})`); + clearWrittenLine(process.stdout, lastTranscript); + lastTranscript = ""; + console.log(`šŸ““ Call ended (code: ${event.code})`); cleanup(); }; }); } +// Approximate terminal display width of a string. Most terminals render +// emojis and CJK glyphs as 2 cells and ASCII as 1; we use a coarse range +// check rather than pulling in a full Unicode width table. Iteration is by +// code point so surrogate pairs (emoji) count once. +function getDisplayWidth(text: string): number { + let width = 0; + for (const char of text) { + const code = char.codePointAt(0) ?? 0; + if (code === 0xfe0f || (code >= 0x200b && code <= 0x200f)) { + // Variation selectors / zero-width joiners: no display width + continue; + } + if ( + (code >= 0x1100 && code <= 0x115f) || // Hangul Jamo + (code >= 0x2e80 && code <= 0x303e) || // CJK radicals / punctuation + (code >= 0x3041 && code <= 0x33ff) || // Hiragana, Katakana, etc. + (code >= 0x3400 && code <= 0x4dbf) || // CJK Extension A + (code >= 0x4e00 && code <= 0x9fff) || // CJK Unified Ideographs + (code >= 0xa000 && code <= 0xa4cf) || // Yi Syllables + (code >= 0xac00 && code <= 0xd7a3) || // Hangul Syllables + (code >= 0xf900 && code <= 0xfaff) || // CJK Compatibility Ideographs + (code >= 0xfe30 && code <= 0xfe4f) || // CJK Compatibility Forms + (code >= 0xff00 && code <= 0xff60) || // Fullwidth forms + (code >= 0xffe0 && code <= 0xffe6) || // Fullwidth signs + (code >= 0x1f300 && code <= 0x1f64f) || // Emoji: misc symbols / pictographs / emoticons + (code >= 0x1f680 && code <= 0x1f6ff) || // Emoji: transport / map + (code >= 0x1f900 && code <= 0x1f9ff) || // Supplemental symbols / pictographs + (code >= 0x1fa70 && code <= 0x1faff) || // Symbols & pictographs extended-A + (code >= 0x2600 && code <= 0x27bf) // Misc symbols, dingbats + ) { + width += 2; + } else { + width += 1; + } + } + return width; +} + +// Erase the previously-written partial transcript, accounting for terminal +// wrap. \r alone only returns to column 0 of the *current* row, so wrapped +// content above the cursor would otherwise stay on screen and pile up as +// the partial is rewritten over and over. +function clearWrittenLine(stream: NodeJS.WriteStream, text: string): void { + if (!text || !stream.isTTY) return; + const cols = stream.columns || 80; + const rows = Math.max(1, Math.ceil(getDisplayWidth(text) / cols)); + + readline.cursorTo(stream, 0); + readline.clearLine(stream, 0); + for (let i = 1; i < rows; i++) { + readline.moveCursor(stream, 0, -1); + readline.clearLine(stream, 0); + } +} + function handleControlMessage( message: ControlMessage, lastTranscript: string, @@ -523,20 +582,18 @@ function handleControlMessage( case "transcript": { const tm = message as TranscriptMessage; const prefix = tm.role === "user" ? "šŸŽ¤ You" : "šŸ¤– Assistant"; + const line = `${prefix}: ${tm.transcript}`; if (tm.transcriptType === "final") { - // Clear partial and show final - process.stdout.write( - "\r" + " ".repeat(lastTranscript.length + 20) + "\r", - ); - console.log(`${prefix}: ${tm.transcript}`); + clearWrittenLine(process.stdout, lastTranscript); + console.log(line); setLastTranscript(""); - } else { - // Show partial (overwrite previous partial) - const line = `${prefix}: ${tm.transcript}`; - process.stdout.write( - "\r" + " ".repeat(lastTranscript.length + 20) + "\r", - ); + } else if (process.stdout.isTTY) { + // Live partial overwrite only makes sense in a TTY. In non-TTY + // output (piped to a file, CI logs, etc.) every partial would + // print as its own line and produce huge spam — skip them and + // wait for the final. + clearWrittenLine(process.stdout, lastTranscript); process.stdout.write(line); setLastTranscript(line); } @@ -546,7 +603,9 @@ function handleControlMessage( const sm = message as SpeechUpdateMessage; if (sm.status === "started") { const who = sm.role === "user" ? "You" : "Assistant"; - console.log(`\nšŸ’¬ ${who} started speaking...`); + clearWrittenLine(process.stdout, lastTranscript); + if (lastTranscript) setLastTranscript(""); + console.log(`šŸ’¬ ${who} started speaking...`); } break; } @@ -565,7 +624,9 @@ function handleControlMessage( "assistant-not-found": "Assistant not found", }; const label = cm.reason ? (reasonLabels[cm.reason] ?? cm.reason) : "unknown reason"; - console.log(`\nšŸ“ž Call ended: ${label}`); + clearWrittenLine(process.stdout, lastTranscript); + if (lastTranscript) setLastTranscript(""); + console.log(`šŸ“ž Call ended: ${label}`); break; } default: