mocode-ai 0.1.0 → 0.1.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.
@@ -1,12 +1,24 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
1
3
  import { chat, } from '../llm/index.js';
2
4
  import { executeTool } from '../tools/registry.js';
3
5
  import { ui } from '../ui/theme.js';
4
6
  import { Spinner } from '../ui/spinner.js';
5
7
  import { summarizeToolCall, summarizeToolResult, truncateDisplay, } from '../ui/render.js';
8
+ import { renderFileChange } from '../ui/diff.js';
6
9
  import * as layout from '../ui/layout.js';
7
10
  import { beginTurn } from '../rollback/index.js';
8
11
  import { maybeCompact, capToolResultForHistory, contextState, } from '../session/index.js';
9
12
  const MAX_STEPS = 25; // 防止无限循环
13
+ /** 解析工具 arguments JSON;非法或空返 null(调用方据此降级到普通 preview)。 */
14
+ function parseArgs(raw) {
15
+ try {
16
+ return raw.trim() ? JSON.parse(raw) : {};
17
+ }
18
+ catch {
19
+ return null;
20
+ }
21
+ }
10
22
  /**
11
23
  * agent 核心循环:
12
24
  * 流式调 LLM(onText / onThinking 实时写内容区)→ 有 tool_calls 就执行并回灌
@@ -27,7 +39,17 @@ export async function runAgent(history, userInput, collapsedThinkings = [], sign
27
39
  // 开新轮次(回滚用):首行截断 40,供 /rollback 轮次菜单展示。
28
40
  beginTurn(truncateDisplay(userInput.split('\n')[0] ?? '', 40));
29
41
  layout.contentMode(); // 防御性:确保光标在内容续写位(enterRunningMode 已置,这里兜底)
30
- const spinner = new Spinner((msg, frame) => layout.setStatus(msg, frame ?? undefined));
42
+ // spinner:状态行 + 续写位内联转圈(思考中 / 执行 工具时,内容区不再「干等」)
43
+ // 内联帧不进缓冲、停时清掉,随后结果即写在该行——故 spinner 不入历史、PgUp 看不到。
44
+ const spinner = new Spinner((msg, frame) => {
45
+ layout.setStatus(msg, frame ?? undefined);
46
+ if (frame) {
47
+ layout.paintLiveAtCursor(` ${ui.brightMagenta}${frame}${ui.reset} ${ui.dim}${msg}…${ui.reset}`);
48
+ }
49
+ else {
50
+ layout.clearLiveAtCursor();
51
+ }
52
+ });
31
53
  // 本轮流式状态:区分「思考」与「正文」,首个 token 到达即停 spinner。
32
54
  let mode = 'idle';
33
55
  let gotText = false;
@@ -69,14 +91,23 @@ export async function runAgent(history, userInput, collapsedThinkings = [], sign
69
91
  };
70
92
  const onText = (s) => {
71
93
  flushThinkCollapsed(); // 思考→正文过渡时折叠
72
- if (mode === 'idle')
73
- spinner.stop();
94
+ spinner.stop(); // 任何正文 token 都停 spinner( token 停「思考中」;onToolCall 重启后若又来文本则停「生成中」)。未旋转时 stop 为 no-op。
74
95
  mode = 'text';
75
96
  gotText = true;
76
97
  layout.contentWrite(s);
77
98
  if (s)
78
99
  lastChar = s[s.length - 1];
79
100
  };
101
+ const onToolCall = (name) => {
102
+ // 文本/思考已流完,模型转而生成 tool_call 参数(可能很长,如 write_file 整篇内容):
103
+ // 补换行(让随后的 ● 行与 diff 不黏在正文末尾)+ 启「生成中」内联 spinner,内容区不再干等。
104
+ if (lastChar && lastChar !== '\n') {
105
+ layout.contentWrite('\n');
106
+ lastChar = '\n';
107
+ }
108
+ if (name)
109
+ spinner.start(`生成 ${name}…`);
110
+ };
80
111
  try {
81
112
  for (let step = 0; step < MAX_STEPS; step++) {
82
113
  // 步前:接近窗口上限时自动压缩(三层)。此时 spinner 已停,通知行干净。
@@ -87,7 +118,7 @@ export async function runAgent(history, userInput, collapsedThinkings = [], sign
87
118
  lastChar = '';
88
119
  let result;
89
120
  try {
90
- result = await chat(history, { onText, onThinking }, signal);
121
+ result = await chat(history, { onText, onThinking, onToolCall }, signal);
91
122
  }
92
123
  catch (e) {
93
124
  // 中断(用户运行中 Ctrl+C):停 spinner、补换行、提示、history 还原到本 turn 前、return(不抛)。
@@ -110,6 +141,9 @@ export async function runAgent(history, userInput, collapsedThinkings = [], sign
110
141
  if (result.toolCalls.length > 0) {
111
142
  // 思考后直接进入 tool_call(无 text):先把可见的思考段折叠
112
143
  flushThinkCollapsed();
144
+ // 流式正文末尾补换行(若 onToolCall 已补则 lastChar='\n',此处 no-op);防 ● 行黏在正文行尾
145
+ if (mode !== 'idle' && lastChar !== '\n')
146
+ layout.contentWrite('\n');
113
147
  // 带工具调用的 assistant 消息原样回灌(OpenAI 格式要求)
114
148
  history.push({
115
149
  role: 'assistant',
@@ -123,12 +157,59 @@ export async function runAgent(history, userInput, collapsedThinkings = [], sign
123
157
  for (const tc of result.toolCalls) {
124
158
  const summary = summarizeToolCall(tc.name, tc.arguments);
125
159
  layout.contentWrite(` ${ui.brightMagenta}●${ui.reset} ${ui.cyan}${tc.name}${ui.reset} ${ui.dim}${summary}${ui.reset}\n`);
160
+ const isMutation = tc.name === 'edit_file' || tc.name === 'write_file';
161
+ const parsed = isMutation ? parseArgs(tc.arguments) : null;
162
+ // write_file 覆盖场景:执行前读旧内容供 diff(不存在→null=新建)。
163
+ // edit_file:执行前读文件定位 old_string 起始行,供 diff 显示真实文件行号。
164
+ // 两者皆失败不阻断(读不到则 diff 退化为相对行号 / 不渲染)。
165
+ let preWriteOld = null;
166
+ let editStartLine = 1;
167
+ if (parsed) {
168
+ const p = String(parsed.path ?? '');
169
+ if (p) {
170
+ if (tc.name === 'write_file') {
171
+ try {
172
+ preWriteOld = readFileSync(resolve(p), 'utf8');
173
+ }
174
+ catch {
175
+ preWriteOld = null; // 文件不存在(新建)或不可读
176
+ }
177
+ }
178
+ else if (tc.name === 'edit_file') {
179
+ const oldStr = String(parsed.old_string ?? '');
180
+ try {
181
+ const data = readFileSync(resolve(p), 'utf8');
182
+ const idx = oldStr ? data.indexOf(oldStr) : -1;
183
+ if (idx >= 0)
184
+ editStartLine = data.slice(0, idx).split('\n').length;
185
+ }
186
+ catch {
187
+ // 读不到:startLine 保持 1(diff 退化为相对行号)
188
+ }
189
+ }
190
+ }
191
+ }
126
192
  spinner.start(`执行 ${tc.name}`);
127
193
  const output = await executeTool(tc.name, tc.arguments);
128
194
  spinner.stop();
129
- const preview = summarizeToolResult(tc.name, output);
130
- if (preview) {
131
- layout.contentWrite(` ${ui.gray}↳ ${preview}${ui.reset}\n`);
195
+ // edit_file / write_file 成功:渲染 diff 块(行号 + 语法高亮,仿 Claude Code);其余工具走一行 preview。
196
+ if (isMutation && parsed && !output.startsWith('错误')) {
197
+ layout.contentWrite(renderFileChange({
198
+ path: String(parsed.path ?? ''),
199
+ kind: tc.name === 'edit_file' ? 'edit' : 'write',
200
+ oldStr: tc.name === 'edit_file'
201
+ ? String(parsed.old_string ?? '')
202
+ : preWriteOld,
203
+ newStr: String((tc.name === 'edit_file' ? parsed.new_string : parsed.content) ??
204
+ ''),
205
+ startLine: tc.name === 'edit_file' ? editStartLine : 1,
206
+ }));
207
+ }
208
+ else {
209
+ const preview = summarizeToolResult(tc.name, output);
210
+ if (preview) {
211
+ layout.contentWrite(` ${ui.gray}↳ ${preview}${ui.reset}\n`);
212
+ }
132
213
  }
133
214
  history.push({
134
215
  role: 'tool',
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { exitAltScreen } from './ui/layout.js';
2
+ import { checkAndMaybeUpdate } from './updater/index.js';
2
3
  // 终端恢复兜底:任一退出 / 中断 / 未捕获异常路径都要恢复 alt screen,避免残留备用屏 + 滚动区域。
3
4
  // exitAltScreen 幂等(未激活时空操作),故全局注册安全——进 alt screen 前的路径(如 --resume 列表、缺环境变量、`mocode config`)调用它无副作用。
4
5
  // 仅 layout 是叶子(不依赖 config),故静态导入安全;repl / session 依赖 config(requireEnv 缺项即退出),改动态按需加载——`mocode config` 才能在零配置下跑。
@@ -63,12 +64,14 @@ async function main() {
63
64
  console.error(`[session] 找不到会话 ${id}(用 --resume 查看列表)`);
64
65
  process.exit(1);
65
66
  }
67
+ const updateNotice = checkAndMaybeUpdate();
66
68
  const { startRepl } = await import('./repl/index.js');
67
- await startRepl(loaded.history, loaded.id);
69
+ await startRepl(loaded.history, loaded.id, updateNotice);
68
70
  }
69
71
  else {
72
+ const updateNotice = checkAndMaybeUpdate();
70
73
  const { startRepl } = await import('./repl/index.js');
71
- await startRepl();
74
+ await startRepl(undefined, undefined, updateNotice);
72
75
  }
73
76
  process.exit(0);
74
77
  }
package/dist/llm/index.js CHANGED
@@ -67,8 +67,12 @@ export async function chat(messages, handlers = {}, signal) {
67
67
  }
68
68
  if (tc.id)
69
69
  entry.id = tc.id;
70
- if (tc.function?.name)
71
- entry.name += tc.function.name;
70
+ const fname = tc.function?.name;
71
+ if (fname) {
72
+ if (!entry.name)
73
+ handlers.onToolCall?.(fname); // 首次得知工具名:通知调用方启生成中 spinner
74
+ entry.name += fname;
75
+ }
72
76
  if (tc.function?.arguments)
73
77
  entry.arguments += tc.function.arguments;
74
78
  }
@@ -249,7 +249,7 @@ export function renderHistory(history) {
249
249
  * contentWrite 落入内容区(滚动区域内自动滚动,底栏不动)。history 由本模块持有,在轮次间持久;
250
250
  * agent 只读取并追加(+ 经 session/ 压缩)。每轮成功结束后自动落盘,退出后可用 --resume / /resume 续接。
251
251
  */
252
- export async function startRepl(initialHistory, sessionId) {
252
+ export async function startRepl(initialHistory, sessionId, updateNotice = null) {
253
253
  // 有预加载(--resume)则用它,并把 history[0] 刷成当前 system prompt(config 可能已变);
254
254
  // 否则新会话只塞 system 提示。
255
255
  const systemPrompt = effectiveSystemPrompt(config.systemPrompt);
@@ -287,6 +287,10 @@ export async function startRepl(initialHistory, sessionId) {
287
287
  else {
288
288
  layout.contentWrite(bannerString(banner()));
289
289
  }
290
+ if (updateNotice) {
291
+ // 自更新提示:开场静态段(进 INPUT 态前),dim 一行,不与流式 / 输入争用。
292
+ layout.contentWrite(` ${ui.gray}↳ ${updateNotice}${ui.reset}\n`);
293
+ }
290
294
  /**
291
295
  * 回滚子流程(由 /rollback 触发):菜单(↑/↓)选轮次 → 选中第 X 轮 = 删第 X 轮及之后 + 预填第 X 轮 user 输入
292
296
  * (仿 Claude Code rewind,Enter 重新跑该轮);被删轮次的文件改动仍逐个「保留/撤销」询问(cooked readline)。
@@ -0,0 +1,295 @@
1
+ import { highlight, plain } from 'cli-highlight';
2
+ import { ui } from './theme.js';
3
+ import { charWidth, displayWidth } from './render.js';
4
+ const MAX_FULL_DIFF_LINES = 800; // 任一边超此行数则跳过全量 LCS(避免大文件 O(n·m) 高开销)
5
+ const MAX_BODY_LINES = 24; // 正文最多展示行数(超出尾注「还有 N 行未显示」)
6
+ const MAX_LINE_DISPLAY = 120; // 单行截断显示宽度(CJK / 全角按 2 计)
7
+ const HEAD_INDENT = ' ';
8
+ const BODY_INDENT = ' ';
9
+ /** 扩展名 / 文件名 → cli-highlight(hljs)语言 id;未命中则不高亮(纯文本)。 */
10
+ const LANG_BY_EXT = {
11
+ '.ts': 'typescript', '.tsx': 'typescript', '.mts': 'typescript', '.cts': 'typescript',
12
+ '.js': 'javascript', '.jsx': 'javascript', '.mjs': 'javascript', '.cjs': 'javascript',
13
+ '.py': 'python', '.pyw': 'python', '.pyi': 'python',
14
+ '.json': 'json', '.json5': 'json',
15
+ '.md': 'markdown', '.markdown': 'markdown',
16
+ '.sh': 'bash', '.bash': 'bash', '.zsh': 'bash', '.fish': 'bash',
17
+ '.yml': 'yaml', '.yaml': 'yaml',
18
+ '.css': 'css', '.scss': 'scss', '.less': 'less',
19
+ '.html': 'xml', '.htm': 'xml', '.xml': 'xml', '.svg': 'xml',
20
+ '.go': 'go', '.rs': 'rust', '.java': 'java', '.kt': 'kotlin', '.kts': 'kotlin',
21
+ '.c': 'cpp', '.h': 'cpp', '.cpp': 'cpp', '.cc': 'cpp', '.hpp': 'cpp', '.cxx': 'cpp',
22
+ '.cs': 'csharp', '.rb': 'ruby', '.php': 'php', '.swift': 'swift',
23
+ '.sql': 'sql', '.lua': 'lua', '.r': 'r', '.dart': 'dart', '.scala': 'scala', '.groovy': 'groovy',
24
+ '.toml': 'ini', '.ini': 'ini', '.cfg': 'ini', '.conf': 'ini', '.properties': 'ini',
25
+ '.dockerfile': 'dockerfile', 'dockerfile': 'dockerfile', 'makefile': 'makefile', '.mk': 'makefile',
26
+ };
27
+ function langForPath(p) {
28
+ const lower = p.toLowerCase();
29
+ const slash = Math.max(lower.lastIndexOf('/'), lower.lastIndexOf('\\'));
30
+ const base = slash >= 0 ? lower.slice(slash + 1) : lower;
31
+ const dot = base.lastIndexOf('.');
32
+ if (dot > 0) {
33
+ const ext = base.slice(dot);
34
+ if (LANG_BY_EXT[ext])
35
+ return LANG_BY_EXT[ext];
36
+ }
37
+ return LANG_BY_EXT[base];
38
+ }
39
+ /** 包一层:token 文本 + 颜色 + reset,自成闭区间(防 SGR 跨行泄漏)。 */
40
+ const paint = (color) => (s) => `${color}${s}${ui.reset}`;
41
+ /** 自定义主题:default=plain(未匹配文本不着色,避免默认主题把普通代码染黄),常见 token 用 mocode ui 色板。 */
42
+ const THEME = {
43
+ default: plain,
44
+ keyword: paint(ui.magenta),
45
+ 'built_in': paint(ui.cyan),
46
+ type: paint(ui.cyan),
47
+ literal: paint(ui.cyan),
48
+ number: paint(ui.green),
49
+ string: paint(ui.green),
50
+ subst: paint(ui.green),
51
+ comment: paint(ui.gray),
52
+ doctag: paint(ui.gray),
53
+ function: paint(ui.blue),
54
+ title: paint(ui.blue),
55
+ class: paint(ui.brightCyan),
56
+ meta: paint(ui.gray),
57
+ 'meta-keyword': paint(ui.magenta),
58
+ regexp: paint(ui.red),
59
+ attr: paint(ui.cyan),
60
+ attribute: paint(ui.cyan),
61
+ variable: paint(ui.red),
62
+ tag: paint(ui.red),
63
+ name: paint(ui.cyan),
64
+ symbol: paint(ui.cyan),
65
+ section: paint(ui.brightMagenta),
66
+ addition: paint(ui.green),
67
+ deletion: paint(ui.red),
68
+ };
69
+ function highlightLine(text, lang) {
70
+ if (!lang || text === '')
71
+ return text;
72
+ try {
73
+ return highlight(text, { language: lang, theme: THEME });
74
+ }
75
+ catch {
76
+ return text; // 语言未注册 / 解析失败:退化为纯文本
77
+ }
78
+ }
79
+ /** 按行切分;去掉末尾单个空行(由末尾 \n 产生)与行尾 \r(CRLF 兼容)。 */
80
+ function splitLines(s) {
81
+ const a = s.split('\n');
82
+ if (a.length > 1 && a[a.length - 1] === '')
83
+ a.pop();
84
+ for (let i = 0; i < a.length; i++) {
85
+ if (a[i].endsWith('\r'))
86
+ a[i] = a[i].slice(0, -1);
87
+ }
88
+ return a;
89
+ }
90
+ /** 按显示宽度截断(不含省略号);CJK / 全角按 2 计,零宽符附上不推进。 */
91
+ function truncatePlain(s, width) {
92
+ let w = 0;
93
+ let out = '';
94
+ for (const ch of s) {
95
+ const cw = charWidth(ch.codePointAt(0) ?? 0);
96
+ if (cw <= 0) {
97
+ out += ch;
98
+ continue;
99
+ }
100
+ if (w + cw > width)
101
+ break;
102
+ out += ch;
103
+ w += cw;
104
+ }
105
+ return out;
106
+ }
107
+ /** 截断(plain)→ 高亮 → 末尾补 reset(+ 截断尾注)。 */
108
+ function codeText(text, lang) {
109
+ let plain = text;
110
+ let cut = false;
111
+ if (displayWidth(plain) > MAX_LINE_DISPLAY) {
112
+ plain = truncatePlain(plain, MAX_LINE_DISPLAY - 1);
113
+ cut = true;
114
+ }
115
+ return highlightLine(plain, lang) + ui.reset + (cut ? `${ui.dim}…${ui.reset}` : '');
116
+ }
117
+ /**
118
+ * 行级 LCS diff。返回 op 序列(ctx/add/del)。
119
+ * 任一边超 MAX_FULL_DIFF_LINES 返回 null(降级,由调用方走「省略 diff」分支)。
120
+ */
121
+ export function lineDiff(oldS, newS) {
122
+ const a = splitLines(oldS);
123
+ const b = splitLines(newS);
124
+ if (a.length > MAX_FULL_DIFF_LINES || b.length > MAX_FULL_DIFF_LINES)
125
+ return null;
126
+ const n = a.length;
127
+ const m = b.length;
128
+ const dp = new Array(n + 1);
129
+ for (let i = 0; i <= n; i++)
130
+ dp[i] = new Uint16Array(m + 1);
131
+ for (let i = n - 1; i >= 0; i--) {
132
+ for (let j = m - 1; j >= 0; j--) {
133
+ dp[i][j] =
134
+ a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
135
+ }
136
+ }
137
+ const out = [];
138
+ let i = 0;
139
+ let j = 0;
140
+ while (i < n && j < m) {
141
+ if (a[i] === b[j]) {
142
+ out.push({ op: 'ctx', text: a[i] });
143
+ i++;
144
+ j++;
145
+ }
146
+ else if (dp[i + 1][j] >= dp[i][j + 1]) {
147
+ out.push({ op: 'del', text: a[i] });
148
+ i++;
149
+ }
150
+ else {
151
+ out.push({ op: 'add', text: b[j] });
152
+ j++;
153
+ }
154
+ }
155
+ while (i < n) {
156
+ out.push({ op: 'del', text: a[i] });
157
+ i++;
158
+ }
159
+ while (j < m) {
160
+ out.push({ op: 'add', text: b[j] });
161
+ j++;
162
+ }
163
+ return out;
164
+ }
165
+ /** 连续 ctx 折叠:run ≤3 全显;>3 显首 + …(n) + 尾(保留紧邻改动的上下文)。 */
166
+ function compactCtx(ops) {
167
+ const items = [];
168
+ let i = 0;
169
+ while (i < ops.length) {
170
+ if (ops[i].op !== 'ctx') {
171
+ items.push({ kind: ops[i].op, text: ops[i].text });
172
+ i++;
173
+ continue;
174
+ }
175
+ let j = i;
176
+ while (j < ops.length && ops[j].op === 'ctx')
177
+ j++;
178
+ const run = ops.slice(i, j);
179
+ if (run.length <= 3) {
180
+ for (const r of run)
181
+ items.push({ kind: 'ctx', text: r.text });
182
+ }
183
+ else {
184
+ items.push({ kind: 'ctx', text: run[0].text });
185
+ items.push({ kind: 'ellipsis', count: run.length - 2 });
186
+ items.push({ kind: 'ctx', text: run[run.length - 1].text });
187
+ }
188
+ i = j;
189
+ }
190
+ return items;
191
+ }
192
+ function gutterOf(op) {
193
+ if (op === 'del')
194
+ return `${ui.red}-${ui.reset}`;
195
+ if (op === 'add')
196
+ return `${ui.green}+${ui.reset}`;
197
+ return `${ui.dim} ${ui.reset}`;
198
+ }
199
+ function lineWord(n) {
200
+ return n === 1 ? 'line' : 'lines';
201
+ }
202
+ /**
203
+ * 渲染文件改动块(供 layout.contentWrite 写入)。
204
+ * - kind:'edit' 头行用 Update;kind:'write' 用 Update(覆盖)/ Create(oldStr===null 新建)。
205
+ * - oldStr===null 表示新建:正文只显 + 行,行号从 1。
206
+ * - startLine:diff 首行对应的文件行号(edit_file 由调用方定位 old_string 起始行;write_file 传 1)。
207
+ * - diff 过大(超 MAX_FULL_DIFF_LINES)时仅显头行 + 「file too large, diff omitted」。
208
+ */
209
+ export function renderFileChange(opts) {
210
+ const { path, kind, oldStr, newStr } = opts;
211
+ const startLine = opts.startLine ?? 1;
212
+ const lang = langForPath(path);
213
+ const pathDisp = path; // 路径原样展示(不做截断,长路径由 contentWrite 折行)
214
+ const verb = oldStr === null ? 'Create' : 'Update';
215
+ // 头行:Update(path) / Create(path)
216
+ const head = `${HEAD_INDENT}${ui.brightMagenta}${verb}${ui.reset}${ui.gray}(${ui.reset}${ui.cyan}${pathDisp}${ui.reset}${ui.gray})${ui.reset}`;
217
+ // 新建:整文件作 + 行,行号从 1
218
+ if (oldStr === null) {
219
+ const lines = splitLines(newStr);
220
+ const counts = `${HEAD_INDENT} ${ui.dim}Added ${ui.reset}${ui.green}${lines.length}${ui.reset}${ui.dim} ${lineWord(lines.length)}${ui.reset}`;
221
+ const padW = Math.max(3, String(startLine + lines.length - 1).length);
222
+ return renderBody(head, counts, lines.map((l) => ({ kind: 'add', text: l })), padW, startLine, lang);
223
+ }
224
+ const ops = lineDiff(oldStr, newStr);
225
+ if (ops === null) {
226
+ return `${head}\n${HEAD_INDENT} ${ui.dim}(file too large, diff omitted)${ui.reset}\n`;
227
+ }
228
+ let add = 0;
229
+ let del = 0;
230
+ for (const o of ops) {
231
+ if (o.op === 'add')
232
+ add++;
233
+ else if (o.op === 'del')
234
+ del++;
235
+ }
236
+ // 计数行:Added N lines, removed M lines(按需省略 0 项)
237
+ const parts = [];
238
+ if (add > 0)
239
+ parts.push(`${ui.dim}Added ${ui.reset}${ui.green}${add}${ui.reset}${ui.dim} ${lineWord(add)}`);
240
+ if (del > 0)
241
+ parts.push(`${ui.dim}removed ${ui.reset}${ui.red}${del}${ui.reset}${ui.dim} ${lineWord(del)}`);
242
+ const counts = parts.length > 0
243
+ ? `${HEAD_INDENT} ${parts.join(`${ui.dim}, ${ui.reset}`)}${ui.reset}`
244
+ : `${HEAD_INDENT} ${ui.dim}No changes${ui.reset}`;
245
+ if (add === 0 && del === 0)
246
+ return `${head}\n${counts}\n`;
247
+ const oldLen = splitLines(oldStr).length;
248
+ const newLen = splitLines(newStr).length;
249
+ const padW = Math.max(3, String(startLine + Math.max(oldLen, newLen) - 1).length);
250
+ return renderBody(head, counts, compactCtx(ops), padW, startLine, lang);
251
+ }
252
+ /** 头行 + 计数行 + 正文(折叠 + 截断 + 行号),每行尾 \n。 */
253
+ function renderBody(head, counts, items, padW, startLine, lang) {
254
+ const lines = [head, counts];
255
+ const metaPrefix = `${BODY_INDENT}${' '.repeat(padW)} `; // 对齐到代码列(num + 空格 + gutter + 空格)
256
+ let oldLine = startLine;
257
+ let newLine = startLine;
258
+ let shown = 0;
259
+ let overflow = 0;
260
+ const pushBody = (num, op, text) => {
261
+ const numStr = String(num).padStart(padW);
262
+ lines.push(`${BODY_INDENT}${ui.gray}${numStr}${ui.reset} ${gutterOf(op)} ${codeText(text, lang)}`);
263
+ };
264
+ for (const it of items) {
265
+ if (shown >= MAX_BODY_LINES) {
266
+ overflow++;
267
+ continue;
268
+ }
269
+ if (it.kind === 'ellipsis') {
270
+ oldLine += it.count;
271
+ newLine += it.count;
272
+ lines.push(`${metaPrefix}${ui.dim}…(${it.count} 行不变)${ui.reset}`);
273
+ shown++;
274
+ continue;
275
+ }
276
+ if (it.kind === 'del') {
277
+ pushBody(oldLine, 'del', it.text);
278
+ oldLine++;
279
+ }
280
+ else if (it.kind === 'add') {
281
+ pushBody(newLine, 'add', it.text);
282
+ newLine++;
283
+ }
284
+ else {
285
+ pushBody(newLine, 'ctx', it.text);
286
+ oldLine++;
287
+ newLine++;
288
+ }
289
+ shown++;
290
+ }
291
+ if (overflow > 0) {
292
+ lines.push(`${metaPrefix}${ui.dim}…(还有 ${overflow} 行未显示)${ui.reset}`);
293
+ }
294
+ return lines.join('\n') + '\n';
295
+ }
package/dist/ui/layout.js CHANGED
@@ -316,6 +316,22 @@ export function setStatus(status, frame) {
316
316
  spinnerFrame = frame;
317
317
  drawStatusBar();
318
318
  }
319
+ /**
320
+ * 在续写位画一行瞬时活动文本(spinner 帧):不进缓冲、不推进续写位,逐行 clearLine 重画。
321
+ * 仅 TTY + offset=0(实时尾)时物理写屏;滚动态跳过(由状态行 spinner 兜底,且避免覆盖 viewport 历史行)。
322
+ * 配合 clearLiveAtCursor 在 spinner 停时清掉,随后 contentWrite 的结果即写在该行(spinner 不入历史缓冲)。
323
+ */
324
+ export function paintLiveAtCursor(text) {
325
+ if (!active || !ui.isTTY || scrollOffset !== 0)
326
+ return;
327
+ stdout.write(cup(contentRow, contentCol) + esc.clearLine + text);
328
+ }
329
+ /** 清掉续写位那行瞬时活动文本(配合 paintLiveAtCursor)。 */
330
+ export function clearLiveAtCursor() {
331
+ if (!active || !ui.isTTY || scrollOffset !== 0)
332
+ return;
333
+ stdout.write(cup(contentRow, contentCol) + esc.clearLine);
334
+ }
319
335
  /** 更新状态行基线(模型 / context / cwd)。repl 在轮次边界调。 */
320
336
  export function setStatusBase(b) {
321
337
  base = b;
package/dist/ui/render.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import { stdout } from 'node:process';
2
+ import { createRequire } from 'node:module';
2
3
  import { ui } from './theme.js';
4
+ const VERSION = createRequire(import.meta.url)('../../package.json').version;
3
5
  /**
4
6
  * 清空整屏 + 滚动缓冲(向上滚动可见的历史输出),光标归位。
5
7
  * 进入会话时调用,让终端只剩当前 agent 对话。非 TTY 时空操作。
@@ -71,6 +73,40 @@ export function padEndDisplay(str, width) {
71
73
  const w = displayWidth(str);
72
74
  return w >= width ? str : str + ' '.repeat(width - w);
73
75
  }
76
+ /** 带色串按可见宽度右补空格(先剥离 ANSI 算真实宽度,空格补在串末,不破坏颜色码)。 */
77
+ export function padEndAnsi(str, width) {
78
+ const w = ansiDisplayWidth(str);
79
+ return w >= width ? str : str + ' '.repeat(width - w);
80
+ }
81
+ /** 带色串按可见宽度截断(保留中间 ANSI 码,超出末尾加 …,补 reset 防 … 继承颜色)。 */
82
+ export function truncateAnsi(str, width) {
83
+ if (ansiDisplayWidth(str) <= width)
84
+ return str;
85
+ let w = 0;
86
+ let out = '';
87
+ let hasStyle = false;
88
+ // 按 ANSI 转义切分:偶数索引=文本,奇数索引=SGR 码
89
+ const parts = str.split(/(\x1b\[[0-9;]*m)/);
90
+ for (let i = 0; i < parts.length; i++) {
91
+ if (i % 2 === 1) {
92
+ out += parts[i];
93
+ hasStyle = parts[i] !== '\x1b[0m';
94
+ continue;
95
+ }
96
+ for (const ch of parts[i]) {
97
+ const cw = charWidth(ch.codePointAt(0) ?? 0);
98
+ if (cw > 0 && w + cw + 1 > width) {
99
+ if (hasStyle)
100
+ out += '\x1b[0m';
101
+ return out + '…';
102
+ }
103
+ if (cw > 0)
104
+ w += cw;
105
+ out += ch;
106
+ }
107
+ }
108
+ return out;
109
+ }
74
110
  /** 按显示宽度截断,超出加 …。 */
75
111
  export function truncateDisplay(str, width) {
76
112
  if (displayWidth(str) <= width)
@@ -116,40 +152,41 @@ export function wrapByDisplayWidth(text, width) {
116
152
  out.push(cur);
117
153
  return out;
118
154
  }
119
- const BOX_W = 76; // 内容区显示宽度(容下完整工具列表 + 命令提示)
155
+ const BOX_W = 60; // 内容区显示宽度(logo + 信息区)
120
156
  const MARGIN = ' '; // 盒外左缩进
121
- function boxBorder(left, mid, right) {
122
- return `${ui.gray}${left}${mid.repeat(BOX_W + 2)}${right}${ui.reset}`;
123
- }
124
- function boxLine(content) {
125
- const inner = padEndDisplay(truncateDisplay(content, BOX_W), BOX_W);
126
- return `${ui.gray}│${ui.reset} ${inner} ${ui.gray}│${ui.reset}`;
127
- }
128
- function boxEmpty() {
129
- return `${ui.gray}│${ui.reset} ${' '.repeat(BOX_W)} ${ui.gray}│${ui.reset}`;
130
- }
131
- function labelRow(label, value) {
132
- return boxLine(`${ui.dim}${padEndDisplay(label, 6)}${ui.reset}${value}`);
133
- }
134
- /** 横幅纯文本(带 ANSI 颜色,不写出)——供 TUI 经 contentWrite 写入内容区以跟踪续写位。 */
157
+ // ── 坐姿 logo(乌龟风,加双手)──
158
+ const LOGO_W = 11; // logo 区显示宽度
159
+ const LOGO_GAP = 4; // logo 与信息区之间的间隔
160
+ const LOGO_PAD = ' '.repeat(LOGO_W + LOGO_GAP); // 无 logo 行的缩进(对齐信息区)
161
+ // 坐着的小人:[●ᴗ●] / | | 身 / ╲| |╱ 双手外撑 / | | 身 / OO 脚
162
+ const LOGO_LINES = [
163
+ ' [◕ᴗ◕] ',
164
+ ' ●| |● ',
165
+ ' OO ',
166
+ ];
167
+ /** 取第 idx 行 logo(着色 + 补宽);越界返空格(logo 下方行仍缩进对齐)。 */
168
+ function logoLine(idx) {
169
+ if (idx >= LOGO_LINES.length)
170
+ return LOGO_PAD;
171
+ return `${ui.brightCyan}${padEndDisplay(LOGO_LINES[idx], LOGO_W)}${ui.reset}${' '.repeat(LOGO_GAP)}`;
172
+ }
173
+ function labelContent(label, value) {
174
+ return `${ui.dim}${padEndDisplay(label, 6)}${ui.reset}${value}`;
175
+ }
176
+ /** 横幅纯文本(带 ANSI 颜色,不写出)——供 TUI 经 contentWrite 写入内容区以跟踪续写位。
177
+ * 无边框:左侧实心小熊 + 右侧标题/信息(模型/目录),末尾一行提示。 */
135
178
  export function bannerString(info) {
179
+ const title = `${ui.bold}${ui.brightCyan}◆ mocode${ui.reset} ${ui.dim}v${VERSION}${ui.reset}`;
136
180
  const rows = [
137
- boxBorder('╭', '─', '╮'),
138
- boxLine(`${ui.bold}${ui.brightCyan}◆ mocode${ui.reset} ${ui.dim}终端编码 agent${ui.reset}`),
139
- boxEmpty(),
140
- labelRow('模型', info.model),
141
- labelRow('后端', info.baseURL),
142
- labelRow('目录', info.cwd),
143
- labelRow('工具', info.tools),
144
- boxEmpty(),
145
- boxLine(`${ui.dim}/exit 退出 · /clear 清空 · /compact 压缩 · /context 用量 · /resume 续接${ui.reset}`),
146
- boxBorder('╰', '─', '╯'),
181
+ logoLine(0) + title,
182
+ logoLine(1) + labelContent('模型', info.model),
183
+ logoLine(2) + labelContent('目录', truncateDisplay(info.cwd, 48)),
147
184
  ];
148
185
  return (rows.map((r) => MARGIN + r).join('\n') +
149
186
  '\n\n' +
150
187
  `${MARGIN}${ui.dim}直接描述任务,agent 会自动读写文件与执行命令。${ui.reset}\n`);
151
188
  }
152
- /** 启动横幅:带边框的信息盒 + 一行提示。纯渲染,不依赖 config / 业务。 */
189
+ /** 启动横幅:小熊 logo + 标题/信息 + 一行提示。纯渲染,不依赖 config / 业务。 */
153
190
  export function printBanner(info) {
154
191
  stdout.write(bannerString(info));
155
192
  }
package/dist/ui/theme.js CHANGED
@@ -13,6 +13,7 @@ export const ui = {
13
13
  red: wrap('\x1B[31m'),
14
14
  green: wrap('\x1B[32m'),
15
15
  yellow: wrap('\x1B[33m'),
16
+ blue: wrap('\x1B[34m'),
16
17
  cyan: wrap('\x1B[36m'),
17
18
  gray: wrap('\x1B[90m'),
18
19
  magenta: wrap('\x1B[35m'),
@@ -0,0 +1,161 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { spawn } from 'node:child_process';
5
+ import { fileURLToPath } from 'node:url';
6
+ /**
7
+ * 启动时自动检测并后台自更新(仿 update-notifier,但**真更新**而非仅提示)。
8
+ *
9
+ * 设计要点:
10
+ * - **零启动延迟**:`checkAndMaybeUpdate()` 同步返回提示串或 null;联网刷新与 `npm i -g` 都是
11
+ * fire-and-forget 后台操作(不 await),不卡 REPL 启动。
12
+ * - **下次启动生效**:当前进程已把 dist/*.js 读入内存,运行中不持有文件句柄;后台 `npm i -g`
13
+ * 覆写磁盘包(含 mocode.cmd shim),当前进程不受影响,**下次** `mocode` 即新版本。
14
+ * - **节流**:检查 24h、spawn 6h(防每跑必联网 / 重复 spawn)。更新成功后下次重读 package.json
15
+ * 得 current=latest → 不再 spawn、不再提示。
16
+ * - **不依赖 config**:只用 stdlib + 全局 fetch,缺 LLM 配置也能跑(在 config 校验前调)。
17
+ * - **dev 跳过**:tsx 运行 `.ts`(`npm start`)不自更新;编译态 `.js`(`mocode` 全局命令)才更新。
18
+ * - **失败静默**:断网 / 无 npm / 权限不足都不阻断启动。
19
+ */
20
+ const PKG_NAME = 'mocode-ai';
21
+ /** 包根 package.json:从 dist/updater/ 或 src/updater/ 均 `../../package.json`(npm 必带 package.json)。 */
22
+ const PKG_PATH = fileURLToPath(new URL('../../package.json', import.meta.url));
23
+ const CACHE_PATH = path.join(os.homedir(), '.mocode', 'update-check.json');
24
+ const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24h:多久重新拉一次 registry
25
+ const SPAWN_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6h:同一待更新版本多久内不重复 spawn
26
+ const FETCH_TIMEOUT_MS = 2500;
27
+ /** 缓存路径:MOCODE_UPDATE_CACHE 可重定向(测试用临时文件,避免污染真实 ~/.mocode 缓存)。 */
28
+ function cachePath() {
29
+ return process.env.MOCODE_UPDATE_CACHE || CACHE_PATH;
30
+ }
31
+ /** tsx 开发态(import.meta.url 以 .ts 结尾)跳过自更新;编译态 .js 才更新。 */
32
+ function isDevRun() {
33
+ try {
34
+ return fileURLToPath(import.meta.url).endsWith('.ts');
35
+ }
36
+ catch {
37
+ return false;
38
+ }
39
+ }
40
+ /** 读当前安装版本(package.json 的 version)。读不到返 ''。 */
41
+ function readCurrentVersion() {
42
+ try {
43
+ const pkg = JSON.parse(fs.readFileSync(PKG_PATH, 'utf8'));
44
+ return pkg.version ?? '';
45
+ }
46
+ catch {
47
+ return '';
48
+ }
49
+ }
50
+ function readCache() {
51
+ try {
52
+ return JSON.parse(fs.readFileSync(cachePath(), 'utf8'));
53
+ }
54
+ catch {
55
+ return {};
56
+ }
57
+ }
58
+ /** 读-合并-写(各写者独立 merge,降低后台 fetch 与同步 spawn 写的竞态丢字段风险)。 */
59
+ function writeCache(patch) {
60
+ try {
61
+ const merged = { ...readCache(), ...patch };
62
+ fs.mkdirSync(path.dirname(cachePath()), { recursive: true });
63
+ fs.writeFileSync(cachePath(), JSON.stringify(merged), 'utf8');
64
+ }
65
+ catch {
66
+ // 写缓存失败不阻断
67
+ }
68
+ }
69
+ /** registry 取自 ~/.npmrc(国内镜像用户与 `npm i -g` 装的源一致);无则用官方端点。保证尾 `/`。 */
70
+ function readRegistry() {
71
+ let reg = 'https://registry.npmjs.org/';
72
+ try {
73
+ const npmrc = fs.readFileSync(path.join(os.homedir(), '.npmrc'), 'utf8');
74
+ const m = /^registry\s*=\s*["']?([^"'\s]+)["']?\s*$/m.exec(npmrc);
75
+ if (m && m[1])
76
+ reg = m[1];
77
+ }
78
+ catch {
79
+ // 无 ~/.npmrc:用默认
80
+ }
81
+ return reg.endsWith('/') ? reg : reg + '/';
82
+ }
83
+ /** 后台拉 registry 最新版(不 await):成功写 latest+lastCheck,失败只写 lastCheck(防离线每跑必联网)。 */
84
+ async function fetchLatestInBackground() {
85
+ try {
86
+ const ctrl = new AbortController();
87
+ const t = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS);
88
+ const res = await fetch(`${readRegistry()}${PKG_NAME}/latest`, { signal: ctrl.signal });
89
+ clearTimeout(t);
90
+ if (!res.ok)
91
+ throw new Error(`HTTP ${res.status}`);
92
+ const data = (await res.json());
93
+ writeCache(data.version ? { latest: data.version, lastCheck: Date.now() } : { lastCheck: Date.now() });
94
+ }
95
+ catch {
96
+ writeCache({ lastCheck: Date.now() });
97
+ }
98
+ }
99
+ /** 后台 spawn `npm i -g <pkg>@latest`(Win:shell 解析 npm.cmd + windowsHide 防控制台闪;detached+unref 跨进程存活)。 */
100
+ function spawnUpdate() {
101
+ if (process.env.MOCODE_NO_SPAWN)
102
+ return; // 测试门控:禁真跑 npm
103
+ try {
104
+ const child = spawn('npm', ['install', '-g', `${PKG_NAME}@latest`], {
105
+ shell: true,
106
+ detached: true,
107
+ stdio: 'ignore',
108
+ windowsHide: true,
109
+ });
110
+ child.unref();
111
+ child.on('error', () => { });
112
+ }
113
+ catch {
114
+ // 忽略
115
+ }
116
+ }
117
+ /** 简易 semver 比较(只管 X.Y.Z 数字,无 pre-release):a>b 返正,a<b 返负,等返 0。 */
118
+ export function compareSemver(a, b) {
119
+ const pa = a.split('.').map((x) => Number(x) || 0);
120
+ const pb = b.split('.').map((x) => Number(x) || 0);
121
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
122
+ const da = pa[i] ?? 0;
123
+ const db = pb[i] ?? 0;
124
+ if (da !== db)
125
+ return da - db;
126
+ }
127
+ return 0;
128
+ }
129
+ /** 纯决策:据当前版本 / 缓存 / 时间,算是否提示 / spawn / 后台刷新。无副作用,可离线测。 */
130
+ export function evaluateUpdate(current, cache, now) {
131
+ const refresh = !cache.latest || now - (cache.lastCheck ?? 0) > CHECK_INTERVAL_MS;
132
+ let notice = null;
133
+ let spawn = false;
134
+ if (cache.latest && compareSemver(cache.latest, current) > 0) {
135
+ notice = `检测到新版本 ${cache.latest},后台更新中,下次启动生效。`;
136
+ spawn = now - (cache.lastSpawn ?? 0) > SPAWN_INTERVAL_MS;
137
+ }
138
+ return { notice, spawn, refresh };
139
+ }
140
+ /**
141
+ * 启动时调(同步,零延迟):返一行纯文本更新提示或 null。
142
+ * 流程:dev 跳过 → 读当前版本 → 读缓存 → evaluateUpdate 决策 → 按需后台刷新 / spawn → 返提示。
143
+ * 提示由 repl 上色写入内容区开场段(进 INPUT 态前)。
144
+ */
145
+ export function checkAndMaybeUpdate() {
146
+ if (isDevRun())
147
+ return null;
148
+ const current = readCurrentVersion();
149
+ if (!current)
150
+ return null;
151
+ const cache = readCache();
152
+ const now = Date.now();
153
+ const d = evaluateUpdate(current, cache, now);
154
+ if (d.refresh)
155
+ void fetchLatestInBackground(); // fire-and-forget:刷新缓存供下次启动用
156
+ if (d.spawn) {
157
+ spawnUpdate();
158
+ writeCache({ lastSpawn: now });
159
+ }
160
+ return d.notice;
161
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mocode-ai",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "终端编码 agent:LLM + tool-call 循环 + 流式输出(含思考)+ 9 个工具,接任意 OpenAI 兼容后端。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -21,6 +21,7 @@
21
21
  "prepare": "npm run build"
22
22
  },
23
23
  "dependencies": {
24
+ "cli-highlight": "^2.1.11",
24
25
  "dotenv": "^16.0.0",
25
26
  "fast-glob": "^3.0.0",
26
27
  "openai": "^4.0.0"