mocode-ai 1.3.1 → 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.
@@ -0,0 +1,81 @@
1
+ /**
2
+ * 模糊匹配(纯函数,零依赖)。供输入框历史搜索(Ctrl+R / Ctrl+P)排序用。
3
+ *
4
+ * 算法是 fzf 的简化版:从左到右贪心子序列匹配 + 启发式打分。
5
+ * 为什么不上完整动态规划:历史条目量级在几十到几百条,候选文本通常 <200 字符,
6
+ * 贪心与最优解在真实输入上差异极小,而 DP 的 O(n·m) 与回溯实现复杂度高得多。
7
+ *
8
+ * 打分项(靠前的更重要):
9
+ * - 每个匹配字符 +1
10
+ * - 与上一个匹配字符相邻 +8 (鼓励连续命中:fzf 里 "abc" 应命中 "abc def" 而非 "a_b_c")
11
+ * - 落在词首/分隔符后 +6 (鼓励缩写命中:"srt" → "sort" / "src/tools")
12
+ * - 落在驼峰大写边界 +4 ("cb" → "copyBuffer")
13
+ * - 大小写精确匹配 +1 (让 "P" 优先命中 "Prompt" 的大写 P)
14
+ * - 首匹配位置惩罚 -firstIdx * 0.05
15
+ * - 文本长度惩罚 -len * 0.02 (同等质量下短的优先)
16
+ */
17
+ /** 词/路径边界:命中紧跟在这些字符之后算"词首",加分。 */
18
+ const BOUNDARY = new Set([
19
+ ' ', '\t', '\n', '-', '_', '/', '\\', ':', '.', ',', ';',
20
+ '(', ')', '[', ']', '{', '}', '<', '>', '"', "'", '`', '=', '+', '|', '&', '?', '!', '#', '@', '$', '%', '^', '~', '*',
21
+ ]);
22
+ function isUpper(ch) {
23
+ return !!ch && ch >= 'A' && ch <= 'Z';
24
+ }
25
+ /**
26
+ * 子序列模糊匹配。不区分大小写;大小写完全对得上时额外加分。
27
+ * 不匹配返回 null。query 为空返回 score=0 的全通过结果(positions 为空数组)。
28
+ */
29
+ export function fuzzyMatch(query, text) {
30
+ const qChars = [...query];
31
+ if (qChars.length === 0)
32
+ return { score: 0, positions: [] };
33
+ const tChars = [...text];
34
+ const tLower = tChars.map((c) => c.toLowerCase());
35
+ const qLower = qChars.map((c) => c.toLowerCase());
36
+ const positions = [];
37
+ let score = 0;
38
+ let qi = 0;
39
+ let prevIdx = -2;
40
+ for (let i = 0; i < tChars.length && qi < qLower.length; i++) {
41
+ if (tLower[i] !== qLower[qi])
42
+ continue;
43
+ positions.push(i);
44
+ score += 1;
45
+ if (i === prevIdx + 1)
46
+ score += 8; // 连续命中
47
+ const prev = i > 0 ? tChars[i - 1] : undefined;
48
+ if (i === 0 || (prev !== undefined && BOUNDARY.has(prev)))
49
+ score += 6;
50
+ else if (isUpper(tChars[i]) && !isUpper(prev))
51
+ score += 4;
52
+ if (tChars[i] === qChars[qi])
53
+ score += 1; // 大小写精确
54
+ qi++;
55
+ prevIdx = i;
56
+ }
57
+ if (qi < qLower.length)
58
+ return null; // 有字符没配上 → 不匹配
59
+ score -= positions[0] * 0.05;
60
+ score -= tChars.length * 0.02;
61
+ return { score, positions };
62
+ }
63
+ /**
64
+ * 对一批文本按匹配质量排序(高分在前);query 为空时保持原顺序不变
65
+ * (调用方通常已按"最近优先"排好,空 query 不应打乱)。
66
+ * 返回 [{ text, score }],已过滤掉不匹配的项。
67
+ */
68
+ export function fuzzyRank(query, items, limit) {
69
+ if (!query) {
70
+ const head = typeof limit === 'number' ? items.slice(0, limit) : [...items];
71
+ return head.map((text) => ({ text, score: 0 }));
72
+ }
73
+ const scored = [];
74
+ for (const text of items) {
75
+ const m = fuzzyMatch(query, text);
76
+ if (m)
77
+ scored.push({ text, score: m.score });
78
+ }
79
+ scored.sort((a, b) => b.score - a.score);
80
+ return typeof limit === 'number' ? scored.slice(0, limit) : scored;
81
+ }
@@ -0,0 +1,214 @@
1
+ import { stdin } from 'node:process';
2
+ import { ui } from './theme.js';
3
+ import { displayWidth, truncateDisplay } from './render.js';
4
+ import * as layout from './layout.js';
5
+ import * as mouse from './mouse.js';
6
+ import { fuzzyRank } from './fuzzy.js';
7
+ import { t } from '../i18n/index.js';
8
+ /** 菜单里最多显示几条(带开窗滚动)。 */
9
+ const MAX_VISIBLE = 8;
10
+ /** 多行 query 折叠成一行展示:首行 + 行数后缀。 */
11
+ function displayLine(text) {
12
+ const lines = text.split('\n');
13
+ const extra = lines.length - 1;
14
+ return extra > 0 ? `${lines[0] ?? ''} (+${extra})` : (lines[0] ?? '');
15
+ }
16
+ export async function promptHistorySearch(opts) {
17
+ if (!layout.isActive())
18
+ return null;
19
+ const items = opts.items.filter((s) => s.trim().length > 0);
20
+ if (items.length === 0)
21
+ return null;
22
+ const emitter = stdin;
23
+ let query = opts.initialQuery ?? '';
24
+ let cursor = query.length;
25
+ let matches = [];
26
+ let selected = 0;
27
+ let menuTop = 0;
28
+ let resolved = false;
29
+ let resolve;
30
+ function recompute() {
31
+ matches = fuzzyRank(query, items).map((r) => r.text);
32
+ if (selected >= matches.length)
33
+ selected = Math.max(0, matches.length - 1);
34
+ if (selected < 0)
35
+ selected = 0;
36
+ if (selected < menuTop)
37
+ menuTop = selected;
38
+ }
39
+ function menuLines() {
40
+ const cols = layout.getGeo().cols;
41
+ const hint = `${ui.dim}${truncateDisplay(t('history.hint'), cols - 2)}${ui.reset}`;
42
+ if (matches.length === 0) {
43
+ return [hint, `${ui.dim}${truncateDisplay(t('history.empty'), cols - 2)}${ui.reset}`];
44
+ }
45
+ const visibleCount = Math.min(MAX_VISIBLE, matches.length);
46
+ if (selected < menuTop)
47
+ menuTop = selected;
48
+ else if (selected >= menuTop + visibleCount)
49
+ menuTop = selected - visibleCount + 1;
50
+ const win = matches.slice(menuTop, menuTop + visibleCount);
51
+ const hasMoreAbove = menuTop > 0;
52
+ const hasMoreBelow = menuTop + visibleCount < matches.length;
53
+ const rows = win.map((text, i) => {
54
+ const idx = menuTop + i;
55
+ const isSel = idx === selected;
56
+ const color = isSel ? `${ui.accent}${ui.bold}` : ui.dim;
57
+ const marker = isSel ? `${ui.accent}${ui.bold}▸${ui.reset}` : ' ';
58
+ let scrollHint = '';
59
+ if (i === 0 && hasMoreAbove)
60
+ scrollHint = ' ▲';
61
+ if (i === win.length - 1 && hasMoreBelow)
62
+ scrollHint = ' ▼';
63
+ const body = truncateDisplay(displayLine(text), cols - 4 - scrollHint.length);
64
+ return `${marker} ${color}${body}${ui.reset}${ui.dim}${scrollHint}${ui.reset}`;
65
+ });
66
+ return [hint, ...rows];
67
+ }
68
+ function redraw() {
69
+ layout.paintInput({
70
+ prompt: '❯ ',
71
+ lines: [query],
72
+ cursorLine: 0,
73
+ cursorCol: displayWidth(query.slice(0, cursor)),
74
+ menu: { lines: menuLines() },
75
+ });
76
+ }
77
+ function cleanup() {
78
+ layout.setMouseEnabled(true); // 输入框态是启用鼠标的(框选/右键粘贴)
79
+ try {
80
+ stdin.setRawMode(false);
81
+ }
82
+ catch {
83
+ // 忽略
84
+ }
85
+ emitter.removeListener('keypress', onKey);
86
+ stdin.pause();
87
+ }
88
+ function finish(value) {
89
+ if (resolved)
90
+ return;
91
+ resolved = true;
92
+ cleanup();
93
+ resolve(value);
94
+ }
95
+ function onKey(_str, key) {
96
+ if (resolved || !key)
97
+ return;
98
+ if (mouse.swallow(key.sequence ?? ''))
99
+ return;
100
+ if (key.ctrl && (key.name === 'c' || key.name === 'd')) {
101
+ finish(null);
102
+ return;
103
+ }
104
+ if (key.name === 'escape') {
105
+ finish(null);
106
+ return;
107
+ }
108
+ // Ctrl+J 也是「直接发送」:Ctrl+Enter 在部分终端发的是裸 \r(与 Enter 无从区分),
109
+ // 给一个永远可靠的替代键。
110
+ if (key.ctrl && key.name === 'j') {
111
+ const p = matches[selected];
112
+ finish(p ? { text: p, send: true } : null);
113
+ return;
114
+ }
115
+ const isReturn = key.name === 'return' || key.name === 'enter';
116
+ if (isReturn) {
117
+ const picked = matches[selected];
118
+ if (!picked) {
119
+ finish(null); // 无匹配:什么都不改,回输入框
120
+ return;
121
+ }
122
+ // Ctrl+Enter / Alt+Enter → 直接发送;裸 Enter → 只填回输入框
123
+ finish({ text: picked, send: !!key.ctrl || !!key.meta });
124
+ return;
125
+ }
126
+ switch (key.name) {
127
+ case 'up':
128
+ if (matches.length)
129
+ selected = (selected - 1 + matches.length) % matches.length;
130
+ redraw();
131
+ return;
132
+ case 'down':
133
+ if (matches.length)
134
+ selected = (selected + 1) % matches.length;
135
+ redraw();
136
+ return;
137
+ case 'left':
138
+ if (cursor > 0) {
139
+ cursor--;
140
+ redraw();
141
+ }
142
+ return;
143
+ case 'right':
144
+ if (cursor < query.length) {
145
+ cursor++;
146
+ redraw();
147
+ }
148
+ return;
149
+ case 'home':
150
+ cursor = 0;
151
+ redraw();
152
+ return;
153
+ case 'end':
154
+ cursor = query.length;
155
+ redraw();
156
+ return;
157
+ case 'backspace':
158
+ if (cursor > 0) {
159
+ query = query.slice(0, cursor - 1) + query.slice(cursor);
160
+ cursor--;
161
+ recompute();
162
+ redraw();
163
+ }
164
+ return;
165
+ case 'delete':
166
+ if (cursor < query.length) {
167
+ query = query.slice(0, cursor) + query.slice(cursor + 1);
168
+ recompute();
169
+ redraw();
170
+ }
171
+ return;
172
+ }
173
+ if (key.ctrl && key.name === 'u') {
174
+ query = query.slice(cursor);
175
+ cursor = 0;
176
+ recompute();
177
+ redraw();
178
+ return;
179
+ }
180
+ if (key.ctrl && (key.name === 'a' || key.name === 'q')) {
181
+ cursor = 0;
182
+ redraw();
183
+ return;
184
+ }
185
+ if (key.ctrl && key.name === 'e') {
186
+ cursor = query.length;
187
+ redraw();
188
+ return;
189
+ }
190
+ // 可打印字符(含中文;粘贴会逐字符到达,行为正确)
191
+ const s = key.sequence ?? '';
192
+ if (s && s >= ' ' && !key.ctrl && !key.meta) {
193
+ query = query.slice(0, cursor) + s + query.slice(cursor);
194
+ cursor += s.length;
195
+ recompute();
196
+ redraw();
197
+ }
198
+ }
199
+ recompute();
200
+ return new Promise((res) => {
201
+ resolve = res;
202
+ layout.setMouseEnabled(false); // 面板期间禁鼠标框选,防拖拽覆盖菜单
203
+ try {
204
+ stdin.setRawMode(true);
205
+ }
206
+ catch {
207
+ res(null);
208
+ return;
209
+ }
210
+ stdin.resume();
211
+ emitter.on('keypress', onKey);
212
+ redraw();
213
+ });
214
+ }
package/dist/ui/layout.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { stdin, stdout } from 'node:process';
2
2
  import { inspect } from 'node:util';
3
3
  import { charWidth, displayWidth, truncateDisplay, truncateDisplayHead, truncateAnsi, ansiDisplayWidth, wrapByDisplayWidth, fmtElapsed, stripAnsi, sliceByDisplayCol, } from './render.js';
4
- import { ui, applyTerminalBackground, resetTerminalBackground } from './theme.js';
4
+ import { ui, resetTerminalBackground } from './theme.js';
5
5
  import * as content from './content.js';
6
6
  import * as mouse from './mouse.js';
7
7
  import { reset as resetBatches, shiftBatchesAfter, setMaxCols } from './batch.js';
@@ -945,6 +945,13 @@ export function setPasteHandler(fn) {
945
945
  export function setCursorChangeHandler(fn) {
946
946
  cursorChangeHandler = fn;
947
947
  }
948
+ /** overlay 鼠标接管者(composer 输入面板等全屏弹窗)。非 null 时所有鼠标事件先喂给它,
949
+ * 返回 true = 已消费(不再走 layout 默认处理);弹窗自己消费全部事件,
950
+ * 防止背景选区/翻页触发 repaintViewport 重画覆盖弹窗。传 null 注销。 */
951
+ let overlayMouseHandler = null;
952
+ export function setOverlayMouseHandler(fn) {
953
+ overlayMouseHandler = fn;
954
+ }
948
955
  /** picker / 介入面板期间禁用鼠标选区与拖拽(避免 viewport 重画覆盖菜单);滚轮仍可用。面板退出后恢复。 */
949
956
  export function setMouseEnabled(v) {
950
957
  mouseEnabled = v;
@@ -1193,6 +1200,8 @@ function inputScreenToInputPos(screenRow, screenCol) {
1193
1200
  function handleMouseEvent(e) {
1194
1201
  if (!active)
1195
1202
  return;
1203
+ if (overlayMouseHandler && overlayMouseHandler(e))
1204
+ return; // overlay(composer 等)先接管,消费即止
1196
1205
  if (e.type === 'wheel') {
1197
1206
  // 滚轮始终可用:面板/picker 期间也允许上下查看 agent 输出,
1198
1207
  // 与 onRunningKey 的 PgUp/PgDn 行为一致;mouseEnabled 仅管选区/拖拽。
@@ -2149,7 +2158,7 @@ export function enterAltScreen() {
2149
2158
  active = true;
2150
2159
  setMaxCols(getGeo().cols); // 同步 batch 展开行宽钳制,防超宽行 auto-wrap 打乱屏位
2151
2160
  stdout.write(esc.altOn);
2152
- applyTerminalBackground();
2161
+ // 不动终端窗口背景:底色保持用户终端原色,主题只作用于我们画出的 SGR 颜色。
2153
2162
  stdout.write(esc.mouseOn); // 完整鼠标追踪(按下/拖动/释放/滚轮)→ mouse.swallow 重组 → handleMouseEvent
2154
2163
  mouse.setHandler(handleMouseEvent);
2155
2164
  // 进入 alt screen 前 base 可能已设了 planSummary;按当前 planSummary 重算 planRows,
@@ -2268,6 +2277,19 @@ export function exitAltScreen() {
2268
2277
  }
2269
2278
  userActiveUntil = 0;
2270
2279
  }
2280
+ /**
2281
+ * 直写终端(绕过 content buffer):仅限弹窗类覆盖层(输入面板)使用——
2282
+ * 它画在 content 区之上,关闭后由调用方 repaintViewport() 整幅还原。
2283
+ * 不做行宽钳制:调用方自己保证每行 ≤ cols(弹窗几何已按 getGeo() 计)。
2284
+ */
2285
+ export function writeDirect(s) {
2286
+ try {
2287
+ stdout.write(s);
2288
+ }
2289
+ catch {
2290
+ // 忽略
2291
+ }
2292
+ }
2271
2293
  export function isActive() {
2272
2294
  return active;
2273
2295
  }
package/dist/ui/prompt.js CHANGED
@@ -5,6 +5,16 @@ import { displayWidth, padEndDisplay, truncateDisplay, visColToCharCol, wrapByDi
5
5
  import * as layout from './layout.js';
6
6
  import * as mouse from './mouse.js';
7
7
  import { t } from '../i18n/index.js';
8
+ import { promptComposer } from './composer.js';
9
+ import { promptHistorySearch } from './history-picker.js';
10
+ // ── 二次确认发送(MOCODE_CONFIRM_SEND=long 时启用,默认关)──
11
+ // 长 prompt 首次 Enter 只武装,窗口内再按一次才真发。默认 opt-in:新交互默认不打扰既有习惯。
12
+ const CONFIRM_SEND_MS = 2000;
13
+ const LONG_INPUT_CHARS = 120;
14
+ /** 二次确认模式:only 'long' 会启用(长/多行输入需按两次 Enter)。每次读 env,便于运行期改配置生效。 */
15
+ function confirmSendMode() {
16
+ return process.env.MOCODE_CONFIRM_SEND === 'long' ? 'long' : 'never';
17
+ }
8
18
  // ── 粘贴检测(块级 + 时间窗)──
9
19
  // 块级:多字节大块(len>8)或含 CR/LF 的小块 = 粘贴(键盘单键 1 字节,Enter=\r 单字节)。
10
20
  // 时间窗:每块重置 50ms 计时器,静默 50ms 即"粘贴结束"→ onPasteEnd。跨多块的大粘贴(块间 <50ms)累积进
@@ -88,6 +98,8 @@ export async function promptWithSlashMenu(opts) {
88
98
  let menuTop = 0; // 窗口首项在 filtered 中的索引,菜单最多显示 MENU_MAX_VISIBLE 条
89
99
  /** Ctrl+C 清空前的缓冲快照(Ctrl+Z 还原一次);单次生效,还原后即清。 */
90
100
  let undoSnapshot = null;
101
+ /** 二次确认武装截止时刻(绝对 ms);0=未武装。见 submit() 与 MOCODE_CONFIRM_SEND。 */
102
+ let confirmArmedUntil = 0;
91
103
  let resolved = false;
92
104
  let resolve;
93
105
  let reject;
@@ -342,12 +354,21 @@ export async function promptWithSlashMenu(opts) {
342
354
  // 空输入引导:缓冲为空时输入框内画 dim ghost 占位(含 /help 提示),让用户知道怎么开始;
343
355
  // 一旦有输入(打字/粘贴/补全)isEmpty 变 false,下一帧占位即消失。
344
356
  const isEmpty = segs.length === 1 && !segs[0].frozen && segs[0].text === '';
357
+ // 二次确认已武装:把提示画成菜单行(向上展开进内容区底,输入框上方),黄色 + 图标。
358
+ // 不复用 placeholder —— layout 只在输入行为空时画 placeholder,而武装态必然有内容。
359
+ const armed = confirmArmedUntil > Date.now();
360
+ const menu = menuLines();
361
+ const menuView = menu.length
362
+ ? { lines: menu }
363
+ : armed
364
+ ? { lines: [`${ui.yellow}${t('prompt.confirmSendHint')}${ui.reset}`] }
365
+ : null;
345
366
  layout.paintInput({
346
367
  prompt: opts.prompt,
347
368
  lines,
348
369
  cursorLine: cur.line,
349
370
  cursorCol: cur.col,
350
- menu: menuLines().length ? { lines: menuLines() } : null,
371
+ menu: menuView,
351
372
  placeholder: isEmpty ? t('prompt.emptyPlaceholder') : undefined,
352
373
  });
353
374
  }
@@ -463,13 +484,30 @@ export async function promptWithSlashMenu(opts) {
463
484
  return true;
464
485
  }
465
486
  /** 提交:菜单打开时先应用选中项;分支进入子菜单,需要参数的叶子只补全。
466
- * 段序列直接按原文(可编辑段原文 + 粘贴块原文)拼接——所见即所得,不再额外插换行。 */
487
+ * 段序列直接按原文(可编辑段原文 + 粘贴块原文)拼接——所见即所得,不再额外插换行。
488
+ *
489
+ * 二次确认(MOCODE_CONFIRM_SEND=long,默认关):长/多行输入首次 Enter 只"武装"并显示提示行,
490
+ * CONFIRM_SEND_MS 内再按一次 Enter 才真发;任何其它键都会解除武装(见 onKey 顶部)——
491
+ * 误触一次 Enter 发不出去,而正常发送只是多敲一下。 */
467
492
  function submit() {
468
493
  if (menuOpen && !applySelected(true))
469
494
  return;
470
495
  const content = segs.map((s) => s.text).join('');
496
+ const now = Date.now();
497
+ if (confirmSendMode() === 'long' && isLongInput(content) && now > confirmArmedUntil) {
498
+ confirmArmedUntil = now + CONFIRM_SEND_MS;
499
+ redraw();
500
+ return;
501
+ }
502
+ confirmArmedUntil = 0;
471
503
  finish(content === '' ? [''] : content.split('\n'));
472
504
  }
505
+ /** 长输入判定:≥2 行 或 ≥120 码点(单行短消息不触发二次确认,免得打扰日常)。 */
506
+ function isLongInput(text) {
507
+ if (text.indexOf('\n') >= 0)
508
+ return true;
509
+ return [...text].length >= LONG_INPUT_CHARS;
510
+ }
473
511
  /** 插换行:在光标处断行(落进当前可编辑段)。 */
474
512
  function insertNewline() {
475
513
  insertText('\n');
@@ -569,6 +607,14 @@ export async function promptWithSlashMenu(opts) {
569
607
  // layout.handleMouseEvent(滚轮/框选/复制全在那处理);此处只需吞掉 fragment 不进 pasteParts/输入框。
570
608
  if (mouse.swallow(key.sequence ?? ''))
571
609
  return;
610
+ // 二次确认已武装:任何非「提交键」都解除武装(继续编辑 / 换行 / 删除都算放弃这次提交)。
611
+ if (confirmArmedUntil) {
612
+ const isSubmitKey = (key.name === 'return' || key.name === 'enter') && !key.shift && !key.meta && !key.ctrl;
613
+ if (!isSubmitKey) {
614
+ confirmArmedUntil = 0;
615
+ redraw();
616
+ }
617
+ }
572
618
  // Shift+Tab:循环切换 agent 模式(auto ↔ plan)。不插字符、不提交、不影响输入文本;
573
619
  // 回调由 repl 注入(翻 agentMode + 重写 history[0] + 设状态行 modeTag),再 redraw() 经
574
620
  // paintInput 重画底栏(状态行 chip 即时刷新 + 光标留输入框)。置于 case 'tab' 之前,故不触发菜单补全。
@@ -596,7 +642,9 @@ export async function promptWithSlashMenu(opts) {
596
642
  return;
597
643
  }
598
644
  justSawCR = false;
599
- if (s && s >= ' ' && !key.ctrl && !key.meta)
645
+ // FEFF(BOM 字符)过滤:其他应用写入剪贴板的正文可能带 BOM,终端级粘贴逐字符灌入时
646
+ // 会敲进输入框(charWidth 按 1 列算但终端渲染成缺字形方块)
647
+ if (s && s >= ' ' && s !== '\uFEFF' && !key.ctrl && !key.meta)
600
648
  pasteParts.push(s);
601
649
  return;
602
650
  }
@@ -653,6 +701,17 @@ export async function promptWithSlashMenu(opts) {
653
701
  deleteToLineEnd();
654
702
  return;
655
703
  }
704
+ // Ctrl+G:打开输入面板弹窗(记事本式编辑,Enter=换行不发送;确认只填回不发送)。
705
+ if (key.ctrl && key.name === 'g') {
706
+ void openComposer();
707
+ return;
708
+ }
709
+ // Ctrl+R / Ctrl+P:历史模糊搜索。Ctrl+R 对齐 bash 反向搜索语义,Ctrl+P 对齐 previous-history 语义,
710
+ // 两者是同一个面板——记住哪个都行。
711
+ if (key.ctrl && (key.name === 'r' || key.name === 'p')) {
712
+ void openHistorySearch();
713
+ return;
714
+ }
656
715
  const isReturn = key.name === 'return' || key.name === 'enter';
657
716
  // 换行:Ctrl+J / Ctrl+Enter / Alt+Enter(meta)/ Shift+Enter(终端区分时)/ lone LF
658
717
  // (粘贴的 CR/LF 已在上方 pasting 分支累积进 pasteParts,不会到此)
@@ -836,9 +895,84 @@ export async function promptWithSlashMenu(opts) {
836
895
  redraw();
837
896
  }
838
897
  }
898
+ /**
899
+ * Ctrl+G:打开 TUI 内「输入面板」弹窗(记事本式多行编辑,**不外调 $EDITOR**)。
900
+ * 弹窗里 Enter 就是换行,不会再触发发送;确认(Ctrl+S)只把内容填回输入框,不自动发送。
901
+ * 期间必须摘掉主 keypress 监听:弹窗关闭后终端可能残留按键事件,不摘会被当成输入吞掉。
902
+ */
903
+ async function openComposer() {
904
+ if (resolved)
905
+ return;
906
+ const before = segs.map((s) => s.text).join('');
907
+ emitter.removeListener('keypress', onKey);
908
+ let result;
909
+ try {
910
+ result = await promptComposer({ initialText: before });
911
+ }
912
+ finally {
913
+ emitter.on('keypress', onKey);
914
+ stdin.resume();
915
+ // 弹窗是直写覆盖层,关闭后整幅重画还原 content 区
916
+ layout.repaintViewport();
917
+ }
918
+ if (resolved)
919
+ return; // 极端路径:弹窗期间被 finish
920
+ if (result.text != null && result.text !== before) {
921
+ undoSnapshot = { segs: segs.map((s) => ({ ...s })), curSeg, curOff }; // Ctrl+Z 可还原回弹窗前
922
+ segs = [{ frozen: false, text: result.text }];
923
+ curSeg = 0;
924
+ curOff = segs[0].text.length;
925
+ }
926
+ computeFiltered();
927
+ redraw();
928
+ }
929
+ /**
930
+ * Ctrl+R / Ctrl+P:历史模糊搜索面板。
931
+ * Enter 只把选中项**填回输入框**(安全网:找回来的长 prompt 通常还要改),Ctrl+Enter 才直接发送。
932
+ */
933
+ async function openHistorySearch() {
934
+ if (resolved || !opts.history)
935
+ return;
936
+ // 惰性求值:跨会话聚合要读盘,只在面板真正打开时做
937
+ const historyItems = typeof opts.history === 'function' ? opts.history() : opts.history;
938
+ if (historyItems.length === 0)
939
+ return;
940
+ const before = segs.map((s) => s.text).join('');
941
+ emitter.removeListener('keypress', onKey);
942
+ let picked = null;
943
+ try {
944
+ picked = await promptHistorySearch({ items: historyItems, initialQuery: before });
945
+ }
946
+ finally {
947
+ emitter.on('keypress', onKey);
948
+ stdin.resume();
949
+ }
950
+ if (resolved)
951
+ return;
952
+ if (picked) {
953
+ segs = [{ frozen: false, text: picked.text }];
954
+ curSeg = 0;
955
+ curOff = segs[0].text.length;
956
+ computeFiltered();
957
+ if (picked.send) {
958
+ submit();
959
+ return;
960
+ }
961
+ }
962
+ redraw();
963
+ }
839
964
  return new Promise((res, rej) => {
840
965
  resolve = res;
841
966
  reject = rej;
967
+ // 清掉上一轮可能残留的粘贴状态:子面板(历史搜索)不装 paste sink,粘贴的字会逐字符进它的输入框,
968
+ // 但共享的 pasteParts 不会有人消费 —— 残留会让 Ctrl+C 的"有内容"判定误判。
969
+ if (pasteTimer) {
970
+ clearTimeout(pasteTimer);
971
+ pasteTimer = null;
972
+ }
973
+ pasting = false;
974
+ pasteParts = [];
975
+ justSawCR = false;
842
976
  ensurePasteDetector(); // 首次调用在 emitKeypressEvents 之前装 data 监听器(保序:mine 先于 解析器)
843
977
  onPasteEnd = finalizePaste; // 粘贴结束回调:落 chip 或保留文本
844
978
  readline.emitKeypressEvents(stdin);
package/dist/ui/theme.js CHANGED
@@ -33,7 +33,6 @@ const DEFAULT = {
33
33
  // diff 行底色:One Dark bg #282c34 上加 ~14% 亮度的对应色,够辨识但不刺眼
34
34
  addBg: '\x1B[48;2;44;62;42m', // 偏暗绿(One Dark green(152,195,121)暗化)
35
35
  delBg: '\x1B[48;2;62;38;42m', // 偏暗红(One Dark red(224,108,117)暗化)
36
- terminalBg: 'rgb:28/2c/34', // One Dark 经典深底,偏蓝灰的黑
37
36
  };
38
37
  /**
39
38
  * 内建主题表。default=One Dark、light=Solarized Light、solarized=Solarized Dark、
@@ -59,7 +58,6 @@ const THEMES = {
59
58
  // 比直接用 base1 更柔,跟深 fg(red/green)对比充足
60
59
  addBg: '\x1B[48;2;220;235;205m',
61
60
  delBg: '\x1B[48;2;245;218;215m',
62
- terminalBg: 'rgb:1a/1e/24', // 深黑底带微蓝,与 light 主题文字反差大
63
61
  },
64
62
  solarized: {
65
63
  red: '\x1B[38;2;220;50;47m',
@@ -76,7 +74,6 @@ const THEMES = {
76
74
  // diff 行底色:Solarized Dark base03(0,43,54)上加对应色族暗 tint
77
75
  addBg: '\x1B[48;2;20;50;38m',
78
76
  delBg: '\x1B[48;2;55;30;30m',
79
- terminalBg: 'rgb:00/2b/36', // Solarized Dark base03,深青黑
80
77
  },
81
78
  gruvbox: {
82
79
  red: '\x1B[38;2;251;73;52m',
@@ -93,7 +90,6 @@ const THEMES = {
93
90
  // diff 行底色:Gruvbox dark bg(40,40,40)上贴暗 bg0_a 风格
94
91
  addBg: '\x1B[48;2;40;55;30m',
95
92
  delBg: '\x1B[48;2;70;35;30m',
96
- terminalBg: 'rgb:28/28/28', // Gruvbox 深黑底,带微暖色调
97
93
  },
98
94
  nord: {
99
95
  red: '\x1B[38;2;191;97;106m',
@@ -110,7 +106,6 @@ const THEMES = {
110
106
  // diff 行底色:Nord polar night(46,52,64)上贴对应色族暗 tint
111
107
  addBg: '\x1B[48;2;46;66;52m',
112
108
  delBg: '\x1B[48;2;72;46;52m',
113
- terminalBg: 'rgb:2e/34/40', // Nord Polar Night 深灰蓝
114
109
  },
115
110
  orange: {
116
111
  // 秋季/南瓜橙:深暖底(#231c16 系)+ accent 主强调走橙色;yellow 槽也偏橙
@@ -130,7 +125,6 @@ const THEMES = {
130
125
  // diff 行底色:暖深棕底上贴对应色族暗 tint,跟 fg 配对柔和可辨
131
126
  addBg: '\x1B[48;2;55;70;35m',
132
127
  delBg: '\x1B[48;2;78;42;32m',
133
- terminalBg: 'rgb:23/1c/16', // 深暖棕黑,带橙色调
134
128
  },
135
129
  rose: {
136
130
  // 玫红:深紫底 + accent 玫粉作主强调;magenta 槽与 accent 同源(主题色),
@@ -149,7 +143,6 @@ const THEMES = {
149
143
  // diff 行底色:深紫底贴对应色族暗 tint,绿暗化偏橄榄、红暗化偏紫红
150
144
  addBg: '\x1B[48;2;50;60;42m',
151
145
  delBg: '\x1B[48;2;75;40;52m',
152
- terminalBg: 'rgb:1e/14/1c', // 深紫黑底,带玫红暖调
153
146
  },
154
147
  emerald: {
155
148
  // 翡翠绿:深绿底 + accent 翡翠绿作主强调;green 槽与 accent 同源(主题色),
@@ -168,7 +161,6 @@ const THEMES = {
168
161
  // diff 行底色:深绿底贴对应色族暗 tint,绿暗化偏深绿、红暗化偏暗红
169
162
  addBg: '\x1B[48;2;30;55;40m',
170
163
  delBg: '\x1B[48;2;60;40;40m',
171
- terminalBg: 'rgb:10/1e/18', // 深绿黑底,翡翠色调
172
164
  },
173
165
  amber: {
174
166
  // 琥珀金黄:深棕底 + accent 琥珀金黄作主强调;yellow 槽与 accent 同源(主题色),
@@ -187,7 +179,6 @@ const THEMES = {
187
179
  // diff 行底色:深棕底贴对应色族暗 tint,绿暗化偏橄榄、红暗化偏暗棕红
188
180
  addBg: '\x1B[48;2;50;55;25m',
189
181
  delBg: '\x1B[48;2;70;40;28m',
190
- terminalBg: 'rgb:1e/18/0e', // 深棕黑底,琥珀金黄调
191
182
  },
192
183
  lavender: {
193
184
  // 薰衣草淡紫:深紫底 + accent 淡紫作主强调;magenta 槽与 accent 同源(主题色),
@@ -206,7 +197,6 @@ const THEMES = {
206
197
  // diff 行底色:深紫底贴对应色族暗 tint,绿暗化偏冷绿、红暗化偏冷紫红
207
198
  addBg: '\x1B[48;2;38;46;42m',
208
199
  delBg: '\x1B[48;2;60;40;55m',
209
- terminalBg: 'rgb:18/14/24', // 深紫黑底,薰衣草冷调
210
200
  },
211
201
  sunset: {
212
202
  // 日落珊瑚:深棕红底 + accent 珊瑚红作主强调;red 槽与 accent 同源(主题色);
@@ -225,7 +215,6 @@ const THEMES = {
225
215
  // diff 行底色:深棕红底贴对应色族暗 tint,绿暗化偏橄榄、红暗化偏暗棕红
226
216
  addBg: '\x1B[48;2;50;55;30m',
227
217
  delBg: '\x1B[48;2;75;32;30m',
228
- terminalBg: 'rgb:20/12/10', // 深棕红黑底,珊瑚暖调
229
218
  },
230
219
  };
231
220
  let currentName = 'default';
@@ -233,13 +222,13 @@ let version = 0;
233
222
  function currentPalette() {
234
223
  return THEMES[currentName] ?? DEFAULT;
235
224
  }
236
- /** Apply the active theme's dark background to the terminal window (OSC 11). */
237
- export function applyTerminalBackground() {
238
- if (!isTTY)
239
- return;
240
- stdout.write(`\x1B]11;${currentPalette().terminalBg}\x07`);
241
- }
242
- /** Ask the terminal to restore its default background color. */
225
+ /**
226
+ * 请终端还原自身默认背景(OSC 111)
227
+ *
228
+ * 我们从不主动设背景(见 Palette 上的注释),这条只作**善后**:agent 跑 run_command
229
+ * 时子进程可能自己下发 OSC 11 改了窗口底色,退出 alt screen 时拉回默认,免得把改动
230
+ * 留在用户终端里。终端不支持 OSC 111 时该序列被忽略,无副作用。
231
+ */
243
232
  export function resetTerminalBackground() {
244
233
  if (!isTTY)
245
234
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mocode-ai",
3
- "version": "1.3.1",
3
+ "version": "1.3.2",
4
4
  "description": "终端编码 agent:LLM + tool-call 循环 + 流式输出(含思考)+ 16 个工具,接任意 OpenAI 兼容后端。",
5
5
  "type": "module",
6
6
  "bin": {