Skip to content

Commit cff858e

Browse files
committed
fix(call): properly clear wrapped partial transcripts in TTY
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.
1 parent fc7c56b commit cff858e

1 file changed

Lines changed: 76 additions & 15 deletions

File tree

src/call.ts

Lines changed: 76 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -435,7 +435,9 @@ async function connectWebSocket(
435435

436436
// Graceful shutdown
437437
const cleanup = () => {
438-
console.log("\n👋 Ending call...");
438+
clearWrittenLine(process.stdout, lastTranscript);
439+
lastTranscript = "";
440+
console.log("👋 Ending call...");
439441
if (micStream) {
440442
micStream.stop();
441443
}
@@ -508,12 +510,69 @@ async function connectWebSocket(
508510
};
509511

510512
ws.onclose = (event) => {
511-
console.log(`\n📴 Call ended (code: ${event.code})`);
513+
clearWrittenLine(process.stdout, lastTranscript);
514+
lastTranscript = "";
515+
console.log(`📴 Call ended (code: ${event.code})`);
512516
cleanup();
513517
};
514518
});
515519
}
516520

521+
// Approximate terminal display width of a string. Most terminals render
522+
// emojis and CJK glyphs as 2 cells and ASCII as 1; we use a coarse range
523+
// check rather than pulling in a full Unicode width table. Iteration is by
524+
// code point so surrogate pairs (emoji) count once.
525+
function getDisplayWidth(text: string): number {
526+
let width = 0;
527+
for (const char of text) {
528+
const code = char.codePointAt(0) ?? 0;
529+
if (code === 0xfe0f || (code >= 0x200b && code <= 0x200f)) {
530+
// Variation selectors / zero-width joiners: no display width
531+
continue;
532+
}
533+
if (
534+
(code >= 0x1100 && code <= 0x115f) || // Hangul Jamo
535+
(code >= 0x2e80 && code <= 0x303e) || // CJK radicals / punctuation
536+
(code >= 0x3041 && code <= 0x33ff) || // Hiragana, Katakana, etc.
537+
(code >= 0x3400 && code <= 0x4dbf) || // CJK Extension A
538+
(code >= 0x4e00 && code <= 0x9fff) || // CJK Unified Ideographs
539+
(code >= 0xa000 && code <= 0xa4cf) || // Yi Syllables
540+
(code >= 0xac00 && code <= 0xd7a3) || // Hangul Syllables
541+
(code >= 0xf900 && code <= 0xfaff) || // CJK Compatibility Ideographs
542+
(code >= 0xfe30 && code <= 0xfe4f) || // CJK Compatibility Forms
543+
(code >= 0xff00 && code <= 0xff60) || // Fullwidth forms
544+
(code >= 0xffe0 && code <= 0xffe6) || // Fullwidth signs
545+
(code >= 0x1f300 && code <= 0x1f64f) || // Emoji: misc symbols / pictographs / emoticons
546+
(code >= 0x1f680 && code <= 0x1f6ff) || // Emoji: transport / map
547+
(code >= 0x1f900 && code <= 0x1f9ff) || // Supplemental symbols / pictographs
548+
(code >= 0x1fa70 && code <= 0x1faff) || // Symbols & pictographs extended-A
549+
(code >= 0x2600 && code <= 0x27bf) // Misc symbols, dingbats
550+
) {
551+
width += 2;
552+
} else {
553+
width += 1;
554+
}
555+
}
556+
return width;
557+
}
558+
559+
// Erase the previously-written partial transcript, accounting for terminal
560+
// wrap. \r alone only returns to column 0 of the *current* row, so wrapped
561+
// content above the cursor would otherwise stay on screen and pile up as
562+
// the partial is rewritten over and over.
563+
function clearWrittenLine(stream: NodeJS.WriteStream, text: string): void {
564+
if (!text || !stream.isTTY) return;
565+
const cols = stream.columns || 80;
566+
const rows = Math.max(1, Math.ceil(getDisplayWidth(text) / cols));
567+
568+
readline.cursorTo(stream, 0);
569+
readline.clearLine(stream, 0);
570+
for (let i = 1; i < rows; i++) {
571+
readline.moveCursor(stream, 0, -1);
572+
readline.clearLine(stream, 0);
573+
}
574+
}
575+
517576
function handleControlMessage(
518577
message: ControlMessage,
519578
lastTranscript: string,
@@ -523,20 +582,18 @@ function handleControlMessage(
523582
case "transcript": {
524583
const tm = message as TranscriptMessage;
525584
const prefix = tm.role === "user" ? "🎤 You" : "🤖 Assistant";
585+
const line = `${prefix}: ${tm.transcript}`;
526586

527587
if (tm.transcriptType === "final") {
528-
// Clear partial and show final
529-
process.stdout.write(
530-
"\r" + " ".repeat(lastTranscript.length + 20) + "\r",
531-
);
532-
console.log(`${prefix}: ${tm.transcript}`);
588+
clearWrittenLine(process.stdout, lastTranscript);
589+
console.log(line);
533590
setLastTranscript("");
534-
} else {
535-
// Show partial (overwrite previous partial)
536-
const line = `${prefix}: ${tm.transcript}`;
537-
process.stdout.write(
538-
"\r" + " ".repeat(lastTranscript.length + 20) + "\r",
539-
);
591+
} else if (process.stdout.isTTY) {
592+
// Live partial overwrite only makes sense in a TTY. In non-TTY
593+
// output (piped to a file, CI logs, etc.) every partial would
594+
// print as its own line and produce huge spam — skip them and
595+
// wait for the final.
596+
clearWrittenLine(process.stdout, lastTranscript);
540597
process.stdout.write(line);
541598
setLastTranscript(line);
542599
}
@@ -546,7 +603,9 @@ function handleControlMessage(
546603
const sm = message as SpeechUpdateMessage;
547604
if (sm.status === "started") {
548605
const who = sm.role === "user" ? "You" : "Assistant";
549-
console.log(`\n💬 ${who} started speaking...`);
606+
clearWrittenLine(process.stdout, lastTranscript);
607+
if (lastTranscript) setLastTranscript("");
608+
console.log(`💬 ${who} started speaking...`);
550609
}
551610
break;
552611
}
@@ -565,7 +624,9 @@ function handleControlMessage(
565624
"assistant-not-found": "Assistant not found",
566625
};
567626
const label = cm.reason ? (reasonLabels[cm.reason] ?? cm.reason) : "unknown reason";
568-
console.log(`\n📞 Call ended: ${label}`);
627+
clearWrittenLine(process.stdout, lastTranscript);
628+
if (lastTranscript) setLastTranscript("");
629+
console.log(`📞 Call ended: ${label}`);
569630
break;
570631
}
571632
default:

0 commit comments

Comments
 (0)