mocode-ai 0.1.8 → 0.1.10
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 +65 -52
- package/dist/agent/core.js +8 -4
- package/dist/agent/index.js +5 -10
- package/dist/commands/config.js +1 -1
- package/dist/config/file.js +1 -1
- package/dist/config/index.js +56 -13
- package/dist/context/classifier.js +2 -1
- package/dist/index.js +2 -1
- package/dist/llm/index.js +13 -1
- package/dist/repl/index.js +204 -15
- package/dist/session/drop.js +135 -0
- package/dist/session/index.js +1 -0
- package/dist/tools/builtins/ask-human.js +4 -4
- package/dist/tools/builtins/codegraph.js +5 -3
- package/dist/tools/builtins/drop-context.js +68 -0
- package/dist/tools/builtins/edit-file.js +1 -1
- package/dist/tools/builtins/glob.js +2 -2
- package/dist/tools/builtins/grep.js +2 -2
- package/dist/tools/builtins/index.js +2 -0
- package/dist/tools/builtins/memory-forget.js +1 -1
- package/dist/tools/builtins/memory-list.js +1 -1
- package/dist/tools/builtins/memory-save.js +1 -1
- package/dist/tools/builtins/memory-search.js +1 -1
- package/dist/tools/builtins/memory-update.js +1 -1
- package/dist/tools/builtins/read-file.js +2 -2
- package/dist/tools/builtins/run-command.js +1 -1
- package/dist/tools/builtins/switch-mode.js +3 -5
- package/dist/tools/builtins/task.js +4 -5
- 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/builtins/write-file.js +1 -1
- package/dist/tools/registry.js +6 -1
- package/dist/ui/intervention.js +0 -11
- package/dist/ui/layout.js +173 -91
- package/dist/ui/prompt.js +2 -14
- package/package.json +40 -40
|
@@ -4,8 +4,8 @@ import { MAX_FILE_LINES } from '../constants.js';
|
|
|
4
4
|
// ---------- read_file ----------
|
|
5
5
|
export const readFileTool = {
|
|
6
6
|
name: 'read_file',
|
|
7
|
-
description: 'Read file content
|
|
8
|
-
'
|
|
7
|
+
description: 'Read file content with line numbers. Read before editing. offset (1-based, default 1), limit (default 2000).' +
|
|
8
|
+
' For architecture/call chains, prefer codegraph over reading files one at a time.',
|
|
9
9
|
parameters: {
|
|
10
10
|
type: 'object',
|
|
11
11
|
properties: {
|
|
@@ -4,7 +4,7 @@ import { getSandboxRoot, filterEnv, isCommandDenied } from '../../sandbox/index.
|
|
|
4
4
|
// ---------- run_command ----------
|
|
5
5
|
export const runCommandTool = {
|
|
6
6
|
name: 'run_command',
|
|
7
|
-
description: '
|
|
7
|
+
description: 'Run a shell command, merging stdout+stderr. Default timeout 120s. For tests, builds, git, etc.',
|
|
8
8
|
parameters: {
|
|
9
9
|
type: 'object',
|
|
10
10
|
properties: {
|
|
@@ -3,11 +3,9 @@ import { setAgentMode, getAgentMode } from '../../agent/mode.js';
|
|
|
3
3
|
export const switchModeTool = {
|
|
4
4
|
name: 'switch_mode',
|
|
5
5
|
description: [
|
|
6
|
-
'Switch
|
|
7
|
-
'
|
|
8
|
-
'
|
|
9
|
-
'Do NOT switch to auto if the user entered plan mode manually (via /plan or Shift+Tab) for a safety review — in that case present the plan and STOP; the user will approve via a prompt and execution happens in a follow-up turn.',
|
|
10
|
-
'Switching to plan from auto is rarely needed; do it only if you realize you should investigate before changing anything.',
|
|
6
|
+
'Switch agent mode between "plan" (read-only investigation) and "auto" (full tool execution).',
|
|
7
|
+
'Use to transition from planning to execution WITHIN THE SAME TURN: in plan, after presenting a plan, call switch_mode("auto") and continue — ONLY when the user asked for autonomous execution ("先 plan 再 auto" etc.).',
|
|
8
|
+
'If the user entered plan mode manually (/plan or Shift+Tab) for review, do NOT switch to auto — present the plan and STOP for approval.',
|
|
11
9
|
].join(' '),
|
|
12
10
|
parameters: {
|
|
13
11
|
type: 'object',
|
|
@@ -9,10 +9,9 @@ import { MAX_OUTPUT } from '../constants.js';
|
|
|
9
9
|
export const taskTool = {
|
|
10
10
|
name: 'task',
|
|
11
11
|
description: [
|
|
12
|
-
'Spawn a sub-agent
|
|
13
|
-
'Use when
|
|
14
|
-
'
|
|
15
|
-
'Optionally restrict the sub-agent to a subset of tools (e.g. read-only tools for pure investigation) via the "tools" parameter.',
|
|
12
|
+
'Spawn a sub-agent for an isolated sub-task (independent history; only its final summary returns to you).',
|
|
13
|
+
'Use when a task splits into independent parts or its many tool calls would bloat your context.',
|
|
14
|
+
'Cannot recursively spawn sub-agents.',
|
|
16
15
|
].join(''),
|
|
17
16
|
parameters: {
|
|
18
17
|
type: 'object',
|
|
@@ -24,7 +23,7 @@ export const taskTool = {
|
|
|
24
23
|
tools: {
|
|
25
24
|
type: 'array',
|
|
26
25
|
items: { type: 'string' },
|
|
27
|
-
description: 'Optional whitelist of tool names the sub-agent is allowed to use (e.g. ["read_file","glob","grep","codegraph"] for read-only investigation). Omit to allow all tools.',
|
|
26
|
+
description: 'Optional whitelist of tool names the sub-agent is allowed to use (e.g. ["read_file","glob","grep","codegraph"] for read-only investigation). Omit to allow all tools. If the sub-task needs verification/build/test (running scripts, typecheck, etc.), remember to include "run_command".',
|
|
28
27
|
},
|
|
29
28
|
maxSteps: {
|
|
30
29
|
type: 'number',
|
|
@@ -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: 'Load
|
|
7
|
+
description: 'Load the full SKILL.md instructions for a given skill. See the skill list in the system prompt for when to use each.',
|
|
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: 'Fetch
|
|
7
|
+
description: 'Fetch a URL and clean HTML to body text. Use to read a link from search results or a URL given by the user.',
|
|
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: 'Search the web (AnySearch). Returns title/url/snippet/body
|
|
9
|
+
description: 'Search the web (AnySearch). Returns title/url/snippet/body per result. Optional tag for sub-domain capability.',
|
|
10
10
|
parameters: {
|
|
11
11
|
type: 'object',
|
|
12
12
|
properties: {
|
|
@@ -3,7 +3,7 @@ import { resolve, dirname } from 'node:path';
|
|
|
3
3
|
// ---------- write_file ----------
|
|
4
4
|
export const writeFileTool = {
|
|
5
5
|
name: 'write_file',
|
|
6
|
-
description: 'Create or overwrite a file
|
|
6
|
+
description: 'Create or overwrite a file; parent dirs created.',
|
|
7
7
|
parameters: {
|
|
8
8
|
type: 'object',
|
|
9
9
|
properties: {
|
package/dist/tools/registry.js
CHANGED
|
@@ -11,6 +11,7 @@ export const tools = builtinTools;
|
|
|
11
11
|
* signal 透传给 tool.execute(经 ctx):长任务工具(run_command/web_fetch)abort 即时取消,
|
|
12
12
|
* 让用户 Ctrl+C 能跟手中断工具执行(而非等命令跑完 / 超时)。
|
|
13
13
|
* opts.skipRollback:子 agent 逻辑隔离用——跳过 recordMutation,子 agent 改动不进主回滚快照链。
|
|
14
|
+
* opts.dropContext:上下文剔除回调(drop_context 工具用),透传给 tool.execute 经 ctx。
|
|
14
15
|
*/
|
|
15
16
|
export async function executeTool(name, argsRaw, signal, opts) {
|
|
16
17
|
const tool = tools.find((t) => t.name === name);
|
|
@@ -38,7 +39,11 @@ export async function executeTool(name, argsRaw, signal, opts) {
|
|
|
38
39
|
args.path) {
|
|
39
40
|
recordMutation(args.path);
|
|
40
41
|
}
|
|
41
|
-
return await tool.execute(args, {
|
|
42
|
+
return await tool.execute(args, {
|
|
43
|
+
signal,
|
|
44
|
+
skipRollback: opts?.skipRollback,
|
|
45
|
+
dropContext: opts?.dropContext,
|
|
46
|
+
});
|
|
42
47
|
}
|
|
43
48
|
catch (e) {
|
|
44
49
|
return `错误:工具 ${name} 执行失败: ${e instanceof Error ? e.message : String(e)}`;
|
package/dist/ui/intervention.js
CHANGED
|
@@ -3,7 +3,6 @@ import { stdin, stderr } from 'node:process';
|
|
|
3
3
|
import { ui } from './theme.js';
|
|
4
4
|
import { displayWidth, truncateDisplay } from './render.js';
|
|
5
5
|
import * as layout from './layout.js';
|
|
6
|
-
import * as mouse from './mouse.js';
|
|
7
6
|
import { Spinner } from './spinner.js';
|
|
8
7
|
const emitter = stdin;
|
|
9
8
|
/** choice 末尾的"自定义输入"项标签(纯 ASCII,宽度安全)。选中后切到 input 子态。 */
|
|
@@ -148,16 +147,6 @@ export async function promptIntervention(req) {
|
|
|
148
147
|
function onKey(_str, key) {
|
|
149
148
|
if (resolved || !key)
|
|
150
149
|
return;
|
|
151
|
-
// SGR 鼠标 fragment:readline 拆碎 \x1B[<…M,经 mouse.consumeMouse 重组;
|
|
152
|
-
// 滚轮 → scrollBy(±5) 滚动查看 agent 输出内容(菜单只靠 ↑↓ 选,滚轮不选菜单);
|
|
153
|
-
// fragment 期间 suppress 吞掉防砸进 onKeyInput 的可打印插入。菜单经 scrollBy 末尾
|
|
154
|
-
// repaint()→paintInput(lastView) 重画,不被 viewport 滚动覆盖。
|
|
155
|
-
const _m = mouse.consumeMouse(key.sequence ?? '');
|
|
156
|
-
if (_m.suppress) {
|
|
157
|
-
if (_m.wheel)
|
|
158
|
-
layout.scrollBy(_m.wheel * 5);
|
|
159
|
-
return;
|
|
160
|
-
}
|
|
161
150
|
// Ctrl+C → 取消(不 reject SIGINT——否则经 executeTool 的 try/catch 变成 tool 错误串)
|
|
162
151
|
if (key.ctrl && key.name === 'c') {
|
|
163
152
|
finish({ action: 'cancelled' });
|
package/dist/ui/layout.js
CHANGED
|
@@ -5,12 +5,11 @@ import { join } from 'node:path';
|
|
|
5
5
|
import { charWidth, displayWidth, truncateDisplay, truncateDisplayHead, ansiDisplayWidth, wrapByDisplayWidth, fmtElapsed, } from './render.js';
|
|
6
6
|
import { ui } from './theme.js';
|
|
7
7
|
import * as content from './content.js';
|
|
8
|
-
import * as mouse from './mouse.js';
|
|
9
8
|
import { renderMarkdown } from './markdown.js';
|
|
10
9
|
// ── 内部状态 ──
|
|
11
10
|
let active = false;
|
|
12
11
|
let mode = 'input';
|
|
13
|
-
let footerH =
|
|
12
|
+
let footerH = 6; // 1 虚拟空行 + 1 spinner行 + 1 上线 + 输入行数 + 1 下线 + 1 model行(两行式底栏)
|
|
14
13
|
let contentRow = 1; // 续写位行(1-based,屏坐标,[1,contentBottom])
|
|
15
14
|
let contentCol = 1; // 续写位列(1-based)
|
|
16
15
|
// 上一次 paintLiveAtCursor 实际画帧的屏坐标(0=未画)。clearLiveAtCursor 清"这行"而非当前续写位——
|
|
@@ -50,8 +49,13 @@ let mdBuf = '';
|
|
|
50
49
|
const esc = {
|
|
51
50
|
altOn: '\x1B[?1049h',
|
|
52
51
|
altOff: '\x1B[?1049l',
|
|
53
|
-
|
|
54
|
-
|
|
52
|
+
// xterm alternate scroll mode(DECSET 1007):alt 屏内滚轮转发 ↑/↓ 键序列(\x1B[A/\x1B[B),
|
|
53
|
+
// 不抓取任何鼠标点击/拖拽事件——终端原生框选与复制全程可用,不需要按 Shift 逃生。
|
|
54
|
+
// 主流终端(Windows Terminal ≥1.14、xterm、VTE 系 GNOME Terminal、Konsole、iTerm2)均支持;
|
|
55
|
+
// 不支持的终端会静默忽略此转义序列,退化为"滚轮不滚动,但选区复制始终能用"——不会更差。
|
|
56
|
+
// 滚轮转发出的 ↑/↓ 由 onKey/onRunningKey 现有的 plain ↑/↓ 分支接住(见下方 scrollBy 调用点)。
|
|
57
|
+
altScrollOn: '\x1B[?1007h',
|
|
58
|
+
altScrollOff: '\x1B[?1007l',
|
|
55
59
|
cursorShow: '\x1B[?25h',
|
|
56
60
|
cursorHide: '\x1B[?25l',
|
|
57
61
|
clearLine: '\x1B[2K',
|
|
@@ -164,11 +168,19 @@ export function contentWrite(s) {
|
|
|
164
168
|
commitMd(); // 非 md 写接续:先收尾 md 段(清 segMark,否则 setLines 会截到旧段)
|
|
165
169
|
// 滚动回看时(scrollOffset>0)只喂缓冲 + 推进续写位,不物理写——否则新流式内容覆盖 viewport 历史行。
|
|
166
170
|
// 回尾(scrollOffset===0)才物理写;写起点 = 当前续写位(loop 会推进续写位,故先捕获)。
|
|
167
|
-
const startRow = contentRow;
|
|
168
|
-
const startCol = contentCol;
|
|
169
171
|
const g = getGeo();
|
|
170
172
|
const cols = g.cols;
|
|
171
173
|
const bottom = g.contentBottom;
|
|
174
|
+
// resize 后 contentRow 可能过时(拖终端框):
|
|
175
|
+
// - 缩小:contentRow > 新 bottom → 钳到新 bottom
|
|
176
|
+
// - 放大:contentRow < 新 bottom 且回尾 → 推进到 min(total, bottom)
|
|
177
|
+
if (contentRow > bottom)
|
|
178
|
+
contentRow = bottom;
|
|
179
|
+
else if (scrollOffset === 0 && contentRow < bottom) {
|
|
180
|
+
contentRow = Math.min(content.totalRows(), bottom);
|
|
181
|
+
}
|
|
182
|
+
const startRow = contentRow;
|
|
183
|
+
const startCol = contentCol;
|
|
172
184
|
const advanceRow = (r) => (r >= bottom ? bottom : r + 1); // 到底则滚动,续写位留底
|
|
173
185
|
// 滚动回看冻结(仿 ink store.pushBlock 的 offset+=newLines):scrollOffset>0 时新内容只入缓冲,
|
|
174
186
|
// 但缓冲增长后若 offset 不变,下次 scrollBy→repaintViewport 会取漂移窗口、把新内容画进历史视图
|
|
@@ -416,6 +428,12 @@ export function scrollBy(delta) {
|
|
|
416
428
|
const off = Math.max(0, Math.min(scrollOffset + delta, maxOff));
|
|
417
429
|
if (off === scrollOffset)
|
|
418
430
|
return;
|
|
431
|
+
// 进入滚动态前清掉 spinner 内联帧:滚动态由状态行心跳兜底,内联帧若残留会卡在历史视图某行。
|
|
432
|
+
if (scrollOffset === 0 && off > 0 && frameRow) {
|
|
433
|
+
stdout.write(cup(Math.min(frameRow, g.contentBottom), frameCol) + esc.clearLine);
|
|
434
|
+
frameRow = 0;
|
|
435
|
+
frameCol = 0;
|
|
436
|
+
}
|
|
419
437
|
scrollOffset = off;
|
|
420
438
|
repaintViewport();
|
|
421
439
|
repaint();
|
|
@@ -443,66 +461,96 @@ export function unlockScroll() {
|
|
|
443
461
|
export function isScrollLocked() {
|
|
444
462
|
return Date.now() < scrollLockUntil;
|
|
445
463
|
}
|
|
446
|
-
// ── 状态行 ──
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
464
|
+
// ── 状态行(两行式:spinner 行 + model 行)──
|
|
465
|
+
const STATUS_SEP = ' ';
|
|
466
|
+
const STATUS_SEP_W = STATUS_SEP.length;
|
|
467
|
+
/** 通用两段式排版:左段左端对齐 + 空格填充 + 右段右端对齐。pad 至少 1 空格防粘连。 */
|
|
468
|
+
function twoColumn(leftStr, leftW, rightStr, rightW, cols) {
|
|
469
|
+
const pad = Math.max(1, cols - leftW - rightW);
|
|
470
|
+
return `${leftStr}${' '.repeat(pad)}${rightStr}`;
|
|
471
|
+
}
|
|
472
|
+
/** 上线之上那行(spinner 行):左段 = spinner 帧 + 状态文字 + 走时(全部左对齐,紧跟不分离)。
|
|
473
|
+
* 右段仅在滚动回看时显历史指示(右端对齐)。
|
|
474
|
+
* 示例:
|
|
475
|
+
* INPUT: ◆ 空闲
|
|
476
|
+
* 思考中: ⠹ 思考中… 0.5s
|
|
477
|
+
* 运行心跳: ♥ 0.5s
|
|
478
|
+
* 滚动回看: ◆ 历史 ↑3 (PgDn 回底) */
|
|
479
|
+
function composeSpinnerLine(status, cols) {
|
|
453
480
|
const spinning = mode === 'running' && runningFrame >= 0;
|
|
454
|
-
const
|
|
455
|
-
// 模式 chip:lead 之后、model 之前。auto 显 dim 'auto'(常态),plan 显亮黄 'plan'(切换时颜色+文字都变,明显)。
|
|
456
|
-
const modeChip = status.modeTag
|
|
457
|
-
? ` ${status.modeTag === 'plan' ? ui.yellow : ui.dim}${status.modeTag}${ui.reset} `
|
|
458
|
-
: '';
|
|
459
|
-
const modeChipW = status.modeTag ? 2 + displayWidth(status.modeTag) : 0; // 2 = 前导+尾随空格
|
|
460
|
-
const model = truncateDisplay(status.model, 22);
|
|
461
|
-
const ctx = status.contextBar; // 已带色
|
|
462
|
-
const ctxW = ansiDisplayWidth(ctx);
|
|
463
|
-
// 滚动回看时状态段改显历史指示(无 spinner——滚动只在 INPUT 态)
|
|
481
|
+
const hasSpinner = !!status.spinnerFrame;
|
|
464
482
|
const scrolled = scrollOffset > 0;
|
|
465
|
-
|
|
483
|
+
// 走时(仅运行态)
|
|
484
|
+
const elapsed = (mode === 'running' && turnStart != null)
|
|
485
|
+
? fmtElapsed(Date.now() - turnStart)
|
|
486
|
+
: '';
|
|
487
|
+
// ── 左段:帧 + 状态 + 走时,全部紧跟 ──
|
|
488
|
+
let lead;
|
|
489
|
+
let leadW;
|
|
466
490
|
if (scrolled) {
|
|
467
|
-
|
|
491
|
+
// 滚动回看:左段仅显 ◆(或心跳帧),右段显历史指示
|
|
492
|
+
lead = `${spinning ? ui.brightMagenta : ui.brightCyan}${spinning ? RUNNING_FRAMES[runningFrame] : '◆'}${ui.reset}`;
|
|
493
|
+
leadW = 1;
|
|
494
|
+
}
|
|
495
|
+
else if (hasSpinner) {
|
|
496
|
+
// spinner 激活(思考中/执行工具…):帧 + 状态 + 走时
|
|
497
|
+
const ePart = elapsed ? ` ${ui.dim}${elapsed}${ui.reset}` : '';
|
|
498
|
+
lead = `${ui.brightMagenta}${status.spinnerFrame}${ui.reset} ${ui.dim}${status.status}${ui.reset}${ePart}`;
|
|
499
|
+
leadW = 1 + 1 + displayWidth(status.status) + (elapsed ? 1 + displayWidth(elapsed) : 0);
|
|
500
|
+
}
|
|
501
|
+
else if (spinning) {
|
|
502
|
+
// 运行态心跳帧(流式输出中):帧 + 走时
|
|
503
|
+
const ePart = elapsed ? ` ${ui.dim}${elapsed}${ui.reset}` : '';
|
|
504
|
+
lead = `${ui.brightMagenta}${RUNNING_FRAMES[runningFrame]}${ui.reset}${ePart}`;
|
|
505
|
+
leadW = 1 + (elapsed ? 1 + displayWidth(elapsed) : 0);
|
|
468
506
|
}
|
|
469
507
|
else {
|
|
470
|
-
//
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
}
|
|
508
|
+
// INPUT 态:◆ + 状态文字(无走时)
|
|
509
|
+
lead = `${ui.brightCyan}◆${ui.reset} ${ui.dim}${status.status}${ui.reset}`;
|
|
510
|
+
leadW = 1 + 1 + displayWidth(status.status);
|
|
511
|
+
}
|
|
512
|
+
// ── 右段:仅滚动回看时显历史指示 ──
|
|
513
|
+
let tail = '';
|
|
514
|
+
let tailW = 0;
|
|
515
|
+
if (scrolled) {
|
|
516
|
+
tail = `历史 ↑${scrollOffset} (PgDn 回底)`;
|
|
517
|
+
tailW = displayWidth(tail);
|
|
481
518
|
}
|
|
482
|
-
const
|
|
483
|
-
|
|
484
|
-
|
|
519
|
+
const rightStr = tail ? `${ui.yellow}${tail}${ui.reset}` : '';
|
|
520
|
+
return twoColumn(lead, leadW, rightStr, tailW, cols);
|
|
521
|
+
}
|
|
522
|
+
/** 下线之下那行(model 行):左 = 模式标识(auto 跟随主题色 / plan 显亮黄);右 = context + cwd,右端对齐。 */
|
|
523
|
+
function composeModelLine(status, cols) {
|
|
524
|
+
const ctx = status.contextBar; // 已带色
|
|
525
|
+
const ctxW = ansiDisplayWidth(ctx);
|
|
526
|
+
// 左段:模式标识(auto 显 brightCyan 随主题 / plan 显亮黄)
|
|
527
|
+
const modeTag = status.modeTag ?? '';
|
|
528
|
+
const leftStr = modeTag
|
|
529
|
+
? `${modeTag === 'plan' ? ui.yellow : ui.brightCyan}${modeTag}${ui.reset}`
|
|
530
|
+
: '';
|
|
531
|
+
const leftW = modeTag ? displayWidth(modeTag) : 0;
|
|
532
|
+
// 右段:ctx + sep + cwd,右端对齐。cwd 按预算截断,极窄(<6)隐藏。
|
|
533
|
+
const minGap = 2;
|
|
534
|
+
const cwdBudget = cols - leftW - minGap - ctxW - STATUS_SEP_W - 1;
|
|
485
535
|
const cwd = cwdBudget >= 6 ? truncateDisplay(status.cwd, cwdBudget) : '';
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
].join('');
|
|
495
|
-
}
|
|
496
|
-
/** 画状态行(光标回续写位)。RUNNING 态 spinner 频繁调。 */
|
|
536
|
+
const cwdW = displayWidth(cwd);
|
|
537
|
+
const rightStr = `${ctx}${STATUS_SEP}${ui.dim}${cwd}${ui.reset}`;
|
|
538
|
+
const rightW = ctxW + STATUS_SEP_W + cwdW;
|
|
539
|
+
return twoColumn(leftStr, leftW, rightStr, rightW, cols);
|
|
540
|
+
}
|
|
541
|
+
/** 画状态行(spinner 行 + model 行,两行)。RUNNING 态 spinner 频繁调。
|
|
542
|
+
* 行号(footerH=6):spinner 行=contentBottom+2,model 行=rows(屏底)。
|
|
543
|
+
* 上线 contentBottom+3 / 输入 contentBottom+4 / 下线 contentBottom+5 由 paintInput 画,此函数只刷两行信息。 */
|
|
497
544
|
export function drawStatusBar(status) {
|
|
498
545
|
if (!active || !base)
|
|
499
546
|
return;
|
|
500
547
|
const s = status ?? { ...base, status: statusText, spinnerFrame };
|
|
501
548
|
const g = getGeo();
|
|
502
|
-
const
|
|
503
|
-
|
|
504
|
-
//
|
|
505
|
-
let out = cup(
|
|
549
|
+
const spinnerRow = g.contentBottom + 2; // +1 虚拟空行,+2 spinner 行
|
|
550
|
+
const modelRow = g.rows; // 屏底:model 行
|
|
551
|
+
// 一次写入:cup spinner 行 + clearLine + spinner 行内容 + cup model 行 + clearLine + model 行内容 + cup 回。
|
|
552
|
+
let out = cup(spinnerRow, 1) + esc.clearLine + composeSpinnerLine(s, g.cols) +
|
|
553
|
+
cup(modelRow, 1) + esc.clearLine + composeModelLine(s, g.cols);
|
|
506
554
|
if (mode === 'running') {
|
|
507
555
|
// 运行态(回尾 / 滚动回看均):cup 回输入框光标位(供 IME 锚定)。
|
|
508
556
|
const p = runningCaretPos();
|
|
@@ -568,18 +616,44 @@ function dbgSpinner(msg) {
|
|
|
568
616
|
*/
|
|
569
617
|
export function paintLiveAtCursor(text) {
|
|
570
618
|
if (!active || !ui.isTTY || scrollOffset !== 0 || isStreamingPaused()) {
|
|
571
|
-
//
|
|
572
|
-
|
|
573
|
-
|
|
619
|
+
// 滚动态 / 暂停态:不画新帧。但若有旧帧残留(frameRow),必须清掉——
|
|
620
|
+
// 否则旧 spinner 帧停在历史视图某行,用户看到「思考中」卡在消息堆里(根因)。
|
|
621
|
+
// 滚动态由状态行心跳兜底显示运行状态,不需内联帧;清完后 frameRow 归零,回尾时下一帧自然重画。
|
|
622
|
+
if (frameRow) {
|
|
623
|
+
// resize 后 contentBottom 可能缩小:清旧帧时钳 frameRow 到当前可视区,避免 cup 到屏外行
|
|
624
|
+
const g = getGeo();
|
|
625
|
+
const fr = Math.min(frameRow, g.contentBottom);
|
|
626
|
+
let out = cup(fr, frameCol) + esc.clearLine;
|
|
627
|
+
if (mode === 'running') {
|
|
628
|
+
const p = runningCaretPos();
|
|
629
|
+
out += cup(p.row, p.col);
|
|
630
|
+
}
|
|
631
|
+
stdout.write(out);
|
|
632
|
+
frameRow = 0;
|
|
633
|
+
frameCol = 0;
|
|
634
|
+
}
|
|
574
635
|
return;
|
|
575
636
|
}
|
|
637
|
+
// resize 后 contentRow 可能过时(拖终端框):
|
|
638
|
+
// - 缩小:contentRow > 新 bottom → 钳到新 bottom
|
|
639
|
+
// - 放大:contentRow < 新 bottom 且回尾 → 推进到 min(total, bottom)(内容末尾或新区底)
|
|
640
|
+
// 内容够填满:推进到 bottom(最底部);内容不够:推进到 total(紧跟内容尾)
|
|
641
|
+
// 否则 cup 到旧行号→帧画在屏幕中间(旧 bottom 位置),而非内容末尾/最底部。
|
|
642
|
+
const g = getGeo();
|
|
643
|
+
const total = content.totalRows();
|
|
644
|
+
if (contentRow > g.contentBottom)
|
|
645
|
+
contentRow = g.contentBottom;
|
|
646
|
+
if (scrollOffset === 0 && contentRow < g.contentBottom) {
|
|
647
|
+
contentRow = Math.min(total, g.contentBottom);
|
|
648
|
+
}
|
|
576
649
|
// 临时诊断:续写位 != 上次画帧位置 = spinner 运行期间续写位漂移(泄漏根因嫌疑)
|
|
577
650
|
if (frameRow && (frameRow !== contentRow || frameCol !== contentCol)) {
|
|
578
651
|
dbgSpinner(`DRIFT-PAINT old=(${frameRow},${frameCol}) cur=(${contentRow},${contentCol}) mode=${mode} off=${scrollOffset}`);
|
|
579
652
|
}
|
|
580
653
|
let out = '';
|
|
581
654
|
if (frameRow && (frameRow !== contentRow || frameCol !== contentCol)) {
|
|
582
|
-
|
|
655
|
+
// 清旧帧同样钳到可视区(resize 后 frameRow 可能 > contentBottom)
|
|
656
|
+
out += cup(Math.min(frameRow, g.contentBottom), frameCol) + esc.clearLine; // 续写位漂移:先清旧帧行,否则残留
|
|
583
657
|
}
|
|
584
658
|
out += cup(contentRow, contentCol) + esc.clearLine + text;
|
|
585
659
|
if (mode === 'running') {
|
|
@@ -591,21 +665,17 @@ export function paintLiveAtCursor(text) {
|
|
|
591
665
|
frameCol = contentCol;
|
|
592
666
|
}
|
|
593
667
|
/** 清掉 paintLiveAtCursor 画过的那行瞬时活动文本。清"实际画过的位置"(frameRow),非当前续写位。
|
|
594
|
-
* 不加 isStreamingPaused 守卫——stop 时必须无条件清(只写一次、结尾 cup 回输入框,不扰 IME);否则打字中 stop 会跳过清帧、制造泄漏。
|
|
668
|
+
* 不加 isStreamingPaused 守卫——stop 时必须无条件清(只写一次、结尾 cup 回输入框,不扰 IME);否则打字中 stop 会跳过清帧、制造泄漏。
|
|
669
|
+
* 滚动态(scrollOffset≠0)也必须清残留帧——否则旧 spinner 帧卡在历史视图某行(「思考中在消息堆里」根因)。 */
|
|
595
670
|
export function clearLiveAtCursor() {
|
|
596
|
-
|
|
597
|
-
if (frameRow && (!active || !ui.isTTY || scrollOffset !== 0)) {
|
|
598
|
-
dbgSpinner(`CLEAR-SKIPPED frame=(${frameRow},${frameCol}) cur=(${contentRow},${contentCol}) off=${scrollOffset} active=${active}`);
|
|
599
|
-
}
|
|
600
|
-
if (!active || !ui.isTTY || scrollOffset !== 0)
|
|
671
|
+
if (!active || !ui.isTTY)
|
|
601
672
|
return;
|
|
602
673
|
if (!frameRow)
|
|
603
674
|
return; // 没画过就不清(避免误清当前续写位内容)
|
|
604
|
-
//
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
let out = cup(frameRow, frameCol) + esc.clearLine; // 清"画过的行",非"当前续写位"
|
|
675
|
+
// 滚动态也清:旧帧不该残留。清"画过的行"(frameRow),回尾后该行由 repaintViewport 重画历史内容。
|
|
676
|
+
// resize 后 contentBottom 可能缩小:frameRow 钳到可视区,避免 cup 到屏外行清错位置。
|
|
677
|
+
const g = getGeo();
|
|
678
|
+
let out = cup(Math.min(frameRow, g.contentBottom), frameCol) + esc.clearLine;
|
|
609
679
|
if (mode === 'running') {
|
|
610
680
|
const p = runningCaretPos();
|
|
611
681
|
out += cup(p.row, p.col);
|
|
@@ -726,7 +796,7 @@ export function paintInput(view) {
|
|
|
726
796
|
startVis: 0,
|
|
727
797
|
}
|
|
728
798
|
: windowInputVis(view.lines, view.cursorLine, view.cursorCol, preGeo.cols, promptW, preGeo.rows);
|
|
729
|
-
const needFooterH =
|
|
799
|
+
const needFooterH = 5 + vis.inputRows; // 1 虚拟空 + 1 spinner 行 + 1 上线 + 输入行 + 1 下线 + 1 model 行
|
|
730
800
|
let g = preGeo;
|
|
731
801
|
if (needFooterH !== footerH) {
|
|
732
802
|
// setRegion 自己 write(DECSTBM + 清行 + 归位):先把已累积的擦除 flush 出去保序(擦除用的是旧几何的
|
|
@@ -746,15 +816,17 @@ export function paintInput(view) {
|
|
|
746
816
|
}
|
|
747
817
|
// 2c. 虚拟空行(内容区与状态栏之间的视觉间隔,属底栏非内容):恒清空,防底栏撑高时旧内容残留该行
|
|
748
818
|
buf += cup(g.contentBottom + 1, 1) + esc.clearLine;
|
|
749
|
-
// 3.
|
|
750
|
-
const
|
|
819
|
+
// 3. 状态行:spinner 行 + model 行(两行式底栏)
|
|
820
|
+
const spinnerRow = g.contentBottom + 2; // +1 虚拟空行,+2 spinner 行
|
|
821
|
+
const modelRow = g.rows; // 屏底:model 行
|
|
751
822
|
const status = { ...base, status: statusText, spinnerFrame };
|
|
752
|
-
buf += cup(
|
|
823
|
+
buf += cup(spinnerRow, 1) + esc.clearLine + composeSpinnerLine(status, g.cols);
|
|
824
|
+
buf += cup(modelRow, 1) + esc.clearLine + composeModelLine(status, g.cols);
|
|
753
825
|
// 3b. 上线(输入框顶):满屏宽细线 ─(cyan),框住输入区上边界
|
|
754
826
|
buf += cup(g.contentBottom + 3, 1) + esc.clearLine + ui.cyan + '─'.repeat(g.cols) + ui.reset;
|
|
755
827
|
// 4. 输入行(g.contentBottom+4 .. rows-1)——按可视行画,首行带 prompt、其余缩进 promptW
|
|
756
828
|
const firstInputRow = g.contentBottom + 4;
|
|
757
|
-
const inputRowsAvail = g.footerH -
|
|
829
|
+
const inputRowsAvail = g.footerH - 5; // 去掉虚拟空/spinner行/上线/下线/model行,留输入行
|
|
758
830
|
const indent = ' '.repeat(promptW);
|
|
759
831
|
const showCaret = view.caret !== false; // 默认 true;picker 等非文本输入传 false 关闭块状光标
|
|
760
832
|
for (let i = 0; i < inputRowsAvail; i++) {
|
|
@@ -775,8 +847,8 @@ export function paintInput(view) {
|
|
|
775
847
|
}
|
|
776
848
|
buf += cup(r, 1) + esc.clearLine + text;
|
|
777
849
|
}
|
|
778
|
-
// 4b. 下线(输入框底):满屏宽细线 ─(cyan)
|
|
779
|
-
buf += cup(g.rows, 1) + esc.clearLine + ui.cyan + '─'.repeat(g.cols) + ui.reset;
|
|
850
|
+
// 4b. 下线(输入框底):满屏宽细线 ─(cyan),在 model 行上一行(model 行占屏底 rows)
|
|
851
|
+
buf += cup(g.rows - 1, 1) + esc.clearLine + ui.cyan + '─'.repeat(g.cols) + ui.reset;
|
|
780
852
|
// 5. 向上菜单(画在内容区底,底栏正上方)
|
|
781
853
|
if (view.menu && view.menu.lines.length > 0) {
|
|
782
854
|
const menuRows = Math.min(view.menu.lines.length, g.contentBottom);
|
|
@@ -883,7 +955,7 @@ export function enterInputMode(status = '空闲') {
|
|
|
883
955
|
repaintViewport();
|
|
884
956
|
}
|
|
885
957
|
if (active && base) {
|
|
886
|
-
setRegion(
|
|
958
|
+
setRegion(6); // 1 虚拟空 + 1 spinner行 + 1 上线 + 1 输入 + 1 下线 + 1 model行(两行式底栏)
|
|
887
959
|
paintInput({
|
|
888
960
|
prompt: '❯ ',
|
|
889
961
|
lines: [''],
|
|
@@ -894,7 +966,7 @@ export function enterInputMode(status = '空闲') {
|
|
|
894
966
|
stdout.write(esc.cursorShow); // 回 INPUT 态:显真光标(运行态藏了)
|
|
895
967
|
}
|
|
896
968
|
}
|
|
897
|
-
/** 进入运行态:底栏输入行改 dim 占位,光标回续写位。footerH 恒
|
|
969
|
+
/** 进入运行态:底栏输入行改 dim 占位,光标回续写位。footerH 恒 6(虚拟空+spinner行+上线+输入+下线+model行)。新轮回尾(确保新内容可见)。 */
|
|
898
970
|
export function enterRunningMode(status, placeholder) {
|
|
899
971
|
mode = 'running';
|
|
900
972
|
statusText = status;
|
|
@@ -903,7 +975,7 @@ export function enterRunningMode(status, placeholder) {
|
|
|
903
975
|
resetScroll(); // 若上轮 INPUT 滚动过(未打字回底),新轮回尾
|
|
904
976
|
lockScrollToBottom(); // 轮首短时锁:吸收发消息前后残留滚轮事件,保 agent 输出从底部开始(锁过期或轮末 enterInputMode 解)
|
|
905
977
|
if (active && base) {
|
|
906
|
-
setRegion(
|
|
978
|
+
setRegion(6); // 1 虚拟空 + 1 spinner行 + 1 上线 + 1 输入 + 1 下线 + 1 model行
|
|
907
979
|
paintInput({
|
|
908
980
|
prompt: '❯ ',
|
|
909
981
|
lines: [''],
|
|
@@ -926,8 +998,8 @@ export function enterAltScreen() {
|
|
|
926
998
|
return;
|
|
927
999
|
active = true;
|
|
928
1000
|
stdout.write(esc.altOn);
|
|
929
|
-
stdout.write(esc.
|
|
930
|
-
setRegion(
|
|
1001
|
+
stdout.write(esc.altScrollOn); // alt 屏滚轮转发 ↑/↓(不抓鼠标点击/拖拽,原生选区复制不受影响)
|
|
1002
|
+
setRegion(6); // 1 虚拟空 + 1 spinner行 + 1 上线 + 1 输入 + 1 下线 + 1 model行(两行式底栏)
|
|
931
1003
|
contentRow = 1;
|
|
932
1004
|
contentCol = 1;
|
|
933
1005
|
segmentStartRow = 1;
|
|
@@ -941,20 +1013,31 @@ export function enterAltScreen() {
|
|
|
941
1013
|
sigwinchHandler = () => {
|
|
942
1014
|
if (!active)
|
|
943
1015
|
return;
|
|
1016
|
+
// 立即(同步)更新行号 + 重设 DECSTBM 区域:不 debounce,否则快速拖动边框时
|
|
1017
|
+
// contentRow 停在旧值、区域未更新,spinner/contentWrite 画到旧行号(「思考中在消息堆里」根因)。
|
|
1018
|
+
// 重画 repaintViewport 防抖(下面 timer),避免连续拖动闪烁;但行号/区域必须立即正确。
|
|
1019
|
+
const g = getGeo(footerH);
|
|
1020
|
+
const total = content.totalRows();
|
|
1021
|
+
// 缩小:contentRow > 新 bottom → 钳到新 bottom
|
|
1022
|
+
if (contentRow > g.contentBottom)
|
|
1023
|
+
contentRow = g.contentBottom;
|
|
1024
|
+
// 放大:contentRow < 新 bottom 且回尾(offset=0)→ 推进到 min(total, bottom)
|
|
1025
|
+
if (scrollOffset === 0 && contentRow < g.contentBottom) {
|
|
1026
|
+
contentRow = Math.min(total, g.contentBottom);
|
|
1027
|
+
}
|
|
1028
|
+
if (frameRow && frameRow > g.contentBottom)
|
|
1029
|
+
frameRow = g.contentBottom;
|
|
1030
|
+
const maxOff = Math.max(0, total - g.contentBottom);
|
|
1031
|
+
if (scrollOffset > maxOff)
|
|
1032
|
+
scrollOffset = maxOff;
|
|
1033
|
+
setRegion(footerH); // 用新 rows 立即重设 DECSTBM 区域(contentBottom 变)
|
|
1034
|
+
// 重画防抖(仅 repaint,避免快速拖动闪烁);行号/区域已立即更新
|
|
944
1035
|
if (resizeTimer)
|
|
945
1036
|
clearTimeout(resizeTimer);
|
|
946
1037
|
resizeTimer = setTimeout(() => {
|
|
947
1038
|
resizeTimer = null;
|
|
948
1039
|
if (!active)
|
|
949
1040
|
return;
|
|
950
|
-
const g = getGeo(footerH);
|
|
951
|
-
if (contentRow > g.contentBottom)
|
|
952
|
-
contentRow = g.contentBottom;
|
|
953
|
-
// offset 钳到新 maxOff(高度缩可能使旧 offset 失效)
|
|
954
|
-
const maxOff = Math.max(0, content.totalRows() - g.contentBottom);
|
|
955
|
-
if (scrollOffset > maxOff)
|
|
956
|
-
scrollOffset = maxOff;
|
|
957
|
-
setRegion(footerH); // 用新 rows 重设区域(contentBottom 变)
|
|
958
1041
|
repaintViewport(); // 内容区按新高度 + 当前 offset 重画(物理行不 reflow)
|
|
959
1042
|
repaint(); // 底栏重画
|
|
960
1043
|
}, 100);
|
|
@@ -972,7 +1055,6 @@ export function exitAltScreen() {
|
|
|
972
1055
|
scrollLockUntil = 0; // 清轮首滚动锁(防状态泄漏到下次进 alt 屏)
|
|
973
1056
|
frameRow = 0; // 清 spinner 帧位置(防状态泄漏到下次进 alt 屏)
|
|
974
1057
|
frameCol = 0;
|
|
975
|
-
mouse.resetMouse(); // 清鼠标重组残留(防退出后状态泄漏到下次进 alt 屏)
|
|
976
1058
|
// raw 还原独立 try:非 TTY / 不支持时 setRawMode 抛错,不应阻断 stdout 恢复(alt 退屏必须执行)。
|
|
977
1059
|
try {
|
|
978
1060
|
stdin.setRawMode(false); // 还原 raw(RUNNING 态常驻 raw,退出时必须还原,否则终端残留 raw 模式)
|
|
@@ -983,7 +1065,7 @@ export function exitAltScreen() {
|
|
|
983
1065
|
try {
|
|
984
1066
|
stdout.write('\x1B[r'); // 复位 DECSTBM margins
|
|
985
1067
|
stdout.write(esc.cursorShow);
|
|
986
|
-
stdout.write(esc.
|
|
1068
|
+
stdout.write(esc.altScrollOff); // 关 alt 屏滚轮转发
|
|
987
1069
|
stdout.write(esc.altOff); // 退 alt(恢复主屏 + 光标)
|
|
988
1070
|
}
|
|
989
1071
|
catch {
|
package/dist/ui/prompt.js
CHANGED
|
@@ -3,7 +3,6 @@ import { stdin, stdout } from 'node:process';
|
|
|
3
3
|
import { ui } from './theme.js';
|
|
4
4
|
import { displayWidth, padEndDisplay, truncateDisplay } from './render.js';
|
|
5
5
|
import * as layout from './layout.js';
|
|
6
|
-
import * as mouse from './mouse.js';
|
|
7
6
|
// ── 粘贴检测(块级 + 时间窗)──
|
|
8
7
|
// 块级:多字节大块(len>8)或含 CR/LF 的小块 = 粘贴(键盘单键 1 字节,Enter=\r 单字节)。
|
|
9
8
|
// 时间窗:每块重置 50ms 计时器,静默 50ms 即"粘贴结束"→ onPasteEnd。跨多块的大粘贴(块间 <50ms)累积进
|
|
@@ -19,10 +18,7 @@ function ensurePasteDetector() {
|
|
|
19
18
|
return;
|
|
20
19
|
pasteDetectorInstalled = true;
|
|
21
20
|
stdin.on('data', (chunk) => {
|
|
22
|
-
const
|
|
23
|
-
// 剔除 SGR 鼠标报告(\x1B[<…M/m,连半截也匹配):防快速滚轮/触控板动量在一个 chunk 里拼 ≥2 条
|
|
24
|
-
// 报表(22 字符 > 16 阈值)误判粘贴 50ms、把后续真按键泄进 pasteParts。
|
|
25
|
-
const text = raw.replace(/\x1b\[<[0-9;]*[Mm]/g, '');
|
|
21
|
+
const text = typeof chunk === 'string' ? chunk : chunk.toString('utf8');
|
|
26
22
|
const hasNL = text.indexOf('\r') >= 0 || text.indexOf('\n') >= 0;
|
|
27
23
|
// 按字符数(码点)判粘贴,非字节:CJK 汉字占 3 UTF-8 字节,旧阈值(len>8 字节)会把 IME 提交的
|
|
28
24
|
// 3-10 个汉字误判为粘贴 → 进 50ms 缓冲 → finalizePaste→insertText 落字(且旧 insertText 把光标
|
|
@@ -265,14 +261,6 @@ export async function promptWithSlashMenu(opts) {
|
|
|
265
261
|
function onKey(_str, key) {
|
|
266
262
|
if (resolved || !key)
|
|
267
263
|
return;
|
|
268
|
-
// SGR 鼠标滚轮:readline 把 \x1B[<btn;col;rowM 拆成多 fragment,经 mouse.consumeMouse 重组;
|
|
269
|
-
// 滚轮 → scrollBy(±5)(只重画内容区,底栏不动)。置于 pasting 之前——鼠标 fragment 永不进 pasteParts。
|
|
270
|
-
const _m = mouse.consumeMouse(key.sequence ?? '');
|
|
271
|
-
if (_m.suppress) {
|
|
272
|
-
if (_m.wheel)
|
|
273
|
-
layout.scrollBy(_m.wheel * 5);
|
|
274
|
-
return;
|
|
275
|
-
}
|
|
276
264
|
// Shift+Tab:循环切换 agent 模式(auto ↔ plan)。不插字符、不提交、不影响输入文本;
|
|
277
265
|
// 回调由 repl 注入(翻 agentMode + 重写 history[0] + 设状态行 modeTag),再 redraw() 经
|
|
278
266
|
// paintInput 重画底栏(状态行 chip 即时刷新 + 光标留输入框)。置于 case 'tab' 之前,故不触发菜单补全。
|
|
@@ -306,7 +294,7 @@ export async function promptWithSlashMenu(opts) {
|
|
|
306
294
|
}
|
|
307
295
|
// 滚动回看键(优先;不触发回尾):PgUp/PgDn 翻页,Ctrl+↑↓ 与 plain ↑/↓ 每次 5 行。
|
|
308
296
|
// plain ↑/↓ 仅在单行输入且菜单关闭时作滚动(多行编辑留给光标移动,菜单打开留给选项);
|
|
309
|
-
//
|
|
297
|
+
// 兼鼠标滚轮——alt 屏内(经 \x1B[?1007h)滚轮转发 ↑/↓,1 行/格太慢故放大到 5。
|
|
310
298
|
const plainArrowScroll = (key.name === 'up' || key.name === 'down') &&
|
|
311
299
|
!key.ctrl &&
|
|
312
300
|
!key.meta &&
|