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
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"
@@ -25,28 +27,20 @@ import { createRenderLoop } from "./render-loop.mjs"
25
27
  import { makeDimsState } from "./dims.mjs"
26
28
  import { SLASH_COMMANDS, SLASH_ALIASES, createSlashCommands } from "./slash-commands.mjs"
27
29
  import { createWizard } from "./wizard.mjs"
28
- import { writeStartupSequence, createExitCleanup } from "./tui-lifecycle.mjs"
30
+ import { writeStartupSequence, setTuiActive, createExitCleanup } from "./tui-lifecycle.mjs"
29
31
  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(冻结锚点), stopped, children: [](§27 R23 嵌套子代理子块载体——subagent-children.mjs) } } — 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(R23:子块计入外层同配额——树级 trim)
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")) {
@@ -147,44 +147,13 @@ export async function startTUI(agent, opts = {}) {
147
147
  // modifyOtherKeys lvl 2 (\x1b[>4;2m): Shift+Enter → \x1b[27;2;13~ (mintty / Git Bash)
148
148
  // translateShiftEnter (stdin layer) maps both to \x1b\r → meta+return → multiline branch.
149
149
  writeStartupSequence()
150
+ setTuiActive(true) // R25(F-R25a):终端接管完成——置 TUI 活动态(崩溃钩子恢复判定源)
150
151
 
151
152
  const utf8Decoder = new TextDecoder("utf-8", { fatal: false })
152
153
 
153
154
  let pasteMode = false
154
155
  let pasteAccum = ""
155
156
 
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
157
  process.stdin.on("data", (chunk) => {
189
158
  try {
190
159
 
@@ -237,7 +206,6 @@ export async function startTUI(agent, opts = {}) {
237
206
 
238
207
  // Scroll wheel: \x1b[<64;col;rowM = up, \x1b[<65;col;rowM = down(3 lines each)
239
208
  // 2026-08-31:坐标命中展开块内容行 → 块内滚动(handleWheel);未命中 → 会话滚动(现状)
240
- // 有意为之:控制字符协议/转义序列剥离正则(ANSI/⟦ev⟧/SGR/history 双线分隔)
241
209
  for (const m of text.matchAll(/\x1b\[<(\d+);(\d+);(\d+)([Mm])/g)) {
242
210
  const button = Number(m[1])
243
211
  if (button === 64 || button === 65) {
@@ -267,9 +235,7 @@ export async function startTUI(agent, opts = {}) {
267
235
  }
268
236
 
269
237
  // Strip complete mouse sequences; keep incomplete tail for reassembly with next chunk
270
- // 有意为之:控制字符协议/转义序列剥离正则(ANSI/⟦ev⟧/SGR/history 双线分隔)
271
238
  text = text.replace(/\x1b\[<\d+;\d+;\d+[Mm]/g, "")
272
- // 有意为之:控制字符协议/转义序列剥离正则(ANSI/⟦ev⟧/SGR/history 双线分隔)
273
239
  const tail = text.match(/\x1b\[<[\d;]*$/)
274
240
  if (tail) {
275
241
  mousePending = tail[0]
@@ -298,6 +264,10 @@ export async function startTUI(agent, opts = {}) {
298
264
  if (state.lines.length > 5000) {
299
265
  state.lines.splice(0, 1000)
300
266
  state.lines.unshift({ text: `... [earlier messages trimmed — ${state.lines.length} lines remaining]`, color: C.dim })
267
+ // 2026-09-03 修复轮(冻结锚点):头裁对在途 settled 锚点整体前移(锚点是绝对
268
+ // 流位置)——不校正则池空补发冻结(freezeSubTaskLines splice)落点漂移;校正量
269
+ // = 净位移(裁 1000 补 1 标记行 → −999,code review round1 #3)。
270
+ shiftFreezeAnchors(state, 1000)
301
271
  }
302
272
  render()
303
273
  }
@@ -326,6 +296,10 @@ export async function startTUI(agent, opts = {}) {
326
296
  pushLine)
327
297
  const { render, scheduleRender } = renderLoop
328
298
 
299
+ // 懒加载更早历史(startup.mjs createLoadOlder——D-S1b):滚轮/PgUp 到顶自动加载;
300
+ // 声明在此(render 已可用)——data 回调(滚轮分支)与 createKeyHandler ctx(PgUp)共用。
301
+ const loadOlder = createLoadOlder({ agent, state, render })
302
+
329
303
  // Resize events are genuine dimension changes on every terminal (2026-08-31
330
304
  // simplification: the earlier settle-timer/double-confirm machinery was built
331
305
  // on the misdiagnosed ConPTY-stale hypothesis and even stalled drag-shrink).
@@ -453,9 +427,8 @@ export async function startTUI(agent, opts = {}) {
453
427
  }
454
428
  })
455
429
 
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 })
430
+ // 鼠标点击/滚轮 ctx 装配(mouse.mjs createMouseDispatch——D-S1a:cancelSubagent/onMouseClick/mouseCtx)
431
+ const { onMouseClick, mouseCtx } = createMouseDispatch({ agent, state, pushLine, render, popPicker })
459
432
 
460
433
  // ---------------------------------------------------------- Startup screen + background indexing
461
434
 
@@ -466,49 +439,11 @@ export async function startTUI(agent, opts = {}) {
466
439
  showStartup({ agent, state, opts, pushLine, pushLabel, render, startWizard })
467
440
  backgroundIndex({ agent, state, render })
468
441
 
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
- })()
442
+ // Check for updates (non-blocking, after startup screen)——实现 update-notice.mjs
443
+ // (D-S1c):有 picker 打开时不硬抢——挂到 state.pendingNotice,picker 全部关闭后由
444
+ // doRender 弹出(showUpdateNotice renderLoop ctx getter 懒引用)。
445
+ const { showUpdateNotice, checkUpdates } = createUpdateNotice({ state, showPicker, pushLine, pushLabel, render })
446
+ checkUpdates()
512
447
  }
513
448
 
514
449
  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}${args.layer ? ` (layer ${args.layer})` : ""}`]
51
+ : [`batch delete layer=${args.layer ?? ""} type=${args.type ?? ""} keyword=${args.keyword ?? ""}`, `confirm=${args.confirm}`]
52
+ if (action === "clear") return [`clear personal memory`, `layer=${args.layer ?? ""} 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