From 3a016da6f1a7ae320c0d95ff91c0dc5cc814aa3b Mon Sep 17 00:00:00 2001 From: nafeesa nafeesa Date: Thu, 15 Oct 2026 21:49:42 +0530 Subject: [PATCH] fix(cli): render scannable QR on Apple Terminal with full-cell ANSI Replace qrcode-terminal half-block glyphs (small:true) with full-cell ANSI background rendering plus spec quiet zone, fixing unscannable codes on Terminal.app line spacing/fonts. Adds --no-qr and --small-qr options. Closes #133. --- .changeset/tidy-pandas-scan.md | 5 +++ docs/cli.md | 2 + packages/crosscode/package.json | 4 +- packages/crosscode/src/cli.ts | 18 ++++---- packages/crosscode/src/qr.ts | 75 +++++++++++++++++++++++++++++++++ pnpm-lock.yaml | 35 ++++++--------- 6 files changed, 105 insertions(+), 34 deletions(-) create mode 100644 .changeset/tidy-pandas-scan.md create mode 100644 packages/crosscode/src/qr.ts diff --git a/.changeset/tidy-pandas-scan.md b/.changeset/tidy-pandas-scan.md new file mode 100644 index 0000000..6d8ebe6 --- /dev/null +++ b/.changeset/tidy-pandas-scan.md @@ -0,0 +1,5 @@ +--- +"crosscode": patch +--- + +Fix Apple Terminal QR codes failing to scan. Replace half-block glyph rendering with full-cell ANSI background rendering plus spec quiet zone, so the code scans regardless of terminal line spacing, font, or light/dark theme. Adds `--no-qr` and `--small-qr` options. diff --git a/docs/cli.md b/docs/cli.md index bc44e15..733308d 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -95,6 +95,8 @@ crosscode -h |---|---| | `--cloudflared` | Force Cloudflare tunnel (`cloudflared` binary required) | | `--ngrok` | Force ngrok tunnel (`ngrok` binary + auth token required) | +| `--no-qr` | Skip QR graphic, print URL only (useful when piping output) | +| `--small-qr` | Compact QR graphic with smaller quiet zone (same scannable full-cell rendering) | | `--help`, `-h` | Show help message | ## Interactive Controls diff --git a/packages/crosscode/package.json b/packages/crosscode/package.json index 06c12fc..a80fc7c 100644 --- a/packages/crosscode/package.json +++ b/packages/crosscode/package.json @@ -25,7 +25,7 @@ "devDependencies": { "@types/cross-spawn": "^6.0.6", "@types/node": "^26.0.0", - "@types/qrcode-terminal": "^0.12.2", + "@types/qrcode": "^1.5.6", "@types/ws": "^8.5.13", "tsup": "^8.5.1", "typescript": "^5.4.0" @@ -36,7 +36,7 @@ "chalk": "^5.6.2", "cross-spawn": "^7.0.6", "ora": "^9.4.1", - "qrcode-terminal": "^0.12.0", + "qrcode": "^1.5.4", "ws": "^8.18.0" } } diff --git a/packages/crosscode/src/cli.ts b/packages/crosscode/src/cli.ts index dfcf2a5..99bac83 100644 --- a/packages/crosscode/src/cli.ts +++ b/packages/crosscode/src/cli.ts @@ -2,14 +2,13 @@ import { readFile } from "fs/promises" import chalk from "chalk" -import qrcode from "qrcode-terminal" -import { encodeQrPayload } from "@crosscode/shared" import { debug, checkDep, setLogWriter, getFreePort, terminateChild } from "./util" import { logCrosscode, closeAllLogs, crosscodeLogFile, cloudflaredLogFile, opencodeLogFile } from "./log" import { readConfig, saveConfig, getProjectConfig, ensureSessionToken, resolveOpencodeBin } from "./config" import { initSentry, captureCliError } from "./sentry" import * as Sentry from "@sentry/node" import { loginFlow, refreshTier } from "./auth" +import { printTunnelQr as renderTunnelQr } from "./qr" import { proxyAgent } from "./proxy" import { onKeypress, cleanupKeypress } from "./keypress" import { startTunnelProvider } from "./providers/tunnel" @@ -22,14 +21,11 @@ let isShuttingDown = false setLogWriter(logCrosscode) function printTunnelQr(url: string, token: string, opencodePort: number, requestedPort: number) { - const payload = encodeQrPayload({ url, token, v: 1 }) - if (opencodePort !== requestedPort) { - console.log(chalk.yellow(`opencode running on port ${opencodePort}`)) - } - console.log(chalk.cyanBright("\n Scan with CrossCode App:")) - qrcode.generate(payload, { small: true }) - console.log(chalk.grey(`URL: ${url}`)) - console.log(chalk.dim.bold("[Press 'l' for logs • 'h' for help • Ctrl+C to exit]")) + const argv = process.argv.slice(2) + renderTunnelQr(url, token, opencodePort, requestedPort, { + disabled: argv.includes("--no-qr"), + small: argv.includes("--small-qr") || process.env.CROSSCODE_QR_SMALL === "1", + }) } function printHelp() { @@ -105,6 +101,8 @@ ${chalk.yellow.bold("COMMANDS:")} ${chalk.yellow.bold("OPTIONS:")} ${chalk.green("--cloudflared")} Use Cloudflare tunnel (default for free tier) ${chalk.green("--ngrok")} Use ngrok tunnel (requires auth token) + ${chalk.green("--no-qr")} Skip QR graphic, print URL only + ${chalk.green("--small-qr")} Compact QR graphic (smaller margin) ${chalk.green("--help, -h")} Show this help message ${chalk.yellow.bold("EXAMPLES:")} diff --git a/packages/crosscode/src/qr.ts b/packages/crosscode/src/qr.ts new file mode 100644 index 0000000..6752af2 --- /dev/null +++ b/packages/crosscode/src/qr.ts @@ -0,0 +1,75 @@ +import chalk from "chalk" +import QRCode from "qrcode" +import { encodeQrPayload } from "@crosscode/shared" +import { debug } from "./util" + +// Full-cell ANSI rendering: each QR module is one terminal cell painted +// with an explicit background colour (black/white). Unlike half-block +// glyph rendering (qrcode-terminal `small: true`, `▀▄█`), this does not +// depend on font glyph shapes, cell aspect ratio, light/dark theme, or +// Terminal.app line spacing — the gaps that made issue #133 unscannable. +const BLACK_CELL = "\x1b[40m \x1b[0m" +const WHITE_CELL = "\x1b[47m \x1b[0m" + +export type QrRenderOptions = { + /** Compact but still full-cell output (smaller margin + lower EC). Default false. */ + small?: boolean +} + +export function renderQrTerminalText(data: string, opts: QrRenderOptions = {}): string { + const margin = opts.small ? 2 : 4 + const errorCorrectionLevel = opts.small ? "L" : "M" + const qr = QRCode.create(data, { errorCorrectionLevel }) + const size = qr.modules.size + const lines: string[] = [] + for (let r = -margin; r < size + margin; r++) { + let line = "" + for (let c = -margin; c < size + margin; c++) { + const dark = r >= 0 && r < size && c >= 0 && c < size ? qr.modules.get(r, c) !== 0 : false + line += dark ? BLACK_CELL : WHITE_CELL + } + lines.push(line) + } + return lines.join("\n") +} + +export type PrintTunnelQrOptions = QrRenderOptions & { + /** When true, skip the QR graphic and print the URL only. */ + disabled?: boolean +} + +export function printTunnelQr( + url: string, + token: string, + opencodePort: number, + requestedPort: number, + opts: PrintTunnelQrOptions = {}, +) { + const payload = encodeQrPayload({ url, token, v: 1 }) + if (opencodePort !== requestedPort) { + console.log(chalk.yellow(`opencode running on port ${opencodePort}`)) + } + if (opts.disabled) { + console.log(chalk.cyanBright("\n Scan with CrossCode App:")) + console.log(chalk.grey(`URL: ${url}`)) + console.log(chalk.dim("QR display disabled (--no-qr). Enter the URL manually in the app.")) + console.log(chalk.dim.bold("[Press 'l' for logs • 'h' for help • Ctrl+C to exit]")) + return + } + console.log(chalk.cyanBright("\n Scan with CrossCode App:")) + try { + console.log() + // Raw ANSI string on its own line: never wrap in chalk or trim, + // or the explicit black/white cells lose contrast. + console.log(renderQrTerminalText(payload, { small: opts.small })) + console.log() + } catch (err) { + debug("QR render failed", { error: err instanceof Error ? err.message : String(err) }) + console.log(chalk.yellow("Could not render QR code in this terminal.")) + } + console.log(chalk.grey(`URL: ${url}`)) + if (process.env.TERM_PROGRAM === "Apple_Terminal") { + console.log(chalk.dim("Terminal.app tip: if the code won't scan, zoom out (Cmd+-) and keep the whole code visible.")) + } + console.log(chalk.dim.bold("[Press 'l' for logs • 'h' for help • Ctrl+C to exit]")) +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1609b99..ad41c7e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -355,9 +355,9 @@ importers: ora: specifier: ^9.4.1 version: 9.4.1 - qrcode-terminal: - specifier: ^0.12.0 - version: 0.12.0 + qrcode: + specifier: ^1.5.4 + version: 1.5.4 ws: specifier: ^8.18.0 version: 8.21.0 @@ -368,9 +368,9 @@ importers: '@types/node': specifier: ^26.0.0 version: 26.0.0 - '@types/qrcode-terminal': - specifier: ^0.12.2 - version: 0.12.2 + '@types/qrcode': + specifier: ^1.5.6 + version: 1.5.6 '@types/ws': specifier: ^8.5.13 version: 8.18.1 @@ -416,8 +416,6 @@ importers: version: 5.9.3 packages: - '@types/cross-spawn@6.0.6': - resolution: {integrity: sha512-fXRhhUkG4H3TQk5dBhQ7m/JDdSNHKwR2BBia62lhwEIq9xGiQKLxd6LymNhn47SjXhsUEPmxi+PKw2OkW4LLjA==} '@acemir/cssom@0.9.31': resolution: {integrity: sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==} @@ -5510,6 +5508,9 @@ packages: '@types/aria-query@5.0.4': resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/cross-spawn@6.0.6': + resolution: {integrity: sha512-fXRhhUkG4H3TQk5dBhQ7m/JDdSNHKwR2BBia62lhwEIq9xGiQKLxd6LymNhn47SjXhsUEPmxi+PKw2OkW4LLjA==} + '@types/debug@4.1.13': resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} @@ -5585,9 +5586,6 @@ packages: '@types/prismjs@1.26.6': resolution: {integrity: sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw==} - '@types/qrcode-terminal@0.12.2': - resolution: {integrity: sha512-v+RcIEJ+Uhd6ygSQ0u5YYY7ZM+la7GgPbs0V/7l/kFs2uO4S8BcIUEMoP7za4DNIqNnUD5npf0A/7kBhrCKG5Q==} - '@types/qrcode@1.5.6': resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==} @@ -9990,10 +9988,6 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} - qrcode-terminal@0.12.0: - resolution: {integrity: sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ==} - hasBin: true - qrcode@1.5.4: resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} engines: {node: '>=10.13.0'} @@ -11703,9 +11697,6 @@ packages: '@types/emscripten': '>=1.39.6' snapshots: - '@types/cross-spawn@6.0.6': - dependencies: - '@types/node': 26.0.0 '@acemir/cssom@0.9.31': {} @@ -17551,6 +17542,10 @@ snapshots: '@types/aria-query@5.0.4': {} + '@types/cross-spawn@6.0.6': + dependencies: + '@types/node': 26.0.0 + '@types/debug@4.1.13': dependencies: '@types/ms': 2.1.0 @@ -17622,8 +17617,6 @@ snapshots: '@types/prismjs@1.26.6': {} - '@types/qrcode-terminal@0.12.2': {} - '@types/qrcode@1.5.6': dependencies: '@types/node': 26.0.0 @@ -22556,8 +22549,6 @@ snapshots: punycode@2.3.1: {} - qrcode-terminal@0.12.0: {} - qrcode@1.5.4: dependencies: dijkstrajs: 1.0.3