thincoder 0.12.53 → 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 +74 -0
- package/bin/thincoder.mjs +17 -3
- package/package.json +3 -7
- package/src/acp/bridge.mjs +1 -1
- package/src/acp.mjs +60 -18
- 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 +16 -3
- 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 +110 -22
- package/src/git/checkpoint.mjs +32 -6
- 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 +17 -3
- 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 +90 -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-migrate.mjs +6 -0
- package/src/session-slots.mjs +361 -0
- package/src/session.mjs +282 -306
- 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-checkpoint.mjs +143 -0
- package/src/tools/git-ext.mjs +173 -0
- package/src/tools/git.md +21 -8
- package/src/tools/git.mjs +55 -177
- 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/ansi.mjs +2 -0
- 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/cmd-new.mjs +6 -6
- package/src/tui/cmd-restore.mjs +27 -6
- package/src/tui/cmd-session.mjs +17 -4
- package/src/tui/index.mjs +28 -27
- package/src/tui/interaction.mjs +28 -1
- package/src/tui/key-handler.mjs +14 -2
- package/src/tui/layout.mjs +81 -25
- package/src/tui/mouse.mjs +41 -2
- package/src/tui/pickers.mjs +62 -4
- package/src/tui/render-conversation.mjs +36 -93
- package/src/tui/render-frame.mjs +40 -16
- package/src/tui/render-loop.mjs +1 -1
- 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/subagent-panel.mjs +81 -0
- package/src/tui/tool-events.mjs +61 -16
- package/src/tui/tui-lifecycle.mjs +45 -0
package/src/tools/shared.mjs
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import { spawn, execFileSync, execFile } from "node:child_process"
|
|
7
7
|
import { readFileSync, existsSync, realpathSync, readdirSync, statSync, openSync, readSync, closeSync } from "node:fs"
|
|
8
|
-
import { dirname, join, resolve
|
|
8
|
+
import { dirname, join, resolve } from "node:path"
|
|
9
9
|
import { fileURLToPath } from "node:url"
|
|
10
10
|
|
|
11
11
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
@@ -170,7 +170,7 @@ export function toOpenAISchema(tool) {
|
|
|
170
170
|
/** Strip ANSI escape sequences */
|
|
171
171
|
export function sanitizeOutput(s) {
|
|
172
172
|
return s
|
|
173
|
-
|
|
173
|
+
// 有意为之:控制字符协议/转义序列剥离正则(ANSI/⟦ev⟧/SGR/history 双线分隔)
|
|
174
174
|
.replace(/\x1b\[[0-9;?]*[\x40-\x7E]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[()][0-9A-B]|\x1b[=>#][0-9]?/g, "")
|
|
175
175
|
.replace(/\r\n/g, "\n")
|
|
176
176
|
.replace(/\r/g, "\n")
|
|
@@ -276,27 +276,14 @@ function realCwd(cwd) {
|
|
|
276
276
|
return realCwdCache.get(cwd)
|
|
277
277
|
}
|
|
278
278
|
|
|
279
|
-
/**
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
// matches both / and \ traversal on the respective platform.
|
|
283
|
-
const rel = relative(cwd, resolved)
|
|
284
|
-
if (isAbsolute(rel) || rel === ".." || rel.startsWith(".." + sep)) {
|
|
285
|
-
throw new Error(`Access denied outside working directory: ${p}`)
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
/** Resolve a user-supplied path relative to cwd, asserting it stays within cwd */
|
|
279
|
+
/** Resolve a user-supplied path relative to cwd — no directory boundary assertion
|
|
280
|
+
* (§10.1 2026-09-02: workspace confinement removed; resolveInCwd ≡ resolveExternal,
|
|
281
|
+
* trust model + approval gate is the only guard — same boundary as bash). */
|
|
290
282
|
export function resolveInCwd(ctx, p) {
|
|
291
|
-
|
|
292
|
-
const resolved = resolve(cwd, p)
|
|
293
|
-
assertInside(cwd, resolved, p)
|
|
294
|
-
const real = realpathNearest(resolved)
|
|
295
|
-
assertInside(cwd, real, p)
|
|
296
|
-
return resolved
|
|
283
|
+
return resolveExternal(ctx, p)
|
|
297
284
|
}
|
|
298
285
|
|
|
299
|
-
/** Resolve a path relative to cwd without boundary check —
|
|
286
|
+
/** Resolve a path relative to cwd without boundary check — kept for compatibility (≡ resolveInCwd) */
|
|
300
287
|
export function resolveExternal(ctx, p) {
|
|
301
288
|
const cwd = realCwd(ctx.cwd)
|
|
302
289
|
return resolve(cwd, p)
|
package/src/tui/agent-turn.mjs
CHANGED
|
@@ -23,10 +23,10 @@ const DISTILL_FLUSH_TIMEOUT_MS = 5000
|
|
|
23
23
|
/** Execute one agent conversation turn (triggered by submit or queue).
|
|
24
24
|
* Extracted from index.mjs: agent loop + callback construction + error handling + queue processing.
|
|
25
25
|
* ctx: { agent, state, pushLine, pushLabel, render, scheduleRender,
|
|
26
|
-
* ensureAssistantLabel, askPermission, askQuestion,
|
|
26
|
+
* ensureAssistantLabel, askPermission, askBatchPermission, askQuestion,
|
|
27
27
|
* handleSlash, summarize } */
|
|
28
28
|
export async function runAgentTurn(ctx, text) {
|
|
29
|
-
const { agent, state, pushLine, pushLabel, render, scheduleRender, ensureAssistantLabel, askPermission, askQuestion, handleSlash } = ctx
|
|
29
|
+
const { agent, state, pushLine, pushLabel, render, scheduleRender, ensureAssistantLabel, askPermission, askBatchPermission, askQuestion, handleSlash } = ctx
|
|
30
30
|
// 可注入覆盖(测试用);默认走真实实现
|
|
31
31
|
const runAgentImpl = ctx.runAgent ?? runAgent
|
|
32
32
|
const saveSessionImpl = ctx.saveSession ?? saveSession
|
|
@@ -59,7 +59,7 @@ export async function runAgentTurn(ctx, text) {
|
|
|
59
59
|
render()
|
|
60
60
|
|
|
61
61
|
const { callbacks, flushStream } = buildToolCallbacks({
|
|
62
|
-
agent, state, pushLine, render, scheduleRender, ensureAssistantLabel, askPermission, askQuestion, saveSessionImpl,
|
|
62
|
+
agent, state, pushLine, render, scheduleRender, ensureAssistantLabel, askPermission, askBatchPermission, askQuestion, saveSessionImpl,
|
|
63
63
|
})
|
|
64
64
|
|
|
65
65
|
// try/finally: every exit path — including an unexpected throw inside the catch
|
package/src/tui/ansi.mjs
CHANGED
|
@@ -25,6 +25,8 @@ export const ansi = {
|
|
|
25
25
|
restoreCursor: `${ESC}8`, // DECRC — restore cursor position
|
|
26
26
|
syncUpdateStart: `${ESC}[?2026h`, // DECSET 2026 — buffer output until syncUpdateEnd
|
|
27
27
|
syncUpdateEnd: `${ESC}[?2026l`, // DECRST 2026 — flush buffered output atomically
|
|
28
|
+
wrapOff: `${ESC}[?7l`, // DECRST 7 — disable auto-wrap: over-wide rows hard-truncate at the margin instead of wrapping to the next physical line (2026-08-31 会诊:Ambiguous 宽度字符如 │/—/●/▸ 在中文 locale 终端渲染 2 格而 stringWidth 按 1 格算 → 行实际超宽 → wrap 污染下一物理行 + \x1b[K 清错行 → picker 残影)
|
|
29
|
+
wrapOn: `${ESC}[?7h`, // DECSET 7 — restore auto-wrap (TUI exit; also re-enable per-frame after write)
|
|
28
30
|
reset: `${ESC}[0m`,
|
|
29
31
|
dim: `${ESC}[2m`,
|
|
30
32
|
bold: `${ESC}[1m`,
|
package/src/tui/clipboard.mjs
CHANGED
|
@@ -102,7 +102,7 @@ export function insertPastedText(state, rawText) {
|
|
|
102
102
|
* Terminals without enhancement send a bare \r for Shift+Enter — nothing to translate
|
|
103
103
|
* (degrades to a normal submit; Alt+Enter remains the fallback). */
|
|
104
104
|
export function translateShiftEnter(text) {
|
|
105
|
-
//
|
|
105
|
+
// 有意为之:控制字符协议/转义序列剥离正则(ANSI/⟦ev⟧/SGR/history 双线分隔)
|
|
106
106
|
return text.replace(/\x1b\[13;2u/g, "\x1b\r").replace(/\x1b\[27;2;13~/g, "\x1b\r")
|
|
107
107
|
}
|
|
108
108
|
|
|
@@ -111,7 +111,7 @@ export function translateShiftEnter(text) {
|
|
|
111
111
|
* modifyOtherKeys: \x1b[27;mod;key~ — function keys
|
|
112
112
|
* Call AFTER translateShiftEnter (which already handles Shift+Enter). */
|
|
113
113
|
export function stripKeyboardProtocol(text) {
|
|
114
|
-
//
|
|
114
|
+
// 有意为之:控制字符协议/转义序列剥离正则(ANSI/⟦ev⟧/SGR/history 双线分隔)
|
|
115
115
|
return text.replace(/\x1b\[\d+;\d+u/g, "").replace(/\x1b\[27;\d+;\d+~/g, "")
|
|
116
116
|
}
|
|
117
117
|
|
package/src/tui/cmd-eng.mjs
CHANGED
|
@@ -49,6 +49,7 @@ export async function handleEngCommand(ctx) {
|
|
|
49
49
|
agent.config.agent.engineering = !agent.config.agent.engineering
|
|
50
50
|
if (!agent.config.agent.engineering) {
|
|
51
51
|
agent._engDesignToken = null // invalidate stale token
|
|
52
|
+
agent._engDesignTokens = new Map() // multi-design slots die with the mode (2026-09-01 fix #2)
|
|
52
53
|
// OFF must reach the model too (2026-08-25): /auto pushes a reminder on toggle — the
|
|
53
54
|
// mode flip is invisible to the agent otherwise. (ON needs none here: the injector
|
|
54
55
|
// in agent.mjs already announces ON transitions on the next turn.)
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { C } from "./ansi.mjs"
|
|
2
|
+
|
|
3
|
+
/** MCP.md §5 v2(D-1):edit/add 统一字段 picker 表单机制。
|
|
4
|
+
* fieldPicker 循环:picker 列字段行(label + 当前值打码)+ `✓ Save & test` 末行;
|
|
5
|
+
* 选中字段 → askQuestion 只输入该字段新值——空=不变、`-`=删除可选字段、`k=`=删除
|
|
6
|
+
* header/env 项、required 字段拒绝 `-`(不许删空)→ 回 picker(已改值保留——T18b
|
|
7
|
+
* 中间 Esc 回 picker 不丢);选 `✓ Save & test` → 必填校验(add 含 name 重复检查)
|
|
8
|
+
* → 调用方走 F2 预览+探活确认环;探活失败回同一 picker(AC2——独立 retry 路径废除)。
|
|
9
|
+
* 本文件为 v1 cmd-mcp.mjs 的 mergeKeyValuePairs/maskToken 迁移落点(逗号分隔 kv 解析
|
|
10
|
+
* 并入 mergeKeyValuePairs——v2 的 headers/env 输入统一按"键值对合并/删除"语义处理,
|
|
11
|
+
* 原 parseHeaders 整段替换语义不再需要);拆分前置——cmd-mcp.mjs 499 行压 500 硬限,评审 #1)。 */
|
|
12
|
+
|
|
13
|
+
/** F3 评审 #3 清除语义(T12):`k=`(空 value)= 从 merged 中删除该项;`k=v` = 设置。
|
|
14
|
+
* 返回更新后的对象(无项时 null——调用方据此 delete 字段)。 */
|
|
15
|
+
function mergeKeyValuePairs(merged, input) {
|
|
16
|
+
for (const pair of String(input).split(",")) {
|
|
17
|
+
const eq = pair.indexOf("=")
|
|
18
|
+
if (eq > 0) {
|
|
19
|
+
const key = pair.slice(0, eq).trim()
|
|
20
|
+
const value = pair.slice(eq + 1).trim().replace(/^["']|["']$/g, "")
|
|
21
|
+
if (!key) continue
|
|
22
|
+
if (value) merged[key] = value
|
|
23
|
+
else delete merged[key]
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return Object.keys(merged).length > 0 ? merged : null
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** F2(评审 #6):预览 token 遮蔽——len > 12 显示前 4 字符 + "…",否则全遮。 */
|
|
30
|
+
export function maskToken(token) {
|
|
31
|
+
const t = String(token ?? "")
|
|
32
|
+
if (!t) return ""
|
|
33
|
+
return t.length > 12 ? `${t.slice(0, 4)}…` : "•".repeat(t.length)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** edit 工作副本——headers/env/args 嵌套独立;fieldPicker 原地改副本,取消/失败不
|
|
37
|
+
* 污染原配置(零副作用)。 */
|
|
38
|
+
export function cloneEntry(srv) {
|
|
39
|
+
return {
|
|
40
|
+
...srv,
|
|
41
|
+
headers: srv.headers ? { ...srv.headers } : undefined,
|
|
42
|
+
env: srv.env ? { ...srv.env } : undefined,
|
|
43
|
+
args: srv.args ? [...srv.args] : undefined,
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const FIELD_LABELS = {
|
|
48
|
+
name: "Name",
|
|
49
|
+
url: "HTTP URL",
|
|
50
|
+
wsUrl: "WebSocket URL",
|
|
51
|
+
token: "Token",
|
|
52
|
+
headers: "Headers",
|
|
53
|
+
command: "Command",
|
|
54
|
+
args: "Args",
|
|
55
|
+
env: "Env",
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** F3/F3b:字段行顺序——add 含 name(可编辑、`(required)` 标注);edit 无 name 行
|
|
59
|
+
* (name 不可改)。HTTP: url/token/headers;WS: wsUrl/token/headers;stdio:
|
|
60
|
+
* command/args/env。 */
|
|
61
|
+
function fieldsFor(transport, mode) {
|
|
62
|
+
const base = transport === "ws" ? ["wsUrl", "token", "headers"]
|
|
63
|
+
: transport === "stdio" ? ["command", "args", "env"]
|
|
64
|
+
: ["url", "token", "headers"]
|
|
65
|
+
return mode === "add" ? ["name", ...base] : base
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** 必填字段(F3b 校验口径):name + transport 的端点/命令字段。 */
|
|
69
|
+
function requiredFieldsFor(transport) {
|
|
70
|
+
return transport === "ws" ? ["name", "wsUrl"]
|
|
71
|
+
: transport === "stdio" ? ["name", "command"]
|
|
72
|
+
: ["name", "url"]
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** edit 模式的 transport 由 entry 推导(AI 生成的 entry 同理——url/wsUrl/command 判定)。 */
|
|
76
|
+
function deriveTransport(entry) {
|
|
77
|
+
return entry.wsUrl ? "ws" : entry.url ? "http" : "stdio"
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** 单行显示值截断——picker 行宽控制(URL/command 可能很长)。 */
|
|
81
|
+
function shortVal(value, max = 32) {
|
|
82
|
+
const v = String(value ?? "")
|
|
83
|
+
return v.length > max ? `${v.slice(0, max - 1)}…` : v
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** F3 字段行显示:label 右侧打码/摘要(`Name (required)`、`Token d90c26bb…`、
|
|
87
|
+
* `Headers 2 items`)。必填空 → (required)(add 初始标注——F3b);可选空 → (none)。 */
|
|
88
|
+
function fieldDisplay(entry, field, { add, required }) {
|
|
89
|
+
const v = entry[field]
|
|
90
|
+
if (field === "headers" || field === "env") return `${Object.keys(v ?? {}).length} items`
|
|
91
|
+
if (field === "args") return (v ?? []).length ? shortVal(v.join(" ")) : "(none)"
|
|
92
|
+
if (field === "token") return v ? maskToken(v) : "(none)"
|
|
93
|
+
// name/url/wsUrl/command:端点与命令非机密——截断显示
|
|
94
|
+
if (v) return shortVal(v)
|
|
95
|
+
return add && required ? "(required)" : "(none)"
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** picker 行集:字段行(action `field:<name>`)+ 末行 `✓ Save & test`(action "save")。
|
|
99
|
+
* label 补齐对齐(最小 10 列——F3 示例形态 `Token d90c26bb…`)。 */
|
|
100
|
+
function formEntries(entry, transport, mode) {
|
|
101
|
+
const fields = fieldsFor(transport, mode)
|
|
102
|
+
const required = requiredFieldsFor(transport)
|
|
103
|
+
const pad = Math.max(10, Math.max(...fields.map((f) => FIELD_LABELS[f].length)) + 1)
|
|
104
|
+
return [
|
|
105
|
+
...fields.map((f) => ({
|
|
106
|
+
type: "item",
|
|
107
|
+
text: `${FIELD_LABELS[f].padEnd(pad)} ${fieldDisplay(entry, f, { add: mode === "add", required: required.includes(f) })}`,
|
|
108
|
+
action: `field:${f}`,
|
|
109
|
+
})),
|
|
110
|
+
{ type: "item", text: "✓ Save & test", action: "save" },
|
|
111
|
+
]
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** 字段输入提示 `(current: …)`——token 打码(maskToken)、headers/env 列键值对、
|
|
115
|
+
* args 空格串接;空值 → "none"。 */
|
|
116
|
+
function currentText(entry, field) {
|
|
117
|
+
const v = entry[field]
|
|
118
|
+
if (field === "token") return v ? maskToken(v) : "none"
|
|
119
|
+
if (field === "headers" || field === "env") {
|
|
120
|
+
const pairs = Object.entries(v ?? {}).map(([k, val]) => `${k}=${val}`)
|
|
121
|
+
return pairs.length ? pairs.join(", ") : "none"
|
|
122
|
+
}
|
|
123
|
+
if (field === "args") return (v ?? []).length ? v.join(" ") : "none"
|
|
124
|
+
return String(v ?? "") || "none"
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const PROMPT_BASES = {
|
|
128
|
+
name: "Server name",
|
|
129
|
+
url: "HTTP URL",
|
|
130
|
+
wsUrl: "WebSocket URL",
|
|
131
|
+
token: "Auth token (Bearer, optional; '-' clears, empty keeps)",
|
|
132
|
+
headers: "Headers (key=value, comma-separated; key= removes; empty keeps; '-' clears all)",
|
|
133
|
+
command: "Command",
|
|
134
|
+
args: "Arguments (space-separated; '-' clears, empty keeps)",
|
|
135
|
+
env: "Environment variables (key=value, comma-separated; key= removes; empty keeps; '-' clears all)",
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function fieldPrompt(entry, field) {
|
|
139
|
+
return `${PROMPT_BASES[field]} (current: ${currentText(entry, field)}):`
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** 字段输入应用(UI 决策 #3):空=不变;`-`=删除可选字段(token/headers/env/args);
|
|
143
|
+
* `k=`=删 header/env 项;required 字段(name/url/wsUrl/command)拒绝 `-`——必填不许删空。
|
|
144
|
+
* 返回错误文案(调用方 pushLine)或 null。 */
|
|
145
|
+
function applyFieldInput(entry, field, input, required) {
|
|
146
|
+
if (required) {
|
|
147
|
+
if (input === "-") return `${FIELD_LABELS[field]} is required — cannot be cleared`
|
|
148
|
+
if (input) entry[field] = input
|
|
149
|
+
return null
|
|
150
|
+
}
|
|
151
|
+
if (field === "token") {
|
|
152
|
+
if (input === "-") delete entry.token
|
|
153
|
+
else if (input) entry.token = input
|
|
154
|
+
} else if (field === "headers" || field === "env") {
|
|
155
|
+
if (input === "-") delete entry[field]
|
|
156
|
+
else if (input) {
|
|
157
|
+
const updated = mergeKeyValuePairs(entry[field] ? { ...entry[field] } : {}, input)
|
|
158
|
+
if (updated) entry[field] = updated
|
|
159
|
+
else delete entry[field]
|
|
160
|
+
}
|
|
161
|
+
} else if (field === "args") {
|
|
162
|
+
if (input === "-") delete entry.args
|
|
163
|
+
else if (input) entry.args = input.split(/\s+/)
|
|
164
|
+
}
|
|
165
|
+
return null
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** D-1 表单循环(F3/F3b——一处实现两处复用;F2 字段级重试 = 复用同一 picker)。
|
|
169
|
+
* entry 为工作副本:已改值在循环间保留(Esc/空输入不丢——T18b)。
|
|
170
|
+
* 返回 { action: "save" | "cancel", entry };cancel = picker 层 Esc(放弃整个表单)。 */
|
|
171
|
+
export async function fieldPicker(ctx, { title, mode, entry, transport, existingNames = [] }) {
|
|
172
|
+
const { showPicker, askQuestion, pushLine } = ctx
|
|
173
|
+
const tr = transport ?? deriveTransport(entry)
|
|
174
|
+
const required = requiredFieldsFor(tr)
|
|
175
|
+
for (;;) {
|
|
176
|
+
const sel = await showPicker(title, formEntries(entry, tr, mode))
|
|
177
|
+
if (!sel) return { action: "cancel", entry }
|
|
178
|
+
if (sel.action === "save") {
|
|
179
|
+
// F3b:Save 校验必填非空——未满足提示并留在 picker(不落盘)
|
|
180
|
+
const missing = fieldsFor(tr, mode).filter((f) => required.includes(f) && !String(entry[f] ?? "").trim())
|
|
181
|
+
if (missing.length) {
|
|
182
|
+
pushLine(`[mcp] Missing required: ${missing.map((f) => FIELD_LABELS[f]).join(", ")} — fill before saving`, C.error)
|
|
183
|
+
continue
|
|
184
|
+
}
|
|
185
|
+
if (mode === "add" && entry.name && existingNames.includes(entry.name)) {
|
|
186
|
+
pushLine(`[mcp] "${entry.name}" already exists`, C.error)
|
|
187
|
+
continue
|
|
188
|
+
}
|
|
189
|
+
return { action: "save", entry }
|
|
190
|
+
}
|
|
191
|
+
const field = sel.action.slice("field:".length)
|
|
192
|
+
const raw = ((await askQuestion(fieldPrompt(entry, field))) ?? "").trim()
|
|
193
|
+
const err = applyFieldInput(entry, field, raw, required.includes(field))
|
|
194
|
+
if (err) pushLine(`[mcp] ${err}`, C.error)
|
|
195
|
+
// 循环回 picker——已改值保留(T18b:中间 Esc 回 picker 不丢)
|
|
196
|
+
}
|
|
197
|
+
}
|