From 52d06c4cb57d2d802c30005bc4041e7d3a2c2393 Mon Sep 17 00:00:00 2001 From: A <258483684+la14-1@users.noreply.github.com> Date: Thu, 26 Mar 2026 01:28:32 -0700 Subject: [PATCH] fix: resolve ANSI spinner corruption and garbled output (#3001) (#3003) * fix(ux): replace download spinner with stderr logging, reset terminal before SSH handoff Fixes two UX issues from live E2E session (#3001): 1. Download spinner (p.spinner from @clack/prompts) wrote ANSI escape codes to stdout. When stdout is captured (E2E harness, piped output), these sequences appeared as raw text rather than rendered colors. Replace p.spinner() in downloadScriptWithFallback and downloadBundle with logStep/logInfo/logError from shared/ui.ts, which write to stderr and correctly check isTTY before emitting ANSI codes. 2. Garbled output at start of interactive session (overlapping status lines from the remote agent's TUI) may be caused by residual ANSI state from @clack/prompts (hidden cursor, active color attributes). Emit ESC[?25h ESC[0m to stderr before prepareStdinForHandoff() to explicitly restore cursor visibility and reset all attributes before the SSH session takes over. Agent: issue-fixer Co-Authored-By: Claude Sonnet 4.6 * fix: resolve ANSI spinner corruption and garbled output in interactive mode (#3001) Three root causes fixed: 1. Spinner wrote to stdout while all other CLI status output goes to stderr, causing ANSI escape sequence interleaving and corruption when both streams are merged on a terminal. Redirected all p.spinner() calls to process.stderr. 2. unicode-detect.ts (which sets TERM=linux for SSH sessions to force ASCII fallback) was only imported in commands/shared.ts but not in shared/ui.ts. Cloud module entry points (hetzner/main.ts, etc.) that import shared/ui.ts loaded @clack/prompts without the TERM override, causing Unicode spinner frames in environments that can't render them. 3. After an interactive SSH session ends, the remote agent's TUI (e.g. Claude Code) may leave the terminal in raw mode with altered attributes. Added terminal reset (ANSI attribute reset + stty sane) after spawnInteractive() returns to prevent garbled post-session output. Agent: ux-engineer Co-Authored-By: Claude Sonnet 4.5 --------- Co-authored-by: B <6723574+louisgv@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.6 --- packages/cli/package.json | 2 +- .../src/__tests__/cmdrun-happy-path.test.ts | 25 ++++++++++--------- packages/cli/src/commands/delete.ts | 4 ++- packages/cli/src/commands/link.ts | 8 ++++-- packages/cli/src/commands/run.ts | 24 ++++++++---------- packages/cli/src/commands/shared.ts | 4 ++- packages/cli/src/commands/status.ts | 4 ++- packages/cli/src/commands/update.ts | 4 ++- packages/cli/src/shared/orchestrate.ts | 7 ++++++ packages/cli/src/shared/ssh.ts | 23 +++++++++++++++++ packages/cli/src/shared/ui.ts | 2 ++ 11 files changed, 75 insertions(+), 32 deletions(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index 2fde4db1..12e4d3b9 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@openrouter/spawn", - "version": "0.26.7", + "version": "0.26.9", "type": "module", "bin": { "spawn": "cli.js" diff --git a/packages/cli/src/__tests__/cmdrun-happy-path.test.ts b/packages/cli/src/__tests__/cmdrun-happy-path.test.ts index 2f2bda5a..867ee71a 100644 --- a/packages/cli/src/__tests__/cmdrun-happy-path.test.ts +++ b/packages/cli/src/__tests__/cmdrun-happy-path.test.ts @@ -113,11 +113,13 @@ describe("cmdRun happy-path pipeline", () => { let consoleMocks: ReturnType; let originalFetch: typeof global.fetch; let processExitSpy: ReturnType; + let stderrSpy: ReturnType; let historyDir: string; let originalSpawnHome: string | undefined; beforeEach(async () => { consoleMocks = createConsoleMocks(); + stderrSpy = spyOn(process.stderr, "write").mockImplementation(() => true); mockLogError.mockClear(); mockLogInfo.mockClear(); mockLogStep.mockClear(); @@ -144,6 +146,7 @@ describe("cmdRun happy-path pipeline", () => { afterEach(() => { global.fetch = originalFetch; processExitSpy.mockRestore(); + stderrSpy.mockRestore(); restoreMocks(consoleMocks.log, consoleMocks.error); // Clean up history directory @@ -173,7 +176,7 @@ describe("cmdRun happy-path pipeline", () => { expect(scriptFetches[0].url).toContain("openrouter.ai"); }); - it("should show spinner start and stop for successful download", async () => { + it("should log download start and completion messages for successful download", async () => { global.fetch = mockFetchForDownload({ primaryOk: true, }); @@ -181,11 +184,9 @@ describe("cmdRun happy-path pipeline", () => { await cmdRun("claude", "sprite"); - const startCalls = mockSpinnerStart.mock.calls.map((c: unknown[]) => c[0]); - expect(startCalls.some((msg: string) => msg.includes("Downloading"))).toBe(true); - - const stopCalls = mockSpinnerStop.mock.calls.map((c: unknown[]) => c[0]); - expect(stopCalls.some((msg: string) => isString(msg) && msg.includes("downloaded"))).toBe(true); + const stderrOutput = stderrSpy.mock.calls.map((c: unknown[]) => String(c[0])).join(""); + expect(stderrOutput).toContain("Downloading"); + expect(stderrOutput).toContain("downloaded"); }); it("should not call process.exit on successful execution", async () => { @@ -220,7 +221,7 @@ describe("cmdRun happy-path pipeline", () => { expect(scriptFetches[1].url).toContain("raw.githubusercontent.com"); }); - it("should show fallback spinner message when primary fails", async () => { + it("should log fallback step message when primary fails", async () => { global.fetch = mockFetchForDownload({ primaryOk: false, primaryStatus: 502, @@ -230,11 +231,11 @@ describe("cmdRun happy-path pipeline", () => { await cmdRun("claude", "sprite"); - const messageCalls = mockSpinnerMessage.mock.calls.map((c: unknown[]) => c[0]); - expect(messageCalls.some((msg: string) => msg.includes("fallback"))).toBe(true); + const stderrOutput = stderrSpy.mock.calls.map((c: unknown[]) => String(c[0])).join(""); + expect(stderrOutput).toContain("fallback"); }); - it("should show 'fallback' in stop message when fallback succeeds", async () => { + it("should log 'fallback' in completion message when fallback succeeds", async () => { global.fetch = mockFetchForDownload({ primaryOk: false, primaryStatus: 403, @@ -244,8 +245,8 @@ describe("cmdRun happy-path pipeline", () => { await cmdRun("claude", "sprite"); - const stopCalls = mockSpinnerStop.mock.calls.map((c: unknown[]) => c[0]); - expect(stopCalls.some((msg: string) => isString(msg) && msg.includes("fallback"))).toBe(true); + const stderrOutput = stderrSpy.mock.calls.map((c: unknown[]) => String(c[0])).join(""); + expect(stderrOutput).toContain("fallback"); }); }); diff --git a/packages/cli/src/commands/delete.ts b/packages/cli/src/commands/delete.ts index 8d254070..3146158e 100644 --- a/packages/cli/src/commands/delete.ts +++ b/packages/cli/src/commands/delete.ts @@ -204,7 +204,9 @@ export async function confirmAndDelete( await ensureDeleteCredentials(record); } - const s = p.spinner(); + const s = p.spinner({ + output: process.stderr, + }); s.start(`Deleting ${label}...`); // Cloud destroy functions log progress to stderr (logStep/logInfo). diff --git a/packages/cli/src/commands/link.ts b/packages/cli/src/commands/link.ts index c43b166c..95eba0f4 100644 --- a/packages/cli/src/commands/link.ts +++ b/packages/cli/src/commands/link.ts @@ -247,7 +247,9 @@ export async function cmdLink(args: string[], options?: LinkOptions): Promise { - const s = p.spinner(); - s.start("Downloading spawn script..."); + logStep("Downloading spawn script..."); const r = await asyncTryCatch(async () => { const res = await fetch(primaryUrl, { @@ -212,26 +211,26 @@ async function downloadScriptWithFallback(primaryUrl: string, fallbackUrl: strin }); if (res.ok) { const text = await res.text(); - s.stop("Script downloaded"); + logInfo("Script downloaded"); return text; } // Fallback to GitHub raw - s.message("Trying fallback source..."); + logStep("Trying fallback source..."); const ghRes = await fetch(fallbackUrl, { signal: AbortSignal.timeout(FETCH_TIMEOUT), }); if (!ghRes.ok) { - s.stop(pc.red("Download failed")); + logError("Download failed"); reportDownloadFailure(res.status, ghRes.status); process.exit(1); } const text = await ghRes.text(); - s.stop("Script downloaded (fallback)"); + logInfo("Script downloaded (fallback)"); return text; }); if (!r.ok) { - s.stop(pc.red("Download failed")); + logError("Download failed"); throw r.error; } return r.data; @@ -589,8 +588,7 @@ function runBashScript( */ async function downloadBundle(cloud: string): Promise { const bundleUrl = `https://github.com/${REPO}/releases/download/${cloud}-latest/${cloud}.js`; - const s = p.spinner(); - s.start("Downloading spawn bundle..."); + logStep("Downloading spawn bundle..."); const r = await asyncTryCatch(async () => { const res = await fetch(bundleUrl, { @@ -598,16 +596,16 @@ async function downloadBundle(cloud: string): Promise { redirect: "follow", }); if (!res.ok) { - s.stop(pc.red("Download failed")); + logError("Download failed"); p.log.error(`Bundle not found at ${bundleUrl} (HTTP ${res.status})`); process.exit(2); } const text = await res.text(); - s.stop("Bundle downloaded"); + logInfo("Bundle downloaded"); return text; }); if (!r.ok) { - s.stop(pc.red("Download failed")); + logError("Download failed"); throw r.error; } return r.data; diff --git a/packages/cli/src/commands/shared.ts b/packages/cli/src/commands/shared.ts index 329899af..f51783d2 100644 --- a/packages/cli/src/commands/shared.ts +++ b/packages/cli/src/commands/shared.ts @@ -31,7 +31,9 @@ export function handleCancel(): never { } async function withSpinner(msg: string, fn: () => Promise, doneMsg?: string): Promise { - const s = p.spinner(); + const s = p.spinner({ + output: process.stderr, + }); s.start(msg); const r = await asyncTryCatch(fn); s.stop(r.ok ? (doneMsg ?? msg.replace(/\.{3}$/, "")) : pc.red("Failed")); diff --git a/packages/cli/src/commands/status.ts b/packages/cli/src/commands/status.ts index 4b6e0aa5..69af210a 100644 --- a/packages/cli/src/commands/status.ts +++ b/packages/cli/src/commands/status.ts @@ -304,7 +304,9 @@ export async function cmdStatus( const goneRecords = results.filter((r) => r.liveState === "gone").map((r) => r.record); if (opts.prune && goneRecords.length > 0) { - const s = p.spinner(); + const s = p.spinner({ + output: process.stderr, + }); s.start(`Pruning ${goneRecords.length} gone server${goneRecords.length !== 1 ? "s" : ""}...`); for (const record of goneRecords) { markRecordDeleted(record); diff --git a/packages/cli/src/commands/update.ts b/packages/cli/src/commands/update.ts index ba8e8a2a..44467cca 100644 --- a/packages/cli/src/commands/update.ts +++ b/packages/cli/src/commands/update.ts @@ -143,7 +143,9 @@ export interface UpdateOptions { } export async function cmdUpdate(options?: UpdateOptions): Promise { - const s = p.spinner(); + const s = p.spinner({ + output: process.stderr, + }); s.start("Checking for updates..."); const r = await asyncTryCatch(() => fetchRemoteVersion()); diff --git a/packages/cli/src/shared/orchestrate.ts b/packages/cli/src/shared/orchestrate.ts index 7fe20cec..233466fc 100644 --- a/packages/cli/src/shared/orchestrate.ts +++ b/packages/cli/src/shared/orchestrate.ts @@ -688,6 +688,13 @@ async function postInstall( logStep("Starting agent..."); + // Reset terminal state before handing off to the interactive SSH session. + // @clack/prompts may have left the cursor hidden or set ANSI attributes + // (e.g. color, bold) that would corrupt the remote agent's TUI rendering. + if (process.stderr.isTTY) { + process.stderr.write("\x1b[?25h\x1b[0m"); + } + prepareStdinForHandoff(); const sessionCmd = cloud.cloudName === "local" ? launchCmd : wrapWithRestartLoop(launchCmd); diff --git a/packages/cli/src/shared/ssh.ts b/packages/cli/src/shared/ssh.ts index 71fd5bbf..1cf02573 100644 --- a/packages/cli/src/shared/ssh.ts +++ b/packages/cli/src/shared/ssh.ts @@ -135,6 +135,29 @@ export function spawnInteractive(args: string[], env?: Record + nodeSpawnSync( + "stty", + [ + "sane", + ], + { + stdio: "inherit", + }, + ), + ); + return result.status ?? 1; } diff --git a/packages/cli/src/shared/ui.ts b/packages/cli/src/shared/ui.ts index e68ee599..0b3c4d48 100644 --- a/packages/cli/src/shared/ui.ts +++ b/packages/cli/src/shared/ui.ts @@ -1,6 +1,8 @@ // shared/ui.ts — Logging, prompts, and browser opening // @clack/prompts is bundled into cli.js at build time. +import "../unicode-detect.js"; // Must run before @clack/prompts: configures TERM for unicode detection + import { readFileSync } from "node:fs"; import * as p from "@clack/prompts"; import { isString } from "@openrouter/spawn-shared";