mocode-ai 0.1.4 → 0.1.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.
- package/README.md +196 -188
- package/dist/agent/core.js +309 -0
- package/dist/agent/index.js +62 -249
- package/dist/agent/mode.js +46 -0
- package/dist/agent/spawn.js +123 -0
- package/dist/commands/config.js +8 -26
- package/dist/config/file.js +48 -0
- package/dist/config/index.js +81 -36
- package/dist/context/classifier.js +83 -0
- package/dist/context/encoders/index.js +10 -0
- package/dist/context/encoders/passthrough.js +23 -0
- package/dist/context/index.js +10 -0
- package/dist/context/pipeline.js +84 -0
- package/dist/context/registry.js +30 -0
- package/dist/context/types.js +12 -0
- package/dist/index.js +12 -2
- package/dist/llm/index.js +11 -9
- package/dist/memory/index.js +2 -2
- package/dist/memory/reflect.js +11 -11
- package/dist/memory/store.js +4 -4
- package/dist/repl/index.js +269 -109
- package/dist/sandbox/command.js +41 -0
- package/dist/sandbox/index.js +5 -0
- package/dist/sandbox/jail.js +75 -0
- package/dist/sandbox/policy.js +58 -0
- package/dist/sandbox/root.js +19 -0
- package/dist/session/compact.js +1 -1
- package/dist/skills/index.js +2 -2
- package/dist/tools/builtins/ask-human.js +7 -7
- package/dist/tools/builtins/codegraph.js +113 -0
- package/dist/tools/builtins/edit-file.js +3 -3
- package/dist/tools/builtins/glob.js +10 -5
- package/dist/tools/builtins/grep.js +14 -9
- package/dist/tools/builtins/index.js +7 -1
- package/dist/tools/builtins/memory-forget.js +2 -2
- package/dist/tools/builtins/memory-list.js +2 -2
- package/dist/tools/builtins/memory-save.js +7 -7
- package/dist/tools/builtins/memory-search.js +4 -4
- package/dist/tools/builtins/memory-update.js +6 -6
- package/dist/tools/builtins/read-file.js +5 -4
- package/dist/tools/builtins/run-command.js +47 -8
- package/dist/tools/builtins/switch-mode.js +46 -0
- package/dist/tools/builtins/task.js +62 -0
- package/dist/tools/builtins/use-skill.js +2 -2
- package/dist/tools/builtins/web-fetch.js +16 -3
- package/dist/tools/builtins/web-search.js +6 -6
- package/dist/tools/builtins/write-file.js +3 -3
- package/dist/tools/constants.js +16 -0
- package/dist/tools/registry.js +18 -4
- package/dist/ui/content.js +25 -17
- package/dist/ui/diff.js +28 -26
- package/dist/ui/intervention.js +24 -3
- package/dist/ui/layout.js +295 -81
- package/dist/ui/markdown.js +607 -0
- package/dist/ui/mouse.js +93 -0
- package/dist/ui/prompt.js +259 -5
- package/dist/ui/render.js +1 -1
- package/dist/ui/theme.js +146 -13
- package/package.json +2 -2
|
@@ -0,0 +1,607 @@
|
|
|
1
|
+
// agent 正文 markdown → ANSI 物理行渲染(纯函数,legacy TUI 用)。
|
|
2
|
+
//
|
|
3
|
+
// agent 的 onText 流式 chunk 经 layout.contentWriteMd 累积成整段 text,再由此渲染成
|
|
4
|
+
// 带色物理行(每行 ≤ cols、自洽带色)写进 content.ts 缓冲。流式安全:每 chunk 重新渲染
|
|
5
|
+
// 整段(text 可能停在未闭合 ``` fence 中段,状态机扫到 EOF 仍 inFence 时把已累积代码行
|
|
6
|
+
// 当「进行中代码块」照常 emit,边生成边显)。
|
|
7
|
+
//
|
|
8
|
+
// 样式:代码块 Flat(Claude Code 风)——语言标签 dim 置顶 + 2 空格 gutter + cli-highlight
|
|
9
|
+
// 语法高亮 + 软折行,无边框。其余:标题/列表(嵌套)/行内代码/粗体/斜体/删除线/链接/引用块/分隔线。
|
|
10
|
+
//
|
|
11
|
+
// 硬约束:每行 ansiDisplayWidth ≤ cols(layout.repaintViewport 直出行,超宽会让终端 auto-wrap
|
|
12
|
+
// 错位;若上层 <Text wrap=truncate> 补 … U+2026 在中文终端算 2 宽会顶屏)。所有路径软折行 +
|
|
13
|
+
// clipAnsiLine 兜底,绝不溢出。颜色一律嵌入式 ANSI(ui.*)写进字符串。
|
|
14
|
+
import { highlight, supportsLanguage } from 'cli-highlight';
|
|
15
|
+
import { ui, getThemeVersion } from './theme.js';
|
|
16
|
+
import { charWidth, displayWidth, ansiDisplayWidth } from './render.js';
|
|
17
|
+
const RESET = '\x1b[0m';
|
|
18
|
+
// theme 无 italic/strike,自备(TTY 感知:!isTTY 退化为空串,同 ui.* 契约)。
|
|
19
|
+
const ITALIC = ui.isTTY ? '\x1b[3m' : '';
|
|
20
|
+
const STRIKE = ui.isTTY ? '\x1b[9m' : '';
|
|
21
|
+
/**
|
|
22
|
+
* ANSI 感知的硬切(**不补 …**):保留串内 SGR 码,按可见宽度截到 ≤ width,末尾补 reset
|
|
23
|
+
* 防断色。用于 markdown 代码行 / 超长单行带色块——绝不能补 …('…' 在中文终端算 2 宽会
|
|
24
|
+
* 触发 auto-wrap 整屏左移)。宽度未超直接原样返回。
|
|
25
|
+
*/
|
|
26
|
+
function clipAnsiLine(str, width) {
|
|
27
|
+
if (ansiDisplayWidth(str) <= width)
|
|
28
|
+
return str;
|
|
29
|
+
let w = 0;
|
|
30
|
+
let out = '';
|
|
31
|
+
const parts = str.split(/(\x1b\[[0-9;]*m)/);
|
|
32
|
+
for (let i = 0; i < parts.length; i++) {
|
|
33
|
+
if (i % 2 === 1) {
|
|
34
|
+
out += parts[i];
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
for (const ch of parts[i]) {
|
|
38
|
+
const cw = charWidth(ch.codePointAt(0) ?? 0);
|
|
39
|
+
if (cw > 0 && w + cw > width) {
|
|
40
|
+
return out + RESET;
|
|
41
|
+
}
|
|
42
|
+
if (cw > 0)
|
|
43
|
+
w += cw;
|
|
44
|
+
out += ch;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
void clipAnsiLine; // 供本模块内部用;亦在下方 export 给 layout/content 复用
|
|
50
|
+
// ── 内联 markdown → Seg[] ──
|
|
51
|
+
/** 找从 from 起、连续 ch 长度 ≥ run 的运行起点;无则 -1。用于配对 `code` 反引号运行。 */
|
|
52
|
+
function findRun(text, ch, from, run) {
|
|
53
|
+
let i = from;
|
|
54
|
+
while (i < text.length) {
|
|
55
|
+
if (text[i] === ch) {
|
|
56
|
+
let c = 0;
|
|
57
|
+
while (i + c < text.length && text[i + c] === ch)
|
|
58
|
+
c++;
|
|
59
|
+
if (c >= run)
|
|
60
|
+
return i;
|
|
61
|
+
i += c;
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
i++;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return -1;
|
|
68
|
+
}
|
|
69
|
+
/** 尝试在 from(`[`)处匹配 [text](url);成功返 {text, end=')' 后下标},否则 null。 */
|
|
70
|
+
function matchLink(text, from) {
|
|
71
|
+
let i = from + 1;
|
|
72
|
+
while (i < text.length && text[i] !== ']')
|
|
73
|
+
i++;
|
|
74
|
+
if (i >= text.length)
|
|
75
|
+
return null;
|
|
76
|
+
const linkText = text.slice(from + 1, i);
|
|
77
|
+
i++; // 跳过 ]
|
|
78
|
+
if (text[i] !== '(')
|
|
79
|
+
return null;
|
|
80
|
+
i++;
|
|
81
|
+
while (i < text.length && text[i] !== ')')
|
|
82
|
+
i++;
|
|
83
|
+
if (i >= text.length)
|
|
84
|
+
return null;
|
|
85
|
+
return { text: linkText, end: i + 1 };
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* 内联 markdown → Seg[](有序 token 流)。支持:行内 code、bold、italic、strike、link。
|
|
89
|
+
* _italic_ 带词边界 flank 检查(避免 snake_case 被斜体)。emphasis 递归一层(粗体内可含
|
|
90
|
+
* 斜体/代码);code/link 内部当字面量(不再递归)。未配对的标记按字面量透传;反斜杠转义下一字符。
|
|
91
|
+
*/
|
|
92
|
+
function renderInline(text) {
|
|
93
|
+
const segs = [];
|
|
94
|
+
let buf = '';
|
|
95
|
+
const flush = () => {
|
|
96
|
+
if (buf) {
|
|
97
|
+
segs.push({ sgr: '', text: buf });
|
|
98
|
+
buf = '';
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
let i = 0;
|
|
102
|
+
const n = text.length;
|
|
103
|
+
while (i < n) {
|
|
104
|
+
const ch = text[i];
|
|
105
|
+
// 转义
|
|
106
|
+
if (ch === '\\' && i + 1 < n) {
|
|
107
|
+
buf += text[i + 1];
|
|
108
|
+
i += 2;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
// 行内代码:`+ 运行,配对同长度运行
|
|
112
|
+
if (ch === '`') {
|
|
113
|
+
let run = 0;
|
|
114
|
+
while (i + run < n && text[i + run] === '`')
|
|
115
|
+
run++;
|
|
116
|
+
const close = findRun(text, '`', i + run, run);
|
|
117
|
+
if (close >= 0) {
|
|
118
|
+
flush();
|
|
119
|
+
segs.push({ sgr: ui.yellow, text: text.slice(i + run, close) });
|
|
120
|
+
i = close + run;
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
buf += ch;
|
|
124
|
+
i++;
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
// 链接 [text](url)
|
|
128
|
+
if (ch === '[') {
|
|
129
|
+
const m = matchLink(text, i);
|
|
130
|
+
if (m) {
|
|
131
|
+
flush();
|
|
132
|
+
segs.push({ sgr: ui.cyan, text: m.text });
|
|
133
|
+
i = m.end;
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
buf += ch;
|
|
137
|
+
i++;
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
// ~~strike~~
|
|
141
|
+
if (ch === '~' && text[i + 1] === '~') {
|
|
142
|
+
const close = text.indexOf('~~', i + 2);
|
|
143
|
+
if (close >= 0) {
|
|
144
|
+
flush();
|
|
145
|
+
for (const s of renderInline(text.slice(i + 2, close))) {
|
|
146
|
+
segs.push({ sgr: STRIKE + s.sgr, text: s.text });
|
|
147
|
+
}
|
|
148
|
+
i = close + 2;
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
buf += '~~';
|
|
152
|
+
i += 2;
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
// **bold** / __bold__
|
|
156
|
+
if ((ch === '*' || ch === '_') && text[i + 1] === ch) {
|
|
157
|
+
const marker = ch + ch;
|
|
158
|
+
const close = text.indexOf(marker, i + 2);
|
|
159
|
+
if (close >= 0) {
|
|
160
|
+
flush();
|
|
161
|
+
for (const s of renderInline(text.slice(i + 2, close))) {
|
|
162
|
+
segs.push({ sgr: ui.bold + s.sgr, text: s.text });
|
|
163
|
+
}
|
|
164
|
+
i = close + 2;
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
buf += marker;
|
|
168
|
+
i += 2;
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
// *italic*(单星号)
|
|
172
|
+
if (ch === '*') {
|
|
173
|
+
const close = text.indexOf('*', i + 1);
|
|
174
|
+
if (close > i + 1) {
|
|
175
|
+
flush();
|
|
176
|
+
for (const s of renderInline(text.slice(i + 1, close))) {
|
|
177
|
+
segs.push({ sgr: ITALIC + s.sgr, text: s.text });
|
|
178
|
+
}
|
|
179
|
+
i = close + 1;
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
buf += ch;
|
|
183
|
+
i++;
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
// _italic_(带 flank:开 _ 前须非词字符;闭 _ 后须非词字符 → snake_case_var 不被斜体)
|
|
187
|
+
if (ch === '_') {
|
|
188
|
+
const prevIsWord = i > 0 && /\w/.test(text[i - 1]);
|
|
189
|
+
if (!prevIsWord) {
|
|
190
|
+
let j = i + 1;
|
|
191
|
+
while (j < n) {
|
|
192
|
+
if (text[j] === '_') {
|
|
193
|
+
const next = j + 1 < n ? text[j + 1] : ' ';
|
|
194
|
+
if (!/\w/.test(next))
|
|
195
|
+
break;
|
|
196
|
+
}
|
|
197
|
+
j++;
|
|
198
|
+
}
|
|
199
|
+
if (j < n && j > i + 1) {
|
|
200
|
+
flush();
|
|
201
|
+
for (const s of renderInline(text.slice(i + 1, j))) {
|
|
202
|
+
segs.push({ sgr: ITALIC + s.sgr, text: s.text });
|
|
203
|
+
}
|
|
204
|
+
i = j + 1;
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
buf += ch;
|
|
209
|
+
i++;
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
buf += ch;
|
|
213
|
+
i++;
|
|
214
|
+
}
|
|
215
|
+
flush();
|
|
216
|
+
return segs;
|
|
217
|
+
}
|
|
218
|
+
/** 给每个 seg 的 sgr 前加 prefix(嵌套叠加,如标题 bold+color 套到内联 token 上)。 */
|
|
219
|
+
function withPrefix(segs, prefix) {
|
|
220
|
+
return segs.map((s) => ({ sgr: prefix + s.sgr, text: s.text }));
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Seg[] → 自洽 ANSI 串(每个 seg 前 reset+开 sgr,行末 reset):样式不跨 seg 泄漏,
|
|
224
|
+
* 交给 wrapAnsiString 折行时由 activeSgr 跨断行重开。
|
|
225
|
+
*/
|
|
226
|
+
function segsToAnsi(segs) {
|
|
227
|
+
let out = '';
|
|
228
|
+
for (const s of segs)
|
|
229
|
+
out += RESET + s.sgr + s.text;
|
|
230
|
+
return out + RESET;
|
|
231
|
+
}
|
|
232
|
+
// ── ANSI 感知软折行(支持嵌入 SGR + \n 硬断行 + 跨断行 SGR 续带)──
|
|
233
|
+
/**
|
|
234
|
+
* 把含 SGR / \n 的串按可见宽度软折成多行(每行可见宽 ≤ width)。等价 wrap-ansi 语义,零新依赖。
|
|
235
|
+
* - SGR 码 `\x1b[…m` 0 宽原样保留;`\x1b[0m`(reset)清 activeSgr,其余 SGR 累加到 activeSgr。
|
|
236
|
+
* - \n 为硬断行:flush 当前行(末尾补 reset),保留 activeSgr → 下一行重开同款样式
|
|
237
|
+
* (cli-highlight 多行串/块注释跨行着色不丢)。
|
|
238
|
+
* - 软折行断点:可见字符累加超 width → 当前行补 reset 收尾、新行首重发 activeSgr + 该字符。
|
|
239
|
+
* - 宽字符(CJK=2)在剩余放不下时整字折下行(同 wrapByDisplayWidth);零宽符(组合符)不计宽度。
|
|
240
|
+
* - 每行末尾补 reset(各自直出不串色);空行(源 \n\n 间)返 ''。
|
|
241
|
+
*/
|
|
242
|
+
function wrapAnsiString(str, width) {
|
|
243
|
+
const w = Math.max(1, width);
|
|
244
|
+
const lines = [];
|
|
245
|
+
let cur = '';
|
|
246
|
+
let curW = 0;
|
|
247
|
+
let activeSgr = '';
|
|
248
|
+
const tokens = str.split(/(\x1b\[[0-9;]*m|\n)/);
|
|
249
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
250
|
+
const tok = tokens[i];
|
|
251
|
+
if (i % 2 === 1) {
|
|
252
|
+
if (tok === '\n') {
|
|
253
|
+
lines.push(cur === '' ? '' : cur + RESET);
|
|
254
|
+
cur = '';
|
|
255
|
+
curW = 0;
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
cur += tok;
|
|
259
|
+
if (tok === RESET)
|
|
260
|
+
activeSgr = '';
|
|
261
|
+
else
|
|
262
|
+
activeSgr += tok;
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
for (const ch of tok) {
|
|
266
|
+
const cw = charWidth(ch.codePointAt(0) ?? 0);
|
|
267
|
+
if (cw <= 0) {
|
|
268
|
+
cur += ch;
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
if (curW + cw > w) {
|
|
272
|
+
lines.push(cur + RESET);
|
|
273
|
+
cur = activeSgr + ch;
|
|
274
|
+
curW = cw;
|
|
275
|
+
}
|
|
276
|
+
else {
|
|
277
|
+
cur += ch;
|
|
278
|
+
curW += cw;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
if (cur !== '' || lines.length === 0) {
|
|
283
|
+
lines.push(cur === '' ? '' : cur.endsWith(RESET) ? cur : cur + RESET);
|
|
284
|
+
}
|
|
285
|
+
return lines;
|
|
286
|
+
}
|
|
287
|
+
/** 折行后每行套前缀(首行 prefix、续行 contPrefix 对齐),再 clipAnsiLine 兜底 ≤ cols。 */
|
|
288
|
+
function wrapWithPrefix(segs, prefix, contPrefix, cols) {
|
|
289
|
+
const w = Math.max(1, cols - ansiDisplayWidth(prefix));
|
|
290
|
+
const wrapped = wrapAnsiString(segsToAnsi(segs), w);
|
|
291
|
+
return wrapped.map((l, idx) => clipAnsiLine((idx === 0 ? prefix : contPrefix) + l, cols));
|
|
292
|
+
}
|
|
293
|
+
// ── 代码块语法高亮 ──
|
|
294
|
+
/** 整块高亮(cli-highlight 支持多行构造);未知 lang / 抛错 → 回退原文。 */
|
|
295
|
+
function highlightCodeBlock(code, lang) {
|
|
296
|
+
if (!lang)
|
|
297
|
+
return code;
|
|
298
|
+
try {
|
|
299
|
+
if (supportsLanguage(lang)) {
|
|
300
|
+
return highlight(code, { language: lang, ignoreIllegals: true });
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
catch {
|
|
304
|
+
// 未知 lang 或高亮异常:回退原文(渲染层再加 gutter)
|
|
305
|
+
}
|
|
306
|
+
return code;
|
|
307
|
+
}
|
|
308
|
+
/** 拆表格行成单元格:去首尾 | 后按 | split,每 cell trim。 */
|
|
309
|
+
function parseTableRow(line) {
|
|
310
|
+
let s = line.trim();
|
|
311
|
+
if (s.startsWith('|'))
|
|
312
|
+
s = s.slice(1);
|
|
313
|
+
if (s.endsWith('|'))
|
|
314
|
+
s = s.slice(0, -1);
|
|
315
|
+
return s.split('|').map((c) => c.trim());
|
|
316
|
+
}
|
|
317
|
+
/** 分隔行每 cell :?-+:? → 对齐(left 默认)。 */
|
|
318
|
+
function parseAlign(sep) {
|
|
319
|
+
return sep.map((c) => {
|
|
320
|
+
const t = c.trim();
|
|
321
|
+
const l = t.startsWith(':');
|
|
322
|
+
const r = t.endsWith(':');
|
|
323
|
+
return l && r ? 'center' : r ? 'right' : 'left';
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
/** ANSI 感知按可见宽度 pad(左/右/中),补空格;不截断(超宽原样返回,由 clipAnsiLine 兜底)。 */
|
|
327
|
+
function padAnsi(str, width, align) {
|
|
328
|
+
const w = ansiDisplayWidth(str);
|
|
329
|
+
if (w >= width)
|
|
330
|
+
return str;
|
|
331
|
+
const lack = width - w;
|
|
332
|
+
if (align === 'right')
|
|
333
|
+
return ' '.repeat(lack) + str;
|
|
334
|
+
if (align === 'center') {
|
|
335
|
+
const l = Math.floor(lack / 2);
|
|
336
|
+
return ' '.repeat(l) + str + ' '.repeat(lack - l);
|
|
337
|
+
}
|
|
338
|
+
return str + ' '.repeat(lack);
|
|
339
|
+
}
|
|
340
|
+
/** 渲染 cell 为 ANSI 串:表头套 dim+bold,数据行裸(内联 markdown 照常)。 */
|
|
341
|
+
function renderCell(text, isHeader) {
|
|
342
|
+
const segs = isHeader ? withPrefix(renderInline(text), ui.dim + ui.bold) : renderInline(text);
|
|
343
|
+
return segsToAnsi(segs);
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* GFM 表格 → ANSI 物理行(│ 边框 + 表头下 ├─┼─┤ 分隔;每行 ≤ cols,clipAnsiLine 兜底)。
|
|
347
|
+
* 自然总宽 ≤ cols:单行模式,各列按自然宽。超 cols:每列等分上限 colMax,cell 软折多行,
|
|
348
|
+
* 行数取各列最大,缺 cell 行补空。对齐按分隔行(: 左 / : 右 / : : 中)。
|
|
349
|
+
*/
|
|
350
|
+
function renderTable(header, aligns, rows, cols) {
|
|
351
|
+
const n = header.length;
|
|
352
|
+
const align = header.map((_, i) => aligns[i] ?? 'left');
|
|
353
|
+
const hCells = header.map((c) => renderCell(c, true));
|
|
354
|
+
const dCells = rows.map((r) => header.map((_, i) => renderCell(r[i] ?? '', false)));
|
|
355
|
+
const natW = header.map((_, i) => {
|
|
356
|
+
let w = ansiDisplayWidth(hCells[i]);
|
|
357
|
+
for (const r of dCells)
|
|
358
|
+
w = Math.max(w, ansiDisplayWidth(r[i]));
|
|
359
|
+
return w;
|
|
360
|
+
});
|
|
361
|
+
const borders = n + 1; // │ 数
|
|
362
|
+
const padding = 2 * n; // 每 cell 前后 1 空格
|
|
363
|
+
const totalNat = natW.reduce((a, b) => a + b, 0) + borders + padding;
|
|
364
|
+
const out = [];
|
|
365
|
+
const rowLine = (cells, widths) => clipAnsiLine(`${ui.dim}│${ui.reset}${cells
|
|
366
|
+
.map((c, i) => ' ' + padAnsi(c, widths[i], align[i]) + ' ')
|
|
367
|
+
.join(`${ui.dim}│${ui.reset}`)}${ui.dim}│${ui.reset}`, cols);
|
|
368
|
+
// 田字格横线:顶 ┌─┬─┐ / 行间(含表头下) ├─┼─┤ / 底 └─┴─┘ —— 每 cell 四周皆边框
|
|
369
|
+
const topLine = (widths) => clipAnsiLine(`${ui.dim}┌${widths.map((w) => '─'.repeat(w + 2)).join('┬')}┐${ui.reset}`, cols);
|
|
370
|
+
const midLine = (widths) => clipAnsiLine(`${ui.dim}├${widths.map((w) => '─'.repeat(w + 2)).join('┼')}┤${ui.reset}`, cols);
|
|
371
|
+
const botLine = (widths) => clipAnsiLine(`${ui.dim}└${widths.map((w) => '─'.repeat(w + 2)).join('┴')}┘${ui.reset}`, cols);
|
|
372
|
+
// cell block:wrap=true 按各列宽软折多行(行数取最大,缺补空);false 直接 pad 单行
|
|
373
|
+
const emitBlock = (cells, widths, wrap) => {
|
|
374
|
+
const wrapped = wrap ? cells.map((c, i) => wrapAnsiString(c, widths[i])) : cells.map((c) => [c]);
|
|
375
|
+
const lines = Math.max(1, ...wrapped.map((w) => w.length));
|
|
376
|
+
for (let k = 0; k < lines; k++) {
|
|
377
|
+
out.push(rowLine(wrapped.map((w) => w[k] ?? ''), widths));
|
|
378
|
+
}
|
|
379
|
+
};
|
|
380
|
+
// 顶 → 表头 → (行间 + 数据行)* → 底;无数据行时 top+header+bot
|
|
381
|
+
if (totalNat <= cols) {
|
|
382
|
+
out.push(topLine(natW));
|
|
383
|
+
emitBlock(hCells, natW, false);
|
|
384
|
+
for (const r of dCells) {
|
|
385
|
+
out.push(midLine(natW));
|
|
386
|
+
emitBlock(r, natW, false);
|
|
387
|
+
}
|
|
388
|
+
out.push(botLine(natW));
|
|
389
|
+
}
|
|
390
|
+
else {
|
|
391
|
+
const colMax = Math.max(1, Math.floor((cols - borders - padding) / n));
|
|
392
|
+
const widths = new Array(n).fill(colMax);
|
|
393
|
+
out.push(topLine(widths));
|
|
394
|
+
emitBlock(hCells, widths, true);
|
|
395
|
+
for (const r of dCells) {
|
|
396
|
+
out.push(midLine(widths));
|
|
397
|
+
emitBlock(r, widths, true);
|
|
398
|
+
}
|
|
399
|
+
out.push(botLine(widths));
|
|
400
|
+
}
|
|
401
|
+
return out;
|
|
402
|
+
}
|
|
403
|
+
// ── 主渲染 ──
|
|
404
|
+
const MEMO = new Map();
|
|
405
|
+
const MEMO_CAP = 16;
|
|
406
|
+
function renderMarkdownImpl(text, cols) {
|
|
407
|
+
const out = [];
|
|
408
|
+
const src = text.split('\n');
|
|
409
|
+
let i = 0;
|
|
410
|
+
let inFence = false;
|
|
411
|
+
let fenceChar = '';
|
|
412
|
+
let lang = '';
|
|
413
|
+
let codeBuf = [];
|
|
414
|
+
let para = [];
|
|
415
|
+
let prevBlank = false;
|
|
416
|
+
const flushPara = () => {
|
|
417
|
+
if (!para.length)
|
|
418
|
+
return;
|
|
419
|
+
const segs = renderInline(para.join(' '));
|
|
420
|
+
const wrapped = wrapAnsiString(segsToAnsi(segs), cols);
|
|
421
|
+
for (const l of wrapped)
|
|
422
|
+
out.push(clipAnsiLine(l, cols));
|
|
423
|
+
out.push('');
|
|
424
|
+
para = [];
|
|
425
|
+
prevBlank = true;
|
|
426
|
+
};
|
|
427
|
+
const flushCode = () => {
|
|
428
|
+
if (lang)
|
|
429
|
+
out.push(` ${ui.dim}${lang}${ui.reset}`);
|
|
430
|
+
const highlighted = highlightCodeBlock(codeBuf.join('\n'), lang);
|
|
431
|
+
const codeW = Math.max(1, cols - 2);
|
|
432
|
+
for (const l of wrapAnsiString(highlighted, codeW)) {
|
|
433
|
+
out.push(clipAnsiLine(` ${l}`, cols));
|
|
434
|
+
}
|
|
435
|
+
out.push('');
|
|
436
|
+
inFence = false;
|
|
437
|
+
fenceChar = '';
|
|
438
|
+
lang = '';
|
|
439
|
+
codeBuf = [];
|
|
440
|
+
prevBlank = true;
|
|
441
|
+
};
|
|
442
|
+
while (i < src.length) {
|
|
443
|
+
const line = src[i];
|
|
444
|
+
// fence 探测(``` 或 ~~~,≥3)
|
|
445
|
+
const fm = line.match(/^(\s*)(`{3,}|~{3,})(.*)$/);
|
|
446
|
+
if (fm) {
|
|
447
|
+
if (!inFence) {
|
|
448
|
+
flushPara();
|
|
449
|
+
inFence = true;
|
|
450
|
+
fenceChar = fm[2][0];
|
|
451
|
+
lang = fm[3].trim();
|
|
452
|
+
codeBuf = [];
|
|
453
|
+
}
|
|
454
|
+
else if (fm[2][0] === fenceChar) {
|
|
455
|
+
flushCode();
|
|
456
|
+
}
|
|
457
|
+
else {
|
|
458
|
+
codeBuf.push(line);
|
|
459
|
+
}
|
|
460
|
+
i++;
|
|
461
|
+
continue;
|
|
462
|
+
}
|
|
463
|
+
if (inFence) {
|
|
464
|
+
codeBuf.push(line);
|
|
465
|
+
i++;
|
|
466
|
+
continue;
|
|
467
|
+
}
|
|
468
|
+
// 空行(折叠连续空行)
|
|
469
|
+
if (line.trim() === '') {
|
|
470
|
+
flushPara();
|
|
471
|
+
if (!prevBlank)
|
|
472
|
+
out.push('');
|
|
473
|
+
prevBlank = true;
|
|
474
|
+
i++;
|
|
475
|
+
continue;
|
|
476
|
+
}
|
|
477
|
+
// 水平分隔线:--- / *** / ___ / - - -
|
|
478
|
+
if (/^\s*([-*_])(\s*\1){2,}\s*$/.test(line)) {
|
|
479
|
+
flushPara();
|
|
480
|
+
out.push(`${ui.dim}${'─'.repeat(cols)}${ui.reset}`);
|
|
481
|
+
out.push('');
|
|
482
|
+
prevBlank = true;
|
|
483
|
+
i++;
|
|
484
|
+
continue;
|
|
485
|
+
}
|
|
486
|
+
// 标题 #(1-6)
|
|
487
|
+
const hm = line.match(/^(#{1,6})\s+(.*)$/);
|
|
488
|
+
if (hm) {
|
|
489
|
+
flushPara();
|
|
490
|
+
const level = hm[1].length;
|
|
491
|
+
const color = level <= 1 ? ui.brightCyan : level === 2 ? ui.cyan : ui.gray;
|
|
492
|
+
const segs = withPrefix(renderInline(hm[2]), ui.bold + color);
|
|
493
|
+
for (const l of wrapAnsiString(segsToAnsi(segs), cols)) {
|
|
494
|
+
out.push(clipAnsiLine(l, cols));
|
|
495
|
+
}
|
|
496
|
+
out.push('');
|
|
497
|
+
prevBlank = true;
|
|
498
|
+
i++;
|
|
499
|
+
continue;
|
|
500
|
+
}
|
|
501
|
+
// 引用块 >
|
|
502
|
+
const bm = line.match(/^>\s?(.*)$/);
|
|
503
|
+
if (bm) {
|
|
504
|
+
flushPara();
|
|
505
|
+
const prefix = ` ${ui.dim}│${ui.reset} `;
|
|
506
|
+
const cont = ' '.repeat(ansiDisplayWidth(prefix));
|
|
507
|
+
for (const l of wrapWithPrefix(renderInline(bm[1]), prefix, cont, cols)) {
|
|
508
|
+
out.push(l);
|
|
509
|
+
}
|
|
510
|
+
prevBlank = false;
|
|
511
|
+
i++;
|
|
512
|
+
continue;
|
|
513
|
+
}
|
|
514
|
+
// 无序列表 - * +
|
|
515
|
+
const um = line.match(/^(\s*)([-*+])\s+(.*)$/);
|
|
516
|
+
if (um) {
|
|
517
|
+
flushPara();
|
|
518
|
+
const indent = displayWidth(um[1]);
|
|
519
|
+
const bullet = indent >= 2 ? '◦' : '•';
|
|
520
|
+
const marker = `${' '.repeat(indent)}${bullet} `;
|
|
521
|
+
const cont = ' '.repeat(displayWidth(marker));
|
|
522
|
+
for (const l of wrapWithPrefix(renderInline(um[3]), marker, cont, cols)) {
|
|
523
|
+
out.push(l);
|
|
524
|
+
}
|
|
525
|
+
prevBlank = false;
|
|
526
|
+
i++;
|
|
527
|
+
continue;
|
|
528
|
+
}
|
|
529
|
+
// 有序列表 1.
|
|
530
|
+
const om = line.match(/^(\s*)(\d+)\.\s+(.*)$/);
|
|
531
|
+
if (om) {
|
|
532
|
+
flushPara();
|
|
533
|
+
const indent = displayWidth(om[1]);
|
|
534
|
+
const marker = `${' '.repeat(indent)}${om[2]}. `;
|
|
535
|
+
const cont = ' '.repeat(displayWidth(marker));
|
|
536
|
+
for (const l of wrapWithPrefix(renderInline(om[3]), marker, cont, cols)) {
|
|
537
|
+
out.push(l);
|
|
538
|
+
}
|
|
539
|
+
prevBlank = false;
|
|
540
|
+
i++;
|
|
541
|
+
continue;
|
|
542
|
+
}
|
|
543
|
+
// GFM 表格:表头(含 |,≥2 cell)+ 紧跟 |---| 分隔行 → 收齐整块渲染(详见 renderTable)
|
|
544
|
+
if (line.includes('|')) {
|
|
545
|
+
const headerCells = parseTableRow(line);
|
|
546
|
+
if (headerCells.length >= 2 && i + 1 < src.length) {
|
|
547
|
+
const sepCells = parseTableRow(src[i + 1]);
|
|
548
|
+
if (sepCells.length === headerCells.length &&
|
|
549
|
+
sepCells.every((c) => /^:?-+:?$/.test(c.trim()))) {
|
|
550
|
+
flushPara();
|
|
551
|
+
const aligns = parseAlign(sepCells);
|
|
552
|
+
i += 2;
|
|
553
|
+
const trows = [];
|
|
554
|
+
while (i < src.length) {
|
|
555
|
+
const r = src[i];
|
|
556
|
+
if (r.trim() === '' || !r.includes('|'))
|
|
557
|
+
break;
|
|
558
|
+
trows.push(parseTableRow(r));
|
|
559
|
+
i++;
|
|
560
|
+
}
|
|
561
|
+
for (const l of renderTable(headerCells, aligns, trows, cols))
|
|
562
|
+
out.push(l);
|
|
563
|
+
out.push('');
|
|
564
|
+
prevBlank = true;
|
|
565
|
+
continue;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
// 段落:累积(连续非空非特殊行 join ' ' 后渲染)
|
|
570
|
+
para.push(line);
|
|
571
|
+
prevBlank = false;
|
|
572
|
+
i++;
|
|
573
|
+
}
|
|
574
|
+
flushPara();
|
|
575
|
+
if (inFence)
|
|
576
|
+
flushCode(); // EOF 仍 inFence:流式中未闭合 fence → 照常 emit 进行中代码块
|
|
577
|
+
// 末尾不留空行:agent onText 后接 onToolCall 的 contentWrite('\n') 会补 1 空行分隔正文与 ● 行;
|
|
578
|
+
// 若 md 末尾自带空行(段落/代码块后)则叠成 2 空行。裁掉末尾连续空行,让 onToolCall / 轮末
|
|
579
|
+
// contentWrite('\n') 恰好补 1 行(与改造前 raw 文本行为一致)。块间空行(flushPara/flushCode 中段
|
|
580
|
+
// push 的)不受影响——只裁末尾。
|
|
581
|
+
while (out.length > 0 && out[out.length - 1] === '')
|
|
582
|
+
out.pop();
|
|
583
|
+
return out;
|
|
584
|
+
}
|
|
585
|
+
/**
|
|
586
|
+
* 把 markdown 文本渲染成 ANSI 物理行数组(每行 ansiDisplayWidth ≤ cols,自洽带色)。
|
|
587
|
+
* 纯函数 + LRU memo(key=text,值 {cols, lines, themeVersion}):同一 text 同一 cols 同一主题版本直接
|
|
588
|
+
* 命中(MRU 提升),cols 变(resize)或切主题(themeVersion 变)则重算覆写。供 layout.contentWriteMd
|
|
589
|
+
* 每 chunk 调用(memo 使重复渲染命中缓存)。themeVersion 使 setTheme 后全量重绘不命中旧主题缓存行。
|
|
590
|
+
*/
|
|
591
|
+
export function renderMarkdown(text, cols) {
|
|
592
|
+
const hit = MEMO.get(text);
|
|
593
|
+
if (hit && hit.cols === cols && hit.themeVersion === getThemeVersion()) {
|
|
594
|
+
MEMO.delete(text);
|
|
595
|
+
MEMO.set(text, hit); // MRU 提升
|
|
596
|
+
return hit.lines;
|
|
597
|
+
}
|
|
598
|
+
const lines = renderMarkdownImpl(text, cols);
|
|
599
|
+
if (MEMO.has(text))
|
|
600
|
+
MEMO.delete(text);
|
|
601
|
+
else if (MEMO.size >= MEMO_CAP)
|
|
602
|
+
MEMO.delete(MEMO.keys().next().value);
|
|
603
|
+
MEMO.set(text, { cols, lines, themeVersion: getThemeVersion() });
|
|
604
|
+
return lines;
|
|
605
|
+
}
|
|
606
|
+
// 供 check 脚本 / 单元验证用(不进 layout 运行时路径)。
|
|
607
|
+
export const __test = { clipAnsiLine, renderInline, wrapAnsiString, segsToAnsi };
|
package/dist/ui/mouse.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// SGR 鼠标报表重组器(叶子,仅正则,无依赖)。
|
|
2
|
+
//
|
|
3
|
+
// 背景:layout 进 alt 屏时发 \x1B[?1000h + \x1B[?1006h 启 SGR 鼠标追踪,终端把滚轮/点击发为
|
|
4
|
+
// \x1B[<button;col;rowM(按下/滚轮/拖动)或 m(释放)。但 Node readline 的 emitKeypressEvents
|
|
5
|
+
// 不认 `<` 为 CSI 参数字节,把一条报表拆成 `\x1b[<` + 逐字符 共 9 个 keypress(数字/`;`/`M` 有
|
|
6
|
+
// 可打印 name,会被当文本砸进输入框)。本模块在 keypress 层重组:每个 keypress 的 key.sequence
|
|
7
|
+
// 喂入 consumeMouse,内部状态机把 fragment 拼回完整报表,期间所有 fragment 返回 suppress=true
|
|
8
|
+
// 让调用方吞掉,完整报表解出滚轮方向后返回 wheel(±1)由调用方调 layout.scrollBy(wheel*5)。
|
|
9
|
+
//
|
|
10
|
+
// 触发可靠:首 fragment `\x1b[<` 是 SGR 鼠标独有——真 Esc 的 key.name==='escape'、sequence==='\x1b';
|
|
11
|
+
// 人按 Esc 再按 < 得 `\x1b<`(meta-<),不进 CSI 分支。故 `\x1b[<` 单 keypress 只能是 SGR 报表开头。
|
|
12
|
+
// 后续 fragment 同步背靠背到达(终端一次发整条报表,readline 在同一 onData chunk 内同步抛完)。
|
|
13
|
+
/** 单条完整报表(非 g:用于 test,不推进 lastIndex);捕获 button。 */
|
|
14
|
+
const REPORT_RE = /\x1b\[<(\d+);\d+;\d+[Mm]/;
|
|
15
|
+
/** 全局报表(用于 matchAll 取所有完整报表,取末条)。 */
|
|
16
|
+
const REPORT_RE_G = /\x1b\[<(\d+);\d+;\d+[Mm]/g;
|
|
17
|
+
/** 收集中 buf 允许的形状:\x1b[< 后跟 [0-9;] 任意个,末尾可选一个 M/m(未完成或刚完成)。 */
|
|
18
|
+
const PARTIAL_RE = /^\x1b\[<[0-9;]*[Mm]?$/;
|
|
19
|
+
const START = '\x1b[<';
|
|
20
|
+
/** button & 64 为滚轮:64=上(+1)、65=下(-1);其余(点击/拖动/移动/释放)→ 0。 */
|
|
21
|
+
function wheelDir(button) {
|
|
22
|
+
if (!(button & 64))
|
|
23
|
+
return 0;
|
|
24
|
+
return button & 1 ? -1 : +1;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* 纯函数:从可能含 ≥1 条完整报表的串里取末条滚轮方向(+1 上 / -1 下 / 0 无或非滚轮)。
|
|
28
|
+
* 供"整体抛出整条报表"的前向兼容分支与测试用(当前 Node 拆碎,正常路径走 consumeMouse)。
|
|
29
|
+
*/
|
|
30
|
+
export function wheelFromReport(seq) {
|
|
31
|
+
let last = 0;
|
|
32
|
+
for (const m of seq.matchAll(REPORT_RE_G)) {
|
|
33
|
+
last = wheelDir(Number(m[1]));
|
|
34
|
+
}
|
|
35
|
+
return last;
|
|
36
|
+
}
|
|
37
|
+
let collecting = false;
|
|
38
|
+
let buf = '';
|
|
39
|
+
let stallTimer = null;
|
|
40
|
+
function resetState() {
|
|
41
|
+
collecting = false;
|
|
42
|
+
buf = '';
|
|
43
|
+
if (stallTimer) {
|
|
44
|
+
clearTimeout(stallTimer);
|
|
45
|
+
stallTimer = null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/** 复位内部收集状态(退出 alt 屏 / 测试间清污染)。 */
|
|
49
|
+
export function resetMouse() {
|
|
50
|
+
resetState();
|
|
51
|
+
}
|
|
52
|
+
function armStall() {
|
|
53
|
+
if (stallTimer)
|
|
54
|
+
clearTimeout(stallTimer);
|
|
55
|
+
// 100ms 内未收齐视为畸形/丢失,丢半截(终端报表原子发出,跨 chunk >100ms 罕见;有界安全)。
|
|
56
|
+
const t = setTimeout(resetState, 100);
|
|
57
|
+
t.unref();
|
|
58
|
+
stallTimer = t;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* 喂入一个 keypress 的 key.sequence,返回 { suppress, wheel }。
|
|
62
|
+
* - suppress=true:本 keypress 是鼠标 fragment(或完整报表),调用方须 return 吞掉。
|
|
63
|
+
* - wheel≠0:刚重组出一条完整滚轮报表,调用方调 layout.scrollBy(wheel*5)。
|
|
64
|
+
* - suppress=false:非鼠标,放行正常处理。
|
|
65
|
+
*/
|
|
66
|
+
export function consumeMouse(seq) {
|
|
67
|
+
if (!collecting) {
|
|
68
|
+
if (!seq.startsWith(START))
|
|
69
|
+
return { wheel: 0, suppress: false };
|
|
70
|
+
// 前向兼容:若未来 Node 整体抛整条报表(含完整 \x1B[<…M),直接解。
|
|
71
|
+
if (REPORT_RE.test(seq))
|
|
72
|
+
return { wheel: wheelFromReport(seq), suppress: true };
|
|
73
|
+
// 开收:首 fragment(典型 readline 的 `\x1b[<`,或 `\x1b[<64;5;` 半截)。
|
|
74
|
+
collecting = true;
|
|
75
|
+
buf = seq;
|
|
76
|
+
armStall();
|
|
77
|
+
return { wheel: 0, suppress: true };
|
|
78
|
+
}
|
|
79
|
+
// 收集中:追加。
|
|
80
|
+
buf += seq;
|
|
81
|
+
if (REPORT_RE.test(buf)) {
|
|
82
|
+
const wheel = wheelFromReport(buf);
|
|
83
|
+
resetState();
|
|
84
|
+
return { wheel, suppress: true };
|
|
85
|
+
}
|
|
86
|
+
if (PARTIAL_RE.test(buf)) {
|
|
87
|
+
// 合法未完成形状(无 M/m 终止)—— 继续等下一 fragment。
|
|
88
|
+
return { wheel: 0, suppress: true };
|
|
89
|
+
}
|
|
90
|
+
// 偏离合法形状:畸形垃圾(报表跨 chunk 超时/编码异常)。丢半截,吞掉不打扰。
|
|
91
|
+
resetState();
|
|
92
|
+
return { wheel: 0, suppress: true };
|
|
93
|
+
}
|