s-app-scoder-agent 0.9.3 → 0.9.6

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
@@ -1,387 +1,40 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Box, Text } from "ink";
3
- import { theme } from "./theme.js";
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Box } from "ink";
3
+ import { parseMarkdown } from "./markdown/parse.js";
4
+ import { renderBlock } from "./markdown/render.js";
5
+ export { parseChartSpec, parseTimelineSpec, } from "./markdown/diagram.js";
6
+ export { parseMarkdown } from "./markdown/parse.js";
7
+ export { tokenizeInline, inlineToPlain } from "./markdown/inline.js";
4
8
  const DEFAULT_WIDTH = 100;
5
- const MAX_CODE_LINES = 60;
6
- function clip(line, width = DEFAULT_WIDTH) {
7
- const safeWidth = Math.max(8, width);
8
- return line.length > safeWidth ? line.slice(0, safeWidth - 1) + "…" : line;
9
- }
10
9
  function contentWidth(width) {
11
10
  return Math.max(32, Math.floor(width ?? DEFAULT_WIDTH));
12
11
  }
13
- function scalar(value) {
14
- if (value == null)
15
- return "";
16
- return typeof value === "string" ? value.trim() : String(value);
17
- }
18
- /** Parse JSON or the intentionally small line format accepted by chart fences. */
19
- export function parseChartSpec(source, language = "chart") {
20
- const raw = source.trim();
21
- if (!raw)
22
- return null;
23
- let parsed;
24
- try {
25
- parsed = JSON.parse(raw);
26
- }
27
- catch {
28
- parsed = null;
29
- }
30
- const object = parsed && typeof parsed === "object" && !Array.isArray(parsed)
31
- ? parsed
32
- : {};
33
- const typeRaw = scalar(object.type) || language.split(/[:\s]+/)[1] || "bar";
34
- const type = typeRaw.toLowerCase();
35
- const rows = Array.isArray(parsed)
36
- ? parsed
37
- : Array.isArray(object.data) ? object.data : [];
38
- const data = rows.flatMap((row, index) => {
39
- if (Array.isArray(row)) {
40
- const value = Number(row[1]);
41
- return Number.isFinite(value) ? [{ label: scalar(row[0]) || String(index + 1), value }] : [];
42
- }
43
- if (row && typeof row === "object") {
44
- const r = row;
45
- const value = Number(r.value ?? r.y ?? r.amount);
46
- return Number.isFinite(value) ? [{ label: scalar(r.label ?? r.name ?? r.x) || String(index + 1), value }] : [];
47
- }
48
- return [];
49
- });
50
- // Friendly fallback: `label | value` or `label, value` per line.
51
- if (data.length === 0) {
52
- for (const [index, line] of raw.split("\n").entries()) {
53
- if (!line.trim() || /^(type|title|data)\s*:/i.test(line))
54
- continue;
55
- const parts = line.split(/\s*[|,]\s*/);
56
- const value = Number(parts.at(-1));
57
- if (parts.length >= 2 && Number.isFinite(value))
58
- data.push({ label: parts.slice(0, -1).join(" ").trim() || String(index + 1), value });
59
- }
60
- }
61
- if (data.length === 0 || !["bar", "line", "pie"].includes(type))
62
- return null;
63
- const title = scalar(object.title);
64
- return {
65
- type: type,
66
- ...(title ? { title } : {}),
67
- data: data.slice(0, 40),
68
- };
69
- }
70
- /** Parse JSON or `time | title | detail | status` timeline rows. */
71
- export function parseTimelineSpec(source) {
72
- const raw = source.trim();
73
- if (!raw)
74
- return [];
75
- let parsed;
76
- try {
77
- parsed = JSON.parse(raw);
78
- }
79
- catch {
80
- parsed = null;
81
- }
82
- const rows = Array.isArray(parsed)
83
- ? parsed
84
- : parsed && typeof parsed === "object" && Array.isArray(parsed.events)
85
- ? parsed.events
86
- : [];
87
- const events = rows.flatMap((row) => {
88
- if (!row || typeof row !== "object")
89
- return [];
90
- const r = row;
91
- const title = scalar(r.title ?? r.name ?? r.event);
92
- if (!title)
93
- return [];
94
- const status = scalar(r.status).toLowerCase();
95
- return [{
96
- time: scalar(r.time ?? r.date ?? r.timestamp),
97
- title,
98
- detail: scalar(r.detail ?? r.description ?? r.body) || undefined,
99
- status: ["done", "active", "pending", "error"].includes(status) ? status : undefined,
100
- }];
101
- });
102
- if (events.length > 0)
103
- return events.slice(0, 40);
104
- return raw.split("\n").flatMap((line) => {
105
- const trimmed = line.replace(/^[-*]\s*/, "").trim();
106
- if (!trimmed)
107
- return [];
108
- const parts = trimmed.split(/\s*\|\s*/);
109
- if (parts.length < 2)
110
- return [];
111
- const status = parts[3]?.toLowerCase();
112
- return [{ time: parts[0], title: parts[1], detail: parts[2] || undefined,
113
- status: ["done", "active", "pending", "error"].includes(status) ? status : undefined }];
114
- }).slice(0, 40);
115
- }
116
- // ── Syntax highlighting ───────────────────────────────────────────────────────
117
- /** T-15: token-based syntax highlighter for TypeScript/JavaScript/JSON/shell. */
118
- function highlightCode(code, width = DEFAULT_WIDTH) {
119
- const kw = new Set(["const", "let", "var", "function", "async", "await", "return",
120
- "if", "else", "for", "while", "do", "switch", "case", "break", "continue",
121
- "try", "catch", "finally", "throw", "class", "extends", "import", "export",
122
- "from", "default", "new", "typeof", "instanceof", "void", "in", "of",
123
- "interface", "type", "enum", "implements", "private", "public", "protected",
124
- "readonly", "abstract", "override", "declare", "namespace", "module"]);
125
- const typeKw = new Set(["string", "number", "boolean", "any", "unknown", "never",
126
- "null", "undefined", "void", "object", "true", "false", "Promise", "Array",
127
- "Record", "Partial", "Required", "Omit", "Pick", "keyof", "infer"]);
128
- const codeWidth = Math.max(8, width - 6);
129
- return code.slice(0, MAX_CODE_LINES).map((sourceLine, i) => {
130
- const line = clip(sourceLine, codeWidth);
131
- const parts = [];
132
- const re = /("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`)|(\/\/[^\n]*|\/\*[\s\S]*?\*\/)|(\b0x[\da-fA-F]+|\d+\.?\d*(?:[eE][+-]?\d+)?\b)|(\w+)/g;
133
- let last = 0;
134
- let k = 0;
135
- let m;
136
- while ((m = re.exec(line)) !== null) {
137
- if (m.index > last)
138
- parts.push(line.slice(last, m.index));
139
- const tok = m[0];
140
- if (m[1])
141
- parts.push(_jsx(Text, { color: theme.code, children: tok }, `c${i}-${k++}`));
142
- else if (m[2])
143
- parts.push(_jsx(Text, { color: theme.thinking, children: tok }, `c${i}-${k++}`));
144
- else if (m[3])
145
- parts.push(_jsx(Text, { color: "magenta", children: tok }, `c${i}-${k++}`));
146
- else if (kw.has(tok))
147
- parts.push(_jsx(Text, { bold: true, color: "cyan", children: tok }, `c${i}-${k++}`));
148
- else if (typeKw.has(tok))
149
- parts.push(_jsx(Text, { color: "yellow", children: tok }, `c${i}-${k++}`));
150
- else
151
- parts.push(line.slice(m.index, m.index + tok.length));
152
- last = m.index + tok.length;
153
- }
154
- if (last < line.length)
155
- parts.push(line.slice(last));
156
- return (_jsxs(Text, { children: [_jsxs(Text, { dimColor: true, children: [String(i + 1).padStart(3, " "), " \u2502 "] }), parts.length > 0 ? parts : line] }, i));
157
- });
158
- }
159
- // ── Mermaid ASCII preview ──────────────────────────────────────────────────────
160
- /** T-15: parse mermaid source → compact ASCII art preview. */
161
- function mermaidPreview(code) {
162
- const text = code.join("\n").trim();
163
- const isFlowchart = /^(graph|flowchart|stateDiagram|erDiagram|gitGraph)/m.test(text);
164
- const isSequence = /^sequenceDiagram/m.test(text);
165
- const result = [];
166
- result.push("┌─ ◉ diagram preview ──────────────────────────────────┐");
167
- if (isFlowchart) {
168
- const nodeMap = new Map();
169
- const edges = [];
170
- const nodeRe = /([A-Z][A-Za-z0-9_]*)(\[[^\]]*\]|\([^)]*\)|\{[^}]*\}|\(\([^)]*\)\))/g;
171
- let m;
172
- while ((m = nodeRe.exec(text)) !== null) {
173
- nodeMap.set(m[1], m[2] ?? m[3] ?? m[4] ?? m[5] ?? m[1]);
174
- }
175
- const edgeRe = /([A-Z][A-Za-z0-9_]*)\s*(-->?|-.->|==>|<-->|===>|---)\|?([^|]+)\|?\s*([A-Z][A-Za-z0-9_]*)/g;
176
- while ((m = edgeRe.exec(text)) !== null) {
177
- edges.push([m[1], "", m[4]]);
178
- }
179
- const nodes = [...nodeMap.entries()].slice(0, 6);
180
- for (const [id, label] of nodes) {
181
- result.push(`│ [${id}] ${label.slice(0, 24).padEnd(24)} │`);
182
- }
183
- if (nodes.length > 0)
184
- result.push("│ ───────────────────────────────── │");
185
- for (const [from, , to] of edges.slice(0, 4)) {
186
- const fl = nodeMap.get(from) ?? from;
187
- const tl = nodeMap.get(to) ?? to;
188
- result.push(`│ ${fl.slice(0, 8)} → ${tl.slice(0, 8).padEnd(8)} │`);
189
- }
190
- }
191
- else if (isSequence) {
192
- const parts = [];
193
- const partRe = /(?:participant|actor)\s+(\w+)/gi;
194
- let m;
195
- while ((m = partRe.exec(text)) !== null)
196
- parts.push(m[1]);
197
- if (parts.length > 0)
198
- result.push(`│ participants: ${parts.slice(0, 5).join(", ")} │`);
199
- result.push("│ ───────────────────────────────── │");
200
- const msgRe = /([\w]+)\s+(?:->|-->|-.>|=>)\s+([\w]+)(?:\s*:\s*(.*))?/gi;
201
- while ((m = msgRe.exec(text)) !== null) {
202
- if (result.length >= 10) {
203
- result.push("│ … (more messages) │");
204
- break;
205
- }
206
- const from = (m[1] ?? "").slice(0, 8), to = (m[2] ?? "").slice(0, 8), msg = (m[3] ?? "").slice(0, 20);
207
- result.push(`│ ${from} → ${to.padEnd(8)} : ${msg} │`);
208
- }
209
- }
210
- else {
211
- result.push("│ ◈ mermaid diagram │");
212
- result.push("│ (run: npx @mermaid-js/mermaid-cli -i <file>) │");
213
- }
214
- result.push("└─────────────────────────────────────────────────────┘");
215
- return (_jsx(Box, { flexDirection: "column", marginTop: 1, children: result.map((l, i) => (_jsx(Text, { color: theme.mermaid, children: i === 0 || i === result.length - 1 ? _jsx(Text, { bold: true, children: l }) : l }, i))) }));
216
- }
217
- function chartPreview(spec, width) {
218
- const chartWidth = Math.max(24, Math.min(72, width - 8));
219
- const max = Math.max(...spec.data.map((p) => Math.abs(p.value)), 1);
220
- const min = Math.min(...spec.data.map((p) => p.value), 0);
221
- const range = Math.max(1, max - min);
222
- const labelWidth = Math.min(14, Math.max(4, ...spec.data.map((p) => p.label.length)));
223
- const plotWidth = Math.max(12, chartWidth - labelWidth - 10);
224
- const title = spec.title ? ` ${spec.title} ` : ` ${spec.type} chart `;
225
- const rows = spec.data.map((point, index) => {
226
- const ratio = (point.value - min) / range;
227
- const filled = Math.max(0, Math.min(plotWidth, Math.round(ratio * plotWidth)));
228
- const bar = "█".repeat(filled) + "░".repeat(plotWidth - filled);
229
- const marker = spec.type === "line" ? (index === 0 ? "╭" : index === spec.data.length - 1 ? "╰" : "│") : "▌";
230
- return ` ${point.label.slice(0, labelWidth).padEnd(labelWidth)} ${marker}${bar} ${String(point.value)}`;
231
- });
232
- if (spec.type === "pie") {
233
- const total = spec.data.reduce((sum, p) => sum + Math.max(0, p.value), 0) || 1;
234
- return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { color: theme.table, bold: true, children: ["\u250C\u2500", title, "─".repeat(Math.max(0, chartWidth - title.length - 2)), "\u2510"] }), spec.data.map((p, i) => {
235
- const pct = Math.round((Math.max(0, p.value) / total) * 100);
236
- const pieBarWidth = Math.max(1, chartWidth - labelWidth - 9);
237
- const pieBar = "█".repeat(Math.min(pieBarWidth, Math.max(1, Math.round((pct / 100) * pieBarWidth))));
238
- return _jsxs(Text, { color: theme.code, children: ["\u2502 ", p.label.slice(0, labelWidth).padEnd(labelWidth), " ", String(pct).padStart(3), "% ", pieBar.padEnd(pieBarWidth, " ")] }, i);
239
- }), _jsxs(Text, { color: theme.table, bold: true, children: ["\u2514", "─".repeat(chartWidth), "\u2518"] })] }));
240
- }
241
- return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { color: theme.table, bold: true, children: ["\u250C\u2500", title, "─".repeat(Math.max(0, chartWidth - title.length - 2)), "\u2510"] }), rows.map((row, i) => _jsxs(Text, { color: theme.code, children: ["\u2502", row] }, i)), _jsxs(Text, { color: theme.table, bold: true, children: ["\u2514", "─".repeat(chartWidth), "\u2518"] })] }));
242
- }
243
- function timelinePreview(events, width) {
244
- const detailWidth = Math.max(16, width - 18);
245
- const icon = (status) => status === "done" ? "✓" : status === "error" ? "✗" : status === "active" ? "◆" : "○";
246
- 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: [" ", clip(event.time || "—", 16).padEnd(16), " "] }), _jsx(Text, { bold: true, children: clip(event.title, detailWidth) })] }), event.detail && _jsxs(Text, { dimColor: true, children: [" ", i === events.length - 1 ? " " : "│", " ", clip(event.detail, detailWidth)] }), i < events.length - 1 && _jsx(Text, { dimColor: true, children: " \u2502" })] }, i)))] }));
247
- }
248
- /** Render inline markdown tokens as nested Ink <Text> elements. */
249
- function renderInline(text, keyPrefix) {
250
- const parts = [];
251
- const re = /(`[^`\n]+`)|(\*\*[^*\n]+\*\*)|(\[[^\]\n]+\]\([^)\n]+\))/g;
252
- let last = 0;
253
- let k = 0;
254
- let m;
255
- while ((m = re.exec(text)) !== null) {
256
- if (m.index > last)
257
- parts.push(text.slice(last, m.index));
258
- const tok = m[0];
259
- if (tok.startsWith("`")) {
260
- parts.push(_jsx(Text, { color: theme.code, children: tok.slice(1, -1) }, `${keyPrefix}-c${k++}`));
261
- }
262
- else if (tok.startsWith("**")) {
263
- parts.push(_jsx(Text, { bold: true, children: tok.slice(2, -2) }, `${keyPrefix}-b${k++}`));
264
- }
265
- else {
266
- const lm = /\[([^\]]+)\]\(([^)]+)\)/.exec(tok);
267
- if (lm) {
268
- parts.push(_jsxs(Text, { children: [_jsx(Text, { color: "blue", children: lm[1] }), _jsxs(Text, { dimColor: true, children: [" (", lm[2], ")"] })] }, `${keyPrefix}-l${k++}`));
269
- }
270
- }
271
- last = m.index + tok.length;
272
- }
273
- if (last < text.length)
274
- parts.push(text.slice(last));
275
- return parts;
12
+ /**
13
+ * Parse cache: finalized assistant messages are immutable, so re-renders
14
+ * (sidebar tick, scroll, resize) must not re-parse. Small LRU keyed by text;
15
+ * parse depends only on the text, not the width.
16
+ */
17
+ const PARSE_CACHE_MAX = 50;
18
+ const parseCache = new Map();
19
+ function parseCached(text) {
20
+ const hit = parseCache.get(text);
21
+ if (hit) {
22
+ // Refresh recency (LRU).
23
+ parseCache.delete(text);
24
+ parseCache.set(text, hit);
25
+ return hit;
26
+ }
27
+ const blocks = parseMarkdown(text);
28
+ parseCache.set(text, blocks);
29
+ if (parseCache.size > PARSE_CACHE_MAX) {
30
+ const oldest = parseCache.keys().next().value;
31
+ if (oldest !== undefined)
32
+ parseCache.delete(oldest);
33
+ }
34
+ return blocks;
276
35
  }
277
36
  export function Markdown({ text, width }) {
278
37
  const paneWidth = contentWidth(width);
279
- const lines = text.split("\n");
280
- const blocks = [];
281
- let i = 0;
282
- let key = 0;
283
- while (i < lines.length) {
284
- const line = lines[i];
285
- // Fenced code block
286
- if (line.startsWith("```")) {
287
- const lang = line.slice(3).trim().toLowerCase();
288
- const code = [];
289
- i++;
290
- while (i < lines.length && !lines[i].startsWith("```"))
291
- code.push(lines[i++]);
292
- i++; // consume closing fence (or EOF)
293
- // T-15: mermaid diagram → visual ASCII preview
294
- if (lang === "mermaid" && code.length > 0) {
295
- blocks.push(_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { dimColor: true, children: " \u25C9 mermaid diagram" }), mermaidPreview(code)] }, key++));
296
- continue;
297
- }
298
- if ((lang === "chart" || lang.startsWith("chart:")) && code.length > 0) {
299
- const chart = parseChartSpec(code.join("\n"), lang);
300
- blocks.push(chart ? _jsx(Box, { children: chartPreview(chart, paneWidth) }, key++) : _jsx(Text, { dimColor: true, children: " (invalid chart data)" }, key++));
301
- continue;
302
- }
303
- if ((lang === "timeline" || lang === "events") && code.length > 0) {
304
- const events = parseTimelineSpec(code.join("\n"));
305
- blocks.push(events.length > 0 ? _jsx(Box, { children: timelinePreview(events, paneWidth) }, key++) : _jsx(Text, { dimColor: true, children: " (empty timeline)" }, key++));
306
- continue;
307
- }
308
- // T-15: syntax-highlighted code block
309
- blocks.push(_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: theme.border, paddingX: 1, marginTop: 1, children: [lang && _jsx(Text, { dimColor: true, bold: true, children: lang }), code.length === 0 ? (_jsx(Text, { dimColor: true, children: "(empty)" })) : (highlightCode(code, paneWidth)), code.length > MAX_CODE_LINES && (_jsxs(Text, { dimColor: true, children: [" \u2026 +", code.length - MAX_CODE_LINES, " more lines"] }))] }, key++));
310
- continue;
311
- }
312
- // Headers
313
- const h = /^(#{1,4})\s+(.*)$/.exec(line);
314
- if (h) {
315
- blocks.push(_jsx(Text, { bold: true, color: h[1].length <= 2 ? "magenta" : "cyan", children: renderInline(h[2], `h${key}`) }, key++));
316
- i++;
317
- continue;
318
- }
319
- // Table (GFM): header | separator | rows — collect the whole block.
320
- if (/^\s*\|.*\|\s*$/.test(line) && i + 1 < lines.length && /^\s*\|[\s:|-]+\|\s*$/.test(lines[i + 1])) {
321
- const MAX_CELL = 40;
322
- const parseRow = (l) => l.trim().replace(/^\|/, "").replace(/\|$/, "").split("|").map((c) => c.trim());
323
- const header = parseRow(line);
324
- i += 2; // header + separator
325
- const rows = [];
326
- while (i < lines.length && /^\s*\|.*\|\s*$/.test(lines[i]))
327
- rows.push(parseRow(lines[i++]));
328
- // Column widths (bounded — wide cells clip via clip()).
329
- const available = Math.max(20, paneWidth - 4);
330
- const naturalWidths = header.map((h, c) => Math.min(MAX_CELL, Math.max(h.length, ...rows.map((r) => (r[c] ?? "").length))));
331
- const separators = Math.max(0, header.length - 1) * 3 + 4;
332
- const totalNatural = naturalWidths.reduce((sum, n) => sum + n, 0);
333
- const widths = naturalWidths.map((n) => totalNatural + separators <= available ? n : Math.max(3, Math.floor((n / Math.max(1, totalNatural)) * (available - separators))));
334
- const renderRow = (cells, chars, color, bold, rowKey) => {
335
- const line = chars.l + widths.map((width, ci) => {
336
- const cell = cells[ci] ?? "";
337
- return (cell.length > width ? cell.slice(0, Math.max(1, width - 1)) + "…" : cell).padEnd(width, " ");
338
- }).join(chars.m) + chars.r;
339
- return _jsx(Text, { color: color, bold: bold, children: clip(line, paneWidth) }, rowKey);
340
- };
341
- blocks.push(_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [renderRow(header, { l: "┌ ", m: " ┬ ", r: " ┐" }, theme.table, true, `th${key}`), renderRow(header.map(() => ""), { l: "├", m: "┼", r: "┤" }, "gray", false, `ts${key}`), rows.map((r, ri) => renderRow(r, { l: "│ ", m: " │ ", r: " │" }, undefined, false, `tr${key}-${ri}`))] }, key++));
342
- continue;
343
- }
344
- // Bullets + task lists: - [ ] pending · - [x] completed
345
- const b = /^(\s*)[-*]\s+(.*)$/.exec(line);
346
- if (b) {
347
- const body = b[2];
348
- const taskMatch = /^\[([ xX])\]\s+(.*)$/.exec(body);
349
- const indent = " ".repeat(Math.floor(b[1].length / 2));
350
- if (taskMatch) {
351
- const checked = taskMatch[1].toLowerCase() === "x";
352
- const taskBody = taskMatch[2];
353
- blocks.push(_jsxs(Text, { children: [_jsxs(Text, { color: theme.accent, children: [indent, checked ? "☑" : "☐", " "] }), checked
354
- ? _jsx(Text, { dimColor: true, strikethrough: true, children: renderInline(taskBody, `b${key}`) })
355
- : renderInline(taskBody, `b${key}`)] }, key++));
356
- }
357
- else {
358
- blocks.push(_jsxs(Text, { children: [_jsxs(Text, { color: theme.accent, children: [indent, "\u2022 "] }), renderInline(body, `b${key}`)] }, key++));
359
- }
360
- i++;
361
- continue;
362
- }
363
- // Numbered list — keep the numbering as-is; nested indent preserved.
364
- const n = /^(\s*)(\d+\.)\s+(.*)$/.exec(line);
365
- if (n) {
366
- const indent = " ".repeat(Math.floor(n[1].length / 2));
367
- blocks.push(_jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: indent }), renderInline(`${n[2]} ${n[3]}`, `n${key}`)] }, key++));
368
- i++;
369
- continue;
370
- }
371
- // Quote
372
- if (/^>\s?/.test(line)) {
373
- blocks.push(_jsxs(Text, { dimColor: true, children: [_jsx(Text, { color: theme.dim, children: "\u2502" }), " ", _jsx(Text, { italic: true, children: clip(line.replace(/^>\s?/, "")) })] }, key++));
374
- i++;
375
- continue;
376
- }
377
- // Blank line → single spacer
378
- if (line.trim() === "") {
379
- blocks.push(_jsx(Text, { children: " " }, key++));
380
- i++;
381
- continue;
382
- }
383
- blocks.push(_jsx(Text, { children: renderInline(clip(line, paneWidth), `t${key}`) }, key++));
384
- i++;
385
- }
386
- return _jsx(Box, { flexDirection: "column", children: blocks });
38
+ const blocks = parseCached(text);
39
+ return (_jsx(Box, { flexDirection: "column", children: blocks.map((block, i) => renderBlock(block, paneWidth, i)) }));
387
40
  }
@@ -8,6 +8,7 @@ import { useState } from "react";
8
8
  import { Box, Text, useInput } from "ink";
9
9
  import { DiffLine } from "./chat-views.js";
10
10
  import { theme } from "./theme.js";
11
+ import { boxBorderStyle } from "./charset.js";
11
12
  const MODAL_DIFF_WINDOW = 20; // rows hiển thị cùng lúc; scroll bằng pgup/pgdn
12
13
  /**
13
14
  * Permission request modal — elevated visual with a bold colored header
@@ -30,5 +31,5 @@ export function PermissionModal({ prompt }) {
30
31
  const shown = lines.slice(start, start + MODAL_DIFF_WINDOW);
31
32
  const hasMoreAbove = start > 0;
32
33
  const hasMoreBelow = start + MODAL_DIFF_WINDOW < lines.length;
33
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "bold", borderColor: theme.warn, paddingX: 1, paddingY: 1, children: [_jsxs(Box, { justifyContent: "space-between", marginBottom: 1, children: [_jsxs(Text, { color: theme.warn, bold: true, children: ["\u26A0 permission \u2014 ", prompt.kind] }), _jsxs(Text, { dimColor: true, children: ["timeout ", prompt.timeoutSecs, "s \u2192 deny"] })] }), _jsx(Text, { bold: true, children: prompt.label }), prompt.diff && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [hasMoreAbove && _jsxs(Text, { dimColor: true, children: ["\u2191 ", start, " earlier lines"] }), shown.map((l, i) => (_jsx(DiffLine, { line: l.length > 160 ? l.slice(0, 160) + "…" : l }, start + i))), hasMoreBelow && (_jsxs(Text, { dimColor: true, children: ["\u2193 ", lines.length - start - MODAL_DIFF_WINDOW, " more lines"] }))] })), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "─".repeat(28) }), _jsx(Text, { dimColor: true, children: " y \u00B7 a \u00B7 n \u00B7 q to answer" }), prompt.diff && _jsx(Text, { dimColor: true, children: " pgup \u00B7 pgdn \u00B7 home \u00B7 end scroll diff" })] })] }));
34
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: boxBorderStyle(), borderColor: theme.warn, paddingX: 1, paddingY: 1, children: [_jsxs(Box, { justifyContent: "space-between", marginBottom: 1, children: [_jsxs(Text, { color: theme.warn, bold: true, children: ["\u26A0 permission \u2014 ", prompt.kind] }), _jsxs(Text, { dimColor: true, children: ["timeout ", prompt.timeoutSecs, "s \u2192 deny"] })] }), _jsx(Text, { bold: true, children: prompt.label }), prompt.diff && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [hasMoreAbove && _jsxs(Text, { dimColor: true, children: ["\u2191 ", start, " earlier lines"] }), shown.map((l, i) => (_jsx(DiffLine, { line: l.length > 160 ? l.slice(0, 160) + "…" : l }, start + i))), hasMoreBelow && (_jsxs(Text, { dimColor: true, children: ["\u2193 ", lines.length - start - MODAL_DIFF_WINDOW, " more lines"] }))] })), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "─".repeat(28) }), _jsx(Text, { dimColor: true, children: " y \u00B7 a \u00B7 n \u00B7 q to answer" }), prompt.diff && _jsx(Text, { dimColor: true, children: " pgup \u00B7 pgdn \u00B7 home \u00B7 end scroll diff" })] })] }));
34
35
  }
@@ -0,0 +1,157 @@
1
+ import { parseMarkdown } from "./markdown/parse.js";
2
+ import { measure } from "./text-width.js";
3
+ const EST_WIDTH = 80;
4
+ /** Ước lượng số dòng wrap của một đoạn text ở width cho trước. */
5
+ function wrapEstimate(text, width) {
6
+ return text.split("\n").reduce((sum, line) => sum + Math.max(1, Math.ceil(measure(line) / width)), 0);
7
+ }
8
+ /**
9
+ * Block-aware estimate cho assistant message: parse markdown rồi cộng chiều
10
+ * cao từng block. Chính xác hơn nhiều so với heuristic len/80 cũ — bảng (+3
11
+ * viền), code box (+2 viền + gutter wrap), mermaid (~14 dòng) trước đây làm
12
+ * virtualization window lệch lớn.
13
+ */
14
+ export function estimateMarkdownRows(text, width = EST_WIDTH) {
15
+ if (!text)
16
+ return 1;
17
+ let rows = 0;
18
+ for (const block of parseMarkdown(text)) {
19
+ switch (block.kind) {
20
+ case "paragraph":
21
+ rows += wrapEstimate(block.text, width);
22
+ break;
23
+ case "heading":
24
+ case "hr":
25
+ case "spacer":
26
+ rows += 1;
27
+ break;
28
+ case "code": {
29
+ const gutter = String(Math.min(block.lines.length, 60)).length;
30
+ const codeWidth = Math.max(8, width - gutter - 6);
31
+ const shown = Math.min(block.lines.length, 60);
32
+ rows += wrapEstimate(block.lines.slice(0, shown).join("\n"), codeWidth)
33
+ + 2 // border top/bottom
34
+ + (block.lang ? 1 : 0)
35
+ + (block.lines.length > 60 ? 1 : 0)
36
+ + 1; // marginTop
37
+ break;
38
+ }
39
+ case "table":
40
+ // top + header + separator + rows + bottom (+ cap note) + margin
41
+ rows += Math.min(block.rows.length, 50) + 4 + (block.rows.length > 50 ? 1 : 0) + 1;
42
+ break;
43
+ case "list":
44
+ rows += block.items.reduce((sum, it) => sum + wrapEstimate(it.text, Math.max(8, width - 4 - it.depth * 2)), 0);
45
+ break;
46
+ case "quote":
47
+ rows += wrapEstimate(block.text, Math.max(8, width - 2));
48
+ 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;
52
+ break;
53
+ case "chart":
54
+ rows += 2 + 40 + 1; // ContentBox max: borders + ≤40 data rows
55
+ break;
56
+ case "timeline":
57
+ rows += 1 + block.source.split("\n").length * 2;
58
+ break;
59
+ }
60
+ }
61
+ return rows;
62
+ }
63
+ /**
64
+ * T4: ước lượng chiều cao (rows) của một ChatItem khi render — dùng cho
65
+ * virtualization (chỉ render window quanh viewport thay vì cap cứng).
66
+ * Over-estimate nhẹ an toàn hơn under-estimate (viewport luôn đủ phủ).
67
+ * Assistant items dùng estimateMarkdownRows (block-aware); các kind khác giữ
68
+ * heuristic đơn giản vì render gần như 1:1 với text.
69
+ */
70
+ export function estimateItemRows(item) {
71
+ switch (item.kind) {
72
+ case "user":
73
+ return 2 + Math.ceil(item.text.length / 80);
74
+ case "assistant":
75
+ // +1 marginTop; streaming raw text dùng wrap đơn giản (chưa qua markdown).
76
+ return item.streaming
77
+ ? wrapEstimate(item.text ?? "", EST_WIDTH)
78
+ : 1 + estimateMarkdownRows(item.text ?? "", EST_WIDTH);
79
+ case "thinking":
80
+ // Render: collapse to last 4 lines.
81
+ return 1 + Math.min(4, item.text ? item.text.split("\n").length : 0);
82
+ case "tool":
83
+ return 1 + 2; // title + preview 2 lines
84
+ case "compaction":
85
+ return 1;
86
+ case "diff":
87
+ return 3 + Math.min(24, (item.text?.split("\n").length ?? 0)); // header + ≤24 lines + border
88
+ case "todo":
89
+ // TodoItemView render null (plan hiển thị ở sidebar qua state.plan) —
90
+ // chiếm 0 dòng thực tế trong transcript.
91
+ return 0;
92
+ case "build":
93
+ return 1;
94
+ case "notice":
95
+ return 1 + Math.ceil(item.text.length / 2000);
96
+ }
97
+ }
98
+ /**
99
+ * T4: chọn window item để render: lấy từ cuối lên tới khi tổng rows ước
100
+ * lượng ≥ viewportRows + margin (render dư phía trên để scroll mượt), nhưng
101
+ * giữ toàn bộ khi tổng nhỏ. Trả về slice của items (không mutate).
102
+ */
103
+ export function visibleWindow(items, viewportRows, margin = 50) {
104
+ const budget = viewportRows + margin;
105
+ let acc = 0;
106
+ let start = items.length;
107
+ while (start > 0 && acc < budget) {
108
+ start--;
109
+ acc += estimateItemRows(items[start]);
110
+ }
111
+ return start === 0 ? items : items.slice(start);
112
+ }
113
+ /**
114
+ * Plan mới nhất cho sidebar — todo items được append mỗi lần update nhưng
115
+ * sidebar chỉ cần snapshot hiện tại (last-wins), null khi chưa có plan.
116
+ *
117
+ * Reset realtime: mỗi lần user gửi message mới, plan reset — chỉ hiện plan
118
+ * được viết SAU user message cuối cùng. Tránh stale plan từ task trước hiện
119
+ * trong task mới.
120
+ */
121
+ export function latestPlan(items) {
122
+ // Tìm user message cuối cùng — plan chỉ reset tại đây.
123
+ let lastUserIdx = -1;
124
+ for (let i = items.length - 1; i >= 0; i--) {
125
+ if (items[i].kind === "user") {
126
+ lastUserIdx = i;
127
+ break;
128
+ }
129
+ }
130
+ // Chỉ tìm todo SAU user cuối.
131
+ for (let i = items.length - 1; i > lastUserIdx; i--) {
132
+ if (items[i].kind === "todo")
133
+ return items[i];
134
+ }
135
+ return null;
136
+ }
137
+ /**
138
+ * Map tên tool → phase của breadcrumb (plan→explore→edit→build→verify).
139
+ * Trả về null với tool không thuộc phase nào (mcp__*...).
140
+ */
141
+ export function toolPhase(name) {
142
+ if (name === "write_todos")
143
+ return "plan";
144
+ if (name === "list_files" || name === "read_file" || name === "grep" || name === "glob" || name === "web_fetch" || name === "load_skill" || name === "repo_map")
145
+ return "explore";
146
+ if (name === "write_file" || name === "edit_file" || name === "apply_patch")
147
+ return "edit";
148
+ if (name === "run_command")
149
+ return "build";
150
+ if (name === "task")
151
+ return "delegate";
152
+ if (name === "ask_user")
153
+ return "clear";
154
+ if (name.startsWith("mcp__"))
155
+ return "mcp";
156
+ return null;
157
+ }
@@ -9,8 +9,9 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
9
9
  */
10
10
  import { useEffect, useState } from "react";
11
11
  import { Box, Text, useInput } from "ink";
12
- import { listSessions } from "../session.js";
12
+ import { listSessions } from "../session/session.js";
13
13
  import { theme, fit } from "./theme.js";
14
+ import { boxBorderStyle } from "./charset.js";
14
15
  const DISPLAY_LIMIT = 12;
15
16
  export function SessionPicker({ workdir, onSelect, onClose }) {
16
17
  const [sessions, setSessions] = useState([]);
@@ -57,9 +58,9 @@ export function SessionPicker({ workdir, onSelect, onClose }) {
57
58
  }
58
59
  });
59
60
  if (sessions.length === 0) {
60
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: theme.brand, paddingX: 1, paddingY: 1, children: [_jsx(Text, { color: theme.brand, bold: true, children: "\u2190 saved sessions" }), _jsx(Text, { dimColor: true, children: "no saved sessions found" }), _jsx(Text, { dimColor: true, children: "esc to close" })] }));
61
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: boxBorderStyle(), borderColor: theme.brand, paddingX: 1, paddingY: 1, children: [_jsx(Text, { color: theme.brand, bold: true, children: "\u2190 saved sessions" }), _jsx(Text, { dimColor: true, children: "no saved sessions found" }), _jsx(Text, { dimColor: true, children: "esc to close" })] }));
61
62
  }
62
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: theme.brand, paddingX: 1, paddingY: 1, children: [_jsxs(Box, { justifyContent: "space-between", marginBottom: 1, children: [_jsx(Text, { color: theme.brand, bold: true, children: "\u2190 saved sessions" }), _jsxs(Text, { dimColor: true, children: [sessions.length, " session", sessions.length !== 1 ? "s" : ""] })] }), _jsx(Box, { children: _jsx(Text, { dimColor: true, children: "─".repeat(38) }) }), sessions.map((s, i) => {
63
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: boxBorderStyle(), borderColor: theme.brand, paddingX: 1, paddingY: 1, children: [_jsxs(Box, { justifyContent: "space-between", marginBottom: 1, children: [_jsx(Text, { color: theme.brand, bold: true, children: "\u2190 saved sessions" }), _jsxs(Text, { dimColor: true, children: [sessions.length, " session", sessions.length !== 1 ? "s" : ""] })] }), _jsx(Box, { children: _jsx(Text, { dimColor: true, children: "─".repeat(38) }) }), sessions.map((s, i) => {
63
64
  const isSelected = i === selected;
64
65
  const idPart = s.id.slice(0, 14);
65
66
  const dateStr = s.updatedAt ? s.updatedAt.slice(0, 16).replace("T", " ") : "—";