thincoder 0.12.58 → 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 (114) hide show
  1. package/CHANGELOG.md +42 -1
  2. package/README.md +1 -1
  3. package/bin/thincoder.mjs +8 -0
  4. package/package.json +1 -1
  5. package/src/acp/bridge.mjs +132 -26
  6. package/src/advisor/messages.mjs +34 -1
  7. package/src/advisor/run.mjs +89 -51
  8. package/src/advisor.mjs +15 -7
  9. package/src/agent/dispatch.mjs +91 -14
  10. package/src/agent/helpers.mjs +35 -4
  11. package/src/agent/setup.mjs +90 -19
  12. package/src/agent/spawn-child.mjs +25 -0
  13. package/src/agent-tools/advisor.mjs +24 -2
  14. package/src/agent-tools/consult.mjs +37 -6
  15. package/src/agent-tools/eng.mjs +2 -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 +467 -193
  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 +161 -125
  29. package/src/auto-think.mjs +14 -0
  30. package/src/cli/make-agent.mjs +2 -1
  31. package/src/cli/permission.mjs +8 -1
  32. package/src/config.mjs +5 -0
  33. package/src/context.mjs +87 -27
  34. package/src/distill.mjs +19 -1
  35. package/src/escape.mjs +6 -4
  36. package/src/log.mjs +195 -0
  37. package/src/memory/code-sync.mjs +1 -1
  38. package/src/memory/core.mjs +126 -0
  39. package/src/memory/docs.mjs +196 -87
  40. package/src/memory.mjs +1 -1
  41. package/src/model-specs.mjs +15 -1
  42. package/src/prompts/advisor-design.md +46 -0
  43. package/src/prompts/advisor-round1.md +49 -2
  44. package/src/prompts/advisor-round2.md +47 -0
  45. package/src/prompts/advisor-round3.md +47 -0
  46. package/src/prompts/coder.md +22 -0
  47. package/src/prompts/consult-base.md +13 -0
  48. package/src/prompts/discipline.md +10 -5
  49. package/src/prompts/eng-coder.md +2 -2
  50. package/src/prompts/engineering-sub.md +23 -1
  51. package/src/prompts/engineering.md +106 -56
  52. package/src/prompts/explore.md +1 -2
  53. package/src/prompts/main.md +11 -6
  54. package/src/prompts/methodology-template.md +14 -0
  55. package/src/prompts/system.md +4 -2
  56. package/src/provider/core.mjs +56 -2
  57. package/src/tools/apply_patch.md +3 -1
  58. package/src/tools/bash.md +1 -1
  59. package/src/tools/delete.md +1 -0
  60. package/src/tools/edit-batch.mjs +31 -43
  61. package/src/tools/edit-diff.mjs +265 -0
  62. package/src/tools/edit.md +10 -8
  63. package/src/tools/execute.md +7 -7
  64. package/src/tools/execute.mjs +24 -20
  65. package/src/tools/file.mjs +18 -68
  66. package/src/tools/file_ops.md +2 -1
  67. package/src/tools/get_current_time.md +3 -1
  68. package/src/tools/hashline_edit.md +2 -0
  69. package/src/tools/index.mjs +3 -2
  70. package/src/tools/insert_after.md +2 -1
  71. package/src/tools/lint.md +2 -0
  72. package/src/tools/lsp.md +4 -1
  73. package/src/tools/patch.mjs +84 -13
  74. package/src/tools/pdf-parse-text.mjs +497 -0
  75. package/src/tools/pdf-parse-xref.mjs +499 -0
  76. package/src/tools/pdf.mjs +155 -0
  77. package/src/tools/question.md +2 -1
  78. package/src/tools/read.md +1 -0
  79. package/src/tools/read_pdf.md +21 -0
  80. package/src/tools/repomap.mjs +1 -1
  81. package/src/tools/shared.mjs +4 -12
  82. package/src/tools/system.mjs +6 -21
  83. package/src/tools/tree.md +2 -1
  84. package/src/tools/web.mjs +5 -3
  85. package/src/tools/websearch.md +2 -1
  86. package/src/tools/write.md +2 -0
  87. package/src/traces/trace-store.mjs +224 -0
  88. package/src/tui/agent-turn.mjs +385 -22
  89. package/src/tui/clipboard.mjs +15 -4
  90. package/src/tui/cmd-config.mjs +29 -9
  91. package/src/tui/cmd-extract.mjs +1 -1
  92. package/src/tui/cmd-mcp.mjs +9 -0
  93. package/src/tui/cmd-think.mjs +1 -1
  94. package/src/tui/index.mjs +29 -95
  95. package/src/tui/interaction.mjs +13 -2
  96. package/src/tui/key-handler.mjs +105 -155
  97. package/src/tui/key-modes.mjs +215 -0
  98. package/src/tui/layout.mjs +22 -1
  99. package/src/tui/mouse.mjs +40 -0
  100. package/src/tui/pickers.mjs +11 -3
  101. package/src/tui/render-conversation.mjs +13 -161
  102. package/src/tui/render-frame.mjs +27 -10
  103. package/src/tui/render-loop.mjs +4 -1
  104. package/src/tui/render-segments.mjs +165 -0
  105. package/src/tui/startup.mjs +36 -0
  106. package/src/tui/subagent-blocks.mjs +322 -144
  107. package/src/tui/subagent-panel.mjs +88 -13
  108. package/src/tui/tool-args.mjs +10 -2
  109. package/src/tui/tool-events.mjs +132 -100
  110. package/src/tui/update-notice.mjs +72 -0
  111. package/src/tui/wizard.mjs +36 -6
  112. package/src/agent-tools/escalate.mjs +0 -179
  113. package/src/agent-tools/subagent-check.mjs +0 -107
  114. package/src/tools/exec-prelude.mjs +0 -84
@@ -174,6 +174,15 @@ Return ONLY the JSON object:`,
174
174
  }],
175
175
  tools: [],
176
176
  signal: AbortSignal.timeout(15_000),
177
+ // §18.6 D-TR4/D-TR6(2026-09-04 fix round1):/mcp ai 生成调用经 chat() 唯一
178
+ // 采集点——补轨迹元数据 + traces 开关透传(agent.config.traces.enabled——
179
+ // 关=不落盘必须全覆盖——与 agent.mjs/context.mjs 同模式)
180
+ logCtx: {
181
+ stage: "mcp", kind: "mcp",
182
+ role: agent?._role ?? null,
183
+ session: agent?._sessionStart ?? null, cwd: agent?.cwd ?? process.cwd(),
184
+ traces: agent?.config?.traces?.enabled !== false,
185
+ },
177
186
  })
178
187
  const jsonMatch = (res.content ?? "").match(/\{[\s\S]*\}/)
179
188
  if (!jsonMatch) { pushLine("[mcp] AI response not valid JSON", C.error); return }
@@ -76,7 +76,7 @@ export async function handleThinkCommand(ctx, args = []) {
76
76
  if (e.action === "auto") {
77
77
  const newAuto = agent.config?.agent?.autoThink === true
78
78
  pushLine(`Auto-think: ${newAuto ? "ON" : "OFF"}`, C.tool)
79
- return // exit loop — no useful actions remain when auto mode just changed
79
+ return // exit loop — no useful actions remain when auto mode just changed (T8b 锁定——toggle 即退是已批准语义——2026-09-05 曾误改 OFF 回菜单——测试抓回归——已撤销)
80
80
  } else if (e.action === "effort") {
81
81
  pushLine(`Reasoning effort: ${e.level}`, C.tool)
82
82
  } else {
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,23 +32,15 @@ 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
- }
45
-
46
- /** 后台更新提示可弹出的条件:无任何交互弹层(picker/permission/question)激活。 */
47
- export function pendingNoticeReady(state) {
48
- return Boolean(state.pendingNotice && !state.picker && !state.permission && !state.question)
49
- }
43
+ export { upgradeFailureText, pendingNoticeReady } from "./update-notice.mjs"
50
44
 
51
45
  /**
52
46
  * SESSION.md §8 D-S2 — TUI 启动首帧前的 provider 重选流程:
@@ -112,7 +106,7 @@ export async function startTUI(agent, opts = {}) {
112
106
  reasoning: "", // thinking stream buffer (dimmed display)
113
107
  completion: null, // Tab completion state { candidates, index }
114
108
 
115
- 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
116
110
  currentTool: null, // currently executing tool name (shown in status bar)
117
111
  processingStarted: 0, // current turn start time (status bar timer)
118
112
  status: "Ready",
@@ -127,7 +121,13 @@ export async function startTUI(agent, opts = {}) {
127
121
  _historyLoaded: 0, // messages loaded from the TAIL of _fullHistory
128
122
  _historyTotal: 0, // total messages in the restored session
129
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)。
130
128
  }
129
+ state._agent = agent
130
+ agent._panelSnapshot = [] // 镜像初始态 = 空面板(非"无镜像"——[] 与 undefined 区分)
131
131
 
132
132
  // On session restore, if all tasks are completed, auto-collapse the todo panel (match runtime behavior)
133
133
  if (state.tasks.length > 0 && state.tasks.every((t) => t.status === "done")) {
@@ -153,38 +153,6 @@ export async function startTUI(agent, opts = {}) {
153
153
  let pasteMode = false
154
154
  let pasteAccum = ""
155
155
 
156
- /** 懒加载更早历史(2026-08-31 用户约定:"滚动到头自动加载"——滚轮/PgUp 到顶皆触发;
157
- * 2026-08-31 前只挂 PgUp 键 = 违约,滚轮到头无反应)。加载后滚动补偿保持锚定。
158
- * 外层作用域:data 回调(滚轮分支)与 createKeyHandler ctx(PgUp 分支)共用。 */
159
- const loadOlder = () => {
160
- if (!state._hasOlder) return
161
- const full = agent._fullHistory ?? []
162
- const loaded = state._historyLoaded
163
- const start = Math.max(0, full.length - loaded - HISTORY_PAGE_MESSAGES)
164
- const end = full.length - loaded
165
- if (start >= end) return
166
-
167
- const d = state.dims ? state.dims.get() : {}
168
- const cols = d.cols ?? ((state.dims?.get() ?? {}).cols ?? (process.stdout.columns || 80))
169
- const before = countConvLines(state, cols, d.rows ?? (process.stdout.rows || 24))
170
-
171
- if (state.lines[0]?.text?.startsWith("… ")) state.lines.shift()
172
- state._lineIdCounter = state._lineIdCounter ?? 0
173
- const older = historyToLines(full, start, end)
174
- for (const l of older) l._lineId = ++state._lineIdCounter
175
- state.lines.unshift(...older)
176
- state._historyLoaded += end - start
177
- state._hasOlder = start > 0
178
- if (state._hasOlder) {
179
- state.lines.unshift({ text: `… ${start} more earlier messages (scroll to top to load)`, color: C.dim })
180
- }
181
-
182
- const after = countConvLines(state, cols, (state.dims?.get() ?? {}).rows ?? (process.stdout.rows || 24))
183
- state.scroll += Math.max(0, after - before)
184
- render()
185
- }
186
-
187
-
188
156
  process.stdin.on("data", (chunk) => {
189
157
  try {
190
158
 
@@ -237,7 +205,6 @@ export async function startTUI(agent, opts = {}) {
237
205
 
238
206
  // Scroll wheel: \x1b[<64;col;rowM = up, \x1b[<65;col;rowM = down(3 lines each)
239
207
  // 2026-08-31:坐标命中展开块内容行 → 块内滚动(handleWheel);未命中 → 会话滚动(现状)
240
- // 有意为之:控制字符协议/转义序列剥离正则(ANSI/⟦ev⟧/SGR/history 双线分隔)
241
208
  for (const m of text.matchAll(/\x1b\[<(\d+);(\d+);(\d+)([Mm])/g)) {
242
209
  const button = Number(m[1])
243
210
  if (button === 64 || button === 65) {
@@ -267,9 +234,7 @@ export async function startTUI(agent, opts = {}) {
267
234
  }
268
235
 
269
236
  // Strip complete mouse sequences; keep incomplete tail for reassembly with next chunk
270
- // 有意为之:控制字符协议/转义序列剥离正则(ANSI/⟦ev⟧/SGR/history 双线分隔)
271
237
  text = text.replace(/\x1b\[<\d+;\d+;\d+[Mm]/g, "")
272
- // 有意为之:控制字符协议/转义序列剥离正则(ANSI/⟦ev⟧/SGR/history 双线分隔)
273
238
  const tail = text.match(/\x1b\[<[\d;]*$/)
274
239
  if (tail) {
275
240
  mousePending = tail[0]
@@ -298,6 +263,10 @@ export async function startTUI(agent, opts = {}) {
298
263
  if (state.lines.length > 5000) {
299
264
  state.lines.splice(0, 1000)
300
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)
301
270
  }
302
271
  render()
303
272
  }
@@ -326,6 +295,10 @@ export async function startTUI(agent, opts = {}) {
326
295
  pushLine)
327
296
  const { render, scheduleRender } = renderLoop
328
297
 
298
+ // 懒加载更早历史(startup.mjs createLoadOlder——D-S1b):滚轮/PgUp 到顶自动加载;
299
+ // 声明在此(render 已可用)——data 回调(滚轮分支)与 createKeyHandler ctx(PgUp)共用。
300
+ const loadOlder = createLoadOlder({ agent, state, render })
301
+
329
302
  // Resize events are genuine dimension changes on every terminal (2026-08-31
330
303
  // simplification: the earlier settle-timer/double-confirm machinery was built
331
304
  // on the misdiagnosed ConPTY-stale hypothesis and even stalled drag-shrink).
@@ -453,9 +426,8 @@ export async function startTUI(agent, opts = {}) {
453
426
  }
454
427
  })
455
428
 
456
- // Mouse clicks (SGR \x1b[<0;col;rowM) — picker selection + fold expansion.
457
- const onMouseClick = (col, row) => handleMouseClick({ state, render, popPicker }, col, row)
458
- const mouseCtx = () => ({ state, render })
429
+ // 鼠标点击/滚轮 ctx 装配(mouse.mjs createMouseDispatch——D-S1a:cancelSubagent/onMouseClick/mouseCtx)
430
+ const { onMouseClick, mouseCtx } = createMouseDispatch({ agent, state, pushLine, render, popPicker })
459
431
 
460
432
  // ---------------------------------------------------------- Startup screen + background indexing
461
433
 
@@ -466,49 +438,11 @@ export async function startTUI(agent, opts = {}) {
466
438
  showStartup({ agent, state, opts, pushLine, pushLabel, render, startWizard })
467
439
  backgroundIndex({ agent, state, render })
468
440
 
469
- // Check for updates (non-blocking, after startup screen)
470
- // picker 打开时不硬抢:挂到 state.pendingNotice,picker 全部关闭后由 doRender 弹出
471
- const showUpdateNotice = async (result) => {
472
- const sel = await showPicker(`Update available: ${result.local} ${result.latest}`, [
473
- { type: "header", text: `thincoder ${result.latest} is available (current: ${result.local})` },
474
- { type: "item", text: "Upgrade now", action: "upgrade" },
475
- { type: "item", text: "Later", action: "later" },
476
- ])
477
- if (sel?.action !== "upgrade") return
478
- pushLabel(`❯ Upgrade`, ansi.bold + C.tool)
479
- pushLine(`Upgrading to ${result.latest}...`, C.tool)
480
- const { exec } = await import("node:child_process")
481
- const cp = exec("npm install -g thincoder@latest", { windowsHide: true })
482
- let stdout = ""
483
- cp.stdout?.on("data", (d) => { stdout += d })
484
- cp.stderr?.on("data", (d) => { stdout += d })
485
- cp.on("close", (code) => {
486
- if (code === 0) {
487
- pushLine(`✓ Upgraded to ${result.latest}. Restart to apply.`, C.tool)
488
- } else {
489
- pushLine(upgradeFailureText(code, stdout), C.error)
490
- }
491
- render()
492
- })
493
- }
494
- ;(async () => {
495
- try {
496
- const { readFileSync } = await import("node:fs")
497
- const pkg = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8"))
498
- const { checkForUpdate } = await import("../upgrade.mjs")
499
- const result = await checkForUpdate(pkg.version)
500
- if (result?.newer) {
501
- // Defer: if wizard is still active, just show a dim line
502
- if (state.wizard) {
503
- pushLine(`Tip: thincoder ${result.latest} is available (run /upgrade later or restart)`, C.dim)
504
- render()
505
- } else {
506
- state.pendingNotice = result
507
- render()
508
- }
509
- }
510
- } catch { /* network error or timeout — silently skip */ }
511
- })()
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()
512
446
  }
513
447
 
514
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
 
@@ -98,10 +107,12 @@ export function createInteraction(ctx) {
98
107
  }
99
108
  if (!options.length) {
100
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)。
101
112
  pushLabel(`❯ Question`, ansi.bold + C.tool)
102
113
  for (const line of text.split("\n")) pushLine(` ${line}`, C.text)
103
114
  return new Promise((resolve) => {
104
- state.question = { text, options: [], resolve }
115
+ state.question = { text, options: [], answer: [], cursor: 0, resolve }
105
116
  state.status = "Waiting for answer..."
106
117
  render()
107
118
  })
@@ -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,131 +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
- // batch state (§16 D-B1): a approve all / o one by one / n deny (Esc = deny)
30
- if (state.permission) {
31
- const answer = (str || "").toLowerCase()
32
- const isContinue = state.permission.name === "continue"
33
- const isBatch = Boolean(state.permission.batch)
34
- const validKeys = isContinue ? ["y", "n"] : isBatch ? ["a", "o", "n"] : ["y", "n", "a"]
35
- if (validKeys.includes(answer) || key.name === "escape") {
36
- const { resolve, name } = state.permission
37
- state.permission = null
38
- state.permissionPreview = []
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
- }
50
- if (answer === "a" && !isContinue) {
51
- agent.autoApprove = true
52
- agent._pendingReminders = agent._pendingReminders ?? []
53
- agent._pendingReminders.push("[System reminder: AUTO mode is now ON. All tool calls are automatically approved. Use /auto to disable.]")
54
- pushLine(` [auto] AUTO ON: tool calls no longer prompt for approval (/auto to disable)`, C.warn)
55
- }
56
- const approved = answer === "y" || (answer === "a" && !isContinue)
57
- // leave trail: record approval/denial in conversation (continue prompt has its own output, don't duplicate)
58
- if (!isContinue) {
59
- pushLine(` [${approved ? "approved" : "denied"}] ${name}`, approved ? C.dim : C.error)
60
- }
61
- resolve(approved)
62
- render()
63
- }
64
- return
65
- }
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
66
30
 
67
- // question tool callback: free text / option selection
68
- if (state.question) {
69
- const q = state.question
70
- if (q.options.length > 0) {
71
- // options mode: ↑↓ select, Enter confirm, Esc cancel
72
- if (key.name === "escape") {
73
- q.resolve("")
74
- state.question = null
75
- state.status = "Processing..."
76
- render()
77
- } else if (key.name === "up") {
78
- q.selected = Math.max(0, (q.selected ?? 0) - 1)
79
- render()
80
- } else if (key.name === "down") {
81
- q.selected = Math.min(q.options.length - 1, (q.selected ?? 0) + 1)
82
- render()
83
- } else if (key.name === "return") {
84
- const answer = q.options[q.selected ?? 0]
85
- if (answer === QUESTION_CUSTOM) {
86
- // Switch to free-text mode — the user wants to type their own answer.
87
- q.options = []
88
- q.answer = ""
89
- q.selected = undefined
90
- state.status = "Waiting for answer..."
91
- render()
92
- return
93
- }
94
- q.resolve(answer)
95
- state.question = null
96
- state.status = "Processing..."
97
- pushLine(` → ${answer}`, C.tool)
98
- render()
99
- }
100
- } else {
101
- // free text: type answer, Enter submit, Esc cancel
102
- if (key.name === "escape") {
103
- q.resolve("")
104
- state.question = null
105
- state.status = "Processing..."
106
- render()
107
- } else if (key.name === "return") {
108
- if (q._pasting) return // block Enter while paste is in flight
109
- const answer = (q.answer ?? "").trim()
110
- q.resolve(answer || "")
111
- state.question = null
112
- state.status = "Processing..."
113
- pushLine(` → ${answer || "(empty)"}`, C.tool)
114
- render()
115
- } else if (key.name === "backspace") {
116
- q.answer = (q.answer ?? "").slice(0, -1)
117
- render()
118
- } else if (key.ctrl && !key.alt && key.name === "v") {
119
- // Ctrl+V paste: read clipboard text (fires when the terminal passes Ctrl+V through
120
- // as a key event; bracketed-paste terminals are handled upstream in the stdin handler)
121
- if (q._pasting) return
122
- q._pasting = true
123
- readClipboardText().then((text) => {
124
- q._pasting = false
125
- if (text) {
126
- insertPastedText(state, text)
127
- render()
128
- }
129
- }).catch((e) => {
130
- q._pasting = false
131
- console.error(`[tui] clipboard paste failed: ${e.message}`)
132
- })
133
- } else if (str && !key.ctrl && !key.meta) {
134
- q.answer = (q.answer ?? "") + str
135
- render()
136
- }
137
- }
138
- return
139
- }
31
+ // question tool callback: free text / option selection——模态实现
32
+ // key-modes.mjs handleQuestionMode(D-S4)
33
+ if (handleQuestionMode(str, key, { state, pushLine, render })) return
140
34
 
141
35
  // Search mode: Ctrl+F to enter, Ctrl+N/Ctrl+P (or Ctrl+G/Ctrl+R) navigate, Esc exit
142
36
  if (handleSearchKey(str, key, state, render)) return
143
37
 
144
- if (key.ctrl && key.name === "c") {
38
+ if (key.ctrl && key.name === "c") {
145
39
  // picker 打开时 Ctrl+C = 取消当前 picker(等同 Esc),不杀进程
146
40
  if (state.picker) {
147
41
  popPicker(null)
148
42
  return
149
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
+ }
150
107
  if (state.processing && state.controller) {
151
- state.controller.abort()
152
- 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?.()
153
120
  render()
154
121
  return
155
122
  }
@@ -174,7 +141,7 @@ export function createKeyHandler(ctx) {
174
141
  // F1: 显示快捷键帮助
175
142
  if (key.name === "f1" && !state.picker && !state.permission && !state.question) {
176
143
  showPicker("Keyboard Shortcuts", [
177
- { 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" },
178
145
  { type: "item", text: "Ctrl+I — Interrupt and inject message" },
179
146
  { type: "item", text: "Ctrl+F — Search conversation history" },
180
147
  { type: "item", text: "Shift+Enter / Ctrl+J — Insert newline (multiline input)" },
@@ -198,34 +165,9 @@ export function createKeyHandler(ctx) {
198
165
  return
199
166
  }
200
167
 
201
- // Interrupt prompt mode: type message, Enter to inject, Esc to cancel
202
- if (state.interruptPrompt) {
203
- if (key.name === "escape") {
204
- state.interruptPrompt = null
205
- render()
206
- } else if (key.name === "return") {
207
- const msg = (state.interruptPrompt.text ?? "").trim()
208
- state.interruptPrompt = null
209
- if (msg) {
210
- // Guard: if the turn already finished while the user was typing, the controller
211
- // may have been replaced or already aborted — don't abort a live turn by mistake.
212
- if (state.processing && state.controller && !state.controller.signal.aborted) {
213
- pushLine(` [inject] ${msg}`, C.warn)
214
- state.controller.abort({ interrupt: true, message: msg })
215
- } else {
216
- pushLine(` [inject — turn ended, message queued] ${msg}`, C.dim)
217
- }
218
- render()
219
- }
220
- } else if (key.name === "backspace") {
221
- state.interruptPrompt.text = state.interruptPrompt.text.slice(0, -1)
222
- render()
223
- } else if (str && !key.ctrl && !key.meta) {
224
- state.interruptPrompt.text += str.replace(/[\r\n]+/g, "")
225
- render()
226
- }
227
- return
228
- }
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
229
171
 
230
172
  // generic list picker: ↑↓/PgUp/PgDn/Home/End 导航,输入即过滤,Enter 选中,Esc 取消
231
173
  if (state.picker) {
@@ -267,7 +209,6 @@ export function createKeyHandler(ctx) {
267
209
  } else if (str && !key.ctrl && !key.meta) {
268
210
  // 输入即过滤;粘贴的多行文本先去换行(与输入框清洗口径一致),仍含控制字符则整段丢弃
269
211
  const text = str.replace(/[\r\n]+/g, "")
270
- // 有意为之:控制字符协议/转义序列剥离正则(ANSI/⟦ev⟧/SGR/history 双线分隔)
271
212
  if (text && !/[\x00-\x1f\x7f]/.test(text)) applyFilter(p.filter + text)
272
213
  }
273
214
  return
@@ -423,19 +364,28 @@ export function createKeyHandler(ctx) {
423
364
  return
424
365
  }
425
366
  if (key.name === "return" || key.name === "enter" || str === "\r") {
426
- // Multiline newline — three entry points (docs/design/TUI-INPUT-BOX.md §1.5):
427
- // 1. Alt+Enter: readline parses \x1b\r as meta+return (all terminals)
428
- // 2. Shift+Enter: keyboard-enhanced terminals send \x1b[13;2u / \x1b[27;2;13~,
429
- // translateShiftEnter maps them to \x1b\r → also meta+return
430
- // 3. Ctrl+J: sends \n (0x0A), readline parses as name:"enter" — the universal
431
- // fallback: \n and \r are distinct bytes in EVERY terminal, no protocol needed
432
- // (legacy conhost users: Shift+Enter is a bare \r there, physically
433
- // 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.
434
369
  if ((key.name === "return" && key.meta) || key.name === "enter") {
435
370
  state.input.splice(state.cursor, 0, "\n")
436
371
  state.cursor++
437
372
  render()
438
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
+ }
439
389
  submit().catch((e) => pushLine(`[error] ${e.message}`, C.error))
440
390
  }
441
391
  return