From 0ecf4aa664db4a9cdf1f813a5632f84c373aec46 Mon Sep 17 00:00:00 2001 From: B <6723574+louisgv@users.noreply.github.com> Date: Thu, 26 Mar 2026 07:50:03 +0000 Subject: [PATCH 1/2] 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 --- packages/cli/package.json | 2 +- .../src/__tests__/cmdrun-happy-path.test.ts | 25 ++++++++++--------- packages/cli/src/commands/run.ts | 24 ++++++++---------- packages/cli/src/shared/orchestrate.ts | 7 ++++++ 4 files changed, 32 insertions(+), 26 deletions(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index 2fde4db11..9b6a9f4bc 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.8", "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 2f2bda5aa..867ee71ae 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/run.ts b/packages/cli/src/commands/run.ts index 869b715ed..5254079fd 100644 --- a/packages/cli/src/commands/run.ts +++ b/packages/cli/src/commands/run.ts @@ -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 { - 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/shared/orchestrate.ts b/packages/cli/src/shared/orchestrate.ts index 7fe20cece..233466fc0 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); From dca6ec73706ac0c35078c5a28811e71b43554da5 Mon Sep 17 00:00:00 2001 From: B <6723574+louisgv@users.noreply.github.com> Date: Thu, 26 Mar 2026 07:59:35 +0000 Subject: [PATCH 2/2] 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 --- packages/cli/package.json | 2 +- packages/cli/src/commands/delete.ts | 4 +++- packages/cli/src/commands/link.ts | 8 ++++++-- 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/ssh.ts | 23 +++++++++++++++++++++++ packages/cli/src/shared/ui.ts | 2 ++ 8 files changed, 44 insertions(+), 7 deletions(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index 9b6a9f4bc..12e4d3b92 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@openrouter/spawn", - "version": "0.26.8", + "version": "0.26.9", "type": "module", "bin": { "spawn": "cli.js" diff --git a/packages/cli/src/commands/delete.ts b/packages/cli/src/commands/delete.ts index 8d2540703..3146158e7 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 c43b166c2..95eba0f4e 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(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 4b6e0aa5b..69af210af 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 ba8e8a2a9..44467cca3 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/ssh.ts b/packages/cli/src/shared/ssh.ts index 71fd5bbf2..1cf025735 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 e68ee5999..0b3c4d481 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";