min-agent 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/README.md +111 -28
  2. package/dist/agent.js +1119 -256
  3. package/dist/cli/commands/chat.js +10 -0
  4. package/dist/cli/commands/exec.js +32 -0
  5. package/dist/cli/commands/history.js +58 -0
  6. package/dist/cli/commands/index.js +224 -0
  7. package/dist/cli/commands/init.js +18 -0
  8. package/dist/cli/commands/mcp.js +173 -0
  9. package/dist/cli/commands/memory.js +69 -0
  10. package/dist/cli/commands/models.js +21 -0
  11. package/dist/cli/commands/permission.js +12 -0
  12. package/dist/cli/commands/rules.js +33 -0
  13. package/dist/cli/commands/sandbox.js +13 -0
  14. package/dist/cli/commands/serve.js +9 -0
  15. package/dist/cli/commands/setup.js +4 -0
  16. package/dist/cli/commands/shared.js +16 -0
  17. package/dist/cli/commands/skills.js +119 -0
  18. package/dist/cli/commands/update.js +7 -0
  19. package/dist/cli/commands/write-config.js +30 -0
  20. package/dist/cli/errors.js +36 -0
  21. package/dist/cli/exec-prompt.js +26 -0
  22. package/dist/cli/option-helpers.js +53 -0
  23. package/dist/cli/program.js +180 -0
  24. package/dist/cli.js +5 -888
  25. package/dist/code-mode.js +32 -14
  26. package/dist/compaction.js +347 -160
  27. package/dist/config.js +119 -10
  28. package/dist/confirm.js +56 -9
  29. package/dist/context-window.js +107 -39
  30. package/dist/doom-loop.js +264 -29
  31. package/dist/fetch-timeout.js +152 -0
  32. package/dist/http-approvals.js +60 -0
  33. package/dist/instructions.js +21 -0
  34. package/dist/logger.js +33 -4
  35. package/dist/markdown.js +37 -11
  36. package/dist/mcp.js +328 -30
  37. package/dist/memory.js +97 -56
  38. package/dist/output.js +7 -5
  39. package/dist/permission-cli.js +43 -0
  40. package/dist/plugins.js +46 -8
  41. package/dist/pricing.js +4 -4
  42. package/dist/provider.js +23 -6
  43. package/dist/question-format.js +60 -0
  44. package/dist/sandbox-cli.js +82 -0
  45. package/dist/sandbox.js +403 -0
  46. package/dist/save-throttle.js +45 -0
  47. package/dist/serve/common.js +404 -0
  48. package/dist/serve/routes-chat.js +347 -0
  49. package/dist/serve/routes-mcp.js +212 -0
  50. package/dist/serve/routes-memory.js +66 -0
  51. package/dist/serve/routes-meta.js +205 -0
  52. package/dist/serve/routes-sessions.js +61 -0
  53. package/dist/serve/routes-skills.js +70 -0
  54. package/dist/serve.js +33 -883
  55. package/dist/sessions.js +53 -9
  56. package/dist/skills.js +82 -18
  57. package/dist/title-gen.js +8 -2
  58. package/dist/token-display.js +36 -0
  59. package/dist/tool-display.js +5 -0
  60. package/dist/tool-output.js +1 -3
  61. package/dist/tools/apply_patch.js +85 -11
  62. package/dist/tools/atomic-file.js +35 -0
  63. package/dist/tools/backend.js +2 -2
  64. package/dist/tools/bash.js +57 -19
  65. package/dist/tools/code_search.js +7 -1
  66. package/dist/tools/edit.js +11 -10
  67. package/dist/tools/explore.js +74 -14
  68. package/dist/tools/glob.js +4 -0
  69. package/dist/tools/grep.js +17 -10
  70. package/dist/tools/index.js +6 -21
  71. package/dist/tools/question.js +28 -9
  72. package/dist/tools/read.js +6 -4
  73. package/dist/tools/search-searxng.js +223 -0
  74. package/dist/tools/search-serper.js +189 -0
  75. package/dist/tools/task.js +84 -30
  76. package/dist/tools/todo.js +120 -19
  77. package/dist/tools/web_fetch.js +11 -3
  78. package/dist/tools/web_search.js +66 -556
  79. package/dist/tools/write.js +23 -6
  80. package/dist/tui/App.js +63 -14
  81. package/dist/tui/ConfirmBar.js +45 -13
  82. package/dist/tui/InputBar.js +150 -35
  83. package/dist/tui/MessageList.js +266 -125
  84. package/dist/tui/ModelPicker.js +8 -3
  85. package/dist/tui/QuestionBar.js +51 -19
  86. package/dist/tui/SessionPicker.js +79 -0
  87. package/dist/tui/StatusBar.js +8 -14
  88. package/dist/tui/agent-runner.js +142 -22
  89. package/dist/tui/caret-pos.js +48 -5
  90. package/dist/tui/caret.js +1 -1
  91. package/dist/tui/click-count.js +13 -0
  92. package/dist/tui/drag-state.js +8 -3
  93. package/dist/tui/hydrate.js +129 -0
  94. package/dist/tui/index.js +42 -13
  95. package/dist/tui/input-history.js +92 -11
  96. package/dist/tui/layout.js +75 -4
  97. package/dist/tui/prompt-queue.js +24 -0
  98. package/dist/tui/selection.js +113 -21
  99. package/dist/tui/session-switch.js +28 -0
  100. package/dist/tui/slash-commands.js +22 -6
  101. package/dist/tui/slash-handler.js +233 -58
  102. package/dist/tui/text-width.js +38 -16
  103. package/dist/tui/token-info.js +7 -0
  104. package/dist/tui/tool-children.js +19 -0
  105. package/dist/tui/undo-stack.js +1 -1
  106. package/dist/tui/use-sgr-mouse.js +3 -1
  107. package/dist/tui-chat.js +276 -40
  108. package/dist/updater.js +88 -29
  109. package/dist/xml-search.js +194 -0
  110. package/docs/API.md +257 -25
  111. package/docs/superpowers/plans/2026-08-20-tui-completeness.md +873 -0
  112. package/docs/superpowers/plans/2026-08-20-unified-tui-default.md +631 -0
  113. package/docs/superpowers/specs/2026-08-20-config-http-alignment-design.md +47 -0
  114. package/docs/superpowers/specs/2026-08-20-mcp-plugins-alignment-design.md +37 -0
  115. package/docs/superpowers/specs/2026-08-20-sandbox-permissions-design.md +68 -0
  116. package/docs/superpowers/specs/2026-08-20-tui-completeness-design.md +273 -0
  117. package/docs/superpowers/specs/2026-08-20-unified-tui-default-design.md +165 -0
  118. package/package.json +6 -1
  119. package/skills/self-config/SKILL.md +90 -0
  120. package/skills/self-config/reference.md +149 -0
@@ -0,0 +1,28 @@
1
+ export function prepareSwitch(opts) {
2
+ if (opts.running)
3
+ return { ok: false, reason: "running" };
4
+ if (opts.currentId === opts.targetId)
5
+ return { ok: false, reason: "current" };
6
+ if (!opts.exists)
7
+ return { ok: false, reason: "missing" };
8
+ return { ok: true };
9
+ }
10
+ export function switchFailMessage(reason) {
11
+ if (reason === "running")
12
+ return "请先等待当前回合结束或按 Esc 取消";
13
+ if (reason === "current")
14
+ return "已是当前会话";
15
+ return "会话不存在";
16
+ }
17
+ export function prepareNew(opts) {
18
+ if (opts.running)
19
+ return { ok: false, reason: "running" };
20
+ if (!opts.sessionId && opts.messageCount === 0)
21
+ return { ok: false, reason: "already_new" };
22
+ return { ok: true };
23
+ }
24
+ export function newFailMessage(reason) {
25
+ if (reason === "running")
26
+ return "请先等待当前回合结束或按 Esc 取消";
27
+ return "已是新会话";
28
+ }
@@ -1,21 +1,28 @@
1
1
  export const SLASH_COMMANDS = [
2
- { name: "clear", description: "清除对话历史" },
2
+ { name: "new", description: "新建会话(保留当前)" },
3
3
  { name: "compact", description: "压缩上下文" },
4
4
  { name: "model", description: "选择/切换模型" },
5
5
  { name: "models", description: "列出可用模型" },
6
6
  { name: "provider", description: "查看/切换 Provider", argHint: "[name]" },
7
- { name: "memory", description: "查看/保存记忆", argHint: "[t]" },
8
- { name: "tokens", description: "显示 token 用量与成本" },
9
- { name: "budget", description: "查看/设置预算上限", argHint: "[n]" },
7
+ { name: "memory", description: "查看/保存记忆", argHint: "[t] [--project]" },
8
+ { name: "tokens", description: "显示上下文占用、累计用量与成本" },
9
+ { name: "budget", description: "查看/设置预算上限", argHint: "[n] [--project]" },
10
+ { name: "permission", description: "查看/设置确认模式", argHint: "[ask|accept-edits|allow-all] [--project]" },
11
+ { name: "sandbox", description: "查看/设置隔离", argHint: "[off|workspace|strict] [--project]" },
10
12
  { name: "undo", description: "撤销上一轮" },
11
13
  { name: "redo", description: "重发最后一条消息" },
12
14
  { name: "diff", description: "显示工作区未提交变更" },
13
15
  { name: "plan", description: "切换只读计划模式" },
14
- { name: "sessions", description: "列出已保存会话" },
16
+ { name: "sessions", description: "列出/切换已保存会话", argHint: "[id]", aliases: ["resume"] },
17
+ { name: "reload", description: "重新加载规则" },
15
18
  { name: "rename", description: "重命名当前会话", argHint: "<title>" },
16
19
  { name: "attach", description: "附加本地图片", argHint: "<path>", aliases: ["image"] },
17
20
  { name: "mcp", description: "显示 MCP 状态" },
18
- { name: "skills", description: "显示/启用/禁用 skills", argHint: "[list|info <name>|enable|disable <name> [--project]]" },
21
+ {
22
+ name: "skills",
23
+ description: "显示/启用/禁用 skills",
24
+ argHint: "[list|info <name>|enable|disable <name> [--project]]",
25
+ },
19
26
  { name: "path", description: "显示工作目录", aliases: ["pwd"] },
20
27
  { name: "paste", description: "粘贴剪贴板图片", argHint: "[t]" },
21
28
  { name: "help", description: "显示帮助" },
@@ -27,6 +34,15 @@ export function filterSlashCommands(input, commands = SLASH_COMMANDS) {
27
34
  return commands;
28
35
  return commands.filter((c) => c.name.startsWith(q) || (c.aliases ?? []).some((a) => a.startsWith(q)));
29
36
  }
37
+ /** True for submitted input that is a known slash command (including aliases). */
38
+ export function isKnownSlashCommandInput(text, commands = SLASH_COMMANDS) {
39
+ if (!text.startsWith("/"))
40
+ return false;
41
+ const name = text.slice(1).trim().split(/\s/, 1)[0]?.toLowerCase() ?? "";
42
+ if (!name)
43
+ return true;
44
+ return commands.some((c) => c.name === name || (c.aliases ?? []).some((a) => a === name));
45
+ }
30
46
  const argProviders = new Map();
31
47
  /** Registered by the app at startup so this module stays free of app imports. */
32
48
  export function setSlashArgProvider(command, provider) {
@@ -20,14 +20,9 @@ export async function handleSlashCommand(input, tui, messages, currentModel, tra
20
20
  case "q":
21
21
  requestExit();
22
22
  break;
23
- case "clear": {
24
- const { clearStack } = await import("./undo-stack.js");
25
- messages.length = 0;
26
- tui.update({ messages: [] });
27
- state.setUndoStack(clearStack(state.undoStack));
28
- sysMsg(tui, "✓ 对话已清除");
23
+ case "new":
24
+ state.startNewSession();
29
25
  break;
30
- }
31
26
  case "undo": {
32
27
  const { popTurn } = await import("./undo-stack.js");
33
28
  const popped = popTurn(state.undoStack);
@@ -55,7 +50,11 @@ export async function handleSlashCommand(input, tui, messages, currentModel, tra
55
50
  const { getWorkingTreeDiff } = await import("./diff-view.js");
56
51
  const result = await getWorkingTreeDiff();
57
52
  if (!result.ok) {
58
- const msg = result.reason === "not_git" ? "非 git 仓库,无法显示 diff" : result.reason === "no_changes" ? "工作区无未提交变更" : `diff 失败: ${result.message}`;
53
+ const msg = result.reason === "not_git"
54
+ ? "非 git 仓库,无法显示 diff"
55
+ : result.reason === "no_changes"
56
+ ? "工作区无未提交变更"
57
+ : `diff 失败: ${result.message}`;
59
58
  sysMsg(tui, msg);
60
59
  }
61
60
  else
@@ -67,20 +66,17 @@ export async function handleSlashCommand(input, tui, messages, currentModel, tra
67
66
  sysMsg(tui, state.planMode ? "✓ 计划模式已开启:仅只读工具可用,输出计划后再次输入 /plan 确认并执行" : "✓ 已退出计划模式");
68
67
  break;
69
68
  }
69
+ case "reload":
70
+ await state.reloadSystemPrompt();
71
+ break;
72
+ case "resume":
70
73
  case "sessions": {
71
- const { listSessions } = await import("../sessions.js");
72
- const sessions = listSessions();
73
- if (sessions.length === 0)
74
- sysMsg(tui, "无已保存的会话");
75
- else {
76
- const header = state.sessionId ? "" : " (当前会话未保存)\n";
77
- const lines = sessions.map((s) => {
78
- const current = state.sessionId === s.id ? " ← 当前" : "";
79
- const date = s.updated.split("T")[0];
80
- return ` ${s.id} ${date} ${s.title} (${s.messageCount} msgs)${current}`;
81
- });
82
- sysMsg(tui, `会话 (${sessions.length}):\n${header}${lines.join("\n")}\n\n恢复: min-agent chat --resume <id>`);
74
+ const id = arg.trim();
75
+ if (!id) {
76
+ state.openSessionPicker();
77
+ break;
83
78
  }
79
+ state.applySession(id);
84
80
  break;
85
81
  }
86
82
  case "rename": {
@@ -92,7 +88,7 @@ export async function handleSlashCommand(input, tui, messages, currentModel, tra
92
88
  const { saveSession, renameSession } = await import("../sessions.js");
93
89
  let id = state.sessionId;
94
90
  if (!id) {
95
- id = saveSession(messages);
91
+ id = saveSession(messages, undefined, undefined, undefined, state.taskState);
96
92
  state.setSessionId(id);
97
93
  }
98
94
  if (renameSession(id, title))
@@ -119,7 +115,12 @@ export async function handleSlashCommand(input, tui, messages, currentModel, tra
119
115
  if (parts.length <= 1)
120
116
  break;
121
117
  messages.push({ role: "user", content: parts });
122
- tui.addMessage({ id: `user-${Date.now()}`, role: "user", content: `[📎 图片] ${filePath}`, timestamp: Date.now() });
118
+ tui.addMessage({
119
+ id: `user-${Date.now()}`,
120
+ role: "user",
121
+ content: `[📎 图片] ${filePath}`,
122
+ timestamp: Date.now(),
123
+ });
123
124
  return "run";
124
125
  }
125
126
  case "compact": {
@@ -133,12 +134,17 @@ export async function handleSlashCommand(input, tui, messages, currentModel, tra
133
134
  sysMsg(tui, "⟳ 正在压缩上下文...");
134
135
  const model = resolveModel(currentModel);
135
136
  state.setUndoStack(clearStack(state.undoStack));
136
- const result = await compactMessages(messages, model, { keepRecentTurns: 2, autoContinue: false });
137
+ const result = await compactMessages(messages, model, {
138
+ keepRecentTurns: 2,
139
+ taskGoal: state.taskState.goal || undefined,
140
+ });
137
141
  messages.length = 0;
138
142
  messages.push(...result.messages);
139
143
  if (tracker)
140
144
  tracker.resetContext();
141
145
  sysMsg(tui, `✓ 已压缩 (约 ${estimateTokens(messages)} tokens)`);
146
+ const { snapshotTokenInfo } = await import("./token-info.js");
147
+ tui.setTokenInfo(await snapshotTokenInfo(tracker, currentModel, messages));
142
148
  }
143
149
  break;
144
150
  }
@@ -209,23 +215,34 @@ export async function handleSlashCommand(input, tui, messages, currentModel, tra
209
215
  break;
210
216
  }
211
217
  case "memory": {
212
- if (arg) {
218
+ const { takeScopeFlags, defaultMemoryScope } = await import("../memory.js");
219
+ const { scope, rest: memRest } = takeScopeFlags(rest);
220
+ const text = memRest.join(" ").trim();
221
+ if (text) {
213
222
  const { addMemory } = await import("../memory.js");
214
- addMemory(arg);
215
- sysMsg(tui, `✓ 已保存记忆: "${arg}"`);
223
+ const resolved = scope ?? defaultMemoryScope();
224
+ addMemory(text, [], resolved);
225
+ sysMsg(tui, `✓ 已保存${resolved === "project" ? "项目" : "全局"}记忆: "${text}"`);
216
226
  }
217
227
  else {
218
228
  const { loadMemories } = await import("../memory.js");
219
- const memories = loadMemories();
220
- if (memories.length === 0)
221
- sysMsg(tui, "无已保存的记忆");
222
- else {
229
+ const scopes = scope ? [scope] : ["project", "global"];
230
+ const sections = scopes.map((s) => {
231
+ const memories = loadMemories(s);
232
+ const label = s === "project" ? "项目记忆" : "全局记忆";
233
+ if (memories.length === 0)
234
+ return `${label}: 无`;
223
235
  const lines = memories.map((m, i) => {
224
236
  const tags = m.tags.length > 0 ? ` [${m.tags.join(", ")}]` : "";
225
237
  return ` #${i + 1}: ${m.content}${tags}`;
226
238
  });
227
- sysMsg(tui, `记忆 (${memories.length}):\n${lines.join("\n")}`);
239
+ return `${label} (${memories.length}):\n${lines.join("\n")}`;
240
+ });
241
+ if (!scope && loadMemories("project").length === 0 && loadMemories("global").length === 0) {
242
+ sysMsg(tui, "无已保存的记忆");
228
243
  }
244
+ else
245
+ sysMsg(tui, sections.join("\n"));
229
246
  }
230
247
  break;
231
248
  }
@@ -242,46 +259,87 @@ export async function handleSlashCommand(input, tui, messages, currentModel, tra
242
259
  }
243
260
  sysMsg(tui, `📎 剪贴板图片 (${(img.data.length / 1024).toFixed(1)} KB)`);
244
261
  const text = arg || "What's in this image?";
245
- const content = [{ type: "text", text }, { type: "image", image: img.data, mimeType: img.mimeType }];
262
+ const content = [
263
+ { type: "text", text },
264
+ { type: "image", image: img.data, mimeType: img.mimeType },
265
+ ];
246
266
  messages.push({ role: "user", content });
247
267
  tui.addMessage({ id: `user-${Date.now()}`, role: "user", content: `${text} [📎 图片]`, timestamp: Date.now() });
248
268
  return "run";
249
269
  }
250
270
  case "tokens": {
251
- const { getContextWindow } = await import("../context-window.js");
271
+ const { getContextWindowInfo } = await import("../context-window.js");
252
272
  const { getModelPrice, estimateCost, formatCost } = await import("../pricing.js");
253
273
  const { estimateTokens } = await import("../compaction.js");
254
- if (tracker && tracker.lastInputTokens > 0) {
255
- const ctxWindow = await getContextWindow(currentModel);
256
- const pct = Math.round((tracker.lastInputTokens / ctxWindow) * 100);
257
- const price = await getModelPrice(currentModel ?? "");
258
- const cost = estimateCost({ inputTokens: tracker.totalInputTokens, outputTokens: tracker.totalOutputTokens }, price);
259
- sysMsg(tui, [`Context: ${tracker.lastInputTokens} / ${ctxWindow} tokens (${pct}%)`, `Total: ${tracker.totalInputTokens} in / ${tracker.totalOutputTokens} out`, ...(cost != null ? [`Cost: 约 ${formatCost(cost)}`] : []), `Messages: ${messages.length}`].join("\n"));
260
- }
261
- else
262
- sysMsg(tui, `估算 tokens: ${estimateTokens(messages)} | Messages: ${messages.length}`);
274
+ const ctx = await getContextWindowInfo(currentModel);
275
+ const estimated = estimateTokens(messages);
276
+ const occupied = tracker.lastInputTokens > 0 ? tracker.lastInputTokens : estimated;
277
+ const pct = ctx.tokens > 0 && occupied > 0 ? Math.round((occupied / ctx.tokens) * 100) : 0;
278
+ const price = await getModelPrice(currentModel ?? "");
279
+ const cost = estimateCost({ inputTokens: tracker.totalInputTokens, outputTokens: tracker.totalOutputTokens }, price);
280
+ const occupancyNote = tracker.lastInputTokens > 0 ? "" : ",按对话内容估算";
281
+ const windowNote = ctx.source === "fallback" ? ",未读到接口窗口,按 512k 默认" : "";
282
+ sysMsg(tui, [
283
+ `上下文: ${occupied} / ${ctx.tokens} tokens (${pct}%${occupancyNote}${windowNote})`,
284
+ `累计: ${tracker.totalInputTokens} in / ${tracker.totalOutputTokens} out`,
285
+ ...(cost != null ? [`成本: 约 ${formatCost(cost)}`] : []),
286
+ `消息: ${messages.length}`,
287
+ ].join("\n"));
263
288
  break;
264
289
  }
265
290
  case "budget": {
266
- const { loadConfig, saveConfig } = await import("../config.js");
291
+ const { takeScopeFlags } = await import("../memory.js");
292
+ const { getBudgetSnapshot, setBudgetMaxCost } = await import("../config.js");
267
293
  const { getModelPrice, estimateCost, formatCost } = await import("../pricing.js");
268
- const cfg = loadConfig();
269
- const maxCost = cfg.budget?.maxCostUSD;
270
- if (arg) {
271
- const n = parseFloat(arg);
272
- if (isNaN(n) || n <= 0) {
273
- sysMsg(tui, "用法: /budget <USD 金额>");
294
+ const { scope, rest: budgetRest } = takeScopeFlags(rest);
295
+ const amount = budgetRest.join(" ").trim();
296
+ if (amount) {
297
+ const n = parseFloat(amount);
298
+ if (Number.isNaN(n) || n <= 0) {
299
+ sysMsg(tui, "用法: /budget <USD 金额> [--project]");
274
300
  break;
275
301
  }
276
- cfg.budget = { maxCostUSD: n };
277
- saveConfig(cfg);
278
- sysMsg(tui, `✓ 预算上限已设为: $${n}`);
302
+ const resolved = scope === "project" ? "project" : "global";
303
+ setBudgetMaxCost(n, resolved);
304
+ sysMsg(tui, `✓ 预算上限已设为: $${n}(${resolved === "project" ? "项目" : "全局"})`);
279
305
  break;
280
306
  }
307
+ const snap = getBudgetSnapshot();
308
+ const maxCost = snap.maxCostUSD;
309
+ const sourceLabel = snap.source === "project" ? "项目" : snap.source === "global" ? "全局" : null;
281
310
  const price = await getModelPrice(currentModel ?? "");
282
311
  const cost = estimateCost({ inputTokens: tracker.totalInputTokens, outputTokens: tracker.totalOutputTokens }, price);
283
312
  const costStr = cost != null ? `约 ${formatCost(cost)}` : "--";
284
- sysMsg(tui, maxCost != null ? `预算: 已用${costStr} / 上限 $${maxCost}\n设置: /budget <金额>` : `预算: 已用${costStr} / 未设置上限\n设置: /budget <金额>`);
313
+ const cap = maxCost != null ? `上限 $${maxCost}${sourceLabel ? `(${sourceLabel})` : ""}` : "未设置上限";
314
+ sysMsg(tui, `预算: 已用${costStr} / ${cap}\n设置: /budget <金额> 或 /budget <金额> --project`);
315
+ break;
316
+ }
317
+ case "permission": {
318
+ const { takeScopeFlags } = await import("../memory.js");
319
+ const { getPermissionSnapshot, parsePermissionMode, permissionModeLabel, setPermissionMode } = await import("../config.js");
320
+ const { getPermissionOverride, setPermissionOverride } = await import("../confirm.js");
321
+ const { scope, rest: permRest } = takeScopeFlags(rest);
322
+ const sourceLabel = (source) => source === "cli" ? "本次启动参数" : source === "project" ? "项目" : source === "global" ? "全局" : "默认";
323
+ if (permRest.length === 0) {
324
+ const snap = getPermissionSnapshot();
325
+ const override = getPermissionOverride();
326
+ const mode = override ?? snap.permission;
327
+ const source = override ? "cli" : snap.source;
328
+ sysMsg(tui, [
329
+ `当前确认: ${permissionModeLabel(mode)}(${sourceLabel(source)})`,
330
+ "设置: /permission ask|accept-edits|allow-all [--project]",
331
+ ].join("\n"));
332
+ break;
333
+ }
334
+ const parsed = parsePermissionMode(permRest[0]);
335
+ if (!parsed || permRest.length > 1) {
336
+ sysMsg(tui, "用法: /permission ask|accept-edits|allow-all [--project]");
337
+ break;
338
+ }
339
+ const resolved = scope === "project" ? "project" : "global";
340
+ setPermissionMode(parsed, resolved);
341
+ setPermissionOverride(parsed);
342
+ sysMsg(tui, `✓ 确认已设为${permissionModeLabel(parsed)}(${resolved === "project" ? "项目" : "全局"})`);
285
343
  break;
286
344
  }
287
345
  case "mcp": {
@@ -298,9 +356,16 @@ export async function handleSlashCommand(input, tui, messages, currentModel, tra
298
356
  const st = status[name];
299
357
  const connected = st?.connected ?? false;
300
358
  const toolCount = st?.tools.length ?? 0;
359
+ const extra = [
360
+ toolCount > 0 ? `${toolCount} tools` : "",
361
+ (st?.resource_count ?? 0) > 0 ? `${st.resource_count} resources` : "",
362
+ (st?.prompt_count ?? 0) > 0 ? `${st.prompt_count} prompts` : "",
363
+ ]
364
+ .filter(Boolean)
365
+ .join(", ");
301
366
  const state = disabled ? "disabled" : connected ? "connected" : "disconnected";
302
367
  const err = !disabled && !connected && st?.error ? ` — ${st.error}` : "";
303
- return ` ${name}: ${state}${toolCount > 0 ? ` (${toolCount} tools)` : ""}${err}`;
368
+ return ` ${name}: ${state}${extra ? ` (${extra})` : ""}${err}`;
304
369
  });
305
370
  sysMsg(tui, `MCP 服务器 (${servers.length}):\n${lines.join("\n")}`);
306
371
  }
@@ -326,7 +391,9 @@ export async function handleSlashCommand(input, tui, messages, currentModel, tra
326
391
  break;
327
392
  }
328
393
  const results = setSkillEnabled(names, action === "enable", scope);
329
- const notes = results.filter((r) => r.blockedBy).map((r) => ` ⚠ ${r.name} 仍为${r.enabled ? "启用" : "禁用"}(被 ${r.blockedBy} 范围覆盖)`);
394
+ const notes = results
395
+ .filter((r) => r.blockedBy)
396
+ .map((r) => ` ⚠ ${r.name} 仍为${r.enabled ? "启用" : "禁用"}(被 ${r.blockedBy} 范围覆盖)`);
330
397
  sysMsg(tui, [`✓ 已${action === "enable" ? "启用" : "禁用"}(${scope}): ${names.join(", ")}`, ...notes].join("\n"));
331
398
  break;
332
399
  }
@@ -338,7 +405,16 @@ export async function handleSlashCommand(input, tui, messages, currentModel, tra
338
405
  break;
339
406
  }
340
407
  const status = skill.enabled ? "enabled" : `disabled(${skill.disabledScope === "project" ? "proj" : "glob"})`;
341
- const meta = [`Name: ${skill.name}`, `Status: ${status}`, ...(skill.version ? [`Version: ${skill.version}`] : []), `Description: ${skill.description.replace(/\s+/g, " ")}`, `Location: ${skill.location}`, ...(skill.allowedTools.length ? [`Tools: ${skill.allowedTools.join(", ")}`] : []), ...(skill.requiredBins.length ? [`Requires: ${skill.requiredBins.join(", ")}`] : [])];
408
+ const meta = [
409
+ `Name: ${skill.name}`,
410
+ `Status: ${status}`,
411
+ ...(skill.builtin ? ["Source: 内置"] : []),
412
+ ...(skill.version ? [`Version: ${skill.version}`] : []),
413
+ `Description: ${skill.description.replace(/\s+/g, " ")}`,
414
+ `Location: ${skill.location}`,
415
+ ...(skill.allowedTools.length ? [`Tools: ${skill.allowedTools.join(", ")}`] : []),
416
+ ...(skill.requiredBins.length ? [`Requires: ${skill.requiredBins.join(", ")}`] : []),
417
+ ];
342
418
  const content = skill.content.trim();
343
419
  sysMsg(tui, [...meta, ...(content ? ["", "--- Content ---", "", content.slice(0, 4000)] : [])].join("\n"));
344
420
  break;
@@ -354,15 +430,114 @@ export async function handleSlashCommand(input, tui, messages, currentModel, tra
354
430
  }
355
431
  const lines = all.map((s) => {
356
432
  const status = s.enabled ? "enabled" : `disabled(${s.disabledScope === "project" ? "proj" : "glob"})`;
357
- return ` ${s.name} ${status} ${truncateDisplay(s.description.replace(/\s+/g, " "), 56)}`;
433
+ const origin = s.builtin ? " 内置" : "";
434
+ return ` ${s.name} ${status}${origin} ${truncateDisplay(s.description.replace(/\s+/g, " "), 56)}`;
358
435
  });
359
436
  if (stale.length > 0)
360
437
  lines.push(` ⚠ disabledSkills/enabledSkills 中无对应 skill: ${stale.join(", ")}`);
361
438
  sysMsg(tui, `Skills (${all.filter((s) => s.enabled).length} enabled / ${all.length}):\n${lines.join("\n")}\n切换: /skills disable <name> [--project]`);
362
439
  break;
363
440
  }
441
+ case "sandbox": {
442
+ const { takeScopeFlags } = await import("../memory.js");
443
+ const { getSandboxSnapshot, setSandboxConfig } = await import("../config.js");
444
+ const { parseSandboxMode, parseNetworkPolicy, getEffectiveSandboxPolicy, sandboxEnforcementCaveat, sandboxModeLabel, setSandboxOverride, } = await import("../sandbox.js");
445
+ const { scope, rest: sbRest } = takeScopeFlags(rest);
446
+ const sourceLabel = (source) => source === "cli"
447
+ ? "本次启动参数"
448
+ : source === "env"
449
+ ? "环境变量"
450
+ : source === "project"
451
+ ? "项目"
452
+ : source === "global"
453
+ ? "全局"
454
+ : "默认";
455
+ if (sbRest.length === 0) {
456
+ const snap = getSandboxSnapshot();
457
+ const extras = [
458
+ ...(snap.extraWriteRoots.length > 0 ? [`额外可写目录: ${snap.extraWriteRoots.join(", ")}`] : []),
459
+ ...(snap.extraReadRoots.length > 0 ? [`额外可读目录: ${snap.extraReadRoots.join(", ")}`] : []),
460
+ ];
461
+ const caveat = sandboxEnforcementCaveat(getEffectiveSandboxPolicy().mode);
462
+ sysMsg(tui, [
463
+ `当前隔离: ${snap.label}(${sourceLabel(snap.source)})`,
464
+ ...extras,
465
+ ...(caveat ? [caveat] : []),
466
+ "设置: /sandbox off|workspace|strict [--project]",
467
+ " /sandbox network allow|deny [--project]",
468
+ ].join("\n"));
469
+ break;
470
+ }
471
+ const resolved = scope === "project" ? "project" : "global";
472
+ const scopeLabel = resolved === "project" ? "项目" : "全局";
473
+ if (sbRest[0] === "network") {
474
+ const parsed = parseNetworkPolicy(sbRest[1]);
475
+ if (!parsed || sbRest.length > 2) {
476
+ sysMsg(tui, "用法: /sandbox network allow|deny [--project]");
477
+ break;
478
+ }
479
+ setSandboxConfig({ network: parsed }, resolved);
480
+ setSandboxOverride({ network: parsed });
481
+ sysMsg(tui, `✓ 联网已设为${parsed === "deny" ? "禁止" : "允许"}(${scopeLabel})`);
482
+ break;
483
+ }
484
+ const parsed = parseSandboxMode(sbRest[0]);
485
+ if (!parsed || sbRest.length > 1) {
486
+ sysMsg(tui, "用法: /sandbox off|workspace|strict [--project]\n /sandbox network allow|deny [--project]");
487
+ break;
488
+ }
489
+ setSandboxConfig({ mode: parsed }, resolved);
490
+ setSandboxOverride({ mode: parsed });
491
+ const caveat = sandboxEnforcementCaveat(parsed);
492
+ sysMsg(tui, [`✓ 隔离已设为${sandboxModeLabel(parsed)}(${scopeLabel})`, ...(caveat ? [caveat] : [])].join("\n"));
493
+ break;
494
+ }
364
495
  case "help":
365
- sysMsg(tui, ["命令:", " 输入 / 弹出命令菜单 (↑↓ 选择, Enter 执行, Esc 关闭)", " /clear 清除对话历史", " /compact 压缩上下文", " /model [n] 交互式选择/切换模型", " /models 列出可用模型", " /provider [n] 查看/切换 Provider", " /memory [t] 查看/保存记忆", " /tokens 显示 token 用量与成本", " /budget [n] 查看/设置预算上限 (USD)", " /undo 撤销上一轮", " /redo 重发最后一条消息", " /diff 显示工作区未提交变更", " /plan 切换只读计划模式", " /sessions 列出已保存会话", " /rename <t> 重命名当前会话", " /attach <p> 附加本地图片", " /mcp 显示 MCP 状态", " /skills 显示/启用/禁用 skills(/skills info <name> 查看详情)", " /path 显示工作目录", " /paste [t] 粘贴剪贴板图片 + 可选提示", " /help 显示帮助", " /exit 退出", "", "快捷键:", " Esc 取消当前任务", " Ctrl+J 输入框换行", " Ctrl+C 退出程序", " PageUp/Down 滚动消息", " 鼠标滚轮 滚动消息", " 鼠标拖拽 选择文本并自动复制", " 鼠标点击 工具调用行展开/收起", " ← → 移动输入框光标", " 鼠标点击 输入框内定位光标"].join("\n"));
496
+ sysMsg(tui, [
497
+ "命令:",
498
+ " 输入 / 弹出命令菜单 (输入过滤, Tab/Enter 执行, Esc 关闭)",
499
+ " /new 新建会话(保留当前)",
500
+ " /compact 压缩上下文",
501
+ " /model [n] 交互式选择/切换模型",
502
+ " /models 列出可用模型",
503
+ " /provider [n] 查看/切换 Provider",
504
+ " /memory [t] 查看/保存记忆(--project 写入当前项目)",
505
+ " /tokens 显示上下文占用、累计用量与成本",
506
+ " /budget [n] 查看/设置预算上限(--project 写入当前项目)",
507
+ " /permission 查看/设置确认模式(ask|accept-edits|allow-all,--project 写入当前项目)",
508
+ " /sandbox 查看/设置隔离(off|workspace|strict,--project 写入当前项目)",
509
+ " /undo 撤销上一轮",
510
+ " /redo 重发最后一条消息",
511
+ " /diff 显示工作区未提交变更",
512
+ " /plan 切换只读计划模式",
513
+ " /sessions 列出/切换已保存会话",
514
+ " /resume [id] 同 /sessions",
515
+ " /reload 重新加载规则",
516
+ " /rename <t> 重命名当前会话",
517
+ " /attach <p> 附加本地图片",
518
+ " /mcp 显示 MCP 状态",
519
+ " /skills 显示/启用/禁用 skills(/skills info <name> 查看详情)",
520
+ " /path 显示工作目录",
521
+ " /paste [t] 粘贴剪贴板图片 + 可选提示",
522
+ " /help 显示帮助",
523
+ " /exit 退出",
524
+ "",
525
+ "快捷键:",
526
+ " Esc 取消当前任务",
527
+ " Ctrl+J 输入框换行",
528
+ " Ctrl+C 清空输入(↑↓ 可找回);生成中则中断,空输入连按两次退出",
529
+ " Ctrl+B 消息区选择模式",
530
+ " Home/End 行首/行尾",
531
+ " PageUp/Down 滚动消息",
532
+ " 鼠标滚轮 滚动消息",
533
+ " 鼠标拖拽 选择文本并自动复制",
534
+ " 鼠标双击 选词并复制",
535
+ " 鼠标三击 选中该条消息并复制",
536
+ " 鼠标点击 工具调用行展开/收起",
537
+ " ↑↓ 翻阅输入历史",
538
+ " ← → 移动输入框光标",
539
+ " 鼠标点击 输入框内定位光标",
540
+ ].join("\n"));
366
541
  break;
367
542
  default:
368
543
  sysMsg(tui, `未知命令: /${cmd},输入 /help 查看可用命令`);
@@ -2,10 +2,12 @@
2
2
  * Terminal display-width helpers.
3
3
  *
4
4
  * CJK / fullwidth characters occupy two terminal cells, combining marks and
5
- * variation selectors occupy none. The input bar needs the same notion of width
6
- * that the terminal uses, otherwise the caret (and therefore the IME
7
- * composition window) drifts as soon as non-ASCII text is typed.
5
+ * variation selectors occupy none. Width is measured per grapheme cluster so
6
+ * ZWJ emoji (and Ink's own layout, which uses the same cluster rules) stay
7
+ * aligned with the mouse column.
8
8
  */
9
+ const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
10
+ const EXT_PICT = /\p{Extended_Pictographic}/u;
9
11
  /** Zero-width: combining marks, variation selectors, zero-width spaces. */
10
12
  function isZeroWidth(cp) {
11
13
  return ((cp >= 0x0300 && cp <= 0x036f) ||
@@ -46,44 +48,64 @@ function isFullWidth(cp) {
46
48
  (cp >= 0x20000 && cp <= 0x2fffd) ||
47
49
  (cp >= 0x30000 && cp <= 0x3fffd));
48
50
  }
49
- /** Display width of a single code point, in terminal cells. */
50
- export function charWidth(char) {
51
+ function codePointWidth(char) {
51
52
  const cp = char.codePointAt(0);
52
53
  if (cp === undefined)
53
54
  return 0;
54
- // C0 / C1 control characters render nothing useful.
55
55
  if (cp < 0x20 || (cp >= 0x7f && cp < 0xa0))
56
56
  return 0;
57
57
  if (isZeroWidth(cp))
58
58
  return 0;
59
- return isFullWidth(cp) ? 2 : 1;
59
+ if (isFullWidth(cp) || EXT_PICT.test(char))
60
+ return 2;
61
+ return 1;
62
+ }
63
+ /** Display width of a grapheme cluster (or a single code point), in terminal cells. */
64
+ export function clusterWidth(cluster) {
65
+ if (cluster.length === 0)
66
+ return 0;
67
+ if (EXT_PICT.test(cluster) || cluster.includes("\u200D"))
68
+ return 2;
69
+ let width = 0;
70
+ for (const ch of cluster)
71
+ width += codePointWidth(ch);
72
+ return cluster.includes("\uFE0F") ? Math.max(width, 2) : width;
73
+ }
74
+ /** Display width of a single code point, in terminal cells. */
75
+ export function charWidth(char) {
76
+ return clusterWidth(char);
77
+ }
78
+ export function* clustersOf(text) {
79
+ for (const { segment } of segmenter.segment(text)) {
80
+ yield { text: segment, width: clusterWidth(segment) };
81
+ }
60
82
  }
61
83
  /** Display width of a string (single logical line, no ANSI escapes). */
62
84
  export function displayWidth(text) {
63
85
  let width = 0;
64
- for (const char of text)
65
- width += charWidth(char);
86
+ for (const { width: w } of clustersOf(text))
87
+ width += w;
66
88
  return width;
67
89
  }
68
90
  /**
69
91
  * Break a single logical line into visual rows of at most `maxWidth` cells.
70
- * Breaks on cell boundaries (not word boundaries) so the caret column can be
71
- * derived exactly — a fullwidth character is never split across rows.
92
+ * Breaks on cluster boundaries (not word boundaries) so the caret column can
93
+ * be derived exactly — a fullwidth character or emoji is never split across rows.
72
94
  */
73
95
  export function wrapByWidth(line, maxWidth) {
96
+ const text = typeof line === "string" ? line : line == null ? "" : String(line);
74
97
  if (maxWidth < 1)
75
- return [line];
98
+ return [text];
76
99
  const rows = [];
77
100
  let current = "";
78
101
  let width = 0;
79
- for (const char of line) {
80
- const w = charWidth(char);
81
- if (width + w > maxWidth) {
102
+ for (const { text: g, width: w } of clustersOf(text)) {
103
+ if (width + w > maxWidth && current.length > 0) {
82
104
  rows.push(current);
83
105
  current = "";
84
106
  width = 0;
85
107
  }
86
- current += char;
108
+ current += g;
87
109
  width += w;
88
110
  }
89
111
  rows.push(current);
@@ -0,0 +1,7 @@
1
+ import { estimateTokens } from "../compaction.js";
2
+ import { getContextWindowInfo } from "../context-window.js";
3
+ import { tokenInfoFromTracker } from "../token-display.js";
4
+ export async function snapshotTokenInfo(tracker, modelId, messages) {
5
+ const ctx = await getContextWindowInfo(modelId);
6
+ return tokenInfoFromTracker(tracker, ctx.tokens, estimateTokens(messages));
7
+ }
@@ -0,0 +1,19 @@
1
+ export function mergeToolChild(messages, parentToolCallId, child) {
2
+ let idx = -1;
3
+ if (parentToolCallId)
4
+ idx = messages.findIndex((m) => m.role === "tool" && m.toolCallId === parentToolCallId);
5
+ if (idx === -1) {
6
+ for (let i = messages.length - 1; i >= 0; i--) {
7
+ if (messages[i].role === "tool" && messages[i].toolName === "task") {
8
+ idx = i;
9
+ break;
10
+ }
11
+ }
12
+ }
13
+ if (idx === -1)
14
+ return messages;
15
+ const target = messages[idx];
16
+ const next = messages.slice();
17
+ next[idx] = { ...target, toolChildren: [...(target.toolChildren ?? []), child] };
18
+ return next;
19
+ }
@@ -9,6 +9,6 @@ export function popTurn(stack) {
9
9
  return null;
10
10
  return { turn: stack[stack.length - 1], stack: stack.slice(0, -1) };
11
11
  }
12
- export function clearStack(stack) {
12
+ export function clearStack(_stack) {
13
13
  return [];
14
14
  }
@@ -22,6 +22,8 @@ export function useSgrMouse(enabled, onEvent) {
22
22
  }
23
23
  };
24
24
  stdin.on("data", handle);
25
- return () => { stdin.off("data", handle); };
25
+ return () => {
26
+ stdin.off("data", handle);
27
+ };
26
28
  }, [enabled, stdin]);
27
29
  }