From afcd972dd080ed5f76253edb7c02364f7ea24594 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 24 Jul 2026 12:47:23 +0200 Subject: [PATCH] Keep terminal pairing QR codes scannable (#2381) * fix(pairing): keep terminal QR codes scannable Prompt framing could wrap dense QR codes and alter long pairing links. Print pairing instructions directly, add the standard quiet zone, and suppress codes that cannot fit without auto-wrapping. * fix(pairing): contain terminal QR background --- packages/cli/src/commands/daemon/pair.ts | 10 +++- packages/cli/src/commands/onboard.ts | 11 ++-- packages/cli/src/output/pairing.test.ts | 30 +++++++++++ packages/cli/src/output/pairing.ts | 37 ++++++++++++++ packages/server/src/server/pairing-qr.test.ts | 28 +++++++++++ packages/server/src/server/pairing-qr.ts | 50 ++++--------------- 6 files changed, 119 insertions(+), 47 deletions(-) create mode 100644 packages/cli/src/output/pairing.test.ts create mode 100644 packages/cli/src/output/pairing.ts create mode 100644 packages/server/src/server/pairing-qr.test.ts diff --git a/packages/cli/src/commands/daemon/pair.ts b/packages/cli/src/commands/daemon/pair.ts index a30f0e2cc..a2ca3aab2 100644 --- a/packages/cli/src/commands/daemon/pair.ts +++ b/packages/cli/src/commands/daemon/pair.ts @@ -4,6 +4,7 @@ import { generateLocalPairingOffer, loadConfig, resolvePaseoHome } from "@getpas import { tryConnectToDaemon } from "../../utils/client.js"; import { resolveLocalDaemonState, resolveTcpHostFromListen } from "./local-daemon.js"; import { addJsonOption } from "../../utils/command-options.js"; +import { formatPairingInstructions } from "../../output/pairing.js"; interface PairOptions { home?: string; @@ -97,6 +98,11 @@ function outputPairingResult( return; } - const qrBlock = pairing.qr ? `${pairing.qr}\n` : ""; - process.stdout.write(`\nScan to pair:\n${qrBlock}${pairing.url}\n`); + process.stdout.write( + formatPairingInstructions({ + url: pairing.url, + qr: pairing.qr, + columns: process.stdout.columns, + }), + ); } diff --git a/packages/cli/src/commands/onboard.ts b/packages/cli/src/commands/onboard.ts index 2600bdb29..22e10cf4a 100644 --- a/packages/cli/src/commands/onboard.ts +++ b/packages/cli/src/commands/onboard.ts @@ -18,6 +18,7 @@ import { type DaemonStartOptions, } from "./daemon/local-daemon.js"; import { tryConnectToDaemon } from "../utils/client.js"; +import { formatPairingInstructions } from "../output/pairing.js"; interface OnboardOptions extends DaemonStartOptions { timeout?: string; @@ -520,11 +521,13 @@ export async function runOnboard(options: OnboardOptions): Promise { return; } - renderNote( - pairing.qr ?? "QR is unavailable in this terminal. Use the pairing link below.", - "Scan to pair", + process.stdout.write( + formatPairingInstructions({ + url: pairing.url, + qr: pairing.qr, + columns: process.stdout.columns, + }), ); - renderNote(pairing.url, "Pairing link"); printNextSteps(pairing.url, paseoHome, richUi); if (richUi) { outro("Paseo is ready!"); diff --git a/packages/cli/src/output/pairing.test.ts b/packages/cli/src/output/pairing.test.ts new file mode 100644 index 000000000..00ed6b0a8 --- /dev/null +++ b/packages/cli/src/output/pairing.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { formatPairingInstructions } from "./pairing.js"; + +const QR = "\u001b[47m\u001b[30m \n ████ \n \u001b[0m"; +const URL = "https://app.paseo.sh/#offer=pairing-offer"; + +describe("formatPairingInstructions", () => { + it("prints the QR and an unmodified pairing-link line when the terminal is wide enough", () => { + const output = formatPairingInstructions({ qr: QR, url: URL, columns: 7 }); + + expect(output).toContain(QR); + expect(output.split("\n")).toContain(URL); + }); + + it("does not print a QR that would reach the terminal edge", () => { + const output = formatPairingInstructions({ qr: QR, url: URL, columns: 6 }); + + expect(output).not.toContain(QR); + expect(output).toContain("Resize the terminal to at least 7 columns"); + expect(output.split("\n")).toContain(URL); + }); + + it("does not risk printing a QR when terminal width is unknown", () => { + const output = formatPairingInstructions({ qr: QR, url: URL }); + + expect(output).not.toContain(QR); + expect(output).toContain("terminal width could not be detected"); + expect(output.split("\n")).toContain(URL); + }); +}); diff --git a/packages/cli/src/output/pairing.ts b/packages/cli/src/output/pairing.ts new file mode 100644 index 000000000..6d0da5bf6 --- /dev/null +++ b/packages/cli/src/output/pairing.ts @@ -0,0 +1,37 @@ +const ANSI_PATTERN = new RegExp(`${String.fromCharCode(0x1b)}\\[[0-9;]*m`, "g"); + +interface PairingInstructions { + url: string; + qr: string | null; + columns?: number; +} + +function visibleWidth(value: string): number { + return Math.max( + ...value + .replace(ANSI_PATTERN, "") + .split("\n") + .map((line) => line.length), + ); +} + +function formatQr(qr: string | null, columns: number | undefined): string { + if (!qr) { + return "QR code is unavailable. Use the pairing link below."; + } + + if (columns === undefined) { + return "QR code not shown because terminal width could not be detected."; + } + + const width = visibleWidth(qr); + if (columns <= width) { + return `QR code not shown. Resize the terminal to at least ${width + 1} columns, then run this command again.`; + } + + return qr; +} + +export function formatPairingInstructions({ url, qr, columns }: PairingInstructions): string { + return `\nScan to pair:\n${formatQr(qr, columns)}\n\nPairing link:\n${url}\n`; +} diff --git a/packages/server/src/server/pairing-qr.test.ts b/packages/server/src/server/pairing-qr.test.ts new file mode 100644 index 000000000..27f317c6e --- /dev/null +++ b/packages/server/src/server/pairing-qr.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import * as QRCode from "qrcode"; +import { renderPairingQr } from "./pairing-qr.js"; + +const ESCAPE = String.fromCharCode(0x1b); +const ANSI_PATTERN = new RegExp(`${ESCAPE}\\[[0-9;]*m`, "g"); + +describe("renderPairingQr", () => { + it("renders a theme-independent QR code with a four-module quiet zone", async () => { + const url = "https://app.paseo.sh/#offer=test-pairing-offer"; + const qr = await renderPairingQr(url); + const styledLines = qr.split("\n"); + const visibleLines = qr.replace(ANSI_PATTERN, "").split("\n"); + const moduleCount = QRCode.create(url).modules.size; + + expect( + styledLines.every( + (line) => line.startsWith(`${ESCAPE}[47m${ESCAPE}[30m`) && line.endsWith(`${ESCAPE}[0m`), + ), + ).toBe(true); + expect(visibleLines.every((line) => line.length === moduleCount + 8)).toBe(true); + expect(visibleLines.slice(0, 2).every((line) => line.trim() === "")).toBe(true); + expect(visibleLines.slice(-2).every((line) => line.trim() === "")).toBe(true); + expect(visibleLines.every((line) => line.startsWith(" ") && line.endsWith(" "))).toBe( + true, + ); + }); +}); diff --git a/packages/server/src/server/pairing-qr.ts b/packages/server/src/server/pairing-qr.ts index 8b8f31f44..7d9d13167 100644 --- a/packages/server/src/server/pairing-qr.ts +++ b/packages/server/src/server/pairing-qr.ts @@ -1,50 +1,18 @@ import * as QRCode from "qrcode"; -import type { QRCodeToStringOptionsTerminal, QRCodeToStringOptionsOther } from "qrcode"; -import type { Logger } from "pino"; +import type { QRCodeToStringOptionsOther } from "qrcode"; -function parseBooleanEnv(value: string | undefined): boolean | undefined { - if (value === undefined) return undefined; - const normalized = value.trim().toLowerCase(); - if (["1", "true", "yes", "y", "on"].includes(normalized)) return true; - if (["0", "false", "no", "n", "off"].includes(normalized)) return false; - return undefined; -} - -function shouldPrintPairingQr(): boolean { - const env = parseBooleanEnv(process.env.PASEO_PAIRING_QR); - if (env !== undefined) return env; - return process.stdout.isTTY ?? false; -} +const BLACK_ON_WHITE = "\u001b[47m\u001b[30m"; +const RESET_COLORS = "\u001b[0m"; export async function renderPairingQr(url: string): Promise { - const terminalOptions: QRCodeToStringOptionsTerminal = { - type: "terminal", - small: true, - }; - const utf8Options: QRCodeToStringOptionsOther = { type: "utf8", + margin: 4, }; - try { - return await QRCode.toString(url, terminalOptions); - } catch { - return await QRCode.toString(url, utf8Options); - } -} - -export async function printPairingQrIfEnabled(args: { - url: string; - logger?: Logger; -}): Promise { - if (!shouldPrintPairingQr()) return; - - const qr = await renderPairingQr(args.url); - const out = `\nScan to pair:\n${qr}\n${args.url}\n`; - - try { - process.stdout.write(out); - } catch (error) { - args.logger?.debug({ error }, "Failed to print pairing QR"); - } + const qr = await QRCode.toString(url, utf8Options); + return qr + .split("\n") + .map((line) => `${BLACK_ON_WHITE}${line}${RESET_COLORS}`) + .join("\n"); }