From 87f280c3a388fd822f3555ca55765a06e4699a52 Mon Sep 17 00:00:00 2001 From: shum Date: Sat, 24 Oct 2026 13:39:48 +0000 Subject: [PATCH] mcp: add plain QR code text --- packages/simplex-chat-mcp/src/control.ts | 30 +------------ packages/simplex-chat-mcp/src/qr.ts | 31 ++++++++++++++ .../simplex-chat-mcp/test/unit/cards.test.ts | 36 +--------------- .../simplex-chat-mcp/test/unit/qr.test.ts | 42 +++++++++++++++++++ 4 files changed, 76 insertions(+), 63 deletions(-) create mode 100644 packages/simplex-chat-mcp/src/qr.ts create mode 100644 packages/simplex-chat-mcp/test/unit/qr.test.ts diff --git a/packages/simplex-chat-mcp/src/control.ts b/packages/simplex-chat-mcp/src/control.ts index 9a3fd96192..c3ddd4a9eb 100644 --- a/packages/simplex-chat-mcp/src/control.ts +++ b/packages/simplex-chat-mcp/src/control.ts @@ -1,9 +1,9 @@ import {ChatEvent, T} from "@simplex-chat/types" -import * as qrcode from "qrcode" import {api} from "simplex-chat" import {Action, ActionHooks, Actions, ConnectAction, Subject, ToolError, leaveVerbs, subjectText, summary} from "./actions" import {ExitError, chatErrorOf, log, logError, oneLine, shellWord} from "./log" import {chatNumber, chatRef, isChatRef, utcMinutes, validName, viewName} from "./messages" +import {qrCodeText} from "./qr" import {AgentTools, chatSubject, isActive, isConnected, memberCurrent} from "./tools" export type QrCodeSize = "small" | "large" @@ -27,15 +27,6 @@ const truncationMark = "…" const longestError = "€".repeat(600) const greeting = "I'm your agent. I ask here before I connect or send." const noOwners = "No owners in the agent control chat." -const qrCodeHint = "If the QR code does not scan, restart with --qr-code large, or paste the link into the app." -const qrMargin = 2 -const darkCell = "\x1b[40m " -const lightCell = "\x1b[47m " -const darkHalfCell = "\x1b[40m " -const lightHalfCell = "\x1b[47m " -const darkTopCell = "\x1b[37;40m▄" -const darkBottomCell = "\x1b[30;47m▄" -const resetColors = "\x1b[0m" export interface ControlData { control: true @@ -318,9 +309,8 @@ export class Control implements Pick i - qrMargin) - const inside = (i: number): boolean => i >= 0 && i < modules.size - const dark = (row: number, col: number): boolean => inside(row) && inside(col) && modules.get(row, col) !== 0 - const cell = (row: number, col: number): string => (dark(row, col) ? darkCell : lightCell) - const halfCell = (top: boolean, bottom: boolean): string => { - if (top === bottom) return top ? darkHalfCell : lightHalfCell - return top ? darkTopCell : darkBottomCell - } - const lines = size === "large" - ? positions.map(row => positions.map(col => cell(row, col)).join("")) - : positions.filter((_, i) => i % 2 === 0).map(row => positions.map(col => halfCell(dark(row, col), dark(row + 1, col))).join("")) - return lines.map(line => line + resetColors).join("\n") -} - function losesControl(event: ChatEvent): boolean { switch (event.type) { case "deletedMemberUser": diff --git a/packages/simplex-chat-mcp/src/qr.ts b/packages/simplex-chat-mcp/src/qr.ts new file mode 100644 index 0000000000..78e8a61d90 --- /dev/null +++ b/packages/simplex-chat-mcp/src/qr.ts @@ -0,0 +1,31 @@ +import * as qrcode from "qrcode" + +export type QrStyle = "ansi" | "plain" + +const qrMargin = 2 +const darkHalfCell = "\x1b[40m " +const lightHalfCell = "\x1b[47m " +const darkTopCell = "\x1b[37;40m▄" +const darkBottomCell = "\x1b[30;47m▄" +const resetColors = "\x1b[0m" + +export function qrCodeText(link: string, style: QrStyle): string { + const {modules} = qrcode.create(link, {errorCorrectionLevel: "L"}) + const positions = Array.from({length: modules.size + 2 * qrMargin}, (_, i) => i - qrMargin) + const inside = (i: number): boolean => i >= 0 && i < modules.size + const dark = (row: number, col: number): boolean => inside(row) && inside(col) && modules.get(row, col) !== 0 + const cell = style === "ansi" ? ansiCell : plainCell + const lines = positions.filter((_, i) => i % 2 === 0).map(row => positions.map(col => cell(dark(row, col), dark(row + 1, col))).join("")) + return lines.map(line => (style === "ansi" ? line + resetColors : line)).join("\n") +} + +function ansiCell(top: boolean, bottom: boolean): string { + if (top === bottom) return top ? darkHalfCell : lightHalfCell + return top ? darkTopCell : darkBottomCell +} + +function plainCell(topDark: boolean, bottomDark: boolean): string { + if (topDark && bottomDark) return " " + if (topDark) return "▄" + return bottomDark ? "▀" : "█" +} diff --git a/packages/simplex-chat-mcp/test/unit/cards.test.ts b/packages/simplex-chat-mcp/test/unit/cards.test.ts index f411bbaf74..e47025b544 100644 --- a/packages/simplex-chat-mcp/test/unit/cards.test.ts +++ b/packages/simplex-chat-mcp/test/unit/cards.test.ts @@ -1,6 +1,5 @@ -import * as qrcode from "qrcode" import {Action, Mode, NewAction, State} from "../../src/actions" -import {cardText, qrCodeText} from "../../src/control" +import {cardText} from "../../src/control" import {Changes, Collected} from "../../src/messages" const expires = Date.UTC(2026, 9, 18, 9, 2) @@ -199,36 +198,3 @@ describe("leave cards", () => { expect(lines(action(group, "pending", {interrupted: true}))).toEqual(["I want to leave business ep (#2).", "Cancelled: the server stopped."]) }) }) - -describe("QR codes", () => { - const link = "https://smp4.simplex.im/g#1ai9d8WS140_IxvPDYWLigJNZU-z7GsDUhp0JoJ7kbU" - const cells = (line: string): string[] => [...line.matchAll(/\x1b\[[0-9;]+m[^\x1b]+/g)].map(([cell]) => cell) - const visible = (line: string): string => line.replace(/\x1b\[[0-9;]*m/g, "") - - it("draws each module as two cells in large", () => { - const lines = qrCodeText(link, "large").split("\n") - expect(lines).toHaveLength(37) - for (const line of lines) { - expect(visible(line)).toHaveLength(74) - expect(line.endsWith("\x1b[0m")).toBe(true) - } - for (const line of [...lines.slice(0, 2), ...lines.slice(-2)]) expect(cells(line)).toEqual(Array(37).fill("\x1b[47m ")) - expect(cells(lines[2]).slice(0, 10)).toEqual([...Array(2).fill("\x1b[47m "), ...Array(7).fill("\x1b[40m "), "\x1b[47m "]) - }) - - it("draws two module rows per line with lower half blocks in small", () => { - const lines = qrCodeText(link, "small").split("\n") - expect(lines).toHaveLength(19) - for (const line of lines) { - expect(visible(line)).toHaveLength(37) - expect(line.endsWith("\x1b[0m")).toBe(true) - expect([...cells(line).slice(0, 2), ...cells(line).slice(-2)]).toEqual(Array(4).fill("\x1b[47m ")) - } - for (const line of [lines[0], lines.at(-1)!]) expect(cells(line)).toEqual(Array(37).fill("\x1b[47m ")) - expect(cells(lines[1]).slice(2, 4)).toEqual(["\x1b[40m ", "\x1b[37;40m▄"]) - const {modules} = qrcode.create(link, {errorCorrectionLevel: "L"}) - const row = Array.from({length: 16}, (_, i) => 2 * i).find(r => Array.from({length: modules.size}, (_, c) => c).some(c => !modules.get(r, c) && modules.get(r + 1, c)))! - const col = Array.from({length: modules.size}, (_, c) => c).find(c => !modules.get(row, c) && modules.get(row + 1, c))! - expect(cells(lines[row / 2 + 1])[col + 2]).toBe("\x1b[30;47m▄") - }) -}) diff --git a/packages/simplex-chat-mcp/test/unit/qr.test.ts b/packages/simplex-chat-mcp/test/unit/qr.test.ts new file mode 100644 index 0000000000..17b3d31c61 --- /dev/null +++ b/packages/simplex-chat-mcp/test/unit/qr.test.ts @@ -0,0 +1,42 @@ +import * as qrcode from "qrcode" +import {qrCodeText} from "../../src/qr" + +describe("QR codes", () => { + const link = "https://smp4.simplex.im/g#1ai9d8WS140_IxvPDYWLigJNZU-z7GsDUhp0JoJ7kbU" + const cells = (line: string): string[] => [...line.matchAll(/\x1b\[[0-9;]+m[^\x1b]+/g)].map(([cell]) => cell) + const visible = (line: string): string => line.replace(/\x1b\[[0-9;]*m/g, "") + + it("draws two module rows per line with lower half blocks in ansi", () => { + const lines = qrCodeText(link, "ansi").split("\n") + expect(lines).toHaveLength(19) + for (const line of lines) { + expect(visible(line)).toHaveLength(37) + expect(line.endsWith("\x1b[0m")).toBe(true) + expect([...cells(line).slice(0, 2), ...cells(line).slice(-2)]).toEqual(Array(4).fill("\x1b[47m ")) + } + for (const line of [lines[0], lines.at(-1)!]) expect(cells(line)).toEqual(Array(37).fill("\x1b[47m ")) + expect(cells(lines[1]).slice(2, 4)).toEqual(["\x1b[40m ", "\x1b[37;40m▄"]) + const {modules} = qrcode.create(link, {errorCorrectionLevel: "L"}) + const row = Array.from({length: 16}, (_, i) => 2 * i).find(r => Array.from({length: modules.size}, (_, c) => c).some(c => !modules.get(r, c) && modules.get(r + 1, c)))! + const col = Array.from({length: modules.size}, (_, c) => c).find(c => !modules.get(row, c) && modules.get(row + 1, c))! + expect(cells(lines[row / 2 + 1])[col + 2]).toBe("\x1b[30;47m▄") + }) + + it("draws light modules filled with plain half blocks, two module rows per line", () => { + const link = "https://smp4.simplex.im/g#test" + const {modules} = qrcode.create(link, {errorCorrectionLevel: "L"}) + const lines = qrCodeText(link, "plain").split("\n") + const width = modules.size + 4 + expect(lines).toHaveLength(Math.ceil(width / 2)) + for (const line of lines) expect([...line]).toHaveLength(width) + expect(qrCodeText(link, "plain")).toMatch(/^[█▀▄ \n]+$/) + expect(lines[0]).toBe("█".repeat(width)) + const light = (row: number, col: number): boolean => row < 0 || col < 0 || row >= modules.size || col >= modules.size || modules.get(row, col) === 0 + const expected = [...lines[1]].map((_, i) => { + const top = light(0, i - 2) + const bottom = light(1, i - 2) + return top && bottom ? "█" : top ? "▀" : bottom ? "▄" : " " + }) + expect([...lines[1]]).toEqual(expected) + }) +})