mocode-ai 1.3.9 → 1.4.0
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 +78 -70
- package/README.zh-CN.md +81 -73
- package/bin/mocode-agent-host.js +1 -1
- package/dist/agent/core.js +226 -293
- package/dist/agent/index.js +14 -10
- package/dist/agent/mode.js +3 -3
- package/dist/agent/runtime-context.js +45 -0
- package/dist/agent/spawn.js +101 -86
- package/dist/agent/tool-helpers.js +134 -0
- package/dist/agent/trace-state.js +118 -0
- package/dist/agents/coordinator.js +6 -1
- package/dist/changeset/index.js +9 -8
- package/dist/config/index.js +109 -77
- package/dist/config/presets.js +3 -1
- package/dist/config/profiles.js +125 -0
- package/dist/context/age-aware.js +2 -4
- package/dist/context/artifacts.js +6 -13
- package/dist/context/budget.js +6 -8
- package/dist/context/classifier.js +1 -2
- package/dist/context/encoders/code.js +2 -6
- package/dist/context/encoders/command.js +1 -3
- package/dist/context/encoders/search.js +1 -3
- package/dist/context/index.js +1 -1
- package/dist/context/lifecycle.js +5 -3
- package/dist/context/pipeline.js +1 -1
- package/dist/context/relevance.js +2 -4
- package/dist/context/token-calibration.js +13 -18
- package/dist/host/stdio.js +73 -12
- package/dist/i18n/index.js +96 -44
- package/dist/index.js +1 -1
- package/dist/llm/index.js +39 -23
- package/dist/llm/provider.js +23 -0
- package/dist/llm/providers/anthropic.js +12 -18
- package/dist/llm/tool-schema.js +48 -0
- package/dist/mcp/client.js +70 -25
- package/dist/mcp/config.js +1 -3
- package/dist/mcp/index.js +3 -1
- package/dist/memory/reflect.js +3 -6
- package/dist/memory/store.js +10 -12
- package/dist/permissions/index.js +66 -22
- package/dist/pet/bridge.js +2 -2
- package/dist/repl/commands/appearance.js +72 -0
- package/dist/repl/commands/compact.js +114 -0
- package/dist/repl/commands/context.js +17 -0
- package/dist/repl/commands/image.js +71 -0
- package/dist/repl/commands/memory.js +51 -0
- package/dist/repl/commands/mode.js +47 -0
- package/dist/repl/commands/model.js +435 -0
- package/dist/repl/commands/pet.js +70 -0
- package/dist/repl/commands/registry.js +46 -0
- package/dist/repl/commands/session.js +102 -0
- package/dist/repl/commands/skill.js +95 -0
- package/dist/repl/commands/system.js +202 -0
- package/dist/repl/commands/tool-group.js +233 -0
- package/dist/repl/commands/types.js +4 -0
- package/dist/repl/commands.js +324 -0
- package/dist/repl/index.js +5 -2577
- package/dist/repl/message-format.js +151 -0
- package/dist/repl/running-input.js +147 -0
- package/dist/repl/runtime.js +816 -0
- package/dist/repl/status-bar.js +161 -0
- package/dist/rollback/index.js +44 -14
- package/dist/runtime/browser-manager.js +1 -1
- package/dist/runtime/dev-server-manager.js +1 -1
- package/dist/runtime/input-injector.js +313 -0
- package/dist/runtime/screen-capture.js +89 -0
- package/dist/runtime/screen-pipeline.js +223 -0
- package/dist/sandbox/index.js +1 -1
- package/dist/sandbox/policy.js +8 -2
- package/dist/session/compact.js +22 -34
- package/dist/session/index.js +2 -2
- package/dist/session/notes.js +39 -5
- package/dist/session/persist.js +14 -8
- package/dist/session/scheduler.js +3 -4
- package/dist/session/trace-metrics.js +6 -3
- package/dist/skills/activation.js +10 -5
- package/dist/skills/discover.js +5 -17
- package/dist/skills/index.js +1 -1
- package/dist/skills/runner.js +28 -44
- package/dist/skills/toolmap.js +5 -2
- package/dist/tools/builtins/browser.js +17 -3
- package/dist/tools/builtins/computer.js +322 -0
- package/dist/tools/builtins/dev-server.js +1 -3
- package/dist/tools/builtins/edit-file.js +21 -7
- package/dist/tools/builtins/grep.js +1 -1
- package/dist/tools/builtins/index.js +28 -25
- package/dist/tools/builtins/memory-forget.js +1 -1
- package/dist/tools/builtins/memory-graph.js +4 -7
- package/dist/tools/builtins/memory-list.js +2 -6
- package/dist/tools/builtins/memory-save.js +8 -2
- package/dist/tools/builtins/memory-search.js +4 -4
- package/dist/tools/builtins/note-append.js +14 -3
- package/dist/tools/builtins/plan-update.js +40 -9
- package/dist/tools/builtins/run-command.js +8 -8
- package/dist/tools/builtins/screenshot.js +11 -87
- package/dist/tools/builtins/task.js +29 -40
- package/dist/tools/builtins/use-skill.js +1 -1
- package/dist/tools/builtins/view-image.js +5 -3
- package/dist/tools/builtins/web-fetch.js +1 -3
- package/dist/tools/builtins/web-search.js +3 -7
- package/dist/tools/builtins/write-file.js +4 -2
- package/dist/tools/constants.js +50 -37
- package/dist/tools/policy.js +221 -0
- package/dist/tools/registry.js +40 -19
- package/dist/tools/resource-lock.js +4 -2
- package/dist/tools/router.js +143 -0
- package/dist/tools/validation.js +6 -5
- package/dist/ui/batch.js +63 -23
- package/dist/ui/clipboard.js +17 -5
- package/dist/ui/content.js +166 -13
- package/dist/ui/diff.js +61 -19
- package/dist/ui/fuzzy.js +35 -2
- package/dist/ui/intervention.js +2 -4
- package/dist/ui/layout-internal/content-write.js +587 -0
- package/dist/ui/layout-internal/core.js +590 -0
- package/dist/ui/layout-internal/input-paint.js +365 -0
- package/dist/ui/layout-internal/screen.js +128 -0
- package/dist/ui/layout-internal/scroll.js +152 -0
- package/dist/ui/layout-internal/selection.js +229 -0
- package/dist/ui/layout-internal/state.js +82 -0
- package/dist/ui/layout-internal/statusbar.js +351 -0
- package/dist/ui/layout-types.js +1 -0
- package/dist/ui/layout.js +3 -2288
- package/dist/ui/markdown.js +10 -2
- package/dist/ui/prompt-internal/editor.js +945 -0
- package/dist/ui/prompt-internal/paste.js +52 -0
- package/dist/ui/prompt-internal/pickers.js +519 -0
- package/dist/ui/prompt-internal/types.js +1 -0
- package/dist/ui/prompt.js +4 -1506
- package/dist/ui/render.js +87 -2
- package/dist/verification/affected.js +11 -5
- package/dist/verification/discovery.js +1 -3
- package/dist/verification/profile.js +11 -3
- package/package.json +11 -2
|
@@ -0,0 +1,945 @@
|
|
|
1
|
+
import readline from 'node:readline';
|
|
2
|
+
import { stdin, stdout } from 'node:process';
|
|
3
|
+
import { ui } from '../theme.js';
|
|
4
|
+
import { displayWidth, padEndDisplay, truncateDisplay, visColToCharCol, wrapByDisplayWidth } from '../render.js';
|
|
5
|
+
import * as layout from '../layout.js';
|
|
6
|
+
import * as mouse from '../mouse.js';
|
|
7
|
+
import { t } from '../../i18n/index.js';
|
|
8
|
+
import { promptComposer } from '../composer.js';
|
|
9
|
+
import { promptHistorySearch } from '../history-picker.js';
|
|
10
|
+
import { CONFIRM_SEND_MS, LONG_INPUT_CHARS, confirmSendMode, ensurePasteDetector, pasteState } from './paste.js';
|
|
11
|
+
/** 非 TTY / 未进 alt screen 时退化为普通 readline 行输入(无菜单、单行)。 */
|
|
12
|
+
function questionFallback(prompt) {
|
|
13
|
+
return new Promise((res, rej) => {
|
|
14
|
+
const rl = readline.createInterface({ input: stdin, output: stdout });
|
|
15
|
+
rl.question(prompt, (answer) => {
|
|
16
|
+
rl.close();
|
|
17
|
+
res(answer);
|
|
18
|
+
});
|
|
19
|
+
rl.on('error', (e) => {
|
|
20
|
+
rl.close();
|
|
21
|
+
rej(e);
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* 读多行输入;TUI 下经 layout.paintInput 把输入框画在固定底栏、斜杠菜单向上展开进内容区底。
|
|
27
|
+
* 换行:Ctrl+J / Alt+Enter / Shift+Enter(终端区分时)/ 粘贴的 LF。Enter 提交。返回行数组;null=空缓冲 Ctrl+D。
|
|
28
|
+
* 非 TTY 退化为 readline.question(单行,返回 [answer])。raw 模式下 Ctrl+C 先恢复终端再 reject('SIGINT')。
|
|
29
|
+
*
|
|
30
|
+
* 渲染全归 layout(prompt 只持有编辑状态:lines / 光标 / 菜单),prompt 不直接发 ANSI 区域控制,
|
|
31
|
+
* 避免 readline 光标错位与区域越界——颜色仅用在菜单行字符串里(由 layout 原样贴入)。
|
|
32
|
+
*/
|
|
33
|
+
export async function promptWithSlashMenu(opts) {
|
|
34
|
+
if (!layout.isActive()) {
|
|
35
|
+
const a = await questionFallback(opts.prompt);
|
|
36
|
+
return [a];
|
|
37
|
+
}
|
|
38
|
+
const emitter = stdin;
|
|
39
|
+
// initialLines(运行中 typeahead 预填):用调用方给的行初始化;光标置文末。
|
|
40
|
+
// 整篇输入是一个 Seg[]:可编辑段与粘贴块交替。光标永远落在某个可编辑段(segs[curSeg].frozen===false)上。
|
|
41
|
+
let segs = opts.initialLines && opts.initialLines.length > 0
|
|
42
|
+
? [{ frozen: false, text: opts.initialLines.join('\n') }]
|
|
43
|
+
: [{ frozen: false, text: '' }];
|
|
44
|
+
let curSeg = 0; // 光标所在段(始终可编辑)
|
|
45
|
+
let curOff = segs[0].text.length; // 段内字符偏移 = 文末
|
|
46
|
+
let justSawCR = false; // \r\n 合一:粘贴的 \r 折行后,紧跟的 \n 吞掉(避免折两行)
|
|
47
|
+
let menuOpen = false;
|
|
48
|
+
let selected = 0;
|
|
49
|
+
let filtered = [];
|
|
50
|
+
const MENU_MAX_VISIBLE = 7;
|
|
51
|
+
let menuTop = 0; // 窗口首项在 filtered 中的索引,菜单最多显示 MENU_MAX_VISIBLE 条
|
|
52
|
+
/** Ctrl+C 清空前的缓冲快照(Ctrl+Z 还原一次);单次生效,还原后即清。 */
|
|
53
|
+
let undoSnapshot = null;
|
|
54
|
+
/** 二次确认武装截止时刻(绝对 ms);0=未武装。见 submit() 与 MOCODE_CONFIRM_SEND。 */
|
|
55
|
+
let confirmArmedUntil = 0;
|
|
56
|
+
let resolved = false;
|
|
57
|
+
let resolve;
|
|
58
|
+
let reject;
|
|
59
|
+
/** 菜单行(预渲染,带色)——向上展开进内容区底,由 layout 贴入。最多显示 MENU_MAX_VISIBLE 条,支持上下滚动。 */
|
|
60
|
+
function menuLines() {
|
|
61
|
+
if (!menuOpen || filtered.length === 0)
|
|
62
|
+
return [];
|
|
63
|
+
const cols = layout.getGeo().cols;
|
|
64
|
+
const visibleCount = Math.min(MENU_MAX_VISIBLE, filtered.length);
|
|
65
|
+
// 保 selected 在窗口内:selected 顶到上/下边界时才挪 menuTop
|
|
66
|
+
if (selected < menuTop)
|
|
67
|
+
menuTop = selected;
|
|
68
|
+
else if (selected >= menuTop + visibleCount)
|
|
69
|
+
menuTop = selected - visibleCount + 1;
|
|
70
|
+
const windowItems = filtered.slice(menuTop, menuTop + visibleCount);
|
|
71
|
+
const maxName = Math.max(...windowItems.map((item) => displayWidth(item.node.name + (item.node.children?.length ? ' ›' : ''))));
|
|
72
|
+
const hasMoreAbove = menuTop > 0;
|
|
73
|
+
const hasMoreBelow = menuTop + visibleCount < filtered.length;
|
|
74
|
+
return windowItems.map((item, i) => {
|
|
75
|
+
const c = item.node;
|
|
76
|
+
const globalIdx = menuTop + i;
|
|
77
|
+
// 选中项:▸ 与文字均 cyan+bold(去 dim),未选中项保持 dim——选中行整体高亮。
|
|
78
|
+
const isSel = globalIdx === selected;
|
|
79
|
+
const color = isSel ? `${ui.accent}${ui.bold}` : ui.dim;
|
|
80
|
+
const marker = isSel ? `${ui.accent}${ui.bold}▸${ui.reset}` : ' ';
|
|
81
|
+
const branchSuffix = c.children?.length ? ' ›' : '';
|
|
82
|
+
const name = padEndDisplay(c.name + branchSuffix, maxName);
|
|
83
|
+
// 首/末附加滚动指示(▲/▼)而非替换整行
|
|
84
|
+
const desc = c.desc;
|
|
85
|
+
let scrollHint = '';
|
|
86
|
+
if (i === 0 && hasMoreAbove)
|
|
87
|
+
scrollHint = ' ▲';
|
|
88
|
+
if (i === windowItems.length - 1 && hasMoreBelow)
|
|
89
|
+
scrollHint = ' ▼';
|
|
90
|
+
const hintW = displayWidth(scrollHint);
|
|
91
|
+
const descW = cols - maxName - 5 - hintW; // marker + 空格 + 2 间距 + hint
|
|
92
|
+
const descStr = descW > 0 ? truncateDisplay(desc, descW) : '';
|
|
93
|
+
return `${marker} ${color}${name}${ui.reset} ${color}${descStr}${ui.reset}${ui.dim}${scrollHint}${ui.reset}`;
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
/** 单个 chip 的展示 token:[📋 行数 · 字符数] (含尾部空格,使相邻 chip 之间留空隙)。 */
|
|
97
|
+
function chipToken(text) {
|
|
98
|
+
const lineCount = text.split('\n').length;
|
|
99
|
+
const charCount = text.length;
|
|
100
|
+
const lineText = t('prompt.lines', { count: lineCount });
|
|
101
|
+
const charText = t('prompt.chars', {
|
|
102
|
+
count: charCount < 1000 ? charCount : `${(charCount / 1000).toFixed(1)}K`,
|
|
103
|
+
});
|
|
104
|
+
return `[📋 ${lineText} · ${charText}] `;
|
|
105
|
+
}
|
|
106
|
+
/** 构建整段显示:可编辑段按原文(含换行)展开,粘贴块(chip)折叠成一行 token 内联在光标原位。
|
|
107
|
+
* 返回展示行数组,以及每个段在"展示串"(= 展示行以 \n 拼接)中的字符起点,供光标正/反向映射。
|
|
108
|
+
* 关键不变量:展示串与 segs 的"段贡献"长度一致(可编辑段贡献其原文长度,含其中 \n;
|
|
109
|
+
* chip 贡献其 token 长度,无 \n),故 segStart 同时是 dispLines.join('\n') 内的字符偏移。 */
|
|
110
|
+
function buildDisplay() {
|
|
111
|
+
const out = [];
|
|
112
|
+
const segStart = [];
|
|
113
|
+
let gLen = 0;
|
|
114
|
+
for (const s of segs) {
|
|
115
|
+
segStart.push(gLen);
|
|
116
|
+
if (!s.frozen) {
|
|
117
|
+
const ls = s.text.split('\n');
|
|
118
|
+
if (out.length === 0)
|
|
119
|
+
out.push(...ls);
|
|
120
|
+
else {
|
|
121
|
+
out[out.length - 1] += ls[0];
|
|
122
|
+
for (let k = 1; k < ls.length; k++)
|
|
123
|
+
out.push(ls[k]);
|
|
124
|
+
}
|
|
125
|
+
gLen += s.text.length;
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
const tok = chipToken(s.text);
|
|
129
|
+
if (out.length === 0)
|
|
130
|
+
out.push(tok);
|
|
131
|
+
else
|
|
132
|
+
out[out.length - 1] += tok;
|
|
133
|
+
gLen += tok.length;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (out.length === 0)
|
|
137
|
+
out.push('');
|
|
138
|
+
return { lines: out, segStart };
|
|
139
|
+
}
|
|
140
|
+
/** 光标 → 展示 (行, 列)。展示行内的列以显示宽度计。 */
|
|
141
|
+
function cursorDisp() {
|
|
142
|
+
const { lines: dispLines, segStart } = buildDisplay();
|
|
143
|
+
const off = segStart[curSeg] + curOff;
|
|
144
|
+
let remaining = off;
|
|
145
|
+
let dl = 0;
|
|
146
|
+
let dispColChars = 0;
|
|
147
|
+
for (; dl < dispLines.length; dl++) {
|
|
148
|
+
const L = dispLines[dl].length;
|
|
149
|
+
if (remaining <= L) {
|
|
150
|
+
dispColChars = remaining;
|
|
151
|
+
break;
|
|
152
|
+
}
|
|
153
|
+
remaining -= L + 1;
|
|
154
|
+
}
|
|
155
|
+
if (dl >= dispLines.length) {
|
|
156
|
+
dl = Math.max(0, dispLines.length - 1);
|
|
157
|
+
dispColChars = dispLines[dl].length;
|
|
158
|
+
}
|
|
159
|
+
return { line: dl, col: displayWidth(dispLines[dl].slice(0, dispColChars)) };
|
|
160
|
+
}
|
|
161
|
+
/** 展示 (行, 显示列) → 段内字符偏移。供 ↑/↓ 移动后重新定位,以及鼠标点击的反向映射。 */
|
|
162
|
+
function dispColToCharInLine(dispLine, dispCol) {
|
|
163
|
+
const { lines } = buildDisplay();
|
|
164
|
+
const ln = lines[dispLine] ?? '';
|
|
165
|
+
return visColToCharCol(ln, dispCol);
|
|
166
|
+
}
|
|
167
|
+
/** 展示 (行, 字符内偏移) → 段+偏移。光标永远落在可编辑段;点中 chip 则吸附到最近的可编辑边界。 */
|
|
168
|
+
function mapDispToCursor(dispLine, charInLine) {
|
|
169
|
+
const { lines: dispLines, segStart } = buildDisplay();
|
|
170
|
+
let off = 0;
|
|
171
|
+
for (let k = 0; k < dispLine && k < dispLines.length; k++)
|
|
172
|
+
off += dispLines[k].length + 1;
|
|
173
|
+
off += Math.max(0, Math.min(charInLine, (dispLines[dispLine] ?? '').length));
|
|
174
|
+
for (let i = 0; i < segs.length; i++) {
|
|
175
|
+
const start = segStart[i];
|
|
176
|
+
const len = segs[i].frozen ? chipToken(segs[i].text).length : segs[i].text.length;
|
|
177
|
+
// 可编辑段用闭区间;只读块用半开区间——其结束边界归紧随其后的可编辑段,
|
|
178
|
+
// 否则光标恰好停在 chip 后的可编辑段开头时,会被误判落在 chip 上(边界争用)。
|
|
179
|
+
const inside = segs[i].frozen ? off >= start && off < start + len : off >= start && off <= start + len;
|
|
180
|
+
if (inside) {
|
|
181
|
+
if (!segs[i].frozen)
|
|
182
|
+
return { seg: i, off: off - start };
|
|
183
|
+
// 落在只读块内:按就近原则吸附——靠近左沿→其前可编辑段末尾,靠近右沿→其后可编辑段开头。
|
|
184
|
+
const distStart = off - start;
|
|
185
|
+
const distEnd = start + len - off;
|
|
186
|
+
return distStart <= distEnd ? editableEndBefore(i) : editableStartAfter(i);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return lastEditableEnd();
|
|
190
|
+
}
|
|
191
|
+
function editableStartAfter(i) {
|
|
192
|
+
let j = i + 1;
|
|
193
|
+
while (j < segs.length && segs[j].frozen)
|
|
194
|
+
j++;
|
|
195
|
+
if (j < segs.length)
|
|
196
|
+
return { seg: j, off: 0 };
|
|
197
|
+
return lastEditableEnd();
|
|
198
|
+
}
|
|
199
|
+
function editableEndBefore(i) {
|
|
200
|
+
let j = i - 1;
|
|
201
|
+
while (j >= 0 && segs[j].frozen)
|
|
202
|
+
j--;
|
|
203
|
+
if (j >= 0)
|
|
204
|
+
return { seg: j, off: segs[j].text.length };
|
|
205
|
+
return firstEditableStart();
|
|
206
|
+
}
|
|
207
|
+
function firstEditableStart() {
|
|
208
|
+
for (let j = 0; j < segs.length; j++)
|
|
209
|
+
if (!segs[j].frozen)
|
|
210
|
+
return { seg: j, off: 0 };
|
|
211
|
+
return { seg: 0, off: 0 };
|
|
212
|
+
}
|
|
213
|
+
function lastEditableEnd() {
|
|
214
|
+
for (let j = segs.length - 1; j >= 0; j--)
|
|
215
|
+
if (!segs[j].frozen)
|
|
216
|
+
return { seg: j, off: segs[j].text.length };
|
|
217
|
+
return { seg: 0, off: 0 };
|
|
218
|
+
}
|
|
219
|
+
/** 在光标处插入文本(含换行则落进当前可编辑段,原样保留)。供短粘贴与可打印字符共用。 */
|
|
220
|
+
function insertText(text) {
|
|
221
|
+
const s = segs[curSeg].text;
|
|
222
|
+
segs[curSeg].text = s.slice(0, curOff) + text + s.slice(curOff);
|
|
223
|
+
curOff += text.length;
|
|
224
|
+
}
|
|
225
|
+
/** 落一段粘贴文本:长粘贴 → 在此处插入一个只读 chip(把当前可编辑段从光标切开成「前段 + chip + 后段」),
|
|
226
|
+
* 短粘贴 → 作为可编辑文本插入。连续长粘贴会产生相邻 chip,各自独立;两次粘贴之间敲的字是可编辑段,
|
|
227
|
+
* 可直接编辑、也可退格删掉任一 chip。光标落在 chip 之后的可编辑段(即使为空也保留,保证光标有落点)。 */
|
|
228
|
+
function applyPastedText(buf) {
|
|
229
|
+
if (!buf)
|
|
230
|
+
return;
|
|
231
|
+
const isLong = buf.split('\n').length > 8 || buf.length > 200;
|
|
232
|
+
if (!isLong) {
|
|
233
|
+
insertText(buf);
|
|
234
|
+
computeFiltered();
|
|
235
|
+
redraw();
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
const s = segs[curSeg].text;
|
|
239
|
+
const before = s.slice(0, curOff);
|
|
240
|
+
const after = s.slice(curOff);
|
|
241
|
+
const newSegs = [];
|
|
242
|
+
if (before)
|
|
243
|
+
newSegs.push({ frozen: false, text: before });
|
|
244
|
+
newSegs.push({ frozen: true, text: buf });
|
|
245
|
+
newSegs.push({ frozen: false, text: after }); // 始终保留可编辑后缀(可为空),保证光标有落点
|
|
246
|
+
segs.splice(curSeg, 1, ...newSegs);
|
|
247
|
+
curSeg = curSeg + (before ? 2 : 1); // 落到 chip 之后的可编辑段
|
|
248
|
+
curOff = 0;
|
|
249
|
+
computeFiltered();
|
|
250
|
+
redraw();
|
|
251
|
+
}
|
|
252
|
+
/** 粘贴结束:长粘贴(>8 行或 >200 字符)落/并进 chip(原子,整段封预览),短粘贴落为可编辑文本。 */
|
|
253
|
+
function finalizePaste() {
|
|
254
|
+
if (resolved) {
|
|
255
|
+
pasteState.pasteParts = [];
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
const buf = pasteState.pasteParts.join('');
|
|
259
|
+
pasteState.pasteParts = [];
|
|
260
|
+
justSawCR = false;
|
|
261
|
+
applyPastedText(buf);
|
|
262
|
+
}
|
|
263
|
+
/** 鼠标右键单击输入框(未拖动)时 layout 读剪贴板后回调:与键盘粘贴走同一落地逻辑(长/短判定)。 */
|
|
264
|
+
function onMousePaste(text) {
|
|
265
|
+
if (resolved)
|
|
266
|
+
return;
|
|
267
|
+
applyPastedText(text.replace(/\r\n/g, '\n').replace(/\r/g, '\n'));
|
|
268
|
+
}
|
|
269
|
+
/** 根据输入文本找到当前层。prefix 始终包含已进入分支及末尾空格。 */
|
|
270
|
+
function menuContext(input) {
|
|
271
|
+
let nodes = opts.commands;
|
|
272
|
+
let prefix = '';
|
|
273
|
+
while (true) {
|
|
274
|
+
const branch = nodes.find((node) => node.children?.length && input.startsWith(`${prefix}${node.name} `));
|
|
275
|
+
if (!branch?.children)
|
|
276
|
+
return { nodes, prefix };
|
|
277
|
+
prefix = `${prefix}${branch.name} `;
|
|
278
|
+
nodes = branch.children;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
function computeFiltered() {
|
|
282
|
+
const firstLine = buildDisplay().lines[0] ?? '';
|
|
283
|
+
if (firstLine.startsWith('/')) {
|
|
284
|
+
const { nodes, prefix } = menuContext(firstLine);
|
|
285
|
+
filtered = nodes
|
|
286
|
+
.map((node) => ({ node, input: `${prefix}${node.name}` }))
|
|
287
|
+
.filter((item) => item.input.startsWith(firstLine));
|
|
288
|
+
menuOpen = filtered.length > 0;
|
|
289
|
+
if (selected >= filtered.length) {
|
|
290
|
+
selected = 0;
|
|
291
|
+
menuTop = 0;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
else {
|
|
295
|
+
filtered = [];
|
|
296
|
+
menuOpen = false;
|
|
297
|
+
menuTop = 0;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
function redraw() {
|
|
301
|
+
const { lines } = buildDisplay();
|
|
302
|
+
const cur = cursorDisp();
|
|
303
|
+
// 空输入引导:缓冲为空时输入框内画 dim ghost 占位(含 /help 提示),让用户知道怎么开始;
|
|
304
|
+
// 一旦有输入(打字/粘贴/补全)isEmpty 变 false,下一帧占位即消失。
|
|
305
|
+
const isEmpty = segs.length === 1 && !segs[0].frozen && segs[0].text === '';
|
|
306
|
+
// 二次确认已武装:把提示画成菜单行(向上展开进内容区底,输入框上方),黄色 + 图标。
|
|
307
|
+
// 不复用 placeholder —— layout 只在输入行为空时画 placeholder,而武装态必然有内容。
|
|
308
|
+
const armed = confirmArmedUntil > Date.now();
|
|
309
|
+
const menu = menuLines();
|
|
310
|
+
const menuView = menu.length
|
|
311
|
+
? { lines: menu }
|
|
312
|
+
: armed
|
|
313
|
+
? { lines: [`${ui.yellow}${t('prompt.confirmSendHint')}${ui.reset}`] }
|
|
314
|
+
: null;
|
|
315
|
+
layout.paintInput({
|
|
316
|
+
prompt: opts.prompt,
|
|
317
|
+
lines,
|
|
318
|
+
cursorLine: cur.line,
|
|
319
|
+
cursorCol: cur.col,
|
|
320
|
+
menu: menuView,
|
|
321
|
+
placeholder: isEmpty ? t('prompt.emptyPlaceholder') : undefined,
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
/** 鼠标点击输入框:layout 已把"屏 tap 位置"以 (flatIdx, inSegVis) 形式传进来
|
|
325
|
+
* (flatIdx = flat 中的绝对段号;inSegVis = 段内显示列)。本函数把点击位还原成 (段, 偏移) → redraw。
|
|
326
|
+
* 点中只读粘贴块时吸附到最近的可编辑边界(见 mapDispToCursor)。 */
|
|
327
|
+
function applyExternalCursor(flatIdx, inSegVis) {
|
|
328
|
+
const g = layout.getGeo();
|
|
329
|
+
const promptW = displayWidth(opts.prompt);
|
|
330
|
+
const W = Math.max(1, g.cols - promptW);
|
|
331
|
+
const { lines: dispLs } = buildDisplay();
|
|
332
|
+
const lineVis = dispLs.map((l) => wrapByDisplayWidth(l, W));
|
|
333
|
+
const flat = [];
|
|
334
|
+
for (const lv of lineVis)
|
|
335
|
+
for (const r of lv)
|
|
336
|
+
flat.push(r);
|
|
337
|
+
let dispLine;
|
|
338
|
+
let charInLine;
|
|
339
|
+
if (flat.length === 0) {
|
|
340
|
+
dispLine = 0;
|
|
341
|
+
charInLine = 0;
|
|
342
|
+
}
|
|
343
|
+
else {
|
|
344
|
+
const safeIdx = Math.max(0, Math.min(flatIdx, flat.length - 1));
|
|
345
|
+
const seg = flat[safeIdx];
|
|
346
|
+
const segW = displayWidth(seg);
|
|
347
|
+
const safeInSeg = Math.max(0, Math.min(inSegVis, segW));
|
|
348
|
+
const inChar = visColToCharCol(seg, safeInSeg);
|
|
349
|
+
// safeIdx → (dispLine, segInLine)
|
|
350
|
+
let dl = 0;
|
|
351
|
+
let segInLine = safeIdx;
|
|
352
|
+
while (dl < lineVis.length && segInLine >= lineVis[dl].length) {
|
|
353
|
+
segInLine -= lineVis[dl].length;
|
|
354
|
+
dl++;
|
|
355
|
+
}
|
|
356
|
+
if (dl >= lineVis.length) {
|
|
357
|
+
dl = lineVis.length - 1;
|
|
358
|
+
segInLine = lineVis[dl].length - 1;
|
|
359
|
+
}
|
|
360
|
+
let segStartChars = 0;
|
|
361
|
+
const segsVis = lineVis[dl];
|
|
362
|
+
for (let s = 0; s < segInLine; s++)
|
|
363
|
+
segStartChars += segsVis[s]?.length ?? 0;
|
|
364
|
+
charInLine = Math.max(0, Math.min(segStartChars + inChar, dispLs[dl]?.length ?? 0));
|
|
365
|
+
dispLine = dl;
|
|
366
|
+
}
|
|
367
|
+
const { seg, off } = mapDispToCursor(dispLine, charInLine);
|
|
368
|
+
curSeg = seg;
|
|
369
|
+
curOff = off;
|
|
370
|
+
// 收起菜单/过滤态:点击输入框关闭菜单,回到纯文本编辑。
|
|
371
|
+
if (menuOpen) {
|
|
372
|
+
menuOpen = false;
|
|
373
|
+
filtered = [];
|
|
374
|
+
selected = 0;
|
|
375
|
+
menuTop = 0;
|
|
376
|
+
}
|
|
377
|
+
justSawCR = false;
|
|
378
|
+
computeFiltered();
|
|
379
|
+
redraw();
|
|
380
|
+
}
|
|
381
|
+
function cleanup() {
|
|
382
|
+
layout.setPasteHandler(null);
|
|
383
|
+
layout.setCursorChangeHandler(null);
|
|
384
|
+
try {
|
|
385
|
+
stdin.setRawMode(false);
|
|
386
|
+
}
|
|
387
|
+
catch {
|
|
388
|
+
// 忽略
|
|
389
|
+
}
|
|
390
|
+
emitter.removeListener('keypress', onKey);
|
|
391
|
+
if (pasteState.pasteTimer) {
|
|
392
|
+
clearTimeout(pasteState.pasteTimer);
|
|
393
|
+
pasteState.pasteTimer = null;
|
|
394
|
+
}
|
|
395
|
+
pasteState.pasting = false;
|
|
396
|
+
pasteState.pasteParts = [];
|
|
397
|
+
pasteState.onPasteEnd = null;
|
|
398
|
+
stdin.pause();
|
|
399
|
+
}
|
|
400
|
+
function finish(value) {
|
|
401
|
+
if (resolved)
|
|
402
|
+
return;
|
|
403
|
+
resolved = true;
|
|
404
|
+
cleanup();
|
|
405
|
+
resolve(value);
|
|
406
|
+
}
|
|
407
|
+
/**
|
|
408
|
+
* 应用当前菜单项。分支节点只进入下一层;叶子节点补全 value/input。
|
|
409
|
+
* 返回 true 表示调用方应立即提交,false 表示仍留在输入态。
|
|
410
|
+
*/
|
|
411
|
+
function applySelected(submitLeaf) {
|
|
412
|
+
const item = filtered[selected];
|
|
413
|
+
if (!menuOpen || !item)
|
|
414
|
+
return submitLeaf;
|
|
415
|
+
if (item.node.children?.length) {
|
|
416
|
+
segs = [{ frozen: false, text: `${item.input} ` }];
|
|
417
|
+
curSeg = 0;
|
|
418
|
+
curOff = segs[0].text.length;
|
|
419
|
+
selected = 0;
|
|
420
|
+
menuTop = 0;
|
|
421
|
+
computeFiltered();
|
|
422
|
+
redraw();
|
|
423
|
+
return false;
|
|
424
|
+
}
|
|
425
|
+
segs = [{ frozen: false, text: item.node.value ?? item.input }];
|
|
426
|
+
curSeg = 0;
|
|
427
|
+
curOff = segs[0].text.length;
|
|
428
|
+
computeFiltered();
|
|
429
|
+
if (!submitLeaf || item.node.submit === false) {
|
|
430
|
+
redraw();
|
|
431
|
+
return false;
|
|
432
|
+
}
|
|
433
|
+
return true;
|
|
434
|
+
}
|
|
435
|
+
/** 提交:菜单打开时先应用选中项;分支进入子菜单,需要参数的叶子只补全。
|
|
436
|
+
* 段序列直接按原文(可编辑段原文 + 粘贴块原文)拼接——所见即所得,不再额外插换行。
|
|
437
|
+
*
|
|
438
|
+
* 二次确认(MOCODE_CONFIRM_SEND=long,默认关):长/多行输入首次 Enter 只"武装"并显示提示行,
|
|
439
|
+
* CONFIRM_SEND_MS 内再按一次 Enter 才真发;任何其它键都会解除武装(见 onKey 顶部)——
|
|
440
|
+
* 误触一次 Enter 发不出去,而正常发送只是多敲一下。 */
|
|
441
|
+
function submit() {
|
|
442
|
+
if (menuOpen && !applySelected(true))
|
|
443
|
+
return;
|
|
444
|
+
const content = segs.map((s) => s.text).join('');
|
|
445
|
+
const now = Date.now();
|
|
446
|
+
if (confirmSendMode() === 'long' && isLongInput(content) && now > confirmArmedUntil) {
|
|
447
|
+
confirmArmedUntil = now + CONFIRM_SEND_MS;
|
|
448
|
+
redraw();
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
confirmArmedUntil = 0;
|
|
452
|
+
finish(content === '' ? [''] : content.split('\n'));
|
|
453
|
+
}
|
|
454
|
+
/** 长输入判定:≥2 行 或 ≥120 码点(单行短消息不触发二次确认,免得打扰日常)。 */
|
|
455
|
+
function isLongInput(text) {
|
|
456
|
+
if (text.indexOf('\n') >= 0)
|
|
457
|
+
return true;
|
|
458
|
+
return [...text].length >= LONG_INPUT_CHARS;
|
|
459
|
+
}
|
|
460
|
+
/** 插换行:在光标处断行(落进当前可编辑段)。 */
|
|
461
|
+
function insertNewline() {
|
|
462
|
+
insertText('\n');
|
|
463
|
+
computeFiltered();
|
|
464
|
+
redraw();
|
|
465
|
+
}
|
|
466
|
+
/** 光标移到当前可编辑段内的行首/行尾(不含段内 \n)。 */
|
|
467
|
+
function moveToLineStart() {
|
|
468
|
+
const s = segs[curSeg].text;
|
|
469
|
+
curOff = s.lastIndexOf('\n', curOff - 1) + 1;
|
|
470
|
+
redraw();
|
|
471
|
+
}
|
|
472
|
+
function moveToLineEnd() {
|
|
473
|
+
const s = segs[curSeg].text;
|
|
474
|
+
const ne = s.indexOf('\n', curOff);
|
|
475
|
+
curOff = ne < 0 ? s.length : ne;
|
|
476
|
+
redraw();
|
|
477
|
+
}
|
|
478
|
+
/** Ctrl+U:删到行首(当前可编辑段内的当前行;已在行首则 no-op,不跨入相邻粘贴块)。 */
|
|
479
|
+
function deleteToLineStart() {
|
|
480
|
+
const s = segs[curSeg].text;
|
|
481
|
+
const ls = s.lastIndexOf('\n', curOff - 1) + 1;
|
|
482
|
+
if (ls === curOff)
|
|
483
|
+
return;
|
|
484
|
+
segs[curSeg].text = s.slice(0, ls) + s.slice(curOff);
|
|
485
|
+
curOff = ls;
|
|
486
|
+
computeFiltered();
|
|
487
|
+
redraw();
|
|
488
|
+
}
|
|
489
|
+
/** Ctrl+K:删到行尾(当前可编辑段内的当前行;已在行尾则 no-op,不跨入相邻粘贴块)。 */
|
|
490
|
+
function deleteToLineEnd() {
|
|
491
|
+
const s = segs[curSeg].text;
|
|
492
|
+
const ne = s.indexOf('\n', curOff);
|
|
493
|
+
if (ne < 0) {
|
|
494
|
+
if (curOff === s.length)
|
|
495
|
+
return;
|
|
496
|
+
segs[curSeg].text = s.slice(0, curOff);
|
|
497
|
+
}
|
|
498
|
+
else {
|
|
499
|
+
segs[curSeg].text = s.slice(0, curOff) + s.slice(ne + 1);
|
|
500
|
+
}
|
|
501
|
+
computeFiltered();
|
|
502
|
+
redraw();
|
|
503
|
+
}
|
|
504
|
+
/** Ctrl+Z:还原被 Ctrl+C 清掉的输入(单次)。 */
|
|
505
|
+
function restoreUndo() {
|
|
506
|
+
const snap = undoSnapshot;
|
|
507
|
+
if (!snap)
|
|
508
|
+
return;
|
|
509
|
+
segs = snap.segs.map((s) => ({ ...s }));
|
|
510
|
+
curSeg = Math.min(snap.curSeg, segs.length - 1);
|
|
511
|
+
curOff = Math.min(snap.curOff, segs[curSeg].text.length);
|
|
512
|
+
undoSnapshot = null;
|
|
513
|
+
computeFiltered();
|
|
514
|
+
redraw();
|
|
515
|
+
}
|
|
516
|
+
/**
|
|
517
|
+
* 清空输入(含粘贴块 / 斜杠菜单 / 粘贴缓冲):Ctrl+C 在有内容时调用——清空而非退出。
|
|
518
|
+
* 清空前留一份快照供 Ctrl+Z 还原——手滑按在输入框上时,正在写的长 prompt 不至于白打。
|
|
519
|
+
*/
|
|
520
|
+
function clearInput() {
|
|
521
|
+
if (layout.isScrolled())
|
|
522
|
+
layout.resetScroll(); // 回尾(若滚动回看),再清空
|
|
523
|
+
undoSnapshot = { segs: segs.map((s) => ({ ...s })), curSeg, curOff };
|
|
524
|
+
segs = [{ frozen: false, text: '' }];
|
|
525
|
+
curSeg = 0;
|
|
526
|
+
curOff = 0;
|
|
527
|
+
menuOpen = false;
|
|
528
|
+
filtered = [];
|
|
529
|
+
selected = 0;
|
|
530
|
+
menuTop = 0;
|
|
531
|
+
if (pasteState.pasteTimer) {
|
|
532
|
+
clearTimeout(pasteState.pasteTimer);
|
|
533
|
+
pasteState.pasteTimer = null;
|
|
534
|
+
}
|
|
535
|
+
pasteState.pasting = false;
|
|
536
|
+
pasteState.pasteParts = [];
|
|
537
|
+
justSawCR = false;
|
|
538
|
+
computeFiltered();
|
|
539
|
+
redraw();
|
|
540
|
+
}
|
|
541
|
+
/** Ctrl+C:有内容(已打字 / 多行 / 粘贴块 / 菜单草稿 / 粘贴缓冲 / 粘贴中)则清空,再按一次(空)才退出(仿 fish)。 */
|
|
542
|
+
function onCtrlC() {
|
|
543
|
+
const flat = segs.map((s) => s.text).join('');
|
|
544
|
+
const hasContent = flat.length > 0 || menuOpen || pasteState.pasteParts.length > 0 || pasteState.pasting;
|
|
545
|
+
if (hasContent) {
|
|
546
|
+
clearInput();
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
cleanup();
|
|
550
|
+
reject(new Error('SIGINT'));
|
|
551
|
+
}
|
|
552
|
+
function onKey(_str, key) {
|
|
553
|
+
if (resolved || !key)
|
|
554
|
+
return;
|
|
555
|
+
// 鼠标 fragment(SGR 报表被 readline 拆碎):mouse.swallow 重组并派发 MouseEvent 给
|
|
556
|
+
// layout.handleMouseEvent(滚轮/框选/复制全在那处理);此处只需吞掉 fragment 不进 pasteParts/输入框。
|
|
557
|
+
if (mouse.swallow(key.sequence ?? ''))
|
|
558
|
+
return;
|
|
559
|
+
// 二次确认已武装:任何非「提交键」都解除武装(继续编辑 / 换行 / 删除都算放弃这次提交)。
|
|
560
|
+
if (confirmArmedUntil) {
|
|
561
|
+
const isSubmitKey = (key.name === 'return' || key.name === 'enter') && !key.shift && !key.meta && !key.ctrl;
|
|
562
|
+
if (!isSubmitKey) {
|
|
563
|
+
confirmArmedUntil = 0;
|
|
564
|
+
redraw();
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
// Shift+Tab:循环切换 agent 模式(auto ↔ plan)。不插字符、不提交、不影响输入文本;
|
|
568
|
+
// 回调由 repl 注入(翻 agentMode + 重写 history[0] + 设状态行 modeTag),再 redraw() 经
|
|
569
|
+
// paintInput 重画底栏(状态行 chip 即时刷新 + 光标留输入框)。置于 case 'tab' 之前,故不触发菜单补全。
|
|
570
|
+
if (key.shift && key.name === 'tab') {
|
|
571
|
+
opts.onCycleMode?.();
|
|
572
|
+
redraw();
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
// Ctrl+C:有内容则清空,再按一次(空)才退出——置顶,使粘贴中也能被截到(否则被 pasting 分支吞掉)
|
|
576
|
+
if (key.ctrl && key.name === 'c') {
|
|
577
|
+
onCtrlC();
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
// 粘贴中:把键累积进 pasteParts(换行 \r\n 合一),不编辑 lines;末尾 finalizePaste 统一落 chip/文本
|
|
581
|
+
if (pasteState.pasting) {
|
|
582
|
+
const s = key.sequence ?? '';
|
|
583
|
+
const isReturn = key.name === 'return' || key.name === 'enter';
|
|
584
|
+
if (s === '\n' && justSawCR) {
|
|
585
|
+
justSawCR = false; // \r\n 的 \n:已随 \r 折行,吞掉
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
if (isReturn || s === '\r' || s === '\n') {
|
|
589
|
+
justSawCR = s === '\r'; // \r 标记,待可能的尾随 \n
|
|
590
|
+
pasteState.pasteParts.push('\n');
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
justSawCR = false;
|
|
594
|
+
// FEFF(BOM 字符)过滤:其他应用写入剪贴板的正文可能带 BOM,终端级粘贴逐字符灌入时
|
|
595
|
+
// 会敲进输入框(charWidth 按 1 列算但终端渲染成缺字形方块)
|
|
596
|
+
if (s && s >= ' ' && s !== '\uFEFF' && !key.ctrl && !key.meta)
|
|
597
|
+
pasteState.pasteParts.push(s);
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
// 滚动回看键(优先;不触发回尾):PgUp/PgDn 翻页,Ctrl+↑/↓ 每次 5 行。
|
|
601
|
+
// 裸 ↑/↓ 优先给斜杠菜单与多行光标,二者都不适用时按滚轮量滚动回看(与鼠标滚轮同一入口)。
|
|
602
|
+
if (key.name === 'pageup' || key.name === 'pagedown' || (key.ctrl && (key.name === 'up' || key.name === 'down'))) {
|
|
603
|
+
const pageH = layout.getGeo().contentBottom;
|
|
604
|
+
if (key.name === 'pageup')
|
|
605
|
+
layout.scrollBy(pageH);
|
|
606
|
+
else if (key.name === 'pagedown')
|
|
607
|
+
layout.scrollBy(-pageH);
|
|
608
|
+
else if (key.name === 'up')
|
|
609
|
+
layout.scrollBy(5);
|
|
610
|
+
else
|
|
611
|
+
layout.scrollBy(-5);
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
// 滚动回看时打字 / 编辑不回尾(保持历史视图,便于边看历史边编辑);仅 Enter 提交时回尾(见下方 submit 前)。
|
|
615
|
+
// Ctrl+C 已在 onKey 顶部统一处理(清空或退出);Ctrl+D:空缓冲退出,否则忽略
|
|
616
|
+
if (key.ctrl && key.name === 'd') {
|
|
617
|
+
if (segs.map((s) => s.text).join('') === '')
|
|
618
|
+
finish(null);
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
if (key.ctrl && key.name === 'a') {
|
|
622
|
+
moveToLineStart();
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
if (key.ctrl && key.name === 'q') {
|
|
626
|
+
// 行首的对称搭档:与 Ctrl+E(行尾)同处顶行相邻(Q/E),左=起始右=结束。
|
|
627
|
+
// Ctrl+A 仍作别名保留(全 app 一致:运行中预填、权限面板都用 Ctrl+A 跳开头)。
|
|
628
|
+
moveToLineStart();
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
if (key.ctrl && key.name === 'e') {
|
|
632
|
+
moveToLineEnd();
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
// Ctrl+Z:还原被 Ctrl+C 清掉的输入。无快照时 no-op。
|
|
636
|
+
if (key.ctrl && key.name === 'z') {
|
|
637
|
+
restoreUndo();
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
// 行级删除:Ctrl+U 删到行首,Ctrl+K 删到行尾(均在当前可编辑段内,不跨入相邻粘贴块)。
|
|
641
|
+
// 词级删除/跳转(Ctrl+W / Ctrl+← 等)刻意不做:终端序列各终端发得不一样
|
|
642
|
+
// (Ctrl+Backspace 在多数终端发 \x08,与裸退格无从区分),做一半会变成按了没反应的哑键。
|
|
643
|
+
if (key.ctrl && key.name === 'u') {
|
|
644
|
+
deleteToLineStart();
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
if (key.ctrl && key.name === 'k') {
|
|
648
|
+
deleteToLineEnd();
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
// Ctrl+G:打开输入面板弹窗(记事本式编辑,Enter=换行不发送;确认只填回不发送)。
|
|
652
|
+
if (key.ctrl && key.name === 'g') {
|
|
653
|
+
void openComposer();
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
// Ctrl+R / Ctrl+P:历史模糊搜索。Ctrl+R 对齐 bash 反向搜索语义,Ctrl+P 对齐 previous-history 语义,
|
|
657
|
+
// 两者是同一个面板——记住哪个都行。
|
|
658
|
+
if (key.ctrl && (key.name === 'r' || key.name === 'p')) {
|
|
659
|
+
void openHistorySearch();
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
const isReturn = key.name === 'return' || key.name === 'enter';
|
|
663
|
+
// 换行:Ctrl+J / Ctrl+Enter / Alt+Enter(meta)/ Shift+Enter(终端区分时)/ lone LF
|
|
664
|
+
// (粘贴的 CR/LF 已在上方 pasting 分支累积进 pasteParts,不会到此)
|
|
665
|
+
// Ctrl+Enter:readline 解析得到 key.ctrl=true && isReturn 走这条路;少数老 xterm 把
|
|
666
|
+
// Ctrl+Enter 当裸 \r 发(没 ctrl flag)→ 落到下方「提交」分支做 Enter 处理,
|
|
667
|
+
// 此时用 Ctrl+J 兜底。
|
|
668
|
+
const wantNewline = (key.ctrl && key.name === 'j') ||
|
|
669
|
+
(key.ctrl && isReturn) ||
|
|
670
|
+
(key.meta && isReturn) ||
|
|
671
|
+
(key.shift && isReturn) ||
|
|
672
|
+
(key.sequence === '\n' && !key.ctrl);
|
|
673
|
+
if (wantNewline) {
|
|
674
|
+
insertNewline();
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
// 提交(键盘 plain Enter):回尾(若滚动回看)再发送——发消息时跳到底部开始 agent 输出
|
|
678
|
+
if (isReturn && !key.shift && !key.meta && !key.ctrl) {
|
|
679
|
+
if (layout.isScrolled())
|
|
680
|
+
layout.resetScroll();
|
|
681
|
+
submit();
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
switch (key.name) {
|
|
685
|
+
case 'backspace': {
|
|
686
|
+
const s = segs[curSeg].text;
|
|
687
|
+
if (curOff > 0) {
|
|
688
|
+
segs[curSeg].text = s.slice(0, curOff - 1) + s.slice(curOff);
|
|
689
|
+
curOff--;
|
|
690
|
+
computeFiltered();
|
|
691
|
+
redraw();
|
|
692
|
+
}
|
|
693
|
+
else if (curSeg > 0) {
|
|
694
|
+
const prev = segs[curSeg - 1];
|
|
695
|
+
if (prev.frozen) {
|
|
696
|
+
// 紧贴粘贴块后沿退格:删整块(原子)
|
|
697
|
+
segs.splice(curSeg - 1, 1);
|
|
698
|
+
curSeg--;
|
|
699
|
+
curOff = 0;
|
|
700
|
+
}
|
|
701
|
+
else {
|
|
702
|
+
// 并回上一个可编辑段
|
|
703
|
+
prev.text += segs[curSeg].text;
|
|
704
|
+
const joinOff = prev.text.length - segs[curSeg].text.length;
|
|
705
|
+
segs.splice(curSeg, 1);
|
|
706
|
+
curSeg--;
|
|
707
|
+
curOff = joinOff;
|
|
708
|
+
}
|
|
709
|
+
computeFiltered();
|
|
710
|
+
redraw();
|
|
711
|
+
}
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
case 'delete': {
|
|
715
|
+
const s = segs[curSeg].text;
|
|
716
|
+
if (curOff < s.length) {
|
|
717
|
+
segs[curSeg].text = s.slice(0, curOff) + s.slice(curOff + 1);
|
|
718
|
+
computeFiltered();
|
|
719
|
+
redraw();
|
|
720
|
+
}
|
|
721
|
+
else if (curSeg < segs.length - 1) {
|
|
722
|
+
const next = segs[curSeg + 1];
|
|
723
|
+
if (next.frozen)
|
|
724
|
+
segs.splice(curSeg + 1, 1); // 并掉下一个粘贴块
|
|
725
|
+
else {
|
|
726
|
+
segs[curSeg].text += next.text;
|
|
727
|
+
segs.splice(curSeg + 1, 1);
|
|
728
|
+
}
|
|
729
|
+
computeFiltered();
|
|
730
|
+
redraw();
|
|
731
|
+
}
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
case 'up':
|
|
735
|
+
if (menuOpen && filtered.length) {
|
|
736
|
+
selected = (selected - 1 + filtered.length) % filtered.length;
|
|
737
|
+
redraw();
|
|
738
|
+
}
|
|
739
|
+
else {
|
|
740
|
+
const { line, col } = cursorDisp();
|
|
741
|
+
if (line > 0) {
|
|
742
|
+
const m = mapDispToCursor(line - 1, dispColToCharInLine(line - 1, col));
|
|
743
|
+
curSeg = m.seg;
|
|
744
|
+
curOff = m.off;
|
|
745
|
+
redraw();
|
|
746
|
+
}
|
|
747
|
+
else {
|
|
748
|
+
layout.scrollWheel(1); // 等同鼠标滚轮上滚一格
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
return;
|
|
752
|
+
case 'down':
|
|
753
|
+
if (menuOpen && filtered.length) {
|
|
754
|
+
selected = (selected + 1) % filtered.length;
|
|
755
|
+
redraw();
|
|
756
|
+
}
|
|
757
|
+
else {
|
|
758
|
+
const { lines: dispLines } = buildDisplay();
|
|
759
|
+
const { line, col } = cursorDisp();
|
|
760
|
+
if (line < dispLines.length - 1) {
|
|
761
|
+
const m = mapDispToCursor(line + 1, dispColToCharInLine(line + 1, col));
|
|
762
|
+
curSeg = m.seg;
|
|
763
|
+
curOff = m.off;
|
|
764
|
+
redraw();
|
|
765
|
+
}
|
|
766
|
+
else {
|
|
767
|
+
layout.scrollWheel(-1); // 等同鼠标滚轮下滚一格
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
return;
|
|
771
|
+
case 'tab':
|
|
772
|
+
if (menuOpen && filtered[selected])
|
|
773
|
+
applySelected(false);
|
|
774
|
+
return;
|
|
775
|
+
case 'escape': {
|
|
776
|
+
const { prefix } = menuContext(buildDisplay().lines[0] ?? '');
|
|
777
|
+
if (prefix) {
|
|
778
|
+
// 子层 Esc 回到父节点;例如 /model sw → /model。
|
|
779
|
+
segs = [{ frozen: false, text: prefix.trimEnd() }];
|
|
780
|
+
curSeg = 0;
|
|
781
|
+
curOff = segs[0].text.length;
|
|
782
|
+
selected = 0;
|
|
783
|
+
menuTop = 0;
|
|
784
|
+
computeFiltered();
|
|
785
|
+
}
|
|
786
|
+
else {
|
|
787
|
+
menuOpen = false;
|
|
788
|
+
filtered = [];
|
|
789
|
+
selected = 0;
|
|
790
|
+
menuTop = 0;
|
|
791
|
+
}
|
|
792
|
+
redraw();
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
case 'left': {
|
|
796
|
+
if (curOff > 0) {
|
|
797
|
+
curOff--;
|
|
798
|
+
redraw();
|
|
799
|
+
}
|
|
800
|
+
else if (curSeg > 0) {
|
|
801
|
+
let p = curSeg - 1;
|
|
802
|
+
while (p >= 0 && segs[p].frozen)
|
|
803
|
+
p--;
|
|
804
|
+
if (p >= 0) {
|
|
805
|
+
curSeg = p;
|
|
806
|
+
curOff = segs[p].text.length;
|
|
807
|
+
redraw();
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
return;
|
|
811
|
+
}
|
|
812
|
+
case 'right': {
|
|
813
|
+
const s = segs[curSeg].text;
|
|
814
|
+
if (curOff < s.length) {
|
|
815
|
+
curOff++;
|
|
816
|
+
redraw();
|
|
817
|
+
}
|
|
818
|
+
else if (curSeg < segs.length - 1) {
|
|
819
|
+
let nx = curSeg + 1;
|
|
820
|
+
while (nx < segs.length && segs[nx].frozen)
|
|
821
|
+
nx++;
|
|
822
|
+
if (nx < segs.length) {
|
|
823
|
+
curSeg = nx;
|
|
824
|
+
curOff = 0;
|
|
825
|
+
redraw();
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
830
|
+
case 'home':
|
|
831
|
+
moveToLineStart();
|
|
832
|
+
return;
|
|
833
|
+
case 'end':
|
|
834
|
+
moveToLineEnd();
|
|
835
|
+
return;
|
|
836
|
+
}
|
|
837
|
+
// 可打印字符(>= 空格,非 ctrl/meta)
|
|
838
|
+
const s = key.sequence ?? '';
|
|
839
|
+
if (s && s >= ' ' && !key.ctrl && !key.meta) {
|
|
840
|
+
insertText(s);
|
|
841
|
+
computeFiltered();
|
|
842
|
+
redraw();
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
/**
|
|
846
|
+
* Ctrl+G:打开 TUI 内「输入面板」弹窗(记事本式多行编辑,**不外调 $EDITOR**)。
|
|
847
|
+
* 弹窗里 Enter 就是换行,不会再触发发送;确认(Ctrl+S)只把内容填回输入框,不自动发送。
|
|
848
|
+
* 期间必须摘掉主 keypress 监听:弹窗关闭后终端可能残留按键事件,不摘会被当成输入吞掉。
|
|
849
|
+
*/
|
|
850
|
+
async function openComposer() {
|
|
851
|
+
if (resolved)
|
|
852
|
+
return;
|
|
853
|
+
const before = segs.map((s) => s.text).join('');
|
|
854
|
+
emitter.removeListener('keypress', onKey);
|
|
855
|
+
let result;
|
|
856
|
+
try {
|
|
857
|
+
result = await promptComposer({ initialText: before });
|
|
858
|
+
}
|
|
859
|
+
finally {
|
|
860
|
+
emitter.on('keypress', onKey);
|
|
861
|
+
stdin.resume();
|
|
862
|
+
// 弹窗是直写覆盖层,关闭后整幅重画还原 content 区
|
|
863
|
+
layout.repaintViewport();
|
|
864
|
+
}
|
|
865
|
+
if (resolved)
|
|
866
|
+
return; // 极端路径:弹窗期间被 finish
|
|
867
|
+
if (result.text != null && result.text !== before) {
|
|
868
|
+
undoSnapshot = { segs: segs.map((s) => ({ ...s })), curSeg, curOff }; // Ctrl+Z 可还原回弹窗前
|
|
869
|
+
segs = [{ frozen: false, text: result.text }];
|
|
870
|
+
curSeg = 0;
|
|
871
|
+
curOff = segs[0].text.length;
|
|
872
|
+
}
|
|
873
|
+
computeFiltered();
|
|
874
|
+
redraw();
|
|
875
|
+
}
|
|
876
|
+
/**
|
|
877
|
+
* Ctrl+R / Ctrl+P:历史模糊搜索面板。
|
|
878
|
+
* Enter 只把选中项**填回输入框**(安全网:找回来的长 prompt 通常还要改),Ctrl+Enter 才直接发送。
|
|
879
|
+
*/
|
|
880
|
+
async function openHistorySearch() {
|
|
881
|
+
if (resolved || !opts.history)
|
|
882
|
+
return;
|
|
883
|
+
// 惰性求值:跨会话聚合要读盘,只在面板真正打开时做
|
|
884
|
+
const historyItems = typeof opts.history === 'function' ? opts.history() : opts.history;
|
|
885
|
+
if (historyItems.length === 0)
|
|
886
|
+
return;
|
|
887
|
+
const before = segs.map((s) => s.text).join('');
|
|
888
|
+
emitter.removeListener('keypress', onKey);
|
|
889
|
+
let picked = null;
|
|
890
|
+
try {
|
|
891
|
+
picked = await promptHistorySearch({ items: historyItems, initialQuery: before });
|
|
892
|
+
}
|
|
893
|
+
finally {
|
|
894
|
+
emitter.on('keypress', onKey);
|
|
895
|
+
stdin.resume();
|
|
896
|
+
}
|
|
897
|
+
if (resolved)
|
|
898
|
+
return;
|
|
899
|
+
if (picked) {
|
|
900
|
+
segs = [{ frozen: false, text: picked.text }];
|
|
901
|
+
curSeg = 0;
|
|
902
|
+
curOff = segs[0].text.length;
|
|
903
|
+
computeFiltered();
|
|
904
|
+
if (picked.send) {
|
|
905
|
+
submit();
|
|
906
|
+
return;
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
redraw();
|
|
910
|
+
}
|
|
911
|
+
return new Promise((res, rej) => {
|
|
912
|
+
resolve = res;
|
|
913
|
+
reject = rej;
|
|
914
|
+
// 清掉上一轮可能残留的粘贴状态:子面板(历史搜索)不装 paste sink,粘贴的字会逐字符进它的输入框,
|
|
915
|
+
// 但共享的 pasteParts 不会有人消费 —— 残留会让 Ctrl+C 的"有内容"判定误判。
|
|
916
|
+
if (pasteState.pasteTimer) {
|
|
917
|
+
clearTimeout(pasteState.pasteTimer);
|
|
918
|
+
pasteState.pasteTimer = null;
|
|
919
|
+
}
|
|
920
|
+
pasteState.pasting = false;
|
|
921
|
+
pasteState.pasteParts = [];
|
|
922
|
+
justSawCR = false;
|
|
923
|
+
ensurePasteDetector(); // 首次调用在 emitKeypressEvents 之前装 data 监听器(保序:mine 先于 解析器)
|
|
924
|
+
pasteState.onPasteEnd = finalizePaste; // 粘贴结束回调:落 chip 或保留文本
|
|
925
|
+
readline.emitKeypressEvents(stdin);
|
|
926
|
+
let rawOk = true;
|
|
927
|
+
try {
|
|
928
|
+
stdin.setRawMode(true);
|
|
929
|
+
}
|
|
930
|
+
catch {
|
|
931
|
+
rawOk = false;
|
|
932
|
+
}
|
|
933
|
+
if (!rawOk) {
|
|
934
|
+
// isTTY 但 setRawMode 失败(罕见):退化为 readline,不注册 setPasteHandler(不走 cleanup,防泄漏)
|
|
935
|
+
questionFallback(opts.prompt).then((a) => res([a]), (e) => rej(e));
|
|
936
|
+
return;
|
|
937
|
+
}
|
|
938
|
+
layout.setPasteHandler(onMousePaste); // 鼠标右键单击输入框(未拖动)→ 读剪贴板贴入;cleanup 时注销
|
|
939
|
+
layout.setCursorChangeHandler(applyExternalCursor); // 鼠标左键单击输入框(未拖动)→ 改 cl/cc 到点击位;cleanup 时注销
|
|
940
|
+
stdin.resume();
|
|
941
|
+
emitter.on('keypress', onKey);
|
|
942
|
+
computeFiltered();
|
|
943
|
+
redraw();
|
|
944
|
+
});
|
|
945
|
+
}
|