@tunnelbox/core 0.1.10 → 0.1.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/messages.ts CHANGED
@@ -1,171 +1,171 @@
1
- /**
2
- * 会话消息快照缓存与分页窗口工具(各智能体适配器共享)。
3
- *
4
- * 背景:拉取历史(session.messages)在部分适配器开销可观——opencode 会对整段会话做
5
- * 全量 hydrate、claude-code 需读盘解析 transcript;手机端蜂窝环境下还要把整份 JSON
6
- * 经 WS 下发并一次性渲染。两点缓解:
7
- * 1. 分页窗口:应答按 [before, limit] 只下发最近的 N 条,过长历史由手机端"滚动到顶
8
- * 加载更早"再拉;构建出完整列表后切片(构建仍是最重一步,缓存命中可完全跳过)。
9
- * 2. 短 TTL 快照:对重复/快速回看的打开直接从缓存切片,避免重复构建。快照在流式事件
10
- * (message.part / session.status 等)到达时由适配器 invalidate,保证不长期陈旧。
11
- *
12
- * 消息数组统一约定为**时间升序(旧 → 新)**,与手机端展示顺序一致。
13
- *
14
- * 3. 载荷上界:超长会话/超大单条内容可能把一帧 push 撑到 relay 的连接读上限
15
- * (旧 relay 4MB)而被误判断开。sliceWindow 统一在返回前截断单条文本/参数长度,
16
- * 并做**字节预算**——超预算从窗口更旧侧继续裁剪(保留最新,缺口可经 before 续拉,
17
- * 不丢内容),保证任何应答帧稳定远小于连接上限。截断只影响手机端展示。
18
- */
19
- import type { ChatMessage, Part } from "./types";
20
-
21
- /** 单页默认条数(手机端首屏/每次"加载更早"的展示窗口)。 */
22
- export const DEFAULT_PAGE_SIZE = 20;
23
-
24
- /** 单条 part 文本(text/thinking)展示上限(字符)。 */
25
- export const MAX_PART_TEXT = 16000;
26
- /** 单条 tool 参数展示上限(字符)。 */
27
- export const MAX_PART_ARGS = 4000;
28
- /** 单页串行化字节预算:预留明显余量,确保帧远小于 relay 连接读上限(4MB/32MB)。 */
29
- export const MAX_PAGE_BYTES = 1_500_000;
30
-
31
- export interface MessagesPageQuery {
32
- /** 游标:只返回早于该消息 id 的更早消息(第一页不传)。 */
33
- before?: string;
34
- /** 每页条数(缺省 DEFAULT_PAGE_SIZE)。 */
35
- limit?: number;
36
- /** 字节预算(缺省 MAX_PAGE_BYTES)。 */
37
- maxBytes?: number;
38
- }
39
-
40
- export interface MessagesPageResult {
41
- sessionID: string;
42
- messages: ChatMessage[];
43
- /** 更早历史是否仍存在(手机端据此决定是否继续"加载更早")。 */
44
- hasMore?: boolean;
45
- }
46
-
47
- function capStr(s: string, max: number): string {
48
- if (s.length <= max) return s;
49
- return `${s.slice(0, max)}…`;
50
- }
51
-
52
- /** 截断单条消息内过大部件(返回新对象;未超限原样返回)。只影响手机端展示体积。 */
53
- export function capMessage(m: ChatMessage): ChatMessage {
54
- let parts: Part[] | undefined;
55
- for (let i = 0; i < m.parts.length; i++) {
56
- const p = m.parts[i];
57
- const nText =
58
- typeof p.text === "string" && p.text.length > MAX_PART_TEXT
59
- ? capStr(p.text, MAX_PART_TEXT)
60
- : p.text;
61
- const nDelta =
62
- typeof p.delta === "string" && p.delta.length > MAX_PART_TEXT
63
- ? capStr(p.delta, MAX_PART_TEXT)
64
- : p.delta;
65
- const nArgs =
66
- typeof p.args === "string" && p.args.length > MAX_PART_ARGS
67
- ? capStr(p.args, MAX_PART_ARGS)
68
- : p.args;
69
- if (nText !== p.text || nDelta !== p.delta || nArgs !== p.args) {
70
- if (!parts) parts = m.parts.slice(0, i);
71
- parts.push({ ...p, text: nText, delta: nDelta, args: nArgs });
72
- } else if (parts) {
73
- parts.push(p);
74
- }
75
- }
76
- return parts ? { ...m, parts } : m;
77
- }
78
-
79
- /** 整批截断(仅对确有超限的条目重建,避免无谓分配)。 */
80
- export function capMessages(messages: ChatMessage[]): ChatMessage[] {
81
- let changed = false;
82
- const out = messages.map((m) => {
83
- const c = capMessage(m);
84
- if (c !== m) changed = true;
85
- return c;
86
- });
87
- return changed ? out : messages;
88
- }
89
-
90
- function byteLength(messages: ChatMessage[]): number {
91
- try {
92
- return JSON.stringify(messages).length;
93
- } catch {
94
- // 循环引用等异常兜底:按上限放大估算,防止误超
95
- return messages.length * MAX_PART_TEXT * 2;
96
- }
97
- }
98
-
99
- /** 在完整(升序)消息列表上切出目标窗口:截断超大内容 + 字节预算裁剪最旧侧。 */
100
- export function sliceWindow(
101
- full: ChatMessage[],
102
- query: MessagesPageQuery = {},
103
- ): Pick<MessagesPageResult, "messages" | "hasMore"> {
104
- const limit = Math.max(1, query.limit ?? DEFAULT_PAGE_SIZE);
105
- const maxBytes = query.maxBytes ?? MAX_PAGE_BYTES;
106
- let end = full.length;
107
- if (query.before !== undefined && query.before !== "") {
108
- const idx = full.findIndex((m) => m.id === query.before);
109
- // 游标消息本身不含在内;找不到(已被清理/不一致)时回退整段,避免丢消息。
110
- if (idx >= 0) end = idx;
111
- }
112
- let start = Math.max(0, end - limit);
113
- let messages = full.slice(start, end);
114
- // 先按条数取窗口,再在「截断后」体积上做预算:超出才从最旧侧继续裁剪
115
- // (单条超大内容经 cap 已大幅缩小,避免按原始长度过度裁剪)。
116
- while (messages.length > 1) {
117
- const capped = capMessages(messages);
118
- if (byteLength(capped) <= maxBytes) {
119
- messages = capped;
120
- break;
121
- }
122
- start++;
123
- messages = full.slice(start, end);
124
- }
125
- return { messages: capMessages(messages), hasMore: start > 0 };
126
- }
127
-
128
- /** 会话消息快照缓存:仅缓存**完整列表**(窗口切片在各请求时现算)。 */
129
- export class MessagesCache {
130
- private ttlMs: number;
131
- private maxEntries: number;
132
- private store = new Map<string, { full: ChatMessage[]; at: number }>();
133
-
134
- constructor(ttlMs = 2000, maxEntries = 64) {
135
- this.ttlMs = ttlMs;
136
- this.maxEntries = maxEntries;
137
- }
138
-
139
- /** 命中且未过 TTL 时返回完整列表副本;否则 undefined。 */
140
- get(sessionID: string, now = Date.now()): ChatMessage[] | undefined {
141
- const e = this.store.get(sessionID);
142
- if (!e) return undefined;
143
- if (now - e.at > this.ttlMs) {
144
- this.store.delete(sessionID);
145
- return undefined;
146
- }
147
- return e.full;
148
- }
149
-
150
- put(sessionID: string, full: ChatMessage[]): void {
151
- if (this.store.size >= this.maxEntries && !this.store.has(sessionID)) {
152
- // 简单 FIFO 淘汰最旧条目,防内存膨胀
153
- const oldest = this.store.keys().next().value;
154
- if (oldest !== undefined) this.store.delete(oldest);
155
- }
156
- this.store.set(sessionID, { full, at: Date.now() });
157
- }
158
-
159
- /** 会话内容变化(流式部件/状态/删除等)时调用,立即失效快照。 */
160
- invalidate(sessionID: string): void {
161
- this.store.delete(sessionID);
162
- }
163
-
164
- /** 适配器重连/整体失效(如清除全部快照)。 */
165
- clear(): void {
166
- this.store.clear();
167
- }
168
- }
169
-
170
- /** 共享会话消息快照缓存(各适配器复用同一实例)。 */
171
- export const messagesCache = new MessagesCache();
1
+ /**
2
+ * 会话消息快照缓存与分页窗口工具(各智能体适配器共享)。
3
+ *
4
+ * 背景:拉取历史(session.messages)在部分适配器开销可观——opencode 会对整段会话做
5
+ * 全量 hydrate、claude-code 需读盘解析 transcript;手机端蜂窝环境下还要把整份 JSON
6
+ * 经 WS 下发并一次性渲染。两点缓解:
7
+ * 1. 分页窗口:应答按 [before, limit] 只下发最近的 N 条,过长历史由手机端"滚动到顶
8
+ * 加载更早"再拉;构建出完整列表后切片(构建仍是最重一步,缓存命中可完全跳过)。
9
+ * 2. 短 TTL 快照:对重复/快速回看的打开直接从缓存切片,避免重复构建。快照在流式事件
10
+ * (message.part / session.status 等)到达时由适配器 invalidate,保证不长期陈旧。
11
+ *
12
+ * 消息数组统一约定为**时间升序(旧 → 新)**,与手机端展示顺序一致。
13
+ *
14
+ * 3. 载荷上界:超长会话/超大单条内容可能把一帧 push 撑到 relay 的连接读上限
15
+ * (旧 relay 4MB)而被误判断开。sliceWindow 统一在返回前截断单条文本/参数长度,
16
+ * 并做**字节预算**——超预算从窗口更旧侧继续裁剪(保留最新,缺口可经 before 续拉,
17
+ * 不丢内容),保证任何应答帧稳定远小于连接上限。截断只影响手机端展示。
18
+ */
19
+ import type { ChatMessage, Part } from "./types";
20
+
21
+ /** 单页默认条数(手机端首屏/每次"加载更早"的展示窗口)。 */
22
+ export const DEFAULT_PAGE_SIZE = 20;
23
+
24
+ /** 单条 part 文本(text/thinking)展示上限(字符)。 */
25
+ export const MAX_PART_TEXT = 16000;
26
+ /** 单条 tool 参数展示上限(字符)。 */
27
+ export const MAX_PART_ARGS = 4000;
28
+ /** 单页串行化字节预算:预留明显余量,确保帧远小于 relay 连接读上限(4MB/32MB)。 */
29
+ export const MAX_PAGE_BYTES = 1_500_000;
30
+
31
+ export interface MessagesPageQuery {
32
+ /** 游标:只返回早于该消息 id 的更早消息(第一页不传)。 */
33
+ before?: string;
34
+ /** 每页条数(缺省 DEFAULT_PAGE_SIZE)。 */
35
+ limit?: number;
36
+ /** 字节预算(缺省 MAX_PAGE_BYTES)。 */
37
+ maxBytes?: number;
38
+ }
39
+
40
+ export interface MessagesPageResult {
41
+ sessionID: string;
42
+ messages: ChatMessage[];
43
+ /** 更早历史是否仍存在(手机端据此决定是否继续"加载更早")。 */
44
+ hasMore?: boolean;
45
+ }
46
+
47
+ function capStr(s: string, max: number): string {
48
+ if (s.length <= max) return s;
49
+ return `${s.slice(0, max)}…`;
50
+ }
51
+
52
+ /** 截断单条消息内过大部件(返回新对象;未超限原样返回)。只影响手机端展示体积。 */
53
+ export function capMessage(m: ChatMessage): ChatMessage {
54
+ let parts: Part[] | undefined;
55
+ for (let i = 0; i < m.parts.length; i++) {
56
+ const p = m.parts[i];
57
+ const nText =
58
+ typeof p.text === "string" && p.text.length > MAX_PART_TEXT
59
+ ? capStr(p.text, MAX_PART_TEXT)
60
+ : p.text;
61
+ const nDelta =
62
+ typeof p.delta === "string" && p.delta.length > MAX_PART_TEXT
63
+ ? capStr(p.delta, MAX_PART_TEXT)
64
+ : p.delta;
65
+ const nArgs =
66
+ typeof p.args === "string" && p.args.length > MAX_PART_ARGS
67
+ ? capStr(p.args, MAX_PART_ARGS)
68
+ : p.args;
69
+ if (nText !== p.text || nDelta !== p.delta || nArgs !== p.args) {
70
+ if (!parts) parts = m.parts.slice(0, i);
71
+ parts.push({ ...p, text: nText, delta: nDelta, args: nArgs });
72
+ } else if (parts) {
73
+ parts.push(p);
74
+ }
75
+ }
76
+ return parts ? { ...m, parts } : m;
77
+ }
78
+
79
+ /** 整批截断(仅对确有超限的条目重建,避免无谓分配)。 */
80
+ export function capMessages(messages: ChatMessage[]): ChatMessage[] {
81
+ let changed = false;
82
+ const out = messages.map((m) => {
83
+ const c = capMessage(m);
84
+ if (c !== m) changed = true;
85
+ return c;
86
+ });
87
+ return changed ? out : messages;
88
+ }
89
+
90
+ function byteLength(messages: ChatMessage[]): number {
91
+ try {
92
+ return JSON.stringify(messages).length;
93
+ } catch {
94
+ // 循环引用等异常兜底:按上限放大估算,防止误超
95
+ return messages.length * MAX_PART_TEXT * 2;
96
+ }
97
+ }
98
+
99
+ /** 在完整(升序)消息列表上切出目标窗口:截断超大内容 + 字节预算裁剪最旧侧。 */
100
+ export function sliceWindow(
101
+ full: ChatMessage[],
102
+ query: MessagesPageQuery = {},
103
+ ): Pick<MessagesPageResult, "messages" | "hasMore"> {
104
+ const limit = Math.max(1, query.limit ?? DEFAULT_PAGE_SIZE);
105
+ const maxBytes = query.maxBytes ?? MAX_PAGE_BYTES;
106
+ let end = full.length;
107
+ if (query.before !== undefined && query.before !== "") {
108
+ const idx = full.findIndex((m) => m.id === query.before);
109
+ // 游标消息本身不含在内;找不到(已被清理/不一致)时回退整段,避免丢消息。
110
+ if (idx >= 0) end = idx;
111
+ }
112
+ let start = Math.max(0, end - limit);
113
+ let messages = full.slice(start, end);
114
+ // 先按条数取窗口,再在「截断后」体积上做预算:超出才从最旧侧继续裁剪
115
+ // (单条超大内容经 cap 已大幅缩小,避免按原始长度过度裁剪)。
116
+ while (messages.length > 1) {
117
+ const capped = capMessages(messages);
118
+ if (byteLength(capped) <= maxBytes) {
119
+ messages = capped;
120
+ break;
121
+ }
122
+ start++;
123
+ messages = full.slice(start, end);
124
+ }
125
+ return { messages: capMessages(messages), hasMore: start > 0 };
126
+ }
127
+
128
+ /** 会话消息快照缓存:仅缓存**完整列表**(窗口切片在各请求时现算)。 */
129
+ export class MessagesCache {
130
+ private ttlMs: number;
131
+ private maxEntries: number;
132
+ private store = new Map<string, { full: ChatMessage[]; at: number }>();
133
+
134
+ constructor(ttlMs = 2000, maxEntries = 64) {
135
+ this.ttlMs = ttlMs;
136
+ this.maxEntries = maxEntries;
137
+ }
138
+
139
+ /** 命中且未过 TTL 时返回完整列表副本;否则 undefined。 */
140
+ get(sessionID: string, now = Date.now()): ChatMessage[] | undefined {
141
+ const e = this.store.get(sessionID);
142
+ if (!e) return undefined;
143
+ if (now - e.at > this.ttlMs) {
144
+ this.store.delete(sessionID);
145
+ return undefined;
146
+ }
147
+ return e.full;
148
+ }
149
+
150
+ put(sessionID: string, full: ChatMessage[]): void {
151
+ if (this.store.size >= this.maxEntries && !this.store.has(sessionID)) {
152
+ // 简单 FIFO 淘汰最旧条目,防内存膨胀
153
+ const oldest = this.store.keys().next().value;
154
+ if (oldest !== undefined) this.store.delete(oldest);
155
+ }
156
+ this.store.set(sessionID, { full, at: Date.now() });
157
+ }
158
+
159
+ /** 会话内容变化(流式部件/状态/删除等)时调用,立即失效快照。 */
160
+ invalidate(sessionID: string): void {
161
+ this.store.delete(sessionID);
162
+ }
163
+
164
+ /** 适配器重连/整体失效(如清除全部快照)。 */
165
+ clear(): void {
166
+ this.store.clear();
167
+ }
168
+ }
169
+
170
+ /** 共享会话消息快照缓存(各适配器复用同一实例)。 */
171
+ export const messagesCache = new MessagesCache();
package/src/qr.ts CHANGED
@@ -1,108 +1,108 @@
1
- /**
2
- * 终端二维码与配对信息打印。qrcode 为可选依赖,失败时降级为配对码提示。
3
- * 输出文案经 core i18n 按语言翻译(见 ./i18n)。
4
- */
5
- import { writePairingFile } from "./state";
6
- import { normalizeLang, resolveLang, t, type Lang } from "./i18n";
7
-
8
- /** 加载 qrcode 渲染器(可选依赖,加载失败返回 null)。 */
9
- async function loadQrcode(): Promise<{
10
- toString?: (text: string, opts: unknown) => Promise<string>;
11
- } | null> {
12
- try {
13
- const mod = await import("qrcode");
14
- const render = (mod as { default?: unknown }).default ?? mod;
15
- const toString = (render as { toString?: (text: string, opts: unknown) => Promise<string> }).toString;
16
- return toString ? { toString } : null;
17
- } catch {
18
- return null;
19
- }
20
- }
21
-
22
- /**
23
- * 生成适合嵌入聊天/等宽代码块的纯文本二维码(无 ANSI 颜色码,半块字符)。
24
- * 供手机「扫一扫」直接扫码配对。qrcode 不可用时返回空字符串(由调用方降级)。
25
- *
26
- * 注意:聊天区无法像终端 printPairing 那样用 ANSI 背景色“直接涂黑白”,只能靠字符,
27
- * 因此必须强制**反相**输出——白色模块用实心块字符(渲染成亮前景)、黑色模块留空
28
- * (呈现深色背景),从而在 opencode 默认的深色 TUI 里保持标准“深码浅底”对比度;
29
- * 若按常规映射(黑码实心、白码空格)在深色主题下会反相,手机无法扫码。
30
- */
31
- export async function renderPairingQrText(text: string): Promise<string> {
32
- const render = await loadQrcode();
33
- if (!render?.toString) return "";
34
- try {
35
- const qr = await render.toString(text, {
36
- type: "utf8",
37
- margin: 4,
38
- // 令 qrcode 选用反相块字符表(dark=白/light=黑 → INVERTED_BLOCK_CHAR)
39
- color: { dark: "#ffffff", light: "#000000" },
40
- });
41
- return qr || "";
42
- } catch {
43
- return "";
44
- }
45
- }
46
-
47
- /** 近似显示宽度:ASCII 宽 1,其余按宽字符 2 计(用于跨语言的框线对齐)。 */
48
- function dispWidth(s: string): number {
49
- let w = 0;
50
- for (const ch of s) w += ch.charCodeAt(0) > 0xff ? 2 : 1;
51
- return w;
52
- }
53
-
54
- function padVisual(s: string, width: number, align: "left" | "center" = "left"): string {
55
- const cur = dispWidth(s);
56
- if (cur >= width) return s;
57
- const pad = width - cur;
58
- if (align === "center") {
59
- const left = Math.floor(pad / 2);
60
- return " ".repeat(left) + s + " ".repeat(pad - left);
61
- }
62
- return s + " ".repeat(pad);
63
- }
64
-
65
- /**
66
- * 打印配对信息框 + 二维码(二维码内容为配对码本身,供 tunnelbox App「扫一扫」配对)。
67
- */
68
- export async function printPairing(
69
- code: string,
70
- type?: string,
71
- name?: string,
72
- lang?: Lang,
73
- ): Promise<void> {
74
- const resolved = lang ? normalizeLang(lang) : resolveLang();
75
- const file = writePairingFile(code, type);
76
- const tt = (key: Parameters<typeof t>[1], vars?: Parameters<typeof t>[2]) => t(resolved, key, vars);
77
-
78
- const title = type ? tt("qr.titleWithType", { type }) : tt("qr.titleNoType");
79
- const codeLine = `${tt("qr.codeLabel")}: ${code}`;
80
- const hint = tt("qr.openHint");
81
-
82
- const inner = Math.max(40, dispWidth(codeLine), dispWidth(title));
83
- const W = Math.min(76, inner + 4);
84
- const bar = "─".repeat(W);
85
-
86
- console.log("");
87
- console.log("┌" + bar + "┐");
88
- console.log("│ " + padVisual(title, W - 2, "center") + " │");
89
- console.log("├" + bar + "┤");
90
- console.log("│ " + padVisual(codeLine, W - 2) + " │");
91
- console.log("└" + bar + "┘");
92
- console.log(" " + hint);
93
-
94
- try {
95
- const mod = await import("qrcode");
96
- const render = (mod as { default?: unknown }).default ?? mod;
97
- const toString = (render as { toString?: (text: string, opts: unknown) => Promise<string> }).toString;
98
- if (toString) {
99
- const qr = await toString(code, { type: "terminal", small: true });
100
- if (qr) console.log(qr);
101
- }
102
- } catch {
103
- console.log(" " + tt("qr.unavailable"));
104
- }
105
-
106
- console.log(" " + tt("qr.fileWritten", { path: file }));
107
- console.log("");
108
- }
1
+ /**
2
+ * 终端二维码与配对信息打印。qrcode 为可选依赖,失败时降级为配对码提示。
3
+ * 输出文案经 core i18n 按语言翻译(见 ./i18n)。
4
+ */
5
+ import { writePairingFile } from "./state";
6
+ import { normalizeLang, resolveLang, t, type Lang } from "./i18n";
7
+
8
+ /** 加载 qrcode 渲染器(可选依赖,加载失败返回 null)。 */
9
+ async function loadQrcode(): Promise<{
10
+ toString?: (text: string, opts: unknown) => Promise<string>;
11
+ } | null> {
12
+ try {
13
+ const mod = await import("qrcode");
14
+ const render = (mod as { default?: unknown }).default ?? mod;
15
+ const toString = (render as { toString?: (text: string, opts: unknown) => Promise<string> }).toString;
16
+ return toString ? { toString } : null;
17
+ } catch {
18
+ return null;
19
+ }
20
+ }
21
+
22
+ /**
23
+ * 生成适合嵌入聊天/等宽代码块的纯文本二维码(无 ANSI 颜色码,半块字符)。
24
+ * 供手机「扫一扫」直接扫码配对。qrcode 不可用时返回空字符串(由调用方降级)。
25
+ *
26
+ * 注意:聊天区无法像终端 printPairing 那样用 ANSI 背景色“直接涂黑白”,只能靠字符,
27
+ * 因此必须强制**反相**输出——白色模块用实心块字符(渲染成亮前景)、黑色模块留空
28
+ * (呈现深色背景),从而在 opencode 默认的深色 TUI 里保持标准“深码浅底”对比度;
29
+ * 若按常规映射(黑码实心、白码空格)在深色主题下会反相,手机无法扫码。
30
+ */
31
+ export async function renderPairingQrText(text: string): Promise<string> {
32
+ const render = await loadQrcode();
33
+ if (!render?.toString) return "";
34
+ try {
35
+ const qr = await render.toString(text, {
36
+ type: "utf8",
37
+ margin: 4,
38
+ // 令 qrcode 选用反相块字符表(dark=白/light=黑 → INVERTED_BLOCK_CHAR)
39
+ color: { dark: "#ffffff", light: "#000000" },
40
+ });
41
+ return qr || "";
42
+ } catch {
43
+ return "";
44
+ }
45
+ }
46
+
47
+ /** 近似显示宽度:ASCII 宽 1,其余按宽字符 2 计(用于跨语言的框线对齐)。 */
48
+ function dispWidth(s: string): number {
49
+ let w = 0;
50
+ for (const ch of s) w += ch.charCodeAt(0) > 0xff ? 2 : 1;
51
+ return w;
52
+ }
53
+
54
+ function padVisual(s: string, width: number, align: "left" | "center" = "left"): string {
55
+ const cur = dispWidth(s);
56
+ if (cur >= width) return s;
57
+ const pad = width - cur;
58
+ if (align === "center") {
59
+ const left = Math.floor(pad / 2);
60
+ return " ".repeat(left) + s + " ".repeat(pad - left);
61
+ }
62
+ return s + " ".repeat(pad);
63
+ }
64
+
65
+ /**
66
+ * 打印配对信息框 + 二维码(二维码内容为配对码本身,供 tunnelbox App「扫一扫」配对)。
67
+ */
68
+ export async function printPairing(
69
+ code: string,
70
+ type?: string,
71
+ name?: string,
72
+ lang?: Lang,
73
+ ): Promise<void> {
74
+ const resolved = lang ? normalizeLang(lang) : resolveLang();
75
+ const file = writePairingFile(code, type);
76
+ const tt = (key: Parameters<typeof t>[1], vars?: Parameters<typeof t>[2]) => t(resolved, key, vars);
77
+
78
+ const title = type ? tt("qr.titleWithType", { type }) : tt("qr.titleNoType");
79
+ const codeLine = `${tt("qr.codeLabel")}: ${code}`;
80
+ const hint = tt("qr.openHint");
81
+
82
+ const inner = Math.max(40, dispWidth(codeLine), dispWidth(title));
83
+ const W = Math.min(76, inner + 4);
84
+ const bar = "─".repeat(W);
85
+
86
+ console.log("");
87
+ console.log("┌" + bar + "┐");
88
+ console.log("│ " + padVisual(title, W - 2, "center") + " │");
89
+ console.log("├" + bar + "┤");
90
+ console.log("│ " + padVisual(codeLine, W - 2) + " │");
91
+ console.log("└" + bar + "┘");
92
+ console.log(" " + hint);
93
+
94
+ try {
95
+ const mod = await import("qrcode");
96
+ const render = (mod as { default?: unknown }).default ?? mod;
97
+ const toString = (render as { toString?: (text: string, opts: unknown) => Promise<string> }).toString;
98
+ if (toString) {
99
+ const qr = await toString(code, { type: "terminal", small: true });
100
+ if (qr) console.log(qr);
101
+ }
102
+ } catch {
103
+ console.log(" " + tt("qr.unavailable"));
104
+ }
105
+
106
+ console.log(" " + tt("qr.fileWritten", { path: file }));
107
+ console.log("");
108
+ }