mocode-ai 0.1.2 → 0.1.4
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.
- package/README.md +34 -7
- package/dist/agent/index.js +134 -60
- package/dist/config/index.js +11 -0
- package/dist/memory/discover.js +49 -0
- package/dist/memory/index.js +45 -0
- package/dist/memory/reflect.js +265 -0
- package/dist/memory/store.js +339 -0
- package/dist/repl/index.js +133 -24
- package/dist/session/compact.js +50 -2
- package/dist/tools/builtins/ask-human.js +50 -0
- package/dist/tools/builtins/index.js +12 -0
- package/dist/tools/builtins/memory-forget.js +32 -0
- package/dist/tools/builtins/memory-list.js +34 -0
- package/dist/tools/builtins/memory-save.js +51 -0
- package/dist/tools/builtins/memory-search.js +42 -0
- package/dist/tools/builtins/memory-update.js +41 -0
- package/dist/tools/builtins/use-skill.js +1 -1
- package/dist/tools/builtins/web-fetch.js +1 -1
- package/dist/tools/builtins/web-search.js +1 -1
- package/dist/tools/constants.js +13 -0
- package/dist/ui/intervention.js +297 -0
- package/dist/ui/layout.js +115 -30
- package/dist/ui/prompt.js +335 -18
- package/dist/ui/render.js +18 -0
- package/dist/ui/spinner.js +15 -0
- package/dist/ui/theme.js +3 -0
- package/package.json +1 -1
|
@@ -4,7 +4,7 @@ import { getSkillBody } from '../../skills/index.js';
|
|
|
4
4
|
// 系统提示里已列出可用 skill 的 name + description(何时用),模型据此决定调用。
|
|
5
5
|
export const useSkillTool = {
|
|
6
6
|
name: 'use_skill',
|
|
7
|
-
description: '加载并返回某个 skill 的完整 SKILL.md
|
|
7
|
+
description: '加载并返回某个 skill 的完整 SKILL.md 指令(传 name)。何时用见系统提示的 skill 列表。',
|
|
8
8
|
parameters: {
|
|
9
9
|
type: 'object',
|
|
10
10
|
properties: {
|
|
@@ -4,7 +4,7 @@ const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML,
|
|
|
4
4
|
// ---------- web_fetch ----------
|
|
5
5
|
export const webFetchTool = {
|
|
6
6
|
name: 'web_fetch',
|
|
7
|
-
description: '抓取指定 URL 的网页内容并清洗成纯文本(去 HTML 标签/脚本/样式,保留正文)。用于读取搜索结果里的某个链接、或用户给出的具体 URL
|
|
7
|
+
description: '抓取指定 URL 的网页内容并清洗成纯文本(去 HTML 标签/脚本/样式,保留正文)。用于读取搜索结果里的某个链接、或用户给出的具体 URL。',
|
|
8
8
|
parameters: {
|
|
9
9
|
type: 'object',
|
|
10
10
|
properties: {
|
|
@@ -6,7 +6,7 @@ const MAX_CONTENT_CHARS = 800;
|
|
|
6
6
|
// ---------- web_search ----------
|
|
7
7
|
export const webSearchTool = {
|
|
8
8
|
name: 'web_search',
|
|
9
|
-
description: '联网搜索(AnySearch)。返回每条结果的标题/URL
|
|
9
|
+
description: '联网搜索(AnySearch)。返回每条结果的标题/URL/摘要/正文。可选 tag 切换子域(见 tag 参数)。',
|
|
10
10
|
parameters: {
|
|
11
11
|
type: 'object',
|
|
12
12
|
properties: {
|
package/dist/tools/constants.js
CHANGED
|
@@ -8,4 +8,17 @@ export const MAX_HISTORY_RESULT = 8000;
|
|
|
8
8
|
export const MAX_SKILL_RESULT = 64000;
|
|
9
9
|
/** 微压缩时旧工具结果截到的存根长度(字符)。 */
|
|
10
10
|
export const MAX_OLD_TOOL_STUB = 600;
|
|
11
|
+
// ── 记忆(Tier-2 JSONL 工具库)──────────────────────────────────────────────
|
|
12
|
+
/** 单条记忆 body 上限(字符)。进 history 前 memory_search 结果另有 MAX_MEMORY_RESULT 兜底。 */
|
|
13
|
+
export const MAX_MEMORY_ENTRY = 4000;
|
|
14
|
+
/** 启动注入 systemPrompt 的索引条目上限(遗忘封顶后 active 条目 ≤ MAX_ACTIVE,索引再封一层)。 */
|
|
15
|
+
export const MAX_INDEX_ENTRIES = 50;
|
|
16
|
+
/** active 记忆封顶:超则按 recallCount 低 × lastRecalledAt 老 淘汰到 archived。 */
|
|
17
|
+
export const MAX_ACTIVE = 100;
|
|
18
|
+
/** 衰减:active + !pinned + (lastRecalledAt|createdAt) 早于 DECAY_DAYS → archived。 */
|
|
19
|
+
export const DECAY_DAYS = 30;
|
|
20
|
+
/** GC:archived 超 GC_DAYS → 硬删。 */
|
|
21
|
+
export const GC_DAYS = 90;
|
|
22
|
+
/** memory_search 结果(召回的记忆正文)的放宽上限:指令性内容,中截破坏语义,对齐 use_skill。 */
|
|
23
|
+
export const MAX_MEMORY_RESULT = 64000;
|
|
11
24
|
export const IGNORE = ['**/node_modules/**', '**/.git/**'];
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import readline from 'node:readline';
|
|
2
|
+
import { stdin, stderr } from 'node:process';
|
|
3
|
+
import { ui } from './theme.js';
|
|
4
|
+
import { displayWidth, truncateDisplay } from './render.js';
|
|
5
|
+
import * as layout from './layout.js';
|
|
6
|
+
import { Spinner } from './spinner.js';
|
|
7
|
+
const emitter = stdin;
|
|
8
|
+
/** choice 末尾的"自定义输入"项标签(纯 ASCII,宽度安全)。选中后切到 input 子态。 */
|
|
9
|
+
const CUSTOM_LABEL = '其他(自定义输入)';
|
|
10
|
+
/** 弹出介入面板,阻塞直到用户完成选择。 */
|
|
11
|
+
export async function promptIntervention(req) {
|
|
12
|
+
// 非 TTY 降级:不阻塞,自动选默认。日志走 stderr(不污染 stdout 内容流)。
|
|
13
|
+
if (!layout.isActive()) {
|
|
14
|
+
const kind = req.type === 'choice' ? '自动选默认项' : '自动返回空输入';
|
|
15
|
+
stderr.write(`[介入] ${req.title}(非交互环境,${kind})\n`);
|
|
16
|
+
if (req.type === 'choice') {
|
|
17
|
+
return { action: 'selected', value: req.options?.[0] ?? '' };
|
|
18
|
+
}
|
|
19
|
+
return { action: 'submitted', value: req.seed ?? '' };
|
|
20
|
+
}
|
|
21
|
+
const options = req.type === 'choice' && Array.isArray(req.options)
|
|
22
|
+
? req.options.map((o) => String(o)).filter((s) => s.length > 0)
|
|
23
|
+
: [];
|
|
24
|
+
// choice 但选项被滤空 → 降级 input(对齐设计文档 §8:ask_human 选项为空数组→input)。
|
|
25
|
+
const startMode = req.type === 'choice' && options.length > 0 ? 'choice' : 'input';
|
|
26
|
+
let mode = startMode;
|
|
27
|
+
// choice:选中下标(0..options.length-1 为各选项,options.length 为"自定义"项)。
|
|
28
|
+
let selected = 0;
|
|
29
|
+
// input:可编辑文本 + 光标(UTF-16 码元索引;显示位置由 displayWidth 算,见 paintInput)。
|
|
30
|
+
let text = req.seed ?? '';
|
|
31
|
+
let cursor = text.length;
|
|
32
|
+
// input 是否由 choice 的"自定义"项切入(决定 Esc 是返回 choice 还是取消)。
|
|
33
|
+
let cameFromChoice = false;
|
|
34
|
+
let resolved = false;
|
|
35
|
+
let resolve;
|
|
36
|
+
// 挂自己监听前快照的现有 keypress 监听(运行态即 onRunningKey),退出时按原序恢复。
|
|
37
|
+
let savedListeners = [];
|
|
38
|
+
/** choice 菜单行:标题(bold)+ detail(dim,行数上限保选项可见)+ 空行 + 选项(▸ 选中/dim)。 */
|
|
39
|
+
function menuLinesChoice() {
|
|
40
|
+
const g = layout.getGeo();
|
|
41
|
+
const cols = g.cols;
|
|
42
|
+
const items = [...options, CUSTOM_LABEL];
|
|
43
|
+
const optionCount = items.length;
|
|
44
|
+
// detail 上限:title(1)+detail(D)+空行(1)+options(O) ≤ contentBottom → D ≤ contentBottom-2-O
|
|
45
|
+
const detailCap = Math.max(0, g.contentBottom - 2 - optionCount);
|
|
46
|
+
const lines = [];
|
|
47
|
+
lines.push(`${ui.bold}${truncateDisplay(req.title, cols)}${ui.reset}`);
|
|
48
|
+
if (req.detail) {
|
|
49
|
+
const dl = req.detail.split('\n');
|
|
50
|
+
const shown = dl.slice(0, detailCap);
|
|
51
|
+
for (const d of shown) {
|
|
52
|
+
lines.push(`${ui.dim}${truncateDisplay(d, cols)}${ui.reset}`);
|
|
53
|
+
}
|
|
54
|
+
if (dl.length > detailCap)
|
|
55
|
+
lines.push(`${ui.dim}…${ui.reset}`);
|
|
56
|
+
}
|
|
57
|
+
lines.push(''); // 分隔空行
|
|
58
|
+
// 选项开窗:超屏高时以 selected 为中心取窗,保选中项可见。
|
|
59
|
+
const maxOptRows = Math.max(1, g.contentBottom - lines.length);
|
|
60
|
+
let start = 0;
|
|
61
|
+
if (optionCount > maxOptRows) {
|
|
62
|
+
start = Math.max(0, Math.min(selected - Math.floor(maxOptRows / 2), optionCount - maxOptRows));
|
|
63
|
+
}
|
|
64
|
+
const count = Math.min(maxOptRows, optionCount);
|
|
65
|
+
for (let i = 0; i < count; i++) {
|
|
66
|
+
const idx = start + i;
|
|
67
|
+
// 选中项:▸ 与正文均 cyan+bold(去 dim),未选中项保持 dim——选中行整体高亮。
|
|
68
|
+
const isSel = idx === selected;
|
|
69
|
+
const color = isSel ? `${ui.cyan}${ui.bold}` : ui.dim;
|
|
70
|
+
const marker = isSel ? `${ui.cyan}${ui.bold}▸${ui.reset}` : ' ';
|
|
71
|
+
const body = truncateDisplay(items[idx], cols - 2);
|
|
72
|
+
lines.push(`${marker} ${color}${body}${ui.reset}`);
|
|
73
|
+
}
|
|
74
|
+
return lines;
|
|
75
|
+
}
|
|
76
|
+
/** input 菜单行:标题(bold)+ detail(dim)+ 提示行(dim)。可编辑文本在底栏输入框。 */
|
|
77
|
+
function menuLinesInput() {
|
|
78
|
+
const g = layout.getGeo();
|
|
79
|
+
const cols = g.cols;
|
|
80
|
+
const detailCap = Math.max(0, g.contentBottom - 2); // title(1)+提示(1)
|
|
81
|
+
const lines = [];
|
|
82
|
+
lines.push(`${ui.bold}${truncateDisplay(req.title, cols)}${ui.reset}`);
|
|
83
|
+
if (req.detail) {
|
|
84
|
+
const dl = req.detail.split('\n');
|
|
85
|
+
const shown = dl.slice(0, detailCap);
|
|
86
|
+
for (const d of shown) {
|
|
87
|
+
lines.push(`${ui.dim}${truncateDisplay(d, cols)}${ui.reset}`);
|
|
88
|
+
}
|
|
89
|
+
if (dl.length > detailCap)
|
|
90
|
+
lines.push(`${ui.dim}…${ui.reset}`);
|
|
91
|
+
}
|
|
92
|
+
lines.push(`${ui.dim}Enter 提交 · Esc ${cameFromChoice ? '返回选项' : '取消'} · Ctrl+C 取消${ui.reset}`);
|
|
93
|
+
return lines;
|
|
94
|
+
}
|
|
95
|
+
function redraw() {
|
|
96
|
+
if (mode === 'choice') {
|
|
97
|
+
const hint = '↑↓ 选择 · Enter 确认 · 数字键直选 · Esc 取消';
|
|
98
|
+
layout.paintInput({
|
|
99
|
+
prompt: '❯ ',
|
|
100
|
+
lines: [hint],
|
|
101
|
+
cursorLine: 0,
|
|
102
|
+
cursorCol: displayWidth(hint),
|
|
103
|
+
menu: { lines: menuLinesChoice() },
|
|
104
|
+
caret: false, // 纯导航(非文本输入):不画输入框块状光标,聚焦由选项 ▸ 标记
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
layout.paintInput({
|
|
109
|
+
prompt: '❯ ',
|
|
110
|
+
lines: [text],
|
|
111
|
+
cursorLine: 0,
|
|
112
|
+
cursorCol: displayWidth(text.slice(0, cursor)),
|
|
113
|
+
menu: { lines: menuLinesInput() },
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
function finish(result) {
|
|
118
|
+
if (resolved)
|
|
119
|
+
return;
|
|
120
|
+
resolved = true;
|
|
121
|
+
cleanup();
|
|
122
|
+
resolve(result);
|
|
123
|
+
}
|
|
124
|
+
/** 退出:摘自己的监听 + 恢复快照监听 + 擦菜单恢复内容区。不 setRawMode(false)/pause stdin(运行态由 repl 接管)。 */
|
|
125
|
+
function cleanup() {
|
|
126
|
+
emitter.removeListener('keypress', onKey);
|
|
127
|
+
for (const l of savedListeners)
|
|
128
|
+
emitter.on('keypress', l);
|
|
129
|
+
savedListeners = [];
|
|
130
|
+
// 先 paintInput(dim,menu:null):擦菜单(用 lastMenuRows)+ 画 dim 占位底栏 + 复位 lastMenuRows=0;
|
|
131
|
+
// 再 repaintViewport:从 content 缓冲重画内容区(恢复被菜单覆盖的 ● ask_human 行)。
|
|
132
|
+
// 顺序不可反——repaintViewport 后再 paintInput(menu:null) 会用 stale lastMenuRows 擦掉已恢复的内容。
|
|
133
|
+
layout.paintInput({
|
|
134
|
+
prompt: '❯ ',
|
|
135
|
+
lines: [''],
|
|
136
|
+
cursorLine: 0,
|
|
137
|
+
cursorCol: 0,
|
|
138
|
+
menu: null,
|
|
139
|
+
dim: true,
|
|
140
|
+
});
|
|
141
|
+
layout.repaintViewport();
|
|
142
|
+
}
|
|
143
|
+
function onKey(_str, key) {
|
|
144
|
+
if (resolved || !key)
|
|
145
|
+
return;
|
|
146
|
+
// Ctrl+C → 取消(不 reject SIGINT——否则经 executeTool 的 try/catch 变成 tool 错误串)
|
|
147
|
+
if (key.ctrl && key.name === 'c') {
|
|
148
|
+
finish({ action: 'cancelled' });
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
if (key.name === 'escape') {
|
|
152
|
+
if (mode === 'input' && cameFromChoice) {
|
|
153
|
+
// 由 choice"自定义"项切入的 input:Esc 返回选项(不取消整次提问)
|
|
154
|
+
mode = 'choice';
|
|
155
|
+
redraw();
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
finish({ action: 'cancelled' });
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
if (mode === 'choice') {
|
|
162
|
+
onKeyChoice(key);
|
|
163
|
+
}
|
|
164
|
+
else {
|
|
165
|
+
onKeyInput(key);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
function onKeyChoice(key) {
|
|
169
|
+
const itemCount = options.length + 1; // +自定义项
|
|
170
|
+
switch (key.name) {
|
|
171
|
+
case 'up':
|
|
172
|
+
selected = (selected - 1 + itemCount) % itemCount;
|
|
173
|
+
redraw();
|
|
174
|
+
return;
|
|
175
|
+
case 'down':
|
|
176
|
+
selected = (selected + 1) % itemCount;
|
|
177
|
+
redraw();
|
|
178
|
+
return;
|
|
179
|
+
case 'return':
|
|
180
|
+
case 'enter':
|
|
181
|
+
if (selected === options.length) {
|
|
182
|
+
// 自定义项 → 切 input 子态(空文本起)
|
|
183
|
+
mode = 'input';
|
|
184
|
+
cameFromChoice = true;
|
|
185
|
+
text = '';
|
|
186
|
+
cursor = 0;
|
|
187
|
+
redraw();
|
|
188
|
+
}
|
|
189
|
+
else {
|
|
190
|
+
finish({ action: 'selected', value: options[selected] });
|
|
191
|
+
}
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
// 数字键 1-9 直选对应选项(自定义项不绑定数字)
|
|
195
|
+
const s = key.sequence ?? '';
|
|
196
|
+
if (s >= '1' && s <= '9') {
|
|
197
|
+
const n = Number(s) - 1;
|
|
198
|
+
if (n < options.length) {
|
|
199
|
+
finish({ action: 'selected', value: options[n] });
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
function onKeyInput(key) {
|
|
204
|
+
if (key.name === 'return' || key.name === 'enter') {
|
|
205
|
+
finish({ action: 'submitted', value: text });
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (key.ctrl && key.name === 'a') {
|
|
209
|
+
cursor = 0;
|
|
210
|
+
redraw();
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
if (key.ctrl && key.name === 'e') {
|
|
214
|
+
cursor = text.length;
|
|
215
|
+
redraw();
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
switch (key.name) {
|
|
219
|
+
case 'backspace':
|
|
220
|
+
if (cursor > 0) {
|
|
221
|
+
text = text.slice(0, cursor - 1) + text.slice(cursor);
|
|
222
|
+
cursor--;
|
|
223
|
+
redraw();
|
|
224
|
+
}
|
|
225
|
+
return;
|
|
226
|
+
case 'left':
|
|
227
|
+
if (cursor > 0) {
|
|
228
|
+
cursor--;
|
|
229
|
+
redraw();
|
|
230
|
+
}
|
|
231
|
+
return;
|
|
232
|
+
case 'right':
|
|
233
|
+
if (cursor < text.length) {
|
|
234
|
+
cursor++;
|
|
235
|
+
redraw();
|
|
236
|
+
}
|
|
237
|
+
return;
|
|
238
|
+
case 'home':
|
|
239
|
+
cursor = 0;
|
|
240
|
+
redraw();
|
|
241
|
+
return;
|
|
242
|
+
case 'end':
|
|
243
|
+
cursor = text.length;
|
|
244
|
+
redraw();
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
// 可打印字符(>= 空格,非 ctrl/meta)→ 插入光标处(\n < ' ' 自动排除,保单行)
|
|
248
|
+
const s = key.sequence ?? '';
|
|
249
|
+
if (s && s >= ' ' && !key.ctrl && !key.meta) {
|
|
250
|
+
text = text.slice(0, cursor) + s + text.slice(cursor);
|
|
251
|
+
cursor += s.length;
|
|
252
|
+
redraw();
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return new Promise((res, rej) => {
|
|
256
|
+
resolve = res;
|
|
257
|
+
try {
|
|
258
|
+
// 进入面板:停 spinner(避免 onFrame 覆盖)+ 回尾(若用户正滚动回看)
|
|
259
|
+
Spinner.pauseCurrent();
|
|
260
|
+
layout.resetScroll();
|
|
261
|
+
// 快照现有 keypress 监听(运行态的 onRunningKey)并摘掉,挂自己的 onKey
|
|
262
|
+
savedListeners = emitter.listeners('keypress').slice();
|
|
263
|
+
for (const l of savedListeners)
|
|
264
|
+
emitter.removeListener('keypress', l);
|
|
265
|
+
readline.emitKeypressEvents(stdin); // 幂等(运行态已挂解析器,防御性再调)
|
|
266
|
+
// raw 模式:runAgent 期间 repl 已设 true;防御性确保(失败则按键不来,但不崩)
|
|
267
|
+
try {
|
|
268
|
+
stdin.setRawMode(true);
|
|
269
|
+
}
|
|
270
|
+
catch {
|
|
271
|
+
// 非 TTY / 不支持:忽略(实际非 TTY 已在上方 isActive 守卫返回)
|
|
272
|
+
}
|
|
273
|
+
stdin.resume();
|
|
274
|
+
emitter.on('keypress', onKey);
|
|
275
|
+
redraw();
|
|
276
|
+
}
|
|
277
|
+
catch (e) {
|
|
278
|
+
// 进入失败:必须恢复运行态监听,否则 onRunningKey 残留摘除 → 本 turns 的 Ctrl+C/滚动/typeahead 全废
|
|
279
|
+
try {
|
|
280
|
+
emitter.removeListener('keypress', onKey);
|
|
281
|
+
}
|
|
282
|
+
catch {
|
|
283
|
+
// 忽略
|
|
284
|
+
}
|
|
285
|
+
for (const l of savedListeners) {
|
|
286
|
+
try {
|
|
287
|
+
emitter.on('keypress', l);
|
|
288
|
+
}
|
|
289
|
+
catch {
|
|
290
|
+
// 忽略
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
savedListeners = [];
|
|
294
|
+
rej(e instanceof Error ? e : new Error(String(e)));
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
}
|
package/dist/ui/layout.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { stdin, stdout } from 'node:process';
|
|
2
|
-
import { charWidth, displayWidth, truncateDisplay, ansiDisplayWidth, wrapByDisplayWidth, } from './render.js';
|
|
2
|
+
import { charWidth, displayWidth, truncateDisplay, ansiDisplayWidth, wrapByDisplayWidth, fmtElapsed, } from './render.js';
|
|
3
3
|
import { ui } from './theme.js';
|
|
4
4
|
import * as content from './content.js';
|
|
5
5
|
// ── 内部状态 ──
|
|
6
6
|
let active = false;
|
|
7
7
|
let mode = 'input';
|
|
8
|
-
let footerH =
|
|
8
|
+
let footerH = 4; // 1 状态行 + 1 上线 + 输入行数 + 1 下线(上下线框住输入区)
|
|
9
9
|
let contentRow = 1; // 续写位行(1-based,屏坐标,[1,contentBottom])
|
|
10
10
|
let contentCol = 1; // 续写位列(1-based)
|
|
11
11
|
let segmentStartRow = 1; // 当前思考段起始屏行(供 eraseSegmentBack 定位擦除起点;段内行数由 content 段标记跟踪)
|
|
@@ -13,6 +13,8 @@ let scrollOffset = 0; // 滚动回看距尾行数(0=尾,跟随新内容);>0 时
|
|
|
13
13
|
let base = null;
|
|
14
14
|
let statusText = '';
|
|
15
15
|
let spinnerFrame;
|
|
16
|
+
let turnStart = null; // RUNNING 态起点(Date.now());INPUT 态为 null。composeStatus 据此拼走时。
|
|
17
|
+
let turnTimer = null; // 走时刷新计时器(独立于 spinner):流式期间 spinner 停转,由它续刷状态行。
|
|
16
18
|
let lastView = null;
|
|
17
19
|
let lastMenuStartRow = 0; // 上次菜单起始屏行(供擦除)
|
|
18
20
|
let lastMenuRows = 0;
|
|
@@ -282,9 +284,17 @@ function composeStatus(status, cols) {
|
|
|
282
284
|
const ctxW = ansiDisplayWidth(ctx);
|
|
283
285
|
// 滚动回看时状态段改显历史指示(无 spinner——滚动只在 INPUT 态)
|
|
284
286
|
const scrolled = scrollOffset > 0;
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
287
|
+
let st;
|
|
288
|
+
if (scrolled) {
|
|
289
|
+
st = `历史 ↑${scrollOffset} (PgDn 回底)`; // 滚动回看显历史指示,不显走时
|
|
290
|
+
}
|
|
291
|
+
else {
|
|
292
|
+
st = (status.spinnerFrame ? status.spinnerFrame + ' ' : '') + status.status;
|
|
293
|
+
// RUNNING 态追加走时:整轮从 enterRunningMode 起计时,200ms 计时器续刷使其连续递增。
|
|
294
|
+
if (mode === 'running' && turnStart != null) {
|
|
295
|
+
st += ` · ${fmtElapsed(Date.now() - turnStart)}`;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
288
298
|
const stW = displayWidth(st);
|
|
289
299
|
const fixed = displayWidth(lead) + displayWidth(model) + sepW * 3 + ctxW + stW;
|
|
290
300
|
const cwdBudget = cols - fixed - 1;
|
|
@@ -316,6 +326,26 @@ export function setStatus(status, frame) {
|
|
|
316
326
|
spinnerFrame = frame;
|
|
317
327
|
drawStatusBar();
|
|
318
328
|
}
|
|
329
|
+
/**
|
|
330
|
+
* 启走时刷新计时器:RUNNING 态每 200ms 重画状态行,使 composeStatus 重算 elapsed。
|
|
331
|
+
* 必要性:spinner 在首 token 到达即 stop,思考/正文流式期间状态行不再经 spinner 刷新;
|
|
332
|
+
* 若走时只挂 spinner onFrame,流式那几十秒会冻住。此计时器独立续刷,与 spinner 80ms 重叠幂等无妨。
|
|
333
|
+
* 非 TTY 不启(active=false 时 drawStatusBar 为 no-op)。
|
|
334
|
+
*/
|
|
335
|
+
function startTurnTimer() {
|
|
336
|
+
if (!active)
|
|
337
|
+
return;
|
|
338
|
+
stopTurnTimer();
|
|
339
|
+
turnTimer = setInterval(() => drawStatusBar(), 200);
|
|
340
|
+
turnTimer.unref();
|
|
341
|
+
}
|
|
342
|
+
/** 停走时计时器。enterInputMode / exitAltScreen 调。 */
|
|
343
|
+
function stopTurnTimer() {
|
|
344
|
+
if (turnTimer) {
|
|
345
|
+
clearInterval(turnTimer);
|
|
346
|
+
turnTimer = null;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
319
349
|
/**
|
|
320
350
|
* 在续写位画一行瞬时活动文本(spinner 帧):不进缓冲、不推进续写位,逐行 clearLine 重画。
|
|
321
351
|
* 仅 TTY + offset=0(实时尾)时物理写屏;滚动态跳过(由状态行 spinner 兜底,且避免覆盖 viewport 历史行)。
|
|
@@ -389,6 +419,31 @@ function windowInputVis(lines, cursorLine, cursorCol, cols, promptW, rows) {
|
|
|
389
419
|
startVis,
|
|
390
420
|
};
|
|
391
421
|
}
|
|
422
|
+
/**
|
|
423
|
+
* 把一行纯可见文本(无 ANSI / 零宽)在光标显示列处切成 before/cur/after:
|
|
424
|
+
* cur = 光标右侧那个字符(块状光标"压"在它上面);光标在行末(列 == 行宽)则 cur=''。
|
|
425
|
+
* 供 paintInput 画块状光标——反白 cur(行末反白一个空格),让用户看清"现在在哪输入"。
|
|
426
|
+
*
|
|
427
|
+
* 光标列恒落在字符边界:cursorCol = 显示宽度(slice 整字累加),不进字符内部,故按 acc===col 取字符即可。
|
|
428
|
+
* 宽字符(CJK=2)在行尾放不下时整字折下行,光标列仍在边界,acc 逐字累加 displayWidth 与终端光标一致。
|
|
429
|
+
*/
|
|
430
|
+
function splitAtVisCol(line, col) {
|
|
431
|
+
let acc = 0;
|
|
432
|
+
let i = 0;
|
|
433
|
+
for (const ch of line) {
|
|
434
|
+
const cw = charWidth(ch.codePointAt(0) ?? 0);
|
|
435
|
+
if (cw <= 0) {
|
|
436
|
+
i += ch.length;
|
|
437
|
+
continue; // 零宽(组合符等):不计列,跳过(输入文本一般无,稳妥)
|
|
438
|
+
}
|
|
439
|
+
if (acc === col) {
|
|
440
|
+
return { before: line.slice(0, i), cur: ch, after: line.slice(i + ch.length) };
|
|
441
|
+
}
|
|
442
|
+
acc += cw;
|
|
443
|
+
i += ch.length;
|
|
444
|
+
}
|
|
445
|
+
return { before: line, cur: '', after: '' }; // 光标在行末:无字符可反白
|
|
446
|
+
}
|
|
392
447
|
/**
|
|
393
448
|
* 画输入区:擦旧菜单 → (必要时)setRegion → 画状态行 + 输入行 + 向上菜单,光标留输入框(dim 时回续写位)。
|
|
394
449
|
* prompt.ts 每次按键调;enterInputMode / enterRunningMode 也调(空 / dim)。
|
|
@@ -398,13 +453,14 @@ export function paintInput(view) {
|
|
|
398
453
|
return;
|
|
399
454
|
lastView = view;
|
|
400
455
|
const preGeo = getGeo();
|
|
456
|
+
// 累积本帧所有 cup/clearLine/文本,末尾一次写出——避免「擦旧菜单」与「重画」分多次 write 时,
|
|
457
|
+
// 终端把中间空白渲染出来 → 菜单/面板切换时闪烁(↑↓ 导航每次 redraw 都全帧擦后重画)。
|
|
458
|
+
let buf = '';
|
|
401
459
|
// 1. 擦旧菜单(用上次记录的起始行 + 行数,与本次几何无关——setRegion 不动屏幕内容)
|
|
402
460
|
if (lastMenuRows > 0) {
|
|
403
|
-
let p = '';
|
|
404
461
|
for (let i = 0; i < lastMenuRows; i++) {
|
|
405
|
-
|
|
462
|
+
buf += cup(lastMenuStartRow + i, 1) + esc.clearLine;
|
|
406
463
|
}
|
|
407
|
-
stdout.write(p);
|
|
408
464
|
lastMenuRows = 0;
|
|
409
465
|
}
|
|
410
466
|
// 2. 算输入可视行(软折行)+ 必要时 setRegion。dim=运行态占位:单行不折行。
|
|
@@ -418,55 +474,77 @@ export function paintInput(view) {
|
|
|
418
474
|
startVis: 0,
|
|
419
475
|
}
|
|
420
476
|
: windowInputVis(view.lines, view.cursorLine, view.cursorCol, preGeo.cols, promptW, preGeo.rows);
|
|
421
|
-
const needFooterH =
|
|
477
|
+
const needFooterH = 3 + vis.inputRows; // 1 状态 + 1 上线 + 输入行 + 1 下线
|
|
422
478
|
let g = preGeo;
|
|
423
|
-
if (needFooterH !== footerH)
|
|
479
|
+
if (needFooterH !== footerH) {
|
|
480
|
+
// setRegion 自己 write(DECSTBM + 清行 + 归位):先把已累积的擦除 flush 出去保序(擦除用的是旧几何的
|
|
481
|
+
// lastMenuStartRow,须在 setRegion 改区域前落地),再 setRegion,之后 2b..6 重新累积。
|
|
482
|
+
// footerH 不变时(常见:单行输入 / 菜单切换 / ↑↓ 导航)整帧一次 write,终端原子应用,无中间空白→不闪烁。
|
|
483
|
+
if (buf) {
|
|
484
|
+
stdout.write(buf);
|
|
485
|
+
buf = '';
|
|
486
|
+
}
|
|
424
487
|
g = setRegion(needFooterH);
|
|
488
|
+
}
|
|
425
489
|
// 2b. 重画内容末行(底栏上一行)从缓冲:防 WT 边距漏影(状态行重复到该行),并保证该行显正确内容
|
|
426
490
|
{
|
|
427
491
|
const slice = content.sliceFromEnd(scrollOffset, g.contentBottom);
|
|
428
492
|
const line = slice[g.contentBottom - 1] ?? '';
|
|
429
|
-
|
|
493
|
+
buf += cup(g.contentBottom, 1) + esc.clearLine + line;
|
|
430
494
|
}
|
|
431
495
|
// 3. 状态行(footerH 变或始终重画——便宜且避免旧状态行残留)
|
|
432
496
|
const statusRow = g.contentBottom + 1;
|
|
433
497
|
const status = { ...base, status: statusText, spinnerFrame };
|
|
434
|
-
|
|
435
|
-
//
|
|
436
|
-
|
|
437
|
-
|
|
498
|
+
buf += cup(statusRow, 1) + esc.clearLine + composeStatus(status, g.cols);
|
|
499
|
+
// 3b. 上线(输入框顶):满屏宽细线 ─(cyan),框住输入区上边界
|
|
500
|
+
buf += cup(g.contentBottom + 2, 1) + esc.clearLine + ui.cyan + '─'.repeat(g.cols) + ui.reset;
|
|
501
|
+
// 4. 输入行(g.contentBottom+3 .. rows-1)——按可视行画,首行带 prompt、其余缩进 promptW
|
|
502
|
+
const firstInputRow = g.contentBottom + 3;
|
|
503
|
+
const inputRowsAvail = g.footerH - 3; // 去掉状态/上线/下线,留输入行
|
|
438
504
|
const indent = ' '.repeat(promptW);
|
|
505
|
+
const showCaret = view.caret !== false; // 默认 true;picker 等非文本输入传 false 关闭块状光标
|
|
439
506
|
for (let i = 0; i < inputRowsAvail; i++) {
|
|
440
507
|
const line = vis.visRows[i] ?? '';
|
|
441
508
|
const r = firstInputRow + i;
|
|
442
509
|
const prefix = vis.startVis === 0 && i === 0 ? view.prompt : indent;
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
510
|
+
let text;
|
|
511
|
+
if (view.dim) {
|
|
512
|
+
text = `${ui.dim}${prefix}${line}${ui.reset}`;
|
|
513
|
+
}
|
|
514
|
+
else if (showCaret && i === vis.visLine) {
|
|
515
|
+
// 块状光标:反白光标右侧字符(cur),行末(无字符)反白一个空格——示"现在在哪输入"
|
|
516
|
+
const { before, cur, after } = splitAtVisCol(line, vis.cursorVisCol);
|
|
517
|
+
text = `${prefix}${before}${ui.reverse}${cur || ' '}${ui.reset}${after}`;
|
|
518
|
+
}
|
|
519
|
+
else {
|
|
520
|
+
text = `${prefix}${line}`;
|
|
521
|
+
}
|
|
522
|
+
buf += cup(r, 1) + esc.clearLine + text;
|
|
447
523
|
}
|
|
524
|
+
// 4b. 下线(输入框底):满屏宽细线 ─(cyan),固定屏底 rows
|
|
525
|
+
buf += cup(g.rows, 1) + esc.clearLine + ui.cyan + '─'.repeat(g.cols) + ui.reset;
|
|
448
526
|
// 5. 向上菜单(画在内容区底,底栏正上方)
|
|
449
527
|
if (view.menu && view.menu.lines.length > 0) {
|
|
450
528
|
const menuRows = Math.min(view.menu.lines.length, g.contentBottom);
|
|
451
529
|
const menuStart = g.contentBottom - menuRows + 1;
|
|
452
|
-
let p = '';
|
|
453
530
|
for (let i = 0; i < menuRows; i++) {
|
|
454
|
-
|
|
531
|
+
buf += cup(menuStart + i, 1) + esc.clearLine + view.menu.lines[i];
|
|
455
532
|
}
|
|
456
|
-
stdout.write(p);
|
|
457
533
|
lastMenuStartRow = menuStart;
|
|
458
534
|
lastMenuRows = menuRows;
|
|
459
535
|
}
|
|
460
536
|
// 6. 光标
|
|
461
537
|
if (view.dim) {
|
|
462
538
|
// 滚动回看时归内容区底(dim=运行态占位,光标不入输入框,viewport 锁历史)
|
|
463
|
-
|
|
539
|
+
buf += cup(scrollOffset === 0 ? contentRow : g.contentBottom, scrollOffset === 0 ? contentCol : 1);
|
|
464
540
|
}
|
|
465
541
|
else {
|
|
466
542
|
const r = firstInputRow + vis.visLine;
|
|
467
543
|
const col = promptW + vis.cursorVisCol + 1;
|
|
468
|
-
|
|
544
|
+
buf += cup(r, col);
|
|
469
545
|
}
|
|
546
|
+
if (buf)
|
|
547
|
+
stdout.write(buf); // 整帧一次写出(footerH 不变时):终端原子应用,无中间空白→不闪烁
|
|
470
548
|
}
|
|
471
549
|
/**
|
|
472
550
|
* 运行态 typeahead 回显:定向写输入行(底栏输入框),把 dim 占位换成已打字文本(无打字时仍显 placeholder)。
|
|
@@ -478,9 +556,10 @@ export function paintRunningInputEcho(text, placeholder) {
|
|
|
478
556
|
if (!active || !base)
|
|
479
557
|
return;
|
|
480
558
|
const g = getGeo();
|
|
481
|
-
const inputRow = g.contentBottom +
|
|
559
|
+
const inputRow = g.contentBottom + 3; // 运行态 footerH 恒 4:状态(+1)+上线(+2)+输入行(+3);下线在 rows
|
|
482
560
|
const shown = text.length > 0 ? text : placeholder;
|
|
483
|
-
|
|
561
|
+
const trimmed = truncateDisplay(shown, g.cols - 2); // 截断防超长软折行写穿下线
|
|
562
|
+
stdout.write(cup(inputRow, 1) + esc.clearLine + `${ui.dim}❯ ${trimmed}${ui.reset}`);
|
|
484
563
|
// 同步 lastView:dim 视图(lines=回显文本),使滚动/resize 的 repaint 不擦掉已打字
|
|
485
564
|
lastView = {
|
|
486
565
|
prompt: '❯ ',
|
|
@@ -507,8 +586,10 @@ export function enterInputMode(status = '空闲') {
|
|
|
507
586
|
mode = 'input';
|
|
508
587
|
statusText = status;
|
|
509
588
|
spinnerFrame = undefined;
|
|
589
|
+
turnStart = null; // 停走时
|
|
590
|
+
stopTurnTimer();
|
|
510
591
|
if (active && base) {
|
|
511
|
-
setRegion(
|
|
592
|
+
setRegion(4); // 1 状态 + 1 上线 + 1 输入 + 1 下线
|
|
512
593
|
paintInput({
|
|
513
594
|
prompt: '❯ ',
|
|
514
595
|
lines: [''],
|
|
@@ -518,14 +599,15 @@ export function enterInputMode(status = '空闲') {
|
|
|
518
599
|
});
|
|
519
600
|
}
|
|
520
601
|
}
|
|
521
|
-
/** 进入运行态:底栏输入行改 dim 占位,光标回续写位。footerH 恒
|
|
602
|
+
/** 进入运行态:底栏输入行改 dim 占位,光标回续写位。footerH 恒 4(状态+上线+输入+下线)。新轮回尾(确保新内容可见)。 */
|
|
522
603
|
export function enterRunningMode(status, placeholder) {
|
|
523
604
|
mode = 'running';
|
|
524
605
|
statusText = status;
|
|
525
606
|
spinnerFrame = undefined;
|
|
607
|
+
turnStart = Date.now(); // 起走时(整轮从发起到 enterInputMode 止)
|
|
526
608
|
resetScroll(); // 若上轮 INPUT 滚动过(未打字回底),新轮回尾
|
|
527
609
|
if (active && base) {
|
|
528
|
-
setRegion(
|
|
610
|
+
setRegion(4); // 1 状态 + 1 上线 + 1 输入 + 1 下线
|
|
529
611
|
paintInput({
|
|
530
612
|
prompt: '❯ ',
|
|
531
613
|
lines: [placeholder],
|
|
@@ -534,6 +616,7 @@ export function enterRunningMode(status, placeholder) {
|
|
|
534
616
|
menu: null,
|
|
535
617
|
dim: true,
|
|
536
618
|
});
|
|
619
|
+
startTurnTimer(); // 续刷状态行走时(流式期间 spinner 停转,由它兜底)
|
|
537
620
|
contentMode();
|
|
538
621
|
}
|
|
539
622
|
}
|
|
@@ -544,7 +627,7 @@ export function enterAltScreen() {
|
|
|
544
627
|
active = true;
|
|
545
628
|
stdout.write(esc.altOn);
|
|
546
629
|
stdout.write(esc.altScrollOn); // alt 屏滚轮转发 ↑/↓(滚轮滚动靠此 + onKey/onRunningKey 的 ↑/↓ 滚动)
|
|
547
|
-
setRegion(
|
|
630
|
+
setRegion(4); // 1 状态 + 1 上线 + 1 输入 + 1 下线(底栏始终含上下线)
|
|
548
631
|
contentRow = 1;
|
|
549
632
|
contentCol = 1;
|
|
550
633
|
segmentStartRow = 1;
|
|
@@ -581,6 +664,8 @@ export function exitAltScreen() {
|
|
|
581
664
|
if (!active)
|
|
582
665
|
return;
|
|
583
666
|
active = false;
|
|
667
|
+
stopTurnTimer(); // 兜底清走时计时器(防异常退出泄漏)
|
|
668
|
+
turnStart = null;
|
|
584
669
|
// raw 还原独立 try:非 TTY / 不支持时 setRawMode 抛错,不应阻断 stdout 恢复(alt 退屏必须执行)。
|
|
585
670
|
try {
|
|
586
671
|
stdin.setRawMode(false); // 还原 raw(RUNNING 态常驻 raw,退出时必须还原,否则终端残留 raw 模式)
|