mocode-ai 1.3.0 → 1.3.2
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 +1 -0
- package/README.zh-CN.md +1 -0
- package/dist/i18n/index.js +60 -6
- package/dist/llm/index.js +26 -0
- package/dist/permissions/index.js +15 -1
- package/dist/repl/index.js +110 -11
- package/dist/session/query-history.js +45 -0
- package/dist/ui/batch.js +62 -11
- package/dist/ui/clipboard.js +24 -6
- package/dist/ui/composer.js +785 -0
- package/dist/ui/fuzzy.js +81 -0
- package/dist/ui/history-picker.js +214 -0
- package/dist/ui/layout.js +80 -8
- package/dist/ui/prompt.js +141 -3
- package/dist/ui/theme.js +7 -18
- package/package.json +1 -1
|
@@ -0,0 +1,785 @@
|
|
|
1
|
+
import { stdin } from 'node:process';
|
|
2
|
+
import { ui } from './theme.js';
|
|
3
|
+
import { displayWidth, visColToCharCol, padEndAnsi, truncateDisplay } from './render.js';
|
|
4
|
+
import * as layout from './layout.js';
|
|
5
|
+
import * as mouse from './mouse.js';
|
|
6
|
+
import { copyToClipboard, readClipboard } from './clipboard.js';
|
|
7
|
+
import { t } from '../i18n/index.js';
|
|
8
|
+
/**
|
|
9
|
+
* 输入面板(TUI 内专属弹窗,Ctrl+G 唤起):记事本式多行编辑体验,**不外调 $EDITOR**。
|
|
10
|
+
*
|
|
11
|
+
* 定位:长 prompt 草稿 / 审查文稿的"安全网"——弹窗里 Enter 就是换行(不再触发发送),
|
|
12
|
+
* 编辑动作全部 TUI 内闭环(光标移动 / 选区 / 复制剪切粘贴 / 撤销重做 / 软换行)。
|
|
13
|
+
* 确认(Ctrl+S)只是把内容**填回输入框**,不自动发送——用户可以再看一遍再发。
|
|
14
|
+
*
|
|
15
|
+
* 渲染:直接 ANSI 写屏,画在 content 区(1..contentBottom)之上;关闭后
|
|
16
|
+
* layout.repaintViewport() 整幅重画还原,内容缓冲不受影响(弹窗不进 content buffer)。
|
|
17
|
+
*
|
|
18
|
+
* 交互约定:
|
|
19
|
+
* - Enter / Ctrl+J 换行(弹窗内永远不发送)
|
|
20
|
+
* - Ctrl+S 确认:内容填回输入框(Ctrl+Enter 同义,终端可区分时)
|
|
21
|
+
* - Esc 取消:丢弃弹窗内改动,输入框保持原样
|
|
22
|
+
* - Ctrl+C / X / V 复制 / 剪切 / 粘贴(有选区时;同步系统剪贴板)
|
|
23
|
+
* - Ctrl+Z / Y 撤销 / 重做;Ctrl+A 全选;Shift+方向键扩选
|
|
24
|
+
* - Ctrl+←/→ 词跳;Ctrl+Backspace/W 词删;Ctrl+U/K 删到行首/行尾
|
|
25
|
+
* - Ctrl+Home/End 文档首尾;PgUp/PgDn 翻页
|
|
26
|
+
* - 鼠标:左键点击定位光标 / 按住拖动选区 / 纯点击清选区(Ctrl+A 后点一下即取消);
|
|
27
|
+
* 右键有选区=复制(并清高亮)、无选区=粘贴;滚轮滚动文本区(可滚离光标翻看)
|
|
28
|
+
*/
|
|
29
|
+
/** 弹窗内边距外扩:左右各留 2 列,顶部从 content 区第 2 行起。 */
|
|
30
|
+
const MARGIN_X = 2;
|
|
31
|
+
const TOP = 2;
|
|
32
|
+
/**
|
|
33
|
+
* 单逻辑行 → 展示行(软换行)。贪心:超宽时优先回退到最近的空格后断行(英文单词不腰斩),
|
|
34
|
+
* 无空格(中文/长 token)按字符断。width 为可见列宽(中文按 2)。
|
|
35
|
+
*/
|
|
36
|
+
export function wrapLogicalLine(text, width) {
|
|
37
|
+
const w = Math.max(1, width);
|
|
38
|
+
const chars = [...text];
|
|
39
|
+
if (chars.length === 0)
|
|
40
|
+
return [{ li: -1, start: 0, text: '' }];
|
|
41
|
+
const rows = [];
|
|
42
|
+
let start = 0;
|
|
43
|
+
while (start < chars.length) {
|
|
44
|
+
let rowW = 0;
|
|
45
|
+
let end = start;
|
|
46
|
+
let breakAfter = -1; // 最近一个空格之后的位置(码点索引,绝对)
|
|
47
|
+
let j = start;
|
|
48
|
+
while (j < chars.length) {
|
|
49
|
+
const cw = displayWidth(chars[j] ?? '');
|
|
50
|
+
if (rowW + cw > w)
|
|
51
|
+
break;
|
|
52
|
+
rowW += cw;
|
|
53
|
+
j++;
|
|
54
|
+
end = j;
|
|
55
|
+
if (chars[j - 1] === ' ')
|
|
56
|
+
breakAfter = j;
|
|
57
|
+
}
|
|
58
|
+
if (end < chars.length && end === start) {
|
|
59
|
+
end = start + 1; // 单字符超宽(极窄终端):保底推进,防死循环
|
|
60
|
+
}
|
|
61
|
+
else if (end < chars.length && breakAfter - start >= 2) {
|
|
62
|
+
end = breakAfter; // 词边界回退(断在空格后,空格留在上一行);至少留 2 字符,防空格独占一行
|
|
63
|
+
}
|
|
64
|
+
rows.push({ li: -1, start, text: chars.slice(start, end).join('') });
|
|
65
|
+
start = end;
|
|
66
|
+
}
|
|
67
|
+
return rows;
|
|
68
|
+
}
|
|
69
|
+
/** 全文档 → 展示行序列(带 logical 行号)。 */
|
|
70
|
+
export function wrapAll(lines, width) {
|
|
71
|
+
const out = [];
|
|
72
|
+
for (let li = 0; li < lines.length; li++) {
|
|
73
|
+
for (const r of wrapLogicalLine(lines[li] ?? '', width))
|
|
74
|
+
out.push({ li, start: r.start, text: r.text });
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
export function posCmp(a, b) {
|
|
79
|
+
return a.line - b.line || a.col - b.col;
|
|
80
|
+
}
|
|
81
|
+
/** anchor + cursor → 有序选区;相等时为 null(无选区)。 */
|
|
82
|
+
export function normSel(anchor, cur) {
|
|
83
|
+
if (posCmp(anchor, cur) === 0)
|
|
84
|
+
return null;
|
|
85
|
+
return posCmp(anchor, cur) < 0
|
|
86
|
+
? { sl: anchor.line, sc: anchor.col, el: cur.line, ec: cur.col }
|
|
87
|
+
: { sl: cur.line, sc: cur.col, el: anchor.line, ec: anchor.col };
|
|
88
|
+
}
|
|
89
|
+
export function spanText(lines, s) {
|
|
90
|
+
if (s.sl === s.el) {
|
|
91
|
+
const line = [...(lines[s.sl] ?? '')];
|
|
92
|
+
return line.slice(s.sc, s.ec).join('');
|
|
93
|
+
}
|
|
94
|
+
const parts = [[...(lines[s.sl] ?? '')].slice(s.sc).join('')];
|
|
95
|
+
for (let i = s.sl + 1; i < s.el; i++)
|
|
96
|
+
parts.push(lines[i] ?? '');
|
|
97
|
+
parts.push([...(lines[s.el] ?? '')].slice(0, s.ec).join(''));
|
|
98
|
+
return parts.join('\n');
|
|
99
|
+
}
|
|
100
|
+
/** 删除选区,返回 { line, col } 为删除后的光标位;lines 原地更新。 */
|
|
101
|
+
export function deleteSpan(lines, s) {
|
|
102
|
+
const head = [...(lines[s.sl] ?? '')].slice(0, s.sc).join('');
|
|
103
|
+
const tail = [...(lines[s.el] ?? '')].slice(s.ec).join('');
|
|
104
|
+
if (s.sl === s.el) {
|
|
105
|
+
lines[s.sl] = head + tail;
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
lines.splice(s.sl, s.el - s.sl + 1, head + tail);
|
|
109
|
+
}
|
|
110
|
+
return { line: s.sl, col: [...head].length };
|
|
111
|
+
}
|
|
112
|
+
const CUP = (row, col) => `\x1B[${row};${col}H`;
|
|
113
|
+
const HIDE = '\x1B[?25l';
|
|
114
|
+
const SHOW = '\x1B[?25h';
|
|
115
|
+
const REVERSE = '\x1B[7m';
|
|
116
|
+
const REVERSE_OFF = '\x1B[27m';
|
|
117
|
+
const MAX_UNDO = 200;
|
|
118
|
+
export async function promptComposer(opts = {}) {
|
|
119
|
+
if (!layout.isActive())
|
|
120
|
+
return { text: null };
|
|
121
|
+
const emitter = stdin;
|
|
122
|
+
const lines = (opts.initialText ?? '').split('\n');
|
|
123
|
+
let cur = { line: lines.length - 1, col: [...(lines[lines.length - 1] ?? '')].length };
|
|
124
|
+
let anchor = null; // 选区锚点(null = 无选区)
|
|
125
|
+
let clip = ''; // 内部剪贴板(系统剪贴板读失败时的兜底)
|
|
126
|
+
let scrollRow = 0; // 展示行滚动偏移
|
|
127
|
+
let done = false;
|
|
128
|
+
let resolve;
|
|
129
|
+
let paintTimer = null;
|
|
130
|
+
let geoCache = layout.getGeo();
|
|
131
|
+
/** 最近一次 paint 的弹窗几何(1-based 屏坐标),供鼠标点击 → 文本坐标换算;null = 太小没画。 */
|
|
132
|
+
let geoBox = null;
|
|
133
|
+
let mouseDragged = false; // 本次按下是否真拖动过(未拖动的 release = 纯点击 → 清选区)
|
|
134
|
+
let wheelOnly = false; // 滚轮刚滚过:下一次 paint 不把光标拉回可视区(允许滚离光标翻看)
|
|
135
|
+
const undoStack = [];
|
|
136
|
+
const redoStack = [];
|
|
137
|
+
let lastUndoKey = '';
|
|
138
|
+
let lastUndoAt = 0;
|
|
139
|
+
const snap = () => ({
|
|
140
|
+
lines: [...lines],
|
|
141
|
+
cur: { ...cur },
|
|
142
|
+
anchor: anchor ? { ...anchor } : null,
|
|
143
|
+
});
|
|
144
|
+
/** 撤销单位入栈。key 相同且间隔 <600ms 的连续输入(连续打字)合并为一个单位。 */
|
|
145
|
+
function pushUndo(key) {
|
|
146
|
+
const now = Date.now();
|
|
147
|
+
if (key && key === lastUndoKey && now - lastUndoAt < 600) {
|
|
148
|
+
lastUndoAt = now;
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
undoStack.push(snap());
|
|
152
|
+
if (undoStack.length > MAX_UNDO)
|
|
153
|
+
undoStack.shift();
|
|
154
|
+
redoStack.length = 0;
|
|
155
|
+
lastUndoKey = key ?? '';
|
|
156
|
+
lastUndoAt = now;
|
|
157
|
+
}
|
|
158
|
+
function undo() {
|
|
159
|
+
const prev = undoStack.pop();
|
|
160
|
+
if (!prev)
|
|
161
|
+
return;
|
|
162
|
+
redoStack.push(snap());
|
|
163
|
+
lines.length = 0;
|
|
164
|
+
lines.push(...prev.lines);
|
|
165
|
+
cur = { ...prev.cur };
|
|
166
|
+
anchor = prev.anchor ? { ...prev.anchor } : null;
|
|
167
|
+
schedulePaint();
|
|
168
|
+
}
|
|
169
|
+
function redo() {
|
|
170
|
+
const next = redoStack.pop();
|
|
171
|
+
if (!next)
|
|
172
|
+
return;
|
|
173
|
+
undoStack.push(snap());
|
|
174
|
+
lines.length = 0;
|
|
175
|
+
lines.push(...next.lines);
|
|
176
|
+
cur = { ...next.cur };
|
|
177
|
+
anchor = next.anchor ? { ...next.anchor } : null;
|
|
178
|
+
schedulePaint();
|
|
179
|
+
}
|
|
180
|
+
// ── 选区工具 ──
|
|
181
|
+
const sel = () => (anchor ? normSel(anchor, cur) : null);
|
|
182
|
+
const lineLen = (i) => [...(lines[i] ?? '')].length;
|
|
183
|
+
/** 在 pushUndo 之后调用:删除当前选区,光标落到删除点。 */
|
|
184
|
+
function deleteSelection() {
|
|
185
|
+
const s = sel();
|
|
186
|
+
if (!s)
|
|
187
|
+
return;
|
|
188
|
+
cur = deleteSpan(lines, s);
|
|
189
|
+
anchor = null;
|
|
190
|
+
}
|
|
191
|
+
// ── 编辑动作 ──
|
|
192
|
+
/** 插入文本(可含 \n);先删选区。key 传 'type' 供连续输入合并撤销。 */
|
|
193
|
+
function insertText(text, key) {
|
|
194
|
+
if (!text)
|
|
195
|
+
return;
|
|
196
|
+
pushUndo(key);
|
|
197
|
+
deleteSelection();
|
|
198
|
+
const parts = text.split('\n');
|
|
199
|
+
const line = [...(lines[cur.line] ?? '')];
|
|
200
|
+
const head = line.slice(0, cur.col).join('');
|
|
201
|
+
const tail = line.slice(cur.col).join('');
|
|
202
|
+
if (parts.length === 1) {
|
|
203
|
+
lines[cur.line] = head + text + tail;
|
|
204
|
+
cur = { line: cur.line, col: cur.col + [...text].length };
|
|
205
|
+
}
|
|
206
|
+
else {
|
|
207
|
+
const first = head + (parts[0] ?? '');
|
|
208
|
+
const last = (parts[parts.length - 1] ?? '') + tail;
|
|
209
|
+
lines.splice(cur.line, 1, first, ...parts.slice(1, -1), last);
|
|
210
|
+
cur = { line: cur.line + parts.length - 1, col: [...(parts[parts.length - 1] ?? '')].length };
|
|
211
|
+
}
|
|
212
|
+
schedulePaint();
|
|
213
|
+
}
|
|
214
|
+
/** 光标移动:extend=Shift 扩选,否则清选区。 */
|
|
215
|
+
function moveTo(p, extend) {
|
|
216
|
+
const old = cur;
|
|
217
|
+
cur = {
|
|
218
|
+
line: Math.max(0, Math.min(p.line, lines.length - 1)),
|
|
219
|
+
col: Math.max(0, Math.min(p.col, lineLen(Math.max(0, Math.min(p.line, lines.length - 1))))),
|
|
220
|
+
};
|
|
221
|
+
if (extend) {
|
|
222
|
+
if (!anchor)
|
|
223
|
+
anchor = { ...old };
|
|
224
|
+
}
|
|
225
|
+
else {
|
|
226
|
+
anchor = null;
|
|
227
|
+
}
|
|
228
|
+
schedulePaint();
|
|
229
|
+
}
|
|
230
|
+
/** 词边界:从 col 向 dir(-1/1) 找词边界(空格串 + 连续非空格)。 */
|
|
231
|
+
function wordBoundary(li, col, dir) {
|
|
232
|
+
const chars = [...(lines[li] ?? '')];
|
|
233
|
+
const isWord = (i) => {
|
|
234
|
+
const ch = chars[i] ?? '';
|
|
235
|
+
return ch !== ' ' && ch !== '\t';
|
|
236
|
+
};
|
|
237
|
+
let i = col;
|
|
238
|
+
if (dir === -1) {
|
|
239
|
+
while (i > 0 && !isWord(i - 1))
|
|
240
|
+
i--;
|
|
241
|
+
while (i > 0 && isWord(i - 1))
|
|
242
|
+
i--;
|
|
243
|
+
}
|
|
244
|
+
else {
|
|
245
|
+
while (i < chars.length && !isWord(i))
|
|
246
|
+
i++;
|
|
247
|
+
while (i < chars.length && isWord(i))
|
|
248
|
+
i++;
|
|
249
|
+
}
|
|
250
|
+
return i;
|
|
251
|
+
}
|
|
252
|
+
function delWord(dir) {
|
|
253
|
+
if (sel()) {
|
|
254
|
+
pushUndo('del');
|
|
255
|
+
deleteSelection();
|
|
256
|
+
schedulePaint();
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
pushUndo('delword');
|
|
260
|
+
const li = cur.line;
|
|
261
|
+
const target = wordBoundary(li, cur.col, dir);
|
|
262
|
+
const chars = [...(lines[li] ?? '')];
|
|
263
|
+
const from = Math.min(cur.col, target);
|
|
264
|
+
const to = Math.max(cur.col, target);
|
|
265
|
+
if (to > from) {
|
|
266
|
+
lines[li] = [...chars.slice(0, from), ...chars.slice(to)].join('');
|
|
267
|
+
cur = { line: li, col: from };
|
|
268
|
+
}
|
|
269
|
+
schedulePaint();
|
|
270
|
+
}
|
|
271
|
+
function delToLineEdge(toEnd) {
|
|
272
|
+
pushUndo('delhead');
|
|
273
|
+
deleteSelection();
|
|
274
|
+
const li = cur.line;
|
|
275
|
+
const chars = [...(lines[li] ?? '')];
|
|
276
|
+
if (toEnd) {
|
|
277
|
+
if (cur.col < chars.length) {
|
|
278
|
+
lines[li] = chars.slice(0, cur.col).join('');
|
|
279
|
+
}
|
|
280
|
+
else if (li < lines.length - 1) {
|
|
281
|
+
// 行尾:吞掉换行(与 readline C-k 一致)
|
|
282
|
+
lines.splice(li, 2, (lines[li] ?? '') + (lines[li + 1] ?? ''));
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
else {
|
|
286
|
+
if (cur.col > 0) {
|
|
287
|
+
lines[li] = chars.slice(cur.col).join('');
|
|
288
|
+
cur = { line: li, col: 0 };
|
|
289
|
+
}
|
|
290
|
+
else if (li > 0) {
|
|
291
|
+
const prevLen = lineLen(li - 1);
|
|
292
|
+
lines.splice(li - 1, 2, (lines[li - 1] ?? '') + (lines[li] ?? ''));
|
|
293
|
+
cur = { line: li - 1, col: prevLen };
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
schedulePaint();
|
|
297
|
+
}
|
|
298
|
+
function backspace() {
|
|
299
|
+
pushUndo('bs');
|
|
300
|
+
if (sel()) {
|
|
301
|
+
deleteSelection();
|
|
302
|
+
schedulePaint();
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
if (cur.col > 0) {
|
|
306
|
+
const chars = [...(lines[cur.line] ?? '')];
|
|
307
|
+
lines[cur.line] = [...chars.slice(0, cur.col - 1), ...chars.slice(cur.col)].join('');
|
|
308
|
+
cur = { line: cur.line, col: cur.col - 1 };
|
|
309
|
+
}
|
|
310
|
+
else if (cur.line > 0) {
|
|
311
|
+
const prevLen = lineLen(cur.line - 1);
|
|
312
|
+
lines.splice(cur.line - 1, 2, (lines[cur.line - 1] ?? '') + (lines[cur.line] ?? ''));
|
|
313
|
+
cur = { line: cur.line - 1, col: prevLen };
|
|
314
|
+
}
|
|
315
|
+
schedulePaint();
|
|
316
|
+
}
|
|
317
|
+
function deleteKey() {
|
|
318
|
+
pushUndo('del');
|
|
319
|
+
if (sel()) {
|
|
320
|
+
deleteSelection();
|
|
321
|
+
schedulePaint();
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
const chars = [...(lines[cur.line] ?? '')];
|
|
325
|
+
if (cur.col < chars.length) {
|
|
326
|
+
lines[cur.line] = [...chars.slice(0, cur.col), ...chars.slice(cur.col + 1)].join('');
|
|
327
|
+
}
|
|
328
|
+
else if (cur.line < lines.length - 1) {
|
|
329
|
+
lines.splice(cur.line, 2, (lines[cur.line] ?? '') + (lines[cur.line + 1] ?? ''));
|
|
330
|
+
}
|
|
331
|
+
schedulePaint();
|
|
332
|
+
}
|
|
333
|
+
// ── 光标 ↔ 展示行换算 ──
|
|
334
|
+
function dispRows() {
|
|
335
|
+
const innerW = Math.max(4, geoCache.cols - MARGIN_X * 2 - 2);
|
|
336
|
+
return wrapAll(lines, innerW);
|
|
337
|
+
}
|
|
338
|
+
/** 光标所在展示行 index(-1 找不到时钳到末行)。 */
|
|
339
|
+
function cursorDispIdx(rows) {
|
|
340
|
+
for (let i = 0; i < rows.length; i++) {
|
|
341
|
+
const r = rows[i];
|
|
342
|
+
if (r && r.li === cur.line && cur.col >= r.start && cur.col <= r.start + [...r.text].length)
|
|
343
|
+
return i;
|
|
344
|
+
}
|
|
345
|
+
return rows.length - 1;
|
|
346
|
+
}
|
|
347
|
+
/** 上下移动一行(按展示行)。 */
|
|
348
|
+
function moveVertically(delta, extend) {
|
|
349
|
+
const rows = dispRows();
|
|
350
|
+
const idx = cursorDispIdx(rows);
|
|
351
|
+
const target = Math.max(0, Math.min(rows.length - 1, idx + delta));
|
|
352
|
+
const r = rows[target];
|
|
353
|
+
if (!r)
|
|
354
|
+
return;
|
|
355
|
+
// 保持视觉列:当前光标视觉列 → 目标展示行同列
|
|
356
|
+
const curRow = rows[idx];
|
|
357
|
+
const curText = curRow ? curRow.text : '';
|
|
358
|
+
const curOff = cur.col - (curRow ? curRow.start : 0);
|
|
359
|
+
const vis = displayWidth([...curText].slice(0, Math.max(0, curOff)).join(''));
|
|
360
|
+
const charCol = visColToCharCol(r.text, vis);
|
|
361
|
+
moveTo({ line: r.li, col: r.start + charCol }, extend);
|
|
362
|
+
}
|
|
363
|
+
function moveByPage(delta, extend) {
|
|
364
|
+
const rows = dispRows();
|
|
365
|
+
const visible = Math.max(1, textRowCount());
|
|
366
|
+
const idx = cursorDispIdx(rows);
|
|
367
|
+
const target = Math.max(0, Math.min(rows.length - 1, idx + delta * visible));
|
|
368
|
+
const r = rows[target];
|
|
369
|
+
if (!r)
|
|
370
|
+
return;
|
|
371
|
+
moveTo({ line: r.li, col: r.start }, extend);
|
|
372
|
+
}
|
|
373
|
+
// ── 绘制 ──
|
|
374
|
+
/** 文本区可用行数 = 弹窗总高 - 顶栏(含上边框) - 提示行(含中分隔) - 底边框。 */
|
|
375
|
+
function textRowCount() {
|
|
376
|
+
const g = geoCache;
|
|
377
|
+
const boxTop = Math.min(TOP, Math.max(1, g.contentBottom - 5));
|
|
378
|
+
const boxBottom = g.contentBottom - 1;
|
|
379
|
+
return Math.max(1, boxBottom - boxTop - 2);
|
|
380
|
+
}
|
|
381
|
+
function schedulePaint() {
|
|
382
|
+
if (paintTimer || done)
|
|
383
|
+
return;
|
|
384
|
+
paintTimer = setTimeout(() => {
|
|
385
|
+
paintTimer = null;
|
|
386
|
+
paint();
|
|
387
|
+
}, 16);
|
|
388
|
+
paintTimer.unref?.();
|
|
389
|
+
}
|
|
390
|
+
function paint() {
|
|
391
|
+
if (done)
|
|
392
|
+
return;
|
|
393
|
+
geoCache = layout.getGeo();
|
|
394
|
+
const g = geoCache;
|
|
395
|
+
if (g.contentBottom - 5 < 4 || g.cols < 24) {
|
|
396
|
+
geoBox = null; // 终端太小:不画( rarely )
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
const boxTop = Math.min(TOP, Math.max(1, g.contentBottom - 5));
|
|
400
|
+
const boxBottom = g.contentBottom - 1;
|
|
401
|
+
const left = MARGIN_X + 1; // 1-based 起边框列
|
|
402
|
+
const right = g.cols - MARGIN_X;
|
|
403
|
+
const innerW = right - left - 1;
|
|
404
|
+
const textTop = boxTop + 1;
|
|
405
|
+
const textRows = textRowCount();
|
|
406
|
+
const hintRow = boxBottom - 1;
|
|
407
|
+
geoBox = { boxTop, boxBottom, left, innerW, textTop, textRows };
|
|
408
|
+
const rows = dispRows();
|
|
409
|
+
// 滚动:光标展示行保持可见(滚轮翻看后例外——允许滚离光标,下次键盘/鼠标操作再拉回)
|
|
410
|
+
const cIdx = cursorDispIdx(rows);
|
|
411
|
+
if (!wheelOnly) {
|
|
412
|
+
if (cIdx < scrollRow)
|
|
413
|
+
scrollRow = cIdx;
|
|
414
|
+
else if (cIdx >= scrollRow + textRows)
|
|
415
|
+
scrollRow = cIdx - textRows + 1;
|
|
416
|
+
}
|
|
417
|
+
wheelOnly = false;
|
|
418
|
+
scrollRow = Math.max(0, scrollRow);
|
|
419
|
+
const s = sel();
|
|
420
|
+
const total = [...lines.join('\n')].length;
|
|
421
|
+
const curVis = displayWidth([...(lines[cur.line] ?? '')].slice(0, cur.col).join(''));
|
|
422
|
+
let buf = HIDE;
|
|
423
|
+
// 顶栏:标题 + 右侧位置
|
|
424
|
+
const title = ` ${t('composer.title')} `;
|
|
425
|
+
const posInfo = `${t('composer.pos', { row: cur.line + 1, total: lines.length, col: curVis + 1, chars: total })} `;
|
|
426
|
+
const titleW = displayWidth(title);
|
|
427
|
+
const posW = displayWidth(posInfo);
|
|
428
|
+
const fill = Math.max(0, innerW - titleW - posW - 2);
|
|
429
|
+
buf += CUP(boxTop, left);
|
|
430
|
+
buf += `${ui.accent}╭─${ui.bold}${title}${ui.reset}${ui.accent}${'─'.repeat(fill)}${posInfo}─╮${ui.reset}`;
|
|
431
|
+
// 文本行
|
|
432
|
+
const blank = ' '.repeat(innerW);
|
|
433
|
+
for (let i = 0; i < textRows; i++) {
|
|
434
|
+
buf += CUP(textTop + i, left);
|
|
435
|
+
const r = rows[scrollRow + i];
|
|
436
|
+
if (!r) {
|
|
437
|
+
buf += `${ui.accent}│${ui.reset}${blank}${ui.accent}│${ui.reset}`;
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
buf += `${ui.accent}│${ui.reset}${renderRow(r, s, innerW)}${ui.accent}│${ui.reset}`;
|
|
441
|
+
}
|
|
442
|
+
// 提示行(居中:截断后左右平分补空格,余数列归左)
|
|
443
|
+
const hintText = truncateDisplay(t('composer.hint'), innerW);
|
|
444
|
+
const hintW = displayWidth(hintText);
|
|
445
|
+
const padL = Math.floor((innerW - hintW) / 2);
|
|
446
|
+
const padR = Math.max(0, innerW - hintW - padL);
|
|
447
|
+
buf += CUP(hintRow, left);
|
|
448
|
+
buf += `${ui.accent}├${ui.reset}${ui.dim}${' '.repeat(padL)}${hintText}${' '.repeat(padR)}${ui.reset}${ui.accent}┤${ui.reset}`;
|
|
449
|
+
// 底边框
|
|
450
|
+
buf += CUP(boxBottom, left);
|
|
451
|
+
buf += `${ui.accent}╰${'─'.repeat(innerW)}╯${ui.reset}`;
|
|
452
|
+
// 光标(滚轮翻看把光标滚出可视区时不画,防 CUP 落到边框/提示行上)
|
|
453
|
+
const cr = rows[cIdx];
|
|
454
|
+
if (cr) {
|
|
455
|
+
const rowIdx = cIdx - scrollRow;
|
|
456
|
+
if (rowIdx >= 0 && rowIdx < textRows) {
|
|
457
|
+
const vis = displayWidth([...cr.text].slice(0, cur.col - cr.start).join(''));
|
|
458
|
+
buf += CUP(textTop + rowIdx, left + 1 + vis) + SHOW;
|
|
459
|
+
}
|
|
460
|
+
else {
|
|
461
|
+
buf += SHOW;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
else {
|
|
465
|
+
buf += SHOW;
|
|
466
|
+
}
|
|
467
|
+
try {
|
|
468
|
+
layout.writeDirect(buf);
|
|
469
|
+
}
|
|
470
|
+
catch {
|
|
471
|
+
// 忽略:极端时序下 stdout 已不可写
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
/** 一条展示行 → 带选区反白的渲染文本(定宽 padEnd,padEndAnsi 剥 ANSI 算宽)。 */
|
|
475
|
+
function renderRow(r, s, innerW) {
|
|
476
|
+
const chars = [...r.text];
|
|
477
|
+
const text = chars.join('');
|
|
478
|
+
if (!s || s.sl > r.li || s.el < r.li)
|
|
479
|
+
return padEndAnsi(text, innerW);
|
|
480
|
+
const s0 = r.li === s.sl ? s.sc : 0;
|
|
481
|
+
const e0 = r.li === s.el ? s.ec : lineLen(r.li); // 选区延伸到本行末
|
|
482
|
+
const from = Math.max(s0, r.start);
|
|
483
|
+
const to = Math.min(e0, r.start + chars.length);
|
|
484
|
+
if (to <= from)
|
|
485
|
+
return padEndAnsi(text, innerW);
|
|
486
|
+
const a = from - r.start;
|
|
487
|
+
const b = to - r.start;
|
|
488
|
+
const out = chars.slice(0, a).join('') + REVERSE + chars.slice(a, b).join('') + REVERSE_OFF + chars.slice(b).join('');
|
|
489
|
+
return padEndAnsi(out, innerW);
|
|
490
|
+
}
|
|
491
|
+
// ── 鼠标(经 layout.setOverlayMouseHandler 接管;弹窗期间消费全部事件防背景重画覆盖)──
|
|
492
|
+
/** 屏坐标(1-based,SGR 报表原值)→ 文本 Pos。落在文本区外的行(边框/标题/提示行)返回 null。 */
|
|
493
|
+
function screenToPos(row, col) {
|
|
494
|
+
const box = geoBox;
|
|
495
|
+
if (!box)
|
|
496
|
+
return null;
|
|
497
|
+
if (row < box.textTop || row >= box.textTop + box.textRows)
|
|
498
|
+
return null;
|
|
499
|
+
const rows = dispRows();
|
|
500
|
+
const idx = scrollRow + (row - box.textTop);
|
|
501
|
+
if (idx < 0)
|
|
502
|
+
return null;
|
|
503
|
+
if (idx >= rows.length) {
|
|
504
|
+
// 点到文本末尾下方的空白区:光标置文档末
|
|
505
|
+
const last = lines.length - 1;
|
|
506
|
+
return { line: last, col: lineLen(last) };
|
|
507
|
+
}
|
|
508
|
+
const r = rows[idx];
|
|
509
|
+
if (!r)
|
|
510
|
+
return null;
|
|
511
|
+
// 屏列(1-based)→ 行内可见列(0-based,点击边框/超右界时钳到 [0, innerW])
|
|
512
|
+
const vis = Math.max(0, Math.min(col - box.left - 1, box.innerW));
|
|
513
|
+
const charCol = visColToCharCol(r.text, vis);
|
|
514
|
+
return { line: r.li, col: Math.min(r.start + charCol, lineLen(r.li)) };
|
|
515
|
+
}
|
|
516
|
+
/** overlay 鼠标处理器:返回 true = layout 不再默认处理(选区/翻页会 repaintViewport 覆盖弹窗)。 */
|
|
517
|
+
function onMouse(e) {
|
|
518
|
+
if (done)
|
|
519
|
+
return false;
|
|
520
|
+
if (e.type === 'wheel') {
|
|
521
|
+
if (!geoBox)
|
|
522
|
+
return false; // 弹窗没画(终端太小):放行给 layout 滚背景
|
|
523
|
+
const rows = dispRows();
|
|
524
|
+
const maxScroll = Math.max(0, rows.length - geoBox.textRows);
|
|
525
|
+
scrollRow = Math.max(0, Math.min(maxScroll, scrollRow + (e.dir > 0 ? -3 : 3)));
|
|
526
|
+
wheelOnly = true; // 允许滚离光标
|
|
527
|
+
schedulePaint();
|
|
528
|
+
return true;
|
|
529
|
+
}
|
|
530
|
+
// 右键(单击 = press→release 未拖动):有选区→复制并清高亮;无选区→粘贴。与主输入框一致。
|
|
531
|
+
if (e.button === 2 && e.type === 'release') {
|
|
532
|
+
if (sel()) {
|
|
533
|
+
copySel(false);
|
|
534
|
+
anchor = null; // 复制后清高亮:视觉确认"已复制",也解了 Ctrl+A 后选不掉的困境
|
|
535
|
+
schedulePaint();
|
|
536
|
+
}
|
|
537
|
+
else {
|
|
538
|
+
void pasteClip();
|
|
539
|
+
}
|
|
540
|
+
return true;
|
|
541
|
+
}
|
|
542
|
+
if (e.button !== 0)
|
|
543
|
+
return true; // 中键等:吞掉,不穿透
|
|
544
|
+
if (e.type === 'press') {
|
|
545
|
+
const p = screenToPos(e.row, e.col);
|
|
546
|
+
if (p) {
|
|
547
|
+
cur = p;
|
|
548
|
+
anchor = { ...p }; // 锚点=按下位:后续拖动即成选区
|
|
549
|
+
mouseDragged = false;
|
|
550
|
+
schedulePaint();
|
|
551
|
+
}
|
|
552
|
+
return true;
|
|
553
|
+
}
|
|
554
|
+
if (e.type === 'drag') {
|
|
555
|
+
if (!anchor)
|
|
556
|
+
return true;
|
|
557
|
+
const p = screenToPos(e.row, e.col);
|
|
558
|
+
if (p && (p.line !== cur.line || p.col !== cur.col)) {
|
|
559
|
+
cur = p;
|
|
560
|
+
mouseDragged = true;
|
|
561
|
+
schedulePaint();
|
|
562
|
+
}
|
|
563
|
+
return true;
|
|
564
|
+
}
|
|
565
|
+
if (e.type === 'release') {
|
|
566
|
+
// 纯点击(未拖动):清选区——Ctrl+A 全选后点一下即可取消
|
|
567
|
+
if (anchor && !mouseDragged) {
|
|
568
|
+
anchor = null;
|
|
569
|
+
schedulePaint();
|
|
570
|
+
}
|
|
571
|
+
return true;
|
|
572
|
+
}
|
|
573
|
+
return true;
|
|
574
|
+
}
|
|
575
|
+
// ── 生命周期 ──
|
|
576
|
+
function finish(text) {
|
|
577
|
+
if (done)
|
|
578
|
+
return;
|
|
579
|
+
done = true;
|
|
580
|
+
if (paintTimer) {
|
|
581
|
+
clearTimeout(paintTimer);
|
|
582
|
+
paintTimer = null;
|
|
583
|
+
}
|
|
584
|
+
try {
|
|
585
|
+
process.removeListener('SIGWINCH', onResize);
|
|
586
|
+
}
|
|
587
|
+
catch {
|
|
588
|
+
// 忽略
|
|
589
|
+
}
|
|
590
|
+
emitter.removeListener('keypress', onKey);
|
|
591
|
+
layout.setOverlayMouseHandler(null); // 注销接管,鼠标交还 layout(输入框框选/右键粘贴)
|
|
592
|
+
stdin.pause();
|
|
593
|
+
resolve({ text });
|
|
594
|
+
}
|
|
595
|
+
function onResize() {
|
|
596
|
+
setTimeout(() => paint(), 50); // layout 自己的 SIGWINCH 重画可能在我们之后跑,稍等再画
|
|
597
|
+
}
|
|
598
|
+
function copySel(cut) {
|
|
599
|
+
const s = sel();
|
|
600
|
+
if (!s)
|
|
601
|
+
return;
|
|
602
|
+
clip = spanText(lines, s);
|
|
603
|
+
copyToClipboard(clip); // 尽力而为写系统剪贴板
|
|
604
|
+
if (cut) {
|
|
605
|
+
pushUndo('cut');
|
|
606
|
+
cur = deleteSpan(lines, s);
|
|
607
|
+
anchor = null;
|
|
608
|
+
}
|
|
609
|
+
schedulePaint();
|
|
610
|
+
}
|
|
611
|
+
async function pasteClip() {
|
|
612
|
+
let text = '';
|
|
613
|
+
try {
|
|
614
|
+
text = (await readClipboard()) ?? '';
|
|
615
|
+
}
|
|
616
|
+
catch {
|
|
617
|
+
text = clip;
|
|
618
|
+
}
|
|
619
|
+
if (!text)
|
|
620
|
+
text = clip;
|
|
621
|
+
if (!text)
|
|
622
|
+
return;
|
|
623
|
+
insertText(text.replace(/\r\n?/g, '\n'), 'paste');
|
|
624
|
+
}
|
|
625
|
+
function onKey(_str, key) {
|
|
626
|
+
if (done || !key)
|
|
627
|
+
return;
|
|
628
|
+
if (mouse.swallow(key.sequence ?? ''))
|
|
629
|
+
return;
|
|
630
|
+
const isReturn = key.name === 'return' || key.name === 'enter';
|
|
631
|
+
// Esc:取消(输入框保持原样)
|
|
632
|
+
if (key.name === 'escape') {
|
|
633
|
+
finish(null);
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
// Ctrl+S / Ctrl+Enter:确认填回(弹窗内 Enter 永远只是换行)
|
|
637
|
+
if ((key.ctrl && key.name === 's') || (key.ctrl && isReturn)) {
|
|
638
|
+
finish(lines.join('\n'));
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
// Ctrl+C:有选区=复制,无选区=什么都不做(取消用 Esc,别劫持复制习惯)
|
|
642
|
+
if (key.ctrl && key.name === 'c') {
|
|
643
|
+
copySel(false);
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
if (key.ctrl && key.name === 'x') {
|
|
647
|
+
copySel(true);
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
if (key.ctrl && key.name === 'v') {
|
|
651
|
+
void pasteClip();
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
if (key.ctrl && key.name === 'z') {
|
|
655
|
+
undo();
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
if (key.ctrl && key.name === 'y') {
|
|
659
|
+
redo();
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
if (key.ctrl && key.name === 'a') {
|
|
663
|
+
anchor = { line: 0, col: 0 };
|
|
664
|
+
cur = { line: lines.length - 1, col: lineLen(lines.length - 1) };
|
|
665
|
+
schedulePaint();
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
668
|
+
if (key.ctrl && key.name === 'u') {
|
|
669
|
+
delToLineEdge(false);
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
if (key.ctrl && key.name === 'k') {
|
|
673
|
+
delToLineEdge(true);
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
if (key.ctrl && key.name === 'delete') {
|
|
677
|
+
delWord(1);
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
if ((key.ctrl && key.name === 'backspace') || (key.ctrl && key.name === 'w')) {
|
|
681
|
+
delWord(-1);
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
const extend = !!key.shift;
|
|
685
|
+
switch (key.name) {
|
|
686
|
+
case 'backspace':
|
|
687
|
+
if (!key.ctrl)
|
|
688
|
+
backspace();
|
|
689
|
+
return;
|
|
690
|
+
case 'delete':
|
|
691
|
+
if (!key.ctrl)
|
|
692
|
+
deleteKey();
|
|
693
|
+
return;
|
|
694
|
+
case 'left':
|
|
695
|
+
if (key.ctrl) {
|
|
696
|
+
const t = wordBoundary(cur.line, cur.col, -1);
|
|
697
|
+
moveTo({ line: cur.line, col: t }, extend);
|
|
698
|
+
}
|
|
699
|
+
else if (cur.col > 0) {
|
|
700
|
+
moveTo({ line: cur.line, col: cur.col - 1 }, extend);
|
|
701
|
+
}
|
|
702
|
+
else if (cur.line > 0) {
|
|
703
|
+
moveTo({ line: cur.line - 1, col: lineLen(cur.line - 1) }, extend);
|
|
704
|
+
}
|
|
705
|
+
return;
|
|
706
|
+
case 'right':
|
|
707
|
+
if (key.ctrl) {
|
|
708
|
+
const t = wordBoundary(cur.line, cur.col, 1);
|
|
709
|
+
moveTo({ line: cur.line, col: t }, extend);
|
|
710
|
+
}
|
|
711
|
+
else if (cur.col < lineLen(cur.line)) {
|
|
712
|
+
moveTo({ line: cur.line, col: cur.col + 1 }, extend);
|
|
713
|
+
}
|
|
714
|
+
else if (cur.line < lines.length - 1) {
|
|
715
|
+
moveTo({ line: cur.line + 1, col: 0 }, extend);
|
|
716
|
+
}
|
|
717
|
+
return;
|
|
718
|
+
case 'up':
|
|
719
|
+
moveVertically(-1, extend);
|
|
720
|
+
return;
|
|
721
|
+
case 'down':
|
|
722
|
+
moveVertically(1, extend);
|
|
723
|
+
return;
|
|
724
|
+
case 'home':
|
|
725
|
+
if (key.ctrl) {
|
|
726
|
+
moveTo({ line: 0, col: 0 }, extend);
|
|
727
|
+
}
|
|
728
|
+
else {
|
|
729
|
+
moveTo({ line: cur.line, col: 0 }, extend);
|
|
730
|
+
}
|
|
731
|
+
return;
|
|
732
|
+
case 'end':
|
|
733
|
+
if (key.ctrl) {
|
|
734
|
+
moveTo({ line: lines.length - 1, col: lineLen(lines.length - 1) }, extend);
|
|
735
|
+
}
|
|
736
|
+
else {
|
|
737
|
+
moveTo({ line: cur.line, col: lineLen(cur.line) }, extend);
|
|
738
|
+
}
|
|
739
|
+
return;
|
|
740
|
+
case 'pageup':
|
|
741
|
+
moveByPage(-1, extend);
|
|
742
|
+
return;
|
|
743
|
+
case 'pagedown':
|
|
744
|
+
moveByPage(1, extend);
|
|
745
|
+
return;
|
|
746
|
+
default:
|
|
747
|
+
break;
|
|
748
|
+
}
|
|
749
|
+
// 换行:Enter / Ctrl+J(弹窗内永不发送)
|
|
750
|
+
if (isReturn || (key.ctrl && key.name === 'j')) {
|
|
751
|
+
insertText('\n', 'nl');
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
if (key.name === 'tab') {
|
|
755
|
+
insertText(' ');
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
758
|
+
// 可打印字符(粘贴会逐字符到达;insertText 撤销合并让大粘贴仍是一个撤销单位)。
|
|
759
|
+
// FEFF(BOM 字符)过滤:终端级粘贴绕过 readClipboard 的剥离,会把它敲进文本渲染成方块。
|
|
760
|
+
const s = key.sequence ?? '';
|
|
761
|
+
if (s && s >= ' ' && s !== '\uFEFF' && !key.ctrl && !key.meta) {
|
|
762
|
+
insertText(s, 'type');
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
return new Promise((res) => {
|
|
766
|
+
resolve = res;
|
|
767
|
+
try {
|
|
768
|
+
stdin.setRawMode(true);
|
|
769
|
+
}
|
|
770
|
+
catch {
|
|
771
|
+
res({ text: null });
|
|
772
|
+
return;
|
|
773
|
+
}
|
|
774
|
+
layout.setOverlayMouseHandler(onMouse); // 弹窗期间接管鼠标:点击定位/拖选/右键复制粘贴/滚轮
|
|
775
|
+
stdin.resume();
|
|
776
|
+
emitter.on('keypress', onKey);
|
|
777
|
+
try {
|
|
778
|
+
process.on('SIGWINCH', onResize);
|
|
779
|
+
}
|
|
780
|
+
catch {
|
|
781
|
+
// 忽略
|
|
782
|
+
}
|
|
783
|
+
paint();
|
|
784
|
+
});
|
|
785
|
+
}
|