thincoder 0.12.54 → 0.12.58
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/CHANGELOG.md +57 -0
- package/bin/thincoder.mjs +17 -3
- package/package.json +3 -7
- package/src/acp/bridge.mjs +1 -1
- package/src/advisor/messages.mjs +4 -2
- package/src/advisor/run.mjs +2 -2
- package/src/agent/dispatch.mjs +66 -26
- package/src/agent/helpers.mjs +13 -2
- package/src/agent/setup.mjs +14 -2
- package/src/agent/spawn-child.mjs +3 -1
- package/src/agent-tools/advisor.mjs +19 -9
- package/src/agent-tools/eng.mjs +2 -0
- package/src/agent-tools/subagent-check.mjs +107 -0
- package/src/agent-tools/subagent.mjs +205 -42
- package/src/agent.mjs +68 -3
- package/src/cli/make-agent.mjs +25 -0
- package/src/cli/memory-command.mjs +28 -7
- package/src/config.mjs +120 -8
- package/src/context.mjs +28 -7
- package/src/escape.mjs +76 -23
- package/src/mcp/transport-http.mjs +13 -1
- package/src/mcp.mjs +52 -7
- package/src/memory/core.mjs +78 -10
- package/src/memory/docs.mjs +33 -7
- package/src/memory.mjs +1 -1
- package/src/model-specs.mjs +23 -0
- package/src/prompts/discipline.md +15 -1
- package/src/prompts/engineering.md +62 -5
- package/src/prompts/main.md +1 -0
- package/src/prompts/system.md +2 -1
- package/src/provider/anthropic.mjs +7 -5
- package/src/provider/core.mjs +48 -26
- package/src/provider/google.mjs +57 -24
- package/src/provider/normalize.mjs +1 -1
- package/src/provider/rate.mjs +0 -2
- package/src/provider/responses.mjs +8 -13
- package/src/provider/sse.mjs +20 -0
- package/src/session.mjs +15 -0
- package/src/tools/apply_patch.md +2 -0
- package/src/tools/bash.md +2 -2
- package/src/tools/edit-batch.mjs +104 -0
- package/src/tools/edit.md +3 -0
- package/src/tools/execute.md +4 -4
- package/src/tools/execute.mjs +14 -22
- package/src/tools/file.mjs +17 -55
- package/src/tools/file_ops.md +1 -1
- package/src/tools/git.md +1 -1
- package/src/tools/git.mjs +8 -16
- package/src/tools/lint.md +1 -1
- package/src/tools/linter.mjs +9 -37
- package/src/tools/patch.mjs +1 -1
- package/src/tools/shared.mjs +7 -20
- package/src/tui/agent-turn.mjs +3 -3
- package/src/tui/clipboard.mjs +2 -2
- package/src/tui/cmd-eng.mjs +1 -0
- package/src/tui/cmd-mcp-form.mjs +197 -0
- package/src/tui/cmd-mcp.mjs +255 -114
- package/src/tui/index.mjs +25 -5
- package/src/tui/interaction.mjs +28 -1
- package/src/tui/key-handler.mjs +14 -2
- package/src/tui/mouse.mjs +1 -1
- package/src/tui/pickers.mjs +62 -4
- package/src/tui/render-frame.mjs +18 -10
- package/src/tui/render.mjs +4 -4
- package/src/tui/startup.mjs +4 -2
- package/src/tui/subagent-blocks.mjs +119 -4
- package/src/tui/tool-events.mjs +60 -15
package/src/tui/cmd-mcp.mjs
CHANGED
|
@@ -1,16 +1,28 @@
|
|
|
1
1
|
import { ansi, C } from "./ansi.mjs"
|
|
2
|
+
import { cloneEntry, fieldPicker, maskToken } from "./cmd-mcp-form.mjs"
|
|
2
3
|
|
|
3
|
-
/** /mcp command handler: view/add/remove/reconnect MCP server.
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
/** /mcp command handler: view/add/edit/remove/test/reconnect MCP server.
|
|
5
|
+
* ctx: { agent, pushLine, pushLabel, showPicker, askQuestion, persistRaw, ansi, C }
|
|
6
|
+
* MCP.md §5(2026-09-02,v2):列表即菜单(F7/D-2)、edit/add 统一字段 picker 表单
|
|
7
|
+
* (F3/F3b/D-1——表单机制在 cmd-mcp-form.mjs,本文件只调 fieldPicker)、保存前预览
|
|
8
|
+
* +探活(F2:探活失败回同一 fieldPicker——AC2)、AI 降 transport picker 末位(F4)、
|
|
9
|
+
* 菜单打开边界磁盘重读 config.json(F5①/D-3,reloadMcpFromDisk in config.mjs)、
|
|
10
|
+
* save&test 确认问句废除(§5 变更段 D-Q1:probe ✓ 直接保存、✗ 报错回表单零保存通道)。 */
|
|
6
11
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
/** F7/D-2:主菜单行与子菜单 picker 行共用——●/○ 连接态 + (端点) + N tools。 */
|
|
13
|
+
function serverLine(srv, connected, toolCount) {
|
|
14
|
+
const desc = srv.wsUrl ? srv.wsUrl : srv.url ? srv.url : `${srv.command} ${(srv.args ?? []).join(" ")}`
|
|
15
|
+
return `${connected ? "●" : "○"} ${srv.name} (${desc})${connected ? ` — ${toolCount} tools` : ""}`
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** persistRaw/edit 落盘的磁盘 entry 形态(name + 按 transport 的可选字段)——add 与
|
|
19
|
+
* edit 共用,两处序列化不再漂移。 */
|
|
20
|
+
function configEntry(srv) {
|
|
21
|
+
const entry = { name: srv.name }
|
|
22
|
+
if (srv.url) { entry.url = srv.url; if (srv.token) entry.token = srv.token; if (srv.headers) entry.headers = srv.headers }
|
|
23
|
+
else if (srv.wsUrl) { entry.wsUrl = srv.wsUrl; if (srv.token) entry.token = srv.token; if (srv.headers) entry.headers = srv.headers }
|
|
24
|
+
else { entry.command = srv.command; if (srv.args) entry.args = srv.args; if (srv.env) entry.env = srv.env }
|
|
25
|
+
return entry
|
|
14
26
|
}
|
|
15
27
|
|
|
16
28
|
/** /mcp shared helper: save config + connect (persistRaw obtained from ctx) */
|
|
@@ -18,11 +30,7 @@ async function addAndConnect(ctx, srv) {
|
|
|
18
30
|
const { agent, pushLine, pushLabel, persistRaw } = ctx
|
|
19
31
|
await persistRaw((raw) => {
|
|
20
32
|
raw.mcp ??= { servers: [] }
|
|
21
|
-
|
|
22
|
-
if (srv.url) { entry.url = srv.url; if (srv.headers) entry.headers = srv.headers }
|
|
23
|
-
else if (srv.wsUrl) { entry.wsUrl = srv.wsUrl; if (srv.headers) entry.headers = srv.headers }
|
|
24
|
-
else { entry.command = srv.command; if (srv.args) entry.args = srv.args; if (srv.env) entry.env = srv.env }
|
|
25
|
-
raw.mcp.servers.push(entry)
|
|
33
|
+
raw.mcp.servers.push(configEntry(srv))
|
|
26
34
|
})
|
|
27
35
|
agent.config ??= {}
|
|
28
36
|
agent.config.mcp ??= { servers: [] }
|
|
@@ -45,6 +53,20 @@ export async function handleMcpCommand(ctx, args = []) {
|
|
|
45
53
|
const { agent, pushLine, pushLabel, showPicker, askQuestion, persistRaw } = ctx
|
|
46
54
|
// 每轮重读:原本无 mcp 配置时 `?? []` 会拿到游离数组,Add server 后快照过期
|
|
47
55
|
const getServers = () => agent.config?.mcp?.servers ?? []
|
|
56
|
+
// D-3/T24:本菜单会话内最新一次磁盘重读是否失败(畸形 config.json → 内存态兜底)
|
|
57
|
+
let diskUnreadable = false
|
|
58
|
+
/** F5①/D-3:菜单打开边界统一走这里——磁盘→内存仅替换 mcp 段(config.mjs 的
|
|
59
|
+
* reloadMcpFromDisk)。畸形磁盘配置回退内存态 + 提示行(T24);persistRaw 落盘后
|
|
60
|
+
* 重读幂等(fingerprint 一致 → 无 ⚠ disk changed 标记,D-3 防环)。ctx.configPath
|
|
61
|
+
* 测试注入 tmp config 路径用(生产 undefined → 默认 ~/.thincoder)。 */
|
|
62
|
+
let changedNames = [] // 最近一次成功重读的对账结果(T23 ⚠ 标记)
|
|
63
|
+
async function reloadFromDisk() {
|
|
64
|
+
const { reloadMcpFromDisk } = await import("../config.mjs")
|
|
65
|
+
const r = reloadMcpFromDisk(agent, ctx.configPath)
|
|
66
|
+
diskUnreadable = !r.ok
|
|
67
|
+
if (r.ok) changedNames = r.changedNames
|
|
68
|
+
return r
|
|
69
|
+
}
|
|
48
70
|
|
|
49
71
|
function listServers() {
|
|
50
72
|
const servers = getServers()
|
|
@@ -54,17 +76,15 @@ export async function handleMcpCommand(ctx, args = []) {
|
|
|
54
76
|
}
|
|
55
77
|
for (const srv of servers) {
|
|
56
78
|
const connected = agent.tools.some((t) => t._mcpName === srv.name)
|
|
57
|
-
const mark = connected ? "●" : "○"
|
|
58
|
-
const color = connected ? C.tool : C.dim
|
|
59
79
|
const toolCount = agent.tools.filter((t) => t._mcpName === srv.name).length
|
|
60
|
-
|
|
61
|
-
pushLine(` ${mark} ${srv.name} (${desc})${connected ? ` — ${toolCount} tools` : ""}`, color)
|
|
80
|
+
pushLine(` ${serverLine(srv, connected, toolCount)}`, connected ? C.tool : C.dim)
|
|
62
81
|
}
|
|
63
82
|
}
|
|
64
83
|
|
|
65
84
|
async function removeServer(name) {
|
|
66
85
|
agent.config.mcp.servers = getServers().filter((s) => s.name !== name)
|
|
67
|
-
|
|
86
|
+
// 评审 #1:磁盘无 mcp 段时(T23 场景——mcp 段被整体删除而连接保留)raw.mcp 为 undefined → 先建段再写
|
|
87
|
+
await persistRaw((raw) => { raw.mcp ??= { servers: [] }; raw.mcp.servers = agent.config.mcp.servers })
|
|
68
88
|
// Remove from tool list
|
|
69
89
|
const { removeMcpTools } = await import("../mcp.mjs")
|
|
70
90
|
removeMcpTools(agent, name)
|
|
@@ -86,17 +106,59 @@ export async function handleMcpCommand(ctx, args = []) {
|
|
|
86
106
|
}
|
|
87
107
|
}
|
|
88
108
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
109
|
+
/** F2(D-1):预览表——name/transport/端点/token 遮蔽 + headers/env 键列表;
|
|
110
|
+
* probeLine 非空时拼在尾部(✓ C.tool 色 / ✗ C.error 色)——预览与探活报告同屏。 */
|
|
111
|
+
function showPreview(entry, probeLine) {
|
|
112
|
+
const endpoint = entry.wsUrl ?? entry.url ?? `${entry.command} ${(entry.args ?? []).join(" ")}`.trim()
|
|
113
|
+
const transport = entry.wsUrl ? "WebSocket" : entry.url ? "HTTP" : "stdio"
|
|
114
|
+
pushLine(` name: ${entry.name}`, C.tool)
|
|
115
|
+
pushLine(` transport: ${transport}`, C.tool)
|
|
116
|
+
pushLine(` endpoint: ${endpoint}`, C.tool)
|
|
117
|
+
if (entry.token) pushLine(` token: ${maskToken(entry.token)}`, C.tool)
|
|
118
|
+
if (entry.headers) pushLine(` headers: ${Object.keys(entry.headers).join(", ")}`, C.tool)
|
|
119
|
+
if (entry.env) pushLine(` env: ${Object.keys(entry.env).join(", ")}`, C.tool)
|
|
120
|
+
if (probeLine) pushLine(` ${probeLine}`, probeLine.startsWith("✓") ? C.tool : C.error)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** F2:保存前探活——probeMcpServer(§4 资产,零副作用:不进 _sessions、不动
|
|
124
|
+
* agent.tools、探完即关)。未保存的临时 entry 直接传 probe(§5 D-1)。 */
|
|
125
|
+
async function probeLineFor(entry) {
|
|
126
|
+
pushLine(`[mcp] Probing ${entry.name}...`, C.dim)
|
|
127
|
+
const { probeMcpServer } = await import("../mcp.mjs")
|
|
128
|
+
const r = await probeMcpServer(entry)
|
|
129
|
+
return r.ok ? `✓ ${r.toolCount} tools, ${r.latencyMs}ms` : `✗ ${r.error}`
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** F2 确认环(D-Q1,2026-09-02 变更段):预览+探活 → probe ✓ 直接保存(返回
|
|
133
|
+
* entry——无任何问句);probe ✗ → 报错 + retryEntry 回 fieldPicker 改字段复 probe
|
|
134
|
+
* (AC2:retryEntry 回调复用同一表单、已改值保留;save-anyway 整个废除——失败
|
|
135
|
+
* 配置零保存通道)。返回 entry / null(null = retryEntry 表单层 Esc 放弃)。 */
|
|
136
|
+
async function confirmLoop(entry, retryEntry) {
|
|
137
|
+
for (;;) {
|
|
138
|
+
pushLabel(`❯ MCP Preview`, ansi.bold + C.tool)
|
|
139
|
+
const probe = await probeLineFor(entry)
|
|
140
|
+
showPreview(entry, probe)
|
|
141
|
+
if (probe.startsWith("✓")) return entry // D-Q1:探活成功 → 直接保存(问句废除)
|
|
142
|
+
// D-Q1:探活失败 → 无保存通道——报错 + 回 fieldPicker 改字段复 probe(AC2)
|
|
143
|
+
pushLine(`[mcp] Probe failed: ${probe.slice(2)} — fix it in the form`, C.error)
|
|
144
|
+
const retried = await retryEntry(entry)
|
|
145
|
+
if (!retried) return null
|
|
146
|
+
entry = retried
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** F4:AI 生成配置(transport picker 末位——文案不再首推)。生成的 entry 同走
|
|
151
|
+
* 预览+探活+确认环(F2 语义一致——D-Q1:✓ 直接保存 / ✗ 回表单)。 */
|
|
152
|
+
async function addWithAI() {
|
|
153
|
+
const description = await askQuestion("Describe the MCP server you want to add (e.g. 'a filesystem server that gives access to /tmp'):")
|
|
154
|
+
if (!description) return
|
|
155
|
+
pushLine("[mcp] Generating config from description...", C.dim)
|
|
156
|
+
try {
|
|
157
|
+
const { chat } = await import("../provider/index.mjs")
|
|
158
|
+
const res = await chat(agent.provider, {
|
|
159
|
+
messages: [{
|
|
160
|
+
role: "user",
|
|
161
|
+
content: `Generate an MCP server configuration JSON from this description. Return ONLY the JSON object, no explanation.
|
|
100
162
|
|
|
101
163
|
Description: "${description}"
|
|
102
164
|
|
|
@@ -109,130 +171,209 @@ Example HTTP: {"name":"filesystem","url":"https://example.com/mcp","headers":{"A
|
|
|
109
171
|
Example stdio: {"name":"filesystem","command":"npx","args":["-y","@modelcontextprotocol/server-filesystem","/tmp"]}
|
|
110
172
|
|
|
111
173
|
Return ONLY the JSON object:`,
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
174
|
+
}],
|
|
175
|
+
tools: [],
|
|
176
|
+
signal: AbortSignal.timeout(15_000),
|
|
177
|
+
})
|
|
178
|
+
const jsonMatch = (res.content ?? "").match(/\{[\s\S]*\}/)
|
|
179
|
+
if (!jsonMatch) { pushLine("[mcp] AI response not valid JSON", C.error); return }
|
|
180
|
+
const srv = JSON.parse(jsonMatch[0])
|
|
181
|
+
if (!srv.name) { pushLine("[mcp] AI response missing 'name' field", C.error); return }
|
|
182
|
+
// F2:同一预览+探活+确认环;探活失败回 fieldPicker 重输(AC2——AI 生成的 entry
|
|
183
|
+
// 字段不完整时可在表单里补齐/修正,mode add = name 可改)
|
|
184
|
+
const saved = await confirmLoop(srv, async (cur) => {
|
|
185
|
+
const r = await fieldPicker(ctx, {
|
|
186
|
+
title: `Fix MCP: ${cur.name ?? "ai"}`,
|
|
187
|
+
mode: "add",
|
|
188
|
+
entry: cur,
|
|
189
|
+
existingNames: getServers().map((s) => s.name),
|
|
115
190
|
})
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
const confirm = await askQuestion("Add this server? (y/n):")
|
|
123
|
-
if (confirm?.toLowerCase() !== "y") { pushLine("[mcp] Cancelled", C.dim); return }
|
|
124
|
-
await addAndConnect(ctx, srv)
|
|
125
|
-
} catch (err) {
|
|
126
|
-
pushLine(`[mcp] AI generation failed: ${err.message}`, C.error)
|
|
127
|
-
}
|
|
128
|
-
return
|
|
191
|
+
return r.action === "save" ? r.entry : null
|
|
192
|
+
})
|
|
193
|
+
if (saved) await addAndConnect(ctx, saved)
|
|
194
|
+
else pushLine("[mcp] Cancelled", C.dim)
|
|
195
|
+
} catch (err) {
|
|
196
|
+
pushLine(`[mcp] AI generation failed: ${err.message}`, C.error)
|
|
129
197
|
}
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
const urlPrompt = transport === "ws" ? "WebSocket URL (ws://…):" : "HTTP URL (https://…):"
|
|
144
|
-
const url = await askQuestion(urlPrompt)
|
|
145
|
-
if (!url) return
|
|
146
|
-
const headersInput = await askQuestion("Headers (key=value, space-separated, or leave empty):")
|
|
147
|
-
const headers = headersInput ? parseHeaders(headersInput.split(/\s+/)) : undefined
|
|
148
|
-
const srv = transport === "ws"
|
|
149
|
-
? { name, wsUrl: url, headers: Object.keys(headers ?? {}).length > 0 ? headers : undefined }
|
|
150
|
-
: { name, url, headers: Object.keys(headers ?? {}).length > 0 ? headers : undefined }
|
|
151
|
-
await addAndConnect(ctx, srv)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** F1/F3b(D-1 v2):add 流程——fieldPicker 空 entry 起(必填 (required) 标注、
|
|
201
|
+
* 只填所选字段、headers/env 不选即跳过)→ 预览+探活+确认环 → addAndConnect。 */
|
|
202
|
+
async function addWithTransport(transport) {
|
|
203
|
+
if (transport === "ai") { await addWithAI(); return }
|
|
204
|
+
const entry = {}
|
|
205
|
+
const existingNames = getServers().map((s) => s.name)
|
|
206
|
+
const formOpts = {
|
|
207
|
+
title: `Add MCP: ${transport === "ws" ? "WebSocket" : transport === "http" ? "HTTP" : "stdio"}`,
|
|
208
|
+
mode: "add",
|
|
209
|
+
transport,
|
|
210
|
+
existingNames,
|
|
152
211
|
}
|
|
212
|
+
const first = await fieldPicker(ctx, { ...formOpts, entry })
|
|
213
|
+
if (first.action === "cancel") return
|
|
214
|
+
const saved = await confirmLoop(first.entry, async (cur) => {
|
|
215
|
+
const r = await fieldPicker(ctx, { ...formOpts, entry: cur })
|
|
216
|
+
return r.action === "save" ? r.entry : null
|
|
217
|
+
})
|
|
218
|
+
if (saved) await addAndConnect(ctx, saved)
|
|
219
|
+
else pushLine("[mcp] Cancelled", C.dim)
|
|
153
220
|
}
|
|
154
221
|
|
|
222
|
+
/** F4:transport picker——HTTP / WebSocket / stdio / AI(末位,文案不再首推)。 */
|
|
155
223
|
async function addFlow() {
|
|
156
|
-
// Pick transport type first, then ask name + URL/command
|
|
157
224
|
const te = await showPicker("MCP Transport", [
|
|
158
|
-
{ type: "header", text: "Select transport
|
|
159
|
-
{ type: "item", text: "🤖 Describe with AI — natural language → config", action: "ai" },
|
|
225
|
+
{ type: "header", text: "Select transport" },
|
|
160
226
|
{ type: "item", text: "HTTP (https://…)", action: "http" },
|
|
161
227
|
{ type: "item", text: "WebSocket (ws://…)", action: "ws" },
|
|
162
228
|
{ type: "item", text: "stdio (local command)", action: "stdio" },
|
|
229
|
+
{ type: "item", text: "AI — describe in natural language", action: "ai" },
|
|
163
230
|
])
|
|
164
231
|
if (te) await addWithTransport(te.action)
|
|
165
232
|
}
|
|
166
233
|
|
|
167
|
-
/**
|
|
168
|
-
|
|
234
|
+
/** 服务器选择共用:带 name 直达(校验存在性);无 name → picker。返回 srv / null。
|
|
235
|
+
* NF1:带 name 的直达路径语义与旧实现逐字一致(空列表也报 no server named)。 */
|
|
236
|
+
async function resolveServer(nameArg, { title, headerText }) {
|
|
169
237
|
const servers = getServers()
|
|
238
|
+
if (nameArg) {
|
|
239
|
+
const srv = servers.find((s) => s.name === nameArg) // NF1:与旧直达路径逐字一致(精确匹配)
|
|
240
|
+
if (!srv) {
|
|
241
|
+
pushLine(`[mcp] no server named "${nameArg}" (${servers.map((s) => s.name).join(", ") || "none configured"})`, C.error)
|
|
242
|
+
return null
|
|
243
|
+
}
|
|
244
|
+
return srv
|
|
245
|
+
}
|
|
170
246
|
if (servers.length === 0) {
|
|
171
247
|
pushLine("[mcp] no MCP server configured", C.error)
|
|
172
|
-
return
|
|
248
|
+
return null
|
|
173
249
|
}
|
|
174
|
-
const
|
|
175
|
-
{ type: "header", text:
|
|
250
|
+
const se = await showPicker(title, [
|
|
251
|
+
{ type: "header", text: headerText },
|
|
176
252
|
...servers.map((s) => ({ type: "item", text: `${s.name} (${s.wsUrl ?? s.url ?? s.command})`, name: s.name })),
|
|
177
|
-
]
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
if (action === "remove") await removeServer(se.name)
|
|
181
|
-
else await connectServer(se.name)
|
|
182
|
-
return true
|
|
253
|
+
])
|
|
254
|
+
if (!se) return null // Esc 取消
|
|
255
|
+
return servers.find((s) => s.name === se.name) ?? null
|
|
183
256
|
}
|
|
184
257
|
|
|
185
|
-
|
|
258
|
+
/** F4(§4 资产):`/mcp test [name]`——probeMcpServer 一次性探活(零副作用)。 */
|
|
259
|
+
async function testServer(nameArg) {
|
|
260
|
+
const srv = await resolveServer(nameArg, { title: "Test MCP Server", headerText: "Select server to test" })
|
|
261
|
+
if (!srv) return
|
|
262
|
+
pushLine(`[mcp] Testing ${srv.name}...`, C.dim)
|
|
263
|
+
const r = await probeLineFor(srv)
|
|
264
|
+
pushLine(`[mcp] ${srv.name}: ${r}`, r.startsWith("✓") ? C.tool : C.error)
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** F3(D-1 v2):edit = 字段 picker 表单——fieldPicker 列可编辑字段行(URL/Token/
|
|
268
|
+
* Headers 或 Command/Args/Env;name 不可改——无 name 行)+ `✓ Save & test`;
|
|
269
|
+
* 收集完成走 confirmLoop(预览+探活——D-Q1:✓ 直接保存 / ✗ 回 fieldPicker
|
|
270
|
+
* AC2/AC5)→ persistRaw 原位替换保数组序 → connectServer 自动重连。取消 → 零保存。 */
|
|
271
|
+
async function editServerWithConfirm(srv) {
|
|
272
|
+
const name = srv.name
|
|
273
|
+
const entry = cloneEntry(srv) // 工作副本——取消不污染原配置
|
|
274
|
+
const formOpts = { title: `Edit MCP: ${name}`, mode: "edit" }
|
|
275
|
+
const first = await fieldPicker(ctx, { ...formOpts, entry })
|
|
276
|
+
if (first.action === "cancel") { pushLine("[mcp] Edit cancelled — nothing saved", C.dim); return }
|
|
277
|
+
const saved = await confirmLoop(first.entry, async (cur) => {
|
|
278
|
+
const r = await fieldPicker(ctx, { ...formOpts, entry: cur })
|
|
279
|
+
return r.action === "save" ? r.entry : null
|
|
280
|
+
})
|
|
281
|
+
if (!saved) { pushLine("[mcp] Edit cancelled — nothing saved", C.dim); return }
|
|
282
|
+
await persistRaw((raw) => {
|
|
283
|
+
// 评审 #2:磁盘无 mcp 段时(T23 场景)先建段——否则静默 return 导致"updated"报错但磁盘零写入
|
|
284
|
+
raw.mcp ??= { servers: [] }
|
|
285
|
+
const servers = raw.mcp.servers
|
|
286
|
+
if (!Array.isArray(servers)) return
|
|
287
|
+
const idx = servers.findIndex((s) => s?.name === name)
|
|
288
|
+
if (idx === -1) return
|
|
289
|
+
servers[idx] = configEntry(saved) // 原位替换——数组序保持
|
|
290
|
+
})
|
|
291
|
+
agent.config.mcp.servers = getServers().map((s) => (s.name === name ? saved : s))
|
|
292
|
+
pushLine(`[mcp] ${name} updated`, C.tool)
|
|
293
|
+
await connectServer(name) // F3:自动重连
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** edit 入口:`/mcp edit [name]`——带 name 直达,否则 picker 选择。 */
|
|
297
|
+
async function editFlowWithConfirm(nameArg) {
|
|
298
|
+
const srv = await resolveServer(nameArg, { title: "Edit MCP Server", headerText: "Select server to edit" })
|
|
299
|
+
if (!srv) return
|
|
300
|
+
await editServerWithConfirm(srv)
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Direct args: /mcp list │ /mcp add │ /mcp http|ws|stdio|ai │ /mcp edit [name] │ /mcp test [name] │ /mcp remove [name] │ /mcp connect [name]
|
|
186
304
|
const sub = args[0]?.toLowerCase()
|
|
187
305
|
if (sub === "list") { listServers(); return }
|
|
188
306
|
if (sub === "add") { await addFlow(); return }
|
|
189
307
|
if (sub === "http" || sub === "ws" || sub === "stdio" || sub === "ai") { await addWithTransport(sub); return }
|
|
308
|
+
if (sub === "edit") { await editFlowWithConfirm(args[1]); return }
|
|
309
|
+
if (sub === "test") { await testServer(args[1]); return }
|
|
190
310
|
if (sub === "remove" || sub === "connect") {
|
|
191
|
-
const
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
if (sub === "remove") await removeServer(name)
|
|
199
|
-
else await connectServer(name)
|
|
200
|
-
return
|
|
201
|
-
}
|
|
202
|
-
// 已明确 remove/connect 意图但没带 name → 直接进服务器选择 picker,不落主菜单
|
|
203
|
-
await pickAndRun(sub)
|
|
311
|
+
const srv = await resolveServer(args[1], {
|
|
312
|
+
title: sub === "remove" ? "Remove MCP Server" : "Reconnect MCP",
|
|
313
|
+
headerText: sub === "remove" ? "Select server to remove" : "Select server to reconnect",
|
|
314
|
+
})
|
|
315
|
+
if (!srv) return
|
|
316
|
+
if (sub === "remove") await removeServer(srv.name)
|
|
317
|
+
else await connectServer(srv.name)
|
|
204
318
|
return
|
|
205
319
|
} else if (sub) {
|
|
206
|
-
pushLine("Usage: /mcp [list|add|http|ws|stdio|ai|remove [name]|connect [name]]", C.error)
|
|
320
|
+
pushLine("Usage: /mcp [list|add|edit [name]|test [name]|http|ws|stdio|ai|remove [name]|connect [name]]", C.error)
|
|
207
321
|
return
|
|
208
322
|
}
|
|
209
323
|
|
|
210
|
-
//
|
|
324
|
+
// F7/D-2 主菜单循环:列表即菜单(server 行 → per-server 子菜单;Esc 回主菜单),
|
|
325
|
+
// 顶部固定 agent 代配提示行(F5②)+ 每轮边界磁盘重读(F5①/D-3)。
|
|
326
|
+
// 主菜单 Esc 退出;Add/Refresh 动作后 continue(Refresh 显式重开菜单重读磁盘)。
|
|
327
|
+
// server 行 action 用 `@name:` 命名空间——与 add/refresh 保留动作永不撞名
|
|
328
|
+
//(server 可以叫 "add" 或 "refresh")。
|
|
211
329
|
for (;;) {
|
|
330
|
+
await reloadFromDisk()
|
|
212
331
|
const servers = getServers()
|
|
332
|
+
const connected = (s) => agent.tools.some((t) => t._mcpName === s.name)
|
|
333
|
+
const toolCount = (s) => agent.tools.filter((t) => t._mcpName === s.name).length
|
|
213
334
|
const entries = [
|
|
214
|
-
{ type: "header", text:
|
|
215
|
-
{ type: "item", text: "View list", action: "list" },
|
|
216
|
-
{ type: "item", text: "Add server", action: "add" },
|
|
335
|
+
{ type: "header", text: "Tip: complex configs — ask the agent to edit the mcp.servers section of ~/.thincoder/config.json directly, then Reconnect here" },
|
|
217
336
|
]
|
|
337
|
+
if (diskUnreadable) entries.push({ type: "header", text: "⚠ disk config unreadable — showing in-memory state" })
|
|
218
338
|
if (servers.length > 0) {
|
|
339
|
+
// D-3/T23 对账:disk 删除/变更的已连接 server——连接不断,行尾 ⚠ disk changed
|
|
340
|
+
const drift = (s) => (changedNames.includes(s.name) ? " — ⚠ disk changed" : "")
|
|
219
341
|
entries.push(
|
|
220
|
-
{ type: "
|
|
221
|
-
{ type: "item", text:
|
|
342
|
+
{ type: "header", text: `${servers.length} MCP server${servers.length === 1 ? "" : "s"} configured` },
|
|
343
|
+
...servers.map((s) => ({ type: "item", text: `${serverLine(s, connected(s), toolCount(s))}${drift(s)}`, action: `@${s.name}:` })),
|
|
222
344
|
)
|
|
345
|
+
} else {
|
|
346
|
+
entries.push({ type: "header", text: "No MCP servers configured" })
|
|
223
347
|
}
|
|
348
|
+
entries.push(
|
|
349
|
+
{ type: "item", text: "+ Add server", action: "add" },
|
|
350
|
+
{ type: "item", text: "↻ Refresh", action: "refresh" },
|
|
351
|
+
)
|
|
224
352
|
const e = await showPicker("MCP", entries)
|
|
225
353
|
if (!e) return // Esc 退出
|
|
226
|
-
if (e.action === "
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
354
|
+
if (e.action === "add") { await addFlow(); continue }
|
|
355
|
+
if (e.action === "refresh") continue // 重开菜单即重读磁盘(reloadFromDisk 在循环顶部)
|
|
356
|
+
if (!e.action.startsWith("@")) continue // 未知动作防御——回主菜单
|
|
357
|
+
// 选中 server 行 → per-server 子菜单:Edit config / Test connection / Reconnect / Remove
|
|
358
|
+
const name = e.action.slice(1, -1)
|
|
359
|
+
const se = await showPicker(`MCP: ${name}`, [
|
|
360
|
+
{ type: "header", text: "Server actions" },
|
|
361
|
+
{ type: "item", text: "Edit config", action: "edit" },
|
|
362
|
+
{ type: "item", text: "Test connection", action: "test" },
|
|
363
|
+
{ type: "item", text: "Reconnect", action: "connect" },
|
|
364
|
+
{ type: "item", text: "Remove", action: "remove" },
|
|
365
|
+
])
|
|
366
|
+
if (!se) continue // 子菜单 Esc → 回主菜单
|
|
367
|
+
const srv = getServers().find((s) => s.name === name)
|
|
368
|
+
if (se.action === "edit") await editFlowWithConfirm(name)
|
|
369
|
+
else if (se.action === "test") await testServer(name)
|
|
370
|
+
else if (se.action === "connect") {
|
|
371
|
+
if (srv) await connectServer(name)
|
|
372
|
+
else pushLine(`[mcp] no server named "${name}"`, C.error)
|
|
373
|
+
} else if (se.action === "remove") {
|
|
374
|
+
if (srv) await removeServer(name)
|
|
375
|
+
else pushLine(`[mcp] no server named "${name}"`, C.error)
|
|
233
376
|
}
|
|
234
|
-
|
|
235
|
-
if (!done) continue // 子菜单 Esc → 回主菜单
|
|
236
|
-
return
|
|
377
|
+
continue
|
|
237
378
|
}
|
|
238
379
|
}
|
package/src/tui/index.mjs
CHANGED
|
@@ -48,6 +48,22 @@ export function pendingNoticeReady(state) {
|
|
|
48
48
|
return Boolean(state.pendingNotice && !state.picker && !state.permission && !state.question)
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
/**
|
|
52
|
+
* SESSION.md §8 D-S2 — TUI 启动首帧前的 provider 重选流程:
|
|
53
|
+
* provider 无效(`_providerInvalid` 标记或 provider 为 null)→ 先弹模型选择 picker
|
|
54
|
+
* (复用 openModelPicker,展示当前可用 providers);用户选定后继续正常启动。
|
|
55
|
+
* 选择取消(Esc)→ 仍进入 TUI,推送提示行("未配置有效 provider,可用 /model 选择或
|
|
56
|
+
* /provider 配置")——绝不因无 provider 拒绝进入。返回 true 表示弹过选择流程。
|
|
57
|
+
*/
|
|
58
|
+
export async function promptProviderIfInvalid(agent, openModelPicker, pushLine) {
|
|
59
|
+
if (!(agent._providerInvalid || !agent.provider)) return false
|
|
60
|
+
await openModelPicker()
|
|
61
|
+
if (!agent.provider) {
|
|
62
|
+
pushLine("未配置有效 provider,可用 /model 选择或 /provider 配置", C.warn)
|
|
63
|
+
}
|
|
64
|
+
return true
|
|
65
|
+
}
|
|
66
|
+
|
|
51
67
|
/**
|
|
52
68
|
* Start the TUI, taking over the terminal until exit.
|
|
53
69
|
* agent: return value of createAgent
|
|
@@ -221,7 +237,7 @@ export async function startTUI(agent, opts = {}) {
|
|
|
221
237
|
|
|
222
238
|
// Scroll wheel: \x1b[<64;col;rowM = up, \x1b[<65;col;rowM = down(3 lines each)
|
|
223
239
|
// 2026-08-31:坐标命中展开块内容行 → 块内滚动(handleWheel);未命中 → 会话滚动(现状)
|
|
224
|
-
//
|
|
240
|
+
// 有意为之:控制字符协议/转义序列剥离正则(ANSI/⟦ev⟧/SGR/history 双线分隔)
|
|
225
241
|
for (const m of text.matchAll(/\x1b\[<(\d+);(\d+);(\d+)([Mm])/g)) {
|
|
226
242
|
const button = Number(m[1])
|
|
227
243
|
if (button === 64 || button === 65) {
|
|
@@ -251,9 +267,9 @@ export async function startTUI(agent, opts = {}) {
|
|
|
251
267
|
}
|
|
252
268
|
|
|
253
269
|
// Strip complete mouse sequences; keep incomplete tail for reassembly with next chunk
|
|
254
|
-
//
|
|
270
|
+
// 有意为之:控制字符协议/转义序列剥离正则(ANSI/⟦ev⟧/SGR/history 双线分隔)
|
|
255
271
|
text = text.replace(/\x1b\[<\d+;\d+;\d+[Mm]/g, "")
|
|
256
|
-
//
|
|
272
|
+
// 有意为之:控制字符协议/转义序列剥离正则(ANSI/⟦ev⟧/SGR/history 双线分隔)
|
|
257
273
|
const tail = text.match(/\x1b\[<[\d;]*$/)
|
|
258
274
|
if (tail) {
|
|
259
275
|
mousePending = tail[0]
|
|
@@ -368,7 +384,7 @@ export async function startTUI(agent, opts = {}) {
|
|
|
368
384
|
}
|
|
369
385
|
|
|
370
386
|
// Interaction primitives: permission approval + Q&A input, implemented in interaction.mjs
|
|
371
|
-
const { askPermission, askQuestion } = createInteraction({
|
|
387
|
+
const { askPermission, askQuestion, askBatchPermission } = createInteraction({
|
|
372
388
|
agent, state, pushLine, pushLabel, render, summarize,
|
|
373
389
|
})
|
|
374
390
|
|
|
@@ -378,7 +394,7 @@ export async function startTUI(agent, opts = {}) {
|
|
|
378
394
|
// Agent loop: implemented in agent-turn.mjs
|
|
379
395
|
const turnCtx = {
|
|
380
396
|
agent, state, pushLine, pushLabel, render, scheduleRender: render, ensureAssistantLabel,
|
|
381
|
-
askPermission, askQuestion, handleSlash: null,
|
|
397
|
+
askPermission, askQuestion, askBatchPermission, handleSlash: null,
|
|
382
398
|
get assistantLabeled() { return assistantLabeled },
|
|
383
399
|
set assistantLabeled(v) { assistantLabeled = v },
|
|
384
400
|
}
|
|
@@ -443,6 +459,10 @@ export async function startTUI(agent, opts = {}) {
|
|
|
443
459
|
|
|
444
460
|
// ---------------------------------------------------------- Startup screen + background indexing
|
|
445
461
|
|
|
462
|
+
// SESSION.md §8 D-S2:startTUI 首帧前 —— provider 无效(_providerInvalid / provider 为 null)
|
|
463
|
+
// → 先弹模型选择 picker(keyStream 已挂 keypress,Esc/Enter 可用);Esc → 提示行,仍进 TUI
|
|
464
|
+
await promptProviderIfInvalid(agent, () => openModelPicker(), pushLine)
|
|
465
|
+
|
|
446
466
|
showStartup({ agent, state, opts, pushLine, pushLabel, render, startWizard })
|
|
447
467
|
backgroundIndex({ agent, state, render })
|
|
448
468
|
|
package/src/tui/interaction.mjs
CHANGED
|
@@ -62,6 +62,33 @@ export function createInteraction(ctx) {
|
|
|
62
62
|
})
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
/** Batch permission ask (AGENT-LOOP.md §16 D-B1): one merged prompt covering N
|
|
66
|
+
* non-readonly tools from the same toolCalls array — approve all / one by one /
|
|
67
|
+
* deny. Verdicts resolve as "approveAll" | "oneByOne" | "deny". */
|
|
68
|
+
const BATCH_PREVIEW_LINES_PER_TOOL = 8
|
|
69
|
+
function askBatchPermission({ tools, count }) {
|
|
70
|
+
if (agent.autoApprove) {
|
|
71
|
+
pushLine(` [auto] ${count} tools: ${tools.map((t) => t.name).join(", ")}`, C.warn)
|
|
72
|
+
return Promise.resolve("approveAll")
|
|
73
|
+
}
|
|
74
|
+
const names = [...new Set(tools.map((t) => t.name))]
|
|
75
|
+
const label = `${count} tools need permission: ${names.join(", ")}`
|
|
76
|
+
// Preview: one compact block per tool (name header + first N formatted lines)
|
|
77
|
+
state.permissionPreview = [
|
|
78
|
+
label,
|
|
79
|
+
...tools.flatMap((t) => {
|
|
80
|
+
const lines = formatPermission(t.name, t.args)
|
|
81
|
+
const shown = lines.slice(0, BATCH_PREVIEW_LINES_PER_TOOL)
|
|
82
|
+
return [` ${t.name}:`, ...shown.map((l) => ` ${l}`), ...(lines.length > shown.length ? [` … (${lines.length - shown.length} more lines)`] : [])]
|
|
83
|
+
}),
|
|
84
|
+
]
|
|
85
|
+
return new Promise((resolve) => {
|
|
86
|
+
state.permission = { name: label, args: {}, resolve, batch: { tools, count } }
|
|
87
|
+
state.status = `Waiting: ${label}`
|
|
88
|
+
render()
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
|
|
65
92
|
function askQuestion(text, options = []) {
|
|
66
93
|
// only one question at a time: question is a read-only tool on a parallel channel,
|
|
67
94
|
// a second concurrent one is rejected directly; otherwise the later one overwrites
|
|
@@ -91,5 +118,5 @@ export function createInteraction(ctx) {
|
|
|
91
118
|
})
|
|
92
119
|
}
|
|
93
120
|
|
|
94
|
-
return { askPermission, askQuestion, formatPermission }
|
|
121
|
+
return { askPermission, askQuestion, formatPermission, askBatchPermission }
|
|
95
122
|
}
|
package/src/tui/key-handler.mjs
CHANGED
|
@@ -26,15 +26,27 @@ export function createKeyHandler(ctx) {
|
|
|
26
26
|
|
|
27
27
|
return function onKeypress(str, key = {}) {
|
|
28
28
|
// permission confirm state: y approve / n deny / a approve + turn ON AUTO (no further prompts)
|
|
29
|
+
// batch state (§16 D-B1): a approve all / o one by one / n deny (Esc = deny)
|
|
29
30
|
if (state.permission) {
|
|
30
31
|
const answer = (str || "").toLowerCase()
|
|
31
32
|
const isContinue = state.permission.name === "continue"
|
|
32
|
-
const
|
|
33
|
+
const isBatch = Boolean(state.permission.batch)
|
|
34
|
+
const validKeys = isContinue ? ["y", "n"] : isBatch ? ["a", "o", "n"] : ["y", "n", "a"]
|
|
33
35
|
if (validKeys.includes(answer) || key.name === "escape") {
|
|
34
36
|
const { resolve, name } = state.permission
|
|
35
37
|
state.permission = null
|
|
36
38
|
state.permissionPreview = []
|
|
37
39
|
state.status = "Processing..."
|
|
40
|
+
if (isBatch) {
|
|
41
|
+
// Merged batch ask: resolve the verdict string; dispatch applies it
|
|
42
|
+
// (approveAll = batch-scope allowance only, NOT the persistent AUTO flag).
|
|
43
|
+
const verdict = answer === "a" ? "approveAll" : answer === "o" ? "oneByOne" : "deny"
|
|
44
|
+
const tone = verdict === "approveAll" ? C.dim : C.error
|
|
45
|
+
pushLine(` [${verdict === "approveAll" ? "approved" : verdict === "deny" ? "denied" : "one by one"}] ${name}`, tone)
|
|
46
|
+
resolve(verdict)
|
|
47
|
+
render()
|
|
48
|
+
return
|
|
49
|
+
}
|
|
38
50
|
if (answer === "a" && !isContinue) {
|
|
39
51
|
agent.autoApprove = true
|
|
40
52
|
agent._pendingReminders = agent._pendingReminders ?? []
|
|
@@ -255,7 +267,7 @@ export function createKeyHandler(ctx) {
|
|
|
255
267
|
} else if (str && !key.ctrl && !key.meta) {
|
|
256
268
|
// 输入即过滤;粘贴的多行文本先去换行(与输入框清洗口径一致),仍含控制字符则整段丢弃
|
|
257
269
|
const text = str.replace(/[\r\n]+/g, "")
|
|
258
|
-
//
|
|
270
|
+
// 有意为之:控制字符协议/转义序列剥离正则(ANSI/⟦ev⟧/SGR/history 双线分隔)
|
|
259
271
|
if (text && !/[\x00-\x1f\x7f]/.test(text)) applyFilter(p.filter + text)
|
|
260
272
|
}
|
|
261
273
|
return
|
package/src/tui/mouse.mjs
CHANGED
|
@@ -72,7 +72,7 @@ export function handleWheel(ctx, button, col, row) {
|
|
|
72
72
|
/** Extract left-click presses from a chunk. Returns [{ col, row }] (1-based). */
|
|
73
73
|
export function parseMouseClicks(text) {
|
|
74
74
|
const out = []
|
|
75
|
-
//
|
|
75
|
+
// 有意为之:控制字符协议/转义序列剥离正则(ANSI/⟦ev⟧/SGR/history 双线分隔)
|
|
76
76
|
for (const m of text.matchAll(/\x1b\[<0;(\d+);(\d+)M/g)) {
|
|
77
77
|
out.push({ col: Number(m[1]), row: Number(m[2]) })
|
|
78
78
|
}
|