diff --git a/__tests__/display-width.test.ts b/__tests__/display-width.test.ts new file mode 100644 index 0000000..976e2f2 --- /dev/null +++ b/__tests__/display-width.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from "vitest"; +import { visibleWidth } from "@earendil-works/pi-tui"; +import { padToDisplayWidth, truncateToDisplayWidth } from "../formatting/display-width"; + +describe("truncateToDisplayWidth", () => { + it("短文本原样返回", () => { + expect(truncateToDisplayWidth("hello", 20)).toBe("hello"); + }); + + it("超宽 ASCII 截断并加省略号", () => { + const r = truncateToDisplayWidth("A".repeat(50), 20); + expect(visibleWidth(r)).toBeLessThanOrEqual(20); + expect(r.endsWith("…")).toBe(true); + }); + + it("中文按显示宽度截断(每字 2 列)", () => { + const r = truncateToDisplayWidth("产品".repeat(30), 20); + // 20 列 = 9 个汉字 + 省略号(1 列)→ 前缀 9 字 + expect(visibleWidth(r)).toBe(19); + expect(r.endsWith("…")).toBe(true); + }); + + it("截断不注入 ANSI(纯文本契约)", () => { + const r = truncateToDisplayWidth("产品".repeat(30), 20); + expect(r).not.toContain("\x1b"); + }); +}); + +describe("padToDisplayWidth", () => { + it("ASCII 补齐到目标宽度", () => { + expect(visibleWidth(padToDisplayWidth("ab", 10))).toBe(10); + }); + + it("中文按显示宽度补齐(不按码元)", () => { + const padded = padToDisplayWidth("产品", 10); + // “产品” 显示 4 列 → 补 6 个空格 + expect(visibleWidth(padded)).toBe(10); + expect(padded.length).toBe(8); + }); + + it("超宽时不补齐原样返回", () => { + expect(padToDisplayWidth("A".repeat(20), 10)).toBe("A".repeat(20)); + }); +}); diff --git a/__tests__/related-browser.test.ts b/__tests__/related-browser.test.ts index 3c45eb3..f0f49ce 100644 --- a/__tests__/related-browser.test.ts +++ b/__tests__/related-browser.test.ts @@ -6,7 +6,7 @@ * 通过 matchesKey 解码——与 filter-input 测试同一契约。 */ import { describe, it, expect } from "vitest"; -import { matchesKey, Key } from "@earendil-works/pi-tui"; +import { matchesKey, Key, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; import { createBrowserNav, buildBrowserContent, @@ -159,6 +159,39 @@ describe("buildBrowserContent", () => { }); }); +// ====== tabs 行超宽截断(回归:TUI 宽度断言崩溃)====== + +describe("tabs 行宽度", () => { + it("长表名标签 join 后超出内容宽度(回归前提成立)", () => { + const labels = Array.from( + { length: 6 }, + (_, i) => `order_items_2024_${i}_snapshot(${i + 1} 行)`, + ); + const styled = labels.map((l, i) => (i === 0 ? `\x1b[1m▶ ${l}\x1b[22m` : ` ${l}`)).join(" "); + // 终端 163 列、overlay 92%、Box 内边距 2 → 内容宽 ≈ 148 + expect(visibleWidth(styled)).toBeGreaterThan(148); + }); + + it("truncateToWidth 把超宽标签行截到内容宽度内并保留省略号", () => { + const labels = Array.from( + { length: 6 }, + (_, i) => `order_items_2024_${i}_snapshot(${i + 1} 行)`, + ); + const styled = labels.map((l, i) => (i === 0 ? `\x1b[1m▶ ${l}\x1b[22m` : ` ${l}`)).join(" "); + const truncated = truncateToWidth(styled, 148); + expect(visibleWidth(truncated)).toBeLessThanOrEqual(148); + // 默认省略号为 ASCII 省略号 + expect(truncated).toContain("..."); + }); + + it("truncateToWidth 截断 ANSI 样式文本时保留样式(冒烟契约)", () => { + const styled = "\x1b[32m" + "A".repeat(50) + "\x1b[0m"; + const truncated = truncateToWidth(styled, 20); + expect(truncated).toContain("\x1b[32m"); + expect(visibleWidth(truncated)).toBeLessThanOrEqual(20); + }); +}); + // ====== formatScrollInfo / formatBrowserFooter ====== describe("formatScrollInfo", () => { diff --git a/__tests__/renderers.test.ts b/__tests__/renderers.test.ts new file mode 100644 index 0000000..bf5a392 --- /dev/null +++ b/__tests__/renderers.test.ts @@ -0,0 +1,118 @@ +import { describe, it, expect, beforeAll } from "vitest"; +import { initTheme } from "@earendil-works/pi-coding-agent"; +import { visibleWidth } from "@earendil-works/pi-tui"; +import { renderQueryResult, type QueryResultEntryData } from "../commands/renderers"; + +/** keyHint 依赖全局 theme——测试环境需先初始化(pi 运行时已初始化)。 */ +beforeAll(() => { + initTheme("dark"); +}); + +/** 透传颜色标记的 fake theme——断言只看文本与宽度。 */ +const fakeTheme = { fg: (_c: string, s: string) => s, bold: (s: string) => s }; + +function entry(over: Partial): QueryResultEntryData { + return { + database: "test_db", + sql: "SELECT 1", + rowCount: 1, + elapsed: "3ms", + columns: ["id"], + rows: [{ id: "1" }], + related: [], + ...over, + }; +} + +/** 断言所有渲染行可见宽度不超过 width。 */ +function expectAllLinesWithin(lines: string[], width: number): void { + for (const line of lines) { + expect(visibleWidth(line), `超宽行:${line.slice(0, 60)}…`).toBeLessThanOrEqual(width); + } +} + +// ====== renderQueryResult(/db query 的 TUI 条目渲染)====== + +describe("renderQueryResult 宽度防线", () => { + const WIDTH = 120; + + it("长 SQL 行被截断到渲染宽度(回归:TUI 宽度断言崩溃)", () => { + const sql = "SELECT * FROM t WHERE a = 1 AND b = " + "x".repeat(200); + const lines = renderQueryResult(entry({ sql }), WIDTH, false, fakeTheme); + expectAllLinesWithin(lines, WIDTH); + expect(lines.some((l) => l.includes("SQL: SELECT"))).toBe(true); + }); + + it("折叠态中文单元格表格行不超过渲染宽度", () => { + const lines = renderQueryResult( + entry({ + columns: ["id", "description"], + rows: [{ id: "1", description: "产品".repeat(60) }], + }), + WIDTH, + false, + fakeTheme, + ); + expectAllLinesWithin(lines, WIDTH); + }); + + it("展开态长值(ASCII)行不超过渲染宽度", () => { + const lines = renderQueryResult( + entry({ + columns: ["id", "payload"], + rows: [{ id: "1", payload: "A".repeat(300) }], + }), + WIDTH, + true, + fakeTheme, + ); + expectAllLinesWithin(lines, WIDTH); + }); + + it("展开态长值(中文)行不超过渲染宽度", () => { + const lines = renderQueryResult( + entry({ + columns: ["id", "payload"], + rows: [{ id: "1", payload: "中".repeat(200) }], + }), + WIDTH, + true, + fakeTheme, + ); + expectAllLinesWithin(lines, WIDTH); + }); + + it("折叠态关联表摘要(长表名)行不超过渲染宽度", () => { + const related = Array.from({ length: 8 }, (_, i) => ({ + schema: "db", + table: `order_items_2024_${i}_snapshot`, + joinPath: "", + rowCount: 1, + elapsed: "1ms", + columns: ["id"], + rows: [{ id: "1" }], + })); + const lines = renderQueryResult(entry({ related }), WIDTH, false, fakeTheme); + expectAllLinesWithin(lines, WIDTH); + }); + + it("展开态关联表节(长表名标题)行不超过渲染宽度", () => { + const related = Array.from({ length: 8 }, (_, i) => ({ + schema: "db", + table: `order_items_2024_${i}_snapshot`, + joinPath: "", + rowCount: 1, + elapsed: "1ms", + columns: ["id"], + rows: [{ id: "1" }], + })); + const lines = renderQueryResult(entry({ related }), WIDTH, true, fakeTheme); + expectAllLinesWithin(lines, WIDTH); + }); + + it("短内容保持原样不被截断", () => { + const lines = renderQueryResult(entry({}), 120, false, fakeTheme); + expect(lines.some((l) => l.includes("SELECT 1"))).toBe(true); + expect(lines.some((l) => l.includes("1 行"))).toBe(true); + }); +}); diff --git a/__tests__/result-table.test.ts b/__tests__/result-table.test.ts index 4ae38be..10da15f 100644 --- a/__tests__/result-table.test.ts +++ b/__tests__/result-table.test.ts @@ -2,6 +2,7 @@ * formatting/result-table.ts 的测试——纯函数,无依赖。 */ import { describe, it, expect } from "vitest"; +import { visibleWidth } from "@earendil-works/pi-tui"; import { analyzeColumns, layoutColumns, @@ -330,4 +331,81 @@ describe("formatTableDisplay", () => { // 12 列单行放不下横向 → 转置。格式化器不应崩溃。 expect(result).not.toBe("(空结果)"); }); + + it("窄宽度下所有行不超过给定宽度(回归:超宽行触发 TUI 断言崩溃)", () => { + // 长列名 + 10 行的宽表:横向放不下 → 转置。修复前 cellWidth 下限 4, + // 窄宽度(40/55)下总行宽超出 width。 + const cols = [ + "very_long_column_name_a", + "very_long_column_name_b", + "very_long_column_name_c", + "very_long_column_name_d", + ]; + const rows = Array.from({ length: 10 }, (_, r) => + Object.fromEntries(cols.map((c, i) => [c, `value_${r}_${i}`])), + ); + for (const width of [40, 55, 70, 90]) { + const result = formatTableDisplay({ columns: cols, rows }, width); + for (const line of result.split("\n")) { + expect(visibleWidth(line), `width=${width}: ${line}`).toBeLessThanOrEqual(width); + } + } + }); + + it("窄宽度下纵向模式的行宽不超过给定宽度", () => { + // 20 列长列名 + 15 行:横向放不下(minW 钳制后仍超预算)、行数 > 10 不走转置 + // → 纵向。修复前 valueCap 下限 20,窄宽度(40/55)下键值行超出 width。 + const cols = Array.from({ length: 20 }, (_, i) => `very_long_column_name_${i}`); + const rows = Array.from({ length: 15 }, (_, r) => + Object.fromEntries(cols.map((c, i) => [c, `value_${r}_${i}`])), + ); + for (const width of [40, 55, 70, 90]) { + const result = formatTableDisplay({ columns: cols, rows }, width); + expect(result).toContain("─── Row"); + for (const line of result.split("\n")) { + expect(visibleWidth(line), `width=${width}: ${line}`).toBeLessThanOrEqual(width); + } + } + }); + + it("中文长值横向表格的行宽不超过给定宽度(回归:宽字符按码元截断)", () => { + // 修复前 pad()/布局预算按 UTF-16 码元计算:中文显示宽度 2 倍, + // 截断到码元宽度后显示仍超宽 → TUI 宽度断言崩溃。 + const cols = ["id", "description", "note"]; + const rows = [{ id: 1, description: "产品".repeat(60), note: "备注" }]; + for (const width of [40, 80, 120]) { + const result = formatTableDisplay({ columns: cols, rows }, width); + for (const line of result.split("\n")) { + expect(visibleWidth(line), `width=${width}: ${line}`).toBeLessThanOrEqual(width); + } + } + }); + + it("中文单元格截断后带省略号且不超预算", () => { + const result = formatTableDisplay( + { columns: ["id", "description"], rows: [{ id: 1, description: "产品".repeat(30) }] }, + 60, + ); + const valueLine = result.split("\n").find((l) => l.includes("…")); + expect(valueLine).toBeDefined(); + expect(visibleWidth(valueLine!)).toBeLessThanOrEqual(60); + }); + + it("中文列名表格的行宽不超过给定宽度", () => { + // 列名本身是中文(占 2 列):colNameWidth/布局按显示宽度计算, + // 修复前 padEnd 按码元补齐导致行超宽。40 宽走转置、60/80 宽 + // 走横向(按比例压缩后 minW>=2 且总宽 ≤ width)——两种布局都不得超宽。 + const cols = Array.from({ length: 10 }, (_, i) => `字段名称${i}`); + const rows = Array.from({ length: 3 }, (_, r) => + Object.fromEntries(cols.map((c, i) => [c, `值${r}${i}`])), + ); + for (const width of [40, 60, 80]) { + const result = formatTableDisplay({ columns: cols, rows }, width); + for (const line of result.split("\n")) { + expect(visibleWidth(line), `width=${width}: ${line}`).toBeLessThanOrEqual(width); + // 表格行是纯文本契约——截断不得注入 ANSI + expect(line, `width=${width} 不应含 ANSI`).not.toContain("\x1b"); + } + } + }); }); diff --git a/__tests__/tool-result-summary.test.ts b/__tests__/tool-result-summary.test.ts new file mode 100644 index 0000000..f7f7411 --- /dev/null +++ b/__tests__/tool-result-summary.test.ts @@ -0,0 +1,135 @@ +import { describe, it, expect } from "vitest"; +import { summarizeDbToolResult } from "../tools/tool-result-summary"; + +describe("summarizeDbToolResult", () => { + describe("db_query", () => { + it("生成行数摘要", () => { + expect( + summarizeDbToolResult("db_query", { + connection: "local", + database: "test_db", + rowCount: 42, + elapsed: "8ms", + }), + ).toBe("db_query:42 行 · 8ms(local/test_db)"); + }); + + it("rowCount 缺失时返回 undefined", () => { + expect( + summarizeDbToolResult("db_query", { + connection: "local", + database: "test_db", + elapsed: "8ms", + }), + ).toBeUndefined(); + }); + }); + + describe("db_tables", () => { + it("列表模式:表数量", () => { + expect( + summarizeDbToolResult("db_tables", { + connection: "local", + database: "test_db", + tables: ["users", "orders", "items"], + }), + ).toBe("db_tables:test_db 表列表(3 个)"); + }); + + it("schema 模式:列数与索引数", () => { + expect( + summarizeDbToolResult("db_tables", { + connection: "local", + database: "test_db", + table: "users", + columnCount: 5, + indexCount: 2, + }), + ).toBe("db_tables:test_db.users 结构(5 列 / 2 索引)"); + }); + + it("schema 模式缺少计数时返回 undefined", () => { + expect( + summarizeDbToolResult("db_tables", { + connection: "local", + database: "test_db", + table: "users", + }), + ).toBeUndefined(); + }); + + it("列表模式缺少 tables 时返回 undefined", () => { + expect( + summarizeDbToolResult("db_tables", { + connection: "local", + database: "test_db", + table: undefined, + }), + ).toBeUndefined(); + }); + }); + + describe("db_discover", () => { + it("只列连接", () => { + expect( + summarizeDbToolResult("db_discover", { + connections: ["local", "staging"], + connection: undefined, + }), + ).toBe("db_discover:2 个连接"); + }); + + it("连接 + 目标库数量", () => { + expect( + summarizeDbToolResult("db_discover", { + connections: ["local", "staging"], + connection: "local", + databaseCount: 7, + }), + ).toBe("db_discover:2 个连接 · 7 个数据库"); + }); + + it("connections 缺失时返回 undefined", () => { + expect(summarizeDbToolResult("db_discover", { connection: "local" })).toBeUndefined(); + }); + }); + + describe("db_tools loader", () => { + it("有新增工具时显示已启用列表", () => { + expect( + summarizeDbToolResult("db_tools", { + matches: ["db_discover", "db_list_relations"], + added: ["db_discover", "db_list_relations"], + }), + ).toBe("db_tools:已启用 db_discover、db_list_relations"); + }); + + it("无新增时显示已激活列表", () => { + expect( + summarizeDbToolResult("db_tools", { + matches: ["db_discover"], + added: [], + }), + ).toBe("db_tools:已激活 db_discover"); + }); + + it("无匹配时给出提示", () => { + expect(summarizeDbToolResult("db_tools", { matches: [], added: [] })).toBe( + "db_tools:无匹配工具", + ); + }); + + it("缺少数组字段时返回 undefined", () => { + expect(summarizeDbToolResult("db_tools", { added: [] })).toBeUndefined(); + }); + }); + + it("未知工具名返回 undefined", () => { + expect(summarizeDbToolResult("db_mutate", { sql: "UPDATE t SET x=1" })).toBeUndefined(); + }); + + it("非对象 details 返回 undefined", () => { + expect(summarizeDbToolResult("db_query", undefined)).toBeUndefined(); + expect(summarizeDbToolResult("db_query", "text")).toBeUndefined(); + }); +}); diff --git a/commands/mutate-confirm.ts b/commands/mutate-confirm.ts index e4c5957..eb1d987 100644 --- a/commands/mutate-confirm.ts +++ b/commands/mutate-confirm.ts @@ -8,8 +8,9 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; import { DynamicBorder } from "@earendil-works/pi-coding-agent"; -import { Container, Text, Spacer, matchesKey, Key } from "@earendil-works/pi-tui"; +import { Container, Text, Spacer, matchesKey, Key, visibleWidth } from "@earendil-works/pi-tui"; import type { MutationApprovalRequest } from "../state/workspace"; +import { padToDisplayWidth, truncateToDisplayWidth } from "../formatting/display-width"; type StyleColor = "success" | "warning" | "error"; @@ -83,14 +84,15 @@ export async function showMutationConfirm( ), ); - // SQL 内容 + // SQL 内容——按显示宽度截断/补齐(中文/emoji 占 2 列, + // 按码元 slice/padEnd 会让中文 SQL 行超宽)。 for (const line of sqlLines) { const trimmed = line.trim(); const maxContent = boxInnerWidth - 2; const display = - trimmed.length > maxContent - ? trimmed.slice(0, maxContent - 1) + "…" - : trimmed.padEnd(maxContent); + visibleWidth(trimmed) > maxContent + ? truncateToDisplayWidth(trimmed, maxContent - 1) + "…" + : padToDisplayWidth(trimmed, maxContent); container.addChild( new Text( `${theme.fg(style.color, " │ ")}${display}${theme.fg(style.color, " │")}`, diff --git a/commands/related-browser.ts b/commands/related-browser.ts index f1e61b1..c48a401 100644 --- a/commands/related-browser.ts +++ b/commands/related-browser.ts @@ -21,7 +21,7 @@ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent"; import { DynamicBorder } from "@earendil-works/pi-coding-agent"; -import { Box, Key, matchesKey, Text } from "@earendil-works/pi-tui"; +import { Box, Key, matchesKey, Text, truncateToWidth } from "@earendil-works/pi-tui"; import { formatTableDisplay } from "../formatting/result-table"; import type { RelatedTuiData } from "./renderers"; @@ -232,22 +232,35 @@ export async function openRelatedBrowser( const maxScroll = Math.max(0, content.table.length - viewport); scroll = Math.min(scroll, maxScroll); + // 所有行都用 truncateToWidth 截断到内容宽度——长表名/长路径/窄终端 + // 下 Text 不会自动截断,超宽行会触发 TUI 的宽度断言崩溃。 titleText.setText( - theme.fg("accent", theme.bold("📎 关联表浏览器")) + - theme.fg("dim", ` — ${content.title}`), + truncateToWidth( + theme.fg("accent", theme.bold("📎 关联表浏览器")) + + theme.fg("dim", ` — ${content.title}`), + contentWidth, + ), ); tabsText.setText( - content.tabs - .map((t) => - t.active - ? theme.fg("accent", theme.bold(`▶ ${t.label}`)) - : theme.fg("dim", ` ${t.label}`), - ) - .join(" "), + truncateToWidth( + content.tabs + .map((t) => + t.active + ? theme.fg("accent", theme.bold(`▶ ${t.label}`)) + : theme.fg("dim", ` ${t.label}`), + ) + .join(" "), + contentWidth, + ), ); - pathText.setText(content.path ? theme.fg("muted", `路径:${content.path}`) : " "); + pathText.setText( + truncateToWidth( + content.path ? theme.fg("muted", `路径:${content.path}`) : " ", + contentWidth, + ), + ); separatorText.setText(theme.fg("dim", "─".repeat(Math.max(10, contentWidth)))); @@ -259,13 +272,16 @@ export async function openRelatedBrowser( tableText.setText( content.table .slice(scroll, scroll + viewport) - .map((line) => theme.fg("text", line)) + .map((line) => truncateToWidth(theme.fg("text", line), contentWidth)) .join("\n"), ); } footerText.setText( - theme.fg("dim", formatBrowserFooter(scroll, viewport, content.table.length)), + truncateToWidth( + theme.fg("dim", formatBrowserFooter(scroll, viewport, content.table.length)), + contentWidth, + ), ); }; diff --git a/commands/renderers.ts b/commands/renderers.ts index b6e98c8..cd181b0 100644 --- a/commands/renderers.ts +++ b/commands/renderers.ts @@ -13,7 +13,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { keyHint } from "@earendil-works/pi-coding-agent"; -import type { Component } from "@earendil-works/pi-tui"; +import { truncateToWidth, type Component } from "@earendil-works/pi-tui"; import type { QueryResultDoc } from "../formatting/result-document"; import { renderQueryDocument } from "../formatting/result-document"; @@ -65,8 +65,12 @@ export function registerRenderers(pi: ExtensionAPI): void { // ====== 内部辅助 ====== -/** 装配文档 + 映射颜色 + 追加 keyHint 交互提示。 */ -function renderQueryResult( +/** + * 装配文档 + 映射颜色 + 追加 keyHint 交互提示。 + * + * 导出供测试(fake theme 可测)。 + */ +export function renderQueryResult( d: QueryResultEntryData, width: number, expanded: boolean, @@ -90,9 +94,14 @@ function renderQueryResult( } else if (l.hint === "expand-related") { text = `(${keyHint("app.tools.expand", "展开查看完整内容")},或 /db related 打开浏览器)`; } - if (l.style === "accent") return theme.fg("accent", theme.bold(text)); - if (l.style === "dim") return theme.fg("dim", text); - if (l.style === "muted") return theme.fg("muted", text); - return text; + let styled: string; + if (l.style === "accent") styled = theme.fg("accent", theme.bold(text)); + else if (l.style === "dim") styled = theme.fg("dim", text); + else if (l.style === "muted") styled = theme.fg("muted", text); + else styled = text; + // 最后防线:任何一行都不超过渲染宽度——长 SQL、展开态长值、 + // 中文单元格表格都可能超宽,Text 不自动截断,超宽行会触发 + // TUI 的宽度断言崩溃(Rendered line N exceeds terminal width)。 + return truncateToWidth(styled, Math.max(1, width), "…"); }); } diff --git a/formatting/display-width.ts b/formatting/display-width.ts new file mode 100644 index 0000000..2a31e64 --- /dev/null +++ b/formatting/display-width.ts @@ -0,0 +1,35 @@ +/** + * 显示宽度(终端列数)的纯函数工具——纯函数,无 I/O。 + * + * TUI 的宽度断言(doRender)以 pi-tui 的 visibleWidth 为基准, + * 布局/截断必须与它同一把尺子:中文、emoji 等宽字符占 2 列, + * 按 UTF-16 码元(s.length)截断/补齐会让行超宽并崩溃。 + * + * 注意:本模块处理的是纯文本(无 ANSI)——样式化文本请用 + * pi-tui 的 truncateToWidth(它感知 ANSI 序列)。 + */ + +import { visibleWidth } from "@earendil-works/pi-tui"; + +/** + * 纯文本按显示宽度截断,超出部分替换为省略号。 + * + * 不用 pi-tui 的 truncateToWidth——它会注入 \x1b[0m reset, + * 破坏表格行等处的纯文本契约。 + */ +export function truncateToDisplayWidth(s: string, max: number): string { + if (visibleWidth(s) <= max) return s; + let w = 0; + let i = 0; + for (; i < s.length; i++) { + const cw = visibleWidth(s[i]); + if (w + cw > max - 1) break; + w += cw; + } + return s.slice(0, i) + "…"; +} + +/** 按显示宽度右补齐到 w 列(超出时不补齐,原样返回)。 */ +export function padToDisplayWidth(s: string, w: number): string { + return s + " ".repeat(Math.max(0, w - visibleWidth(s))); +} diff --git a/formatting/result-table.ts b/formatting/result-table.ts index 4c92604..cb4bf88 100644 --- a/formatting/result-table.ts +++ b/formatting/result-table.ts @@ -9,6 +9,8 @@ */ import type { SqlRow } from "../types"; +import { visibleWidth } from "@earendil-works/pi-tui"; +import { padToDisplayWidth, truncateToDisplayWidth } from "./display-width"; // ====== 类型 ====== @@ -88,7 +90,7 @@ function hiddenNote(stats: ColumnStats, maxWidth = 160): string { } function trimToWidth(s: string, max: number): string { - return s.length > max ? s.slice(0, Math.max(0, max - 1)) + "…" : s; + return truncateToDisplayWidth(s, max); } // ====== 自适应列宽打包 ====== @@ -136,9 +138,14 @@ export function layoutColumns(idealWidths: number[], budget: number): number[] { // ====== 单元格辅助 ====== +/** + * 单元格截断/补齐到显示宽度 w。 + * + * 不能用 UTF-16 码元长度(s.length):中文/emoji 单元格按码元截断 + * 后显示宽度仍可能超出预算,行宽超出 width 会触发 TUI 断言崩溃。 + */ function pad(s: string, w: number): string { - if (s.length > w) return s.slice(0, Math.max(0, w - 1)) + "…"; - return s.padEnd(w); + return visibleWidth(s) > w ? truncateToDisplayWidth(s, w - 1) + "…" : padToDisplayWidth(s, w); } function cellString(val: unknown): string { @@ -201,15 +208,17 @@ function formatTransposed( return label.length > 22 ? label.slice(0, 19) + "…" : label; }); - // 自适应列名宽度(上限 24) - const colNameWidth = Math.min(24, Math.max(...cols.map((c) => c.length))); - // 单元格宽度:计入行表头之间的所有 “ │ ” 分隔符 + // 自适应列名宽度(上限 24)——按显示宽度 + const colNameWidth = Math.min(24, Math.max(...cols.map((c) => visibleWidth(c)))); + // 单元格宽度:计入行表头之间的所有 “ │ ” 分隔符。 + // 注意下限是 1 而不是 4——cellsBudget 小于每列 4 字符时若钳制到 4, + // 总行宽会超出 width,触发 TUI 的超宽行断言崩溃。 const LEFT_OVERHEAD = 5; // " " + " │ " before the first cell const BETWEEN_OVERHEAD = 3 * (rowHeaders.length - 1); // " │ " between cells const cellsBudget = Math.max(0, width - colNameWidth - LEFT_OVERHEAD - BETWEEN_OVERHEAD); const cellWidth = rowHeaders.length > 0 - ? Math.min(40, Math.max(4, Math.floor(cellsBudget / rowHeaders.length))) + ? Math.min(40, Math.max(1, Math.floor(cellsBudget / rowHeaders.length))) : 40; const lines: string[] = []; @@ -229,7 +238,7 @@ function formatTransposed( // 每列一行 for (const col of cols) { const vals = displayRows.map((row) => pad(cellString(row[col]), cellWidth)); - lines.push(" " + col.padEnd(colNameWidth) + " │ " + vals.join(" │ ")); + lines.push(" " + padToDisplayWidth(col, colNameWidth) + " │ " + vals.join(" │ ")); } lines.push(""); @@ -251,10 +260,11 @@ function formatVertical( ): string { const MAX_DISPLAY = 5; const displayRows = rows.slice(0, MAX_DISPLAY); - const labelWidth = Math.min(28, Math.max(...cols.map((c) => c.length))); - // 将单元格值截断到标签与分隔符之后能容纳的长度 + const labelWidth = Math.min(28, Math.max(...cols.map((c) => visibleWidth(c)))); + // 将单元格值截断到标签与分隔符之后能容纳的长度。 + // 下限 1 而不是 20——宽度不够时若钳制到 20,行宽会超出 width。 const VALUE_OVERHEAD = 7; // " " + " │ " (2 leading + 3 separator + 2 padding) - const valueCap = Math.max(20, Math.min(60, width - labelWidth - VALUE_OVERHEAD)); + const valueCap = Math.max(1, Math.min(60, width - labelWidth - VALUE_OVERHEAD)); const lines: string[] = []; for (let i = 0; i < displayRows.length; i++) { const row = displayRows[i]; @@ -262,8 +272,9 @@ function formatVertical( lines.push(`─── Row ${i + 1}${id ? ` [${id}]` : ""} ───`); for (const col of cols) { const s = cellString(row[col]); - const display = s.length > valueCap ? s.slice(0, Math.max(0, valueCap - 1)) + "…" : s; - lines.push(` ${col.padEnd(labelWidth)} │ ${display}`); + const display = + visibleWidth(s) > valueCap ? truncateToDisplayWidth(s, valueCap - 1) + "…" : s; + lines.push(` ${padToDisplayWidth(col, labelWidth)} │ ${display}`); } lines.push(""); } @@ -276,7 +287,7 @@ function formatVertical( // ====== 纵向完整(展开模式——不截断,全部行)====== export function formatVerticalFull(columns: string[], rows: SqlRow[]): string[] { - const labelWidth = Math.min(28, Math.max(...columns.map((c) => c.length))); + const labelWidth = Math.min(28, Math.max(...columns.map((c) => visibleWidth(c)))); const lines: string[] = []; for (let i = 0; i < rows.length; i++) { @@ -285,7 +296,7 @@ export function formatVerticalFull(columns: string[], rows: SqlRow[]): string[] lines.push(`─── Row ${i + 1}${id ? ` [${id}]` : ""} ───`); for (const col of columns) { const s = cellString(row[col]); - lines.push(` ${col.padEnd(labelWidth)} │ ${s}`); + lines.push(` ${padToDisplayWidth(col, labelWidth)} │ ${s}`); } lines.push(""); } @@ -312,13 +323,13 @@ export function formatTableDisplay(result: TableResult, width: number): string { const cols = allHidden ? result.columns : stats.visible; const totalRows = result.rows.length; - // 计算每列理想内容宽度(表头 + 首页行) + // 计算每列理想内容宽度(表头 + 首页行)——按显示宽度,中文/emoji 占 2 列 const CELL_CAP = 60; const IDEAL_ROWS = 20; const ideal = cols.map((col) => { - let max = col.length; + let max = visibleWidth(col); for (let i = 0; i < Math.min(IDEAL_ROWS, result.rows.length); i++) { - const len = cellString(result.rows[i][col]).length; + const len = visibleWidth(cellString(result.rows[i][col])); max = Math.max(max, Math.min(len, CELL_CAP)); } return max; diff --git a/tools/db-tools.ts b/tools/db-tools.ts index db1589c..e48cd64 100644 --- a/tools/db-tools.ts +++ b/tools/db-tools.ts @@ -27,6 +27,8 @@ import { formatSchemaMarkdown } from "../formatting/schema-table"; import { MutationValidationError } from "../connection/sql-policy"; import { showMutationConfirm } from "../commands/mutate-confirm"; import { LOADER_TOOL_NAME, LAZY_TOOL_INFO, matchDbTools } from "./db-tool-catalog"; +import { dbToolResultRenderer } from "./tool-result-render"; +import type { DbTablesDetails } from "./tool-result-summary"; export { applyInitialToolSet } from "./db-tool-catalog"; function truncate(text: string, hint = "查询范围过大,请缩小查询或添加 LIMIT。"): string { @@ -117,6 +119,7 @@ export function registerDbTools( details: { matches, added }, }; }, + renderResult: dbToolResultRenderer(LOADER_TOOL_NAME), }); pi.registerTool({ @@ -175,6 +178,7 @@ export function registerDbTools( }, }; }, + renderResult: dbToolResultRenderer("db_query"), }); pi.registerTool({ @@ -207,16 +211,19 @@ export function registerDbTools( ]; const targetId = params.connection ?? ws.current?.connectionId; + let databaseCount: number | undefined; if (targetId) { const dbs = await ws.getDatabases(targetId); + databaseCount = dbs.length; lines.push("", `${targetId} 上的数据库(${dbs.length} 个):`, ...dbs); } return { content: [{ type: "text", text: lines.join("\n") }], - details: { connections: conns.map((c) => c.id), connection: targetId }, + details: { connections: conns.map((c) => c.id), connection: targetId, databaseCount }, }; }, + renderResult: dbToolResultRenderer("db_discover"), }); pi.registerTool({ @@ -243,15 +250,15 @@ export function registerDbTools( database: params.database, }); // 统一 details 形状——两种模式仅设置不同字段。 - const details: { - connection: string; - database: string; - table?: string; - tables?: string[]; - } = { connection: target.connectionId, database: target.database }; + const details: DbTablesDetails = { + connection: target.connectionId, + database: target.database, + }; if (params.table) { const { columns, indexes } = await ws.getTableSchema(params.table, target); details.table = params.table; + details.columnCount = columns.length; + details.indexCount = indexes.length; return { content: [ { @@ -278,6 +285,7 @@ export function registerDbTools( details, }; }, + renderResult: dbToolResultRenderer("db_tables"), }); pi.registerTool({ diff --git a/tools/tool-result-render.ts b/tools/tool-result-render.ts new file mode 100644 index 0000000..b02c56e --- /dev/null +++ b/tools/tool-result-render.ts @@ -0,0 +1,82 @@ +/** + * 数据库工具结果的自定义 TUI 渲染。 + * + * 默认(折叠)只显示一行摘要(summarizeDbToolResult),用户按 + * ctrl+o(app.tools.expand)展开后展示 content 全文。渲染器是薄层—— + * 文案生成在 tool-result-summary.ts(纯函数,可测试)。渲染器抛错时 + * pi 的 tool-execution 会自动回退到默认渲染,因此无需兜底逻辑。 + */ + +import { Text, type Component, truncateToWidth } from "@earendil-works/pi-tui"; +import { + keyHint, + type AgentToolResult, + type Theme, + type ToolRenderResultOptions, +} from "@earendil-works/pi-coding-agent"; +import { summarizeDbToolResult, type SummarizableDbTool } from "./tool-result-summary"; + +/** 渲染上下文的最小结构类型——只取渲染器用到的字段(ToolRenderContext 未从包顶层导出)。 */ +interface RenderContext { + isError: boolean; +} + +/** 取结果的第一段文本内容(这些工具只返回文本)。 */ +function firstText(result: AgentToolResult): string { + const block = result.content.find((c) => c.type === "text"); + return block?.type === "text" ? block.text : ""; +} + +/** + * 多行文本组件:每行按容器宽度截断。 + * + * 展开态全文是最长的渲染路径(可达 50KB),虽然 Text 自带 word wrap, + * 这里再逐行截断作为防线——无空白断点的长行(长值、长表名、长 SQL) + * 在窄容器下也可能撑出超宽行。 + */ +class TruncatedMultiline implements Component { + private lines: string[]; + + constructor(text: string) { + this.lines = text.split("\n"); + } + + invalidate(): void {} + + render(width: number): string[] { + const w = Math.max(1, width); + return this.lines.map((line) => truncateToWidth(line, w)); + } +} + +/** + * renderResult 工厂——每个常驻工具注册时传入自己的名字, + * 渲染器按该工具的 details 形状生成折叠态摘要。 + */ +export function dbToolResultRenderer(toolName: SummarizableDbTool) { + return ( + result: AgentToolResult, + options: ToolRenderResultOptions, + theme: Theme, + context: RenderContext, + ): Component => { + if (options.isPartial) { + return new Text(theme.fg("warning", `${toolName} 处理中…`), 0, 0); + } + if (context.isError) { + return new Text(theme.fg("error", firstText(result) || `${toolName} 执行失败`), 0, 0); + } + if (options.expanded) { + return new TruncatedMultiline(theme.fg("toolOutput", firstText(result))); + } + const summary = summarizeDbToolResult(toolName, result.details); + if (summary) { + return new Text( + theme.fg("muted", summary) + " " + keyHint("app.tools.expand", "展开"), + 0, + 0, + ); + } + return new Text(theme.fg("toolOutput", firstText(result)), 0, 0); + }; +} diff --git a/tools/tool-result-summary.ts b/tools/tool-result-summary.ts new file mode 100644 index 0000000..616f62d --- /dev/null +++ b/tools/tool-result-summary.ts @@ -0,0 +1,94 @@ +/** + * 数据库工具结果的折叠态摘要——纯函数,无 pi 导入、无 I/O。 + * + * db-tools.ts 中常驻工具的 renderResult 用它生成默认(折叠)展示的 + * 一行摘要;用户按 ctrl+o(app.tools.expand)展开后展示 content 全文。 + * 摘要按各工具的 details 形状生成,未知工具或未知形状返回 undefined, + * 由调用方回退到完整内容。 + */ + +/** db_query 的 details 形状。 */ +export interface DbQueryDetails { + connection: string; + database: string; + rowCount: number; + elapsed: string; +} + +/** db_tables 的 details 形状(列表 / schema 两种模式仅设置不同字段)。 */ +export interface DbTablesDetails { + connection: string; + database: string; + /** schema 模式:目标表名。 */ + table?: string; + /** 列表模式:全部表名。 */ + tables?: string[]; + /** schema 模式:列数。 */ + columnCount?: number; + /** schema 模式:索引数。 */ + indexCount?: number; +} + +/** db_discover 的 details 形状。 */ +export interface DbDiscoverDetails { + connections: string[]; + connection: string; + /** 目标连接上的数据库数(未指定 connection 时缺省)。 */ + databaseCount?: number; +} + +/** db_tools loader 的 details 形状。 */ +export interface DbToolsDetails { + matches: string[]; + added: string[]; +} + +/** 带折叠摘要的常驻工具名。 */ +export type SummarizableDbTool = "db_query" | "db_tables" | "db_discover" | "db_tools"; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +/** + * 生成工具结果的折叠态摘要(中文,用户可见)。 + * + * 返回 undefined 表示该工具或 details 形状没有已知摘要—— + * 调用方应回退到完整内容展示。 + */ +export function summarizeDbToolResult(toolName: string, details: unknown): string | undefined { + if (!isRecord(details)) return undefined; + switch (toolName) { + case "db_query": { + if (typeof details.rowCount !== "number") return undefined; + const d = details as unknown as Partial; + return `db_query:${d.rowCount} 行 · ${d.elapsed ?? ""}(${d.connection ?? "?"}/${d.database ?? "?"})`; + } + case "db_tables": { + const d = details as unknown as Partial; + if (d.table !== undefined) { + if (typeof d.columnCount !== "number" || typeof d.indexCount !== "number") { + return undefined; + } + return `db_tables:${d.database ?? "?"}.${d.table} 结构(${d.columnCount} 列 / ${d.indexCount} 索引)`; + } + if (!Array.isArray(d.tables)) return undefined; + return `db_tables:${d.database ?? "?"} 表列表(${d.tables.length} 个)`; + } + case "db_discover": { + const d = details as unknown as Partial; + if (!Array.isArray(d.connections)) return undefined; + const base = `db_discover:${d.connections.length} 个连接`; + return typeof d.databaseCount === "number" ? `${base} · ${d.databaseCount} 个数据库` : base; + } + case "db_tools": { + const d = details as unknown as Partial; + if (!Array.isArray(d.added) || !Array.isArray(d.matches)) return undefined; + if (d.added.length > 0) return `db_tools:已启用 ${d.added.join("、")}`; + if (d.matches.length > 0) return `db_tools:已激活 ${d.matches.join("、")}`; + return "db_tools:无匹配工具"; + } + default: + return undefined; + } +}