mocode-ai 0.1.0 → 0.1.1

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/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
  }
@@ -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'),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mocode-ai",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
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"