s-app-scoder-agent 0.9.3 → 0.9.7

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.
Files changed (112) hide show
  1. package/README.md +615 -638
  2. package/dist/{guardrails.js → agent/guardrails.js} +1 -1
  3. package/dist/agent/interaction.js +70 -0
  4. package/dist/agent/mcp-bridge.js +136 -0
  5. package/dist/agent/prompt-context.js +83 -0
  6. package/dist/agent/run-loop.js +318 -0
  7. package/dist/agent/session-persistence.js +61 -0
  8. package/dist/agent/tool-executor.js +158 -0
  9. package/dist/agent/types.js +88 -0
  10. package/dist/agent/verification.js +99 -0
  11. package/dist/agent.js +239 -1002
  12. package/dist/cli/args.js +230 -0
  13. package/dist/cli/init.js +79 -0
  14. package/dist/cli/main.js +211 -0
  15. package/dist/cli/mcp-command.js +185 -0
  16. package/dist/cli/repl.js +77 -0
  17. package/dist/cli/run-once.js +26 -0
  18. package/dist/{tui/commands.js → commands/definitions.js} +6 -16
  19. package/dist/commands/registry.js +260 -0
  20. package/dist/{config.js → config/config.js} +1 -1
  21. package/dist/{prompt-builder.js → context/prompt-builder.js} +12 -1
  22. package/dist/{prompt.js → context/prompt.js} +111 -9
  23. package/dist/{skills.js → context/skills.js} +41 -2
  24. package/dist/index.js +5 -798
  25. package/dist/io/console-reporter.js +24 -0
  26. package/dist/io/reporter.js +1 -0
  27. package/dist/mcp/connection.js +227 -0
  28. package/dist/mcp/env.js +8 -0
  29. package/dist/{mcp.js → mcp/manager.js} +3 -515
  30. package/dist/mcp/sse.js +28 -0
  31. package/dist/mcp/transport-http.js +140 -0
  32. package/dist/mcp/transport-stdio.js +98 -0
  33. package/dist/mcp/types.js +11 -0
  34. package/dist/media/image.js +149 -0
  35. package/dist/{server.js → server/server.js} +1 -1
  36. package/dist/{cost.js → session/cost.js} +1 -1
  37. package/dist/{session.js → session/session.js} +1 -1
  38. package/dist/{version.js → shared/version.js} +1 -1
  39. package/dist/tools/apply-patch.js +62 -0
  40. package/dist/tools/edit-file.js +29 -0
  41. package/dist/tools/file-edit-common.js +124 -0
  42. package/dist/tools/list-files.js +64 -0
  43. package/dist/tools/path-safety.js +75 -0
  44. package/dist/tools/read-file.js +64 -0
  45. package/dist/tools/registry.js +297 -0
  46. package/dist/tools/run-command.js +192 -0
  47. package/dist/tools/schemas.js +8 -0
  48. package/dist/tools/search.js +237 -0
  49. package/dist/tools/task-tool.js +48 -0
  50. package/dist/tools/types.js +1 -0
  51. package/dist/tools/write-file.js +34 -0
  52. package/dist/tui/app.js +59 -532
  53. package/dist/tui/ask-user-modal.js +2 -1
  54. package/dist/tui/at-complete.js +2 -93
  55. package/dist/tui/charset.js +41 -0
  56. package/dist/tui/chat-views.js +72 -16
  57. package/dist/tui/chrome.js +11 -5
  58. package/dist/tui/hooks/use-command-host.js +26 -0
  59. package/dist/tui/hooks/use-event-queue.js +69 -0
  60. package/dist/tui/hooks/use-keybinds.js +87 -0
  61. package/dist/tui/hooks/use-scroll.js +38 -0
  62. package/dist/tui/hooks/use-session-resume.js +47 -0
  63. package/dist/tui/hooks/use-submit.js +106 -0
  64. package/dist/tui/image.js +2 -149
  65. package/dist/tui/input.js +1 -1
  66. package/dist/tui/markdown/ast.js +5 -0
  67. package/dist/tui/markdown/diagram.js +239 -0
  68. package/dist/tui/markdown/inline-render.js +24 -0
  69. package/dist/tui/markdown/inline.js +93 -0
  70. package/dist/tui/markdown/parse.js +139 -0
  71. package/dist/tui/markdown/render.js +146 -0
  72. package/dist/tui/markdown/table.js +106 -0
  73. package/dist/tui/markdown.js +32 -379
  74. package/dist/tui/permission-modal.js +2 -1
  75. package/dist/tui/selectors.js +157 -0
  76. package/dist/tui/session-picker.js +4 -3
  77. package/dist/tui/sidebar.js +130 -15
  78. package/dist/tui/state.js +47 -118
  79. package/dist/tui/text-width.js +139 -0
  80. package/dist/tui/theme.js +7 -2
  81. package/dist/{build.js → workspace/build.js} +1 -1
  82. package/dist/workspace/file-index.js +93 -0
  83. package/package.json +5 -1
  84. package/dist/tools.js +0 -1223
  85. package/dist/tui/command-registry.js +0 -477
  86. package/dist/tui/review.js +0 -64
  87. package/dist/tui/transcript.js +0 -145
  88. /package/dist/{agents-guide.js → context/agents-guide.js} +0 -0
  89. /package/dist/{agents.js → context/agents.js} +0 -0
  90. /package/dist/{compaction.js → context/compaction.js} +0 -0
  91. /package/dist/{context.js → context/context.js} +0 -0
  92. /package/dist/{memory.js → context/memory.js} +0 -0
  93. /package/dist/{repo-map.js → context/repo-map.js} +0 -0
  94. /package/dist/{ui.js → io/terminal.js} +0 -0
  95. /package/dist/{llm.js → llm/llm.js} +0 -0
  96. /package/dist/{model-catalog.js → llm/model-catalog.js} +0 -0
  97. /package/dist/{provider-anthropic.js → llm/provider-anthropic.js} +0 -0
  98. /package/dist/{provider-registry.js → llm/provider-registry.js} +0 -0
  99. /package/dist/{retry.js → llm/retry.js} +0 -0
  100. /package/dist/{mcp-import.js → mcp/mcp-import.js} +0 -0
  101. /package/dist/{permissions.js → security/permissions.js} +0 -0
  102. /package/dist/{sandbox.js → security/sandbox.js} +0 -0
  103. /package/dist/{shell-scanner.js → security/shell-scanner.js} +0 -0
  104. /package/dist/{logger.js → session/logger.js} +0 -0
  105. /package/dist/{steering.js → session/steering.js} +0 -0
  106. /package/dist/{constants.js → shared/constants.js} +0 -0
  107. /package/dist/{json-schema.js → shared/json-schema.js} +0 -0
  108. /package/dist/{webfetch.js → tools/webfetch.js} +0 -0
  109. /package/dist/{diagnostics.js → workspace/diagnostics.js} +0 -0
  110. /package/dist/{diff.js → workspace/diff.js} +0 -0
  111. /package/dist/{snapshot.js → workspace/snapshot.js} +0 -0
  112. /package/dist/{watch.js → workspace/watch.js} +0 -0
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Shared entry point for every task submission (steer vs. new-run, slash
3
+ * commands). Extracted verbatim from app.tsx.
4
+ */
5
+ import { useCallback } from "react";
6
+ import { dispatchCommand } from "../../commands/registry.js";
7
+ import { parseSlashCommand } from "../../commands/definitions.js";
8
+ import { applyEvent } from "../state.js";
9
+ import { THEMES, useNamedTheme } from "../theme.js";
10
+ export function useSubmit(deps) {
11
+ const { cfg, pushNotice, writeInput, exit, update, queueEvent, skills, skillNames, permMode, tuiHost, agentRef, runningRef, stateRef, historyRef, setScrollOffset, createAgent, bumpFrame } = deps;
12
+ // Close any lingering streaming messages when the user sends a new query —
13
+ // prevents green/dim text from the previous response appearing stale.
14
+ const closeStreamingItems = useCallback(() => {
15
+ update((s) => {
16
+ let changed = false;
17
+ for (const it of s.items) {
18
+ if (it.kind === "assistant" && it.streaming) {
19
+ it.streaming = false;
20
+ changed = true;
21
+ }
22
+ }
23
+ return changed ? { ...s, items: [...s.items] } : s;
24
+ });
25
+ }, [update]);
26
+ /**
27
+ * Shared entry point for every task submission:
28
+ * 1. Close any still-streaming assistant message from the previous response.
29
+ * 2. Record the query in history.
30
+ * 3. Clear the input field.
31
+ * Then the caller handles steer vs. new-run separately.
32
+ */
33
+ const preSubmit = useCallback((task) => {
34
+ closeStreamingItems();
35
+ historyRef.current = [...historyRef.current, task].slice(-100);
36
+ writeInput("");
37
+ }, [closeStreamingItems, writeInput]);
38
+ const submit = useCallback(async (text) => {
39
+ let task = text.trim();
40
+ if (!task)
41
+ return;
42
+ // "exit"/"quit" always works — aborts a running agent first.
43
+ if (task === "exit" || task === "quit") {
44
+ agentRef.current?.abort();
45
+ exit();
46
+ return;
47
+ }
48
+ // Slash commands — TUI-specific first (/theme), then the shared
49
+ // registry (T1) for the rest.
50
+ const slash = parseSlashCommand(task);
51
+ if (slash) {
52
+ writeInput("");
53
+ if (slash.cmd === "/theme") {
54
+ // T-09: palette hot-swap.
55
+ if (!slash.arg) {
56
+ pushNotice(`themes: ${Object.keys(THEMES).join(", ")} — /theme <name>; project default qua .scoder/theme.json`);
57
+ return;
58
+ }
59
+ pushNotice(useNamedTheme(slash.arg.trim()), "info");
60
+ bumpFrame();
61
+ return;
62
+ }
63
+ // T1: phần còn lại qua registry dùng chung với REPL.
64
+ const { handled, outcome } = await dispatchCommand(task, tuiHost(), pushNotice);
65
+ if (handled && outcome?.task) {
66
+ task = outcome.task;
67
+ }
68
+ else {
69
+ return;
70
+ }
71
+ }
72
+ if (runningRef.current) {
73
+ // Steer: user input while the agent runs — deliver at next step
74
+ // boundary (H-08, "steer by default" — pattern from opencode).
75
+ preSubmit(task);
76
+ agentRef.current?.steer(task);
77
+ // Steer = user message mới → plan cũ reset (cùng ngữ nghĩa latestPlan).
78
+ update((s) => ({ ...s, plan: null, items: [...s.items, { kind: "user", text: `${task} (steered)` }] }));
79
+ return;
80
+ }
81
+ preSubmit(task);
82
+ runningRef.current = true;
83
+ setScrollOffset(0);
84
+ // isFirst must be computed BEFORE pushing the user item below.
85
+ const isFirst = stateRef.current.items.every((i) => i.kind !== "user");
86
+ // runTurn() doesn't emit run_start, so the TUI shows the user's message
87
+ // itself; applyEvent's run_start dedupe keeps turn 1 from doubling.
88
+ // runTurn cũng không reset plan — reset ở đây (user message mới).
89
+ update((s) => ({ ...s, plan: null, items: [...s.items, { kind: "user", text: task }], status: "running", summary: null }));
90
+ const agent = createAgent();
91
+ try {
92
+ // First submission runs the full closed loop; later ones continue the session.
93
+ const result = isFirst ? await agent.run(task) : await agent.runTurn(task);
94
+ void result;
95
+ runningRef.current = false;
96
+ }
97
+ catch (err) {
98
+ runningRef.current = false;
99
+ // Surface the failure through the reducer (not just a notice) so the
100
+ // status leaves "running" and the error is visible as the final line.
101
+ update((s) => applyEvent({ ...s, items: [...s.items] }, { type: "finish", success: false, steps: s.step, summary: `Run failed: ${err.message}` }));
102
+ update((s) => ({ ...s, items: [...s.items, { kind: "notice", text: err.message, level: "error" }] }));
103
+ }
104
+ }, [cfg, pushNotice, writeInput, exit, update, queueEvent, skills, skillNames, permMode, tuiHost, preSubmit]);
105
+ return { submit };
106
+ }
package/dist/tui/image.js CHANGED
@@ -1,149 +1,2 @@
1
- /**
2
- * T7: image render trong chat — detect terminal protocol + halfblock preview.
3
- * Không thêm dependency: halfblock qua unicode upper-half ▀ với truecolor
4
- * (iTerm2/kitty hỗ trợ COLORTERM=truecolor). Terminal không hỗ trợ → fallback
5
- * link text. ponytail: 40px width cố định, không đo cell size thật — nâng
6
- * cấp khi cần aspect ratio chuẩn.
7
- */
8
- import { inflateSync } from "node:zlib";
9
- export function detectImageCaps(env = process.env) {
10
- const truecolor = env.COLORTERM === "truecolor" || env.COLORTERM === "24bit";
11
- const iterm = env.TERM_PROGRAM === "iTerm.app";
12
- const kitty = env.TERM === "xterm-kitty" || env.KITTY_WINDOW_ID !== undefined;
13
- return { truecolor, iterm, kitty };
14
- }
15
- const IMG_MAGIC = [
16
- { ext: "png", test: (b) => b.length > 8 && b[0] === 0x89 && b[1] === 0x50 },
17
- { ext: "jpg", test: (b) => b.length > 3 && b[0] === 0xff && b[1] === 0xd8 },
18
- { ext: "gif", test: (b) => b.length > 6 && b.toString("ascii", 0, 3) === "GIF" },
19
- { ext: "bmp", test: (b) => b.length > 2 && b[0] === 0x42 && b[1] === 0x4d },
20
- ];
21
- /** Detect image từ magic bytes (đuôi file không đáng tin). */
22
- export function detectImageBytes(buf) {
23
- return IMG_MAGIC.find((m) => m.test(buf))?.ext ?? null;
24
- }
25
- /** Decode PNG/JPG size (width/height) — chỉ PNG (IHDR) và JPEG (SOF markers). */
26
- export function imageSize(buf) {
27
- if (detectImageBytes(buf) === "png" && buf.length > 24) {
28
- return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
29
- }
30
- if (detectImageBytes(buf) === "jpg") {
31
- let i = 2;
32
- while (i + 9 < buf.length) {
33
- if (buf[i] !== 0xff)
34
- break;
35
- const marker = buf[i + 1];
36
- const len = buf.readUInt16BE(i + 2);
37
- if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
38
- return { height: buf.readUInt16BE(i + 5), width: buf.readUInt16BE(i + 7) };
39
- }
40
- i += 2 + len;
41
- }
42
- }
43
- return null;
44
- }
45
- /**
46
- * Render buffer ảnh thành mảng dòng "▀" (2 pixel ảnh / 1 dòng terminal).
47
- * Trả về null khi terminal không truecolor (caller fallback link).
48
- */
49
- export function halfblockRows(buf, maxCols = 40) {
50
- const png = detectImageBytes(buf) === "png";
51
- if (!png)
52
- return null; // ponytail: chỉ decode PNG thuần (zlib builtin); JPG → null
53
- const size = imageSize(buf);
54
- if (!size || size.width === 0 || size.height === 0)
55
- return null;
56
- // Decode IDAT: zlib inflate + unfilter scanlines.
57
- let rows;
58
- try {
59
- rows = decodePngPixels(buf, size.width, size.height);
60
- }
61
- catch {
62
- return null;
63
- }
64
- const w = size.width;
65
- const cols = Math.min(maxCols, w);
66
- const step = w / cols; // downsample theo cột
67
- const out = [];
68
- for (let py = 0; py < size.height; py += 2) {
69
- let line = "";
70
- for (let cx = 0; cx < cols; cx++) {
71
- const px = Math.min(w - 1, Math.floor(cx * step));
72
- const top = pixelAt(rows, w, px, py);
73
- const bot = py + 1 < size.height ? pixelAt(rows, w, px, py + 1) : [0, 0, 0];
74
- line += `\x1b[38;2;${top[0]};${top[1]};${top[2]};48;2;${bot[0]};${bot[1]};${bot[2]}m▀`;
75
- }
76
- out.push({ text: line + "\x1b[0m" });
77
- }
78
- return out;
79
- }
80
- function pixelAt(rows, w, x, y) {
81
- const o = (y * w + x) * 3;
82
- return [rows[o], rows[o + 1], rows[o + 2]];
83
- }
84
- /** PNG IDAT decode (zlib builtin) + unfilter — RGB/RGBA 8-bit không interlace. */
85
- function decodePngPixels(buf, w, h) {
86
- // Scan chunks: gather IDAT data.
87
- let off = 8;
88
- const idat = [];
89
- let bitDepth = 8;
90
- let colorType = 2;
91
- while (off + 8 <= buf.length) {
92
- const len = buf.readUInt32BE(off);
93
- const type = buf.toString("ascii", off + 4, off + 8);
94
- if (type === "IHDR") {
95
- bitDepth = buf[off + 16];
96
- colorType = buf[off + 17];
97
- }
98
- if (type === "IDAT")
99
- idat.push(buf.subarray(off + 8, off + 8 + len));
100
- if (type === "IEND")
101
- break;
102
- off += 12 + len;
103
- }
104
- if (bitDepth !== 8 || (colorType !== 2 && colorType !== 6))
105
- throw new Error("unsupported png");
106
- const bpp = colorType === 6 ? 4 : 3;
107
- const raw = inflateSync(Buffer.concat(idat));
108
- const stride = w * bpp;
109
- const px = new Uint8Array(w * h * 3);
110
- let prev = new Uint8Array(stride);
111
- for (let y = 0; y < h; y++) {
112
- const f = raw[y * (stride + 1)];
113
- const line = raw.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1));
114
- const cur = new Uint8Array(stride);
115
- for (let x = 0; x < stride; x++) {
116
- const a = x >= bpp ? cur[x - bpp] : 0;
117
- const b = prev[x];
118
- const c = x >= bpp ? prev[x - bpp] : 0;
119
- let v = line[x];
120
- switch (f) {
121
- case 1:
122
- v += a;
123
- break;
124
- case 2:
125
- v += b;
126
- break;
127
- case 3:
128
- v += (a + b) >> 1;
129
- break;
130
- case 4: {
131
- const p = a + b - c;
132
- const pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c);
133
- v += pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
134
- break;
135
- }
136
- }
137
- cur[x] = v & 0xff;
138
- }
139
- for (let x = 0; x < w; x++) {
140
- const so = x * bpp;
141
- const do_ = (y * w + x) * 3;
142
- px[do_] = cur[so];
143
- px[do_ + 1] = cur[so + 1];
144
- px[do_ + 2] = cur[so + 2];
145
- }
146
- prev = cur;
147
- }
148
- return px;
149
- }
1
+ /** Legacy compatibility facade; implementation lives in src/media/image.ts. */
2
+ export * from "../media/image.js";
package/dist/tui/input.js CHANGED
@@ -6,7 +6,7 @@ import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-run
6
6
  import { useCallback, useMemo, useRef, useState } from "react";
7
7
  import { Box, Text, useInput } from "ink";
8
8
  import { applySuggestion, atQuery, fileIndex, suggest } from "./at-complete.js";
9
- import { completeSlashCommand, completionProviders, ghostText, completeMcpArg } from "./commands.js";
9
+ import { completeSlashCommand, completionProviders, ghostText, completeMcpArg } from "../commands/definitions.js";
10
10
  import { applyNormalKey } from "./vim.js"; // T5
11
11
  import { theme } from "./theme.js";
12
12
  export function Input({ valueRef, value, onChange, onSubmit, historyRef, skillNames, agentRef, config, atSources, modalRef }) {
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Markdown block AST — output of parse.ts, input of render.tsx.
3
+ * Pure data, zero Ink dependency, fully unit-testable.
4
+ */
5
+ export {};
@@ -0,0 +1,239 @@
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ import { Box, Text } from "ink";
3
+ import { theme } from "../theme.js";
4
+ import { charset } from "../charset.js";
5
+ import { clipCells, measure, padEndCells } from "../text-width.js";
6
+ // ── Chart / timeline specs (unchanged data formats) ─────────────────────────
7
+ function scalar(value) {
8
+ if (value == null)
9
+ return "";
10
+ return typeof value === "string" ? value.trim() : String(value);
11
+ }
12
+ /** Parse JSON or the intentionally small line format accepted by chart fences. */
13
+ export function parseChartSpec(source, language = "chart") {
14
+ const raw = source.trim();
15
+ if (!raw)
16
+ return null;
17
+ let parsed;
18
+ try {
19
+ parsed = JSON.parse(raw);
20
+ }
21
+ catch {
22
+ parsed = null;
23
+ }
24
+ const object = parsed && typeof parsed === "object" && !Array.isArray(parsed)
25
+ ? parsed
26
+ : {};
27
+ const typeRaw = scalar(object.type) || language.split(/[:\s]+/)[1] || "bar";
28
+ const type = typeRaw.toLowerCase();
29
+ const rows = Array.isArray(parsed)
30
+ ? parsed
31
+ : Array.isArray(object.data) ? object.data : [];
32
+ const data = rows.flatMap((row, index) => {
33
+ if (Array.isArray(row)) {
34
+ const value = Number(row[1]);
35
+ return Number.isFinite(value) ? [{ label: scalar(row[0]) || String(index + 1), value }] : [];
36
+ }
37
+ if (row && typeof row === "object") {
38
+ const r = row;
39
+ const value = Number(r.value ?? r.y ?? r.amount);
40
+ return Number.isFinite(value) ? [{ label: scalar(r.label ?? r.name ?? r.x) || String(index + 1), value }] : [];
41
+ }
42
+ return [];
43
+ });
44
+ // Friendly fallback: `label | value` or `label, value` per line.
45
+ if (data.length === 0) {
46
+ for (const [index, line] of raw.split("\n").entries()) {
47
+ if (!line.trim() || /^(type|title|data)\s*:/i.test(line))
48
+ continue;
49
+ const parts = line.split(/\s*[|,]\s*/);
50
+ const value = Number(parts.at(-1));
51
+ if (parts.length >= 2 && Number.isFinite(value))
52
+ data.push({ label: parts.slice(0, -1).join(" ").trim() || String(index + 1), value });
53
+ }
54
+ }
55
+ if (data.length === 0 || !["bar", "line", "pie"].includes(type))
56
+ return null;
57
+ const title = scalar(object.title);
58
+ return {
59
+ type: type,
60
+ ...(title ? { title } : {}),
61
+ data: data.slice(0, 40),
62
+ };
63
+ }
64
+ /** Parse JSON or `time | title | detail | status` timeline rows. */
65
+ export function parseTimelineSpec(source) {
66
+ const raw = source.trim();
67
+ if (!raw)
68
+ return [];
69
+ let parsed;
70
+ try {
71
+ parsed = JSON.parse(raw);
72
+ }
73
+ catch {
74
+ parsed = null;
75
+ }
76
+ const rows = Array.isArray(parsed)
77
+ ? parsed
78
+ : parsed && typeof parsed === "object" && Array.isArray(parsed.events)
79
+ ? parsed.events
80
+ : [];
81
+ const events = rows.flatMap((row) => {
82
+ if (!row || typeof row !== "object")
83
+ return [];
84
+ const r = row;
85
+ const title = scalar(r.title ?? r.name ?? r.event);
86
+ if (!title)
87
+ return [];
88
+ const status = scalar(r.status).toLowerCase();
89
+ return [{
90
+ time: scalar(r.time ?? r.date ?? r.timestamp),
91
+ title,
92
+ detail: scalar(r.detail ?? r.description ?? r.body) || undefined,
93
+ status: ["done", "active", "pending", "error"].includes(status) ? status : undefined,
94
+ }];
95
+ });
96
+ if (events.length > 0)
97
+ return events.slice(0, 40);
98
+ return raw.split("\n").flatMap((line) => {
99
+ const trimmed = line.replace(/^[-*]\s*/, "").trim();
100
+ if (!trimmed)
101
+ return [];
102
+ const parts = trimmed.split(/\s*\|\s*/);
103
+ if (parts.length < 2)
104
+ return [];
105
+ const status = parts[3]?.toLowerCase();
106
+ return [{ time: parts[0], title: parts[1], detail: parts[2] || undefined,
107
+ status: ["done", "active", "pending", "error"].includes(status) ? status : undefined }];
108
+ }).slice(0, 40);
109
+ }
110
+ // ── Bordered content box (cell-measured) ────────────────────────────────────
111
+ /** Render content lines inside a properly aligned box sized to `width` cells. */
112
+ export function ContentBox({ title, lines, width, color }) {
113
+ const inner = Math.max(10, width - 2); // 2 border cells
114
+ const titleText = ` ${title} `;
115
+ const topFill = Math.max(0, inner - measure(titleText) - 1);
116
+ const borderColor = color ?? theme.table;
117
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { color: theme.table, bold: true, children: [charset.tl, charset.h, clipCells(titleText, inner - 1), charset.h.repeat(topFill), charset.tr] }), lines.map((line, i) => (_jsxs(Text, { children: [_jsx(Text, { color: theme.table, children: charset.v }), _jsxs(Text, { color: borderColor, children: [" ", padEndCells(clipCells(line, inner - 1), inner - 1)] }), _jsx(Text, { color: theme.table, children: charset.v })] }, i))), _jsxs(Text, { color: theme.table, bold: true, children: [charset.bl, charset.h.repeat(inner), charset.br] })] }));
118
+ }
119
+ /** Node id: any word char + unicode letters, optionally quoted label. */
120
+ const MERMAID_NODE_RX = /([\p{L}\p{N}_]+)\s*(\[\s*(?:[^\]]*)\s*\]|\(\s*(?:[^)]*)\s*\)|\{\s*(?:[^}]*)\s*\}|\(\(\s*(?:[^)]*)\s*\)\))/gu;
121
+ /** Node id with optional shape, so `A[Start] --> B{Valid?}` still parses. */
122
+ const NODE_WITH_SHAPE = String.raw `([\p{L}\p{N}_]+)(?:\(\([^)]*\)\)|\[[^\]]*\]|\([^)]*\)|\{[^}]*\})?`;
123
+ 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". */
125
+ function nodeLabel(shape) {
126
+ return shape.replace(/^[[({]+|[)\]}]+$/g, "").trim().replace(/^"|"$/g, "");
127
+ }
128
+ export function summarizeMermaid(source) {
129
+ const text = source.join("\n");
130
+ const typeMatch = /^\s*(\w+)/.exec(text);
131
+ const type = typeMatch?.[1] ?? "unknown";
132
+ if (/^(graph|flowchart)$/.test(type)) {
133
+ const nodeMap = new Map();
134
+ for (const m of text.matchAll(MERMAID_NODE_RX)) {
135
+ const label = nodeLabel(m[2] ?? "");
136
+ if (label)
137
+ nodeMap.set(m[1], label);
138
+ }
139
+ const edges = [];
140
+ for (const m of text.matchAll(MERMAID_EDGE_RX)) {
141
+ edges.push({ from: m[1], to: m[4], ...(m[3]?.trim() ? { label: m[3].trim() } : {}) });
142
+ }
143
+ const lines = [`flowchart · ${nodeMap.size} nodes · ${edges.length} edges`, ""];
144
+ for (const [id, label] of [...nodeMap.entries()].slice(0, 6)) {
145
+ lines.push(`◦ ${label} (${id})`);
146
+ }
147
+ if (nodeMap.size > 6)
148
+ lines.push(`… ${nodeMap.size - 6} more nodes`);
149
+ const nameOf = (id) => nodeMap.get(id) ?? id;
150
+ for (const e of edges.slice(0, 8)) {
151
+ lines.push(`${nameOf(e.from)} → ${nameOf(e.to)}${e.label ? ` : ${e.label}` : ""}`);
152
+ }
153
+ if (edges.length > 8)
154
+ lines.push(`… ${edges.length - 8} more edges`);
155
+ return { type: "flowchart", supported: true, lines };
156
+ }
157
+ if (type === "sequenceDiagram") {
158
+ const aliases = new Map();
159
+ for (const m of text.matchAll(/(?:participant|actor)\s+([\p{L}\p{N}_]+)(?:\s+as\s+(.+))?/giu)) {
160
+ aliases.set(m[1], m[2]?.trim() ?? m[1]);
161
+ }
162
+ const messages = [];
163
+ for (const m of text.matchAll(/([\p{L}\p{N}_]+)\s*(?:->>|-->>|->|-->|-\)|--\))\s*([\p{L}\p{N}_]+)\s*:\s*(.+)/gu)) {
164
+ messages.push({ from: m[1], to: m[2], text: m[3].trim() });
165
+ }
166
+ const nameOf = (id) => aliases.get(id) ?? id;
167
+ const lines = [
168
+ `sequence · ${aliases.size} participants · ${messages.length} messages`,
169
+ "",
170
+ ];
171
+ if (aliases.size > 0)
172
+ lines.push(`participants: ${[...aliases.values()].slice(0, 6).join(", ")}`);
173
+ for (const msg of messages.slice(0, 8)) {
174
+ lines.push(`${nameOf(msg.from)} → ${nameOf(msg.to)}: ${msg.text}`);
175
+ }
176
+ if (messages.length > 8)
177
+ lines.push(`… ${messages.length - 8} more messages`);
178
+ return { type: "sequence", supported: true, lines };
179
+ }
180
+ const KNOWN_UNSUPPORTED = /^(stateDiagram|stateDiagram-v2|erDiagram|classDiagram|gitGraph|gantt|pie|journey|mindmap|timeline|quadrantChart|sankey|xychart-beta|block-beta|packet-beta|architecture-beta|requirementDiagram|C4Context)$/;
181
+ if (KNOWN_UNSUPPORTED.test(type)) {
182
+ return {
183
+ type,
184
+ supported: false,
185
+ lines: [`${type} — preview not supported for this diagram type`],
186
+ };
187
+ }
188
+ return {
189
+ type,
190
+ supported: false,
191
+ lines: [`${type || "unknown"} — unknown diagram type`],
192
+ };
193
+ }
194
+ const MERMAID_EXPORT_HINT = "export: npx @mermaid-js/mermaid-cli -i <file> -o out.svg";
195
+ const MAX_SOURCE_LINES = 12;
196
+ /** Mermaid block: structured summary box; unsupported types → source + note. */
197
+ export function MermaidPreview({ source, width }) {
198
+ const summary = summarizeMermaid(source);
199
+ 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 })] }));
201
+ }
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] })] }));
203
+ }
204
+ // ── Chart fence ─────────────────────────────────────────────────────────────
205
+ export function ChartPreview({ spec, width }) {
206
+ const chartWidth = Math.max(24, Math.min(72, width - 8));
207
+ const max = Math.max(...spec.data.map((p) => Math.abs(p.value)), 1);
208
+ const min = Math.min(...spec.data.map((p) => p.value), 0);
209
+ const range = Math.max(1, max - min);
210
+ const labelWidth = Math.min(14, Math.max(4, ...spec.data.map((p) => measure(p.label))));
211
+ const valueWidth = Math.max(1, ...spec.data.map((p) => String(p.value).length));
212
+ // Row = label + " " + marker + bar + " " + value ≤ chartWidth-3 (ContentBox
213
+ // padding) → plot budget = chartWidth - labelWidth - valueWidth - 6.
214
+ const plotWidth = Math.max(12, chartWidth - labelWidth - valueWidth - 6);
215
+ if (spec.type === "pie") {
216
+ const total = spec.data.reduce((sum, p) => sum + Math.max(0, p.value), 0) || 1;
217
+ const lines = spec.data.map((p) => {
218
+ const pct = Math.round((Math.max(0, p.value) / total) * 100);
219
+ // Row = label + " " + "NN%" + " " + bar ≤ chartWidth-3 → -9.
220
+ const barWidth = Math.max(1, chartWidth - labelWidth - 9);
221
+ const bar = charset.blockFull.repeat(Math.min(barWidth, Math.max(1, Math.round((pct / 100) * barWidth))));
222
+ return `${padEndCells(clipCells(p.label, labelWidth), labelWidth)} ${String(pct).padStart(3)}% ${bar}`;
223
+ });
224
+ return _jsx(ContentBox, { title: spec.title ?? "pie chart", lines: lines, width: chartWidth, color: theme.code });
225
+ }
226
+ const rows = spec.data.map((point) => {
227
+ const ratio = (point.value - min) / range;
228
+ const filled = Math.max(0, Math.min(plotWidth, Math.round(ratio * plotWidth)));
229
+ const bar = charset.blockFull.repeat(filled) + charset.blockEmpty.repeat(plotWidth - filled);
230
+ return `${padEndCells(clipCells(point.label, labelWidth), labelWidth)} ▌${bar} ${String(point.value).padStart(valueWidth)}`;
231
+ });
232
+ return _jsx(ContentBox, { title: spec.title ?? `${spec.type} chart`, lines: rows, width: chartWidth, color: theme.code });
233
+ }
234
+ // ── Timeline fence ──────────────────────────────────────────────────────────
235
+ export function TimelinePreview({ events, width }) {
236
+ const detailWidth = Math.max(16, width - 18);
237
+ const icon = (status) => status === "done" ? charset.ok : status === "error" ? charset.fail : status === "active" ? charset.active : charset.pending;
238
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { color: theme.table, bold: true, children: [" timeline \u00B7 ", events.length, " event", events.length === 1 ? "" : "s"] }), events.map((event, i) => (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: [_jsx(Text, { color: event.status === "error" ? theme.error : event.status === "done" ? theme.success : theme.accent, bold: true, children: icon(event.status) }), _jsxs(Text, { dimColor: true, children: [" ", padEndCells(clipCells(event.time || "—", 16), 16), " "] }), _jsx(Text, { bold: true, children: clipCells(event.title, detailWidth) })] }), event.detail && _jsxs(Text, { dimColor: true, children: [" ", i === events.length - 1 ? " " : charset.v, " ", clipCells(event.detail, detailWidth)] }), i < events.length - 1 && _jsxs(Text, { dimColor: true, children: [" ", charset.v] })] }, i)))] }));
239
+ }
@@ -0,0 +1,24 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Text } from "ink";
3
+ import { theme } from "../theme.js";
4
+ import { tokenizeInline } from "./inline.js";
5
+ /** Render inline markdown as nested Ink <Text> elements. */
6
+ export function renderInline(text, keyPrefix) {
7
+ return tokenizeInline(text).map((token, i) => {
8
+ const key = `${keyPrefix}-${i}`;
9
+ switch (token.type) {
10
+ case "code":
11
+ return _jsx(Text, { color: theme.code, children: token.text }, key);
12
+ case "bold":
13
+ return _jsx(Text, { bold: true, children: token.text }, key);
14
+ case "italic":
15
+ return _jsx(Text, { italic: true, children: token.text }, key);
16
+ case "strike":
17
+ return _jsx(Text, { strikethrough: true, dimColor: true, children: token.text }, key);
18
+ case "link":
19
+ return (_jsxs(Text, { children: [_jsx(Text, { color: "blue", underline: true, children: token.text }), _jsxs(Text, { dimColor: true, children: [" (", token.url, ")"] })] }, key));
20
+ default:
21
+ return token.text;
22
+ }
23
+ });
24
+ }
@@ -0,0 +1,93 @@
1
+ /** Placeholder chars (private use area) for escaped punctuation. */
2
+ const ESCAPES = {
3
+ "\\*": "\uE000", "\\_": "\uE001", "\\`": "\uE002", "\\~": "\uE003",
4
+ "\\[": "\uE004", "\\]": "\uE005", "\\(": "\uE006", "\\)": "\uE007",
5
+ "\\\\": "\uE008", "\\|": "\uE009",
6
+ };
7
+ const UNESCAPE = new Map(Object.entries(UNESCAPE_MAP()));
8
+ function UNESCAPE_MAP() {
9
+ const out = {};
10
+ for (const [k, v] of Object.entries({
11
+ "\uE000": "*", "\uE001": "_", "\uE002": "`", "\uE003": "~",
12
+ "\uE004": "[", "\uE005": "]", "\uE006": "(", "\uE007": ")",
13
+ "\uE008": "\\", "\uE009": "|",
14
+ }))
15
+ out[k] = v;
16
+ return out;
17
+ }
18
+ /** Escape `\X` sequences so tokenizers don't see the marker chars. */
19
+ export function protectEscapes(text) {
20
+ let out = text;
21
+ for (const [esc, placeholder] of Object.entries(ESCAPES)) {
22
+ // replaceAll on literal backslash sequences
23
+ out = out.split(esc).join(placeholder);
24
+ }
25
+ return out;
26
+ }
27
+ /** Restore placeholder chars to their literal punctuation. */
28
+ export function unprotect(text) {
29
+ let out = text;
30
+ for (const [placeholder, literal] of UNESCAPE) {
31
+ out = out.split(placeholder).join(literal);
32
+ }
33
+ return out;
34
+ }
35
+ /** Split text keeping code spans intact — returns raw segments for tables etc. */
36
+ export function splitOutsideCodeSpans(text, delimiter) {
37
+ const protected_ = protectEscapes(text);
38
+ const out = [];
39
+ let current = "";
40
+ let inCode = false;
41
+ for (const ch of protected_) {
42
+ if (ch === "`")
43
+ inCode = !inCode;
44
+ if (ch === delimiter && !inCode) {
45
+ out.push(current);
46
+ current = "";
47
+ }
48
+ else {
49
+ current += ch;
50
+ }
51
+ }
52
+ out.push(current);
53
+ return out.map(unprotect);
54
+ }
55
+ /** Tokenize one line of inline markdown. */
56
+ export function tokenizeInline(text) {
57
+ const src = protectEscapes(text);
58
+ const tokens = [];
59
+ // Order matters: code spans → links → bold → strike → italic.
60
+ const rx = /(`[^`\n]+`)|(\[[^\]\n]*\]\((?:[^()\n]|\([^()\n]*\))+\))|(\*\*[^*\n]+\*\*|__[^_\n]+__)|(~~[^~\n]+~~)|(\*[^*\n]+\*|_[^_\n]+_)/g;
61
+ let last = 0;
62
+ for (const m of src.matchAll(rx)) {
63
+ if (m.index > last)
64
+ tokens.push({ type: "text", text: unprotect(src.slice(last, m.index)) });
65
+ const tok = m[0];
66
+ if (m[1]) {
67
+ tokens.push({ type: "code", text: unprotect(tok.slice(1, -1)) });
68
+ }
69
+ else if (m[2]) {
70
+ const lm = /^\[([^\]\n]*)\]\(((?:[^()\n]|\([^()\n]*\))+)\)$/.exec(tok);
71
+ tokens.push({ type: "link", text: unprotect(lm?.[1] ?? ""), url: unprotect(lm?.[2] ?? "") });
72
+ }
73
+ else if (m[3]) {
74
+ tokens.push({ type: "bold", text: unprotect(tok.slice(2, -2)) });
75
+ }
76
+ else if (m[4]) {
77
+ tokens.push({ type: "strike", text: unprotect(tok.slice(2, -2)) });
78
+ }
79
+ else if (m[5]) {
80
+ tokens.push({ type: "italic", text: unprotect(tok.slice(1, -1)) });
81
+ }
82
+ last = m.index + tok.length;
83
+ }
84
+ if (last < src.length)
85
+ tokens.push({ type: "text", text: unprotect(src.slice(last)) });
86
+ return tokens.filter((t) => t.text !== "" || t.type === "link");
87
+ }
88
+ /** Plain-text content of inline markdown (markers stripped) — for measuring. */
89
+ export function inlineToPlain(text) {
90
+ return tokenizeInline(text)
91
+ .map((t) => (t.type === "link" ? `${t.text} (${t.url})` : t.text))
92
+ .join("");
93
+ }