mocode-ai 1.5.11 → 1.5.13

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 CHANGED
@@ -110,7 +110,7 @@ MoCode isn't a chat box with a coat of paint — it's an agent that actually get
110
110
  - **Session persistence** — Every turn is saved automatically; `--resume` / `/resume` picks up a past session.
111
111
  - **Skills system** — Scans directories like `~/.mocode/skills/` automatically; each skill's description is injected into the system prompt, and the model calls `use_skill` to load the full instructions only when relevant (progressive disclosure: skim the summary first, load the body only if needed).
112
112
  - **Optional desktop pet** — A small floating window (`/pet`) shows a stateful character that mirrors agent activity (idle / thinking / tool running / waiting for human). Works as a separate process over WebSocket; quit it with `/pet quit`. Sits beside the terminal, never blocks it.
113
- - **Slash commands** — `/exit` `/clear` `/context` `/skills` `/compact` `/resume` `/rollback` `/memory` `/reflect` `/init` `/theme` `/model` `/plan` `/auto` `/pet`, with dropdown filtering as you type.
113
+ - **Slash commands** — `/exit` `/clear` `/cd` `/context` `/skills` `/compact` `/resume` `/rollback` `/memory` `/reflect` `/init` `/theme` `/model` `/plan` `/auto` `/pet`, with dropdown filtering as you type.
114
114
 
115
115
  ## Documentation
116
116
 
@@ -280,6 +280,7 @@ dev_server stop id=srv-xxxx
280
280
  | ---------------- | ---------------------------------------------------------------------------------------------- |
281
281
  | `/exit` `/quit` | Exit MoCode |
282
282
  | `/clear` | Clear history (keeps the system prompt) + clear screen |
283
+ | `/cd` | Switch workspace (`/cd <path>`; bare `/cd` shows current, `/cd -` returns). Old session is saved to the old workspace, the new one starts like `/clear` |
283
284
  | `/image` | Attach a local image to the next message; supports `attach <path>` / `list` / `clear` |
284
285
  | `/context` | Show a context usage bar (tokens / message count, estimated or measured) |
285
286
  | `/skills` | List discovered skills |
package/README.zh-CN.md CHANGED
@@ -109,7 +109,7 @@ mocode 不是一个套壳聊天框,而是一个能真正动手干活的 agent:
109
109
  - **会话持久化** — 每轮自动落盘,`--resume` / `/resume` 续接历史会话
110
110
  - **Skills 系统** — 自动扫描 `~/.mocode/skills/` 等目录,description 注入系统提示,模型按需调 `use_skill` 加载完整指令(渐进式披露:先看简介,任务相关才加载正文)
111
111
  - **可选桌宠** — 独立悬浮窗(`/pet`)显示一个小角色,镜像 agent 活动(空闲 / 思考 / 跑工具 / 等人工),独立进程走 WebSocket,`/pet quit` 完全关闭。挂在终端外,绝不挡终端。
112
- - **斜杠命令** — `/exit` `/clear` `/context` `/skills` `/compact` `/resume` `/rollback` `/memory` `/reflect` `/init` `/theme` `/model` `/plan` `/auto` `/pet`,输入时下拉过滤
112
+ - **斜杠命令** — `/exit` `/clear` `/cd` `/context` `/skills` `/compact` `/resume` `/rollback` `/memory` `/reflect` `/init` `/theme` `/model` `/plan` `/auto` `/pet`,输入时下拉过滤
113
113
 
114
114
  ## 使用文档
115
115
 
@@ -271,6 +271,7 @@ dev_server stop id=srv-xxxx
271
271
  | ---------------- | ------------------------------------------------------------------- |
272
272
  | `/exit` `/quit` | 退出 mocode |
273
273
  | `/clear` | 清空历史(保留系统提示)+ 清屏 |
274
+ | `/cd` | 切换工作空间(`/cd <路径>`;`/cd` 看当前、`/cd -` 回上一个)。旧会话落盘到原工作区,新工作区按 /clear 重开 |
274
275
  | `/image` | 附加本地图片到下一条消息;支持 `list` / `clear` |
275
276
  | `/context` | 显示上下文用量条(token / 消息数 / 估算或实测) |
276
277
  | `/skills` | 列出已发现的 skill |
@@ -4,6 +4,9 @@ const zhCN = {
4
4
  'commands.help': '查看所有命令',
5
5
  'commands.exit': '退出 mocode(同 /quit)',
6
6
  'commands.clear': '清空历史(保留系统提示)',
7
+ 'commands.cd': '切换工作空间(新工作区按 /clear 重开会话)',
8
+ 'commands.cdTo': '切换到指定目录(支持 ~ 与相对路径)',
9
+ 'commands.cdPrev': '回到上一个工作空间',
7
10
  'commands.context': '显示上下文用量条',
8
11
  'commands.skills': '列出已发现的 skill',
9
12
  'commands.compact': '压缩历史(可带焦点 /compact …)',
@@ -66,6 +69,15 @@ const zhCN = {
66
69
  'commands.upgradeNow': '立即升级',
67
70
  'commands.upgradeCheck': '检查当前版本与最新版本差异',
68
71
  'commands.upgradeStatus': '显示当前本地版本',
72
+ 'workspace.current': '当前工作空间:{root}',
73
+ 'workspace.usage': '/cd 用法:\n /cd 显示当前工作空间\n /cd <路径> 切换到该目录(支持 ~ 与相对路径)\n /cd - 回到上一个工作空间',
74
+ 'workspace.missing': '目录不存在:{root}',
75
+ 'workspace.notDir': '不是目录:{root}',
76
+ 'workspace.noPrevious': '还没有上一个工作空间(先用 /cd <路径> 切换一次)。',
77
+ 'workspace.same': '已经在该工作空间:{root}',
78
+ 'workspace.switched': '工作空间已切换 → {root}',
79
+ 'workspace.cleared': '会话已按 /clear 重开;旧会话留在 {root}',
80
+ 'workspace.projectConfigHint': '注:项目级 .env / .mocode/config 仅在启动时加载,切换后不重载;如需生效请重启 mocode。MCP 连接同理。',
69
81
  'help.title': '可用斜杠命令',
70
82
  'help.hint': '(› 表示可进入子菜单;→ 后为实际执行命令)',
71
83
  'help.groupFrequent': '常用',
@@ -90,6 +102,7 @@ const zhCN = {
90
102
  'running.plan': '切 plan',
91
103
  'running.auto': '切 auto',
92
104
  'running.clear': '清空',
105
+ 'running.workspace': '切工作空间',
93
106
  'running.theme': '切主题',
94
107
  'running.chooseTheme': '选择主题…',
95
108
  'running.model': '配模型',
@@ -204,6 +217,10 @@ const zhCN = {
204
217
  'agent.toolsRunning': '正在探索',
205
218
  'agent.toolsComplete': '探索',
206
219
  'agent.toolsFailed': '探索失败',
220
+ /** 单次工具调用 = 1「步」的计数单位(中文量词;英文用 countN)。 */
221
+ 'agent.steps': '{count} 步',
222
+ /** 摘要行分项间的最小视觉分隔符(不占宽度,纯前景色点)。 */
223
+ 'agent.stepSep': '·',
207
224
  'agent.changes': '文件变更',
208
225
  'agent.files': '{count} 个文件',
209
226
  'agent.changeAdded': ' 新增',
@@ -355,6 +372,9 @@ const en = {
355
372
  'commands.help': 'Show all commands',
356
373
  'commands.exit': 'Exit mocode (same as /quit)',
357
374
  'commands.clear': 'Clear history (keep system prompt)',
375
+ 'commands.cd': 'Switch workspace (new workspace restarts the session like /clear)',
376
+ 'commands.cdTo': 'Switch to a directory (supports ~ and relative paths)',
377
+ 'commands.cdPrev': 'Return to the previous workspace',
358
378
  'commands.context': 'Show context usage',
359
379
  'commands.skills': 'List discovered skills',
360
380
  'commands.compact': 'Compact history (optional focus: /compact …)',
@@ -417,6 +437,15 @@ const en = {
417
437
  'commands.upgradeNow': 'Upgrade now',
418
438
  'commands.upgradeCheck': 'Check current vs latest version',
419
439
  'commands.upgradeStatus': 'Show current local version',
440
+ 'workspace.current': 'Current workspace: {root}',
441
+ 'workspace.usage': '/cd usage:\n /cd Show the current workspace\n /cd <path> Switch to that directory (supports ~ and relative paths)\n /cd - Return to the previous workspace',
442
+ 'workspace.missing': 'Directory does not exist: {root}',
443
+ 'workspace.notDir': 'Not a directory: {root}',
444
+ 'workspace.noPrevious': 'No previous workspace yet (switch once with /cd <path>).',
445
+ 'workspace.same': 'Already in that workspace: {root}',
446
+ 'workspace.switched': 'Workspace switched → {root}',
447
+ 'workspace.cleared': 'Session restarted like /clear; the old session stays in {root}',
448
+ 'workspace.projectConfigHint': 'Note: project-level .env / .mocode/config is loaded at startup and is not reloaded after a switch — restart mocode for it to take effect. MCP connections behave the same way.',
420
449
  'help.title': 'Available slash commands',
421
450
  'help.hint': '(› opens a submenu; → shows the command that will run)',
422
451
  'help.groupFrequent': 'Frequently used',
@@ -441,6 +470,7 @@ const en = {
441
470
  'running.plan': 'Plan mode',
442
471
  'running.auto': 'Auto mode',
443
472
  'running.clear': 'Clear',
473
+ 'running.workspace': 'Workspace',
444
474
  'running.theme': 'Theme',
445
475
  'running.chooseTheme': 'Choose a theme…',
446
476
  'running.model': 'Model',
@@ -555,6 +585,8 @@ const en = {
555
585
  'agent.toolsRunning': 'Exploring',
556
586
  'agent.toolsComplete': 'Exploration',
557
587
  'agent.toolsFailed': 'Exploration failed',
588
+ 'agent.steps': '{count} step(s)',
589
+ 'agent.stepSep': '·',
558
590
  'agent.changes': 'Changes',
559
591
  'agent.files': '{count} file(s)',
560
592
  'agent.changeAdded': ' Added',
@@ -1,5 +1,6 @@
1
1
  import { unhandled } from './types.js';
2
2
  import { systemCommands } from './system.js';
3
+ import { workspaceCommands } from './workspace.js';
3
4
  import { modeCommands } from './mode.js';
4
5
  import { petCommands } from './pet.js';
5
6
  import { imageCommands } from './image.js';
@@ -20,6 +21,7 @@ import { toolGroupCommands } from './tool-group.js';
20
21
  */
21
22
  export const commandHandlers = [
22
23
  ...systemCommands,
24
+ ...workspaceCommands,
23
25
  ...modeCommands,
24
26
  ...petCommands,
25
27
  ...imageCommands,
@@ -0,0 +1,92 @@
1
+ /**
2
+ * 工作区命令组:/cd
3
+ *
4
+ * `/cd <path>` 切换工作空间(等同 shell 的 cd,但同步移动沙箱边界与会话落盘目录),
5
+ * `/cd` 无参显示当前工作区,`/cd -` 回到上一个工作区。
6
+ *
7
+ * 切换的语义按用户拍板的结果:**等同 /clear**——旧会话先落盘到原工作区,再在新工作区开新会话,
8
+ * 屏幕历史清空、重新回到空会话状态。理由:跨项目的对话上下文混在一条 history 里会让
9
+ * compaction 与 rollback 快照互相污染(旧项目的文件快照对新项目毫无意义),不如干净切开。
10
+ * 与 /clear 的差异只有两点:① 切换前先 save(旧会话完整留在原工作区,不丢最后一轮);
11
+ * ② history[0] 用新工作区重算(AGENTS.md 等按新工作区重新导入)。
12
+ *
13
+ * 底层改写集中在 src/workspace/index.ts(chdir + 沙箱根 + sessionDir 三处一起改),
14
+ * 本文件只负责解析输入、提示、重置 REPL 状态。
15
+ */
16
+ import * as layout from '../../ui/layout.js';
17
+ import { ui } from '../../ui/theme.js';
18
+ import { bannerLines } from '../../ui/render.js';
19
+ import { t } from '../../i18n/index.js';
20
+ import { getAgentMode } from '../../agent/mode.js';
21
+ import { getWorkspaceRoot, resolveWorkspaceTarget, switchWorkspaceRoot } from '../../workspace/index.js';
22
+ import { unhandled, next } from './types.js';
23
+ /** 解析失败原因 → 提示文案的 i18n 键。 */
24
+ const ERROR_KEYS = {
25
+ missing: 'workspace.missing',
26
+ 'not-dir': 'workspace.notDir',
27
+ 'no-previous': 'workspace.noPrevious',
28
+ empty: 'workspace.usage',
29
+ };
30
+ export const workspaceCommands = [
31
+ (ctx) => {
32
+ if (ctx.line !== '/cd' && !ctx.line.startsWith('/cd '))
33
+ return unhandled();
34
+ const arg = ctx.line === '/cd' ? '' : ctx.line.slice('/cd '.length).trim();
35
+ // 无参:只报当前工作区(沙箱根 == 工作区根),不改任何状态。
36
+ if (!arg) {
37
+ layout.contentWrite(`${ui.dim}${t('workspace.current', { root: getWorkspaceRoot() })}${ui.reset}\n`);
38
+ layout.contentWrite(`${ui.dim}${t('workspace.usage')}${ui.reset}\n\n`);
39
+ return next();
40
+ }
41
+ const target = resolveWorkspaceTarget(arg);
42
+ if (!target.ok) {
43
+ const key = ERROR_KEYS[target.error];
44
+ layout.contentWrite(`${ui.yellow}${t(key, { root: target.root })}${ui.reset}\n\n`);
45
+ return next();
46
+ }
47
+ // 已经是当前工作区:什么都不用做(不触发 /clear,避免误清历史)。
48
+ if (pathEqual(target.root, getWorkspaceRoot())) {
49
+ layout.contentWrite(`${ui.dim}${t('workspace.same', { root: target.root })}${ui.reset}\n\n`);
50
+ return next();
51
+ }
52
+ // ① 切前落盘:旧会话完整留在原工作区(/clear 不保存,切换必须保存,否则最后一轮丢失)。
53
+ // 无 user 消息时 save 内部会跳过空会话,不会产生垃圾 session 文件。
54
+ if (ctx.state.currentSessionId) {
55
+ try {
56
+ ctx.runtime.session.save(ctx.history, ctx.state.currentSessionId, ctx.state.queryHistory, ctx.state.lastToolGroups);
57
+ }
58
+ catch {
59
+ // 落盘失败不阻断切换:会话是附加价值,工作区本身必须切过去。
60
+ }
61
+ }
62
+ // ② 改进程级状态(cwd + 沙箱根 + sessionDir)。
63
+ const result = switchWorkspaceRoot(target.root);
64
+ // ③ 重置 REPL 状态(与 /clear 对齐)——新工作区 = 新会话。
65
+ ctx.history.length = 1;
66
+ ctx.history[0] = { role: 'system', content: ctx.buildSystemMessage(getAgentMode() === 'plan') };
67
+ ctx.state.currentSessionId = ctx.runtime.session.clear();
68
+ ctx.state.turnCount = 0;
69
+ ctx.state.lastTurnUsage = undefined;
70
+ ctx.state.queryHistory = [];
71
+ ctx.state.lastToolGroups = [];
72
+ ctx.contextState.lastUsage = undefined;
73
+ ctx.contextState.lifecycleStats = undefined;
74
+ ctx.contextState.ephemeralText = undefined;
75
+ ctx.contextState.activeTools = undefined;
76
+ ctx.contextState.promptAnchor = undefined; // 新工作区不得沿用旧会话的实测锚点
77
+ ctx.attachments.clear();
78
+ // ④ 重画:banner 里的 cwd 已随 chdir 变新值;状态行基线同步刷新(底栏目录显示)。
79
+ layout.clearContent();
80
+ layout.writeBanner(bannerLines(ctx.banner()));
81
+ ctx.refreshStatusBase(ctx.history);
82
+ layout.contentWrite(`${ui.cyan}${t('workspace.switched', { root: result.root })}${ui.reset}\n`);
83
+ layout.contentWrite(`${ui.dim}${t('workspace.cleared', { root: result.previous })}${ui.reset}\n`);
84
+ layout.contentWrite(`${ui.dim}${t('workspace.projectConfigHint')}${ui.reset}\n\n`);
85
+ layout.writeWelcomeBlock(ctx.welcomeLines());
86
+ return next();
87
+ },
88
+ ];
89
+ /** 同目录判定:Windows 大小写不敏感 + 尾部分隔符差异(path.resolve 已归一,故只比大小写)。 */
90
+ function pathEqual(a, b) {
91
+ return process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b;
92
+ }
@@ -12,6 +12,14 @@ export function buildSlashCommands() {
12
12
  { name: '/help', desc: d('commands.help') },
13
13
  { name: '/exit', desc: d('commands.exit') },
14
14
  { name: '/clear', desc: d('commands.clear') },
15
+ {
16
+ name: '/cd',
17
+ desc: d('commands.cd'),
18
+ children: [
19
+ { name: '<path>', value: '/cd ', submit: false, desc: d('commands.cdTo') },
20
+ { name: '-', value: '/cd -', desc: d('commands.cdPrev') },
21
+ ],
22
+ },
15
23
  { name: '/context', desc: d('commands.context') },
16
24
  { name: '/skills', desc: d('commands.skills') },
17
25
  { name: '/skill', desc: '执行某个 skill(/skill <name> [args-json])' },
@@ -162,7 +170,7 @@ export const LLM_ERROR_HINT_KEYS = {
162
170
  };
163
171
  /** /help 的分组:按使用场景归组,组内保持菜单树顺序。未列入的顶层命令兜底进「其他」。 */
164
172
  export const HELP_GROUPS = [
165
- { key: 'help.groupFrequent', names: ['/help', '/clear', '/context', '/compact', '/resume', '/model'] },
173
+ { key: 'help.groupFrequent', names: ['/help', '/clear', '/cd', '/context', '/compact', '/resume', '/model'] },
166
174
  { key: 'help.groupSession', names: ['/sessions', '/rollback', '/memory', '/skills', '/skill', '/init'] },
167
175
  {
168
176
  key: 'help.groupConfig',
@@ -188,6 +188,8 @@ export function runningStateFor(cmd) {
188
188
  return { status: t('running.auto'), placeholder: '…' };
189
189
  case '/clear':
190
190
  return { status: t('running.clear'), placeholder: '…' };
191
+ case '/cd':
192
+ return { status: t('running.workspace'), placeholder: t('running.switching') };
191
193
  case '/theme':
192
194
  return { status: t('running.theme'), placeholder: t('running.chooseTheme') };
193
195
  case '/model':
@@ -3,6 +3,7 @@ import { AsyncLocalStorage } from 'node:async_hooks';
3
3
  import { existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from 'node:fs';
4
4
  import path from 'node:path';
5
5
  import { config, getActiveModel } from '../config/index.js';
6
+ import { getWorkspaceRoot } from '../workspace/index.js';
6
7
  import { isToolRouteGroupName } from '../config/profiles.js';
7
8
  import { truncateDisplay } from '../ui/render.js';
8
9
  import { getDefaultCurrentSessionId, setCurrentSessionId as setDefaultCurrentSessionId, withCurrentSessionIdProvider, } from './state.js';
@@ -49,14 +50,28 @@ function firstUserOf(history) {
49
50
  * `<sessionsRoot>/<id>/session.json`, with `<sessionsRoot>/<id>.json` as a read fallback.
50
51
  */
51
52
  export class SessionStore {
52
- workspaceRoot;
53
+ /**
54
+ * 会话归属的工作区根。**动态**:默认实例用 provider 读 workspace root,
55
+ * 这样 /cd 切换工作区后不必重建 store(重建会丢 currentSessionId 等进程级绑定)。
56
+ */
57
+ get workspaceRoot() {
58
+ return this.workspaceRootProvider();
59
+ }
60
+ workspaceRootProvider;
53
61
  sessionsRootProvider;
54
62
  getModel;
55
63
  currentSessionIdProvider;
56
64
  currentSessionIdSetter;
57
65
  currentSessionId;
58
66
  constructor(options = {}) {
59
- this.workspaceRoot = path.resolve(options.workspaceRoot ?? process.cwd());
67
+ if (typeof options.workspaceRoot === 'function') {
68
+ const provider = options.workspaceRoot;
69
+ this.workspaceRootProvider = () => path.resolve(provider());
70
+ }
71
+ else {
72
+ const fixedWorkspace = path.resolve(options.workspaceRoot ?? process.cwd());
73
+ this.workspaceRootProvider = () => fixedWorkspace;
74
+ }
60
75
  if (typeof options.sessionsRoot === 'function') {
61
76
  const provider = options.sessionsRoot;
62
77
  this.sessionsRootProvider = () => path.resolve(provider());
@@ -202,7 +217,8 @@ export class SessionStore {
202
217
  /** 旧函数 API 的进程级兼容实例;session root 每次读取 config,保留测试和运行时切换语义。 */
203
218
  export const defaultSessionStore = new SessionStore({
204
219
  sessionsRoot: () => config.sessionDir,
205
- workspaceRoot: process.cwd(),
220
+ // 动态读工作区根:/cd 切走后本实例仍归属新工作区(见 src/workspace/index.ts)。
221
+ workspaceRoot: () => getWorkspaceRoot(),
206
222
  getModel: getActiveModel,
207
223
  getCurrentSessionId: getDefaultCurrentSessionId,
208
224
  setCurrentSessionId: (id) => setDefaultCurrentSessionId(id, process.cwd()),
package/dist/ui/batch.js CHANGED
@@ -13,13 +13,13 @@
13
13
  */
14
14
  import { ui } from './theme.js';
15
15
  import { t } from '../i18n/index.js';
16
- import { truncateAnsi } from './render.js';
16
+ import { truncateAnsi, fmtElapsed } from './render.js';
17
+ import { isScrolled, repaintContentLine } from './layout-internal/scroll.js';
18
+ import { replaceLine } from './content.js';
17
19
  /** entry 是否已拿到结果。历史回放构造的 entry 无 done 字段,退化按内容判定。 */
18
20
  function isEntryDone(e) {
19
21
  return e.done === true || !!e.resultSummary || !!e.diffBlock || !!e.failed;
20
22
  }
21
- /** 子 agent 运行中的专属图标:与完成态的实心 ● 区分,也区别于父层「探索」运行态的 ◇。 */
22
- const RUNNING_GLYPH = '◐';
23
23
  const batches = new Map();
24
24
  /** 绝对行索引 → 所属 batch id(仅记录 summary 行;用于鼠标点击反查)。
25
25
  * buffer 行数变化时本表可能漂移——但只在 insertAfter/deleteFrom 后由本模块同步更新,
@@ -72,6 +72,8 @@ export function reset() {
72
72
  expandedBatches.clear();
73
73
  callToBatch.clear();
74
74
  groupChildIndexByCall.clear();
75
+ // 扫光心跳随运行态批自停:batch 全清后 hasRunningBatch 必为 false。
76
+ syncSweepTimer();
75
77
  }
76
78
  /** 新建一个 batch(在 agent 拿到第一条 onToolHeader 时调)。返回 id。
77
79
  * label 可选:自定义摘要行标签(子 agent 批用),缺省按完成态取 agent.tools*。
@@ -94,6 +96,9 @@ export function beginBatch(label, opts) {
94
96
  groupChildIndex: opts?.groupChildIndex,
95
97
  running: opts?.running ?? false,
96
98
  });
99
+ // 运行态批 → 扫光心跳开始(在 beginBatch 就置 running 的路径也能启动)。
100
+ if (opts?.running)
101
+ syncSweepTimer();
97
102
  return id;
98
103
  }
99
104
  /** 登记 tool_call id → 所属 batch。结果回填按 id 归位(并行时 currentBatchId 会漂移),
@@ -120,6 +125,8 @@ export function recordCall(id, name, callSummary, callId) {
120
125
  b.entries.push({ name, callSummary, resultSummary: '', diffBlock: null });
121
126
  if (callId && b.groupParent)
122
127
  groupChildIndexByCall.set(callId, b.entries.length - 1);
128
+ // 新增 entry = 本批重新「在飞」→ 扫光心跳起(此前收口时可能已停)。
129
+ syncSweepTimer();
123
130
  }
124
131
  /** 查询某 sub-agent 调用在所属组容器批 entries 中的序号(供 spawn.ts 建子批时定锚点)。 */
125
132
  export function getGroupChildIndex(callId) {
@@ -183,43 +190,224 @@ export function recordResult(id, name, resultSummary, diffBlock, fullOutput, fai
183
190
  }
184
191
  }
185
192
  // ── 摘要行文本生成 ──
186
- /** 把 entry 列表压缩成一行摘要。 */
193
+ /**
194
+ * 运行态「符号即进度」字形:符号本身编码 completed/total,不必旁读数字。
195
+ *
196
+ * 全部取自 U+25A0–25FF Geometric Shapes——与 `●`(U+25CF)同块。本模块已实测记录过
197
+ * 「本机等宽字体对 Geometric Shapes 按 1 列渲染、对 Box Drawing 按 2 列」(见下方
198
+ * CARET_COLLAPSED 注释),故此族是本终端里唯一有实测依据的安全符号集。
199
+ * 按 1/4 档进阶:◇(0) ◔(1/4) ◑(1/2) ◕(3/4) ◆(满)。档位边界与 total 无关,
200
+ * 故 total 增长时档位不会回退(填满 3/5 与 3/9 同取 ◑)。
201
+ */
202
+ const STEP_GLYPHS = ['◇', '◔', '◑', '◕', '◆'];
203
+ /** 收尾态字形:全成 ◆(实心=落定,与运行态的 ◇◔◑◕ 同族)、部分失败 !、全失败 ×。 */
204
+ const DONE_GLYPH = '◆';
205
+ const PARTIAL_FAIL_GLYPH = '!';
206
+ const ALL_FAIL_GLYPH = '×';
207
+ /** 耗时色阶(ms):≥30s 转黄(该留意),≥60s 转红(已超常规阈值)。 */
208
+ const ELAPSED_WARN_MS = 30_000;
209
+ const ELAPSED_ALERT_MS = 60_000;
210
+ /** 扫光:高亮带宽度(字符数)。标签很短(4 字),带宽 3 既能看出"扫"又不整行同时亮。 */
211
+ const SWEEP_BAND = 3;
212
+ /** 扫光心跳间隔(ms):约 17fps,每拍推进 1 字符,4 字标签一个来回约 0.5s。 */
213
+ const SWEEP_TICK_MS = 60;
214
+ /**
215
+ * 运行态摘要行的扫光帧号。**逐帧只改前景 SGR,不增删字符**——可见宽度恒定,
216
+ * `sanitizeRow` / `truncateAnsi` 的列宽钳制与 buffer 行索引全部不受影响。
217
+ *
218
+ * 这是本方案能落在纯字符串层的前提:换成背景色块或增删字符都会让「一条逻辑行 =
219
+ * 一条物理行」的模型失效,进而打乱 repaintViewport 的 CUP 寻址。
220
+ */
221
+ let _sweepFrame = 0;
222
+ let _sweepTimer = null;
223
+ /**
224
+ * 最近一次 showLiveBatch 收到的 layout —— 仅用于**判断**当前是否有可用 UI 通道
225
+ * (`_sweepLayout != null`)。扫光落屏走 `content.replaceLine` + `repaintContentLine` 单行路径,
226
+ * 不走 layout 方法:contentWrite 那版末尾会整屏 repaintViewport,16fps 逐帧调会持续闪。
227
+ * 存 layout 而不自己 import 判断 TUI 是否激活,是沿用「agent 侧注入」的既有形态。
228
+ */
229
+ let _sweepLayout = null;
230
+ /**
231
+ * 批是否「在飞」(需要扫光与实时耗时)。判据是**已收口即落定**:
232
+ * `endBatch` 必设 finishedAt,故先看它 —— 否则任何「endBatch 了但 running 标志残留」
233
+ * 的路径会让扫光永远转下去(实测:探针手动置 running 后收口,扫光仍在写)。
234
+ * 收口标志优先于 running,是因为 running 在语义上只描述"这一批还没落定"。
235
+ * `recordCall` 会把 finishedAt 清回 undefined(允许累计探索后再追加工具),重新在飞时自然复活。
236
+ */
237
+ function isBatchLive(b) {
238
+ if (b.finishedAt != null)
239
+ return false;
240
+ if (b.running)
241
+ return true;
242
+ return b.entries.length > 0 && !b.entries.every(isEntryDone);
243
+ }
244
+ /** 是否有在飞批——扫光只服务它们。 */
245
+ function hasLiveBatch() {
246
+ for (const b of batches.values())
247
+ if (isBatchLive(b))
248
+ return true;
249
+ return false;
250
+ }
251
+ /** 扫光可用性:TUI(非 TTY 输出纯文本,不能塞动画转义)+ 有在飞批 + 有可写回的 layout。 */
252
+ function sweepEnabled() {
253
+ return ui.isTTY && _sweepLayout != null && hasLiveBatch();
254
+ }
255
+ /**
256
+ * 同步扫光心跳开关(建批/收批时调)。心跳随最后一个运行态批结束自停,不常驻空转。
257
+ */
258
+ export function syncSweepTimer() {
259
+ if (!sweepEnabled()) {
260
+ if (_sweepTimer) {
261
+ clearInterval(_sweepTimer);
262
+ _sweepTimer = null;
263
+ }
264
+ return;
265
+ }
266
+ _sweepTimer ??= (() => {
267
+ const timer = setInterval(() => {
268
+ if (!sweepEnabled()) {
269
+ syncSweepTimer();
270
+ return;
271
+ }
272
+ // 滚动回看时冻结视口:写回该行会画出不含最新帧的窗口、造成闪烁。帧号照常推进,回底后不跳帧。
273
+ if (isScrolled()) {
274
+ _sweepFrame++;
275
+ return;
276
+ }
277
+ _sweepFrame++;
278
+ const layout = _sweepLayout;
279
+ if (!layout)
280
+ return;
281
+ for (const b of batches.values()) {
282
+ if (b.summaryAbsIdx < 0)
283
+ continue;
284
+ if (!isBatchLive(b))
285
+ continue; // 已落定的批:耗时为定值,重画纯属浪费
286
+ const line = truncateAnsi(buildSummaryLine(b, true), maxCols);
287
+ // ① 直接写缓冲(不经过 layout.contentReplaceLine —— 那一版末尾会整屏 repaintViewport,
288
+ // 16fps 下让运行中的列表持续闪,正是用户反馈的观感问题);
289
+ // ② 只重画这一行。两步都是 O(1)-ish,与内容区长度无关。
290
+ replaceLine(b.summaryAbsIdx, line);
291
+ repaintContentLine(b.summaryAbsIdx, line);
292
+ }
293
+ }, SWEEP_TICK_MS);
294
+ timer.unref?.();
295
+ return timer;
296
+ })();
297
+ }
298
+ /**
299
+ * 计算本帧高亮带覆盖的字符区间 `[start, end)`(纯函数,导出供帧推进/带宽测试)。
300
+ *
301
+ * head 在一个周期内 0 → n → 0:扫进(0..n)后扫回(n..2n),使带子从右端出去、从左端
302
+ * 回来而无跳变(单向回绕会让"扫到底"瞬间跳回左边,读成闪烁)。
303
+ */
304
+ export function sweepBandRange(charCount, frame, band = SWEEP_BAND) {
305
+ if (charCount <= 0)
306
+ return [0, 0];
307
+ const cycle = charCount * 2;
308
+ const pos = ((frame % cycle) + cycle) % cycle; // 帧号负数也安全(不要依赖调用方已归一)
309
+ const head = pos <= charCount ? pos : cycle - pos;
310
+ const w = Math.min(band, charCount);
311
+ // end 由 head 推得,而 head 可等于 charCount(带子探到串尾之外一格)。
312
+ // 必须钳到 charCount:否则本函数会返回越界区间,「每个字符都被扫到」等不变量随之失准。
313
+ return [Math.max(0, Math.min(head - w + 1, charCount - 1)), Math.min(head + 1, charCount)];
314
+ }
315
+ /**
316
+ * 按帧给文本套扫光(纯函数,导出供「可见宽度不变量」测试)。
317
+ *
318
+ * **只改前景 SGR、不增删字符**是硬约束:调用方(sanitizeRow / truncateAnsi)按字符列宽
319
+ * 钳制行宽,一旦这里动了可见字符,「一条逻辑行 = 一条物理行」的模型就会失效,
320
+ * 进而打乱 repaintViewport 的 CUP 寻址。故本函数只做 SGR 开合,输出宽度恒等于输入。
321
+ *
322
+ * 颜色只在**状态切换处**写出(连续同色字符共用一个 SGR),避免逐字符刷 SGR 撑大输出。
323
+ */
324
+ export function sweepRender(text, base, hi, frame) {
325
+ const chars = [...text];
326
+ const [start, end] = sweepBandRange(chars.length, frame);
327
+ let out = '';
328
+ let cur = '';
329
+ for (let i = 0; i < chars.length; i++) {
330
+ const want = i >= start && i < end ? hi : base;
331
+ if (want !== cur) {
332
+ out += want;
333
+ cur = want;
334
+ }
335
+ out += chars[i];
336
+ }
337
+ // 收尾回常态色:调用方随后会补 reset,但这里显式闭合可让本函数的输出自成一段
338
+ // (测试/history 回放等直接消费返回值的场景不必再关心尾色)。
339
+ return cur === base ? out : `${out}${base}`;
340
+ }
341
+ /**
342
+ * 给标签文本渲染扫光高亮。`base` 是标签的常态色(运行态 accent / 失败态 red 等),
343
+ * 高亮带扫过的字符临时换成 `ui.sweepHighlight`。
344
+ *
345
+ * 中文按**字符**而非列推进:按列推进需要把列偏移折算回字符边界,折算后相邻帧会落在
346
+ * 同一字符上、视觉上「卡一拍」;按字推进牺牲严格等速,换取每帧必有可见位移。
347
+ */
348
+ function sweepText(text, base) {
349
+ if (!sweepEnabled())
350
+ return text;
351
+ return sweepRender(text, base, ui.sweepHighlight, _sweepFrame);
352
+ }
353
+ /**
354
+ * 单行摘要。结构(全部单行、行内样式,不改行数):
355
+ * ` ◆ 探索 · 5 步 · 16.8s run_command ×2 glob ×1`
356
+ * 分项之间用 dim 的 `·` 分隔(比裸空格更有"字段感"),工具名去引号、单次调用不带计数。
357
+ */
187
358
  function buildSummaryLine(record, live = false) {
188
359
  const prefix = record.indent ?? '';
189
360
  const entries = record.entries;
190
361
  if (entries.length === 0) {
191
362
  return `${prefix} ${ui.dim}│${ui.reset} ${ui.bold}${ui.accent}◇${ui.reset} ${ui.dim}No tools${ui.reset}`;
192
363
  }
193
- // N>1:同类合并 "read_file 3, glob 1, grep 1"
194
- const counts = new Map();
195
- for (const e of entries)
196
- counts.set(e.name, (counts.get(e.name) ?? 0) + 1);
197
- const parts = [];
198
- for (const [n, c] of counts)
199
- parts.push(`${n} ${c}`);
200
364
  const completed = entries.filter(isEntryDone).length;
201
365
  const failedCount = entries.filter((e) => e.failed).length;
202
- // 工具本身完成就立即显示完成态,不等待整轮正文流完/onDone。
203
- // 单项失败不代表整批失败:执行中优先展示进度;完成后区分部分失败与全部失败。
204
366
  const finished = completed >= entries.length;
205
367
  const allFailed = finished && failedCount === entries.length;
206
368
  const partiallyFailed = finished && failedCount > 0 && !allFailed;
207
- const symbol = record.running ? RUNNING_GLYPH : !finished ? '◇' : allFailed ? '×' : partiallyFailed ? '!' : '●';
208
- const color = record.running
209
- ? ui.accent
210
- : !finished
211
- ? ui.accent
212
- : allFailed
213
- ? ui.red
214
- : partiallyFailed
215
- ? ui.yellow
216
- : ui.green;
217
- const label = !finished ? t('agent.toolsRunning') : allFailed ? t('agent.toolsFailed') : t('agent.toolsComplete');
218
- const progress = live && !finished ? ` ${completed}/${entries.length}` : ` ${entries.length}`;
219
- const elapsedMs = record.finishedAt ? record.finishedAt - record.startedAt : 0;
220
- const elapsed = record.finishedAt ? ` ${elapsedMs < 100 ? '<0.1s' : `${(elapsedMs / 1000).toFixed(1)}s`}` : '';
221
- const displayLabel = record.label ?? label;
222
- return `${prefix} ${ui.bold}${color}${symbol}${ui.reset} ${displayLabel}${progress}${elapsed} ${ui.dim}${parts.join(' ')}${ui.reset}`;
369
+ const running = !finished || record.running;
370
+ // 符号即进度:运行态按 completed/total 取档(子 agent 批额外 +1 档以示"整个 agent 还在跑")。
371
+ const stepIdx = Math.min(4, Math.floor((completed / entries.length) * 5));
372
+ const symbol = running
373
+ ? STEP_GLYPHS[Math.min(4, record.running ? stepIdx + 1 : stepIdx)]
374
+ : allFailed
375
+ ? ALL_FAIL_GLYPH
376
+ : partiallyFailed
377
+ ? PARTIAL_FAIL_GLYPH
378
+ : DONE_GLYPH;
379
+ const color = running ? ui.accent : allFailed ? ui.red : partiallyFailed ? ui.yellow : ui.accent;
380
+ const labelText = record.label ?? (running ? t('agent.toolsRunning') : allFailed ? t('agent.toolsFailed') : t('agent.toolsComplete'));
381
+ // 扫光只挂在**执行中**刷新的那一帧(live):收尾定稿(endBatch, live=false)后标签应静止落定。
382
+ const labelSeg = running && live ? sweepText(labelText, color) : labelText;
383
+ // 步数:运行态用主题色强调进度,收尾退回灰(信息已结算,不抢主色)。
384
+ const stepsSeg = running
385
+ ? `${ui.accent}${t('agent.steps', { count: entries.length })}${ui.reset}`
386
+ : `${ui.dim}${t('agent.steps', { count: entries.length })}${ui.reset}`;
387
+ // 耗时色阶 ≥30s 黄 / ≥60s 红:长步骤自己跳出来,不再和 6.3s 等重。
388
+ // 在飞态显**实时**耗时(而非等到收尾才出一个死数字):工具阻塞几十秒时,
389
+ // 「这一行还在走」本身就是最重要的信息(实测场景:单条 run_command 干等 78s,
390
+ // 期间摘要行只有静态的计数 1,用户无法判断是卡死还是在跑)。
391
+ const liveElapsed = !record.finishedAt && running;
392
+ const elapsedMs = record.finishedAt
393
+ ? record.finishedAt - record.startedAt
394
+ : liveElapsed
395
+ ? Date.now() - record.startedAt
396
+ : 0;
397
+ const elapsedColor = elapsedMs >= ELAPSED_ALERT_MS ? ui.red : elapsedMs >= ELAPSED_WARN_MS ? ui.yellow : ui.dim;
398
+ const elapsedSeg = record.finishedAt || liveElapsed
399
+ ? ` ${ui.dim}·${ui.reset} ${elapsedColor}${elapsedMs < 100 ? '<0.1s' : fmtElapsed(elapsedMs)}${ui.reset}`
400
+ : '';
401
+ // 工具分项:同类合并;单次调用不带计数(「run_command 1」的尾 1 是纯噪声),多次才写 ×N。
402
+ const counts = new Map();
403
+ for (const e of entries)
404
+ counts.set(e.name, (counts.get(e.name) ?? 0) + 1);
405
+ const tools = [...counts].map(([n, c]) => (c > 1 ? `${n} ×${c}` : n));
406
+ const toolsSeg = tools.length ? ` ${ui.dim}${tools.join(' ')}${ui.reset}` : '';
407
+ // 符号与标签之间只留 1 空格:Geometric Shapes 字形窄于字符格(左右各约 0.2 列),
408
+ // 再叠 3 空格会读出「符号孤立在左」的疏离感(实测:3 空格时光学间距≈3.2 列)。
409
+ const head = `${ui.bold}${color}${symbol}${ui.reset} ${ui.bold}${color}${labelSeg}${ui.reset} ${stepsSeg}${elapsedSeg}`;
410
+ return `${prefix}${head}${toolsSeg}`;
223
411
  }
224
412
  // ── 展开/折叠 ──
225
413
  /** 把 batch 的详情行展开成自洽行数组(供 layout.contentInsertAfter 走 mid-buffer 插入)。
@@ -337,6 +525,8 @@ export function endBatch(id, layout) {
337
525
  if (!b)
338
526
  return;
339
527
  b.finishedAt ??= Date.now();
528
+ // 本批收口 → 摘要行变「落定」态(◆ + 定值耗时),不再扫光。若无其它在飞批则心跳自停。
529
+ syncSweepTimer();
340
530
  if (b.summaryAbsIdx >= 0) {
341
531
  layout.contentReplaceLine?.(b.summaryAbsIdx, buildSummaryLine(b));
342
532
  // 点击命中在 showLiveBatch 首次落盘时即已登记(运行态可展开);这里幂等重登,
@@ -376,6 +566,8 @@ export function showLiveBatch(id, layout) {
376
566
  const b = batches.get(id);
377
567
  if (!b)
378
568
  return;
569
+ // 登记 layout:扫光逐帧重画要靠它把新帧写回摘要行(见 _sweepLayout 注释)。
570
+ _sweepLayout = layout;
379
571
  const summary = buildSummaryLine(b, true);
380
572
  if (b.summaryAbsIdx < 0) {
381
573
  // 子批:摘要行插到父批已渲染块的正下方(而非 buffer 末尾),
@@ -428,6 +620,8 @@ export function showLiveBatch(id, layout) {
428
620
  }
429
621
  // 展开态的运行态同步:已渲染 entry 的结果回填原位替换 + 新 entry 追加。
430
622
  syncLiveExpanded(b, layout);
623
+ // 本批可能在飞(新 entry 刚入、结果未回)→ 确保扫光心跳在跑;已收口则由上一句的判定自停。
624
+ syncSweepTimer();
431
625
  }
432
626
  /** 展开态 batch 的运行态同步(每次 showLiveBatch 后调):不展开/未落盘时 no-op。 */
433
627
  function syncLiveExpanded(b, layout) {
@@ -800,11 +994,13 @@ export function setBatchLabel(id, label) {
800
994
  if (b)
801
995
  b.label = label;
802
996
  }
803
- /** 设置/清除运行态标志:running=true 时摘要行用「运行中」专属图标,收尾时置 false。 */
997
+ /** 设置/清除运行态标志:running=true 时摘要行用「运行中」专属图标,收尾时置 false。
998
+ * 同时驱动扫光心跳——置位即开(若尚未开),清除后若无其它运行态批则自停。 */
804
999
  export function setBatchRunning(id, running) {
805
1000
  const b = batches.get(id);
806
1001
  if (b)
807
1002
  b.running = running;
1003
+ syncSweepTimer();
808
1004
  }
809
1005
  /** history 回放支持 ── */
810
1006
  /** 把已构造好的 BatchEntry[] 落成可切换摘要行(用于 renderHistory 回放)。
@@ -32,7 +32,7 @@ import { contentWrite, contentInsertAfter, contentDeleteFrom, contentReplaceLine
32
32
  export { isTuiActive, getGeo, setRegion, contentMode, isStreamingPaused } from './screen.js';
33
33
  export { drawStatusBar, setStatus, stopTurnTimer, startTurnTimerIfRunning, paintLiveAtCursor, clearLiveAtCursor, setLiveUsage, setStatusBase, } from './statusbar.js';
34
34
  export { paintInput, paintRunningInput, repaint } from './input-paint.js';
35
- export { isScrolled, repaintViewport, scrollBy, scrollWheel, resetScroll, lockScrollToBottom, unlockScroll, isScrollLocked, } from './scroll.js';
35
+ export { isScrolled, repaintViewport, repaintContentLine, scrollBy, scrollWheel, resetScroll, lockScrollToBottom, unlockScroll, isScrollLocked, } from './scroll.js';
36
36
  export { contentWrite, writeBanner, rewriteBanner, bannerHeight, contentWriteMd, contentWriteMdOnce, clearContent, rewindContent, writeWelcomeBlock, dismissWelcomeBlock, contentInsertAfter, contentDeleteFrom, totalRows, isLastContentRowBlank, normalizeMutationBoundary, normalizeInputBoundary, contentReplaceLine, notifyContentReset, } from './content-write.js';
37
37
  // ── 裸 console 防御 ──
38
38
  // 第三方库(如 openai SDK)可能用 console.log 直写 stdout,在 RUNNING 态会落到光标所在的底栏
@@ -29,6 +29,38 @@ export function viewportAbsStart() {
29
29
  export function screenRowToAbsLine(row) {
30
30
  return viewportAbsStart() + (row - 1);
31
31
  }
32
+ /**
33
+ * 只重画**一条**已提交内容行(单行 CUP + clearLine + 行文本 + 光标归位)。
34
+ *
35
+ * 与 repaintViewport 的区别是「写多少」:后者把整个内容区 h 行全刷一遍。批量摘要行的
36
+ * 动态刷新(如扫光逐帧换 SGR)若走整屏重画,在 16fps 下会让运行中的列表持续闪 ——
37
+ * 这正是用户实测反馈的观感问题。单行重画只碰目标行,无闪烁且开销恒定。
38
+ *
39
+ * 前置条件:调用方保证该行**当前在视口内**(offset=0 的实时尾屏天然满足)且行宽已钳到
40
+ * cols(identity: 行内只换 SGR,可见宽度不变)。滚动回看态一律不走这里:那里应由上层
41
+ * 直接跳过刷新(冻结视口),而非在这里猜屏幕映射。
42
+ */
43
+ export function repaintContentLine(absIdx, line) {
44
+ if (!state.active)
45
+ return;
46
+ const g = getGeo();
47
+ const row = absIdx - viewportAbsStart() + 1;
48
+ if (row < 1 || row > g.contentBottom)
49
+ return; // 不在视口内:不猜映射,交由上层处理
50
+ // 已提交行自带完整 SGR 前缀(breakRow 落盘的是 `rowStartSgr + curRaw + reset`,见 content.ts),
51
+ // 故单行重画无需补前缀,只需按当前列宽钳宽(窄窗兜底,防 auto-wrap 破坏「一行=一物理行」)。
52
+ const text = truncateAnsi(line, g.cols);
53
+ let out = cup(row, 1) + esc.clearLine + text;
54
+ // 与 repaintViewport 同约定:运行态光标归输入框(供 IME 锚定),INPUT 态归续写位。
55
+ if (state.mode === 'running') {
56
+ const c = runningCaretPos();
57
+ out += cup(c.row, c.col);
58
+ }
59
+ else {
60
+ out += cup(state.scrollOffset === 0 ? state.contentRow : g.contentBottom, state.scrollOffset === 0 ? state.contentCol : 1);
61
+ }
62
+ stdout.write(out);
63
+ }
32
64
  /**
33
65
  * 重画内容区 viewport:按 scrollOffset 取缓冲尾窗,逐行 cup+clearline+rowtext 映射到屏 1..contentBottom。
34
66
  * offset=0 即尾窗(== 实时屏,resize / 回尾时用)。清行含 contentBottom——顺带擦 WT 边距漏影(状态行重复)。
package/dist/ui/theme.js CHANGED
@@ -29,6 +29,7 @@ const DEFAULT = {
29
29
  brightCyan: '\x1B[38;2;86;182;194m',
30
30
  brightMagenta: '\x1B[38;2;198;120;221m',
31
31
  accent: '\x1B[38;2;86;182;194m', // 与 cyan 同源(One Dark):logo/标题/输入框顶线/选中项统一承载
32
+ sweepHighlight: '\x1B[38;2;130;225;240m', // 亮青:深底上扫过 accent 文字时跳出
32
33
  userBg: '\x1B[48;2;72;78;90m', // 统一灰白用户消息底(黑底终端可见)
33
34
  // diff 行底色:One Dark bg #282c34 上加 ~14% 亮度的对应色,够辨识但不刺眼
34
35
  addBg: '\x1B[48;2;44;62;42m', // 偏暗绿(One Dark green(152,195,121)暗化)
@@ -53,6 +54,7 @@ const THEMES = {
53
54
  brightCyan: '\x1B[38;2;42;161;152m',
54
55
  brightMagenta: '\x1B[38;2;108;113;196m',
55
56
  accent: '\x1B[38;2;38;139;210m', // Solarized blue:浅底下更醒目的强调
57
+ sweepHighlight: '\x1B[38;2;108;113;196m', // Solarized violet:浅底上比蓝更深一档才看得出「扫过」
56
58
  userBg: '\x1B[48;2;72;78;90m', // 统一灰白用户消息底(黑底终端可见)
57
59
  // diff 行底色:Solarized Light base2(238,232,213)上贴同色族浅 tint,
58
60
  // 比直接用 base1 更柔,跟深 fg(red/green)对比充足
@@ -70,6 +72,7 @@ const THEMES = {
70
72
  brightCyan: '\x1B[38;2;147;161;161m',
71
73
  brightMagenta: '\x1B[38;2;108;113;196m',
72
74
  accent: '\x1B[38;2;42;161;152m', // Solarized cyan(深底版):暗底上跳出
75
+ sweepHighlight: '\x1B[38;2;120;215;205m', // 亮青:同族提亮
73
76
  userBg: '\x1B[48;2;72;78;90m', // 统一灰白用户消息底(黑底终端可见)
74
77
  // diff 行底色:Solarized Dark base03(0,43,54)上加对应色族暗 tint
75
78
  addBg: '\x1B[48;2;20;50;38m',
@@ -86,6 +89,7 @@ const THEMES = {
86
89
  brightCyan: '\x1B[38;2;142;192;124m',
87
90
  brightMagenta: '\x1B[38;2;211;134;155m',
88
91
  accent: '\x1B[38;2;250;189;47m', // Gruvbox yellow(主题色)
92
+ sweepHighlight: '\x1B[38;2;255;235;150m', // 浅黄:深红底上扫过黄字提亮
89
93
  userBg: '\x1B[48;2;72;78;90m', // 统一灰白用户消息底(黑底终端可见)
90
94
  // diff 行底色:Gruvbox dark bg(40,40,40)上贴暗 bg0_a 风格
91
95
  addBg: '\x1B[48;2;40;55;30m',
@@ -102,6 +106,7 @@ const THEMES = {
102
106
  brightCyan: '\x1B[38;2;136;192;208m',
103
107
  brightMagenta: '\x1B[38;2;180;142;173m',
104
108
  accent: '\x1B[38;2;136;192;208m', // Nord 浅冰蓝(主题色)
109
+ sweepHighlight: '\x1B[38;2;190;230;240m', // 更亮的冰蓝:Nord polar night 上跳出
105
110
  userBg: '\x1B[48;2;72;78;90m', // 统一灰白用户消息底(黑底终端可见)
106
111
  // diff 行底色:Nord polar night(46,52,64)上贴对应色族暗 tint
107
112
  addBg: '\x1B[48;2;46;66;52m',
@@ -121,6 +126,7 @@ const THEMES = {
121
126
  brightCyan: '\x1B[38;2;160;220;220m',
122
127
  brightMagenta: '\x1B[38;2;245;165;215m',
123
128
  accent: '\x1B[38;2;255;170;60m', // 南瓜橙(主题主色:logo/标题/输入框顶线/选中项)
129
+ sweepHighlight: '\x1B[38;2;255;225;165m', // 浅杏橙:深暖底上扫过提亮
124
130
  userBg: '\x1B[48;2;72;78;90m', // 统一灰白用户消息底(黑底终端可见)
125
131
  // diff 行底色:暖深棕底上贴对应色族暗 tint,跟 fg 配对柔和可辨
126
132
  addBg: '\x1B[48;2;55;70;35m',
@@ -139,6 +145,7 @@ const THEMES = {
139
145
  brightCyan: '\x1B[38;2;170;220;220m',
140
146
  brightMagenta: '\x1B[38;2;250;160;200m',
141
147
  accent: '\x1B[38;2;230;90;150m', // 玫粉(主题主色)
148
+ sweepHighlight: '\x1B[38;2;255;175;210m', // 亮玫粉:深紫底上扫过提亮
142
149
  userBg: '\x1B[48;2;72;78;90m', // 统一灰白用户消息底(黑底终端可见)
143
150
  // diff 行底色:深紫底贴对应色族暗 tint,绿暗化偏橄榄、红暗化偏紫红
144
151
  addBg: '\x1B[48;2;50;60;42m',
@@ -157,6 +164,7 @@ const THEMES = {
157
164
  brightCyan: '\x1B[38;2;140;230;210m',
158
165
  brightMagenta: '\x1B[38;2;210;170;230m',
159
166
  accent: '\x1B[38;2;80;210;140m', // 翡翠绿(主题主色)
167
+ sweepHighlight: '\x1B[38;2;170;245;205m', // 亮翡翠:深绿底上扫过提亮
160
168
  userBg: '\x1B[48;2;72;78;90m', // 统一灰白用户消息底(黑底终端可见)
161
169
  // diff 行底色:深绿底贴对应色族暗 tint,绿暗化偏深绿、红暗化偏暗红
162
170
  addBg: '\x1B[48;2;30;55;40m',
@@ -175,6 +183,7 @@ const THEMES = {
175
183
  brightCyan: '\x1B[38;2;170;210;200m',
176
184
  brightMagenta: '\x1B[38;2;240;180;210m',
177
185
  accent: '\x1B[38;2;255;200;80m', // 琥珀金黄(主题主色)
186
+ sweepHighlight: '\x1B[38;2;255;240;175m', // 浅金:深棕底上扫过提亮
178
187
  userBg: '\x1B[48;2;72;78;90m', // 统一灰白用户消息底(黑底终端可见)
179
188
  // diff 行底色:深棕底贴对应色族暗 tint,绿暗化偏橄榄、红暗化偏暗棕红
180
189
  addBg: '\x1B[48;2;50;55;25m',
@@ -193,6 +202,7 @@ const THEMES = {
193
202
  brightCyan: '\x1B[38;2;180;230;230m',
194
203
  brightMagenta: '\x1B[38;2;210;180;250m',
195
204
  accent: '\x1B[38;2;180;150;230m', // 薰衣草紫(主题主色)
205
+ sweepHighlight: '\x1B[38;2;225;210;255m', // 淡紫白:深紫底上扫过提亮
196
206
  userBg: '\x1B[48;2;72;78;90m', // 统一灰白用户消息底(黑底终端可见)
197
207
  // diff 行底色:深紫底贴对应色族暗 tint,绿暗化偏冷绿、红暗化偏冷紫红
198
208
  addBg: '\x1B[48;2;38;46;42m',
@@ -211,6 +221,7 @@ const THEMES = {
211
221
  brightCyan: '\x1B[38;2;170;225;215m',
212
222
  brightMagenta: '\x1B[38;2;250;170;210m',
213
223
  accent: '\x1B[38;2;255;120;100m', // 珊瑚红(主题主色)
224
+ sweepHighlight: '\x1B[38;2;255;185;165m', // 浅珊瑚:深棕红底上扫过提亮
214
225
  userBg: '\x1B[48;2;72;78;90m', // 统一灰白用户消息底(黑底终端可见)
215
226
  // diff 行底色:深棕红底贴对应色族暗 tint,绿暗化偏橄榄、红暗化偏暗棕红
216
227
  addBg: '\x1B[48;2;50;55;30m',
@@ -297,6 +308,9 @@ export const ui = {
297
308
  get accent() {
298
309
  return color('accent');
299
310
  },
311
+ get sweepHighlight() {
312
+ return color('sweepHighlight');
313
+ },
300
314
  get userBg() {
301
315
  return color('userBg');
302
316
  },
@@ -0,0 +1,89 @@
1
+ /**
2
+ * 工作区(workspace)切换的**唯一入口**:进程级单例的集中改写。
3
+ *
4
+ * 为什么需要这个模块:
5
+ * 「当前工作区」在 mocode 里不是一个变量,而是散落在几处进程级单例上的状态——
6
+ * 1. `process.cwd()`:memory 存储、skill 扫描、AGENTS.md、rollback 默认实例都直接读它;
7
+ * 2. sandboxRoot(sandbox/root.ts):文件操作边界(jail / 工具 cwd / run_command cwd / 权限判定);
8
+ * 3. `config.sessionDir`:会话落盘目录(启动时按 cwd 算好的固定值)。
9
+ * 只改其中任意一个都会留下「半个工作区」——比如只 setSandboxRoot 不 chdir,
10
+ * 文件读写走新目录而 memory / skill / AGENTS.md 还读旧目录。所以这里一次性改全部三处,
11
+ * 且顺序固定(先 chdir 后改 config,避免中途失败留下不一致)。
12
+ *
13
+ * 依赖方向:本模块只依赖 config 与 sandbox/root 两个叶子,不 import session/repl/agent,
14
+ * 避免出现环;session store 反过来**读**本模块(见 session/store.ts 的 workspaceRoot provider)。
15
+ *
16
+ * 未覆盖(有意):项目级 `.env` / `.mocode/config` 只在启动时加载一次,切换后不重新加载
17
+ * ——否则切个目录就可能把模型/baseURL 换掉,比不重载更危险。命令层会提示「如需生效请重启」。
18
+ */
19
+ import fs from 'node:fs';
20
+ import os from 'node:os';
21
+ import path from 'node:path';
22
+ import { config } from '../config/index.js';
23
+ import { getSandboxRoot, setSandboxRoot } from '../sandbox/root.js';
24
+ /** 当前工作区根(绝对路径)。沙箱根优先,未初始化回退 process.cwd()。 */
25
+ export function getWorkspaceRoot() {
26
+ return getSandboxRoot() ?? process.cwd();
27
+ }
28
+ /**
29
+ * 上一个工作区根,供 `/cd -` 来回切换。模块级单例(一个进程只有一个 REPL 主循环),
30
+ * 与 sandboxRoot 同生命周期;切换失败时不更新,保证 `-` 永远指向一个真实去过的目录。
31
+ */
32
+ let previousRoot = null;
33
+ /** 展开 `~`(仅前缀,仅 POSIX/Windows 通用形式);其余原样返回。 */
34
+ function expandHome(input) {
35
+ if (input === '~')
36
+ return os.homedir();
37
+ if (input.startsWith('~/') || input.startsWith('~\\'))
38
+ return path.join(os.homedir(), input.slice(2));
39
+ return input;
40
+ }
41
+ /**
42
+ * 解析切换目标:支持 `~`、相对路径(相对当前工作区根)、`-`(上一个工作区)。
43
+ * 只做解析与存在性校验,**不改任何状态**——调用方据此决定提示文案。
44
+ */
45
+ export function resolveWorkspaceTarget(input) {
46
+ const raw = input.trim();
47
+ if (!raw)
48
+ return { ok: false, error: 'empty', root: '' };
49
+ if (raw === '-') {
50
+ if (!previousRoot)
51
+ return { ok: false, error: 'no-previous', root: '' };
52
+ if (!isDirectory(previousRoot))
53
+ return { ok: false, error: 'missing', root: previousRoot };
54
+ return { ok: true, root: previousRoot };
55
+ }
56
+ // 相对路径按当前工作区根解析(而非 cwd):--sandbox-root 启动时两者可能不同,
57
+ // 用户在 TUI 里看到的"当前目录"就是工作区根,相对路径必须与之对齐。
58
+ const root = path.resolve(getWorkspaceRoot(), expandHome(raw));
59
+ if (!fs.existsSync(root))
60
+ return { ok: false, error: 'missing', root };
61
+ if (!isDirectory(root))
62
+ return { ok: false, error: 'not-dir', root };
63
+ return { ok: true, root };
64
+ }
65
+ function isDirectory(p) {
66
+ try {
67
+ return fs.statSync(p).isDirectory();
68
+ }
69
+ catch {
70
+ return false;
71
+ }
72
+ }
73
+ /**
74
+ * 切换工作区:chdir + 沙箱根 + 会话目录,三处一起改。
75
+ *
76
+ * 返回切换前后路径供命令层提示。调用方(命令层)负责保存旧会话、开新会话、重画 banner。
77
+ */
78
+ export function switchWorkspaceRoot(root) {
79
+ const previous = getWorkspaceRoot();
80
+ const abs = path.resolve(root);
81
+ // ① chdir 让所有直接读 process.cwd() 的消费者(memory / skill / AGENTS.md / rollback 默认实例)跟随;
82
+ // ② setSandboxRoot 移动文件操作边界(jail、glob/grep/run_command 的 cwd、权限判定);
83
+ // ③ sessionDir 是启动时按 cwd 算死的固定值,必须显式改写,否则会话仍落进旧工作区。
84
+ process.chdir(abs);
85
+ setSandboxRoot(abs);
86
+ config.sessionDir = path.join(abs, '.mocode', 'sessions');
87
+ previousRoot = previous;
88
+ return { previous, root: abs };
89
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mocode-ai",
3
- "version": "1.5.11",
3
+ "version": "1.5.13",
4
4
  "description": "终端编码 agent:LLM + tool-call 循环 + 流式输出(含思考)+ 25 个工具,接任意 OpenAI 兼容后端。",
5
5
  "type": "module",
6
6
  "bin": {