mocode-ai 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,162 @@
1
+ import OpenAI from 'openai';
2
+ import { config } from '../config/index.js';
3
+ import { tools } from '../tools/registry.js';
4
+ const client = new OpenAI({
5
+ baseURL: config.baseURL,
6
+ apiKey: config.apiKey,
7
+ });
8
+ /** 把内部工具定义转成 OpenAI 的 tool 格式 */
9
+ export const chatTools = tools.map((t) => ({
10
+ type: 'function',
11
+ function: {
12
+ name: t.name,
13
+ description: t.description,
14
+ // 不同版本 SDK 的 FunctionParameters 宽严不一,用 any 兜底
15
+ parameters: t.parameters,
16
+ },
17
+ }));
18
+ /**
19
+ * 流式调一次 LLM:增量回调文本 / 思考,内部累加 tool_calls 片段。
20
+ * 思考内容走 delta.reasoning_content(DeepSeek / GLM / Qwen 等推理模型,
21
+ * SDK 类型无此字段,用 as any 取;不支持的模型则无思考,只流文本)。
22
+ * tool_calls 跨 chunk 按 index 累加(id / name / arguments 拼接)。
23
+ * include_usage 时末尾 chunk 携带 usage,先读再 continue(末尾 chunk 无 delta)。
24
+ */
25
+ export async function chat(messages, handlers = {}, signal) {
26
+ // signal 透传给 SDK 第二参(RequestOptions);abort 后 for await 抛错,chat 不 catch,透传 runAgent 处理。
27
+ const stream = await client.chat.completions.create({
28
+ model: config.model,
29
+ messages,
30
+ tools: chatTools,
31
+ stream: true,
32
+ ...(config.maxTokens ? { max_tokens: config.maxTokens } : {}),
33
+ ...(config.includeUsage ? { stream_options: { include_usage: true } } : {}),
34
+ }, signal ? { signal } : undefined);
35
+ let content = '';
36
+ let hasContent = false;
37
+ let usage;
38
+ const toolAcc = new Map();
39
+ for await (const chunk of stream) {
40
+ // usage:末尾 chunk(choices 可能为空)在 include_usage 时携带;先读再 continue。
41
+ if (chunk.usage) {
42
+ usage = {
43
+ promptTokens: chunk.usage.prompt_tokens,
44
+ completionTokens: chunk.usage.completion_tokens,
45
+ totalTokens: chunk.usage.total_tokens,
46
+ };
47
+ }
48
+ const delta = chunk.choices[0]?.delta;
49
+ if (!delta)
50
+ continue; // 末尾 usage-only chunk 等无 delta
51
+ // 思考内容(非标准字段,SDK 类型无)
52
+ const reasoning = delta.reasoning_content ?? delta.reasoning;
53
+ if (reasoning)
54
+ handlers.onThinking?.(reasoning);
55
+ if (delta.content) {
56
+ content += delta.content;
57
+ hasContent = true;
58
+ handlers.onText?.(delta.content);
59
+ }
60
+ if (delta.tool_calls) {
61
+ for (const tc of delta.tool_calls) {
62
+ const idx = tc.index ?? 0;
63
+ let entry = toolAcc.get(idx);
64
+ if (!entry) {
65
+ entry = { name: '', arguments: '' };
66
+ toolAcc.set(idx, entry);
67
+ }
68
+ if (tc.id)
69
+ entry.id = tc.id;
70
+ if (tc.function?.name)
71
+ entry.name += tc.function.name;
72
+ if (tc.function?.arguments)
73
+ entry.arguments += tc.function.arguments;
74
+ }
75
+ }
76
+ }
77
+ const toolCalls = [...toolAcc.entries()]
78
+ .sort((a, b) => a[0] - b[0])
79
+ .map(([, e]) => ({
80
+ id: e.id ?? '',
81
+ name: e.name,
82
+ arguments: e.arguments,
83
+ }));
84
+ return {
85
+ content: hasContent ? content : null,
86
+ toolCalls,
87
+ usage,
88
+ };
89
+ }
90
+ // ── token 估算(自包含,不依赖 ui / 外部 tokenizer)──────────────────────
91
+ // CJK 感知启发式:CJK 字符 ≈ 1 token,其余 ≈ 4 字符/token。
92
+ // 故意偏过估(安全侧):估算偏高 → 压缩触发偏早 → 不会溢出窗口。
93
+ // 真实 usage 由 chat() 的 include_usage 返回;此处仅作预检 / 兜底 / /context 显示。
94
+ /** 判断一个码点是否 CJK 表意 / 假名 / 韩文(按 1 token 计)。 */
95
+ function isCJK(cp) {
96
+ return ((cp >= 0x4e00 && cp <= 0x9fff) || // CJK 统一表意
97
+ (cp >= 0x3400 && cp <= 0x4dbf) || // CJK 扩展 A
98
+ (cp >= 0xf900 && cp <= 0xfaff) || // CJK 兼容表意
99
+ (cp >= 0x3040 && cp <= 0x30ff) || // 假名
100
+ (cp >= 0xac00 && cp <= 0xd7a3) // 韩文音节
101
+ );
102
+ }
103
+ /** 粗估一段文本的 token 数。CJK≈1/字,其余≈1/4字,向上取整。 */
104
+ export function estimateTokens(text) {
105
+ if (!text)
106
+ return 0;
107
+ let cjk = 0;
108
+ let other = 0;
109
+ for (const ch of text) {
110
+ const cp = ch.codePointAt(0) ?? 0;
111
+ if (isCJK(cp))
112
+ cjk++;
113
+ else
114
+ other++;
115
+ }
116
+ return Math.ceil(cjk + other / 4);
117
+ }
118
+ /** 把任意消息内容(content 可能是 string | null | 多模态数组)拍平成字符串。 */
119
+ function contentToText(content) {
120
+ if (content == null)
121
+ return '';
122
+ if (typeof content === 'string')
123
+ return content;
124
+ try {
125
+ return JSON.stringify(content);
126
+ }
127
+ catch {
128
+ return String(content);
129
+ }
130
+ }
131
+ /** 估算单条消息的 token 数:结构开销 + content + tool_calls 参数。 */
132
+ export function messageTokens(m) {
133
+ const role = m.role;
134
+ let structural = 4; // {role}\n{content}\n 框架基线
135
+ if (role === 'system')
136
+ structural = 3;
137
+ else if (role === 'tool')
138
+ structural = 6;
139
+ let body = contentToText(m.content);
140
+ const tcs = m
141
+ .tool_calls;
142
+ if (tcs) {
143
+ for (const tc of tcs)
144
+ body += tc?.function?.arguments ?? '';
145
+ }
146
+ return structural + estimateTokens(body);
147
+ }
148
+ /** 估算整段 messages 的 token 数(不含工具 schema,含 priming 常数)。 */
149
+ export function estimateMessagesTokens(messages) {
150
+ let sum = 3; // priming:每轮对话的基础开销
151
+ for (const m of messages)
152
+ sum += messageTokens(m);
153
+ return sum;
154
+ }
155
+ let schemaTokensCache;
156
+ /** 估算 chatTools(工具 schema)占用的一次性 token,带缓存。 */
157
+ export function estimateToolSchemaTokens() {
158
+ if (schemaTokensCache === undefined) {
159
+ schemaTokensCache = estimateTokens(JSON.stringify(chatTools)) + 16;
160
+ }
161
+ return schemaTokensCache;
162
+ }
@@ -0,0 +1,530 @@
1
+ import readline from 'node:readline/promises';
2
+ import { emitKeypressEvents } from 'node:readline';
3
+ import { stdin, stdout } from 'node:process';
4
+ import { config } from '../config/index.js';
5
+ import { runAgent } from '../agent/index.js';
6
+ import { ui } from '../ui/theme.js';
7
+ import { bannerString, displayWidth, summarizeToolCall, summarizeToolResult } from '../ui/render.js';
8
+ import * as layout from '../ui/layout.js';
9
+ import { promptWithSlashMenu, promptTurnPicker } from '../ui/prompt.js';
10
+ import { tools } from '../tools/registry.js';
11
+ import { estimateMessagesTokens, estimateToolSchemaTokens, } from '../llm/index.js';
12
+ import { compactHistory, contextState, newSessionId, saveSession, loadSession, listSessions, } from '../session/index.js';
13
+ import { listTurns, planRollback, applyRollback, persistSnapshots, loadSnapshots, rebuildFromHistory, resetState, } from '../rollback/index.js';
14
+ import { listSkills, effectiveSystemPrompt } from '../skills/index.js';
15
+ /**
16
+ * readline 的 prompt 必须是纯文本(无 ANSI):readline 按字符数算光标位置,
17
+ * 颜色码会让光标错位、编辑时漂移。颜色只用在直接 stdout.write 的横幅 / 工具行 / 回复。
18
+ */
19
+ const PROMPT = '❯ ';
20
+ /** 斜杠命令菜单(仅用于输入时下拉显示与过滤;分发仍走下方 if 链)。 */
21
+ const SLASH_COMMANDS = [
22
+ { name: '/exit', desc: '退出 mocode(同 /quit)' },
23
+ { name: '/clear', desc: '清空历史(保留系统提示)' },
24
+ { name: '/context', desc: '显示上下文用量条' },
25
+ { name: '/skills', desc: '列出已发现的 skill' },
26
+ { name: '/compact', desc: '压缩历史(可带焦点 /compact …)' },
27
+ { name: '/resume', desc: '续接已保存的会话' },
28
+ { name: '/think', desc: '展开折叠思考段(/think N)' },
29
+ { name: '/rollback', desc: '菜单选轮次回滚(↑↓·Enter)' },
30
+ ];
31
+ /** 临时 readline 读一行(cooked,用于子提问;主输入走 promptWithSlashMenu)。 */
32
+ async function askLine(prompt) {
33
+ const rl = readline.createInterface({ input: stdin, output: stdout });
34
+ try {
35
+ return await rl.question(prompt);
36
+ }
37
+ finally {
38
+ rl.close();
39
+ }
40
+ }
41
+ /** /context 的用量条(详情版,进内容区):优先用上次 chat() 返回的实测 usage,否则用启发式估算。 */
42
+ function renderContextBar(history) {
43
+ const schema = estimateToolSchemaTokens();
44
+ const est = contextState.lastUsage?.totalTokens ??
45
+ estimateMessagesTokens(history) + schema;
46
+ const win = config.contextWindowTokens;
47
+ const pct = Math.min(1, est / win);
48
+ const W = 10;
49
+ const filled = Math.round(pct * W);
50
+ const bar = '█'.repeat(filled) + '░'.repeat(W - filled);
51
+ const src = contextState.lastUsage ? '实测' : '估算';
52
+ const k = (n) => `${Math.round(n / 1000)}k`;
53
+ const pctCol = pct >= config.compactThreshold ? ui.yellow : ui.cyan;
54
+ return `${ui.gray}[${pctCol}${bar}${ui.reset}] ${Math.round(pct * 100)}% ${k(est)}/${k(win)} tokens · ${history.length} 条消息 (${src})${ui.reset}`;
55
+ }
56
+ /** 状态行用量条(精简版,进底栏):[bar] pct% k/k。 */
57
+ function renderContextBarInline(history) {
58
+ const schema = estimateToolSchemaTokens();
59
+ const est = contextState.lastUsage?.totalTokens ??
60
+ estimateMessagesTokens(history) + schema;
61
+ const win = config.contextWindowTokens;
62
+ const pct = Math.min(1, est / win);
63
+ const W = 10;
64
+ const filled = Math.round(pct * W);
65
+ const bar = '█'.repeat(filled) + '░'.repeat(W - filled);
66
+ const k = (n) => `${Math.round(n / 1000)}k`;
67
+ const pctCol = pct >= config.compactThreshold ? ui.yellow : ui.cyan;
68
+ return `${ui.gray}[${pctCol}${bar}${ui.reset}] ${pctCol}${Math.round(pct * 100)}%${ui.reset} ${ui.dim}${k(est)}/${k(win)}${ui.reset}`;
69
+ }
70
+ /** 状态行基线:模型 / context / cwd。repl 在轮次边界调,刷新 context 用量。 */
71
+ function refreshStatusBase(history) {
72
+ layout.setStatusBase({
73
+ model: config.model,
74
+ contextBar: renderContextBarInline(history),
75
+ cwd: process.cwd(),
76
+ });
77
+ }
78
+ /** 命令 → 运行态状态文字 + 底栏 dim 占位。 */
79
+ function runningStateFor(cmd) {
80
+ switch (cmd) {
81
+ case '/compact':
82
+ return { status: '压缩', placeholder: '压缩中…' };
83
+ case '/resume':
84
+ return { status: '续接', placeholder: '选择会话…' };
85
+ case '/rollback':
86
+ return { status: '回滚', placeholder: '选择轮次…' };
87
+ case '/clear':
88
+ return { status: '清空', placeholder: '…' };
89
+ default:
90
+ return { status: '处理', placeholder: '思考中… Ctrl+C 中断' };
91
+ }
92
+ }
93
+ const emitter = stdin;
94
+ // ── 运行态交互(typeahead 输入 + 滚动回看 + Ctrl+C 中断)──
95
+ // 只在 await runAgent() 期间挂载;/resume /rollback /compact 等走 askLine(cooked readline)的分支不挂(避免抢 stdin)。
96
+ let runningInput = ''; // 运行中已打字缓冲(单行;agent 结束后预填下一轮 INPUT 态)
97
+ let runningPlaceholder = '';
98
+ let currentAbort = null;
99
+ let pendingPrefill = null; // /rollback 选中后预填的 user 输入(下轮 INPUT 态消费)
100
+ /** 运行态按键:滚动优先,再 Ctrl+C 中断,再 typeahead 编辑(单行,Enter=无操作)。 */
101
+ function onRunningKey(_str, key) {
102
+ if (!key)
103
+ return;
104
+ // 滚动回看键(优先;不触发回尾):PgUp/PgDn 翻页,↑/↓ 单行(含鼠标滚轮——alt 屏滚轮转发↑↓)。
105
+ // 运行态无输入光标,↑/↓ 无其他用途,直接作滚动。
106
+ if (key.name === 'pageup' ||
107
+ key.name === 'pagedown' ||
108
+ key.name === 'up' ||
109
+ key.name === 'down') {
110
+ const pageH = layout.getGeo().contentBottom;
111
+ if (key.name === 'pageup')
112
+ layout.scrollBy(pageH);
113
+ else if (key.name === 'pagedown')
114
+ layout.scrollBy(-pageH);
115
+ else if (key.name === 'up')
116
+ layout.scrollBy(1);
117
+ else
118
+ layout.scrollBy(-1);
119
+ return;
120
+ }
121
+ // 其他键:若处于滚动回看,先回尾再处理(打字即回底)
122
+ if (layout.isScrolled())
123
+ layout.resetScroll();
124
+ // Ctrl+C 中断当前 agent 轮次(不退进程;raw 模式下 Ctrl+C 是按键,不触发 SIGINT)
125
+ if (key.ctrl && key.name === 'c') {
126
+ currentAbort?.abort();
127
+ return;
128
+ }
129
+ const s = key.sequence ?? '';
130
+ if (key.name === 'backspace') {
131
+ if (runningInput.length > 0) {
132
+ runningInput = runningInput.slice(0, -1);
133
+ layout.paintRunningInputEcho(runningInput, runningPlaceholder);
134
+ }
135
+ return;
136
+ }
137
+ if (key.name === 'escape') {
138
+ runningInput = '';
139
+ layout.paintRunningInputEcho(runningInput, runningPlaceholder);
140
+ return;
141
+ }
142
+ // Enter / Ctrl+J:运行中 no-op(单行 typeahead;agent 结束后预填,用户在 INPUT 态按 Enter 提交)
143
+ if (key.name === 'return' ||
144
+ key.name === 'enter' ||
145
+ (key.ctrl && key.name === 'j')) {
146
+ return;
147
+ }
148
+ // 可打印字符(>= 空格,非 ctrl/meta)→ 追加 + dim 回显
149
+ if (s && s >= ' ' && !key.ctrl && !key.meta) {
150
+ runningInput += s;
151
+ layout.paintRunningInputEcho(runningInput, runningPlaceholder);
152
+ }
153
+ }
154
+ /** 进入运行态:挂 keypress 监听 + raw mode + 新建 abort 控制器,返回其 signal。在 await runAgent 前、enterRunningMode 后调。 */
155
+ function startRunningListener(placeholder) {
156
+ runningPlaceholder = placeholder;
157
+ runningInput = '';
158
+ emitKeypressEvents(stdin); // 幂等:首轮 prompt 已永久挂解析器,这里防御性再调
159
+ try {
160
+ stdin.setRawMode(true);
161
+ }
162
+ catch {
163
+ // 非 TTY / 不支持 raw:监听器仍挂(按键可能不来,不影响 agent)
164
+ }
165
+ stdin.resume();
166
+ emitter.on('keypress', onRunningKey);
167
+ const ac = new AbortController();
168
+ currentAbort = ac;
169
+ return ac.signal;
170
+ }
171
+ /** 退出运行态:摘监听 + 清 abort。不 pause / 不 setRawMode(false)——紧接着 promptWithSlashMenu 自己接管 raw。 */
172
+ function stopRunningListener() {
173
+ emitter.off('keypress', onRunningKey);
174
+ currentAbort = null;
175
+ }
176
+ /** 把多行提交输入回显进内容区(❯ 首行,续行按 prompt 宽度缩进)。仅 TUI 态回显(非 TTY 由 readline 自带回显)。 */
177
+ function echoInput(lines) {
178
+ if (!layout.isActive())
179
+ 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);
184
+ }
185
+ /** 把任意消息 content 拍平成字符串(OpenAI 可能 string / null / 多模态数组)。 */
186
+ function textOf(c) {
187
+ if (typeof c === 'string')
188
+ return c;
189
+ if (c == null)
190
+ return '';
191
+ if (Array.isArray(c)) {
192
+ return c
193
+ .map((p) => typeof p === 'string' ? p : p?.text ?? '')
194
+ .join('');
195
+ }
196
+ return String(c);
197
+ }
198
+ /**
199
+ * 把会话历史渲染成静态文本进内容区(回滚 / 续接 / --resume 后复显上下文,仿 Claude Code):
200
+ * user→❯ 回显、assistant→正文(+ tool_calls 作 ● 行)、tool→↳ 结果预览;system 跳过。
201
+ * 思考段不持久(history 只存正文),故无思考折叠。渲染后续写位在末尾,紧接 enterInputMode 画输入框。
202
+ * 内容长于屏时 viewport 显尾(最近轮次),PgUp 可看更早——与流式态一致。
203
+ */
204
+ export function renderHistory(history) {
205
+ const indent = ' '.repeat(displayWidth(PROMPT));
206
+ const idToName = new Map();
207
+ for (const m of history) {
208
+ if (m.role === 'system')
209
+ continue;
210
+ if (m.role === 'user') {
211
+ 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');
215
+ continue;
216
+ }
217
+ if (m.role === 'assistant') {
218
+ const text = textOf(m.content);
219
+ if (text) {
220
+ layout.contentWrite(text);
221
+ if (!text.endsWith('\n'))
222
+ layout.contentWrite('\n');
223
+ }
224
+ const tcs = m.tool_calls;
225
+ if (Array.isArray(tcs)) {
226
+ for (const tc of tcs) {
227
+ const name = tc?.function?.name ?? '';
228
+ const args = tc?.function?.arguments ?? '';
229
+ if (tc?.id && name)
230
+ idToName.set(tc.id, name);
231
+ layout.contentWrite(` ${ui.brightMagenta}●${ui.reset} ${ui.cyan}${name}${ui.reset} ${ui.dim}${summarizeToolCall(name, args)}${ui.reset}\n`);
232
+ }
233
+ }
234
+ continue;
235
+ }
236
+ if (m.role === 'tool') {
237
+ const id = m.tool_call_id ?? '';
238
+ const name = idToName.get(id) ?? '';
239
+ const preview = summarizeToolResult(name, textOf(m.content));
240
+ if (preview)
241
+ layout.contentWrite(` ${ui.gray}↳ ${preview}${ui.reset}\n`);
242
+ continue;
243
+ }
244
+ }
245
+ }
246
+ /**
247
+ * 交互式 REPL:全屏 TUI(alt screen + 固定底栏)。INPUT 态底栏=状态行+输入框(raw mode 等按键);
248
+ * 提交后 enterRunningMode(底栏改 dim 占位、光标回内容续写位),命令分发与 runAgent 的流式输出经
249
+ * contentWrite 落入内容区(滚动区域内自动滚动,底栏不动)。history 由本模块持有,在轮次间持久;
250
+ * agent 只读取并追加(+ 经 session/ 压缩)。每轮成功结束后自动落盘,退出后可用 --resume / /resume 续接。
251
+ */
252
+ export async function startRepl(initialHistory, sessionId) {
253
+ // 有预加载(--resume)则用它,并把 history[0] 刷成当前 system prompt(config 可能已变);
254
+ // 否则新会话只塞 system 提示。
255
+ const systemPrompt = effectiveSystemPrompt(config.systemPrompt);
256
+ const history = initialHistory && initialHistory.length
257
+ ? initialHistory
258
+ : [{ role: 'system', content: systemPrompt }];
259
+ if (initialHistory &&
260
+ initialHistory.length &&
261
+ history[0]?.role === 'system') {
262
+ history[0] = { role: 'system', content: systemPrompt };
263
+ }
264
+ // --resume:读回该会话的轮次/快照;无文件则从 history 重建 turns(无快照→旧轮次文件改动不可撤销)
265
+ if (sessionId && initialHistory && initialHistory.length) {
266
+ if (!loadSnapshots(sessionId))
267
+ rebuildFromHistory(history);
268
+ }
269
+ let currentSessionId = sessionId;
270
+ // 本会话累积的折叠思考段,供 /think N 重打原文。
271
+ const collapsedThinkings = [];
272
+ const toolsLine = tools.map((t) => t.name).join(' · ');
273
+ const banner = () => ({
274
+ model: config.model,
275
+ baseURL: config.baseURL,
276
+ cwd: process.cwd(),
277
+ tools: toolsLine,
278
+ });
279
+ // 开场:进 alt screen + 状态基线 + 清内容区。--resume 有历史则渲染对话(仿 Claude Code),否则横幅。
280
+ layout.enterAltScreen();
281
+ refreshStatusBase(history);
282
+ layout.clearContent();
283
+ layout.contentMode();
284
+ if (history.some((m) => m.role === 'user')) {
285
+ renderHistory(history);
286
+ }
287
+ else {
288
+ layout.contentWrite(bannerString(banner()));
289
+ }
290
+ /**
291
+ * 回滚子流程(由 /rollback 触发):菜单(↑/↓)选轮次 → 选中第 X 轮 = 删第 X 轮及之后 + 预填第 X 轮 user 输入
292
+ * (仿 Claude Code rewind,Enter 重新跑该轮);被删轮次的文件改动仍逐个「保留/撤销」询问(cooked readline)。
293
+ * 选轮菜单走 promptTurnPicker(raw mode);文件询问走 askLine(cooked)。预填经 pendingPrefill 注入下轮 INPUT。
294
+ */
295
+ const rollbackFlow = async () => {
296
+ const turnList = listTurns();
297
+ if (turnList.length < 1) {
298
+ layout.contentWrite(`${ui.dim}(没有可回滚的轮次)${ui.reset}\n`);
299
+ return;
300
+ }
301
+ // 各轮 user 全文(预填用;按 user 消息顺序与 turnList 对齐)
302
+ const userTexts = [];
303
+ for (const m of history) {
304
+ if (m.role === 'user')
305
+ userTexts.push(textOf(m.content));
306
+ }
307
+ const items = turnList.map((t) => ({ firstLine: t.firstLine }));
308
+ let picked;
309
+ try {
310
+ picked = await promptTurnPicker(items);
311
+ }
312
+ catch {
313
+ return; // Ctrl+C(SIGINT)→ 取消回滚
314
+ }
315
+ if (picked === null)
316
+ return; // Esc 取消
317
+ // picked(0-based)= 第 (picked+1) 轮:删该轮及之后(planRollback(picked) 保 1..picked),预填该轮 user 输入
318
+ const prefillText = userTexts[picked] ?? '';
319
+ const plan = planRollback(picked, history);
320
+ // 清屏(擦选轮菜单 + /rollback 回显)+ 复位 lastView(dim 空),给文件询问一个干净、resize 安全的画面
321
+ layout.clearContent();
322
+ layout.paintInput({
323
+ prompt: '❯ ',
324
+ lines: [''],
325
+ cursorLine: 0,
326
+ cursorCol: 0,
327
+ menu: null,
328
+ dim: true,
329
+ });
330
+ const revertPaths = new Set();
331
+ for (const c of plan.changes) {
332
+ layout.contentWrite(` ${ui.cyan}${c.path}${ui.reset} ${ui.dim}(${c.ops.join(', ')})${ui.reset}\n`);
333
+ let ans = '';
334
+ try {
335
+ ans = (await askLine(' 保留/撤销 [k/u](回车=保留): ')).trim();
336
+ }
337
+ catch {
338
+ continue;
339
+ }
340
+ if (ans.startsWith('u') || ans.startsWith('U')) {
341
+ if (c.snapshotAvailable) {
342
+ revertPaths.add(c.path);
343
+ }
344
+ else {
345
+ layout.contentWrite(`${ui.dim} (无快照,无法撤销——保留)${ui.reset}\n`);
346
+ }
347
+ }
348
+ }
349
+ applyRollback(plan, history, revertPaths);
350
+ if (!currentSessionId)
351
+ currentSessionId = newSessionId();
352
+ try {
353
+ saveSession(history, currentSessionId);
354
+ }
355
+ catch {
356
+ // 落盘失败不阻断
357
+ }
358
+ persistSnapshots(currentSessionId);
359
+ // 复显剩余对话(无提示行),输入框预填该轮 user 输入 → 下轮 Enter 重新跑
360
+ layout.clearContent();
361
+ renderHistory(history);
362
+ pendingPrefill = prefillText.split('\n');
363
+ };
364
+ while (true) {
365
+ // INPUT 态:画底栏输入框 + 状态行,光标入输入框
366
+ refreshStatusBase(history);
367
+ layout.enterInputMode('空闲');
368
+ let input = null;
369
+ try {
370
+ input = await promptWithSlashMenu({
371
+ prompt: PROMPT,
372
+ commands: SLASH_COMMANDS,
373
+ // /rollback 预填优先;否则上一轮运行中 typeahead 打的字 → 预填进输入框,用户可改可发
374
+ ...(pendingPrefill
375
+ ? { initialLines: pendingPrefill }
376
+ : runningInput
377
+ ? { initialLines: [runningInput] }
378
+ : {}),
379
+ });
380
+ }
381
+ catch {
382
+ break; // Ctrl+C(SIGINT)/ 异常 → 退出
383
+ }
384
+ pendingPrefill = null; // 预填已消费,清空
385
+ runningInput = ''; // 预填已消费,清空(下轮运行态从空开始)
386
+ if (input === null)
387
+ break; // 空 prompt Ctrl+D
388
+ const joined = input.join('\n');
389
+ const line = joined.trim();
390
+ if (!line)
391
+ continue;
392
+ if (line === '/exit' || line === '/quit')
393
+ break;
394
+ // RUNNING 态:回显输入 → 底栏改 dim 占位、光标回内容续写位
395
+ echoInput(input);
396
+ const cmd = line.split(/\s+/)[0];
397
+ const { status, placeholder } = runningStateFor(cmd);
398
+ refreshStatusBase(history);
399
+ layout.enterRunningMode(status, placeholder);
400
+ if (line === '/clear') {
401
+ history.length = 1; // 保留 system 提示
402
+ collapsedThinkings.length = 0; // 同步清空折叠的思考段
403
+ resetState(); // 同步清空回滚轮次/快照
404
+ currentSessionId = undefined; // 下轮起新会话文件
405
+ contextState.lastUsage = undefined;
406
+ layout.clearContent();
407
+ layout.contentWrite(bannerString(banner()));
408
+ layout.contentWrite(`${ui.dim}(历史已清空,保留系统提示)${ui.reset}\n`);
409
+ continue;
410
+ }
411
+ if (line === '/context') {
412
+ layout.contentWrite(` ${renderContextBar(history)}\n`);
413
+ continue;
414
+ }
415
+ if (line === '/skills') {
416
+ const skills = listSkills();
417
+ if (skills.length === 0) {
418
+ layout.contentWrite(`${ui.dim}(没有已发现的 skill)${ui.reset}\n`);
419
+ }
420
+ else {
421
+ layout.contentWrite(`${ui.dim}已发现 ${skills.length} 个 skill:${ui.reset}\n`);
422
+ for (const s of skills) {
423
+ layout.contentWrite(` ${ui.cyan}${s.name}${ui.reset} ${ui.dim}${s.description}${ui.reset}\n`);
424
+ }
425
+ layout.contentWrite(`${ui.dim}(用 use_skill 工具加载某 skill 的完整指令)${ui.reset}\n`);
426
+ }
427
+ continue;
428
+ }
429
+ if (line === '/compact' || line.startsWith('/compact ')) {
430
+ const focus = line.startsWith('/compact ')
431
+ ? line.slice('/compact '.length).trim()
432
+ : undefined;
433
+ const r = await compactHistory(history, {
434
+ window: config.contextWindowTokens,
435
+ threshold: config.compactThreshold,
436
+ focus,
437
+ });
438
+ if (r.reason === 'noop') {
439
+ layout.contentWrite(`${ui.dim}(无需压缩:没有可压缩的旧消息)${ui.reset}\n`);
440
+ }
441
+ continue;
442
+ }
443
+ if (line === '/resume') {
444
+ const sessions = listSessions();
445
+ if (sessions.length === 0) {
446
+ layout.contentWrite(`${ui.dim}(没有已保存的会话)${ui.reset}\n`);
447
+ continue;
448
+ }
449
+ sessions.forEach((s, i) => {
450
+ layout.contentWrite(` ${ui.dim}${i + 1}${ui.reset} ${s.id} ${ui.cyan}${s.firstUser || '(无)'}${ui.reset} ${ui.dim}${s.model}${ui.reset}\n`);
451
+ });
452
+ let pick = '';
453
+ try {
454
+ pick = (await askLine('序号(回车取消): ')).trim();
455
+ }
456
+ catch {
457
+ continue;
458
+ }
459
+ const idx = Number(pick);
460
+ if (!pick || !Number.isInteger(idx) || idx < 1 || idx > sessions.length)
461
+ continue;
462
+ const loaded = loadSession(sessions[idx - 1].id);
463
+ if (!loaded || !loaded.history.length) {
464
+ layout.contentWrite(`${ui.yellow}(加载失败)${ui.reset}\n`);
465
+ continue;
466
+ }
467
+ if (loaded.history[0]?.role === 'system') {
468
+ loaded.history[0] = { role: 'system', content: systemPrompt };
469
+ }
470
+ history.length = 0;
471
+ history.push(...loaded.history);
472
+ currentSessionId = loaded.id;
473
+ // 读回该会话的轮次/快照;无文件则从 history 重建 turns(无快照→旧轮次文件改动不可撤销)
474
+ if (!loadSnapshots(loaded.id))
475
+ rebuildFromHistory(history);
476
+ contextState.lastUsage = undefined;
477
+ collapsedThinkings.length = 0;
478
+ layout.clearContent();
479
+ renderHistory(history);
480
+ layout.contentWrite(`${ui.dim}(已续接会话 ${loaded.id})${ui.reset}\n`);
481
+ continue;
482
+ }
483
+ if (line === '/think' || line.startsWith('/think ')) {
484
+ const arg = line.split(/\s+/)[1];
485
+ if (!arg) {
486
+ layout.contentWrite(`${ui.dim}折叠思考段: ${collapsedThinkings.length} 段 · 用法: /think N (展开第 N 段)${ui.reset}\n`);
487
+ continue;
488
+ }
489
+ const idx = Number(arg);
490
+ if (!Number.isInteger(idx) || idx < 1 || idx > collapsedThinkings.length) {
491
+ layout.contentWrite(`${ui.yellow}无第 ${arg} 段(共 ${collapsedThinkings.length})${ui.reset}\n`);
492
+ continue;
493
+ }
494
+ const content = collapsedThinkings[idx - 1];
495
+ layout.contentWrite(`${ui.dim}▎ 思考 ▾ (第 ${idx} 段)${ui.reset}\n`);
496
+ layout.contentWrite(`${ui.dim}${content}${ui.reset}\n`);
497
+ if (!content.endsWith('\n'))
498
+ layout.contentWrite('\n');
499
+ continue;
500
+ }
501
+ if (line === '/rollback' || line.startsWith('/rollback ')) {
502
+ // /rollback:打开轮次菜单(↑/↓ 选,Enter 回滚到该轮并预填其输入,再 Enter 重新跑)。
503
+ // 忽略任何数字参数(原「输数字选回滚」已删,统一走菜单)。无快照的旧轮次(/resume 重建)文件改动不可撤销。
504
+ await rollbackFlow();
505
+ continue;
506
+ }
507
+ try {
508
+ const signal = startRunningListener(placeholder);
509
+ await runAgent(history, joined, collapsedThinkings, signal);
510
+ // 成功轮次自动落盘(崩溃也保住上一轮);新会话首轮分配 id
511
+ if (!currentSessionId)
512
+ currentSessionId = newSessionId();
513
+ try {
514
+ saveSession(history, currentSessionId);
515
+ }
516
+ catch {
517
+ // 落盘失败不阻断 REPL
518
+ }
519
+ persistSnapshots(currentSessionId); // 随会话落盘回滚快照(/resume 后仍可撤销)
520
+ }
521
+ catch (e) {
522
+ layout.contentWrite(`${ui.red}[错误]${ui.reset} ${e instanceof Error ? e.message : String(e)}\n`);
523
+ }
524
+ finally {
525
+ stopRunningListener();
526
+ }
527
+ layout.contentWrite('\n'); // 轮次之间空行
528
+ }
529
+ layout.exitAltScreen();
530
+ }