s-app-scoder-agent 0.9.14 → 0.9.15
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/dist/agent.js +6 -0
- package/dist/context/prompt.js +6 -4
- package/dist/tui/app.js +1 -1
- package/dist/tui/charset.js +26 -0
- package/dist/tui/markdown/diagram.js +85 -6
- package/dist/tui/markdown/graph.js +1021 -0
- package/dist/tui/selectors.js +9 -3
- package/dist/tui/state.js +13 -0
- package/package.json +1 -1
package/dist/agent.js
CHANGED
|
@@ -968,11 +968,14 @@ export class Agent {
|
|
|
968
968
|
async runWarmup(signal, settle, rewarm = false) {
|
|
969
969
|
const t0 = Date.now();
|
|
970
970
|
try {
|
|
971
|
+
// Báo UI biết warm-up đang chạy (giữ màn hình welcome + progress bar).
|
|
972
|
+
this.emit({ type: "warmup", active: true, stage: "init", percent: 5, message: "đang chuẩn bị warm-up…" });
|
|
971
973
|
// Dời hai việc network ra khỏi đường tới token đầu. run() vẫn gọi lại
|
|
972
974
|
// nguyên như cũ — cả hai đều return sớm khi đã hoàn tất ở đây.
|
|
973
975
|
await Promise.allSettled([this.applyContextProbe(), this.connectMcp()]);
|
|
974
976
|
if (signal.aborted || this.disposed)
|
|
975
977
|
return;
|
|
978
|
+
this.emit({ type: "warmup", active: true, stage: "context+mcp", percent: 60, message: "context window + MCP sẵn sàng" });
|
|
976
979
|
// FILE-READ-PERF P3: heartbeat sau khi đã chạy ≥1 lượt thật gửi lại
|
|
977
980
|
// HISTORY THẬT — đó mới là prefix của lượt kế tiếp (placeholder sẽ phân
|
|
978
981
|
// nhánh ngay sau system). Trước lượt đầu: dựng đúng messages như run().
|
|
@@ -1002,7 +1005,9 @@ export class Agent {
|
|
|
1002
1005
|
task: "[warmup]",
|
|
1003
1006
|
pendingContext: this.promptContext.peekPendingContext(),
|
|
1004
1007
|
});
|
|
1008
|
+
this.emit({ type: "warmup", active: true, stage: "prompt", percent: 80, message: "đang prefill system prompt…" });
|
|
1005
1009
|
const res = await this.client.chat(messages, tools, { signal, maxTokens: 1 });
|
|
1010
|
+
this.emit({ type: "warmup", active: true, stage: "done", percent: 100, message: "warm-up xong" });
|
|
1006
1011
|
this.logger.log({
|
|
1007
1012
|
type: "warmup",
|
|
1008
1013
|
ok: true,
|
|
@@ -1023,6 +1028,7 @@ export class Agent {
|
|
|
1023
1028
|
});
|
|
1024
1029
|
}
|
|
1025
1030
|
finally {
|
|
1031
|
+
this.emit({ type: "warmup", active: false, stage: "done", percent: 100, message: "warm-up kết thúc" });
|
|
1026
1032
|
settle();
|
|
1027
1033
|
// dispose() chạy giữa chừng: kill lại MCP servers mà connectMcp của
|
|
1028
1034
|
// warm-up vừa spawn (killAll của dispose đã chạy TRƯỚC khi chúng mọc).
|
package/dist/context/prompt.js
CHANGED
|
@@ -203,8 +203,9 @@ verifying your work with the build command. You act; you don't just advise.`,
|
|
|
203
203
|
Your answers render in a terminal (Ink). Markdown displays best when you:
|
|
204
204
|
- Tables: at most 6 columns with short cell text; no line breaks inside cells;
|
|
205
205
|
escape literal pipes as \\| and keep pipes inside \`code spans\` intact.
|
|
206
|
-
- Diagrams:
|
|
207
|
-
|
|
206
|
+
- Diagrams: \`\`\`mermaid flowchart/graph renders as a real box-drawing chart
|
|
207
|
+
in the terminal — keep it ≤12 nodes, plain node ids, short labels (line
|
|
208
|
+
breaks via <br/>). sequence/state/other types show a structured summary.
|
|
208
209
|
- Numeric comparisons/trends: prefer a \`\`\`chart fence (JSON array of
|
|
209
210
|
{"label","value"}) — it renders as a native terminal bar/pie chart.
|
|
210
211
|
- Code fences: always tag the language; keep lines reasonably short (they wrap
|
|
@@ -298,8 +299,9 @@ export const PROMPT_SECTIONS = {
|
|
|
298
299
|
- Reply in the user's language; keep final reports concise. Never print
|
|
299
300
|
secrets (API keys, tokens, .env contents).
|
|
300
301
|
- Terminal markdown: tag code fences with the language; tables ≤6 columns,
|
|
301
|
-
short cells;
|
|
302
|
-
|
|
302
|
+
short cells; \`\`\`mermaid flowcharts render as real terminal graphs (keep
|
|
303
|
+
≤12 nodes, plain ids, short labels), sequence as a summary; numeric trends
|
|
304
|
+
as a \`\`\`chart fence (JSON array of {"label","value"}).`,
|
|
303
305
|
};
|
|
304
306
|
/** V6 — assembled from the sections above (order matters). */
|
|
305
307
|
function assembleV6() {
|
package/dist/tui/app.js
CHANGED
|
@@ -203,7 +203,7 @@ export function TuiApp({ config, initialTask }) {
|
|
|
203
203
|
_jsxs(Box, { flexDirection: "column", height: rows || 24, flexShrink: 0, children: [_jsx(BuildRail, { state: state, config: cfg }), _jsxs(Box, { flexDirection: "row", flexGrow: 1, flexShrink: 1, minHeight: 0, children: [_jsx(Box, { ref: viewportRef, flexDirection: "column", flexGrow: 1, flexShrink: 1, minHeight: 0, overflow: "hidden", contentOffsetY: contentOffset, children: _jsxs(Box, { ref: contentRef, flexDirection: "column", paddingX: 1, flexShrink: 0, children: [state.items.length === 0 ? (
|
|
204
204
|
// Welcome: căn giữa cả 2 chiều — height = chiều cao đo được của
|
|
205
205
|
// viewport (flexGrow không giãn được vì content box flexShrink=0).
|
|
206
|
-
_jsxs(Box, { flexDirection: "column", alignItems: "center", justifyContent: "center", height: viewport.clientHeight || undefined, children: [(columns ?? 100) - (showSidebar ? SIDEBAR_WIDTH + 2 : 4) >= 54 ? (_jsx(Box, { flexDirection: "column", alignItems: "center", children: (isAsciiMode() ? BANNER_ASCII : BANNER).map((line, i) => (_jsx(Text, { bold: true, color: theme.brand, children: line }, i))) })) : (_jsx(Text, { bold: true, color: theme.brand, children: "\u2691 SCoder" })), _jsx(Text, { dimColor: true, children: `autonomous coding agent · v${getVersion()}` }), _jsx(Text, {}), _jsx(Text, { dimColor: true, children: "type a task to start \u2014 the agent plans, edits, and builds" }), _jsx(Text, {}), _jsxs(Text, { children: [_jsx(Text, { bold: true, color: theme.accent, children: "\u23CE" }), _jsx(Text, { dimColor: true, children: " send " }), _jsx(Text, { bold: true, color: theme.accent, children: "ctrl+s" }), _jsx(Text, { dimColor: true, children: " sessions " }), _jsx(Text, { bold: true, color: theme.accent, children: "tab" }), _jsx(Text, { dimColor: true, children: " complete " }), _jsx(Text, { bold: true, color: theme.accent, children: "/help" }), _jsx(Text, { dimColor: true, children: " commands" })] }), _jsxs(Text, { children: [_jsx(Text, { bold: true, color: theme.accent, children: "ctrl+o" }), _jsx(Text, { dimColor: true, children: " select/copy (t\u1EAFt mouse tracking \u0111\u1EC3 t\u00F4 \u0111en k\u00E9o chu\u1ED9t)" })] })] })) : (state.items.slice(win.start, win.end).map((item, i) => (_jsx(ChatItemView, { item: item, width: (columns ?? 100) - (showSidebar ? SIDEBAR_WIDTH + 2 : 4) }, win.start + i)))), lastSummary && (_jsx(Box, { marginTop: 1, children: state.status === "success" ? (_jsxs(Text, { color: theme.success, bold: true, children: ["\u2714 ", lastSummary] })) : state.status === "aborted" ? (_jsxs(Text, { color: theme.warn, bold: true, children: ["\u23F9 ", lastSummary] })) : (_jsxs(Text, { color: theme.error, bold: true, children: ["\u2718 ", lastSummary] })) }))] }) }), showSidebar && _jsx(Sidebar, { state: state, config: cfg, rows: viewport.clientHeight || undefined })] }), sessionPickerOpen && (_jsx(SessionPicker, { workdir: cfg.workdir, onSelect: handleSessionSelect, onClose: () => setSessionPickerOpen(false) })), paletteOpen && (_jsxs(Box, { flexDirection: "column", borderStyle: boxBorderStyle(), borderColor: theme.accent, paddingX: 1, paddingY: 1, children: [_jsx(Text, { color: theme.accent, bold: true, children: "Command palette (Ctrl+P to close)" }), SLASH_COMMANDS.slice(0, 40).map((c) => (_jsxs(Text, { children: [c.cmd.padEnd(12), " ", c.desc] }, c.cmd)))] })), helpOpen && (_jsxs(Box, { flexDirection: "column", borderStyle: boxBorderStyle(), borderColor: theme.dim, paddingX: 1, paddingY: 1, children: [_jsx(Text, { color: theme.brand, bold: true, children: "Help (?)" }), _jsx(Text, { children: formatSlashHelp("scoder tui") })] })), permPrompt && _jsx(PermissionModal, { prompt: permPrompt }), _jsx(Box, { paddingX: 1, paddingTop: 0, paddingBottom: 0, flexShrink: 0, alignSelf: "stretch", children: _jsx(Input, { valueRef: inputRef, value: input, onChange: writeInput, onSubmit: (v) => void submit(v), historyRef: historyRef, skillNames: skillNames, agentRef: agentRef, config: cfg, atSources: atSources, modalRef: permPromptRef }) }), _jsx(StatusBar, { state: state, scroll: scrollOffset > 0 ? `↑ ${Math.min(scrollOffset, maxScroll)}/${maxScroll}` : null, selectMode: selectMode })] }));
|
|
206
|
+
_jsxs(Box, { flexDirection: "column", alignItems: "center", justifyContent: "center", height: viewport.clientHeight || undefined, children: [(columns ?? 100) - (showSidebar ? SIDEBAR_WIDTH + 2 : 4) >= 54 ? (_jsx(Box, { flexDirection: "column", alignItems: "center", children: (isAsciiMode() ? BANNER_ASCII : BANNER).map((line, i) => (_jsx(Text, { bold: true, color: theme.brand, children: line }, i))) })) : (_jsx(Text, { bold: true, color: theme.brand, children: "\u2691 SCoder" })), _jsx(Text, { dimColor: true, children: `autonomous coding agent · v${getVersion()}` }), _jsx(Text, {}), _jsx(Text, { dimColor: true, children: "type a task to start \u2014 the agent plans, edits, and builds" }), _jsx(Text, {}), _jsxs(Text, { children: [_jsx(Text, { bold: true, color: theme.accent, children: "\u23CE" }), _jsx(Text, { dimColor: true, children: " send " }), _jsx(Text, { bold: true, color: theme.accent, children: "ctrl+s" }), _jsx(Text, { dimColor: true, children: " sessions " }), _jsx(Text, { bold: true, color: theme.accent, children: "tab" }), _jsx(Text, { dimColor: true, children: " complete " }), _jsx(Text, { bold: true, color: theme.accent, children: "/help" }), _jsx(Text, { dimColor: true, children: " commands" })] }), _jsxs(Text, { children: [_jsx(Text, { bold: true, color: theme.accent, children: "ctrl+o" }), _jsx(Text, { dimColor: true, children: " select/copy (t\u1EAFt mouse tracking \u0111\u1EC3 t\u00F4 \u0111en k\u00E9o chu\u1ED9t)" })] }), state.warmup?.active && (_jsxs(Box, { flexDirection: "column", alignItems: "center", marginTop: 1, children: [_jsx(Text, { dimColor: true, children: state.warmup.message ?? "đang warm-up…" }), _jsxs(Box, { children: [_jsxs(Text, { color: theme.accent, children: ["█".repeat(Math.round((state.warmup.percent / 100) * 32)), "░".repeat(32 - Math.round((state.warmup.percent / 100) * 32))] }), _jsxs(Text, { dimColor: true, children: [" ", state.warmup.percent, "%"] })] })] }))] })) : (state.items.slice(win.start, win.end).map((item, i) => (_jsx(ChatItemView, { item: item, width: (columns ?? 100) - (showSidebar ? SIDEBAR_WIDTH + 2 : 4) }, win.start + i)))), lastSummary && (_jsx(Box, { marginTop: 1, children: state.status === "success" ? (_jsxs(Text, { color: theme.success, bold: true, children: ["\u2714 ", lastSummary] })) : state.status === "aborted" ? (_jsxs(Text, { color: theme.warn, bold: true, children: ["\u23F9 ", lastSummary] })) : (_jsxs(Text, { color: theme.error, bold: true, children: ["\u2718 ", lastSummary] })) }))] }) }), showSidebar && _jsx(Sidebar, { state: state, config: cfg, rows: viewport.clientHeight || undefined })] }), sessionPickerOpen && (_jsx(SessionPicker, { workdir: cfg.workdir, onSelect: handleSessionSelect, onClose: () => setSessionPickerOpen(false) })), paletteOpen && (_jsxs(Box, { flexDirection: "column", borderStyle: boxBorderStyle(), borderColor: theme.accent, paddingX: 1, paddingY: 1, children: [_jsx(Text, { color: theme.accent, bold: true, children: "Command palette (Ctrl+P to close)" }), SLASH_COMMANDS.slice(0, 40).map((c) => (_jsxs(Text, { children: [c.cmd.padEnd(12), " ", c.desc] }, c.cmd)))] })), helpOpen && (_jsxs(Box, { flexDirection: "column", borderStyle: boxBorderStyle(), borderColor: theme.dim, paddingX: 1, paddingY: 1, children: [_jsx(Text, { color: theme.brand, bold: true, children: "Help (?)" }), _jsx(Text, { children: formatSlashHelp("scoder tui") })] })), permPrompt && _jsx(PermissionModal, { prompt: permPrompt }), _jsx(Box, { paddingX: 1, paddingTop: 0, paddingBottom: 0, flexShrink: 0, alignSelf: "stretch", children: _jsx(Input, { valueRef: inputRef, value: input, onChange: writeInput, onSubmit: (v) => void submit(v), historyRef: historyRef, skillNames: skillNames, agentRef: agentRef, config: cfg, atSources: atSources, modalRef: permPromptRef }) }), _jsx(StatusBar, { state: state, scroll: scrollOffset > 0 ? `↑ ${Math.min(scrollOffset, maxScroll)}/${maxScroll}` : null, selectMode: selectMode })] }));
|
|
207
207
|
}
|
|
208
208
|
/** Mount the TUI (called from the `scoder tui` subcommand). */
|
|
209
209
|
export async function runTui(config, initialTask) {
|
package/dist/tui/charset.js
CHANGED
|
@@ -6,6 +6,18 @@
|
|
|
6
6
|
* ASCII mode activates when SCODER_ASCII=1 or TERM=dumb. Colors are NOT
|
|
7
7
|
* handled here: Ink/chalk already respect NO_COLOR and FORCE_COLOR.
|
|
8
8
|
*/
|
|
9
|
+
// Connection-mask glyph table: index = up(1) | right(2) | down(4) | left(8).
|
|
10
|
+
// A cell shows the arms it has: up+right=╰ · up+left=╯ · down+right=┌ ·
|
|
11
|
+
// down+left=┐ · no-left=├ · no-right=┤ · no-down=┴ · no-up=┬ — so elbow
|
|
12
|
+
// routing needs no corner special-casing.
|
|
13
|
+
const LINE_UNICODE = [
|
|
14
|
+
" ", "│", "─", "╰", "│", "│", "┌", "├",
|
|
15
|
+
"─", "╯", "─", "┴", "┐", "┤", "┬", "┼",
|
|
16
|
+
];
|
|
17
|
+
const LINE_ASCII = [
|
|
18
|
+
" ", "|", "-", "+", "|", "|", "+", "+",
|
|
19
|
+
"-", "+", "-", "+", "+", "+", "+", "+",
|
|
20
|
+
];
|
|
9
21
|
const UNICODE = {
|
|
10
22
|
tl: "┌", tr: "┐", bl: "└", br: "┘",
|
|
11
23
|
h: "─", v: "│",
|
|
@@ -15,6 +27,13 @@ const UNICODE = {
|
|
|
15
27
|
ok: "✓", fail: "✗", active: "◆", pending: "○",
|
|
16
28
|
cont: "↪",
|
|
17
29
|
spinner: ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"],
|
|
30
|
+
line: LINE_UNICODE,
|
|
31
|
+
hHeavy: "═", vHeavy: "║", hDotted: "╌", vDotted: "┆",
|
|
32
|
+
arrowUp: "▲", arrowRight: "▶", arrowDown: "▼", arrowLeft: "◀",
|
|
33
|
+
cornerRect: ["┌", "┐", "┘", "└"],
|
|
34
|
+
cornerRound: ["╭", "╮", "╯", "╰"],
|
|
35
|
+
cornerDiamond: ["╱", "╲", "╱", "╲"],
|
|
36
|
+
loop: "↩",
|
|
18
37
|
};
|
|
19
38
|
const ASCII = {
|
|
20
39
|
tl: "+", tr: "+", bl: "+", br: "+",
|
|
@@ -25,6 +44,13 @@ const ASCII = {
|
|
|
25
44
|
ok: "v", fail: "x", active: ">", pending: "o",
|
|
26
45
|
cont: ">",
|
|
27
46
|
spinner: ["-", "\\", "|", "/"],
|
|
47
|
+
line: LINE_ASCII,
|
|
48
|
+
hHeavy: "=", vHeavy: "|", hDotted: ".", vDotted: ":",
|
|
49
|
+
arrowUp: "^", arrowRight: ">", arrowDown: "v", arrowLeft: "<",
|
|
50
|
+
cornerRect: ["+", "+", "+", "+"],
|
|
51
|
+
cornerRound: ["+", "+", "+", "+"],
|
|
52
|
+
cornerDiamond: ["/", "\\", "/", "\\"],
|
|
53
|
+
loop: "<-",
|
|
28
54
|
};
|
|
29
55
|
export function isAsciiMode() {
|
|
30
56
|
return process.env.SCODER_ASCII === "1" || process.env.TERM === "dumb";
|
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { Box, Text } from "ink";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
3
6
|
import { theme } from "../theme.js";
|
|
4
7
|
import { charset } from "../charset.js";
|
|
5
8
|
import { clipCells, measure, padEndCells } from "../text-width.js";
|
|
9
|
+
import { cleanMermaidLabel, renderFlowchart, MAX_GRAPH_ROWS } from "./graph.js";
|
|
6
10
|
// ── Chart / timeline specs (unchanged data formats) ─────────────────────────
|
|
7
11
|
function scalar(value) {
|
|
8
12
|
if (value == null)
|
|
@@ -121,9 +125,9 @@ const MERMAID_NODE_RX = /([\p{L}\p{N}_]+)\s*(\[\s*(?:[^\]]*)\s*\]|\(\s*(?:[^)]*)
|
|
|
121
125
|
/** Node id with optional shape, so `A[Start] --> B{Valid?}` still parses. */
|
|
122
126
|
const NODE_WITH_SHAPE = String.raw `([\p{L}\p{N}_]+)(?:\(\([^)]*\)\)|\[[^\]]*\]|\([^)]*\)|\{[^}]*\})?`;
|
|
123
127
|
const MERMAID_EDGE_RX = new RegExp(NODE_WITH_SHAPE + String.raw `\s*(--?>?|-.->|==>|<--?>|x--?x|o--?o|---)\s*(?:\|([^|]*)\|)?\s*` + NODE_WITH_SHAPE, "gu");
|
|
124
|
-
/**
|
|
128
|
+
/** Shape wrapper → display label: `"[Start<br/>2016]"` → `"Start 2016"`. */
|
|
125
129
|
function nodeLabel(shape) {
|
|
126
|
-
return shape.replace(/^[[({]+|[)\]}]+$/g, "").trim()
|
|
130
|
+
return cleanMermaidLabel(shape.replace(/^[[({]+|[)\]}]+$/g, "").trim());
|
|
127
131
|
}
|
|
128
132
|
export function summarizeMermaid(source) {
|
|
129
133
|
const text = source.join("\n");
|
|
@@ -191,15 +195,90 @@ export function summarizeMermaid(source) {
|
|
|
191
195
|
lines: [`${type || "unknown"} — unknown diagram type`],
|
|
192
196
|
};
|
|
193
197
|
}
|
|
194
|
-
const MERMAID_EXPORT_HINT = "export: npx @mermaid-js/mermaid-cli -i <file> -o out.svg";
|
|
195
198
|
const MAX_SOURCE_LINES = 12;
|
|
196
|
-
|
|
199
|
+
const exported = new Set();
|
|
200
|
+
/** `Angular 12-14 <br/> 2021` → `angular-12-14-2021-a1b2c3`. */
|
|
201
|
+
function diagramSlug(source) {
|
|
202
|
+
const first = source.join(" ").match(/\["?([^"\]]{3,40})"\?\]/)?.[1] ?? "diagram";
|
|
203
|
+
const base = first
|
|
204
|
+
.toLowerCase()
|
|
205
|
+
.replace(/<[^>]+>/g, " ")
|
|
206
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
207
|
+
.replace(/^-+|-+$/g, "")
|
|
208
|
+
.slice(0, 24);
|
|
209
|
+
const hash = createHash("sha1").update(source.join("\n")).digest("hex").slice(0, 6);
|
|
210
|
+
return `${base || "diagram"}-${hash}`;
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Save the diagram source so the export hint becomes a command that actually
|
|
214
|
+
* runs. Idempotent per source and silent on failure — rendering must never
|
|
215
|
+
* break because the filesystem did. `.scoder/` is gitignored.
|
|
216
|
+
*/
|
|
217
|
+
function exportDiagram(source) {
|
|
218
|
+
const path = join(".scoder", "diagrams", `${diagramSlug(source)}.mmd`);
|
|
219
|
+
if (exported.has(path))
|
|
220
|
+
return path;
|
|
221
|
+
try {
|
|
222
|
+
mkdirSync(join(".scoder", "diagrams"), { recursive: true });
|
|
223
|
+
writeFileSync(path, `${source.join("\n")}\n`, "utf8");
|
|
224
|
+
exported.add(path);
|
|
225
|
+
return path;
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Export hint, pre-split so the real path is never clipped by a box border:
|
|
233
|
+
* break before the arguments and indent the continuation line.
|
|
234
|
+
*/
|
|
235
|
+
function exportHintLines(file, width) {
|
|
236
|
+
if (!file) {
|
|
237
|
+
return [
|
|
238
|
+
"export: save these lines to a .mmd file, then",
|
|
239
|
+
" npx @mermaid-js/mermaid-cli -i FILE -o diagram.svg",
|
|
240
|
+
];
|
|
241
|
+
}
|
|
242
|
+
const oneLine = `export: npx @mermaid-js/mermaid-cli -i ${file} -o diagram.svg`;
|
|
243
|
+
if (measure(oneLine) <= width)
|
|
244
|
+
return [oneLine];
|
|
245
|
+
const args = `-i ${file} -o diagram.svg`;
|
|
246
|
+
return [
|
|
247
|
+
"export: npx @mermaid-js/mermaid-cli",
|
|
248
|
+
` ${measure(args) <= width - 2 ? args : clipCells(args, Math.max(4, width - 2))}`,
|
|
249
|
+
];
|
|
250
|
+
}
|
|
251
|
+
/** Cell kind → text props: boxes/edges stay quiet, labels carry the meaning. */
|
|
252
|
+
function cellStyle(kind) {
|
|
253
|
+
switch (kind) {
|
|
254
|
+
case "box": return { color: theme.mermaid };
|
|
255
|
+
case "arrow": return { color: theme.mermaid };
|
|
256
|
+
case "edge": return { color: theme.dim };
|
|
257
|
+
case "label": return { bold: true };
|
|
258
|
+
case "elabel": return { color: theme.accent };
|
|
259
|
+
default: return {};
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
/** A flowchart drawn for real: box-drawing nodes, routed edges, edge labels. */
|
|
263
|
+
export function FlowchartView({ raster, source, width }) {
|
|
264
|
+
const file = exportDiagram(source);
|
|
265
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { dimColor: true, children: [_jsx(Text, { color: theme.mermaid, bold: true, children: "\u25C9 " }), "flowchart ", raster.direction, " \u00B7 ", raster.nodes, " nodes \u00B7 ", raster.edges, " edges", raster.mode === "strip" ? " · chain layout" : ""] }), raster.rows.map((segs, y) => (_jsx(Text, { children: segs.map((seg, i) => (_jsx(Text, { ...cellStyle(seg.kind), children: seg.text }, i))) }, y))), raster.notes.map((note, i) => (_jsx(Text, { dimColor: true, children: `${charset.bullet} ${clipCells(note, Math.max(8, width - 2))}` }, `note-${i}`))), exportHintLines(file, Math.max(24, width)).map((line, i) => (_jsx(Text, { dimColor: true, children: line }, `hint-${i}`)))] }));
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Mermaid block: draw the graph when it fits the pane; otherwise an honest
|
|
269
|
+
* structured summary (supported types) or source + note (unsupported).
|
|
270
|
+
*/
|
|
197
271
|
export function MermaidPreview({ source, width }) {
|
|
272
|
+
const graph = renderFlowchart(source, Math.max(24, width), MAX_GRAPH_ROWS);
|
|
273
|
+
if (graph)
|
|
274
|
+
return _jsx(FlowchartView, { raster: graph, source: source, width: width });
|
|
198
275
|
const summary = summarizeMermaid(source);
|
|
199
276
|
if (summary.supported) {
|
|
200
|
-
|
|
277
|
+
const boxWidth = Math.min(width, 72);
|
|
278
|
+
return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { dimColor: true, children: " \u25C9 mermaid diagram" }), _jsx(ContentBox, { title: "\u25C8 diagram preview", lines: [...summary.lines, "", ...exportHintLines(exportDiagram(source), boxWidth - 4)], width: boxWidth, color: theme.mermaid })] }));
|
|
201
279
|
}
|
|
202
|
-
|
|
280
|
+
const file = exportDiagram(source);
|
|
281
|
+
return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { dimColor: true, children: [" \u25C9 mermaid diagram \u2014 ", summary.lines[0]] }), source.slice(0, MAX_SOURCE_LINES).map((l, i) => (_jsxs(Text, { color: theme.mermaid, children: [" ", clipCells(l, Math.max(8, width - 2))] }, i))), source.length > MAX_SOURCE_LINES && (_jsxs(Text, { dimColor: true, children: [" \u2026 +", source.length - MAX_SOURCE_LINES, " more lines"] })), exportHintLines(file, Math.max(24, width - 2)).map((line, i) => (_jsxs(Text, { dimColor: true, children: [" ", line] }, `hint-${i}`)))] }));
|
|
203
282
|
}
|
|
204
283
|
// ── Chart fence ─────────────────────────────────────────────────────────────
|
|
205
284
|
export function ChartPreview({ spec, width }) {
|
|
@@ -0,0 +1,1021 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Flowchart layout engine — real graph rendering in the terminal (P9).
|
|
3
|
+
*
|
|
4
|
+
* Pipeline: mermaid source → parse (nodes, shapes, arrows) → rank (longest
|
|
5
|
+
* path, cycle-safe) → order (barycenter sweeps) → size boxes → place →
|
|
6
|
+
* rasterize into a cell grid that carries a per-cell *kind*, so the Ink layer
|
|
7
|
+
* can color box borders, labels and edges separately.
|
|
8
|
+
*
|
|
9
|
+
* Two placement modes:
|
|
10
|
+
* - `layered` — Sugiyama-style bands, one layer per rank. Used whenever the
|
|
11
|
+
* graph fits the pane (the common case for branching flowcharts).
|
|
12
|
+
* - `strip` — topological order flowed into wrapped rows of boxes joined by
|
|
13
|
+
* `──▶`, with a `┌───┘` connector where the chain wraps. Used for long
|
|
14
|
+
* chains (version timelines) that would be taller than the viewport.
|
|
15
|
+
*
|
|
16
|
+
* Pure module: no Ink, no I/O, never throws. Every failure path returns null
|
|
17
|
+
* so diagram.tsx falls back to the structured summary — the TUI shows a
|
|
18
|
+
* complete graph or an honest summary, never a clipped drawing.
|
|
19
|
+
*
|
|
20
|
+
* Known approximations (documented in src/tui/README.md):
|
|
21
|
+
* - mermaid shapes collapse to three corner styles (rect / round / chamfer);
|
|
22
|
+
* - subgraphs are flattened into their nodes (a note names them);
|
|
23
|
+
* - edges that close a cycle are listed as notes instead of drawn;
|
|
24
|
+
* - an edge label with no free run wide enough is dropped and noted.
|
|
25
|
+
*/
|
|
26
|
+
import { charset } from "../charset.js";
|
|
27
|
+
import { clipCells, measure, wrapCells } from "../text-width.js";
|
|
28
|
+
// ── Geometry budget ─────────────────────────────────────────────────────────
|
|
29
|
+
const GAP = 2; // cross-axis gap between sibling boxes
|
|
30
|
+
const MARGIN = 2; // cross-axis gutters reserved for detour channels
|
|
31
|
+
const BAND = 3; // routing rows between layers (elbows + labels)
|
|
32
|
+
const STRIP_GAP = 3; // gap between strip boxes — wide enough for `──▶`
|
|
33
|
+
const STRIP_BAND = 2; // rows between strip rows — holds the wrap connector
|
|
34
|
+
const MAX_NODES = 80;
|
|
35
|
+
const MAX_EDGES = 200;
|
|
36
|
+
/** Label width caps, widest first: the first one that fits the pane wins. */
|
|
37
|
+
const LAYERED_CAPS = [22, 16, 12, 10];
|
|
38
|
+
const STRIP_CAPS = [26, 20, 14, 10];
|
|
39
|
+
/** Height budget for a drawn graph — anything taller degrades to the summary. */
|
|
40
|
+
export const MAX_GRAPH_ROWS = 40;
|
|
41
|
+
// ── 1. Label cleanup (shared with the summary renderer) ─────────────────────
|
|
42
|
+
const HTML_BREAK_RX = /<\s*br\s*\/?\s*>/gi;
|
|
43
|
+
const HTML_TAG_RX = /<\s*\/?\s*[a-z][^>]*>/gi;
|
|
44
|
+
const ENTITY_RX = /&(#x?[0-9a-f]+|[a-z]+);/gi;
|
|
45
|
+
const NAMED_ENTITIES = {
|
|
46
|
+
amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " ",
|
|
47
|
+
};
|
|
48
|
+
/**
|
|
49
|
+
* Mermaid label → display text. `<br/>` is a LINE BREAK inside a mermaid node,
|
|
50
|
+
* so printing it literally is wrong: it becomes a space. Other tags are
|
|
51
|
+
* dropped, numeric/named entities decoded, quotes and repeated spaces
|
|
52
|
+
* collapsed.
|
|
53
|
+
*/
|
|
54
|
+
export function cleanMermaidLabel(raw) {
|
|
55
|
+
let out = raw.trim().replace(HTML_BREAK_RX, " ").replace(HTML_TAG_RX, "");
|
|
56
|
+
out = out.replace(ENTITY_RX, (whole, entity) => {
|
|
57
|
+
if (entity[0] !== "#")
|
|
58
|
+
return NAMED_ENTITIES[entity.toLowerCase()] ?? whole;
|
|
59
|
+
const hex = entity[1] === "x" || entity[1] === "X";
|
|
60
|
+
const code = Number.parseInt(hex ? entity.slice(2) : entity.slice(1), hex ? 16 : 10);
|
|
61
|
+
return Number.isFinite(code) && code > 31 ? String.fromCharCode(code) : whole;
|
|
62
|
+
});
|
|
63
|
+
return out.replace(/^["']|["']$/g, "").replace(/\s+/g, " ").trim();
|
|
64
|
+
}
|
|
65
|
+
// ── 2. Parsing ──────────────────────────────────────────────────────────────
|
|
66
|
+
/** Shape wrappers, longest opener first so `[[x]]` never parses as `[x]`. */
|
|
67
|
+
const SHAPE_DEFS = [
|
|
68
|
+
["[[", "]]", "rect"],
|
|
69
|
+
["([", ")]", "round"],
|
|
70
|
+
["[(", ")]", "cylinder"],
|
|
71
|
+
["((", "))", "circle"],
|
|
72
|
+
["{{", "}}", "hex"],
|
|
73
|
+
["[", "]", "rect"],
|
|
74
|
+
["(", ")", "round"],
|
|
75
|
+
["{", "}", "diamond"],
|
|
76
|
+
[">", "]", "flag"],
|
|
77
|
+
];
|
|
78
|
+
const ARROW_SPECS = [
|
|
79
|
+
{ text: "<-.->", style: "dotted", head: "both" },
|
|
80
|
+
{ text: "<-->", style: "solid", head: "both" },
|
|
81
|
+
{ text: "<==>", style: "thick", head: "both" },
|
|
82
|
+
{ text: "x--x", style: "solid", head: "open" },
|
|
83
|
+
{ text: "o--o", style: "solid", head: "open" },
|
|
84
|
+
{ text: "<--x", style: "solid", head: "back" },
|
|
85
|
+
{ text: "--->", style: "solid", head: "fwd" },
|
|
86
|
+
{ text: "-.->", style: "dotted", head: "fwd" },
|
|
87
|
+
{ text: "==>", style: "thick", head: "fwd" },
|
|
88
|
+
{ text: "-->", style: "solid", head: "fwd" },
|
|
89
|
+
{ text: "<--", style: "solid", head: "back" },
|
|
90
|
+
{ text: "---", style: "solid", head: "open" },
|
|
91
|
+
{ text: "===", style: "thick", head: "open" },
|
|
92
|
+
{ text: "-.-", style: "dotted", head: "open" },
|
|
93
|
+
{ text: "->", style: "solid", head: "fwd" },
|
|
94
|
+
{ text: "=>", style: "thick", head: "fwd" },
|
|
95
|
+
{ text: "--", style: "solid", head: "open" },
|
|
96
|
+
{ text: "<-", style: "solid", head: "back" },
|
|
97
|
+
];
|
|
98
|
+
const ID_RX = /^[\p{L}\p{N}_][\p{L}\p{N}_\-.]*/u;
|
|
99
|
+
function skipSpace(line, i) {
|
|
100
|
+
while (i < line.length && (line[i] === " " || line[i] === "\t"))
|
|
101
|
+
i++;
|
|
102
|
+
return i;
|
|
103
|
+
}
|
|
104
|
+
function readShape(line, i) {
|
|
105
|
+
for (const [open, close, shape] of SHAPE_DEFS) {
|
|
106
|
+
if (!line.startsWith(open, i))
|
|
107
|
+
continue;
|
|
108
|
+
const end = line.indexOf(close, i + open.length);
|
|
109
|
+
if (end === -1)
|
|
110
|
+
continue;
|
|
111
|
+
return { text: line.slice(i + open.length, end), shape, end: end + close.length };
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
function matchArrow(line, i) {
|
|
116
|
+
for (const spec of ARROW_SPECS) {
|
|
117
|
+
if (line.startsWith(spec.text, i))
|
|
118
|
+
return { spec, start: i, end: i + spec.text.length };
|
|
119
|
+
}
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
/** Next arrow at/after `from`, skipping node shapes so `-->` inside a label is safe. */
|
|
123
|
+
function findNextArrow(line, from) {
|
|
124
|
+
for (let i = from; i < line.length; i++) {
|
|
125
|
+
const shape = readShape(line, i);
|
|
126
|
+
if (shape) {
|
|
127
|
+
i = shape.end - 1;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
const hit = matchArrow(line, i);
|
|
131
|
+
if (hit)
|
|
132
|
+
return hit;
|
|
133
|
+
}
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
/** Scan one statement into alternating node/edge tokens (`A --> B --> C` works). */
|
|
137
|
+
function scanStatement(line) {
|
|
138
|
+
const toks = [];
|
|
139
|
+
let i = 0;
|
|
140
|
+
for (;;) {
|
|
141
|
+
i = skipSpace(line, i);
|
|
142
|
+
const id = ID_RX.exec(line.slice(i))?.[0];
|
|
143
|
+
if (!id)
|
|
144
|
+
return toks.length >= 2 ? toks : null;
|
|
145
|
+
i += id.length;
|
|
146
|
+
const shape = readShape(line, i);
|
|
147
|
+
if (shape) {
|
|
148
|
+
toks.push({ t: "node", id, label: shape.text, shape: shape.shape });
|
|
149
|
+
i = shape.end;
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
const cls = /^:::[\w-]+/.exec(line.slice(i)); // `A:::someClass`
|
|
153
|
+
if (cls)
|
|
154
|
+
i += cls[0].length;
|
|
155
|
+
toks.push({ t: "node", id });
|
|
156
|
+
}
|
|
157
|
+
i = skipSpace(line, i);
|
|
158
|
+
let arrow = matchArrow(line, i);
|
|
159
|
+
if (!arrow)
|
|
160
|
+
break;
|
|
161
|
+
i = arrow.end;
|
|
162
|
+
let label = "";
|
|
163
|
+
const bar = skipSpace(line, i);
|
|
164
|
+
if (line[bar] === "|") {
|
|
165
|
+
const close = line.indexOf("|", bar + 1);
|
|
166
|
+
if (close !== -1) {
|
|
167
|
+
label = line.slice(bar + 1, close);
|
|
168
|
+
i = close + 1;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
else if (arrow.spec.head === "open") {
|
|
172
|
+
// `A -- yes --> B`: open arrow, label text, then the real arrow head.
|
|
173
|
+
const next = findNextArrow(line, i);
|
|
174
|
+
if (next && next.start > i) {
|
|
175
|
+
label = line.slice(i, next.start).trim();
|
|
176
|
+
arrow = next;
|
|
177
|
+
i = next.end;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
toks.push({ t: "edge", spec: arrow.spec, label: cleanMermaidLabel(label) });
|
|
181
|
+
}
|
|
182
|
+
return toks.length >= 2 ? toks : null;
|
|
183
|
+
}
|
|
184
|
+
function stripComment(line) {
|
|
185
|
+
const i = line.indexOf("%%");
|
|
186
|
+
return (i === -1 ? line : line.slice(0, i)).trim();
|
|
187
|
+
}
|
|
188
|
+
/** Parse a mermaid `graph`/`flowchart` block; null for anything else. */
|
|
189
|
+
export function parseFlowchart(lines) {
|
|
190
|
+
const rows = lines.map(stripComment).filter((l) => l !== "");
|
|
191
|
+
if (rows.length === 0)
|
|
192
|
+
return null;
|
|
193
|
+
const header = /^(?:graph|flowchart)(?:\s+(TB|BT|TD|RL|LR))?\b/i.exec(rows[0]);
|
|
194
|
+
if (!header)
|
|
195
|
+
return null;
|
|
196
|
+
const direction = (header[1] ?? "TD").toUpperCase();
|
|
197
|
+
const nodes = new Map();
|
|
198
|
+
const edges = [];
|
|
199
|
+
const notes = [];
|
|
200
|
+
const seen = new Set();
|
|
201
|
+
const touch = (id, label, shape) => {
|
|
202
|
+
const node = nodes.get(id) ?? { id, label: id, shape: "rect" };
|
|
203
|
+
if (label !== undefined && label !== "") {
|
|
204
|
+
node.label = cleanMermaidLabel(label);
|
|
205
|
+
node.shape = shape ?? node.shape;
|
|
206
|
+
}
|
|
207
|
+
nodes.set(id, node);
|
|
208
|
+
};
|
|
209
|
+
for (let li = 1; li < rows.length; li++) {
|
|
210
|
+
const line = rows[li];
|
|
211
|
+
if (/^subgraph\b/i.test(line)) {
|
|
212
|
+
const title = cleanMermaidLabel(line.replace(/^subgraph\b/i, "").replace(/\[[^\]]*\]|\([^)]*\)/g, ""));
|
|
213
|
+
if (title)
|
|
214
|
+
notes.push(`subgraph flattened: ${title}`);
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
if (/^end\b/i.test(line))
|
|
218
|
+
continue;
|
|
219
|
+
// Styling / interaction statements do not change the graph shape.
|
|
220
|
+
if (/^(direction|style|classDef|class|linkStyle|click|callback|accTitle|accDescr)\b/i.test(line))
|
|
221
|
+
continue;
|
|
222
|
+
const toks = scanStatement(line);
|
|
223
|
+
if (!toks)
|
|
224
|
+
continue;
|
|
225
|
+
let prev = null;
|
|
226
|
+
let pending = null;
|
|
227
|
+
for (const tok of toks) {
|
|
228
|
+
if (tok.t === "edge") {
|
|
229
|
+
pending = tok;
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
touch(tok.id, tok.label, tok.shape);
|
|
233
|
+
if (pending && prev) {
|
|
234
|
+
if (prev === tok.id) {
|
|
235
|
+
notes.push(`self-loop omitted: ${nodes.get(prev)?.label ?? prev}`);
|
|
236
|
+
}
|
|
237
|
+
else if (edges.length < MAX_EDGES) {
|
|
238
|
+
const reverse = pending.spec.head === "back";
|
|
239
|
+
const from = reverse ? tok.id : prev;
|
|
240
|
+
const to = reverse ? prev : tok.id;
|
|
241
|
+
const key = `${from}\u0000${to}\u0000${pending.label}\u0000${pending.spec.style}`;
|
|
242
|
+
if (!seen.has(key)) {
|
|
243
|
+
seen.add(key);
|
|
244
|
+
edges.push({ from, to, label: pending.label, style: pending.spec.style });
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
prev = tok.id;
|
|
249
|
+
pending = null;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
if (nodes.size === 0 || nodes.size > MAX_NODES)
|
|
253
|
+
return null;
|
|
254
|
+
return { direction, nodes: [...nodes.values()], edges, notes: dedupe(notes).slice(0, 4) };
|
|
255
|
+
}
|
|
256
|
+
/** Longest-path ranking with DFS cycle breaking, then barycenter ordering. */
|
|
257
|
+
function rankLayers(g) {
|
|
258
|
+
const ids = g.nodes.map((n) => n.id);
|
|
259
|
+
const out = new Map(ids.map((id) => [id, []]));
|
|
260
|
+
for (const e of g.edges)
|
|
261
|
+
out.get(e.from)?.push(e);
|
|
262
|
+
// 1. mark back edges (target still on the DFS stack ⇒ cycle closer)
|
|
263
|
+
const state = new Map(); // 0 unseen · 1 on stack · 2 done
|
|
264
|
+
const back = new Set();
|
|
265
|
+
const stack = [];
|
|
266
|
+
for (const root of ids) {
|
|
267
|
+
if (state.get(root))
|
|
268
|
+
continue;
|
|
269
|
+
state.set(root, 1);
|
|
270
|
+
stack.push({ id: root, i: 0 });
|
|
271
|
+
while (stack.length > 0) {
|
|
272
|
+
const frame = stack[stack.length - 1];
|
|
273
|
+
const list = out.get(frame.id) ?? [];
|
|
274
|
+
if (frame.i >= list.length) {
|
|
275
|
+
state.set(frame.id, 2);
|
|
276
|
+
stack.pop();
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
const edge = list[frame.i++];
|
|
280
|
+
const mark = state.get(edge.to) ?? 0;
|
|
281
|
+
if (mark === 1)
|
|
282
|
+
back.add(edge);
|
|
283
|
+
else if (mark === 0) {
|
|
284
|
+
state.set(edge.to, 1);
|
|
285
|
+
stack.push({ id: edge.to, i: 0 });
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
// 2. Kahn topological order + longest-path rank over the acyclic part
|
|
290
|
+
const indeg = new Map(ids.map((id) => [id, 0]));
|
|
291
|
+
const fwd = new Map(ids.map((id) => [id, []]));
|
|
292
|
+
for (const e of g.edges) {
|
|
293
|
+
if (back.has(e))
|
|
294
|
+
continue;
|
|
295
|
+
fwd.get(e.from)?.push(e.to);
|
|
296
|
+
indeg.set(e.to, (indeg.get(e.to) ?? 0) + 1);
|
|
297
|
+
}
|
|
298
|
+
const rank = new Map(ids.map((id) => [id, 0]));
|
|
299
|
+
const queue = ids.filter((id) => (indeg.get(id) ?? 0) === 0);
|
|
300
|
+
const topo = [];
|
|
301
|
+
while (queue.length > 0) {
|
|
302
|
+
const id = queue.shift();
|
|
303
|
+
topo.push(id);
|
|
304
|
+
for (const next of fwd.get(id) ?? []) {
|
|
305
|
+
rank.set(next, Math.max(rank.get(next) ?? 0, (rank.get(id) ?? 0) + 1));
|
|
306
|
+
const left = (indeg.get(next) ?? 1) - 1;
|
|
307
|
+
indeg.set(next, left);
|
|
308
|
+
if (left === 0)
|
|
309
|
+
queue.push(next);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
for (const id of ids)
|
|
313
|
+
if (!topo.includes(id))
|
|
314
|
+
topo.push(id);
|
|
315
|
+
const depth = ids.reduce((max, id) => Math.max(max, (rank.get(id) ?? 0) + 1), 0);
|
|
316
|
+
const layers = Array.from({ length: depth }, () => []);
|
|
317
|
+
for (const id of topo)
|
|
318
|
+
layers[rank.get(id) ?? 0].push(id);
|
|
319
|
+
// 3. barycenter sweeps to reduce crossings
|
|
320
|
+
const neighbors = new Map(ids.map((id) => [id, []]));
|
|
321
|
+
for (const e of g.edges) {
|
|
322
|
+
if (back.has(e))
|
|
323
|
+
continue;
|
|
324
|
+
neighbors.get(e.from)?.push(e.to);
|
|
325
|
+
neighbors.get(e.to)?.push(e.from);
|
|
326
|
+
}
|
|
327
|
+
for (let sweep = 0; sweep < 4; sweep++) {
|
|
328
|
+
const down = sweep % 2 === 0;
|
|
329
|
+
const order = [];
|
|
330
|
+
for (let i = 1; i < depth; i++)
|
|
331
|
+
order.push(down ? i : depth - 1 - i);
|
|
332
|
+
for (const li of order) {
|
|
333
|
+
const ref = layers[down ? li - 1 : li + 1];
|
|
334
|
+
if (!ref || ref.length === 0)
|
|
335
|
+
continue;
|
|
336
|
+
const pos = new Map(ref.map((id, idx) => [id, idx]));
|
|
337
|
+
layers[li] = layers[li]
|
|
338
|
+
.map((id, idx) => {
|
|
339
|
+
const near = (neighbors.get(id) ?? [])
|
|
340
|
+
.map((n) => pos.get(n))
|
|
341
|
+
.filter((p) => p !== undefined);
|
|
342
|
+
const b = near.length > 0 ? near.reduce((s, p) => s + p, 0) / near.length : idx;
|
|
343
|
+
return { id, idx, b };
|
|
344
|
+
})
|
|
345
|
+
.sort((a, b) => a.b - b.b || a.idx - b.idx)
|
|
346
|
+
.map((x) => x.id);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
return { layers: layers.filter((L) => L.length > 0), back };
|
|
350
|
+
}
|
|
351
|
+
// ── 4. Canvas ───────────────────────────────────────────────────────────────
|
|
352
|
+
const UP = 1, RIGHT = 2, DOWN = 4, LEFT = 8;
|
|
353
|
+
function lineGlyph(mask, style) {
|
|
354
|
+
const horizontal = (mask & (RIGHT | LEFT)) !== 0 && (mask & (UP | DOWN)) === 0;
|
|
355
|
+
const vertical = (mask & (UP | DOWN)) !== 0 && (mask & (RIGHT | LEFT)) === 0;
|
|
356
|
+
if (style === "thick") {
|
|
357
|
+
if (horizontal)
|
|
358
|
+
return charset.hHeavy;
|
|
359
|
+
if (vertical)
|
|
360
|
+
return charset.vHeavy;
|
|
361
|
+
}
|
|
362
|
+
else if (style === "dotted") {
|
|
363
|
+
if (horizontal)
|
|
364
|
+
return charset.hDotted;
|
|
365
|
+
if (vertical)
|
|
366
|
+
return charset.vDotted;
|
|
367
|
+
}
|
|
368
|
+
return charset.line[mask] ?? "+";
|
|
369
|
+
}
|
|
370
|
+
class Canvas {
|
|
371
|
+
w;
|
|
372
|
+
h;
|
|
373
|
+
chars;
|
|
374
|
+
kinds;
|
|
375
|
+
masks;
|
|
376
|
+
styles;
|
|
377
|
+
constructor(w, h) {
|
|
378
|
+
this.w = w;
|
|
379
|
+
this.h = h;
|
|
380
|
+
this.chars = Array.from({ length: h }, () => new Array(w).fill(" "));
|
|
381
|
+
this.kinds = Array.from({ length: h }, () => new Array(w).fill("empty"));
|
|
382
|
+
this.masks = Array.from({ length: h }, () => new Array(w).fill(0));
|
|
383
|
+
this.styles = Array.from({ length: h }, () => new Array(w).fill("solid"));
|
|
384
|
+
}
|
|
385
|
+
ok(x, y) {
|
|
386
|
+
return x >= 0 && y >= 0 && x < this.w && y < this.h;
|
|
387
|
+
}
|
|
388
|
+
busy(x, y) {
|
|
389
|
+
return this.ok(x, y) && this.kinds[y][x] !== "empty";
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* Can a channel be drawn through this cell? `allowShared` lets a second
|
|
393
|
+
* edge merge into an existing line (junction) instead of giving up — used
|
|
394
|
+
* only after every clean route is exhausted, so the picture stays readable.
|
|
395
|
+
*/
|
|
396
|
+
passable(x, y, allowShared) {
|
|
397
|
+
if (!this.ok(x, y))
|
|
398
|
+
return false;
|
|
399
|
+
const kind = this.kinds[y][x];
|
|
400
|
+
return kind === "empty" || (allowShared && kind === "edge");
|
|
401
|
+
}
|
|
402
|
+
freeH(y, x1, x2, allowShared = false) {
|
|
403
|
+
const [a, b] = x1 <= x2 ? [x1, x2] : [x2, x1];
|
|
404
|
+
for (let x = a; x <= b; x++)
|
|
405
|
+
if (!this.passable(x, y, allowShared))
|
|
406
|
+
return false;
|
|
407
|
+
return true;
|
|
408
|
+
}
|
|
409
|
+
freeV(x, y1, y2, allowShared = false) {
|
|
410
|
+
const [a, b] = y1 <= y2 ? [y1, y2] : [y2, y1];
|
|
411
|
+
for (let y = a; y <= b; y++)
|
|
412
|
+
if (!this.passable(x, y, allowShared))
|
|
413
|
+
return false;
|
|
414
|
+
return true;
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* Free run along the RANK axis at a fixed cross coordinate. TD: rank is
|
|
418
|
+
* vertical (a column); LR: rank is horizontal (a row).
|
|
419
|
+
*/
|
|
420
|
+
freeRank(atCross, r1, r2, rankVertical, allowShared = false) {
|
|
421
|
+
return rankVertical ? this.freeV(atCross, r1, r2, allowShared) : this.freeH(atCross, r1, r2, allowShared);
|
|
422
|
+
}
|
|
423
|
+
/** Free run along the CROSS axis at a fixed rank coordinate. */
|
|
424
|
+
freeCross(atRank, c1, c2, rankVertical, allowShared = false) {
|
|
425
|
+
return rankVertical ? this.freeH(atRank, c1, c2, allowShared) : this.freeV(atRank, c1, c2, allowShared);
|
|
426
|
+
}
|
|
427
|
+
put(x, y, ch, kind) {
|
|
428
|
+
if (!this.ok(x, y))
|
|
429
|
+
return;
|
|
430
|
+
this.chars[y][x] = ch;
|
|
431
|
+
this.kinds[y][x] = kind;
|
|
432
|
+
this.masks[y][x] = 0;
|
|
433
|
+
}
|
|
434
|
+
/** Add connection arms to a cell; crossing lines merge into one glyph. */
|
|
435
|
+
line(x, y, mask, style) {
|
|
436
|
+
if (!this.ok(x, y))
|
|
437
|
+
return;
|
|
438
|
+
const kind = this.kinds[y][x];
|
|
439
|
+
if (kind === "box" || kind === "label" || kind === "arrow")
|
|
440
|
+
return;
|
|
441
|
+
this.kinds[y][x] = "edge";
|
|
442
|
+
this.masks[y][x] |= mask;
|
|
443
|
+
if (style !== "solid")
|
|
444
|
+
this.styles[y][x] = style;
|
|
445
|
+
}
|
|
446
|
+
hLine(y, x1, x2, style) {
|
|
447
|
+
const [a, b] = x1 <= x2 ? [x1, x2] : [x2, x1];
|
|
448
|
+
for (let x = a; x <= b; x++) {
|
|
449
|
+
const mask = a === b ? RIGHT | LEFT : x === a ? RIGHT : x === b ? LEFT : RIGHT | LEFT;
|
|
450
|
+
this.line(x, y, mask, style);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
vLine(x, y1, y2, style) {
|
|
454
|
+
const [a, b] = y1 <= y2 ? [y1, y2] : [y2, y1];
|
|
455
|
+
for (let y = a; y <= b; y++) {
|
|
456
|
+
const mask = a === b ? UP | DOWN : y === a ? DOWN : y === b ? UP : UP | DOWN;
|
|
457
|
+
this.line(x, y, mask, style);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
arrow(x, y, dir) {
|
|
461
|
+
if (!this.ok(x, y))
|
|
462
|
+
return;
|
|
463
|
+
if (this.kinds[y][x] === "box" || this.kinds[y][x] === "label")
|
|
464
|
+
return;
|
|
465
|
+
const ch = dir === "up" ? charset.arrowUp : dir === "right" ? charset.arrowRight
|
|
466
|
+
: dir === "down" ? charset.arrowDown : charset.arrowLeft;
|
|
467
|
+
this.put(x, y, ch, "arrow");
|
|
468
|
+
}
|
|
469
|
+
/**
|
|
470
|
+
* Write text at a cell. Busy cells are skipped unless `overwriteLines`,
|
|
471
|
+
* which lets an edge label break a line (mermaid draws labels on the
|
|
472
|
+
* connector) while never touching box borders or other text.
|
|
473
|
+
*/
|
|
474
|
+
text(x, y, str, kind, overwriteLines = false) {
|
|
475
|
+
let cx = x;
|
|
476
|
+
for (const seg of new Intl.Segmenter("en", { granularity: "grapheme" }).segment(str)) {
|
|
477
|
+
const width = measure(seg.segment);
|
|
478
|
+
if (cx < 0) {
|
|
479
|
+
cx += width;
|
|
480
|
+
continue;
|
|
481
|
+
}
|
|
482
|
+
if (cx + width > this.w)
|
|
483
|
+
break;
|
|
484
|
+
const existing = this.ok(cx, y) ? this.kinds[y][cx] : "empty";
|
|
485
|
+
const blocked = existing !== "empty" && !(overwriteLines && existing === "edge");
|
|
486
|
+
if (!blocked) {
|
|
487
|
+
this.put(cx, y, seg.segment, kind);
|
|
488
|
+
for (let k = 1; k < width; k++)
|
|
489
|
+
this.put(cx + k, y, " ", kind);
|
|
490
|
+
}
|
|
491
|
+
cx += width;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
/** Node box: border from `cornersFor(shape)` + centered label lines. */
|
|
495
|
+
box(rect, lines, shape) {
|
|
496
|
+
const { x, y, w, h } = rect;
|
|
497
|
+
if (w < 5 || h < 3) {
|
|
498
|
+
this.text(x, y, clipCells(lines[0] ?? "", Math.max(1, w)), "label");
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
const [tl, tr, br, bl] = cornersFor(shape);
|
|
502
|
+
this.put(x, y, tl, "box");
|
|
503
|
+
this.put(x + w - 1, y, tr, "box");
|
|
504
|
+
this.put(x + w - 1, y + h - 1, br, "box");
|
|
505
|
+
this.put(x, y + h - 1, bl, "box");
|
|
506
|
+
for (let i = x + 1; i < x + w - 1; i++) {
|
|
507
|
+
this.put(i, y, charset.h, "box");
|
|
508
|
+
this.put(i, y + h - 1, charset.h, "box");
|
|
509
|
+
}
|
|
510
|
+
for (let j = y + 1; j < y + h - 1; j++) {
|
|
511
|
+
this.put(x, j, charset.v, "box");
|
|
512
|
+
this.put(x + w - 1, j, charset.v, "box");
|
|
513
|
+
}
|
|
514
|
+
const inner = w - 4;
|
|
515
|
+
lines.forEach((line, i) => {
|
|
516
|
+
const row = y + 1 + i;
|
|
517
|
+
if (row >= y + h - 1)
|
|
518
|
+
return;
|
|
519
|
+
const pad = Math.max(0, Math.floor((inner - measure(line)) / 2));
|
|
520
|
+
this.text(x + 2 + pad, row, line, "label");
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
/** Resolve glyphs from masks, trim trailing blanks, group runs into segments. */
|
|
524
|
+
toRows() {
|
|
525
|
+
const rows = [];
|
|
526
|
+
for (let y = 0; y < this.h; y++) {
|
|
527
|
+
const segs = [];
|
|
528
|
+
let text = "";
|
|
529
|
+
let kind = "empty";
|
|
530
|
+
let last = -1;
|
|
531
|
+
for (let x = 0; x < this.w; x++) {
|
|
532
|
+
const cellKind = this.kinds[y][x];
|
|
533
|
+
const ch = cellKind === "edge" ? lineGlyph(this.masks[y][x], this.styles[y][x]) : this.chars[y][x];
|
|
534
|
+
if (cellKind !== kind) {
|
|
535
|
+
if (text !== "")
|
|
536
|
+
segs.push({ text, kind });
|
|
537
|
+
kind = cellKind;
|
|
538
|
+
text = "";
|
|
539
|
+
}
|
|
540
|
+
text += ch;
|
|
541
|
+
if (cellKind !== "empty")
|
|
542
|
+
last = x;
|
|
543
|
+
}
|
|
544
|
+
if (text !== "")
|
|
545
|
+
segs.push({ text, kind });
|
|
546
|
+
rows.push(cutToLast(segs, last));
|
|
547
|
+
}
|
|
548
|
+
return rows;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
/** Drop the trailing empty run so rows carry no padding spaces. */
|
|
552
|
+
function cutToLast(segs, last) {
|
|
553
|
+
if (last < 0)
|
|
554
|
+
return [];
|
|
555
|
+
const out = [];
|
|
556
|
+
let used = 0;
|
|
557
|
+
for (const seg of segs) {
|
|
558
|
+
if (used > last)
|
|
559
|
+
break;
|
|
560
|
+
const width = measure(seg.text);
|
|
561
|
+
if (used + width > last + 1)
|
|
562
|
+
out.push({ text: seg.text.slice(0, last + 1 - used), kind: seg.kind });
|
|
563
|
+
else
|
|
564
|
+
out.push(seg);
|
|
565
|
+
used += width;
|
|
566
|
+
}
|
|
567
|
+
return out;
|
|
568
|
+
}
|
|
569
|
+
function cornersFor(shape) {
|
|
570
|
+
if (shape === "diamond")
|
|
571
|
+
return charset.cornerDiamond;
|
|
572
|
+
if (shape === "round" || shape === "circle" || shape === "hex")
|
|
573
|
+
return charset.cornerRound;
|
|
574
|
+
return charset.cornerRect;
|
|
575
|
+
}
|
|
576
|
+
/** Wrap each label into ≤ maxLines lines of ≤ cap-4 cells, then size its box. */
|
|
577
|
+
function sizeBoxes(g, cap, maxLines) {
|
|
578
|
+
const sizes = new Map();
|
|
579
|
+
const inner = Math.max(1, cap - 4);
|
|
580
|
+
for (const node of g.nodes) {
|
|
581
|
+
let lines = wrapCells(node.label || node.id, inner);
|
|
582
|
+
if (lines.length > maxLines) {
|
|
583
|
+
lines = lines.slice(0, maxLines);
|
|
584
|
+
lines[maxLines - 1] = clipCells(lines[maxLines - 1], inner);
|
|
585
|
+
}
|
|
586
|
+
const widest = lines.reduce((max, l) => Math.max(max, measure(l)), 1);
|
|
587
|
+
sizes.set(node.id, { lines, w: widest + 4, h: lines.length + 2 });
|
|
588
|
+
}
|
|
589
|
+
return sizes;
|
|
590
|
+
}
|
|
591
|
+
function toXY(p, rankVertical) {
|
|
592
|
+
return rankVertical ? { x: p.c, y: p.r } : { x: p.r, y: p.c };
|
|
593
|
+
}
|
|
594
|
+
function finalize(cv, meta) {
|
|
595
|
+
const rows = crop(cv.toRows());
|
|
596
|
+
const width = rows.reduce((max, segs) => Math.max(max, segs.reduce((s, seg) => s + measure(seg.text), 0)), 0);
|
|
597
|
+
return { ...meta, rows, width, height: rows.length, notes: dedupe(meta.notes).slice(0, 4) };
|
|
598
|
+
}
|
|
599
|
+
/**
|
|
600
|
+
* Drop the unused gutters: leading/trailing blank rows, and the blank column
|
|
601
|
+
* offset shared by every row (a detour channel that was never needed costs
|
|
602
|
+
* the reader nothing).
|
|
603
|
+
*/
|
|
604
|
+
function crop(rows) {
|
|
605
|
+
const empty = (segs) => segs.length === 0;
|
|
606
|
+
let top = 0, bottom = rows.length;
|
|
607
|
+
while (top < bottom && empty(rows[top]))
|
|
608
|
+
top++;
|
|
609
|
+
while (bottom > top && empty(rows[bottom - 1]))
|
|
610
|
+
bottom--;
|
|
611
|
+
const body = rows.slice(top, bottom);
|
|
612
|
+
let indent = Number.POSITIVE_INFINITY;
|
|
613
|
+
for (const segs of body) {
|
|
614
|
+
const first = segs[0];
|
|
615
|
+
const lead = first !== undefined && first.kind === "empty" ? measure(first.text) : 0;
|
|
616
|
+
indent = Math.min(indent, lead);
|
|
617
|
+
}
|
|
618
|
+
if (!Number.isFinite(indent) || indent <= 0)
|
|
619
|
+
return body;
|
|
620
|
+
return body.map((segs) => {
|
|
621
|
+
const [first, ...rest] = segs;
|
|
622
|
+
if (first === undefined || first.kind !== "empty")
|
|
623
|
+
return segs;
|
|
624
|
+
const left = measure(first.text) - indent;
|
|
625
|
+
return left > 0 ? [{ text: " ".repeat(left), kind: "empty" }, ...rest] : rest;
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
function dedupe(items) {
|
|
629
|
+
return [...new Set(items.filter((s) => s !== ""))];
|
|
630
|
+
}
|
|
631
|
+
/**
|
|
632
|
+
* One band per rank. `BT`/`RL` are laid out as `TD`/`LR` on reversed edges and
|
|
633
|
+
* the arrowheads are moved back to the upstream end, so the picture still
|
|
634
|
+
* reads in the direction the author asked for.
|
|
635
|
+
*/
|
|
636
|
+
function layeredLayout(g, opts) {
|
|
637
|
+
const rankVertical = g.direction !== "LR" && g.direction !== "RL";
|
|
638
|
+
const flip = g.direction === "BT" || g.direction === "RL";
|
|
639
|
+
const graph = flip
|
|
640
|
+
? { ...g, edges: g.edges.map((e) => ({ ...e, from: e.to, to: e.from })) }
|
|
641
|
+
: g;
|
|
642
|
+
const { layers, back } = rankLayers(graph);
|
|
643
|
+
if (layers.length === 0)
|
|
644
|
+
return null;
|
|
645
|
+
// A cap that would split a WORD is rejected: boxes get taller, not chopped
|
|
646
|
+
// up. Chains with long single words fall through to strip layout, which has
|
|
647
|
+
// a wider cap and wraps at box level.
|
|
648
|
+
const wordsFit = graph.nodes.every((n) => {
|
|
649
|
+
const longest = (n.label || n.id).split(/\s+/).reduce((m, w) => Math.max(m, measure(w)), 0);
|
|
650
|
+
return longest <= opts.cap - 4;
|
|
651
|
+
});
|
|
652
|
+
if (!wordsFit)
|
|
653
|
+
return null;
|
|
654
|
+
const sizes = sizeBoxes(graph, opts.cap, 2);
|
|
655
|
+
const byId = new Map(graph.nodes.map((n) => [n.id, n]));
|
|
656
|
+
const crossOf = (id) => (rankVertical ? sizes.get(id).w : sizes.get(id).h);
|
|
657
|
+
const rankOf = (id) => (rankVertical ? sizes.get(id).h : sizes.get(id).w);
|
|
658
|
+
const layerCross = layers.map((L) => L.reduce((s, id) => s + crossOf(id), 0) + GAP * (L.length - 1));
|
|
659
|
+
const innerCross = layerCross.reduce((m, v) => Math.max(m, v), 0);
|
|
660
|
+
// Side gutters: a long edge that has to skip a layer detours through them
|
|
661
|
+
// instead of being dropped (see the Z-route candidates in `routeEdge`).
|
|
662
|
+
const totalCross = innerCross + MARGIN * 2;
|
|
663
|
+
const boxes = new Map();
|
|
664
|
+
let rank = 0;
|
|
665
|
+
layers.forEach((L, li) => {
|
|
666
|
+
let c = MARGIN + Math.floor((innerCross - layerCross[li]) / 2);
|
|
667
|
+
for (const id of L) {
|
|
668
|
+
const size = sizes.get(id);
|
|
669
|
+
const node = byId.get(id);
|
|
670
|
+
const cr = crossOf(id);
|
|
671
|
+
const rr = rankOf(id);
|
|
672
|
+
const p = toXY({ r: rank, c }, rankVertical);
|
|
673
|
+
boxes.set(id, {
|
|
674
|
+
node,
|
|
675
|
+
lines: size.lines,
|
|
676
|
+
rect: { x: p.x, y: p.y, w: size.w, h: size.h },
|
|
677
|
+
rank0: rank,
|
|
678
|
+
rank1: rank + rr - 1,
|
|
679
|
+
cross0: c,
|
|
680
|
+
cross1: c + cr,
|
|
681
|
+
crossMid: c + Math.floor(cr / 2),
|
|
682
|
+
});
|
|
683
|
+
c += cr + GAP;
|
|
684
|
+
}
|
|
685
|
+
rank += L.reduce((max, id) => Math.max(max, rankOf(id)), 0) + BAND;
|
|
686
|
+
});
|
|
687
|
+
const totalRank = rank - BAND;
|
|
688
|
+
const width = rankVertical ? totalCross : totalRank;
|
|
689
|
+
const height = rankVertical ? totalRank : totalCross;
|
|
690
|
+
if (width < 1 || height < 1 || width > opts.width || height > opts.maxRows)
|
|
691
|
+
return null;
|
|
692
|
+
const cv = new Canvas(width, height);
|
|
693
|
+
for (const b of boxes.values())
|
|
694
|
+
cv.box(b.rect, b.lines, b.node.shape);
|
|
695
|
+
const notes = [];
|
|
696
|
+
const dropped = { labels: 0 };
|
|
697
|
+
const ctx = { rankVertical, flip, crossMax: totalCross - 1, dropped };
|
|
698
|
+
let drawn = 0;
|
|
699
|
+
for (const e of graph.edges) {
|
|
700
|
+
if (back.has(e))
|
|
701
|
+
continue;
|
|
702
|
+
const a = boxes.get(e.from);
|
|
703
|
+
const b = boxes.get(e.to);
|
|
704
|
+
if (!a || !b)
|
|
705
|
+
continue;
|
|
706
|
+
if (routeEdge(cv, a, b, e, ctx))
|
|
707
|
+
drawn++;
|
|
708
|
+
else
|
|
709
|
+
notes.push(`edge not drawn: ${a.node.label} → ${b.node.label}`);
|
|
710
|
+
}
|
|
711
|
+
if (dropped.labels > 0)
|
|
712
|
+
notes.push(`${dropped.labels} edge label(s) omitted (no room)`);
|
|
713
|
+
for (const e of back) {
|
|
714
|
+
const fromId = flip ? e.to : e.from;
|
|
715
|
+
const toId = flip ? e.from : e.to;
|
|
716
|
+
notes.push(`${charset.loop} cycle edge: ${byId.get(fromId)?.label ?? fromId} → ${byId.get(toId)?.label ?? toId}`);
|
|
717
|
+
}
|
|
718
|
+
return finalize(cv, {
|
|
719
|
+
mode: "layered",
|
|
720
|
+
direction: g.direction,
|
|
721
|
+
nodes: boxes.size,
|
|
722
|
+
edges: drawn,
|
|
723
|
+
notes: [...g.notes, ...notes],
|
|
724
|
+
});
|
|
725
|
+
}
|
|
726
|
+
/**
|
|
727
|
+
* Candidate paths for `a → b`, best-looking first: straight shared channel,
|
|
728
|
+
* elbows next to either box, then Z routes through any column (including the
|
|
729
|
+
* side gutters, so long edges detour instead of disappearing).
|
|
730
|
+
*/
|
|
731
|
+
function edgePaths(a, b, from, to, crossMax) {
|
|
732
|
+
const candidates = [];
|
|
733
|
+
// 1. Straight channel: a column (TD) / row (LR) that touches both boxes,
|
|
734
|
+
// so the edge never has to jog. Nearest the target center first — when
|
|
735
|
+
// several edges enter the same box they take neighbouring ports instead
|
|
736
|
+
// of fighting over one cell.
|
|
737
|
+
const lo = Math.max(a.cross0, b.cross0), hi = Math.min(a.cross1, b.cross1) - 1;
|
|
738
|
+
if (lo <= hi) {
|
|
739
|
+
const ports = [];
|
|
740
|
+
for (let c = lo; c <= hi; c++)
|
|
741
|
+
ports.push(c);
|
|
742
|
+
ports.sort((p, q) => Math.abs(p - to.c) - Math.abs(q - to.c));
|
|
743
|
+
for (const c of ports.slice(0, 6))
|
|
744
|
+
candidates.push([{ r: from.r, c }, { r: to.r, c }]);
|
|
745
|
+
}
|
|
746
|
+
if (from.c === to.c)
|
|
747
|
+
candidates.push([from, to]);
|
|
748
|
+
// 2. Elbow just above the target, then 3. just below the source.
|
|
749
|
+
candidates.push([from, { r: to.r, c: from.c }, to]);
|
|
750
|
+
candidates.push([from, { r: from.r, c: to.c }, to]);
|
|
751
|
+
// 4. Z route through a channel column, nearest the target first.
|
|
752
|
+
const columns = [];
|
|
753
|
+
for (let c = 0; c <= crossMax; c++)
|
|
754
|
+
if (c !== from.c && c !== to.c)
|
|
755
|
+
columns.push(c);
|
|
756
|
+
columns.sort((p, q) => Math.abs(p - to.c) - Math.abs(q - to.c));
|
|
757
|
+
for (const c of columns.slice(0, 40))
|
|
758
|
+
candidates.push([from, { r: from.r, c }, { r: to.r, c }, to]);
|
|
759
|
+
return candidates;
|
|
760
|
+
}
|
|
761
|
+
/**
|
|
762
|
+
* Route one edge. `a` is the upstream box in layout space; `flip` means the
|
|
763
|
+
* real arrow points upstream, so the arrowhead goes at `a` instead of `b`.
|
|
764
|
+
*
|
|
765
|
+
* Candidate paths are tried clean-first (nothing else drawn on them), then
|
|
766
|
+
* again allowing merges, so a busy diagram still shows every edge instead of
|
|
767
|
+
* silently dropping it.
|
|
768
|
+
*/
|
|
769
|
+
function routeEdge(cv, a, b, edge, ctx) {
|
|
770
|
+
const { rankVertical, flip, crossMax, dropped } = ctx;
|
|
771
|
+
const from = { r: a.rank1 + 1, c: a.crossMid };
|
|
772
|
+
const to = { r: b.rank0 - 1, c: b.crossMid };
|
|
773
|
+
if (to.r < from.r)
|
|
774
|
+
return false;
|
|
775
|
+
const headAtB = !flip;
|
|
776
|
+
const dir = rankVertical
|
|
777
|
+
? (headAtB ? "down" : "up")
|
|
778
|
+
: (headAtB ? "right" : "left");
|
|
779
|
+
const candidates = edgePaths(a, b, from, to, crossMax);
|
|
780
|
+
for (const allowShared of [false, true]) {
|
|
781
|
+
for (const path of candidates) {
|
|
782
|
+
if (!pathClear(cv, path, rankVertical, allowShared))
|
|
783
|
+
continue;
|
|
784
|
+
for (let i = 0; i + 1 < path.length; i++)
|
|
785
|
+
drawAlong(cv, path[i], path[i + 1], edge.style, rankVertical);
|
|
786
|
+
const head = toXY(headAtB ? path[path.length - 1] : path[0], rankVertical);
|
|
787
|
+
cv.arrow(head.x, head.y, dir);
|
|
788
|
+
placeLabel(cv, path, edge.label, rankVertical, dropped);
|
|
789
|
+
return true;
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
return false;
|
|
793
|
+
}
|
|
794
|
+
/** Every segment of a candidate path is drawable. */
|
|
795
|
+
function pathClear(cv, path, rankVertical, allowShared) {
|
|
796
|
+
for (let i = 0; i + 1 < path.length; i++) {
|
|
797
|
+
const p = path[i], q = path[i + 1];
|
|
798
|
+
if (p.c === q.c) {
|
|
799
|
+
if (!cv.freeRank(p.c, p.r, q.r, rankVertical, allowShared))
|
|
800
|
+
return false;
|
|
801
|
+
}
|
|
802
|
+
else if (p.r === q.r) {
|
|
803
|
+
if (!cv.freeCross(p.r, p.c, q.c, rankVertical, allowShared))
|
|
804
|
+
return false;
|
|
805
|
+
}
|
|
806
|
+
else {
|
|
807
|
+
return false; // not axis-aligned
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
return true;
|
|
811
|
+
}
|
|
812
|
+
/** Draw one axis-aligned abstract segment (rank or cross direction). */
|
|
813
|
+
function drawAlong(cv, p, q, style, rankVertical) {
|
|
814
|
+
const A = toXY(p, rankVertical);
|
|
815
|
+
const B = toXY(q, rankVertical);
|
|
816
|
+
if (A.x === B.x)
|
|
817
|
+
cv.vLine(A.x, A.y, B.y, style);
|
|
818
|
+
else
|
|
819
|
+
cv.hLine(A.y, A.x, B.x, style);
|
|
820
|
+
}
|
|
821
|
+
/**
|
|
822
|
+
* Edge labels: prefer the longest horizontal run (the label sits on the line,
|
|
823
|
+
* like mermaid does); otherwise put it beside the longest vertical run, in the
|
|
824
|
+
* routing band that is free by construction. `dropped` counts labels that had
|
|
825
|
+
* no room, so the caller can say so instead of losing them silently.
|
|
826
|
+
*/
|
|
827
|
+
function placeLabel(cv, path, label, rankVertical, dropped) {
|
|
828
|
+
if (!label)
|
|
829
|
+
return;
|
|
830
|
+
const runs = [];
|
|
831
|
+
for (let i = 0; i + 1 < path.length; i++) {
|
|
832
|
+
const p = path[i], q = path[i + 1];
|
|
833
|
+
const A = toXY(p, rankVertical), B = toXY(q, rankVertical);
|
|
834
|
+
const horizontal = A.y === B.y;
|
|
835
|
+
const len = horizontal ? Math.abs(B.x - A.x) + 1 : Math.abs(B.y - A.y) + 1;
|
|
836
|
+
runs.push({ p, q, len, horizontal });
|
|
837
|
+
}
|
|
838
|
+
const byLen = (a, b) => b.len - a.len;
|
|
839
|
+
const labelW = measure(label);
|
|
840
|
+
const ordered = runs.slice().sort(byLen);
|
|
841
|
+
// Longest suitable run wins: the text stays away from the box the edge just
|
|
842
|
+
// left. Horizontal runs carry the label on the line, vertical runs carry it
|
|
843
|
+
// beside the line (mermaid's own convention).
|
|
844
|
+
const onLine = ordered.find((r) => r.horizontal && r.len >= labelW + 2);
|
|
845
|
+
if (onLine) {
|
|
846
|
+
const A = toXY(onLine.p, rankVertical), B = toXY(onLine.q, rankVertical);
|
|
847
|
+
const [x1, x2] = A.x <= B.x ? [A.x, B.x] : [B.x, A.x];
|
|
848
|
+
cv.text(x1 + Math.floor((x2 - x1 + 1 - labelW) / 2), A.y, label, "elabel", true);
|
|
849
|
+
return;
|
|
850
|
+
}
|
|
851
|
+
const beside = ordered.find((r) => !r.horizontal);
|
|
852
|
+
if (beside) {
|
|
853
|
+
const A = toXY(beside.p, rankVertical), B = toXY(beside.q, rankVertical);
|
|
854
|
+
const midY = A.y + Math.floor((B.y - A.y) / 2);
|
|
855
|
+
if (cv.freeH(midY, A.x + 1, A.x + labelW))
|
|
856
|
+
cv.text(A.x + 1, midY, label, "elabel");
|
|
857
|
+
else if (cv.freeH(midY, A.x - labelW, A.x - 1))
|
|
858
|
+
cv.text(A.x - labelW, midY, label, "elabel");
|
|
859
|
+
else
|
|
860
|
+
dropped.labels++;
|
|
861
|
+
return;
|
|
862
|
+
}
|
|
863
|
+
dropped.labels++;
|
|
864
|
+
}
|
|
865
|
+
// ── 7. Strip layout (long chains) ───────────────────────────────────────────
|
|
866
|
+
/**
|
|
867
|
+
* Flow the topological order into wrapped rows of boxes. Chosen when layered
|
|
868
|
+
* placement cannot fit: a 20-node version timeline becomes 3 rows of boxes
|
|
869
|
+
* instead of 20 rows of boxes.
|
|
870
|
+
*/
|
|
871
|
+
function stripLayout(g, opts) {
|
|
872
|
+
const { layers, back } = rankLayers(g);
|
|
873
|
+
if (layers.length === 0)
|
|
874
|
+
return null;
|
|
875
|
+
const sizes = sizeBoxes(g, opts.cap, 3);
|
|
876
|
+
const order = [];
|
|
877
|
+
for (const L of layers)
|
|
878
|
+
order.push(...L);
|
|
879
|
+
const byId = new Map(g.nodes.map((n) => [n.id, n]));
|
|
880
|
+
const next = new Map();
|
|
881
|
+
for (const e of g.edges) {
|
|
882
|
+
if (back.has(e))
|
|
883
|
+
continue;
|
|
884
|
+
if (!next.has(e.from))
|
|
885
|
+
next.set(e.from, { id: e.to, style: e.style, label: e.label });
|
|
886
|
+
}
|
|
887
|
+
const rows = [];
|
|
888
|
+
let current = [];
|
|
889
|
+
let x = 0;
|
|
890
|
+
for (const id of order) {
|
|
891
|
+
const size = sizes.get(id);
|
|
892
|
+
const prev = current[current.length - 1];
|
|
893
|
+
const linked = prev !== undefined && next.get(prev.id)?.id === id;
|
|
894
|
+
const gap = linked ? STRIP_GAP : GAP;
|
|
895
|
+
if (current.length > 0 && x + gap + size.w > opts.width) {
|
|
896
|
+
rows.push(current);
|
|
897
|
+
current = [];
|
|
898
|
+
x = 0;
|
|
899
|
+
}
|
|
900
|
+
current.push({
|
|
901
|
+
id, row: rows.length, lines: size.lines, shape: byId.get(id).shape,
|
|
902
|
+
rect: { x, y: 0, w: size.w, h: size.h },
|
|
903
|
+
});
|
|
904
|
+
x += gap + size.w;
|
|
905
|
+
}
|
|
906
|
+
if (current.length > 0)
|
|
907
|
+
rows.push(current);
|
|
908
|
+
// Stack rows, vertically centering each box inside its row band.
|
|
909
|
+
let y = 0;
|
|
910
|
+
for (const row of rows) {
|
|
911
|
+
const h = row.reduce((max, p) => Math.max(max, p.rect.h), 0);
|
|
912
|
+
for (const p of row)
|
|
913
|
+
p.rect.y = y + Math.floor((h - p.rect.h) / 2);
|
|
914
|
+
y += h + STRIP_BAND;
|
|
915
|
+
}
|
|
916
|
+
const height = y - STRIP_BAND;
|
|
917
|
+
const width = rows.reduce((max, row) => {
|
|
918
|
+
const last = row[row.length - 1];
|
|
919
|
+
return Math.max(max, last ? last.rect.x + last.rect.w : 0);
|
|
920
|
+
}, 0);
|
|
921
|
+
if (width < 1 || height < 1 || width > opts.width || height > opts.maxRows)
|
|
922
|
+
return null;
|
|
923
|
+
const cv = new Canvas(width, height);
|
|
924
|
+
const placed = new Map();
|
|
925
|
+
for (const row of rows)
|
|
926
|
+
for (const p of row) {
|
|
927
|
+
placed.set(p.id, p);
|
|
928
|
+
cv.box(p.rect, p.lines, p.shape);
|
|
929
|
+
}
|
|
930
|
+
const notes = [];
|
|
931
|
+
let drawn = 0;
|
|
932
|
+
let labelsDropped = 0;
|
|
933
|
+
for (let i = 0; i + 1 < order.length; i++) {
|
|
934
|
+
const a = placed.get(order[i]);
|
|
935
|
+
const b = placed.get(order[i + 1]);
|
|
936
|
+
const link = next.get(order[i]);
|
|
937
|
+
if (!a || !b || !link || link.id !== order[i + 1])
|
|
938
|
+
continue;
|
|
939
|
+
if (a.row === b.row) {
|
|
940
|
+
// Same row: `──▶` across the gap, on the line joining the two box middles.
|
|
941
|
+
const midY = Math.floor((a.rect.y + Math.floor(a.rect.h / 2) + b.rect.y + Math.floor(b.rect.h / 2)) / 2);
|
|
942
|
+
const from = a.rect.x + a.rect.w;
|
|
943
|
+
const to = b.rect.x - 1;
|
|
944
|
+
cv.hLine(midY, from, to - 1, link.style);
|
|
945
|
+
cv.arrow(to, midY, "right");
|
|
946
|
+
if (link.label === "" || to - from >= measure(link.label) + 2) {
|
|
947
|
+
const start = from + Math.floor((to - from - measure(link.label)) / 2);
|
|
948
|
+
cv.text(start, midY - 1, link.label, "elabel");
|
|
949
|
+
}
|
|
950
|
+
else
|
|
951
|
+
labelsDropped++;
|
|
952
|
+
drawn++;
|
|
953
|
+
continue;
|
|
954
|
+
}
|
|
955
|
+
// Wrapped to the next row: down out of `a`, across the band, down into `b`.
|
|
956
|
+
const sx = a.rect.x + Math.floor(a.rect.w / 2);
|
|
957
|
+
const tx = b.rect.x + Math.floor(b.rect.w / 2);
|
|
958
|
+
const headY = Math.max(a.rect.y + a.rect.h, b.rect.y - 1);
|
|
959
|
+
const runY = Math.max(a.rect.y + a.rect.h, headY - 1);
|
|
960
|
+
cv.line(sx, runY, UP | LEFT, link.style);
|
|
961
|
+
cv.hLine(runY, tx + 1, sx - 1, link.style);
|
|
962
|
+
cv.line(tx, runY, DOWN | RIGHT, link.style);
|
|
963
|
+
cv.arrow(tx, headY, "down");
|
|
964
|
+
drawn++;
|
|
965
|
+
}
|
|
966
|
+
if (labelsDropped > 0)
|
|
967
|
+
notes.push(`${labelsDropped} edge label(s) omitted (no room)`);
|
|
968
|
+
for (const e of g.edges)
|
|
969
|
+
if (back.has(e)) {
|
|
970
|
+
notes.push(`${charset.loop} cycle edge: ${byId.get(e.from)?.label ?? e.from} → ${byId.get(e.to)?.label ?? e.to}`);
|
|
971
|
+
}
|
|
972
|
+
const skipped = g.edges.length - back.size - drawn;
|
|
973
|
+
if (skipped > 0)
|
|
974
|
+
notes.push(`${skipped} side edge(s) not drawn (chain order only)`);
|
|
975
|
+
return finalize(cv, {
|
|
976
|
+
mode: "strip",
|
|
977
|
+
direction: "chain",
|
|
978
|
+
nodes: placed.size,
|
|
979
|
+
edges: drawn,
|
|
980
|
+
notes: [...g.notes, ...notes],
|
|
981
|
+
});
|
|
982
|
+
}
|
|
983
|
+
// ── 8. Entry point ──────────────────────────────────────────────────────────
|
|
984
|
+
const cache = new Map();
|
|
985
|
+
const CACHE_MAX = 24;
|
|
986
|
+
/**
|
|
987
|
+
* Render a mermaid flowchart into a raster, or null when the source is not a
|
|
988
|
+
* flowchart, is too large, or cannot fit the pane. Cached per source+width so
|
|
989
|
+
* the row estimator and the renderer share one layout pass.
|
|
990
|
+
*/
|
|
991
|
+
export function renderFlowchart(lines, width, maxRows = 40) {
|
|
992
|
+
const key = `${width}x${maxRows}\u0000${lines.join("\n")}`;
|
|
993
|
+
const hit = cache.get(key);
|
|
994
|
+
if (hit !== undefined)
|
|
995
|
+
return hit;
|
|
996
|
+
let result = null;
|
|
997
|
+
try {
|
|
998
|
+
const graph = parseFlowchart(lines);
|
|
999
|
+
if (graph) {
|
|
1000
|
+
for (const cap of LAYERED_CAPS) {
|
|
1001
|
+
result = layeredLayout(graph, { width, maxRows, cap });
|
|
1002
|
+
if (result)
|
|
1003
|
+
break;
|
|
1004
|
+
}
|
|
1005
|
+
if (!result) {
|
|
1006
|
+
for (const cap of STRIP_CAPS) {
|
|
1007
|
+
result = stripLayout(graph, { width, maxRows, cap });
|
|
1008
|
+
if (result)
|
|
1009
|
+
break;
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
catch {
|
|
1015
|
+
result = null; // caller falls back to the summary box
|
|
1016
|
+
}
|
|
1017
|
+
if (cache.size >= CACHE_MAX)
|
|
1018
|
+
cache.delete(cache.keys().next().value ?? "");
|
|
1019
|
+
cache.set(key, result);
|
|
1020
|
+
return result;
|
|
1021
|
+
}
|
package/dist/tui/selectors.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { parseMarkdown } from "./markdown/parse.js";
|
|
2
|
+
import { MAX_GRAPH_ROWS, renderFlowchart } from "./markdown/graph.js";
|
|
2
3
|
import { measure } from "./text-width.js";
|
|
3
4
|
const EST_WIDTH = 80;
|
|
4
5
|
/** Ước lượng số dòng wrap của một đoạn text ở width cho trước. */
|
|
@@ -46,10 +47,15 @@ export function estimateMarkdownRows(text, width = EST_WIDTH) {
|
|
|
46
47
|
case "quote":
|
|
47
48
|
rows += wrapEstimate(block.text, Math.max(8, width - 2));
|
|
48
49
|
break;
|
|
49
|
-
case "mermaid":
|
|
50
|
-
//
|
|
51
|
-
|
|
50
|
+
case "mermaid": {
|
|
51
|
+
// Drawn graph: header + raster + notes + hint. Summary fallback:
|
|
52
|
+
// ~4 header lines + ≤6 nodes + ≤8 edges + hint + 2 border.
|
|
53
|
+
const graph = renderFlowchart(block.lines, Math.max(24, width), MAX_GRAPH_ROWS);
|
|
54
|
+
rows += graph
|
|
55
|
+
? graph.height + 2 + Math.min(graph.notes.length, 4) + 2
|
|
56
|
+
: Math.min(block.lines.length, 12) + 6;
|
|
52
57
|
break;
|
|
58
|
+
}
|
|
53
59
|
case "chart":
|
|
54
60
|
rows += 2 + 40 + 1; // ContentBox max: borders + ≤40 data rows
|
|
55
61
|
break;
|
package/dist/tui/state.js
CHANGED
|
@@ -30,6 +30,7 @@ export function initialState() {
|
|
|
30
30
|
verification: null,
|
|
31
31
|
subagents: [],
|
|
32
32
|
plan: null,
|
|
33
|
+
warmup: null,
|
|
33
34
|
};
|
|
34
35
|
}
|
|
35
36
|
const MAX_ITEMS = 400;
|
|
@@ -126,6 +127,7 @@ export function applyEvent(state, ev) {
|
|
|
126
127
|
}
|
|
127
128
|
// Plan của task trước không được lọt sang task mới.
|
|
128
129
|
state.plan = null;
|
|
130
|
+
state.warmup = null;
|
|
129
131
|
break;
|
|
130
132
|
}
|
|
131
133
|
case "llm_reasoning": {
|
|
@@ -291,6 +293,8 @@ export function applyEvent(state, ev) {
|
|
|
291
293
|
state.mcpServers = { ...state.mcpServers, [ev.server]: ev.state };
|
|
292
294
|
break;
|
|
293
295
|
}
|
|
296
|
+
if (state.warmup?.active)
|
|
297
|
+
break;
|
|
294
298
|
appendItem(state, { kind: "notice", text: ev.line, level: ev.line.includes("unavailable") ? "warn" : "info" });
|
|
295
299
|
break;
|
|
296
300
|
}
|
|
@@ -361,6 +365,15 @@ export function applyEvent(state, ev) {
|
|
|
361
365
|
state.items = state.items.map((it) => it.kind === "assistant" && it.streaming ? { ...it, streaming: false } : it);
|
|
362
366
|
break;
|
|
363
367
|
}
|
|
368
|
+
case "warmup": {
|
|
369
|
+
if (ev.active) {
|
|
370
|
+
state.warmup = { active: true, stage: ev.stage, percent: Math.max(0, Math.min(100, ev.percent)), message: ev.message ?? null };
|
|
371
|
+
}
|
|
372
|
+
else {
|
|
373
|
+
state.warmup = null;
|
|
374
|
+
}
|
|
375
|
+
break;
|
|
376
|
+
}
|
|
364
377
|
case "error": {
|
|
365
378
|
appendItem(state, { kind: "notice", text: ev.message, level: "error" });
|
|
366
379
|
break;
|
package/package.json
CHANGED