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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
A 2026-03-26 01:28:32 -07:00 committed by GitHub
parent af37ad2db5
commit 52d06c4cb5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 75 additions and 32 deletions

View file

@ -1,6 +1,6 @@
{
"name": "@openrouter/spawn",
"version": "0.26.7",
"version": "0.26.9",
"type": "module",
"bin": {
"spawn": "cli.js"

View file

@ -113,11 +113,13 @@ describe("cmdRun happy-path pipeline", () => {
let consoleMocks: ReturnType<typeof createConsoleMocks>;
let originalFetch: typeof global.fetch;
let processExitSpy: ReturnType<typeof spyOn>;
let stderrSpy: ReturnType<typeof spyOn>;
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");
});
});

View file

@ -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).

View file

@ -247,7 +247,9 @@ export async function cmdLink(args: string[], options?: LinkOptions): Promise<vo
}
// ── Check connectivity ─────────────────────────────────────────────────────
const connectSpinner = p.spinner();
const connectSpinner = p.spinner({
output: process.stderr,
});
connectSpinner.start(`Checking connectivity to ${pc.cyan(ip)}...`);
const reachable = await tcpCheckFn(ip, 22, 10000);
@ -272,7 +274,9 @@ export async function cmdLink(args: string[], options?: LinkOptions): Promise<vo
const needsDetection = !detectedAgent || !detectedCloud;
if (needsDetection) {
const detectSpinner = p.spinner();
const detectSpinner = p.spinner({
output: process.stderr,
});
detectSpinner.start("Auto-detecting agent and cloud provider...");
if (!detectedAgent) {

View file

@ -12,7 +12,7 @@ import { loadManifest, RAW_BASE, REPO, SPAWN_CDN } from "../manifest.js";
import { validateIdentifier, validatePrompt, validateScriptContent } from "../security.js";
import { asyncTryCatch, isFileError, tryCatch, tryCatchIf } from "../shared/result.js";
import { getLocalShell, isWindows } from "../shared/shell.js";
import { prepareStdinForHandoff, toKebabCase } from "../shared/ui.js";
import { logError, logInfo, logStep, prepareStdinForHandoff, toKebabCase } from "../shared/ui.js";
import { promptSetupOptions, promptSpawnName } from "./interactive.js";
import { handleRecordAction } from "./list.js";
import {
@ -203,8 +203,7 @@ export function showDryRunPreview(manifest: Manifest, agent: string, cloud: stri
// ── Script download ──────────────────────────────────────────────────────────
async function downloadScriptWithFallback(primaryUrl: string, fallbackUrl: string): Promise<string> {
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<string> {
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<string> {
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;

View file

@ -31,7 +31,9 @@ export function handleCancel(): never {
}
async function withSpinner<T>(msg: string, fn: () => Promise<T>, doneMsg?: string): Promise<T> {
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"));

View file

@ -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);

View file

@ -143,7 +143,9 @@ export interface UpdateOptions {
}
export async function cmdUpdate(options?: UpdateOptions): Promise<void> {
const s = p.spinner();
const s = p.spinner({
output: process.stderr,
});
s.start("Checking for updates...");
const r = await asyncTryCatch(() => fetchRemoteVersion());

View file

@ -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);

View file

@ -135,6 +135,29 @@ export function spawnInteractive(args: string[], env?: Record<string, string | u
stdio: "inherit",
env: env ?? process.env,
});
// Reset terminal state after the interactive session ends.
// The remote agent's TUI (e.g. Claude Code) may leave the terminal in
// raw mode or with altered attributes, causing garbled post-session output.
if (process.stderr.isTTY) {
process.stderr.write("\x1b[0m\x1b[?25h"); // reset attributes + show cursor
}
if (process.stdout.isTTY) {
process.stdout.write("\x1b[0m\x1b[?25h");
}
// Restore sane terminal settings (cooked mode, echo, etc.)
tryCatch(() =>
nodeSpawnSync(
"stty",
[
"sane",
],
{
stdio: "inherit",
},
),
);
return result.status ?? 1;
}

View file

@ -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";