thincoder 0.12.58 → 0.12.60

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 (158) hide show
  1. package/CHANGELOG.md +78 -2
  2. package/README.md +3 -3
  3. package/bin/thincoder.mjs +88 -19
  4. package/package.json +4 -3
  5. package/src/acp/bridge.mjs +135 -26
  6. package/src/advisor/messages.mjs +57 -4
  7. package/src/advisor/run.mjs +119 -79
  8. package/src/advisor.mjs +34 -7
  9. package/src/agent/completion.mjs +17 -11
  10. package/src/agent/dispatch.mjs +182 -22
  11. package/src/agent/helpers.mjs +71 -4
  12. package/src/agent/record-results.mjs +46 -10
  13. package/src/agent/run-stages.mjs +227 -0
  14. package/src/agent/setup-reminders.mjs +62 -0
  15. package/src/agent/setup.mjs +107 -20
  16. package/src/agent/spawn-child.mjs +54 -4
  17. package/src/agent-tools/advisor-async.mjs +456 -0
  18. package/src/agent-tools/advisor.mjs +133 -109
  19. package/src/agent-tools/async-settle.mjs +191 -0
  20. package/src/agent-tools/consult.mjs +154 -104
  21. package/src/agent-tools/design-token.mjs +104 -0
  22. package/src/agent-tools/eng.mjs +26 -30
  23. package/src/agent-tools/escalate-async.mjs +286 -0
  24. package/src/agent-tools/goal.mjs +11 -1
  25. package/src/agent-tools/read-history.mjs +284 -0
  26. package/src/agent-tools/recent-changes.mjs +2 -1
  27. package/src/agent-tools/settings.mjs +152 -0
  28. package/src/agent-tools/skill.mjs +2 -1
  29. package/src/agent-tools/subagent-actions.mjs +470 -0
  30. package/src/agent-tools/subagent-async.mjs +382 -0
  31. package/src/agent-tools/subagent-panel.mjs +153 -0
  32. package/src/agent-tools/subagent-run.mjs +202 -0
  33. package/src/agent-tools/subagent-scheduler.mjs +343 -0
  34. package/src/agent-tools/subagent-spawn.mjs +406 -0
  35. package/src/agent-tools/subagent.mjs +203 -377
  36. package/src/agent-tools/task.mjs +4 -3
  37. package/src/agent-tools/timer.mjs +9 -4
  38. package/src/agent-tools/verify.mjs +198 -238
  39. package/src/agent-tools.mjs +1 -0
  40. package/src/agent.mjs +145 -242
  41. package/src/auto-think.mjs +14 -0
  42. package/src/cli/distill-command.mjs +10 -4
  43. package/src/cli/make-agent.mjs +4 -1
  44. package/src/cli/memory-command.mjs +2 -1
  45. package/src/cli/permission.mjs +8 -1
  46. package/src/cli/setup-wizard.mjs +17 -12
  47. package/src/config.mjs +61 -8
  48. package/src/context.mjs +81 -163
  49. package/src/crash-reports.mjs +123 -0
  50. package/src/distill.mjs +30 -12
  51. package/src/escape.mjs +6 -4
  52. package/src/explore-distill.mjs +155 -0
  53. package/src/log.mjs +195 -0
  54. package/src/memory/code-sync.mjs +2 -1
  55. package/src/memory/core.mjs +11 -72
  56. package/src/memory/delete.mjs +234 -0
  57. package/src/memory/docs.mjs +206 -87
  58. package/src/memory.mjs +3 -1
  59. package/src/model-specs.mjs +15 -1
  60. package/src/peer-domains.mjs +265 -0
  61. package/src/peer-instances.mjs +231 -0
  62. package/src/prompt-overlays.mjs +25 -0
  63. package/src/prompts/advisor-design.md +18 -39
  64. package/src/prompts/advisor-round1.md +20 -32
  65. package/src/prompts/advisor-round2.md +16 -16
  66. package/src/prompts/advisor-round3.md +16 -16
  67. package/src/prompts/coder.md +7 -28
  68. package/src/prompts/consult-base.md +4 -11
  69. package/src/prompts/discipline.md +31 -44
  70. package/src/prompts/eng-coder.md +9 -34
  71. package/src/prompts/engineering-sub.md +10 -8
  72. package/src/prompts/engineering.md +61 -264
  73. package/src/prompts/explore.md +4 -14
  74. package/src/prompts/main.md +18 -35
  75. package/src/prompts/methodology-template.md +32 -38
  76. package/src/prompts/plan.md +2 -9
  77. package/src/prompts/system.md +18 -35
  78. package/src/provider/core.mjs +62 -69
  79. package/src/provider/errors.mjs +76 -0
  80. package/src/provider/retry.mjs +8 -45
  81. package/src/session-gc.mjs +214 -0
  82. package/src/session-guard.mjs +47 -0
  83. package/src/session-rename.mjs +38 -0
  84. package/src/session-slots.mjs +181 -58
  85. package/src/session.mjs +48 -89
  86. package/src/token-ttl.mjs +273 -0
  87. package/src/tools/apply_patch.md +3 -1
  88. package/src/tools/bash.md +1 -1
  89. package/src/tools/checklist-sync.mjs +181 -0
  90. package/src/tools/checklist.mjs +52 -39
  91. package/src/tools/delete.md +1 -0
  92. package/src/tools/edit-batch.mjs +131 -44
  93. package/src/tools/edit-diff.mjs +348 -0
  94. package/src/tools/edit.md +20 -13
  95. package/src/tools/execute.md +7 -7
  96. package/src/tools/execute.mjs +55 -24
  97. package/src/tools/file.mjs +25 -70
  98. package/src/tools/file_ops.md +2 -1
  99. package/src/tools/get_current_time.md +3 -1
  100. package/src/tools/git.mjs +14 -6
  101. package/src/tools/glob-dialect.mjs +130 -0
  102. package/src/tools/glob.md +3 -3
  103. package/src/tools/grep.md +1 -1
  104. package/src/tools/hashline_edit.md +2 -0
  105. package/src/tools/index.mjs +3 -3
  106. package/src/tools/insert_after.md +2 -1
  107. package/src/tools/lint.md +2 -0
  108. package/src/tools/lsp.md +4 -1
  109. package/src/tools/ops.mjs +175 -3
  110. package/src/tools/patch.mjs +84 -13
  111. package/src/tools/question.md +5 -1
  112. package/src/tools/repomap.mjs +1 -1
  113. package/src/tools/shared.mjs +18 -25
  114. package/src/tools/system.mjs +50 -30
  115. package/src/tools/tree.md +2 -1
  116. package/src/tools/wait_for.md +22 -0
  117. package/src/tools/web.mjs +5 -3
  118. package/src/tools/websearch.md +2 -1
  119. package/src/tools/write.md +2 -0
  120. package/src/traces/trace-store.mjs +224 -0
  121. package/src/tui/agent-turn.mjs +179 -27
  122. package/src/tui/clipboard.mjs +15 -4
  123. package/src/tui/cmd-config.mjs +77 -16
  124. package/src/tui/cmd-eng.mjs +20 -16
  125. package/src/tui/cmd-extract.mjs +1 -1
  126. package/src/tui/cmd-mcp.mjs +17 -2
  127. package/src/tui/cmd-new.mjs +3 -2
  128. package/src/tui/cmd-session.mjs +19 -4
  129. package/src/tui/cmd-think.mjs +11 -11
  130. package/src/tui/cmd-upgrade.mjs +19 -4
  131. package/src/tui/config-helpers.mjs +28 -16
  132. package/src/tui/distill-cmd.mjs +1 -1
  133. package/src/tui/index.mjs +31 -96
  134. package/src/tui/interaction.mjs +13 -2
  135. package/src/tui/key-handler.mjs +105 -155
  136. package/src/tui/key-modes.mjs +215 -0
  137. package/src/tui/layout.mjs +22 -1
  138. package/src/tui/mouse.mjs +46 -0
  139. package/src/tui/pickers.mjs +51 -25
  140. package/src/tui/render-conversation.mjs +13 -161
  141. package/src/tui/render-frame.mjs +27 -10
  142. package/src/tui/render-loop.mjs +4 -1
  143. package/src/tui/render-segments.mjs +182 -0
  144. package/src/tui/startup.mjs +40 -0
  145. package/src/tui/subagent-blocks.mjs +272 -262
  146. package/src/tui/subagent-children.mjs +176 -0
  147. package/src/tui/subagent-freeze.mjs +172 -0
  148. package/src/tui/subagent-panel.mjs +125 -12
  149. package/src/tui/suspension-drive.mjs +351 -0
  150. package/src/tui/tool-args.mjs +10 -2
  151. package/src/tui/tool-display.mjs +142 -0
  152. package/src/tui/tool-events.mjs +127 -231
  153. package/src/tui/tui-lifecycle.mjs +29 -0
  154. package/src/tui/update-notice.mjs +76 -0
  155. package/src/tui/wizard.mjs +48 -12
  156. package/src/agent-tools/escalate.mjs +0 -179
  157. package/src/agent-tools/subagent-check.mjs +0 -107
  158. package/src/tools/exec-prelude.mjs +0 -84
@@ -0,0 +1,215 @@
1
+ /**
2
+ * key-modes.mjs — 按键模态层(2026-09-03 D-S4 自 key-handler.mjs 拆出):
3
+ * permission / question / interruptPrompt 三种独占模态分支。每个 handler 在模态激活时
4
+ * 消费全部按键(返回 true——含未匹配键,与拆出前无条件 return 语义一致),未激活返回 false。
5
+ * ctx: { state, agent, pushLine, render }(各 handler 按需取用)。
6
+ * 搜索模态另居 key-handler-search.mjs(既有拆分,本批不动)。
7
+ */
8
+ import { C } from "./ansi.mjs"
9
+ import { readClipboardText, insertPastedText } from "./clipboard.mjs"
10
+ import { QUESTION_CUSTOM } from "./interaction.mjs"
11
+
12
+ /** permission 确认模态:y/n/a(a = approve + AUTO ON);batch(§16 D-B1):a/o/n(Esc = deny)。
13
+ * consume:valid 键或 Esc 走 resolve 分支;其余键静默吞掉(模态独占)。 */
14
+ export function handlePermissionMode(str, key, ctx) {
15
+ const { state, agent, pushLine, render } = ctx
16
+ if (!state.permission) return false
17
+ const answer = (str || "").toLowerCase()
18
+ const isContinue = state.permission.name === "continue"
19
+ const isBatch = Boolean(state.permission.batch)
20
+ const validKeys = isContinue ? ["y", "n"] : isBatch ? ["a", "o", "n"] : ["y", "n", "a"]
21
+ if (validKeys.includes(answer) || key.name === "escape") {
22
+ const { resolve, name } = state.permission
23
+ state.permission = null
24
+ state.permissionPreview = []
25
+ state.status = "Processing..."
26
+ if (isBatch) {
27
+ // Merged batch ask: resolve the verdict string; dispatch applies it
28
+ // (approveAll = batch-scope allowance only, NOT the persistent AUTO flag).
29
+ const verdict = answer === "a" ? "approveAll" : answer === "o" ? "oneByOne" : "deny"
30
+ const tone = verdict === "approveAll" ? C.dim : C.error
31
+ pushLine(` [${verdict === "approveAll" ? "approved" : verdict === "deny" ? "denied" : "one by one"}] ${name}`, tone)
32
+ resolve(verdict)
33
+ render()
34
+ return true
35
+ }
36
+ if (answer === "a" && !isContinue) {
37
+ agent.autoApprove = true
38
+ agent._pendingReminders = agent._pendingReminders ?? []
39
+ agent._pendingReminders.push("[System reminder: AUTO mode is now ON. All tool calls are automatically approved. Use /auto to disable.]")
40
+ pushLine(` [auto] AUTO ON: tool calls no longer prompt for approval (/auto to disable)`, C.warn)
41
+ }
42
+ const approved = answer === "y" || (answer === "a" && !isContinue)
43
+ // leave trail: record approval/denial in conversation (continue prompt has its own output, don't duplicate)
44
+ if (!isContinue) {
45
+ pushLine(` [${approved ? "approved" : "denied"}] ${name}`, approved ? C.dim : C.error)
46
+ }
47
+ resolve(approved)
48
+ render()
49
+ }
50
+ return true
51
+ }
52
+
53
+ /** question 工具回调模态:自由文本(type answer, Enter submit, Esc cancel)或选项
54
+ * (↑↓ select, Enter confirm, Esc cancel——QUESTION_CUSTOM 项切自由文本模式)。
55
+ * 自由文本态编辑键契约见 TUI-INPUT-BOX.md §7.2(←→/Home/End/Ctrl+U/Backspace/中段插入/
56
+ * Ctrl+V 落 cursor/\n→空格/Ctrl+J no-op/未列键吞——无 fall-through);Esc 语义 round2 #5:
57
+ * 有 options(Custom 进入)→ 回 options 态;无 options → 中止 question。
58
+ * consume:模态激活时全部按键独占(含未匹配键——拆出前 onKeypress 尾 return)。 */
59
+ export function handleQuestionMode(str, key, ctx) {
60
+ const { state, pushLine, render } = ctx
61
+ if (!state.question) return false
62
+ const q = state.question
63
+ if (q.options.length > 0) {
64
+ // options mode: ↑↓ select, Enter confirm, Esc cancel
65
+ if (key.name === "escape") {
66
+ q.resolve("")
67
+ state.question = null
68
+ state.status = "Processing..."
69
+ render()
70
+ } else if (key.name === "up") {
71
+ q.selected = Math.max(0, (q.selected ?? 0) - 1)
72
+ render()
73
+ } else if (key.name === "down") {
74
+ q.selected = Math.min(q.options.length - 1, (q.selected ?? 0) + 1)
75
+ render()
76
+ } else if (key.name === "return") {
77
+ const answer = q.options[q.selected ?? 0]
78
+ if (answer === QUESTION_CUSTOM) {
79
+ // Switch to free-text mode — the user wants to type their own answer.
80
+ // options 列表备份(_backOptions)——自由文本态 Esc 可回 options 态(round2 #5 逃生口);
81
+ // answer/cursor 按 §7.2 初始化(codepoint 数组 + cursor = answer.length)。
82
+ q._backOptions = { options: q.options, selected: q.selected }
83
+ q.options = []
84
+ q.selected = undefined
85
+ q.answer = []
86
+ q.cursor = 0
87
+ state.status = "Waiting for answer..."
88
+ render()
89
+ return true
90
+ }
91
+ q.resolve(answer)
92
+ state.question = null
93
+ state.status = "Processing..."
94
+ pushLine(` → ${answer}`, C.tool)
95
+ render()
96
+ }
97
+ } else {
98
+ // free text: codepoint-array answer + cursor editing (TUI-INPUT-BOX.md §7.2)
99
+ if (!Array.isArray(q.answer)) q.answer = q.answer ? [...q.answer] : []
100
+ q.cursor = Math.max(0, Math.min(q.cursor ?? q.answer.length, q.answer.length))
101
+ const len = q.answer.length
102
+ if (key.name === "escape") {
103
+ // round2 #5:有 options(Custom 进入)→ 回 options 态(误触 Custom 有逃生口——
104
+ // 选择列表与选中位恢复);无 options → 中止 question(原语义)。
105
+ if (q._backOptions) {
106
+ q.options = q._backOptions.options
107
+ q.selected = q._backOptions.selected
108
+ delete q._backOptions
109
+ delete q.answer
110
+ delete q.cursor
111
+ state.status = "Waiting for choice..."
112
+ render()
113
+ } else {
114
+ q.resolve("")
115
+ state.question = null
116
+ state.status = "Processing..."
117
+ render()
118
+ }
119
+ } else if (key.name === "return") {
120
+ if (q._pasting) return true // block Enter while paste is in flight
121
+ const answer = q.answer.join("").trim()
122
+ q.resolve(answer || "")
123
+ state.question = null
124
+ state.status = "Processing..."
125
+ pushLine(` → ${answer || "(empty)"}`, C.tool)
126
+ render()
127
+ } else if (key.name === "enter") {
128
+ // Ctrl+J(\n → name "enter")= no-op 吞——不插换行无 fall-through(round2 #2)
129
+ } else if (key.name === "left") {
130
+ q.cursor = Math.max(0, q.cursor - 1)
131
+ render()
132
+ } else if (key.name === "right") {
133
+ q.cursor = Math.min(len, q.cursor + 1)
134
+ render()
135
+ } else if (key.name === "home") {
136
+ q.cursor = 0
137
+ render()
138
+ } else if (key.name === "end") {
139
+ q.cursor = len
140
+ render()
141
+ } else if (key.ctrl && !key.alt && key.name === "u") {
142
+ q.answer = []
143
+ q.cursor = 0
144
+ render()
145
+ } else if (key.name === "backspace") {
146
+ if (q.cursor > 0) {
147
+ q.answer.splice(q.cursor - 1, 1) // codepoint 元素删除——emoji 不劈半(round2 #1)
148
+ q.cursor--
149
+ render()
150
+ }
151
+ } else if (key.ctrl && !key.alt && !key.meta && key.name === "v") {
152
+ // Ctrl+V paste: read clipboard text (fires when the terminal passes Ctrl+V through
153
+ // as a key event; bracketed-paste terminals are handled upstream in the stdin handler).
154
+ // 两路径同归 insertPastedText——落 cursor 位置、\n→空格(clipboard.mjs 单行守卫)。
155
+ if (q._pasting) return true
156
+ q._pasting = true
157
+ readClipboardText().then((text) => {
158
+ q._pasting = false
159
+ if (text) {
160
+ // activeQuestion 守卫:粘贴在途用户 Esc 中止/Enter 提交后,本回调不得把
161
+ // 剪贴板内容落进主输入框(审计 F1——insertPastedText 见 stale 即丢)。
162
+ insertPastedText(state, text, q)
163
+ render()
164
+ }
165
+ }).catch((e) => {
166
+ q._pasting = false
167
+ console.error(`[tui] clipboard paste failed: ${e.message}`)
168
+ })
169
+ } else if (str && !key.ctrl && !key.meta && key.name !== "tab" && key.name !== "enter") {
170
+ // 可打印字符插入光标位置(codepoint 拆字);\r\n 剥离 + \t→2 空格(单行不变式同主输入清洗口径)
171
+ const chars = [...str.replace(/[\r\n]+/g, "").replace(/\t/g, " ")]
172
+ if (chars.length) {
173
+ q.answer.splice(q.cursor, 0, ...chars)
174
+ q.cursor += chars.length
175
+ render()
176
+ }
177
+ }
178
+ // 未列键(↑↓/PgUp/PgDn/Delete/F1/…)一律消费 return——无 fall-through 到正常编辑
179
+ // (search 穿透教训——TUI-INPUT-BOX.md §3.2 BUG-2)
180
+ }
181
+ return true
182
+ }
183
+
184
+ /** interruptPrompt 模态(Ctrl+I 后输入注入消息):Enter 提交 → controller.abort
185
+ * { interrupt: true, message };Esc 取消;字符进 prompt;其余键静默吞(模态独占)。
186
+ * 返回 false 当模态未激活(key-handler 先检 state.interruptPrompt 入口再调)。 */
187
+ export function handleInterruptMode(str, key, ctx) {
188
+ const { state, pushLine, render } = ctx
189
+ if (!state.interruptPrompt) return false
190
+ if (key.name === "escape") {
191
+ state.interruptPrompt = null
192
+ render()
193
+ } else if (key.name === "return") {
194
+ const msg = (state.interruptPrompt.text ?? "").trim()
195
+ state.interruptPrompt = null
196
+ if (msg) {
197
+ // Guard: if the turn already finished while the user was typing, the controller
198
+ // may have been replaced or already aborted — don't abort a live turn by mistake.
199
+ if (state.processing && state.controller && !state.controller.signal.aborted) {
200
+ pushLine(` [inject] ${msg}`, C.warn)
201
+ state.controller.abort({ interrupt: true, message: msg })
202
+ } else {
203
+ pushLine(` [inject — turn ended, message queued] ${msg}`, C.dim)
204
+ }
205
+ render()
206
+ }
207
+ } else if (key.name === "backspace") {
208
+ state.interruptPrompt.text = state.interruptPrompt.text.slice(0, -1)
209
+ render()
210
+ } else if (str && !key.ctrl && !key.meta) {
211
+ state.interruptPrompt.text += str.replace(/[\r\n]+/g, "")
212
+ render()
213
+ }
214
+ return true
215
+ }
@@ -26,6 +26,14 @@ const MAX_INPUT_LINES = 5
26
26
  const MAX_TASK_LINES = 5
27
27
  const QWIN = 5
28
28
 
29
+ /** question 自由文本态渲染布局:与主输入框同实现(TUI-INPUT-BOX.md §7.2 round2 #1/#6)——
30
+ * 折行展开(`▸ ` 首行 + 续行 2 空格缩进)+ 光标行列;answer 单行不变式(无 \n 字符)由
31
+ * 输入侧硬守卫(Ctrl+J no-op / 粘贴 \n→空格),此处只复用 layoutInput 的折行/光标语义,
32
+ * cap/滚动由 computeLayout 以 MAX_INPUT_LINES + questionOffset 承担(同主输入 inputOffset)。 */
33
+ export function layoutAnswer(chars, cursor, width) {
34
+ return layoutInput(chars, cursor, width)
35
+ }
36
+
29
37
  /**
30
38
  * Computes layout. Returns panel coordinates + precomputed content (height-affecting parts).
31
39
  * Pure function: does not modify state.
@@ -43,6 +51,11 @@ export function computeLayout(state, { cols, rows }) {
43
51
  }
44
52
  const inputLines = inputLayout.lines.slice(inputOffset, inputOffset + MAX_INPUT_LINES)
45
53
  let boxLines = inputLines
54
+ // question 自由文本态(无 options):boxLines = layoutAnswer 行(同主输入 cap + offset 滚动——
55
+ // 光标随行;长答案不挤压会话面板到零行——TUI-INPUT-BOX.md §7.2 round2 #6)。questionLayout/
56
+ // questionOffset 供 render-frame 反显光标与硬件定位(hasOverlay 例外细化同 §7.2)。
57
+ let questionLayout = null
58
+ let questionOffset = 0
46
59
  if (state.question) {
47
60
  const q = state.question
48
61
  if (q.options.length > 0) {
@@ -51,7 +64,13 @@ export function computeLayout(state, { cols, rows }) {
51
64
  boxLines = q.options.slice(start, start + QWIN).map((opt, i) =>
52
65
  (start + i === sel ? "▸ " : " ") + (opt === QUESTION_CUSTOM ? "✍ Custom answer…" : optText(opt)))
53
66
  } else {
54
- boxLines = ["▸ " + (q.answer ?? "")]
67
+ const answerChars = Array.isArray(q.answer) ? q.answer : q.answer ? [...q.answer] : []
68
+ const qCursor = Math.max(0, Math.min(q.cursor ?? answerChars.length, answerChars.length))
69
+ questionLayout = layoutAnswer(answerChars, qCursor, W - 4)
70
+ if (questionLayout.lines.length > MAX_INPUT_LINES) {
71
+ questionOffset = Math.min(questionLayout.cursorLine, questionLayout.lines.length - MAX_INPUT_LINES)
72
+ }
73
+ boxLines = questionLayout.lines.slice(questionOffset, questionOffset + MAX_INPUT_LINES)
55
74
  }
56
75
  }
57
76
  const inputBoxH = boxLines.length + 2
@@ -174,6 +193,8 @@ export function computeLayout(state, { cols, rows }) {
174
193
  // precomputed content (affects height, reused during render)
175
194
  inputLayout,
176
195
  inputOffset,
196
+ questionLayout,
197
+ questionOffset,
177
198
  boxLines,
178
199
  visibleTasks,
179
200
  permPreviewLines,
package/src/tui/mouse.mjs CHANGED
@@ -17,6 +17,9 @@
17
17
  import { computeLayout, subagentLineIndex } from "./layout.mjs"
18
18
  import { buildConvLines, convViewport } from "./render-conversation.mjs"
19
19
  import { toggleFoldBlock, scrollFoldBlock, foldScrollOffset } from "./fold-block.mjs"
20
+ import { cancelAsyncSubagent } from "../agent-tools/subagent-async.mjs"
21
+ import { cancelAsyncAdvisor } from "../agent-tools/advisor-async.mjs"
22
+ import { C } from "./ansi.mjs"
20
23
 
21
24
  /** 2026-08-31 滚轮事件分派(用户需求"展开块能滚动阅读全文"):坐标命中展开块内容行 →
22
25
  * 块内逐行滚动(scrollFoldBlock ±3);未命中 → 会话滚动(调用方继续处理)。
@@ -125,6 +128,15 @@ export function handleMouseClick(ctx, col, row) {
125
128
  // 评审 #4:保底截断后可见行 ≠ 前 h 行——命中映射与 render-frame 同一几何契约
126
129
  // (subagentLineIndex:分隔线 + 末尾区块行优先)。
127
130
  const lineEl = layout.subagentLines[subagentLineIndex(layout.subagentLines, P.subagent.h, r - P.subagent.y)]
131
+ // §19.5 D-M7/D-M7b ⏹ 停止标记(round1 #6 + 用户裁定 B 形态):列级命中——⏹ 列
132
+ // 点击 = cancel(定向该子代理——ctx.cancelSubagent 直连池 abort 路径,不经模型
133
+ // 回合),不触发折叠翻转;**仅 async 区块带 _stopSub 元数据**(sync 无 ⏹——
134
+ // 其右缘点击照常走折叠翻转——D-M7b ③);⏹ 区外点击照常走折叠/翻窗。
135
+ if (lineEl?._stopSub && col >= (lineEl._stopCol ?? Infinity)) {
136
+ ctx.cancelSubagent?.(lineEl._stopSub)
137
+ render()
138
+ return true
139
+ }
128
140
  if (lineEl?._foldScrollUp || lineEl?._foldScrollDown) {
129
141
  // ▲/▼ 控制行点击翻窗(60% 封顶保留、窗口随翻滚动,全文可达)
130
142
  scrollFoldBlock(state, lineEl._foldScrollUp ?? lineEl._foldScrollDown,
@@ -164,3 +176,37 @@ export function handleMouseClick(ctx, col, row) {
164
176
 
165
177
  return false
166
178
  }
179
+
180
+ /** 鼠标装配簇(2026-09-03 D-S1a 自 index.mjs 迁入):index.mjs 只留装配调用。
181
+ * ctx: { agent, state, pushLine, render, popPicker };返回 { onMouseClick, mouseCtx }。
182
+ * cancelSubagent(⏹ 停止标记,§19.5 D-M7):UI 停止不经模型回合,直连池 abort——
183
+ * 与 action:"cancel" 同实现路径。block key "role#id" → 池条目 id。 */
184
+ export function createMouseDispatch({ agent, state, pushLine, render, popPicker }) {
185
+ const cancelSubagent = (key) => {
186
+ try {
187
+ const id = key.slice(key.lastIndexOf("#") + 1)
188
+ const isAdvisorBlock = key.startsWith("advisor#")
189
+ // §24 D-24b (②-6b): ⏹ on an advisor block cancels the background review
190
+ // (directed abort → cancelled settle: no pending entry / no token).
191
+ const r = isAdvisorBlock
192
+ ? cancelAsyncAdvisor(agent, id)
193
+ : cancelAsyncSubagent(agent, id)
194
+ if (r?.status === "error") {
195
+ // 池内无此条目但区块仍 live(!done)→ 阻塞型(sync)spawn 中——无池条目可定向
196
+ // 中止(§19.5 cancel 只针对 async 后台子代理);给出可操作指引而非神秘 unknown id
197
+ const liveBlock = state.subTasks?.[key] && !state.subTasks[key].done
198
+ pushLine(liveBlock
199
+ ? `[subagent stop] ${key} is a blocking (sync) child mid-call — targeted stop is not available; use Ctrl+C to interrupt the turn`
200
+ : `[subagent stop] ${r.error}`, C.error)
201
+ } else {
202
+ pushLine(`[subagent ${key} stop requested]`, C.warn)
203
+ }
204
+ } catch (e) {
205
+ pushLine(`[subagent stop] ${e?.message ?? String(e)}`, C.error)
206
+ }
207
+ render()
208
+ }
209
+ const onMouseClick = (col, row) => handleMouseClick({ state, render, popPicker, cancelSubagent }, col, row)
210
+ const mouseCtx = () => ({ state, render })
211
+ return { onMouseClick, mouseCtx }
212
+ }
@@ -300,22 +300,24 @@ export function createPickers(ctx) {
300
300
  const target = agent.providers.find((pp) => pp.name === item.provider)
301
301
  if (!target) return
302
302
  const providerDefault = target.model
303
+ const nextActiveModel = item.model !== providerDefault ? item.model : null // default model → clear activeModel
304
+ // D-F5a(selectModel——清单外同型写回补正)先盘后存:磁盘 fresh raw 单操作(model + active 指针)——冲突放弃不留 ghost
305
+ await persistRaw((raw) => {
306
+ raw.providers ??= []
307
+ const t = raw.providers.find((p) => p?.name === item.provider)
308
+ if (t) t.model = item.model
309
+ raw.activeProvider = item.provider
310
+ raw.activeModel = nextActiveModel || undefined // null → omit from config
311
+ })
303
312
  target.model = item.model
304
313
  agent.activeProvider = item.provider
305
- // If selecting the provider's default model, clear activeModel; otherwise set it
306
- agent.activeModel = item.model !== providerDefault ? item.model : null
314
+ agent.activeModel = nextActiveModel
307
315
  agent.provider = { ...target }
308
316
  if (agent.config?.agent?.compactThresholdAuto) {
309
317
  const { resolveCompactThreshold } = await import("../config.mjs")
310
318
  // provider 对象(非 model 字符串)——阈值跟随 providers[].context 覆盖(PROVIDER.md §15 T-C2)
311
319
  agent.config.agent.compactThreshold = resolveCompactThreshold(null, target).value
312
320
  }
313
- await persistRaw((raw) => {
314
- // 落盘前剥离运行时注入的 proxyUri(由 loadConfig + injectProxy 在加载时重建)
315
- raw.providers = agent.providers.map(({ proxyUri: _, ...p }) => p)
316
- raw.activeProvider = item.provider
317
- raw.activeModel = agent.activeModel || undefined // null → omit from config
318
- })
319
321
  agent.config.activeProvider = item.provider
320
322
  agent.config.activeModel = agent.activeModel
321
323
  if (!agent.provider.apiKey) {
@@ -342,12 +344,20 @@ export function createPickers(ctx) {
342
344
  if (!baseURL) return
343
345
  const model = await askQuestion("Enter model name:")
344
346
  if (!model) return
345
- const format = (await askQuestion("API format [openai/anthropic/google] (default: openai):")).trim().toLowerCase()
347
+ // D-C1(TUI.md §10.6D):API format 改 picker 选择(openai/anthropic/google——默认 index=0
348
+ // openai)——固定枚举不再手输;Esc/取消 = 中止整个 Add Provider 流程(cfg 尚未落盘——
349
+ // 无半配置,与旧手输非法 abort 同语义)。与 preset 选择器同形态——零新机制。
350
+ const format = await showPicker("API format", [
351
+ { type: "item", text: "openai", name: "openai" },
352
+ { type: "item", text: "anthropic", name: "anthropic" },
353
+ { type: "item", text: "google", name: "google" },
354
+ ])
355
+ if (!format) return // Esc/取消 → 中止流程
346
356
  const cfg = { name, baseURL, model }
347
- if (format === "anthropic" || format === "google") cfg.format = format
348
- else if (format && format !== "openai") return // unknown format → abort
357
+ if (format.name === "anthropic" || format.name === "google") cfg.format = format.name
358
+ else if (format.name !== "openai") return // 防御:未知格式(理论不可达——picker 枚举)
359
+ await persistRaw((raw) => { raw.providers ??= []; raw.providers.push(cfg) }) // D-F5a(#3)先盘后存
349
360
  agent.providers.push(cfg)
350
- await persistRaw((raw) => { raw.providers = agent.providers })
351
361
  const key = await askQuestion(`Enter API key for ${name} (skip if none):`)
352
362
  if (key) await setProviderKey(name, key)
353
363
  return
@@ -360,8 +370,8 @@ export function createPickers(ctx) {
360
370
  if (preset.maxTokens) cfg.maxTokens = preset.maxTokens
361
371
  if (preset.chatPath) cfg.chatPath = preset.chatPath
362
372
  if (preset.format) cfg.format = preset.format
373
+ await persistRaw((raw) => { raw.providers ??= []; raw.providers.push(cfg) }) // D-F5a(#2)先盘后存
363
374
  agent.providers.push(cfg)
364
- await persistRaw((raw) => { raw.providers = agent.providers })
365
375
  const key = await askQuestion(`Enter API key for ${se.name} (skip if none):`)
366
376
  if (key) await setProviderKey(se.name, key)
367
377
  }
@@ -374,8 +384,13 @@ export function createPickers(ctx) {
374
384
  ...candidates.map((p) => ({ type: "item", text: `${p.name} (${p.model})`, name: p.name })),
375
385
  ])
376
386
  if (!se) return
387
+ // D-F5a(#4)先盘后存:磁盘 fresh raw 上 splice;冲突放弃不留下内存 ghost
388
+ await persistRaw((raw) => {
389
+ raw.providers ??= []
390
+ const idx = raw.providers.findIndex((p) => p?.name === se.name)
391
+ if (idx !== -1) raw.providers.splice(idx, 1)
392
+ })
377
393
  agent.providers.splice(agent.providers.findIndex((p) => p.name === se.name), 1)
378
- await persistRaw((raw) => { raw.providers = agent.providers })
379
394
  }
380
395
 
381
396
  async function setKeyFlow() {
@@ -391,14 +406,18 @@ export function createPickers(ctx) {
391
406
  async function setProviderKey(name, key) {
392
407
  const target = agent.providers.find((p) => p.name === name)
393
408
  if (!target) return
409
+ // D-F5a(#5)先盘后存:磁盘 fresh raw 上改目标项 apiKey;冲突放弃不留下内存 ghost
410
+ await persistRaw((raw) => {
411
+ raw.providers ??= []
412
+ const t = raw.providers.find((p) => p?.name === name)
413
+ if (t) t.apiKey = key
414
+ })
394
415
  target.apiKey = key
395
416
  if (name === agent.activeProvider) agent.provider.apiKey = key
396
- await persistRaw((raw) => { raw.providers = agent.providers })
397
417
  }
398
418
 
399
- /** /model provider 管理:context 窗口字段(K 单位,PROVIDER.md §15 D-C4/D-C5)——
400
- * picker 选 provider + 表单输入(复用 syncProviderField 的落盘模式:改 agent.providers 目标项
401
- * → persistRaw 全量写盘);空输入清空(回 spec 值);非法输入报错不落盘(D-C1 语义同 loadConfig)。 */
419
+ /** /model provider 管理:context 窗口字段(K 单位,PROVIDER.md §15 D-C4/D-C5)——picker 选
420
+ * provider + 表单输入;空清空/非法不落盘(D-C1);落盘 = D-F5a(#6)fresh 单字段补丁——先盘后存。 */
402
421
  async function setContextFlow() {
403
422
  const se = await showPicker("Set Context Window", [
404
423
  { type: "header", text: "Select provider" },
@@ -415,26 +434,33 @@ export function createPickers(ctx) {
415
434
  const val = (await askQuestion(
416
435
  `Context window for ${se.name} in K units (current: ${current ? `${current}K` : "spec default"} — e.g. 128 = 128K; empty to clear):`
417
436
  ))?.trim() ?? ""
418
- if (val === "") {
419
- delete target.context
420
- } else {
437
+ let newCtx // undefined = 清空(回 spec 值)
438
+ if (val !== "") {
421
439
  const n = Number(val)
422
440
  if (!Number.isInteger(n) || n <= 0) {
423
441
  pushLine(`Invalid context: "${val}" — must be a positive integer in K units (e.g. 128 = 128K)`, C.error)
424
442
  return
425
443
  }
426
- target.context = n
444
+ newCtx = n
427
445
  }
446
+ await persistRaw((raw) => {
447
+ raw.providers ??= []
448
+ const t = raw.providers.find((p) => p?.name === se.name)
449
+ if (!t) return
450
+ if (newCtx === undefined) delete t.context
451
+ else t.context = newCtx
452
+ })
453
+ if (newCtx === undefined) delete target.context
454
+ else target.context = newCtx
428
455
  if (se.name === agent.activeProvider) {
429
456
  // 运行时 provider 同步(同 setProviderKey 先例:只补 context,不重建对象以免丢 activeModel 覆盖)
430
- if (target.context === undefined) delete agent.provider.context
431
- else agent.provider.context = target.context
457
+ if (newCtx === undefined) delete agent.provider.context
458
+ else agent.provider.context = newCtx
432
459
  if (agent.config?.agent?.compactThresholdAuto) {
433
460
  const { resolveCompactThreshold } = await import("../config.mjs")
434
461
  agent.config.agent.compactThreshold = resolveCompactThreshold(null, agent.provider).value
435
462
  }
436
463
  }
437
- await persistRaw((raw) => { raw.providers = agent.providers })
438
464
  pushLine(target.context !== undefined
439
465
  ? `ctx = ${target.context}K (${target.context * 1024} tokens)`
440
466
  : `context cleared — using model spec (${fmtContextK(providerSpec(target).context)})`, C.tool)