min-agent 0.2.1 → 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.
- package/README.md +242 -31
- package/dist/agent.js +1233 -485
- package/dist/assistant-stream.js +11 -7
- package/dist/cli/commands/chat.js +10 -0
- package/dist/cli/commands/exec.js +32 -0
- package/dist/cli/commands/history.js +58 -0
- package/dist/cli/commands/index.js +224 -0
- package/dist/cli/commands/init.js +18 -0
- package/dist/cli/commands/mcp.js +173 -0
- package/dist/cli/commands/memory.js +69 -0
- package/dist/cli/commands/models.js +21 -0
- package/dist/cli/commands/permission.js +12 -0
- package/dist/cli/commands/rules.js +33 -0
- package/dist/cli/commands/sandbox.js +13 -0
- package/dist/cli/commands/serve.js +9 -0
- package/dist/cli/commands/setup.js +4 -0
- package/dist/cli/commands/shared.js +16 -0
- package/dist/cli/commands/skills.js +119 -0
- package/dist/cli/commands/update.js +7 -0
- package/dist/cli/commands/write-config.js +30 -0
- package/dist/cli/errors.js +36 -0
- package/dist/cli/exec-prompt.js +26 -0
- package/dist/cli/option-helpers.js +53 -0
- package/dist/cli/program.js +180 -0
- package/dist/cli.js +7 -632
- package/dist/clipboard.js +59 -23
- package/dist/code-mode.js +35 -17
- package/dist/compaction.js +457 -169
- package/dist/config.js +298 -38
- package/dist/confirm.js +105 -9
- package/dist/context-window.js +156 -75
- package/dist/doom-loop.js +268 -26
- package/dist/fetch-timeout.js +152 -0
- package/dist/http-approvals.js +60 -0
- package/dist/http.js +119 -0
- package/dist/instructions.js +72 -33
- package/dist/logger.js +95 -0
- package/dist/markdown.js +35 -50
- package/dist/mcp.js +847 -102
- package/dist/memory.js +128 -45
- package/dist/output.js +42 -31
- package/dist/paste-handler.js +3 -3
- package/dist/permission-cli.js +43 -0
- package/dist/plugins.js +76 -11
- package/dist/pricing.js +119 -0
- package/dist/provider.js +34 -15
- package/dist/question-format.js +60 -0
- package/dist/sandbox-cli.js +82 -0
- package/dist/sandbox.js +403 -0
- package/dist/save-throttle.js +45 -0
- package/dist/serve/common.js +404 -0
- package/dist/serve/routes-chat.js +347 -0
- package/dist/serve/routes-mcp.js +212 -0
- package/dist/serve/routes-memory.js +66 -0
- package/dist/serve/routes-meta.js +205 -0
- package/dist/serve/routes-sessions.js +61 -0
- package/dist/serve/routes-skills.js +70 -0
- package/dist/serve.js +74 -635
- package/dist/sessions.js +197 -15
- package/dist/skills.js +531 -77
- package/dist/synthetic.js +7 -0
- package/dist/title-gen.js +9 -2
- package/dist/token-display.js +36 -0
- package/dist/tool-display.js +178 -0
- package/dist/tool-output.js +53 -46
- package/dist/tools/apply_patch.js +265 -0
- package/dist/tools/atomic-file.js +35 -0
- package/dist/tools/backend.js +61 -0
- package/dist/tools/bash.js +186 -71
- package/dist/tools/code_search.js +13 -6
- package/dist/tools/edit.js +26 -9
- package/dist/tools/explore.js +144 -16
- package/dist/tools/glob.js +7 -3
- package/dist/tools/grep.js +153 -14
- package/dist/tools/index.js +9 -24
- package/dist/tools/question.js +31 -30
- package/dist/tools/read.js +77 -15
- package/dist/tools/search-searxng.js +223 -0
- package/dist/tools/search-serper.js +189 -0
- package/dist/tools/task.js +100 -33
- package/dist/tools/todo.js +178 -67
- package/dist/tools/web_fetch.js +158 -46
- package/dist/tools/web_search.js +217 -29
- package/dist/tools/write.js +34 -11
- package/dist/tui/App.js +89 -6
- package/dist/tui/ConfirmBar.js +57 -4
- package/dist/tui/InputBar.js +504 -44
- package/dist/tui/MessageList.js +674 -20
- package/dist/tui/ModelPicker.js +113 -0
- package/dist/tui/QuestionBar.js +136 -0
- package/dist/tui/SessionPicker.js +79 -0
- package/dist/tui/StatusBar.js +14 -12
- package/dist/tui/agent-runner.js +223 -0
- package/dist/tui/caret-pos.js +177 -0
- package/dist/tui/caret.js +69 -0
- package/dist/tui/click-count.js +13 -0
- package/dist/tui/diff-view.js +61 -0
- package/dist/tui/drag-state.js +49 -0
- package/dist/tui/hydrate.js +129 -0
- package/dist/tui/index.js +189 -31
- package/dist/tui/input-history.js +125 -0
- package/dist/tui/layout.js +88 -0
- package/dist/tui/mouse.js +46 -0
- package/dist/tui/prompt-queue.js +24 -0
- package/dist/tui/selection.js +226 -0
- package/dist/tui/session-switch.js +28 -0
- package/dist/tui/slash-commands.js +106 -0
- package/dist/tui/slash-handler.js +545 -0
- package/dist/tui/text-width.js +113 -0
- package/dist/tui/theme.js +12 -0
- package/dist/tui/token-info.js +7 -0
- package/dist/tui/tool-children.js +19 -0
- package/dist/tui/undo-stack.js +14 -0
- package/dist/tui/use-sgr-mouse.js +29 -0
- package/dist/tui-chat.js +346 -330
- package/dist/updater.js +116 -0
- package/dist/xml-search.js +194 -0
- package/docs/API.md +410 -32
- package/docs/superpowers/plans/2026-08-16-batch1-tui-improvements.md +1510 -0
- package/docs/superpowers/plans/2026-08-16-batch2-cli-tools-api.md +2105 -0
- package/docs/superpowers/plans/2026-08-16-batch3-config-engineering.md +1595 -0
- package/docs/superpowers/plans/2026-08-16-input-caret.md +782 -0
- package/docs/superpowers/plans/2026-08-20-tui-completeness.md +873 -0
- package/docs/superpowers/plans/2026-08-20-unified-tui-default.md +631 -0
- package/docs/superpowers/specs/2026-08-16-batch1-tui-improvements-design.md +183 -0
- package/docs/superpowers/specs/2026-08-16-batch2-cli-tools-api-design.md +220 -0
- package/docs/superpowers/specs/2026-08-16-batch3-config-engineering-design.md +196 -0
- package/docs/superpowers/specs/2026-08-16-input-caret-design.md +63 -0
- package/docs/superpowers/specs/2026-08-17-mouse-selection-design.md +116 -0
- package/docs/superpowers/specs/2026-08-20-config-http-alignment-design.md +47 -0
- package/docs/superpowers/specs/2026-08-20-mcp-plugins-alignment-design.md +37 -0
- package/docs/superpowers/specs/2026-08-20-sandbox-permissions-design.md +68 -0
- package/docs/superpowers/specs/2026-08-20-tui-completeness-design.md +273 -0
- package/docs/superpowers/specs/2026-08-20-unified-tui-default-design.md +165 -0
- package/package.json +12 -8
- package/skills/self-config/SKILL.md +90 -0
- package/skills/self-config/reference.md +149 -0
|
@@ -0,0 +1,545 @@
|
|
|
1
|
+
export function sysMsg(tui, content) {
|
|
2
|
+
tui.addMessage({
|
|
3
|
+
id: `sys-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
|
4
|
+
role: "system",
|
|
5
|
+
content,
|
|
6
|
+
timestamp: Date.now(),
|
|
7
|
+
});
|
|
8
|
+
}
|
|
9
|
+
export async function handleSlashCommand(input, tui, messages, currentModel, tracker, setModel, requestExit, state) {
|
|
10
|
+
const { parseSlashArgs } = await import("./slash-commands.js");
|
|
11
|
+
const parsed = parseSlashArgs(input);
|
|
12
|
+
const rawParts = input.slice(1).trim().split(/\s+/);
|
|
13
|
+
const cmd = (parsed?.command || rawParts[0] || "").toLowerCase();
|
|
14
|
+
const rest = parsed ? [...parsed.tokens, ...(parsed.partial ? [parsed.partial] : [])] : rawParts.slice(1);
|
|
15
|
+
const arg = rest.join(" ");
|
|
16
|
+
const { truncateDisplay } = await import("../tool-display.js");
|
|
17
|
+
switch (cmd) {
|
|
18
|
+
case "exit":
|
|
19
|
+
case "quit":
|
|
20
|
+
case "q":
|
|
21
|
+
requestExit();
|
|
22
|
+
break;
|
|
23
|
+
case "new":
|
|
24
|
+
state.startNewSession();
|
|
25
|
+
break;
|
|
26
|
+
case "undo": {
|
|
27
|
+
const { popTurn } = await import("./undo-stack.js");
|
|
28
|
+
const popped = popTurn(state.undoStack);
|
|
29
|
+
if (!popped) {
|
|
30
|
+
sysMsg(tui, "没有可撤销的内容");
|
|
31
|
+
break;
|
|
32
|
+
}
|
|
33
|
+
state.setUndoStack(popped.stack);
|
|
34
|
+
messages.splice(popped.turn.start, popped.turn.end - popped.turn.start);
|
|
35
|
+
tui.truncateMessages(popped.turn.uiEnd);
|
|
36
|
+
sysMsg(tui, "✓ 已撤销 1 轮");
|
|
37
|
+
break;
|
|
38
|
+
}
|
|
39
|
+
case "redo": {
|
|
40
|
+
if (!state.lastUserMessage) {
|
|
41
|
+
sysMsg(tui, "没有可重发的消息");
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
const text = state.lastUserMessage;
|
|
45
|
+
tui.addMessage({ id: `user-${Date.now()}`, role: "user", content: text, timestamp: Date.now() });
|
|
46
|
+
messages.push({ role: "user", content: text });
|
|
47
|
+
return "run";
|
|
48
|
+
}
|
|
49
|
+
case "diff": {
|
|
50
|
+
const { getWorkingTreeDiff } = await import("./diff-view.js");
|
|
51
|
+
const result = await getWorkingTreeDiff();
|
|
52
|
+
if (!result.ok) {
|
|
53
|
+
const msg = result.reason === "not_git"
|
|
54
|
+
? "非 git 仓库,无法显示 diff"
|
|
55
|
+
: result.reason === "no_changes"
|
|
56
|
+
? "工作区无未提交变更"
|
|
57
|
+
: `diff 失败: ${result.message}`;
|
|
58
|
+
sysMsg(tui, msg);
|
|
59
|
+
}
|
|
60
|
+
else
|
|
61
|
+
sysMsg(tui, result.diff);
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
case "plan": {
|
|
65
|
+
state.togglePlanMode();
|
|
66
|
+
sysMsg(tui, state.planMode ? "✓ 计划模式已开启:仅只读工具可用,输出计划后再次输入 /plan 确认并执行" : "✓ 已退出计划模式");
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
69
|
+
case "reload":
|
|
70
|
+
await state.reloadSystemPrompt();
|
|
71
|
+
break;
|
|
72
|
+
case "resume":
|
|
73
|
+
case "sessions": {
|
|
74
|
+
const id = arg.trim();
|
|
75
|
+
if (!id) {
|
|
76
|
+
state.openSessionPicker();
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
state.applySession(id);
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
case "rename": {
|
|
83
|
+
const title = arg.trim();
|
|
84
|
+
if (!title) {
|
|
85
|
+
sysMsg(tui, "用法: /rename <标题>");
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
const { saveSession, renameSession } = await import("../sessions.js");
|
|
89
|
+
let id = state.sessionId;
|
|
90
|
+
if (!id) {
|
|
91
|
+
id = saveSession(messages, undefined, undefined, undefined, state.taskState);
|
|
92
|
+
state.setSessionId(id);
|
|
93
|
+
}
|
|
94
|
+
if (renameSession(id, title))
|
|
95
|
+
sysMsg(tui, `✓ 会话已重命名为: ${title}`);
|
|
96
|
+
else
|
|
97
|
+
sysMsg(tui, "重命名失败: 会话不存在");
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
case "attach":
|
|
101
|
+
case "image": {
|
|
102
|
+
const filePath = arg;
|
|
103
|
+
if (!filePath) {
|
|
104
|
+
sysMsg(tui, "用法: /attach <图片路径>");
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
const { loadImageParts } = await import("../agent.js");
|
|
108
|
+
const notify = (kind, imgPath) => {
|
|
109
|
+
if (kind === "not_found")
|
|
110
|
+
sysMsg(tui, `⚠ 图片不存在: ${imgPath}`);
|
|
111
|
+
else if (kind === "too_large")
|
|
112
|
+
sysMsg(tui, `⚠ 图片超过 5MB 限制: ${imgPath}`);
|
|
113
|
+
};
|
|
114
|
+
const parts = [{ type: "text", text: "" }, ...loadImageParts([filePath], notify)];
|
|
115
|
+
if (parts.length <= 1)
|
|
116
|
+
break;
|
|
117
|
+
messages.push({ role: "user", content: parts });
|
|
118
|
+
tui.addMessage({
|
|
119
|
+
id: `user-${Date.now()}`,
|
|
120
|
+
role: "user",
|
|
121
|
+
content: `[📎 图片] ${filePath}`,
|
|
122
|
+
timestamp: Date.now(),
|
|
123
|
+
});
|
|
124
|
+
return "run";
|
|
125
|
+
}
|
|
126
|
+
case "compact": {
|
|
127
|
+
const { compactMessages } = await import("../compaction.js");
|
|
128
|
+
const { estimateTokens } = await import("../compaction.js");
|
|
129
|
+
const { resolveModel } = await import("../provider.js");
|
|
130
|
+
const { clearStack } = await import("./undo-stack.js");
|
|
131
|
+
if (messages.length < 4)
|
|
132
|
+
sysMsg(tui, "消息太少,无需压缩");
|
|
133
|
+
else {
|
|
134
|
+
sysMsg(tui, "⟳ 正在压缩上下文...");
|
|
135
|
+
const model = resolveModel(currentModel);
|
|
136
|
+
state.setUndoStack(clearStack(state.undoStack));
|
|
137
|
+
const result = await compactMessages(messages, model, {
|
|
138
|
+
keepRecentTurns: 2,
|
|
139
|
+
taskGoal: state.taskState.goal || undefined,
|
|
140
|
+
});
|
|
141
|
+
messages.length = 0;
|
|
142
|
+
messages.push(...result.messages);
|
|
143
|
+
if (tracker)
|
|
144
|
+
tracker.resetContext();
|
|
145
|
+
sysMsg(tui, `✓ 已压缩 (约 ${estimateTokens(messages)} tokens)`);
|
|
146
|
+
const { snapshotTokenInfo } = await import("./token-info.js");
|
|
147
|
+
tui.setTokenInfo(await snapshotTokenInfo(tracker, currentModel, messages));
|
|
148
|
+
}
|
|
149
|
+
break;
|
|
150
|
+
}
|
|
151
|
+
case "provider": {
|
|
152
|
+
const { loadConfig, saveConfig } = await import("../config.js");
|
|
153
|
+
const cfg = loadConfig();
|
|
154
|
+
const providers = cfg.providers ?? [];
|
|
155
|
+
if (arg) {
|
|
156
|
+
const target = providers.find((p) => p.name === arg);
|
|
157
|
+
if (!target) {
|
|
158
|
+
sysMsg(tui, `Provider "${arg}" 不存在`);
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
cfg.activeProvider = arg;
|
|
162
|
+
saveConfig(cfg);
|
|
163
|
+
setModel(target.defaultModel ?? "");
|
|
164
|
+
sysMsg(tui, `✓ 当前 Provider 已切换为: ${arg}`);
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
if (providers.length === 0) {
|
|
168
|
+
sysMsg(tui, "无已配置 Provider,运行: min-agent setup");
|
|
169
|
+
break;
|
|
170
|
+
}
|
|
171
|
+
const lines = providers.map((p) => {
|
|
172
|
+
const cur = p.name === cfg.activeProvider ? " ← 当前" : "";
|
|
173
|
+
return ` ${p.name} ${p.baseURL}${cur}`;
|
|
174
|
+
});
|
|
175
|
+
sysMsg(tui, `Providers:\n${lines.join("\n")}\n\n切换: /provider <name>`);
|
|
176
|
+
}
|
|
177
|
+
break;
|
|
178
|
+
}
|
|
179
|
+
case "model": {
|
|
180
|
+
if (arg) {
|
|
181
|
+
const config = await import("../config.js");
|
|
182
|
+
const cfg = config.loadConfig();
|
|
183
|
+
const provider = config.getActiveProvider(cfg);
|
|
184
|
+
if (provider) {
|
|
185
|
+
provider.defaultModel = arg;
|
|
186
|
+
config.saveConfig(cfg);
|
|
187
|
+
}
|
|
188
|
+
setModel(arg);
|
|
189
|
+
sysMsg(tui, `✓ 默认模型已设为: ${arg}`);
|
|
190
|
+
}
|
|
191
|
+
else
|
|
192
|
+
tui.showModelPicker();
|
|
193
|
+
break;
|
|
194
|
+
}
|
|
195
|
+
case "models": {
|
|
196
|
+
const { fetchModels } = await import("../config.js");
|
|
197
|
+
const { loadConfig, getActiveProvider } = await import("../config.js");
|
|
198
|
+
const cfg = loadConfig();
|
|
199
|
+
const provider = getActiveProvider(cfg);
|
|
200
|
+
if (!provider?.baseURL || !provider?.apiKey) {
|
|
201
|
+
sysMsg(tui, "未配置 Provider,运行: min-agent setup");
|
|
202
|
+
break;
|
|
203
|
+
}
|
|
204
|
+
sysMsg(tui, "正在获取模型列表...");
|
|
205
|
+
const models = await fetchModels(provider.baseURL, provider.apiKey);
|
|
206
|
+
if (models.length === 0)
|
|
207
|
+
sysMsg(tui, "无法获取模型列表");
|
|
208
|
+
else {
|
|
209
|
+
const lines = models.map((m) => {
|
|
210
|
+
const marker = m === provider.defaultModel ? " ← 默认" : "";
|
|
211
|
+
return ` ${m}${marker}`;
|
|
212
|
+
});
|
|
213
|
+
sysMsg(tui, `可用模型 (${models.length}):\n${lines.join("\n")}`);
|
|
214
|
+
}
|
|
215
|
+
break;
|
|
216
|
+
}
|
|
217
|
+
case "memory": {
|
|
218
|
+
const { takeScopeFlags, defaultMemoryScope } = await import("../memory.js");
|
|
219
|
+
const { scope, rest: memRest } = takeScopeFlags(rest);
|
|
220
|
+
const text = memRest.join(" ").trim();
|
|
221
|
+
if (text) {
|
|
222
|
+
const { addMemory } = await import("../memory.js");
|
|
223
|
+
const resolved = scope ?? defaultMemoryScope();
|
|
224
|
+
addMemory(text, [], resolved);
|
|
225
|
+
sysMsg(tui, `✓ 已保存${resolved === "project" ? "项目" : "全局"}记忆: "${text}"`);
|
|
226
|
+
}
|
|
227
|
+
else {
|
|
228
|
+
const { loadMemories } = await import("../memory.js");
|
|
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}: 无`;
|
|
235
|
+
const lines = memories.map((m, i) => {
|
|
236
|
+
const tags = m.tags.length > 0 ? ` [${m.tags.join(", ")}]` : "";
|
|
237
|
+
return ` #${i + 1}: ${m.content}${tags}`;
|
|
238
|
+
});
|
|
239
|
+
return `${label} (${memories.length}):\n${lines.join("\n")}`;
|
|
240
|
+
});
|
|
241
|
+
if (!scope && loadMemories("project").length === 0 && loadMemories("global").length === 0) {
|
|
242
|
+
sysMsg(tui, "无已保存的记忆");
|
|
243
|
+
}
|
|
244
|
+
else
|
|
245
|
+
sysMsg(tui, sections.join("\n"));
|
|
246
|
+
}
|
|
247
|
+
break;
|
|
248
|
+
}
|
|
249
|
+
case "path":
|
|
250
|
+
case "pwd":
|
|
251
|
+
sysMsg(tui, process.cwd());
|
|
252
|
+
break;
|
|
253
|
+
case "paste": {
|
|
254
|
+
const { getClipboardImage } = await import("../clipboard.js");
|
|
255
|
+
const img = getClipboardImage();
|
|
256
|
+
if (!img) {
|
|
257
|
+
sysMsg(tui, "剪贴板中未找到图片");
|
|
258
|
+
break;
|
|
259
|
+
}
|
|
260
|
+
sysMsg(tui, `📎 剪贴板图片 (${(img.data.length / 1024).toFixed(1)} KB)`);
|
|
261
|
+
const text = arg || "What's in this image?";
|
|
262
|
+
const content = [
|
|
263
|
+
{ type: "text", text },
|
|
264
|
+
{ type: "image", image: img.data, mimeType: img.mimeType },
|
|
265
|
+
];
|
|
266
|
+
messages.push({ role: "user", content });
|
|
267
|
+
tui.addMessage({ id: `user-${Date.now()}`, role: "user", content: `${text} [📎 图片]`, timestamp: Date.now() });
|
|
268
|
+
return "run";
|
|
269
|
+
}
|
|
270
|
+
case "tokens": {
|
|
271
|
+
const { getContextWindowInfo } = await import("../context-window.js");
|
|
272
|
+
const { getModelPrice, estimateCost, formatCost } = await import("../pricing.js");
|
|
273
|
+
const { estimateTokens } = await import("../compaction.js");
|
|
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"));
|
|
288
|
+
break;
|
|
289
|
+
}
|
|
290
|
+
case "budget": {
|
|
291
|
+
const { takeScopeFlags } = await import("../memory.js");
|
|
292
|
+
const { getBudgetSnapshot, setBudgetMaxCost } = await import("../config.js");
|
|
293
|
+
const { getModelPrice, estimateCost, formatCost } = await import("../pricing.js");
|
|
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]");
|
|
300
|
+
break;
|
|
301
|
+
}
|
|
302
|
+
const resolved = scope === "project" ? "project" : "global";
|
|
303
|
+
setBudgetMaxCost(n, resolved);
|
|
304
|
+
sysMsg(tui, `✓ 预算上限已设为: $${n}(${resolved === "project" ? "项目" : "全局"})`);
|
|
305
|
+
break;
|
|
306
|
+
}
|
|
307
|
+
const snap = getBudgetSnapshot();
|
|
308
|
+
const maxCost = snap.maxCostUSD;
|
|
309
|
+
const sourceLabel = snap.source === "project" ? "项目" : snap.source === "global" ? "全局" : null;
|
|
310
|
+
const price = await getModelPrice(currentModel ?? "");
|
|
311
|
+
const cost = estimateCost({ inputTokens: tracker.totalInputTokens, outputTokens: tracker.totalOutputTokens }, price);
|
|
312
|
+
const costStr = cost != null ? `约 ${formatCost(cost)}` : "--";
|
|
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" ? "项目" : "全局"})`);
|
|
343
|
+
break;
|
|
344
|
+
}
|
|
345
|
+
case "mcp": {
|
|
346
|
+
const { loadMcpConfig } = await import("../mcp.js");
|
|
347
|
+
const { getMcpStatus } = await import("../mcp.js");
|
|
348
|
+
const config = loadMcpConfig();
|
|
349
|
+
const servers = Object.entries(config.mcpServers);
|
|
350
|
+
if (servers.length === 0)
|
|
351
|
+
sysMsg(tui, "无 MCP 服务器配置");
|
|
352
|
+
else {
|
|
353
|
+
const status = getMcpStatus();
|
|
354
|
+
const lines = servers.map(([name, cfg]) => {
|
|
355
|
+
const disabled = cfg.enabled === false;
|
|
356
|
+
const st = status[name];
|
|
357
|
+
const connected = st?.connected ?? false;
|
|
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(", ");
|
|
366
|
+
const state = disabled ? "disabled" : connected ? "connected" : "disconnected";
|
|
367
|
+
const err = !disabled && !connected && st?.error ? ` — ${st.error}` : "";
|
|
368
|
+
return ` ${name}: ${state}${extra ? ` (${extra})` : ""}${err}`;
|
|
369
|
+
});
|
|
370
|
+
sysMsg(tui, `MCP 服务器 (${servers.length}):\n${lines.join("\n")}`);
|
|
371
|
+
}
|
|
372
|
+
break;
|
|
373
|
+
}
|
|
374
|
+
case "skills": {
|
|
375
|
+
const { discoverSkills, getAllSkills, getSkill, getStaleDisabledSkills, setSkillEnabled } = await import("../skills.js");
|
|
376
|
+
discoverSkills({ silent: true });
|
|
377
|
+
const tokens = rest.filter(Boolean);
|
|
378
|
+
const action = tokens[0];
|
|
379
|
+
const all = getAllSkills().sort((a, b) => a.name.localeCompare(b.name));
|
|
380
|
+
if (action === "enable" || action === "disable") {
|
|
381
|
+
const scope = tokens.includes("--project") ? "project" : "global";
|
|
382
|
+
const names = tokens.slice(1).filter((t) => t !== "--project" && t !== "--global");
|
|
383
|
+
if (names.length === 0) {
|
|
384
|
+
sysMsg(tui, `用法: /skills ${action} <name...> [--project]`);
|
|
385
|
+
break;
|
|
386
|
+
}
|
|
387
|
+
const known = new Set(all.map((s) => s.name));
|
|
388
|
+
const unknown = names.filter((n) => !known.has(n));
|
|
389
|
+
if (unknown.length > 0) {
|
|
390
|
+
sysMsg(tui, `未找到 skill: ${unknown.join(", ")}\n可用: ${known.size ? [...known].join(", ") : "无"}`);
|
|
391
|
+
break;
|
|
392
|
+
}
|
|
393
|
+
const results = setSkillEnabled(names, action === "enable", scope);
|
|
394
|
+
const notes = results
|
|
395
|
+
.filter((r) => r.blockedBy)
|
|
396
|
+
.map((r) => ` ⚠ ${r.name} 仍为${r.enabled ? "启用" : "禁用"}(被 ${r.blockedBy} 范围覆盖)`);
|
|
397
|
+
sysMsg(tui, [`✓ 已${action === "enable" ? "启用" : "禁用"}(${scope}): ${names.join(", ")}`, ...notes].join("\n"));
|
|
398
|
+
break;
|
|
399
|
+
}
|
|
400
|
+
if (action === "info") {
|
|
401
|
+
const name = tokens[1];
|
|
402
|
+
const skill = name ? getSkill(name) : undefined;
|
|
403
|
+
if (!skill) {
|
|
404
|
+
sysMsg(tui, `未找到 skill: ${name ?? "(未指定)"}\n可用: ${all.length ? all.map((s) => s.name).join(", ") : "无"}`);
|
|
405
|
+
break;
|
|
406
|
+
}
|
|
407
|
+
const status = skill.enabled ? "enabled" : `disabled(${skill.disabledScope === "project" ? "proj" : "glob"})`;
|
|
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
|
+
];
|
|
418
|
+
const content = skill.content.trim();
|
|
419
|
+
sysMsg(tui, [...meta, ...(content ? ["", "--- Content ---", "", content.slice(0, 4000)] : [])].join("\n"));
|
|
420
|
+
break;
|
|
421
|
+
}
|
|
422
|
+
if (action && action !== "list") {
|
|
423
|
+
sysMsg(tui, "用法: /skills [list|info <name>|enable <name...> [--project]|disable <name...> [--project]]");
|
|
424
|
+
break;
|
|
425
|
+
}
|
|
426
|
+
const stale = getStaleDisabledSkills();
|
|
427
|
+
if (all.length === 0 && stale.length === 0) {
|
|
428
|
+
sysMsg(tui, "无可用 skills。添加 SKILL.md 到 .min-agent/skills/<name>/");
|
|
429
|
+
break;
|
|
430
|
+
}
|
|
431
|
+
const lines = all.map((s) => {
|
|
432
|
+
const status = s.enabled ? "enabled" : `disabled(${s.disabledScope === "project" ? "proj" : "glob"})`;
|
|
433
|
+
const origin = s.builtin ? " 内置" : "";
|
|
434
|
+
return ` ${s.name} ${status}${origin} ${truncateDisplay(s.description.replace(/\s+/g, " "), 56)}`;
|
|
435
|
+
});
|
|
436
|
+
if (stale.length > 0)
|
|
437
|
+
lines.push(` ⚠ disabledSkills/enabledSkills 中无对应 skill: ${stale.join(", ")}`);
|
|
438
|
+
sysMsg(tui, `Skills (${all.filter((s) => s.enabled).length} enabled / ${all.length}):\n${lines.join("\n")}\n切换: /skills disable <name> [--project]`);
|
|
439
|
+
break;
|
|
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
|
+
}
|
|
495
|
+
case "help":
|
|
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"));
|
|
541
|
+
break;
|
|
542
|
+
default:
|
|
543
|
+
sysMsg(tui, `未知命令: /${cmd},输入 /help 查看可用命令`);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal display-width helpers.
|
|
3
|
+
*
|
|
4
|
+
* CJK / fullwidth characters occupy two terminal cells, combining marks and
|
|
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
|
+
*/
|
|
9
|
+
const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
10
|
+
const EXT_PICT = /\p{Extended_Pictographic}/u;
|
|
11
|
+
/** Zero-width: combining marks, variation selectors, zero-width spaces. */
|
|
12
|
+
function isZeroWidth(cp) {
|
|
13
|
+
return ((cp >= 0x0300 && cp <= 0x036f) ||
|
|
14
|
+
(cp >= 0x0483 && cp <= 0x0489) ||
|
|
15
|
+
(cp >= 0x0591 && cp <= 0x05bd) ||
|
|
16
|
+
(cp >= 0x0610 && cp <= 0x061a) ||
|
|
17
|
+
(cp >= 0x064b && cp <= 0x065f) ||
|
|
18
|
+
(cp >= 0x0e31 && cp <= 0x0e3a) ||
|
|
19
|
+
(cp >= 0x0e47 && cp <= 0x0e4e) ||
|
|
20
|
+
(cp >= 0x1ab0 && cp <= 0x1aff) ||
|
|
21
|
+
(cp >= 0x1dc0 && cp <= 0x1dff) ||
|
|
22
|
+
(cp >= 0x200b && cp <= 0x200f) ||
|
|
23
|
+
(cp >= 0x2060 && cp <= 0x2064) ||
|
|
24
|
+
(cp >= 0x20d0 && cp <= 0x20ff) ||
|
|
25
|
+
(cp >= 0xfe00 && cp <= 0xfe0f) ||
|
|
26
|
+
(cp >= 0xfe20 && cp <= 0xfe2f) ||
|
|
27
|
+
cp === 0xfeff ||
|
|
28
|
+
(cp >= 0xe0100 && cp <= 0xe01ef));
|
|
29
|
+
}
|
|
30
|
+
/** East Asian Wide / Fullwidth, plus the emoji blocks terminals render double. */
|
|
31
|
+
function isFullWidth(cp) {
|
|
32
|
+
return ((cp >= 0x1100 && cp <= 0x115f) ||
|
|
33
|
+
(cp >= 0x2e80 && cp <= 0x303e) ||
|
|
34
|
+
(cp >= 0x3041 && cp <= 0x33ff) ||
|
|
35
|
+
(cp >= 0x3400 && cp <= 0x4dbf) ||
|
|
36
|
+
(cp >= 0x4e00 && cp <= 0x9fff) ||
|
|
37
|
+
(cp >= 0xa000 && cp <= 0xa4cf) ||
|
|
38
|
+
(cp >= 0xa960 && cp <= 0xa97f) ||
|
|
39
|
+
(cp >= 0xac00 && cp <= 0xd7a3) ||
|
|
40
|
+
(cp >= 0xf900 && cp <= 0xfaff) ||
|
|
41
|
+
(cp >= 0xfe10 && cp <= 0xfe19) ||
|
|
42
|
+
(cp >= 0xfe30 && cp <= 0xfe6f) ||
|
|
43
|
+
(cp >= 0xff00 && cp <= 0xff60) ||
|
|
44
|
+
(cp >= 0xffe0 && cp <= 0xffe6) ||
|
|
45
|
+
(cp >= 0x1f300 && cp <= 0x1f64f) ||
|
|
46
|
+
(cp >= 0x1f680 && cp <= 0x1f6ff) ||
|
|
47
|
+
(cp >= 0x1f900 && cp <= 0x1f9ff) ||
|
|
48
|
+
(cp >= 0x20000 && cp <= 0x2fffd) ||
|
|
49
|
+
(cp >= 0x30000 && cp <= 0x3fffd));
|
|
50
|
+
}
|
|
51
|
+
function codePointWidth(char) {
|
|
52
|
+
const cp = char.codePointAt(0);
|
|
53
|
+
if (cp === undefined)
|
|
54
|
+
return 0;
|
|
55
|
+
if (cp < 0x20 || (cp >= 0x7f && cp < 0xa0))
|
|
56
|
+
return 0;
|
|
57
|
+
if (isZeroWidth(cp))
|
|
58
|
+
return 0;
|
|
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
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/** Display width of a string (single logical line, no ANSI escapes). */
|
|
84
|
+
export function displayWidth(text) {
|
|
85
|
+
let width = 0;
|
|
86
|
+
for (const { width: w } of clustersOf(text))
|
|
87
|
+
width += w;
|
|
88
|
+
return width;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Break a single logical line into visual rows of at most `maxWidth` cells.
|
|
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.
|
|
94
|
+
*/
|
|
95
|
+
export function wrapByWidth(line, maxWidth) {
|
|
96
|
+
const text = typeof line === "string" ? line : line == null ? "" : String(line);
|
|
97
|
+
if (maxWidth < 1)
|
|
98
|
+
return [text];
|
|
99
|
+
const rows = [];
|
|
100
|
+
let current = "";
|
|
101
|
+
let width = 0;
|
|
102
|
+
for (const { text: g, width: w } of clustersOf(text)) {
|
|
103
|
+
if (width + w > maxWidth && current.length > 0) {
|
|
104
|
+
rows.push(current);
|
|
105
|
+
current = "";
|
|
106
|
+
width = 0;
|
|
107
|
+
}
|
|
108
|
+
current += g;
|
|
109
|
+
width += w;
|
|
110
|
+
}
|
|
111
|
+
rows.push(current);
|
|
112
|
+
return rows;
|
|
113
|
+
}
|