mocode-ai 0.1.2 → 0.1.3

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.
@@ -31,7 +31,9 @@ function parseArgs(raw) {
31
31
  * 思考段用 layout.beginSegment / eraseSegmentBack 精确按物理行折叠(修预存按 \n 数行漏擦 bug),
32
32
  * 原文存入 collapsedThinkings 供 /think N 重打。
33
33
  */
34
- export async function runAgent(history, userInput, collapsedThinkings = [], signal) {
34
+ export async function runAgent(history, userInput, collapsedThinkings = [], signal,
35
+ /** 每步 chat() 返回后回调:repl 据此重算并重画状态行 context 用量条(运行中实时刷新,不冻结在轮首)。 */
36
+ onContextUpdate) {
35
37
  // 中断回滚快照:入口(本 turn push 任何消息前)整段浅拷贝。abort 时 length=0;push(...saved) 还原。
36
38
  // 用 slice() 而非 length:maybeCompact 会原地重建(length=0;push(...rebuilt)),savedLen 会失效。
37
39
  const savedHistory = history.slice();
@@ -138,6 +140,8 @@ export async function runAgent(history, userInput, collapsedThinkings = [], sign
138
140
  }
139
141
  contextState.lastUsage = result.usage; // 供 /context 与状态行显示实测 token
140
142
  spinner.stop();
143
+ // lastUsage 已更新:触发状态行 context 用量条重算+重画,运行中不再冻结在轮首。
144
+ onContextUpdate?.();
141
145
  if (result.toolCalls.length > 0) {
142
146
  // 思考后直接进入 tool_call(无 text):先把可见的思考段折叠
143
147
  flushThinkCollapsed();
@@ -4,7 +4,7 @@ import { stdin, stdout } from 'node:process';
4
4
  import { config } from '../config/index.js';
5
5
  import { runAgent } from '../agent/index.js';
6
6
  import { ui } from '../ui/theme.js';
7
- import { bannerString, displayWidth, summarizeToolCall, summarizeToolResult } from '../ui/render.js';
7
+ import { bannerString, displayWidth, padEndDisplay, summarizeToolCall, summarizeToolResult } from '../ui/render.js';
8
8
  import * as layout from '../ui/layout.js';
9
9
  import { promptWithSlashMenu, promptTurnPicker } from '../ui/prompt.js';
10
10
  import { tools } from '../tools/registry.js';
@@ -173,14 +173,30 @@ function stopRunningListener() {
173
173
  emitter.off('keypress', onRunningKey);
174
174
  currentAbort = null;
175
175
  }
176
+ /**
177
+ * 把用户消息格式化为带满宽背景色的文本(上滑时易辨认用户消息)。
178
+ * 每行用 padEndDisplay 填充到终端宽度(含 ❯ / 缩进),背景色 SGR 包裹整行 + 行末 reset。
179
+ * 满宽 pad 使终端背景色覆盖整行(含行尾空单元格),上滑滚动时用户消息呈连续色块、与 assistant 正文区分。
180
+ */
181
+ function formatUserMessage(lines) {
182
+ const cols = layout.getGeo().cols;
183
+ const promptW = displayWidth(PROMPT);
184
+ const indent = ' '.repeat(promptW);
185
+ const { userBg, reset } = ui;
186
+ return (lines
187
+ .map((l, i) => {
188
+ const prefix = i === 0 ? PROMPT : indent;
189
+ const full = prefix + l;
190
+ const padded = padEndDisplay(full, cols);
191
+ return `${userBg}${padded}${reset}`;
192
+ })
193
+ .join('\n') + '\n');
194
+ }
176
195
  /** 把多行提交输入回显进内容区(❯ 首行,续行按 prompt 宽度缩进)。仅 TUI 态回显(非 TTY 由 readline 自带回显)。 */
177
196
  function echoInput(lines) {
178
197
  if (!layout.isActive())
179
198
  return;
180
- const indent = ' '.repeat(displayWidth(PROMPT));
181
- const echo = lines.map((l, i) => (i === 0 ? `${PROMPT}${l}` : `${indent}${l}`)).join('\n') +
182
- '\n';
183
- layout.contentWrite(echo);
199
+ layout.contentWrite(formatUserMessage(lines));
184
200
  }
185
201
  /** 把任意消息 content 拍平成字符串(OpenAI 可能 string / null / 多模态数组)。 */
186
202
  function textOf(c) {
@@ -202,16 +218,13 @@ function textOf(c) {
202
218
  * 内容长于屏时 viewport 显尾(最近轮次),PgUp 可看更早——与流式态一致。
203
219
  */
204
220
  export function renderHistory(history) {
205
- const indent = ' '.repeat(displayWidth(PROMPT));
206
221
  const idToName = new Map();
207
222
  for (const m of history) {
208
223
  if (m.role === 'system')
209
224
  continue;
210
225
  if (m.role === 'user') {
211
226
  const lines = textOf(m.content).split('\n');
212
- layout.contentWrite(lines
213
- .map((l, i) => (i === 0 ? `${PROMPT}${l}` : `${indent}${l}`))
214
- .join('\n') + '\n');
227
+ layout.contentWrite(formatUserMessage(lines));
215
228
  continue;
216
229
  }
217
230
  if (m.role === 'assistant') {
@@ -510,7 +523,12 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null)
510
523
  }
511
524
  try {
512
525
  const signal = startRunningListener(placeholder);
513
- await runAgent(history, joined, collapsedThinkings, signal);
526
+ // 运行中每步 chat() 返回后刷新状态行 context 用量条(用 fresh lastUsage / 估算),
527
+ // 否则整轮冻结在轮首 refreshStatusBase 的值,「执行 grep」时 2k/1000k 不动。
528
+ await runAgent(history, joined, collapsedThinkings, signal, () => {
529
+ refreshStatusBase(history);
530
+ layout.drawStatusBar();
531
+ });
514
532
  // 成功轮次自动落盘(崩溃也保住上一轮);新会话首轮分配 id
515
533
  if (!currentSessionId)
516
534
  currentSessionId = newSessionId();
package/dist/ui/prompt.js CHANGED
@@ -3,6 +3,40 @@ import { stdin, stdout } from 'node:process';
3
3
  import { ui } from './theme.js';
4
4
  import { displayWidth, padEndDisplay, truncateDisplay } from './render.js';
5
5
  import * as layout from './layout.js';
6
+ // ── 粘贴检测(块级 + 时间窗)──
7
+ // 块级:多字节大块(len>8)或含 CR/LF 的小块 = 粘贴(键盘单键 1 字节,Enter=\r 单字节)。
8
+ // 时间窗:每块重置 50ms 计时器,静默 50ms 即"粘贴结束"→ onPasteEnd。跨多块的大粘贴(块间 <50ms)累积进
9
+ // 同一个 pasteParts、末尾一次性落 chip——避免"首块成 chip、后续块泄成文本"。粘贴中 onKey 把键累积进
10
+ // pasteParts(不编辑 lines)。不启用 bracketed paste——emitKeypressEvents 会把 \x1B[200~ 标记当按键砸进输入框。
11
+ let pasting = false;
12
+ let pasteParts = [];
13
+ let pasteTimer = null;
14
+ let onPasteEnd = null; // 粘贴结束回调(prompt 注入:落 chip 或保留为文本)
15
+ let pasteDetectorInstalled = false;
16
+ function ensurePasteDetector() {
17
+ if (pasteDetectorInstalled)
18
+ return;
19
+ pasteDetectorInstalled = true;
20
+ stdin.on('data', (chunk) => {
21
+ const len = chunk.length;
22
+ const hasNL = typeof chunk === 'string'
23
+ ? chunk.indexOf('\r') >= 0 || chunk.indexOf('\n') >= 0
24
+ : chunk.indexOf(0x0d) >= 0 || chunk.indexOf(0x0a) >= 0;
25
+ // 大块(>8 字节)或含换行的多字节块 = 粘贴;键盘单键(1 字节,含 Enter=\r)不触发
26
+ if (!(len > 8 || (len > 1 && hasNL)))
27
+ return;
28
+ pasting = true;
29
+ if (pasteTimer)
30
+ clearTimeout(pasteTimer);
31
+ const t = setTimeout(() => {
32
+ pasteTimer = null;
33
+ pasting = false;
34
+ onPasteEnd?.();
35
+ }, 50);
36
+ t.unref();
37
+ pasteTimer = t;
38
+ });
39
+ }
6
40
  /** 非 TTY / 未进 alt screen 时退化为普通 readline 行输入(无菜单、单行)。 */
7
41
  function questionFallback(prompt) {
8
42
  return new Promise((res, rej) => {
@@ -38,6 +72,8 @@ export async function promptWithSlashMenu(opts) {
38
72
  : [''];
39
73
  let cl = lines.length - 1; // 光标行(0-based)= 末行
40
74
  let cc = lines[cl].length; // 光标在该行的字符索引 = 末行末尾
75
+ let justSawCR = false; // \r\n 合一:粘贴的 \r 折行后,紧跟的 \n 吞掉(避免折两行)
76
+ let chip = null; // 原子粘贴块:整段封进 [预览…],不可编辑;提交时拼回全文
41
77
  let menuOpen = false;
42
78
  let selected = 0;
43
79
  let filtered = [];
@@ -62,6 +98,58 @@ export async function promptWithSlashMenu(opts) {
62
98
  function cursorCol() {
63
99
  return displayWidth(lines[cl].slice(0, cc));
64
100
  }
101
+ /** chip 预览前缀:整段扁平化(行界→空格,避免框内折行)取前 ~20 列,超长 truncateDisplay 自带 …;末尾空格与 suffix 分隔。 */
102
+ function chipPrefix() {
103
+ return chip ? `[${truncateDisplay(chip.split('\n').join(' '), 20)}] ` : '';
104
+ }
105
+ /** 供 layout 画的行:chip 存在时把前缀拼到第 0 行前(chip 原子显示,不可编辑;光标活在 suffix)。 */
106
+ function dispLines() {
107
+ if (!chip)
108
+ return lines;
109
+ const pre = chipPrefix();
110
+ return lines.length > 0 ? [pre + lines[0], ...lines.slice(1)] : [pre];
111
+ }
112
+ /** 供 layout 定位光标列:chip 在第 0 行时偏移 chipPrefix 宽度(suffix 光标始终在 chip 之后)。 */
113
+ function dispCursorCol() {
114
+ return chip && cl === 0 ? displayWidth(chipPrefix()) + cursorCol() : cursorCol();
115
+ }
116
+ /** 在光标处插入文本(含换行则拆行)。供短粘贴 finalize 落为可编辑文本。 */
117
+ function insertText(text) {
118
+ const parts = text.split('\n');
119
+ const before = lines[cl].slice(0, cc);
120
+ const after = lines[cl].slice(cc);
121
+ const newLines = [before + parts[0]];
122
+ for (let i = 1; i < parts.length; i++)
123
+ newLines.push(parts[i]);
124
+ newLines[newLines.length - 1] += after;
125
+ lines.splice(cl, 1, ...newLines);
126
+ cl = cl + parts.length - 1;
127
+ cc = parts[parts.length - 1].length;
128
+ }
129
+ /** 粘贴结束:长粘贴(>8 行或 >400 字符)落/并进 chip(原子,整段封预览),短粘贴落为可编辑文本。 */
130
+ function finalizePaste() {
131
+ if (resolved) {
132
+ pasteParts = [];
133
+ return;
134
+ }
135
+ const buf = pasteParts.join('');
136
+ pasteParts = [];
137
+ justSawCR = false;
138
+ if (!buf)
139
+ return;
140
+ const isLong = buf.split('\n').length > 8 || buf.length > 400;
141
+ if (isLong) {
142
+ chip = chip == null ? buf : chip + '\n' + buf; // 多块粘贴:并进同一 chip
143
+ lines = [''];
144
+ cl = 0;
145
+ cc = 0;
146
+ }
147
+ else {
148
+ insertText(buf);
149
+ }
150
+ computeFiltered();
151
+ redraw();
152
+ }
65
153
  function computeFiltered() {
66
154
  if (cl === 0 && lines[0].startsWith('/')) {
67
155
  filtered = opts.commands.filter((c) => c.name.startsWith(lines[0]));
@@ -77,9 +165,9 @@ export async function promptWithSlashMenu(opts) {
77
165
  function redraw() {
78
166
  layout.paintInput({
79
167
  prompt: opts.prompt,
80
- lines,
168
+ lines: dispLines(),
81
169
  cursorLine: cl,
82
- cursorCol: cursorCol(),
170
+ cursorCol: dispCursorCol(),
83
171
  menu: menuLines().length ? { lines: menuLines() } : null,
84
172
  });
85
173
  }
@@ -91,6 +179,13 @@ export async function promptWithSlashMenu(opts) {
91
179
  // 忽略
92
180
  }
93
181
  emitter.removeListener('keypress', onKey);
182
+ if (pasteTimer) {
183
+ clearTimeout(pasteTimer);
184
+ pasteTimer = null;
185
+ }
186
+ pasting = false;
187
+ pasteParts = [];
188
+ onPasteEnd = null;
94
189
  stdin.pause();
95
190
  }
96
191
  function finish(value) {
@@ -100,14 +195,16 @@ export async function promptWithSlashMenu(opts) {
100
195
  cleanup();
101
196
  resolve(value);
102
197
  }
103
- /** 提交:菜单打开时先补全选中项到第 0 行。 */
198
+ /** 提交:菜单打开时先补全选中项到第 0 行。chip 与 suffix 拼回全文(chip 在前,换行接 suffix)。 */
104
199
  function submit() {
105
200
  if (menuOpen && filtered[selected]) {
106
201
  lines = [filtered[selected].name];
107
202
  cl = 0;
108
203
  cc = lines[0].length;
109
204
  }
110
- finish(lines);
205
+ const suffix = lines.join('\n');
206
+ const content = chip ? (suffix.length > 0 ? chip + '\n' + suffix : chip) : suffix;
207
+ finish(content === '' ? [''] : content.split('\n'));
111
208
  }
112
209
  /** 插换行:在光标处断行。 */
113
210
  function insertNewline() {
@@ -122,6 +219,29 @@ export async function promptWithSlashMenu(opts) {
122
219
  function onKey(_str, key) {
123
220
  if (resolved || !key)
124
221
  return;
222
+ // 粘贴中:把键累积进 pasteParts(换行 \r\n 合一),不编辑 lines;末尾 finalizePaste 统一落 chip/文本
223
+ if (pasting) {
224
+ if (key.ctrl && key.name === 'c') {
225
+ cleanup();
226
+ reject(new Error('SIGINT'));
227
+ return;
228
+ }
229
+ const s = key.sequence ?? '';
230
+ const isReturn = key.name === 'return' || key.name === 'enter';
231
+ if (s === '\n' && justSawCR) {
232
+ justSawCR = false; // \r\n 的 \n:已随 \r 折行,吞掉
233
+ return;
234
+ }
235
+ if (isReturn || s === '\r' || s === '\n') {
236
+ justSawCR = s === '\r'; // \r 标记,待可能的尾随 \n
237
+ pasteParts.push('\n');
238
+ return;
239
+ }
240
+ justSawCR = false;
241
+ if (s && s >= ' ' && !key.ctrl && !key.meta)
242
+ pasteParts.push(s);
243
+ return;
244
+ }
125
245
  // 滚动回看键(优先;不触发回尾):PgUp/PgDn 翻页,Ctrl+↑↓ 与 plain ↑/↓ 单行。
126
246
  // plain ↑/↓ 仅在单行输入且菜单关闭时作滚动(多行编辑留给光标移动,菜单打开留给选项);
127
247
  // 兼鼠标滚轮——WT alt 屏(经 \x1B[?1007h)滚轮转发 ↑/↓。
@@ -171,17 +291,17 @@ export async function promptWithSlashMenu(opts) {
171
291
  return;
172
292
  }
173
293
  const isReturn = key.name === 'return' || key.name === 'enter';
174
- // 换行:Ctrl+J / Alt+Enter(meta)/ Shift+Enter(终端区分时)/ 粘贴的 LF
294
+ // 换行:Ctrl+J / Alt+Enter(meta)/ Shift+Enter(终端区分时)/ lone LF
295
+ // (粘贴的 CR/LF 已在上方 pasting 分支累积进 pasteParts,不会到此)
175
296
  const wantNewline = (key.ctrl && key.name === 'j') ||
176
297
  (key.meta && isReturn) ||
177
298
  (key.shift && isReturn) ||
178
299
  (key.sequence === '\n' && !key.ctrl);
179
- // 换行(Ctrl+J / Alt+Enter / Shift+Enter / 粘贴 LF)
180
300
  if (wantNewline) {
181
301
  insertNewline();
182
302
  return;
183
303
  }
184
- // 提交(plain Enter)
304
+ // 提交(键盘 plain Enter)
185
305
  if (isReturn && !key.shift && !key.meta && !key.ctrl) {
186
306
  submit();
187
307
  return;
@@ -204,6 +324,12 @@ export async function promptWithSlashMenu(opts) {
204
324
  computeFiltered();
205
325
  redraw();
206
326
  }
327
+ else if (chip) {
328
+ // 光标在 suffix 开头(紧贴 ] 后):退格删整个 chip(原子)
329
+ chip = null;
330
+ computeFiltered();
331
+ redraw();
332
+ }
207
333
  return;
208
334
  case 'up':
209
335
  if (menuOpen && filtered.length) {
@@ -284,6 +410,8 @@ export async function promptWithSlashMenu(opts) {
284
410
  return new Promise((res, rej) => {
285
411
  resolve = res;
286
412
  reject = rej;
413
+ ensurePasteDetector(); // 首次调用在 emitKeypressEvents 之前装 data 监听器(保序:mine 先于 解析器)
414
+ onPasteEnd = finalizePaste; // 粘贴结束回调:落 chip 或保留文本
287
415
  readline.emitKeypressEvents(stdin);
288
416
  let rawOk = true;
289
417
  try {
@@ -395,6 +523,7 @@ export async function promptTurnPicker(items) {
395
523
  return new Promise((res, rej) => {
396
524
  resolve = res;
397
525
  reject = rej;
526
+ ensurePasteDetector();
398
527
  readline.emitKeypressEvents(stdin);
399
528
  let rawOk = true;
400
529
  try {
package/dist/ui/theme.js CHANGED
@@ -19,4 +19,6 @@ export const ui = {
19
19
  magenta: wrap('\x1B[35m'),
20
20
  brightCyan: wrap('\x1B[96m'),
21
21
  brightMagenta: wrap('\x1B[95m'),
22
+ /** 用户消息满宽背景色(上滑时易辨认);bright black bg = 深灰,深色终端上微妙可辨。 */
23
+ userBg: wrap('\x1B[100m'),
22
24
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mocode-ai",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "终端编码 agent:LLM + tool-call 循环 + 流式输出(含思考)+ 9 个工具,接任意 OpenAI 兼容后端。",
5
5
  "type": "module",
6
6
  "bin": {