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 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).
@@ -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: keep \`\`\`mermaid sources simple (flowchart/sequence with plain
207
- node ids). The terminal shows a structured summary, not the graph image.
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; keep mermaid sources simple (flowchart/sequence, plain ids);
302
- numeric trends as a \`\`\`chart fence (JSON array of {"label","value"}).`,
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) {
@@ -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
- /** Strip surrounding shape brackets to get the label: "[Start]" → "Start". */
128
+ /** Shape wrapper → display label: `"[Start<br/>2016]"` → `"Start 2016"`. */
125
129
  function nodeLabel(shape) {
126
- return shape.replace(/^[[({]+|[)\]}]+$/g, "").trim().replace(/^"|"$/g, "");
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
- /** Mermaid block: structured summary box; unsupported types → source + note. */
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
- 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, "", MERMAID_EXPORT_HINT], width: Math.min(width, 72), color: theme.mermaid })] }));
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
- 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"] })), _jsxs(Text, { dimColor: true, children: [" ", MERMAID_EXPORT_HINT] })] }));
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
+ }
@@ -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
- // summary box: ~4 header lines + ≤6 nodes + ≤8 edges + hint + 2 border
51
- rows += Math.min(block.lines.length, 12) + 6;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "s-app-scoder-agent",
3
- "version": "0.9.14",
3
+ "version": "0.9.15",
4
4
  "description": "Autonomous coding agent CLI — writes, edits, and builds code autonomously until the project compiles without errors.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",