Skip to content

Commit e210277

Browse files
authored
fix(call): clear wrapped partial transcripts cleanly in npm run call (#11)
## Describe your changes Fixes a long-standing display bug in `npm run call` where partial assistant transcripts visibly duplicate themselves in the terminal, leaving stacks of `🤖 Assistant: ...` lines on screen instead of overwriting cleanly. - **Root cause:** `handleControlMessage` in `src/call.ts` was repainting partials with `\r` + N spaces + `\r`. `\r` only returns the cursor to column 0 of the *current* terminal row, so when a partial wraps to multiple rows (very common for assistant utterances on an 80-col terminal — the example that surfaced this was 110 cells wide), only the bottom row gets cleared. Every wrapped row above stays on screen, and the next partial paints another wrapping block on top. - **Fix:** Compute the previous partial's row count from `process.stdout.columns` and an estimated display width that treats emoji and CJK glyphs as 2 cells, then walk the cursor up with `readline.moveCursor` / `readline.cursorTo` / `readline.clearLine` to erase every row, not just the bottom one. - **Apply the same clear** before printing speech-update events, the call-ended message, the WebSocket `onclose` log, and the SIGINT/SIGTERM cleanup banner so they don't print on top of an in-flight partial either. - **Non-TTY mode:** When `process.stdout.isTTY` is false (piping to `tee`, CI logs, redirecting to a file), `\r` was producing literal carriage returns and concatenated lines. We now skip live partial overwrites entirely in non-TTY contexts and only emit finals — one transcript per line, clean logs. No new dependencies. Pure refactor of an existing function plus two small helpers (`getDisplayWidth`, `clearWrittenLine`) below `connectWebSocket`. ## Relevant Context (linear ticket, slack link, etc) Surfaced during a manual simulated call (`npm run call -- <org> -s <squad>`) — every assistant turn was producing 5–10 stacked copies of the same `🤖 Assistant: ... Seller C` partial in the terminal, with long stretches of trailing whitespace between them. The duplication signature (identical content, growing whitespace gaps) traced back to `\r` only clearing one wrapped row instead of all of them. ## API Changes - **Is this changing the public API?** - [ ] Yes - [x] No - **If yes, is it backward‐compatible?** - [ ] Yes - [ ] No N/A. This repo is an internal gitops CLI; the only user-visible change is that `npm run call` now renders partial transcripts cleanly in TTY and emits one line per final transcript in non-TTY contexts. No flags, no commands, no on-disk schema, no platform requests change. Non backward-compatible changes might break customers' agents. Please proceed with care and notify the team. ## How did you test this? **Automated:** - `npx tsc --noEmit` — clean. - `npm test` — 33/33 passing (no new tests added; existing suite covers the credential walker, path matching, cleanup safety, CLI arg parsing, and clean-resource regression set). **Width math sanity check:** - The exact partial that was duplicating in the reported call (`🤖 Assistant: Understood. Let's pick up right where we left off. The last instruction was: first open Seller C`) has a display width of **110 cells** under the new `getDisplayWidth`, which correctly resolves to **2 rows on an 80-col terminal**, **2 rows at 100 cols**, and **1 row at 120+ cols**. Short partials and `🎤 You: ...` lines correctly resolve to 1 row at all standard widths. **Manual smoke test flagged for reviewer** (requires real terminal + a configured org with an assistant or squad): - `npm run call -- <org> -a <name>` (or `-s <squad>`) and have a multi-sentence exchange. Confirm that long assistant partials overwrite in place — no stack of duplicate `🤖 Assistant: ...` lines, no leftover wrapped rows. - Resize the terminal mid-call to a narrow width (e.g. 50 cols) and continue the conversation; partials should still overwrite cleanly when they wrap to 3+ rows. - Pipe the output to a file: `npm run call -- <org> -a <name> | tee /tmp/call.log`. Confirm the log contains only finals (one per line), no `\r` artifacts, no duplicated partials. - Press Ctrl+C while a partial is on screen; confirm `👋 Ending call...` prints cleanly without the partial visible above it.
2 parents b46ee88 + cff858e commit e210277

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)