@nsyan/db 1.3.3 → 1.3.5

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/README.md CHANGED
@@ -68,7 +68,7 @@ pi install ./packages/db # 本仓库开发者
68
68
  | 机制 | 行为 |
69
69
  |------|------|
70
70
  | AI 只读模式 | **默认开**;关闭后允许写 |
71
- | 写确认 | 写操作弹框展示:理由 + 命令摘要 + SQL |
71
+ | 写确认 | 写操作弹框展示:理由 + 命令摘要 + SQL(自动格式化 + 按方言语法高亮) |
72
72
  | 写执行理由 | AI 必须附 `reason`(动机+影响范围),缺失直接拒绝 |
73
73
  | 连接级强制只读 | 标记的连接(如生产库)无视全局开关,永远只读 |
74
74
  | 家族白名单 | Redis/ES/Mongo 只读白名单;`DROP`、管理 DDL、服务端 JS、`CALL dbms.*` 恒拒 |
package/docs/USAGE.md CHANGED
@@ -161,6 +161,7 @@ SHOW INDEXES
161
161
 
162
162
  - AI 发起写操作必须附 `reason` 参数(动机 + 影响范围,如 *"将 status=2 的历史订单归档,预计影响 1.2 万行"*)
163
163
  - 确认框**首行展示理由**,执行结果末尾回显;缺失时拒绝并引导补充(与确认策略无关)
164
+ - 弹窗里的 SQL 自动**格式化**(子句换行、逗号拆列、`AND`/`OR` 缩进、`CREATE TABLE` 长列定义块状展开)并按方言**语法高亮**(关系型/大数据/图 → SQL,MongoDB/Elasticsearch → JSON,Redis → 纯色代码块);理由/摘要/库名用 **Markdown 排版**(引用块 + 加粗标签 + 行内代码 + emoji 图标)。TUI 走自定义弹窗(Enter 执行 / Esc 取消);**pi web 走同一份 Markdown**(react-markdown + Prism 渲染,同样高亮,代码块带行号与复制按钮,正文可滚动)。弹窗尺寸由 pi-web 写死(560×760),内容过长靠正文滚动。美化只作用于展示,**实际执行的永远是原始语句**
164
165
  - 开启审计(`/db config → 审计日志`)后,写成功追加 `~/.pi/agent/db-audit/<YYYY-MM-DD>.jsonl`:
165
166
  - 一天一个文件;字段含时间/项目路径/连接/摘要/理由/**完整 SQL 不截断**
166
167
  - 文件 `0600`;审计失败静默降级,不阻断主流程
package/index.ts CHANGED
@@ -16,6 +16,7 @@ import {
16
16
  recordLastUsed, recordTestResult, connSummaryLine, envTagLabel, formatRelativeTime,
17
17
  } from "./src/config.js";
18
18
  import type { PluginConfig } from "./src/config.js";
19
+ import { confirmSqlExecution } from "./src/ui/sql-confirm.js";
19
20
 
20
21
  // ── URL 解析:遍历 registry 各方言 parseUrl,首个非 null 胜出 ────
21
22
  // 关系型 JDBC + 原生 URI 双形态由各方言 parseUrl 兼收(Spec §7)
@@ -530,10 +531,11 @@ export default function (pi: ExtensionAPI) {
530
531
  continue;
531
532
  }
532
533
  if (action === "confirm") {
533
- const ok = await ctx.ui.confirm(
534
- "SQL 执行确认",
535
- `${verdict.summary ?? ""}\n\nSQL:\n${sql}`
536
- );
534
+ const ok = await confirmSqlExecution(ctx, {
535
+ summary: verdict.summary,
536
+ sql,
537
+ type: config.type,
538
+ });
537
539
  if (!ok) continue;
538
540
  }
539
541
  ctx.ui.notify("正在执行查询...", "info");
@@ -800,10 +802,13 @@ export default function (pi: ExtensionAPI) {
800
802
  content: [{ type: "text" as const, text: "当前环境无法弹出确认对话框,已取消 SQL 执行。请在有界面的环境中操作。" }],
801
803
  };
802
804
  }
803
- const ok = await ctx.ui.confirm(
804
- "SQL 执行确认",
805
- `理由: ${params.reason}\n\n${verdict.summary ?? ""}\n\n数据库: ${config.name}\n\nSQL:\n${params.sql}`,
806
- );
805
+ const ok = await confirmSqlExecution(ctx, {
806
+ reason: params.reason,
807
+ summary: verdict.summary,
808
+ database: config.name,
809
+ sql: params.sql,
810
+ type: config.type,
811
+ });
807
812
  if (!ok) {
808
813
  return { details: undefined,
809
814
  content: [{ type: "text" as const, text: "用户取消了 SQL 执行。" }],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nsyan/db",
3
- "version": "1.3.3",
3
+ "version": "1.3.5",
4
4
  "description": "AI 数据库接入扩展 for pi coding agent:PostgreSQL/MySQL/Oracle/达梦DM/Redis/Elasticsearch/MongoDB/Neo4j/Hive/Spark 十种数据库,查询/表结构/扫描建连/安全管控 | Database tools for the pi agent: query, schema, scan-and-connect for 10 databases",
5
5
  "keywords": [
6
6
  "pi-extension",
@@ -45,6 +45,7 @@
45
45
  },
46
46
  "peerDependencies": {
47
47
  "@earendil-works/pi-coding-agent": "*",
48
+ "@earendil-works/pi-tui": "*",
48
49
  "typebox": "*"
49
50
  },
50
51
  "files": [
@@ -69,10 +70,12 @@
69
70
  "mysql2": "^3.23.1",
70
71
  "neo4j-driver": "^5.28.3",
71
72
  "oracledb": "^7.0.1",
72
- "pg": "^8.22.0"
73
+ "pg": "^8.22.0",
74
+ "ws": "^8.22.0"
73
75
  },
74
76
  "devDependencies": {
75
77
  "@earendil-works/pi-coding-agent": "^0.85.1",
78
+ "@earendil-works/pi-tui": "^0.85.1",
76
79
  "@types/node": "^22.20.2",
77
80
  "@types/pg": "^8.20.0",
78
81
  "tsx": "^4.23.13",
package/src/core/index.ts CHANGED
@@ -3,5 +3,7 @@ export type { DbTypeId, DbFamily, ConnConfig, ParsedTarget, DbConnection, ExecOp
3
3
  CandidateStatus, Candidate, CandidateInput, TableInfo, ColumnInfo, QueryResult, ListTablesResult,
4
4
  DescribeTableResult, TestConnectionResult } from "./types.js";
5
5
  export { stripComments, splitStatements, isWriteStatement, isDropStatement } from "./sql-text.js";
6
+ export { formatSql, formatSqlForDisplay, displayLangFor } from "./sql-format.js";
7
+ export type { DisplayLang } from "./sql-format.js";
6
8
  export { decide } from "./policy.js";
7
9
  export type { ConfirmMode } from "./policy.js";
@@ -0,0 +1,294 @@
1
+ // core/sql-format.ts —— 确认框「展示用」SQL 美化 + 高亮语言映射。
2
+ //
3
+ // 设计红线:本模块只决定「弹窗里看到什么」,绝不参与执行。
4
+ // 实际执行永远走原始 params.sql(见 index.ts query_database),
5
+ // 因此这里即使美化得再激进也不会改变语义;反过来也要求尽量保留
6
+ // 字符串字面量、注释与标识符原貌,避免「看到的 SQL ≠ 执行的 SQL」。
7
+ import type { DbTypeId } from "./types.js";
8
+
9
+ /** 弹窗代码块高亮语言;undefined = 无对应语法,走 pi 的代码块配色 */
10
+ export type DisplayLang = "sql" | "json" | undefined;
11
+
12
+ /** 方言 → 高亮语言:关系型/大数据/图用 sql,文档/搜索用 json,Redis 命令无同类语法 */
13
+ export function displayLangFor(type: DbTypeId): DisplayLang {
14
+ switch (type) {
15
+ case "postgresql":
16
+ case "mysql":
17
+ case "oracle":
18
+ case "dm":
19
+ case "hive":
20
+ case "spark":
21
+ case "neo4j":
22
+ return "sql";
23
+ case "elasticsearch":
24
+ case "mongodb":
25
+ return "json";
26
+ case "redis":
27
+ return undefined;
28
+ }
29
+ }
30
+
31
+ interface Tok {
32
+ k: "w" | "s" | "n" | "p" | "c"; // word / string / number / punctuation / comment
33
+ t: string;
34
+ sp: boolean; // 原串中该 token 前是否有空白(用于保留 `COUNT(*)` 与 `IN (` 的差异)
35
+ }
36
+
37
+ const MULTI_OP = ["->>", "->", "::", "<=", ">=", "<>", "!=", "||", ":=", "|>"];
38
+ const JOIN_PREFIX = new Set(["LEFT", "RIGHT", "INNER", "OUTER", "FULL", "CROSS", "NATURAL"]);
39
+
40
+ // 触发换行的「子句关键字」(含多词形式,合并后匹配)
41
+ const CLAUSE = new Set([
42
+ "SELECT", "FROM", "WHERE", "GROUP BY", "HAVING", "ORDER BY", "LIMIT", "OFFSET",
43
+ "FETCH", "RETURNING", "WINDOW", "QUALIFY",
44
+ "INSERT INTO", "VALUES", "SET", "UPDATE", "DELETE FROM", "WITH",
45
+ "UNION", "UNION ALL", "EXCEPT", "INTERSECT",
46
+ "JOIN", "LEFT JOIN", "RIGHT JOIN", "INNER JOIN", "OUTER JOIN", "FULL JOIN",
47
+ "CROSS JOIN", "NATURAL JOIN", "MERGE", "MATCH", "UNWIND", "RETURN", "REMOVE",
48
+ "CREATE", "ALTER", "DROP", "TRUNCATE", "CALL", "EXPLAIN",
49
+ ]);
50
+ // 这些子句里的逗号按列/赋值拆行(更接近主流 SQL 格式化器)
51
+ const COMMA_CLAUSES = new Set(["SELECT", "SET", "VALUES", "RETURNING", "GROUP BY", "ORDER BY"]);
52
+ const COND = new Set(["AND", "OR", "ON", "WHEN"]);
53
+ // 不换行但统一大写的常见保留字/属性词(保持与子句关键字一致的观感)
54
+ const KEYWORD = new Set([
55
+ "AS", "ASC", "DESC", "DISTINCT", "ALL", "IN", "NOT", "NULL", "IS", "LIKE", "ILIKE",
56
+ "BETWEEN", "EXISTS", "CASE", "THEN", "ELSE", "END", "TRUE", "FALSE", "DEFAULT", "USING",
57
+ "RECURSIVE", "OVER", "PARTITION", "UNBOUNDED", "PRECEDING", "FOLLOWING", "CURRENT", "ROW",
58
+ "ROWS", "RANGE", "CAST", "INTERVAL", "PRIMARY", "FOREIGN", "KEY", "REFERENCES", "UNIQUE",
59
+ "INDEX", "CONSTRAINT", "CHECK", "CASCADE", "RESTRICT", "IF", "REPLACE", "IGNORE",
60
+ "DUPLICATE", "INTO", "TOP", "NULLS", "FIRST", "LAST", "OPTION", "LOCK", "FOR",
61
+ ]);
62
+
63
+ const INDENT = " ";
64
+ // 括号组内容超过这个「显示宽度」就当成块状列表拆行:
65
+ // 覆盖 CREATE TABLE(...) 长列定义、IN(...) 长列表等子句关键字覆盖不到的列表。
66
+ // 约等于 pi web 弹窗代码区(~560px / 等宽 ~7.5px)的可见列数。
67
+ const BLOCK_WIDTH = 64;
68
+ const WIDE = /[\u1100-\u115f\u2329\u232a\u2e80-\u4dcf\u4e00-\u9fff\uac00-\ud7a3\uf900-\ufaff\ufe30-\ufe6f\uff00-\uff60\uffe0-\uffe6\u20000-\u3fffd]/u;
69
+
70
+ /** 终端显示宽度:CJK/全角按 2 列(与 pi-tui/可见宽度一致,避免中文注释撑爆行宽判断) */
71
+ function displayWidth(s: string): number {
72
+ let w = 0;
73
+ for (const ch of s) w += WIDE.test(ch) ? 2 : 1;
74
+ return w;
75
+ }
76
+
77
+ function tokenize(sql: string): Tok[] {
78
+ const toks: Tok[] = [];
79
+ const n = sql.length;
80
+ let i = 0;
81
+ let sp = false;
82
+ const rest = (from: number) => sql.slice(from);
83
+
84
+ while (i < n) {
85
+ const ch = sql[i];
86
+ if (ch === " " || ch === "\t" || ch === "\r" || ch === "\n") { sp = true; i++; continue; }
87
+
88
+ // 行注释 -- ...
89
+ if (ch === "-" && sql[i + 1] === "-") {
90
+ let j = i;
91
+ while (j < n && sql[j] !== "\n") j++;
92
+ toks.push({ k: "c", t: sql.slice(i, j).trimEnd(), sp });
93
+ i = j; sp = true; continue;
94
+ }
95
+ // 块注释 /* ... */
96
+ if (ch === "/" && sql[i + 1] === "*") {
97
+ let j = i + 2;
98
+ while (j < n && !(sql[j] === "*" && sql[j + 1] === "/")) j++;
99
+ j = Math.min(n, j + 2);
100
+ toks.push({ k: "c", t: sql.slice(i, j), sp });
101
+ i = j; sp = true; continue;
102
+ }
103
+ // 字符串 / 引号标识符('' 与反斜杠转义都按原样保留)
104
+ if (ch === "'" || ch === '"' || ch === "`") {
105
+ let j = i + 1;
106
+ while (j < n) {
107
+ if (sql[j] === "\\") { j += 2; continue; }
108
+ if (sql[j] === ch) {
109
+ if (sql[j + 1] === ch) { j += 2; continue; }
110
+ j++; break;
111
+ }
112
+ j++;
113
+ }
114
+ toks.push({ k: "s", t: sql.slice(i, j), sp });
115
+ i = j; sp = false; continue;
116
+ }
117
+ // 数字
118
+ const num = /^(?:0[xX][0-9a-fA-F]+|[0-9]+(?:\.[0-9]*)?(?:[eE][+-]?[0-9]+)?|\.[0-9]+(?:[eE][+-]?[0-9]+)?)/.exec(rest(i));
119
+ if (num) { toks.push({ k: "n", t: num[0], sp }); i += num[0].length; sp = false; continue; }
120
+ // 单词 / 标识符(保留原始大小写,仅子句关键字后续统一大写)
121
+ const word = /^[A-Za-z_@#$][A-Za-z0-9_@#$]*/.exec(rest(i));
122
+ if (word) { toks.push({ k: "w", t: word[0], sp }); i += word[0].length; sp = false; continue; }
123
+ // 多字符运算符
124
+ const op = MULTI_OP.find((o) => rest(i).startsWith(o));
125
+ if (op) { toks.push({ k: "p", t: op, sp }); i += op.length; sp = false; continue; }
126
+ // 单字符标点
127
+ toks.push({ k: "p", t: ch, sp });
128
+ i++; sp = false;
129
+ }
130
+ return toks;
131
+ }
132
+
133
+ /** 宽松的 SQL 美化:按子句换行、逗号拆列、AND/OR 缩进、长括号组拆块;不改变 token 内容 */
134
+ export function formatSql(sql: string): string {
135
+ const toks = tokenize(sql);
136
+ if (toks.length === 0) return sql.trim();
137
+
138
+ // 预计算括号配对,用于判断某个 (...) 是否足够长、该拆成块状列表
139
+ const matchIdx = new Map<number, number>();
140
+ {
141
+ const stack: number[] = [];
142
+ for (let idx = 0; idx < toks.length; idx++) {
143
+ const t = toks[idx];
144
+ if (t.k !== "p") continue;
145
+ if (t.t === "(") stack.push(idx);
146
+ else if (t.t === ")") {
147
+ const open = stack.pop();
148
+ if (open !== undefined) matchIdx.set(open, idx);
149
+ }
150
+ }
151
+ }
152
+ const innerWidth = (open: number): number => {
153
+ const close = matchIdx.get(open);
154
+ if (close === undefined) return 0;
155
+ let w = 0;
156
+ for (let k = open + 1; k < close; k++) w += displayWidth(toks[k].t) + 1;
157
+ return w;
158
+ };
159
+
160
+ let out = "";
161
+ let pending = false; // 下一个 token 前补一个空格
162
+ let depth = 0; // 括号深度
163
+ let breakCommas = false;
164
+ let prevWord = ""; // 上一个单词(大写),用于识别 `FOR UPDATE` / `ON DUPLICATE KEY UPDATE` 等非语句头场景
165
+ // 每层括号:block = 长列表按块拆行;clause = 内含子句(子查询),收尾 ) 单独成行
166
+ const parenStack: { block: boolean; clause: boolean }[] = [];
167
+
168
+ const atLineStart = () => out === "" || out.endsWith("\n");
169
+ const newline = (indent: number) => {
170
+ out = out.replace(/[ \t]+$/, "");
171
+ if (!out.endsWith("\n")) out += "\n";
172
+ out += INDENT.repeat(Math.max(0, indent));
173
+ pending = false;
174
+ };
175
+ const write = (s: string, noSpace = false) => {
176
+ if (!atLineStart() && pending && !noSpace) out += " ";
177
+ out += s;
178
+ pending = false;
179
+ };
180
+
181
+ for (let i = 0; i < toks.length; i++) {
182
+ const tok = toks[i];
183
+
184
+ if (tok.k === "c") {
185
+ write(tok.t);
186
+ if (tok.t.startsWith("--")) newline(depth); else pending = true;
187
+ prevWord = "";
188
+ continue;
189
+ }
190
+
191
+ if (tok.k === "s" || tok.k === "n") { write(tok.t); pending = true; prevWord = ""; continue; }
192
+
193
+ if (tok.k === "p") {
194
+ prevWord = "";
195
+ const t = tok.t;
196
+ if (t === ",") {
197
+ write(",", true);
198
+ const inBlock = parenStack.length > 0 && parenStack[parenStack.length - 1].block;
199
+ if (breakCommas && depth === 0) newline(1);
200
+ else if (inBlock) newline(depth);
201
+ else pending = true;
202
+ } else if (t === "(") {
203
+ const block = innerWidth(i) > BLOCK_WIDTH;
204
+ parenStack.push({ block, clause: false });
205
+ write("(", !tok.sp);
206
+ depth++;
207
+ if (block) newline(depth); // 长列表:首项另起一行
208
+ } else if (t === ")") {
209
+ const top = parenStack.pop();
210
+ if (top?.clause === true || top?.block === true) newline(depth - 1); // 子查询/块列表收尾对齐
211
+ write(")", true);
212
+ depth = Math.max(0, depth - 1);
213
+ pending = true;
214
+ } else if (t === ".") {
215
+ write(".", true);
216
+ } else if (t === ";") {
217
+ write(";", true);
218
+ out = out.replace(/[ \t]+$/, "") + "\n\n";
219
+ pending = false;
220
+ breakCommas = false;
221
+ } else if (t === "*" && (out.endsWith("(") || out.endsWith(",") || atLineStart())) {
222
+ write("*", true); // COUNT(*) / SELECT * 的星号
223
+ pending = true;
224
+ } else {
225
+ write(t); // 二元运算符两侧留空格
226
+ pending = true;
227
+ }
228
+ continue;
229
+ }
230
+
231
+ // word:先尝试合并多词子句
232
+ const upper = tok.t.toUpperCase();
233
+ const nextW = toks[i + 1]?.k === "w" ? toks[i + 1].t.toUpperCase() : "";
234
+ let kw = upper;
235
+ if ((upper === "GROUP" || upper === "ORDER" || upper === "PARTITION") && nextW === "BY") { kw = upper + " BY"; i++; }
236
+ else if (upper === "UNION" && nextW === "ALL") { kw = "UNION ALL"; i++; }
237
+ else if (upper === "INSERT" && nextW === "INTO") { kw = "INSERT INTO"; i++; }
238
+ else if (upper === "DELETE" && nextW === "FROM") { kw = "DELETE FROM"; i++; }
239
+ else if (JOIN_PREFIX.has(upper) && nextW === "JOIN") { kw = upper + " JOIN"; i++; }
240
+
241
+ // 这些 UPDATE/VALUES 不是语句头/子句头,不能换行——否则确认框会
242
+ // 把一条 INSERT 的尾部渲染成看似两条语句,误导安全判断。
243
+ const nextTok = toks[i + 1];
244
+ const nextIsFnParen = nextTok?.k === "p" && nextTok.t === "(" && nextTok.sp === false;
245
+ const inlineUpdate = kw === "UPDATE" && (prevWord === "FOR" || prevWord === "DO" || prevWord === "KEY" || prevWord === "DUPLICATE");
246
+ const inlineValuesFn = kw === "VALUES" && nextIsFnParen; // MySQL VALUES(col) 函数
247
+
248
+ if (CLAUSE.has(kw) && !inlineUpdate && !inlineValuesFn) {
249
+ if (parenStack.length > 0) parenStack[parenStack.length - 1].clause = true;
250
+ newline(depth);
251
+ breakCommas = COMMA_CLAUSES.has(kw);
252
+ write(kw);
253
+ pending = true;
254
+ } else if (COND.has(kw)) {
255
+ newline(depth + 1);
256
+ write(kw);
257
+ pending = true;
258
+ } else if (KEYWORD.has(upper) || inlineUpdate || inlineValuesFn) {
259
+ write(upper);
260
+ pending = true;
261
+ } else {
262
+ write(tok.t); // 非关键字保留原始大小写
263
+ pending = true;
264
+ }
265
+ prevWord = kw.slice(kw.lastIndexOf(" ") + 1);
266
+ }
267
+
268
+ return out
269
+ .replace(/[ \t]+$/gm, "")
270
+ .replace(/\n{3,}/g, "\n\n")
271
+ .trim();
272
+ }
273
+
274
+ /**
275
+ * 展示用 SQL:关系型/大数据/图走 SQL 美化;MongoDB / Elasticsearch 的 JSON 信封走 2 空格缩进。
276
+ * 解析/美化失败时原样返回(弹窗宁可不好看,也不能显示错)。
277
+ */
278
+ export function formatSqlForDisplay(sql: string, type: DbTypeId): string {
279
+ const raw = sql.trim();
280
+ if (!raw) return raw;
281
+ if (type === "mongodb" || type === "elasticsearch") {
282
+ try {
283
+ return JSON.stringify(JSON.parse(raw), null, 2);
284
+ } catch {
285
+ return raw;
286
+ }
287
+ }
288
+ try {
289
+ const formatted = formatSql(raw).trim();
290
+ return formatted || raw;
291
+ } catch {
292
+ return raw;
293
+ }
294
+ }
@@ -0,0 +1,124 @@
1
+ // src/ui/sql-confirm.ts —— SQL 执行确认弹窗(展示层)。
2
+ //
3
+ // 目标:把「弹窗里那段密不透风的 SQL」变成可读的格式化 + 语法高亮 + 有层次的排版。
4
+ //
5
+ // 能力边界(已核对 pi / pi-web 源码):
6
+ // - ctx.ui.confirm 底层是 SelectList,标题/正文按纯文本渲染,无法高亮;
7
+ // - ctx.ui.custom 可挂任意 TUI 组件,且 pi-tui 的 Markdown 代码块会调用
8
+ // theme.highlightCode(cli-highlight),这就是 TUI 的 SQL 语法高亮入口;
9
+ // - RPC(pi-web)下 custom() 返回 undefined,但 pi-web 0.9.x 的确认弹窗正文
10
+ // 用 react-markdown 渲染,代码围栏走 Prism(已注册 sql/json),因此**发 Markdown
11
+ // 就能在 pi web 里高亮**(还会自带语言标签/行号/复制按钮 + 正文原生滚动)。
12
+ // 注意:Markdown 会把单个换行折叠成空格,所以标签之间必须用空行/列表分隔。
13
+ // - RPC 的 confirm 请求只有 title/message/timeout,弹窗尺寸由 pi-web 写死
14
+ // (560×760),插件层改不了。
15
+ import type { DbTypeId } from "../core/types.js";
16
+ import { displayLangFor, formatSqlForDisplay } from "../core/sql-format.js";
17
+
18
+ export interface SqlConfirmInfo {
19
+ /** 写操作执行理由(菜单「执行查询」路径没有 reason) */
20
+ reason?: string;
21
+ /** 策略层摘要,如「CREATE IF(共 1 条语句)」 */
22
+ summary?: string;
23
+ database?: string;
24
+ sql: string;
25
+ type: DbTypeId;
26
+ }
27
+
28
+ /** 压缩换行/多余空白:Markdown 单换行会折叠,多行理由先并成一行避免结构被破坏 */
29
+ function oneLine(s: string): string {
30
+ return s.replace(/\s*\n+\s*/g, " ").replace(/\s{2,}/g, " ").trim();
31
+ }
32
+
33
+ /** 转义会破坏行内代码/引用的反引号(其余 Markdown 符号在中文语境下误伤概率低,不激进转义) */
34
+ function escInline(s: string): string {
35
+ return s.replace(/`/g, "\\`");
36
+ }
37
+
38
+ /** 围栏长度 = SQL 里最长反引号串 + 1(至少 3),避免语句内含 ``` 时把围栏撑破 */
39
+ function fenceFor(sql: string): string {
40
+ const runs = sql.match(/`+/g) ?? [];
41
+ const longest = runs.reduce((max, r) => Math.max(max, r.length), 0);
42
+ return "`".repeat(Math.max(3, longest + 1));
43
+ }
44
+
45
+ /**
46
+ * 构造确认弹窗的 Markdown 正文(TUI 与 pi web 共用同一份文案)。
47
+ * 结构:引用块(执行理由) + 列表(影响摘要/数据库) + 代码块(SQL,带方言语言标记)。
48
+ * 颜色由主题对 引用/加粗/行内代码/代码块 的默认着色决定(Markdown 无法任意指定颜色)。
49
+ */
50
+ export function buildSqlConfirmMarkdown(info: SqlConfirmInfo): string {
51
+ const sql = formatSqlForDisplay(info.sql, info.type);
52
+ const lang = displayLangFor(info.type) ?? "";
53
+ const fence = fenceFor(sql);
54
+ const lines: string[] = [];
55
+
56
+ const reason = info.reason ? oneLine(info.reason) : "";
57
+ if (reason) {
58
+ lines.push(`> **📝 执行理由**:${escInline(reason)}`);
59
+ lines.push("");
60
+ }
61
+
62
+ const meta: string[] = [];
63
+ const summary = info.summary ? oneLine(info.summary) : "";
64
+ const database = info.database ? oneLine(info.database) : "";
65
+ if (summary) meta.push(`- **📋 影响摘要**:\`${escInline(summary)}\``);
66
+ if (database) meta.push(`- **🗄️ 数据库**:\`${escInline(database)}\``);
67
+ if (meta.length > 0) {
68
+ lines.push(...meta);
69
+ lines.push("");
70
+ }
71
+
72
+ lines.push(`${fence}${lang}`);
73
+ lines.push(sql);
74
+ lines.push(fence);
75
+ return lines.join("\n");
76
+ }
77
+
78
+ /** TUI 自定义弹窗:标题 + Markdown 正文(引用/列表/高亮 SQL 代码块)+ 操作提示 */
79
+ async function showTuiConfirm(ctx: any, markdown: string): Promise<boolean> {
80
+ const [{ DynamicBorder, getMarkdownTheme }, { Container, Markdown, matchesKey, Text }] = await Promise.all([
81
+ import("@earendil-works/pi-coding-agent"),
82
+ import("@earendil-works/pi-tui"),
83
+ ]);
84
+
85
+ const result = await ctx.ui.custom((_tui: any, theme: any, _kb: any, done: (v: boolean) => void) => {
86
+ const container = new Container();
87
+ const border = new DynamicBorder((s: string) => theme.fg("accent", s));
88
+
89
+ container.addChild(border);
90
+ container.addChild(new Text(theme.fg("accent", theme.bold("SQL 执行确认")), 1, 0));
91
+ // 与 pi web 同一份 Markdown:Markdown 代码块 → highlightCode(code, lang) → 语法高亮
92
+ container.addChild(new Markdown(markdown, 1, 1, getMarkdownTheme()));
93
+ container.addChild(new Text(theme.fg("dim", "Enter 执行 · Esc 取消"), 1, 1));
94
+ container.addChild(border);
95
+
96
+ return {
97
+ render: (width: number) => container.render(width),
98
+ invalidate: () => container.invalidate(),
99
+ handleInput: (data: string) => {
100
+ if (matchesKey(data, "enter")) { done(true); return; }
101
+ if (matchesKey(data, "escape")) { done(false); return; }
102
+ },
103
+ };
104
+ });
105
+
106
+ return result === true;
107
+ }
108
+
109
+ /**
110
+ * 统一的 SQL 执行确认入口。返回 true 表示用户确认执行。
111
+ * 任何 UI 异常都回退到 ctx.ui.confirm(同一份 Markdown)——确认环节绝不因渲染故障被静默跳过。
112
+ */
113
+ export async function confirmSqlExecution(ctx: any, info: SqlConfirmInfo): Promise<boolean> {
114
+ const markdown = buildSqlConfirmMarkdown(info);
115
+ if (ctx?.mode === "tui" && typeof ctx?.ui?.custom === "function") {
116
+ try {
117
+ return await showTuiConfirm(ctx, markdown);
118
+ } catch {
119
+ // 自定义组件不可用/异常 → 落到下方 confirm
120
+ }
121
+ }
122
+ const ok = await ctx.ui.confirm("SQL 执行确认", markdown);
123
+ return ok === true;
124
+ }