thincoder 0.12.54 → 0.12.59

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 (129) hide show
  1. package/CHANGELOG.md +98 -0
  2. package/README.md +1 -1
  3. package/bin/thincoder.mjs +25 -3
  4. package/package.json +3 -7
  5. package/src/acp/bridge.mjs +132 -26
  6. package/src/advisor/messages.mjs +38 -3
  7. package/src/advisor/run.mjs +91 -53
  8. package/src/advisor.mjs +15 -7
  9. package/src/agent/dispatch.mjs +156 -39
  10. package/src/agent/helpers.mjs +46 -4
  11. package/src/agent/setup.mjs +102 -19
  12. package/src/agent/spawn-child.mjs +28 -1
  13. package/src/agent-tools/advisor.mjs +43 -11
  14. package/src/agent-tools/consult.mjs +37 -6
  15. package/src/agent-tools/eng.mjs +4 -1
  16. package/src/agent-tools/goal.mjs +11 -1
  17. package/src/agent-tools/read-history.mjs +160 -0
  18. package/src/agent-tools/settings.mjs +162 -0
  19. package/src/agent-tools/skill.mjs +2 -1
  20. package/src/agent-tools/subagent-actions.mjs +432 -0
  21. package/src/agent-tools/subagent-async.mjs +427 -0
  22. package/src/agent-tools/subagent-scheduler.mjs +319 -0
  23. package/src/agent-tools/subagent.mjs +565 -128
  24. package/src/agent-tools/task.mjs +4 -3
  25. package/src/agent-tools/timer.mjs +9 -4
  26. package/src/agent-tools/verify.mjs +161 -49
  27. package/src/agent-tools.mjs +1 -0
  28. package/src/agent.mjs +182 -81
  29. package/src/auto-think.mjs +14 -0
  30. package/src/cli/make-agent.mjs +27 -1
  31. package/src/cli/memory-command.mjs +28 -7
  32. package/src/cli/permission.mjs +8 -1
  33. package/src/config.mjs +125 -8
  34. package/src/context.mjs +115 -34
  35. package/src/distill.mjs +19 -1
  36. package/src/escape.mjs +82 -27
  37. package/src/log.mjs +195 -0
  38. package/src/mcp/transport-http.mjs +13 -1
  39. package/src/mcp.mjs +52 -7
  40. package/src/memory/code-sync.mjs +1 -1
  41. package/src/memory/core.mjs +204 -10
  42. package/src/memory/docs.mjs +197 -62
  43. package/src/memory.mjs +1 -1
  44. package/src/model-specs.mjs +38 -1
  45. package/src/prompts/advisor-design.md +46 -0
  46. package/src/prompts/advisor-round1.md +49 -2
  47. package/src/prompts/advisor-round2.md +47 -0
  48. package/src/prompts/advisor-round3.md +47 -0
  49. package/src/prompts/coder.md +22 -0
  50. package/src/prompts/consult-base.md +13 -0
  51. package/src/prompts/discipline.md +25 -6
  52. package/src/prompts/eng-coder.md +2 -2
  53. package/src/prompts/engineering-sub.md +23 -1
  54. package/src/prompts/engineering.md +157 -50
  55. package/src/prompts/explore.md +1 -2
  56. package/src/prompts/main.md +11 -5
  57. package/src/prompts/methodology-template.md +14 -0
  58. package/src/prompts/system.md +5 -2
  59. package/src/provider/anthropic.mjs +7 -5
  60. package/src/provider/core.mjs +104 -28
  61. package/src/provider/google.mjs +57 -24
  62. package/src/provider/normalize.mjs +1 -1
  63. package/src/provider/rate.mjs +0 -2
  64. package/src/provider/responses.mjs +8 -13
  65. package/src/provider/sse.mjs +20 -0
  66. package/src/session.mjs +15 -0
  67. package/src/tools/apply_patch.md +5 -1
  68. package/src/tools/bash.md +3 -3
  69. package/src/tools/delete.md +1 -0
  70. package/src/tools/edit-batch.mjs +92 -0
  71. package/src/tools/edit-diff.mjs +265 -0
  72. package/src/tools/edit.md +11 -6
  73. package/src/tools/execute.md +8 -8
  74. package/src/tools/execute.mjs +31 -35
  75. package/src/tools/file.mjs +26 -114
  76. package/src/tools/file_ops.md +3 -2
  77. package/src/tools/get_current_time.md +3 -1
  78. package/src/tools/git.md +1 -1
  79. package/src/tools/git.mjs +8 -16
  80. package/src/tools/hashline_edit.md +2 -0
  81. package/src/tools/index.mjs +3 -2
  82. package/src/tools/insert_after.md +2 -1
  83. package/src/tools/lint.md +3 -1
  84. package/src/tools/linter.mjs +9 -37
  85. package/src/tools/lsp.md +4 -1
  86. package/src/tools/patch.mjs +84 -13
  87. package/src/tools/pdf-parse-text.mjs +497 -0
  88. package/src/tools/pdf-parse-xref.mjs +499 -0
  89. package/src/tools/pdf.mjs +155 -0
  90. package/src/tools/question.md +2 -1
  91. package/src/tools/read.md +1 -0
  92. package/src/tools/read_pdf.md +21 -0
  93. package/src/tools/repomap.mjs +1 -1
  94. package/src/tools/shared.mjs +11 -32
  95. package/src/tools/system.mjs +6 -21
  96. package/src/tools/tree.md +2 -1
  97. package/src/tools/web.mjs +5 -3
  98. package/src/tools/websearch.md +2 -1
  99. package/src/tools/write.md +2 -0
  100. package/src/traces/trace-store.mjs +224 -0
  101. package/src/tui/agent-turn.mjs +387 -24
  102. package/src/tui/clipboard.mjs +17 -6
  103. package/src/tui/cmd-config.mjs +29 -9
  104. package/src/tui/cmd-eng.mjs +1 -0
  105. package/src/tui/cmd-extract.mjs +1 -1
  106. package/src/tui/cmd-mcp-form.mjs +197 -0
  107. package/src/tui/cmd-mcp.mjs +264 -114
  108. package/src/tui/cmd-think.mjs +1 -1
  109. package/src/tui/index.mjs +49 -95
  110. package/src/tui/interaction.mjs +41 -3
  111. package/src/tui/key-handler.mjs +105 -143
  112. package/src/tui/key-modes.mjs +215 -0
  113. package/src/tui/layout.mjs +22 -1
  114. package/src/tui/mouse.mjs +41 -1
  115. package/src/tui/pickers.mjs +73 -7
  116. package/src/tui/render-conversation.mjs +13 -161
  117. package/src/tui/render-frame.mjs +45 -20
  118. package/src/tui/render-loop.mjs +4 -1
  119. package/src/tui/render-segments.mjs +165 -0
  120. package/src/tui/render.mjs +4 -4
  121. package/src/tui/startup.mjs +40 -2
  122. package/src/tui/subagent-blocks.mjs +404 -111
  123. package/src/tui/subagent-panel.mjs +88 -13
  124. package/src/tui/tool-args.mjs +10 -2
  125. package/src/tui/tool-events.mjs +172 -95
  126. package/src/tui/update-notice.mjs +72 -0
  127. package/src/tui/wizard.mjs +36 -6
  128. package/src/agent-tools/escalate.mjs +0 -179
  129. package/src/tools/exec-prelude.mjs +0 -84
package/src/tui/index.mjs CHANGED
@@ -14,6 +14,8 @@
14
14
  * config-helpers.mjs — persistRaw / syncProviderField / maskKey
15
15
  * clipboard.mjs — clipboard image paste
16
16
  * distill-cmd.mjs — /distill command
17
+ * mouse.mjs — SGR mouse parsing + dispatch assembly (createMouseDispatch)
18
+ * update-notice.mjs — background update notice + startup check
17
19
  */
18
20
 
19
21
  import { emitKeypressEvents } from "node:readline"
@@ -30,22 +32,30 @@ import { createPickers } from "./pickers.mjs"
30
32
  import { runDistill as runDistillImpl } from "./distill-cmd.mjs"
31
33
  import { createInteraction } from "./interaction.mjs"
32
34
  import { pasteClipboardImage as pasteClipboardImageImpl, insertPastedText, translateShiftEnter, stripKeyboardProtocol } from "./clipboard.mjs"
33
- import { parseMouseClicks, handleMouseClick, handleWheel } from "./mouse.mjs"
35
+ import { parseMouseClicks, handleWheel, createMouseDispatch } from "./mouse.mjs"
34
36
  import { runAgentTurn } from "./agent-turn.mjs"
35
37
  import { createKeyHandler, convMaxScroll } from "./key-handler.mjs"
36
- import { showStartup, backgroundIndex, historyToLines, HISTORY_PAGE_MESSAGES } from "./startup.mjs"
37
- import { countConvLines } from "./render-conversation.mjs"
38
+ import { showStartup, backgroundIndex, createLoadOlder } from "./startup.mjs"
39
+ import { shiftFreezeAnchors } from "./subagent-blocks.mjs"
38
40
  import { createConfigHelpers } from "./config-helpers.mjs"
41
+ import { createUpdateNotice, pendingNoticeReady } from "./update-notice.mjs"
39
42
 
40
- /** 升级失败提示文案:附 npm 输出尾部(最多 3 行),方便定位失败原因。 */
41
- export function upgradeFailureText(code, output) {
42
- const tail = (output ?? "").trimEnd().split("\n").slice(-3).join("\n")
43
- return `✗ Upgrade failed (exit ${code}). Run \`thincoder upgrade\` manually.${tail ? `\n${tail}` : ""}`
44
- }
43
+ export { upgradeFailureText, pendingNoticeReady } from "./update-notice.mjs"
45
44
 
46
- /** 后台更新提示可弹出的条件:无任何交互弹层(picker/permission/question)激活。 */
47
- export function pendingNoticeReady(state) {
48
- return Boolean(state.pendingNotice && !state.picker && !state.permission && !state.question)
45
+ /**
46
+ * SESSION.md §8 D-S2 — TUI 启动首帧前的 provider 重选流程:
47
+ * provider 无效(`_providerInvalid` 标记或 provider null)→ 先弹模型选择 picker
48
+ * (复用 openModelPicker,展示当前可用 providers);用户选定后继续正常启动。
49
+ * 选择取消(Esc)→ 仍进入 TUI,推送提示行("未配置有效 provider,可用 /model 选择或
50
+ * /provider 配置")——绝不因无 provider 拒绝进入。返回 true 表示弹过选择流程。
51
+ */
52
+ export async function promptProviderIfInvalid(agent, openModelPicker, pushLine) {
53
+ if (!(agent._providerInvalid || !agent.provider)) return false
54
+ await openModelPicker()
55
+ if (!agent.provider) {
56
+ pushLine("未配置有效 provider,可用 /model 选择或 /provider 配置", C.warn)
57
+ }
58
+ return true
49
59
  }
50
60
 
51
61
  /**
@@ -96,7 +106,7 @@ export async function startTUI(agent, opts = {}) {
96
106
  reasoning: "", // thinking stream buffer (dimmed display)
97
107
  completion: null, // Tab completion state { candidates, index }
98
108
 
99
- subTasks: {}, // sub-agent activity blocks (§7.2 D4): { "coder#1": { key, role, model, started, done, doneAt, blocks: [{kind,text}], currentTool, toolArgs, turn, maxTurns, approval, lastError, dropped, blockEpoch } } — rendered as collapsible in-conversation blocks; persists across turns (blocks are the child activity's ONLY carrier — child tool calls never enter the parent history); bounded by the N2 500-line per-child ring buffer
109
+ subTasks: {}, // sub-agent activity blocks (§7.2 D4): { "coder#1": { key, role, model, started, done, doneAt, blocks: [{kind,text}], currentTool, toolArgs, turn, maxTurns, approval, lastError, dropped, blockEpoch, awaitingDigest(§17 挂起中间态), _freezeAt(冻结锚点) } } — rendered as collapsible in-conversation blocks; persists across turns (blocks are the child activity's ONLY carrier — child tool calls never enter the parent history); bounded by the N2 500-line per-child ring buffer
100
110
  currentTool: null, // currently executing tool name (shown in status bar)
101
111
  processingStarted: 0, // current turn start time (status bar timer)
102
112
  status: "Ready",
@@ -111,7 +121,13 @@ export async function startTUI(agent, opts = {}) {
111
121
  _historyLoaded: 0, // messages loaded from the TAIL of _fullHistory
112
122
  _historyTotal: 0, // total messages in the restored session
113
123
  _hasOlder: false, // more earlier messages remain unloaded
124
+ _agent: null, // §19.6 D-P1 面板镜像挂载点:startTUI 装配时置 agent 引用——subagent-blocks
125
+ // syncPanelSnapshot 经此写 agent._panelSnapshot(块级状态变更即刷——subagent
126
+ // action:"panel" 读——模型可查面板视图 + 门控补发冻结)。headless/VS Code 无此
127
+ // 挂载 = 无镜像 → panel 动作恒降级池视图(CLI-only 完整能力——AC-P4)。
114
128
  }
129
+ state._agent = agent
130
+ agent._panelSnapshot = [] // 镜像初始态 = 空面板(非"无镜像"——[] 与 undefined 区分)
115
131
 
116
132
  // On session restore, if all tasks are completed, auto-collapse the todo panel (match runtime behavior)
117
133
  if (state.tasks.length > 0 && state.tasks.every((t) => t.status === "done")) {
@@ -137,38 +153,6 @@ export async function startTUI(agent, opts = {}) {
137
153
  let pasteMode = false
138
154
  let pasteAccum = ""
139
155
 
140
- /** 懒加载更早历史(2026-08-31 用户约定:"滚动到头自动加载"——滚轮/PgUp 到顶皆触发;
141
- * 2026-08-31 前只挂 PgUp 键 = 违约,滚轮到头无反应)。加载后滚动补偿保持锚定。
142
- * 外层作用域:data 回调(滚轮分支)与 createKeyHandler ctx(PgUp 分支)共用。 */
143
- const loadOlder = () => {
144
- if (!state._hasOlder) return
145
- const full = agent._fullHistory ?? []
146
- const loaded = state._historyLoaded
147
- const start = Math.max(0, full.length - loaded - HISTORY_PAGE_MESSAGES)
148
- const end = full.length - loaded
149
- if (start >= end) return
150
-
151
- const d = state.dims ? state.dims.get() : {}
152
- const cols = d.cols ?? ((state.dims?.get() ?? {}).cols ?? (process.stdout.columns || 80))
153
- const before = countConvLines(state, cols, d.rows ?? (process.stdout.rows || 24))
154
-
155
- if (state.lines[0]?.text?.startsWith("… ")) state.lines.shift()
156
- state._lineIdCounter = state._lineIdCounter ?? 0
157
- const older = historyToLines(full, start, end)
158
- for (const l of older) l._lineId = ++state._lineIdCounter
159
- state.lines.unshift(...older)
160
- state._historyLoaded += end - start
161
- state._hasOlder = start > 0
162
- if (state._hasOlder) {
163
- state.lines.unshift({ text: `… ${start} more earlier messages (scroll to top to load)`, color: C.dim })
164
- }
165
-
166
- const after = countConvLines(state, cols, (state.dims?.get() ?? {}).rows ?? (process.stdout.rows || 24))
167
- state.scroll += Math.max(0, after - before)
168
- render()
169
- }
170
-
171
-
172
156
  process.stdin.on("data", (chunk) => {
173
157
  try {
174
158
 
@@ -221,7 +205,6 @@ export async function startTUI(agent, opts = {}) {
221
205
 
222
206
  // Scroll wheel: \x1b[<64;col;rowM = up, \x1b[<65;col;rowM = down(3 lines each)
223
207
  // 2026-08-31:坐标命中展开块内容行 → 块内滚动(handleWheel);未命中 → 会话滚动(现状)
224
- // eslint-disable-next-line no-control-regex -- 有意为之:控制字符协议/转义序列剥离正则(ANSI/⟦ev⟧/SGR/history 双线分隔)
225
208
  for (const m of text.matchAll(/\x1b\[<(\d+);(\d+);(\d+)([Mm])/g)) {
226
209
  const button = Number(m[1])
227
210
  if (button === 64 || button === 65) {
@@ -251,9 +234,7 @@ export async function startTUI(agent, opts = {}) {
251
234
  }
252
235
 
253
236
  // Strip complete mouse sequences; keep incomplete tail for reassembly with next chunk
254
- // eslint-disable-next-line no-control-regex -- 有意为之:控制字符协议/转义序列剥离正则(ANSI/⟦ev⟧/SGR/history 双线分隔)
255
237
  text = text.replace(/\x1b\[<\d+;\d+;\d+[Mm]/g, "")
256
- // eslint-disable-next-line no-control-regex -- 有意为之:控制字符协议/转义序列剥离正则(ANSI/⟦ev⟧/SGR/history 双线分隔)
257
238
  const tail = text.match(/\x1b\[<[\d;]*$/)
258
239
  if (tail) {
259
240
  mousePending = tail[0]
@@ -282,6 +263,10 @@ export async function startTUI(agent, opts = {}) {
282
263
  if (state.lines.length > 5000) {
283
264
  state.lines.splice(0, 1000)
284
265
  state.lines.unshift({ text: `... [earlier messages trimmed — ${state.lines.length} lines remaining]`, color: C.dim })
266
+ // 2026-09-03 修复轮(冻结锚点):头裁对在途 settled 锚点整体前移(锚点是绝对
267
+ // 流位置)——不校正则池空补发冻结(freezeSubTaskLines splice)落点漂移;校正量
268
+ // = 净位移(裁 1000 补 1 标记行 → −999,code review round1 #3)。
269
+ shiftFreezeAnchors(state, 1000)
285
270
  }
286
271
  render()
287
272
  }
@@ -310,6 +295,10 @@ export async function startTUI(agent, opts = {}) {
310
295
  pushLine)
311
296
  const { render, scheduleRender } = renderLoop
312
297
 
298
+ // 懒加载更早历史(startup.mjs createLoadOlder——D-S1b):滚轮/PgUp 到顶自动加载;
299
+ // 声明在此(render 已可用)——data 回调(滚轮分支)与 createKeyHandler ctx(PgUp)共用。
300
+ const loadOlder = createLoadOlder({ agent, state, render })
301
+
313
302
  // Resize events are genuine dimension changes on every terminal (2026-08-31
314
303
  // simplification: the earlier settle-timer/double-confirm machinery was built
315
304
  // on the misdiagnosed ConPTY-stale hypothesis and even stalled drag-shrink).
@@ -368,7 +357,7 @@ export async function startTUI(agent, opts = {}) {
368
357
  }
369
358
 
370
359
  // Interaction primitives: permission approval + Q&A input, implemented in interaction.mjs
371
- const { askPermission, askQuestion } = createInteraction({
360
+ const { askPermission, askQuestion, askBatchPermission } = createInteraction({
372
361
  agent, state, pushLine, pushLabel, render, summarize,
373
362
  })
374
363
 
@@ -378,7 +367,7 @@ export async function startTUI(agent, opts = {}) {
378
367
  // Agent loop: implemented in agent-turn.mjs
379
368
  const turnCtx = {
380
369
  agent, state, pushLine, pushLabel, render, scheduleRender: render, ensureAssistantLabel,
381
- askPermission, askQuestion, handleSlash: null,
370
+ askPermission, askQuestion, askBatchPermission, handleSlash: null,
382
371
  get assistantLabeled() { return assistantLabeled },
383
372
  set assistantLabeled(v) { assistantLabeled = v },
384
373
  }
@@ -437,58 +426,23 @@ export async function startTUI(agent, opts = {}) {
437
426
  }
438
427
  })
439
428
 
440
- // Mouse clicks (SGR \x1b[<0;col;rowM) — picker selection + fold expansion.
441
- const onMouseClick = (col, row) => handleMouseClick({ state, render, popPicker }, col, row)
442
- const mouseCtx = () => ({ state, render })
429
+ // 鼠标点击/滚轮 ctx 装配(mouse.mjs createMouseDispatch——D-S1a:cancelSubagent/onMouseClick/mouseCtx)
430
+ const { onMouseClick, mouseCtx } = createMouseDispatch({ agent, state, pushLine, render, popPicker })
443
431
 
444
432
  // ---------------------------------------------------------- Startup screen + background indexing
445
433
 
434
+ // SESSION.md §8 D-S2:startTUI 首帧前 —— provider 无效(_providerInvalid / provider 为 null)
435
+ // → 先弹模型选择 picker(keyStream 已挂 keypress,Esc/Enter 可用);Esc → 提示行,仍进 TUI
436
+ await promptProviderIfInvalid(agent, () => openModelPicker(), pushLine)
437
+
446
438
  showStartup({ agent, state, opts, pushLine, pushLabel, render, startWizard })
447
439
  backgroundIndex({ agent, state, render })
448
440
 
449
- // Check for updates (non-blocking, after startup screen)
450
- // picker 打开时不硬抢:挂到 state.pendingNotice,picker 全部关闭后由 doRender 弹出
451
- const showUpdateNotice = async (result) => {
452
- const sel = await showPicker(`Update available: ${result.local} ${result.latest}`, [
453
- { type: "header", text: `thincoder ${result.latest} is available (current: ${result.local})` },
454
- { type: "item", text: "Upgrade now", action: "upgrade" },
455
- { type: "item", text: "Later", action: "later" },
456
- ])
457
- if (sel?.action !== "upgrade") return
458
- pushLabel(`❯ Upgrade`, ansi.bold + C.tool)
459
- pushLine(`Upgrading to ${result.latest}...`, C.tool)
460
- const { exec } = await import("node:child_process")
461
- const cp = exec("npm install -g thincoder@latest", { windowsHide: true })
462
- let stdout = ""
463
- cp.stdout?.on("data", (d) => { stdout += d })
464
- cp.stderr?.on("data", (d) => { stdout += d })
465
- cp.on("close", (code) => {
466
- if (code === 0) {
467
- pushLine(`✓ Upgraded to ${result.latest}. Restart to apply.`, C.tool)
468
- } else {
469
- pushLine(upgradeFailureText(code, stdout), C.error)
470
- }
471
- render()
472
- })
473
- }
474
- ;(async () => {
475
- try {
476
- const { readFileSync } = await import("node:fs")
477
- const pkg = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8"))
478
- const { checkForUpdate } = await import("../upgrade.mjs")
479
- const result = await checkForUpdate(pkg.version)
480
- if (result?.newer) {
481
- // Defer: if wizard is still active, just show a dim line
482
- if (state.wizard) {
483
- pushLine(`Tip: thincoder ${result.latest} is available (run /upgrade later or restart)`, C.dim)
484
- render()
485
- } else {
486
- state.pendingNotice = result
487
- render()
488
- }
489
- }
490
- } catch { /* network error or timeout — silently skip */ }
491
- })()
441
+ // Check for updates (non-blocking, after startup screen)——实现 update-notice.mjs
442
+ // (D-S1c):有 picker 打开时不硬抢——挂到 state.pendingNotice,picker 全部关闭后由
443
+ // doRender 弹出(showUpdateNotice renderLoop ctx getter 懒引用)。
444
+ const { showUpdateNotice, checkUpdates } = createUpdateNotice({ state, showPicker, pushLine, pushLabel, render })
445
+ checkUpdates()
492
446
  }
493
447
 
494
448
  function summarize(obj) {
@@ -42,7 +42,16 @@ export function createInteraction(ctx) {
42
42
  }
43
43
  if (base === "delete") return [`${args.path}${args.force ? " (force: also delete tracked files)" : ""}`]
44
44
  if (base === "subagent") return cap(args.task ?? "", 500).split("\n")
45
- if (base === "memory_put") return [`[${args.type ?? ""}] ${args.title ?? ""}`, ...cap(args.content ?? "", 500).split("\n")]
45
+ if (base === "memory") {
46
+ // §6 action-routed preview (D-M5): put shows content, batch delete/clear show gate args
47
+ const action = String(args.action ?? "")
48
+ if (action === "put") return [`[${args.type ?? ""}] ${args.title ?? ""}`, ...cap(args.content ?? "", 500).split("\n")]
49
+ if (action === "delete") return args.id
50
+ ? [`delete single: ${args.id} (scope ${args.scope})`]
51
+ : [`batch delete scope=${args.scope} type=${args.type ?? ""} keyword=${args.keyword ?? ""}`, `confirm=${args.confirm}`]
52
+ if (action === "clear") return [`clear personal memory`, `scope=${args.scope} confirm=${args.confirm}`]
53
+ return [cap(summarize(args), 300)]
54
+ }
46
55
  return [cap(summarize(args), 300)]
47
56
  }
48
57
 
@@ -62,6 +71,33 @@ export function createInteraction(ctx) {
62
71
  })
63
72
  }
64
73
 
74
+ /** Batch permission ask (AGENT-LOOP.md §16 D-B1): one merged prompt covering N
75
+ * non-readonly tools from the same toolCalls array — approve all / one by one /
76
+ * deny. Verdicts resolve as "approveAll" | "oneByOne" | "deny". */
77
+ const BATCH_PREVIEW_LINES_PER_TOOL = 8
78
+ function askBatchPermission({ tools, count }) {
79
+ if (agent.autoApprove) {
80
+ pushLine(` [auto] ${count} tools: ${tools.map((t) => t.name).join(", ")}`, C.warn)
81
+ return Promise.resolve("approveAll")
82
+ }
83
+ const names = [...new Set(tools.map((t) => t.name))]
84
+ const label = `${count} tools need permission: ${names.join(", ")}`
85
+ // Preview: one compact block per tool (name header + first N formatted lines)
86
+ state.permissionPreview = [
87
+ label,
88
+ ...tools.flatMap((t) => {
89
+ const lines = formatPermission(t.name, t.args)
90
+ const shown = lines.slice(0, BATCH_PREVIEW_LINES_PER_TOOL)
91
+ return [` ${t.name}:`, ...shown.map((l) => ` ${l}`), ...(lines.length > shown.length ? [` … (${lines.length - shown.length} more lines)`] : [])]
92
+ }),
93
+ ]
94
+ return new Promise((resolve) => {
95
+ state.permission = { name: label, args: {}, resolve, batch: { tools, count } }
96
+ state.status = `Waiting: ${label}`
97
+ render()
98
+ })
99
+ }
100
+
65
101
  function askQuestion(text, options = []) {
66
102
  // only one question at a time: question is a read-only tool on a parallel channel,
67
103
  // a second concurrent one is rejected directly; otherwise the later one overwrites
@@ -71,10 +107,12 @@ export function createInteraction(ctx) {
71
107
  }
72
108
  if (!options.length) {
73
109
  // free text: open input mode for user typing, Enter to submit
110
+ // q.cursor 装配(TUI-INPUT-BOX.md §7.2/§7.5 #10b):answer 存 codepoint 数组(同
111
+ // state.input 语义——emoji 不劈半),进入自由文本态即 cursor = answer.length(= 0)。
74
112
  pushLabel(`❯ Question`, ansi.bold + C.tool)
75
113
  for (const line of text.split("\n")) pushLine(` ${line}`, C.text)
76
114
  return new Promise((resolve) => {
77
- state.question = { text, options: [], resolve }
115
+ state.question = { text, options: [], answer: [], cursor: 0, resolve }
78
116
  state.status = "Waiting for answer..."
79
117
  render()
80
118
  })
@@ -91,5 +129,5 @@ export function createInteraction(ctx) {
91
129
  })
92
130
  }
93
131
 
94
- return { askPermission, askQuestion, formatPermission }
132
+ return { askPermission, askQuestion, formatPermission, askBatchPermission }
95
133
  }
@@ -3,12 +3,11 @@ import { readClipboardText, insertPastedText } from "./clipboard.mjs"
3
3
  import { computeLayout } from "./layout.mjs"
4
4
  import { handleSearchKey } from "./key-handler-search.mjs"
5
5
  import { countConvLines } from "./render-conversation.mjs"
6
- import { QUESTION_CUSTOM } from "./interaction.mjs"
6
+ import { handlePermissionMode, handleQuestionMode, handleInterruptMode } from "./key-modes.mjs"
7
7
 
8
8
  /** Current conversation max scroll offset (display lines beyond the visible panel). */
9
9
  export function convMaxScroll(state) {
10
- // Single source (Windows ConPTY instability, 2026-08-30) — cached dims.
11
- const d = state.dims ? state.dims.get() : {}
10
+ const d = state.dims ? state.dims.get() : {} // Single source (ConPTY instability, 2026-08-30) — cached dims
12
11
  const cols = d.cols ?? ((state.dims?.get() ?? {}).cols ?? (process.stdout.columns || 80))
13
12
  const rows = d.rows ?? (process.stdout.rows || 24)
14
13
  const layout = computeLayout(state, { cols, rows })
@@ -25,119 +24,99 @@ export function createKeyHandler(ctx) {
25
24
  const { agent, state, render, popPicker, renderPickerLines, handleSlash, handleTab, submit, pasteClipboardImage, wizardChooseProvider, wizardSubmitText, cancelWizard, wizardProviderItems, renderWizard, pushLine, cleanup, showPicker, loadOlder } = ctx
26
25
 
27
26
  return function onKeypress(str, key = {}) {
28
- // permission confirm state: y approve / n deny / a approve + turn ON AUTO (no further prompts)
29
- if (state.permission) {
30
- const answer = (str || "").toLowerCase()
31
- const isContinue = state.permission.name === "continue"
32
- const validKeys = isContinue ? ["y", "n"] : ["y", "n", "a"]
33
- if (validKeys.includes(answer) || key.name === "escape") {
34
- const { resolve, name } = state.permission
35
- state.permission = null
36
- state.permissionPreview = []
37
- state.status = "Processing..."
38
- if (answer === "a" && !isContinue) {
39
- agent.autoApprove = true
40
- agent._pendingReminders = agent._pendingReminders ?? []
41
- agent._pendingReminders.push("[System reminder: AUTO mode is now ON. All tool calls are automatically approved. Use /auto to disable.]")
42
- pushLine(` [auto] AUTO ON: tool calls no longer prompt for approval (/auto to disable)`, C.warn)
43
- }
44
- const approved = answer === "y" || (answer === "a" && !isContinue)
45
- // leave trail: record approval/denial in conversation (continue prompt has its own output, don't duplicate)
46
- if (!isContinue) {
47
- pushLine(` [${approved ? "approved" : "denied"}] ${name}`, approved ? C.dim : C.error)
48
- }
49
- resolve(approved)
50
- render()
51
- }
52
- return
53
- }
27
+ // permission confirm: y/n/a (a = approve + AUTO ON); batch (§16 D-B1): a/o/n (Esc = deny)
28
+ // ——模态实现 key-modes.mjs handlePermissionMode(2026-09-03 D-S4)
29
+ if (handlePermissionMode(str, key, { state, agent, pushLine, render })) return
54
30
 
55
- // question tool callback: free text / option selection
56
- if (state.question) {
57
- const q = state.question
58
- if (q.options.length > 0) {
59
- // options mode: ↑↓ select, Enter confirm, Esc cancel
60
- if (key.name === "escape") {
61
- q.resolve("")
62
- state.question = null
63
- state.status = "Processing..."
64
- render()
65
- } else if (key.name === "up") {
66
- q.selected = Math.max(0, (q.selected ?? 0) - 1)
67
- render()
68
- } else if (key.name === "down") {
69
- q.selected = Math.min(q.options.length - 1, (q.selected ?? 0) + 1)
70
- render()
71
- } else if (key.name === "return") {
72
- const answer = q.options[q.selected ?? 0]
73
- if (answer === QUESTION_CUSTOM) {
74
- // Switch to free-text mode — the user wants to type their own answer.
75
- q.options = []
76
- q.answer = ""
77
- q.selected = undefined
78
- state.status = "Waiting for answer..."
79
- render()
80
- return
81
- }
82
- q.resolve(answer)
83
- state.question = null
84
- state.status = "Processing..."
85
- pushLine(` → ${answer}`, C.tool)
86
- render()
87
- }
88
- } else {
89
- // free text: type answer, Enter submit, Esc cancel
90
- if (key.name === "escape") {
91
- q.resolve("")
92
- state.question = null
93
- state.status = "Processing..."
94
- render()
95
- } else if (key.name === "return") {
96
- if (q._pasting) return // block Enter while paste is in flight
97
- const answer = (q.answer ?? "").trim()
98
- q.resolve(answer || "")
99
- state.question = null
100
- state.status = "Processing..."
101
- pushLine(` → ${answer || "(empty)"}`, C.tool)
102
- render()
103
- } else if (key.name === "backspace") {
104
- q.answer = (q.answer ?? "").slice(0, -1)
105
- render()
106
- } else if (key.ctrl && !key.alt && key.name === "v") {
107
- // Ctrl+V paste: read clipboard text (fires when the terminal passes Ctrl+V through
108
- // as a key event; bracketed-paste terminals are handled upstream in the stdin handler)
109
- if (q._pasting) return
110
- q._pasting = true
111
- readClipboardText().then((text) => {
112
- q._pasting = false
113
- if (text) {
114
- insertPastedText(state, text)
115
- render()
116
- }
117
- }).catch((e) => {
118
- q._pasting = false
119
- console.error(`[tui] clipboard paste failed: ${e.message}`)
120
- })
121
- } else if (str && !key.ctrl && !key.meta) {
122
- q.answer = (q.answer ?? "") + str
123
- render()
124
- }
125
- }
126
- return
127
- }
31
+ // question tool callback: free text / option selection——模态实现
32
+ // key-modes.mjs handleQuestionMode(D-S4)
33
+ if (handleQuestionMode(str, key, { state, pushLine, render })) return
128
34
 
129
35
  // Search mode: Ctrl+F to enter, Ctrl+N/Ctrl+P (or Ctrl+G/Ctrl+R) navigate, Esc exit
130
36
  if (handleSearchKey(str, key, state, render)) return
131
37
 
132
- if (key.ctrl && key.name === "c") {
38
+ if (key.ctrl && key.name === "c") {
133
39
  // picker 打开时 Ctrl+C = 取消当前 picker(等同 Esc),不杀进程
134
40
  if (state.picker) {
135
41
  popPicker(null)
136
42
  return
137
43
  }
44
+ // §17.6(2026-09-03):武装窗口内第二次 Ctrl+C = 显式全停(D-C4 /abort 语义)。
45
+ // 在状态路由前检查(picker 取消分支例外——picker 打开时 Ctrl+C 语义 = 取消
46
+ // picker,武装让位;场景极窄——3s 窗口内需另有 picker 被打开)——两次按下之间
47
+ // 状态会迁移(首按停回合 → 释放窗口/挂起会话启动),武装必须跨态桥接:只查挂起
48
+ // 分支会让非挂起 processing 首按后的二按落入空闲退出分支(三按误退出应用)。
49
+ if (state.suspAbortArmed) {
50
+ if (ctx.suspArmTimer) clearTimeout(ctx.suspArmTimer)
51
+ state.suspAbortArmed = false
52
+ // 无可停目标(无 processing 回合 / 无会话 abort 集合 / 未挂起)时不吞键:
53
+ // 首按停的是普通回合(无后台池)——二按落回下方空闲退出分支(exitArmed 双
54
+ // 确认),不打印误导性的 "[Aborting background subagents…]"(advisor round1
55
+ // 🟡——停回合后连按退出的第二次按下被空转吞掉 → 退出从 3 按变 4 按)。
56
+ const hasStopTarget = (state.processing && state.controller) ||
57
+ (agent._sessionAbortAll?.length ?? 0) > 0 ||
58
+ (agent._sessionAbort != null && !agent._sessionAbort.signal.aborted) ||
59
+ state.suspended
60
+ if (hasStopTarget) {
61
+ // 当前回合平 abort(无 interrupt——命中 agent.mjs 回合收尾清池分支——全停)
62
+ if (state.processing && state.controller) state.controller.abort()
63
+ // 会话 abort 集合 = 链条内全部 controller(含 Ctrl+I/ContinueError 重建的旧
64
+ // controller——children 不逃逸,round1 偏差 #3)。挂起态才标记 _suspAborted +
65
+ // 唤醒 driver(driver 收尾清池)——非挂起语境置位会粘滞阻塞未来挂起会话重入
66
+ // (round2 偏差 #1 语义)。
67
+ for (const c of agent._sessionAbortAll ?? (agent._sessionAbort ? [agent._sessionAbort] : [])) c?.abort()
68
+ if (state.suspended) {
69
+ state._suspAborted = true
70
+ state._suspWake?.()
71
+ }
72
+ pushLine("[Aborting background subagents…]", C.warn)
73
+ render()
74
+ return
75
+ }
76
+ // 无目标可停 → 落空继续到下方分支(processing 已停 → 空闲态 exitArmed 双确认)
77
+ }
78
+ if (state.suspended) {
79
+ // §17 D-S9 + round2 偏差 #4(2026-09-02)+ §17.6 修订(2026-09-03):挂起态
80
+ // Ctrl+C = 武装窗口两级中止——一次按键直接清池中止全部后台子代理的误触代价高
81
+ // (digest 刷屏时用户可能只想停住当前回合);仿空闲态退出武装语义(同下方空闲态
82
+ // exitArmed 双确认分支)。§17.6 修订 ①:中止当前回合的 abort 带 interrupt
83
+ // (无 message)——此前平 abort 命中 agent.mjs 回合收尾清池分支(aborted &&
84
+ // !reason.interrupt → 无条件 _asyncSubagents.clear()),一次首按即误杀全部后台
85
+ // 子代理(2026-09-03 用户两次实测——提示"再按才杀"与实际"一次就全杀"不符);
86
+ // interrupt 排除清池分支——会话与后台保留(agent-turn 区分:无 message 停回合
87
+ // 不续跑):
88
+ // ① 未武装 + 有回合在跑(digest 消化/会话内回合):仅中止当前回合
89
+ // (controller.abort({ interrupt: true }))——会话继续、后台子代理不受
90
+ // 影响,回挂起等待;
91
+ // ② 未武装 + 纯挂起等待:仅提示不清池;
92
+ // ③ 3s 武装窗口内再次 Ctrl+C:彻底中止——上方统一块(二按语义不变)。
93
+ if (state.processing && state.controller) {
94
+ state.controller.abort({ interrupt: true }) // ① 仅中止当前回合(digest/会话内回合),不碰后台池
95
+ pushLine("[stopped current turn — press Ctrl+C again within 3s to abort all background subagents]", C.warn)
96
+ } else {
97
+ const bgActive = [...(agent._asyncSubagents?.values() ?? [])].filter((e) => e.status === "running").length + (agent._asyncQueue?.length ?? 0)
98
+ pushLine(`[abort] Press Ctrl+C again within 3s to abort all background subagents (${bgActive} running)`, C.warn)
99
+ }
100
+ state.suspAbortArmed = true
101
+ if (ctx.suspArmTimer) clearTimeout(ctx.suspArmTimer)
102
+ ctx.suspArmTimer = setTimeout(() => { state.suspAbortArmed = false }, ctx.exitArmDelay ?? 3000)
103
+ ctx.suspArmTimer.unref?.()
104
+ render()
105
+ return
106
+ }
138
107
  if (state.processing && state.controller) {
139
- state.controller.abort()
140
- pushLine("[Aborting…]", C.warn)
108
+ // §17.6 D-C1(2026-09-03 紧急修复——processing 态曾无武装窗口,第一按直接平
109
+ // abort() → 命中 agent.mjs 回合收尾清池分支 → 一次 Ctrl+C 误杀全部后台子代理,
110
+ // 用户两次实测被坑):首按 = interrupt 语义(无 message——停当前回合不续跑——
111
+ // interrupt 排除 agent.mjs 清池分支——后台池保留——agent-turn 区分不重建续跑)
112
+ // + 武装 3s(复用 suspArmTimer/exitArmDelay——与挂起态同构——过期自动复位);
113
+ // 窗口内再按 = 上方统一块全停(平 abort → agent.mjs 清池)。
114
+ state.controller.abort({ interrupt: true })
115
+ pushLine("[stopped current turn — press Ctrl+C again within 3s to abort all background subagents]", C.warn)
116
+ state.suspAbortArmed = true
117
+ if (ctx.suspArmTimer) clearTimeout(ctx.suspArmTimer)
118
+ ctx.suspArmTimer = setTimeout(() => { state.suspAbortArmed = false }, ctx.exitArmDelay ?? 3000)
119
+ ctx.suspArmTimer.unref?.()
141
120
  render()
142
121
  return
143
122
  }
@@ -162,7 +141,7 @@ export function createKeyHandler(ctx) {
162
141
  // F1: 显示快捷键帮助
163
142
  if (key.name === "f1" && !state.picker && !state.permission && !state.question) {
164
143
  showPicker("Keyboard Shortcuts", [
165
- { type: "item", text: "Ctrl+C — Cancel/Abort; idle: press twice to exit" },
144
+ { type: "item", text: "Ctrl+C — Cancel/Abort; first press stops the turn (or arms), twice within 3s stops all / exits" },
166
145
  { type: "item", text: "Ctrl+I — Interrupt and inject message" },
167
146
  { type: "item", text: "Ctrl+F — Search conversation history" },
168
147
  { type: "item", text: "Shift+Enter / Ctrl+J — Insert newline (multiline input)" },
@@ -186,34 +165,9 @@ export function createKeyHandler(ctx) {
186
165
  return
187
166
  }
188
167
 
189
- // Interrupt prompt mode: type message, Enter to inject, Esc to cancel
190
- if (state.interruptPrompt) {
191
- if (key.name === "escape") {
192
- state.interruptPrompt = null
193
- render()
194
- } else if (key.name === "return") {
195
- const msg = (state.interruptPrompt.text ?? "").trim()
196
- state.interruptPrompt = null
197
- if (msg) {
198
- // Guard: if the turn already finished while the user was typing, the controller
199
- // may have been replaced or already aborted — don't abort a live turn by mistake.
200
- if (state.processing && state.controller && !state.controller.signal.aborted) {
201
- pushLine(` [inject] ${msg}`, C.warn)
202
- state.controller.abort({ interrupt: true, message: msg })
203
- } else {
204
- pushLine(` [inject — turn ended, message queued] ${msg}`, C.dim)
205
- }
206
- render()
207
- }
208
- } else if (key.name === "backspace") {
209
- state.interruptPrompt.text = state.interruptPrompt.text.slice(0, -1)
210
- render()
211
- } else if (str && !key.ctrl && !key.meta) {
212
- state.interruptPrompt.text += str.replace(/[\r\n]+/g, "")
213
- render()
214
- }
215
- return
216
- }
168
+ // Interrupt prompt mode: type message, Enter to inject, Esc to cancel——模态实现
169
+ // key-modes.mjs handleInterruptMode(D-S4)
170
+ if (handleInterruptMode(str, key, { state, pushLine, render })) return
217
171
 
218
172
  // generic list picker: ↑↓/PgUp/PgDn/Home/End 导航,输入即过滤,Enter 选中,Esc 取消
219
173
  if (state.picker) {
@@ -255,7 +209,6 @@ export function createKeyHandler(ctx) {
255
209
  } else if (str && !key.ctrl && !key.meta) {
256
210
  // 输入即过滤;粘贴的多行文本先去换行(与输入框清洗口径一致),仍含控制字符则整段丢弃
257
211
  const text = str.replace(/[\r\n]+/g, "")
258
- // eslint-disable-next-line no-control-regex -- 有意为之:控制字符协议/转义序列剥离正则(ANSI/⟦ev⟧/SGR/history 双线分隔)
259
212
  if (text && !/[\x00-\x1f\x7f]/.test(text)) applyFilter(p.filter + text)
260
213
  }
261
214
  return
@@ -411,19 +364,28 @@ export function createKeyHandler(ctx) {
411
364
  return
412
365
  }
413
366
  if (key.name === "return" || key.name === "enter" || str === "\r") {
414
- // Multiline newline — three entry points (docs/design/TUI-INPUT-BOX.md §1.5):
415
- // 1. Alt+Enter: readline parses \x1b\r as meta+return (all terminals)
416
- // 2. Shift+Enter: keyboard-enhanced terminals send \x1b[13;2u / \x1b[27;2;13~,
417
- // translateShiftEnter maps them to \x1b\r → also meta+return
418
- // 3. Ctrl+J: sends \n (0x0A), readline parses as name:"enter" — the universal
419
- // fallback: \n and \r are distinct bytes in EVERY terminal, no protocol needed
420
- // (legacy conhost users: Shift+Enter is a bare \r there, physically
421
- // indistinguishable from Enter — Ctrl+J is their newline key)
367
+ // Multiline newline (docs/design/TUI-INPUT-BOX.md §1.5): Alt+Enter / Shift+Enter
368
+ // (translateShiftEnter) / Ctrl+J the universal no-protocol fallback.
422
369
  if ((key.name === "return" && key.meta) || key.name === "enter") {
423
370
  state.input.splice(state.cursor, 0, "\n")
424
371
  state.cursor++
425
372
  render()
426
373
  } else {
374
+ const text = state.input.join("").trim()
375
+ // §17 D-S5/F3/F7 + 偏差 #1:挂起态(suspended 或释放窗口 _suspPending)Enter =
376
+ // 新回合输入(非打断)——入 pendingInput 由挂起会话调度;输入框零干扰(F3)。
377
+ if ((state.suspended || state._suspPending) && text && !text.startsWith("/")) {
378
+ state.input = []
379
+ state.cursor = 0
380
+ state.history.push(text)
381
+ state.historyIndex = -1
382
+ state._draft = null
383
+ state.pendingInput ??= []
384
+ state.pendingInput.push(text)
385
+ state._suspWake?.()
386
+ render()
387
+ return
388
+ }
427
389
  submit().catch((e) => pushLine(`[error] ${e.message}`, C.error))
428
390
  }
429
391
  return