thincoder 0.12.64 → 0.12.65

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.
@@ -1,10 +1,12 @@
1
- import { ansi, C } from "./ansi.mjs"
2
- import { readClipboardText, insertPastedText } from "./clipboard.mjs"
3
- import { computeLayout, inputContentWidth } from "./layout.mjs"
4
- import { moveCursorVertical } from "./render.mjs"
1
+ import { computeLayout } from "./layout.mjs"
5
2
  import { handleSearchKey } from "./key-handler-search.mjs"
6
3
  import { countConvLines } from "./render-conversation.mjs"
7
4
  import { handlePermissionMode, handleQuestionMode, handleInterruptMode } from "./key-modes.mjs"
5
+ import { handleCtrlCFamily } from "./key-handler-ctrlc.mjs"
6
+ import { handlePickerKeys, handleWizardKeys } from "./key-handler-modals.mjs"
7
+ import { handleScrollKeys, handleHistoryKeys, handleCursorKeys } from "./key-handler-scroll.mjs"
8
+ import { handleBusyEnter } from "./key-handler-busy.mjs"
9
+ import { handleEditKeys } from "./key-handler-edit.mjs"
8
10
 
9
11
  /** 第 33 批(TUI §14.3(e)——attention 清位):输入即在场——仅当原值为真时置假 + `render()`
10
12
  * (已假则零副作用、零重绘)。键盘入口与本文件(`onKeypress`)与鼠标输入路径(index.mjs
@@ -28,12 +30,15 @@ export function convMaxScroll(state) {
28
30
 
29
31
  /** Keyboard event dispatch: permission confirm / question / picker / wizard / edit / scroll / history / paste.
30
32
  * Extracted from index.mjs.
33
+ * 2026-09-22 structure-debt §2.1(#226):五族块体迁出为 key-handler-{ctrlc,modals,scroll,busy,edit}.mjs
34
+ * (逐字搬移);本档保留 attention 清位 / 三模态前置委派 / F1 表 / Ctrl+I 入口 / interrupt 模态 +
35
+ * 五族顺序委派(委派守卫 = 原块入口条件;族体自持同一入口判定,可直接调用)。
31
36
  * ctx: { agent, state, render, renderPickerLines, popPicker,
32
37
  * handleSlash, handleTab, submit, pasteClipboardImage,
33
38
  * wizardChooseProvider, wizardSubmitText, cancelWizard, wizardProviderItems,
34
39
  * renderWizard, pushLine, cleanup, showPicker } */
35
40
  export function createKeyHandler(ctx) {
36
- const { agent, state, render, popPicker, renderPickerLines, handleSlash, handleTab, submit, pasteClipboardImage, wizardChooseProvider, wizardSubmitText, cancelWizard, wizardProviderItems, renderWizard, pushLine, cleanup, showPicker, loadOlder } = ctx
41
+ const { agent, state, render, pushLine, showPicker } = ctx
37
42
 
38
43
  return function onKeypress(str, key = {}) {
39
44
  // 第 33 批(TUI §14.3(e) 键盘点):模态分派**之前**——任意按键 = 用户在场 ⇒ 清 attention
@@ -50,109 +55,9 @@ export function createKeyHandler(ctx) {
50
55
  // Search mode: Ctrl+F to enter, Ctrl+N/Ctrl+P (or Ctrl+G/Ctrl+R) navigate, Esc exit
51
56
  if (handleSearchKey(str, key, state, render)) return
52
57
 
53
- if (key.ctrl && key.name === "c") {
54
- // picker 打开时 Ctrl+C = 取消当前 picker(等同 Esc),不杀进程
55
- if (state.picker) {
56
- popPicker(null)
57
- return
58
- }
59
- // §17.6(2026-09-03):武装窗口内第二次 Ctrl+C = 显式全停(D-C4 /abort 语义)。
60
- // 在状态路由前检查(picker 取消分支例外——picker 打开时 Ctrl+C 语义 = 取消
61
- // picker,武装让位;场景极窄——3s 窗口内需另有 picker 被打开)——两次按下之间
62
- // 状态会迁移(首按停回合 → 释放窗口/挂起会话启动),武装必须跨态桥接:只查挂起
63
- // 分支会让非挂起 processing 首按后的二按落入空闲退出分支(三按误退出应用)。
64
- if (state.suspAbortArmed) {
65
- if (ctx.suspArmTimer) clearTimeout(ctx.suspArmTimer)
66
- state.suspAbortArmed = false
67
- // 无可停目标(无 processing 回合 / 无会话 abort 集合 / 未挂起)时不吞键:
68
- // 首按停的是普通回合(无后台池)——二按落回下方空闲退出分支(exitArmed 双
69
- // 确认),不打印误导性的 "[Aborting background subagents…]"(advisor round1
70
- // 🟡——停回合后连按退出的第二次按下被空转吞掉 → 退出从 3 按变 4 按)。
71
- const hasStopTarget = (state.processing && state.controller) ||
72
- (agent._sessionAbortAll?.length ?? 0) > 0 ||
73
- (agent._sessionAbort != null && !agent._sessionAbort.signal.aborted) ||
74
- state.suspended
75
- if (hasStopTarget) {
76
- // 当前回合平 abort(无 interrupt——命中 agent.mjs 回合收尾清池分支——全停)
77
- // §20.3 站点 #11(第 24 批):整批 / 会话级停 = stop(reason 载荷)
78
- if (state.processing && state.controller) state.controller.abort({ abortTrigger: "stop", abortDetail: "session-stop" })
79
- // 会话 abort 集合 = 链条内全部 controller(含 Ctrl+I/ContinueError 重建的旧
80
- // controller——children 不逃逸,round1 偏差 #3)。挂起态才标记 _suspAborted +
81
- // 唤醒 driver(driver 收尾清池)——非挂起语境置位会粘滞阻塞未来挂起会话重入
82
- // (round2 偏差 #1 语义)。
83
- for (const c of agent._sessionAbortAll ?? (agent._sessionAbort ? [agent._sessionAbort] : [])) c?.abort({ abortTrigger: "stop", abortDetail: "session-stop" })
84
- if (state.suspended) {
85
- state._suspAborted = true
86
- state._suspWake?.()
87
- }
88
- pushLine("[Aborting background subagents…]", C.warn)
89
- render()
90
- return
91
- }
92
- // 无目标可停 → 落空继续到下方分支(processing 已停 → 空闲态 exitArmed 双确认)
93
- }
94
- if (state.suspended) {
95
- // §17 D-S9 + round2 偏差 #4(2026-09-02)+ §17.6 修订(2026-09-03):挂起态
96
- // Ctrl+C = 武装窗口两级中止——一次按键直接清池中止全部后台子代理的误触代价高
97
- // (digest 刷屏时用户可能只想停住当前回合);仿空闲态退出武装语义(同下方空闲态
98
- // exitArmed 双确认分支)。§17.6 修订 ①:中止当前回合的 abort 带 interrupt
99
- // (无 message)——此前平 abort 命中 agent.mjs 回合收尾清池分支(aborted &&
100
- // !reason.interrupt → 无条件 _asyncSubagents.clear()),一次首按即误杀全部后台
101
- // 子代理(2026-09-03 用户两次实测——提示"再按才杀"与实际"一次就全杀"不符);
102
- // interrupt 排除清池分支——会话与后台保留(agent-turn 区分:无 message 停回合
103
- // 不续跑):
104
- // ① 未武装 + 有回合在跑(digest 消化/会话内回合):仅中止当前回合
105
- // (controller.abort({ interrupt: true }))——会话继续、后台子代理不受
106
- // 影响,回挂起等待;
107
- // ② 未武装 + 纯挂起等待:仅提示不清池;
108
- // ③ 3s 武装窗口内再次 Ctrl+C:彻底中止——上方统一块(二按语义不变)。
109
- if (state.processing && state.controller) {
110
- state.controller.abort({ interrupt: true }) // ① 仅中止当前回合(digest/会话内回合),不碰后台池
111
- pushLine("[stopped current turn — press Ctrl+C again within 3s to abort all background subagents]", C.warn)
112
- } else {
113
- const bgActive = [...(agent._asyncSubagents?.values() ?? [])].filter((e) => e.status === "running").length + (agent._asyncQueue?.length ?? 0)
114
- pushLine(`[abort] Press Ctrl+C again within 3s to abort all background subagents (${bgActive} running)`, C.warn)
115
- }
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?.()
120
- render()
121
- return
122
- }
123
- if (state.processing && state.controller) {
124
- // §17.6 D-C1(2026-09-03 紧急修复——processing 态曾无武装窗口,第一按直接平
125
- // abort() → 命中 agent.mjs 回合收尾清池分支 → 一次 Ctrl+C 误杀全部后台子代理,
126
- // 用户两次实测被坑):首按 = interrupt 语义(无 message——停当前回合不续跑——
127
- // interrupt 排除 agent.mjs 清池分支——后台池保留——agent-turn 区分不重建续跑)
128
- // + 武装 3s(复用 suspArmTimer/exitArmDelay——与挂起态同构——过期自动复位);
129
- // 窗口内再按 = 上方统一块全停(平 abort → agent.mjs 清池)。
130
- state.controller.abort({ interrupt: true })
131
- pushLine("[stopped current turn — press Ctrl+C again within 3s to abort all background subagents]", C.warn)
132
- state.suspAbortArmed = true
133
- if (ctx.suspArmTimer) clearTimeout(ctx.suspArmTimer)
134
- ctx.suspArmTimer = setTimeout(() => { state.suspAbortArmed = false }, ctx.exitArmDelay ?? 3000)
135
- ctx.suspArmTimer.unref?.()
136
- render()
137
- return
138
- }
139
- // 防误触:空闲态第一次 Ctrl+C 仅提示并武装,窗口内再按才真正退出
140
- if (!state.exitArmed) {
141
- state.exitArmed = true
142
- if (ctx.exitArmTimer) clearTimeout(ctx.exitArmTimer)
143
- ctx.exitArmTimer = setTimeout(() => { state.exitArmed = false }, ctx.exitArmDelay ?? 3000)
144
- ctx.exitArmTimer.unref?.()
145
- pushLine("[exit] Press Ctrl+C again within 3s to exit", C.warn)
146
- render()
147
- return
148
- }
149
- if (ctx.exitArmTimer) clearTimeout(ctx.exitArmTimer)
150
- cleanup()
151
- // 延迟退出可注入(测试传大值并清理定时器,避免定时器在 mock 恢复后调到真 process.exit)
152
- ctx.exitTimer = setTimeout(() => process.exit(0), ctx.exitDelay ?? 100)
153
- ctx.exitTimer.unref?.()
154
- return // review #5 fix: exiting — don't fall through to later branches
155
- }
58
+ // 序 1:Ctrl+C 族(key-handler-ctrlc.mjs)——picker 取消 / 武装窗口全停 / 挂起态两级中止 /
59
+ // 回合 interrupt / 空闲退出武装(守卫 = 原块入口条件)
60
+ if (key.ctrl && key.name === "c") { handleCtrlCFamily(str, key, ctx); return }
156
61
 
157
62
  // F1: 显示快捷键帮助
158
63
  if (key.name === "f1" && !state.picker && !state.permission && !state.question) {
@@ -186,289 +91,34 @@ export function createKeyHandler(ctx) {
186
91
  // key-modes.mjs handleInterruptMode(D-S4)
187
92
  if (handleInterruptMode(str, key, { state, pushLine, render })) return
188
93
 
189
- // generic list picker: ↑↓/PgUp/PgDn/Home/End 导航,输入即过滤,Enter 选中,Esc 取消
190
- if (state.picker) {
191
- const p = state.picker
192
- const items = p.filteredItems ?? p.entries.filter((e) => e.type === "item")
193
- // 可视窗高度:直接取 layout 算出的实际 picker 面板高(含小终端 pickerFinalH 压缩),减标题行。
194
- // 单一数据源,避免与 layout.mjs 公式漂移
195
- const winH = Math.max(1, (computeLayout(state, { cols: (state.dims?.get() ?? {}).cols ?? (process.stdout.columns || 80), rows: (state.dims?.get() ?? {}).rows ?? ((state.dims?.get() ?? {}).rows ?? (process.stdout.rows || 24)) }).panels.picker?.h ?? p.lines.length + 1) - 1)
196
- const applyFilter = (f) => {
197
- p.filter = f
198
- p.index = 0
199
- p.scroll = 0
200
- renderPickerLines()
201
- }
202
- if (key.name === "escape") {
203
- popPicker(null)
204
- } else if (key.name === "up" && items.length) {
205
- p.index = (p.index - 1 + items.length) % items.length
206
- renderPickerLines()
207
- } else if (key.name === "down" && items.length) {
208
- p.index = (p.index + 1) % items.length
209
- renderPickerLines()
210
- } else if (key.name === "pageup" && items.length) {
211
- p.index = Math.max(0, p.index - winH)
212
- renderPickerLines()
213
- } else if (key.name === "pagedown" && items.length) {
214
- p.index = Math.min(items.length - 1, p.index + winH)
215
- renderPickerLines()
216
- } else if (key.name === "home" && items.length) {
217
- p.index = 0
218
- renderPickerLines()
219
- } else if (key.name === "end" && items.length) {
220
- p.index = items.length - 1
221
- renderPickerLines()
222
- } else if (key.name === "backspace") {
223
- if (p.filter) applyFilter(p.filter.slice(0, -1))
224
- } else if ((key.name === "return" || key.name === "enter" || str === "\r") && items.length) {
225
- popPicker(items[p.index]) // 选中即关闭
226
- } else if (str && !key.ctrl && !key.meta) {
227
- // 输入即过滤;粘贴的多行文本先去换行(与输入框清洗口径一致),仍含控制字符则整段丢弃
228
- const text = str.replace(/[\r\n]+/g, "")
229
- if (text && !/[\x00-\x1f\x7f]/.test(text)) applyFilter(p.filter + text)
230
- }
231
- return
232
- }
233
-
234
- // initial config wizard: menu step ↑↓/Enter/Esc; text step Enter submit, Esc cancel, edit keys fall through to normal input
94
+ // 序 2:模态族(key-handler-modals.mjs)——picker 导航 / 初始配置 wizard
95
+ if (state.picker) { handlePickerKeys(str, key, ctx); return }
235
96
  if (state.wizard) {
97
+ // 守卫 = wizard 块内返回分支(provider 步恒返回;文本步仅 escape / return / ↑↓ / PgUp·PgDn 返回,
98
+ // 其余编辑键落空至编辑族 —— 守卫项与 key-handler-modals.mjs 块内返回点逐条对应)
236
99
  const w = state.wizard
237
- if (key.name === "escape") {
238
- cancelWizard()
239
- return
240
- }
241
- if (w.step === "provider") {
242
- const items = wizardProviderItems()
243
- if (key.name === "up" && items.length) {
244
- w.index = (w.index - 1 + items.length) % items.length
245
- renderWizard()
246
- } else if (key.name === "down" && items.length) {
247
- w.index = (w.index + 1) % items.length
248
- renderWizard()
249
- } else if ((key.name === "return" || key.name === "enter" || str === "\r") && items.length) {
250
- wizardChooseProvider(items[w.index])
251
- }
252
- return
253
- }
254
- if (key.name === "return") {
255
- wizardSubmitText()
100
+ if (w.step === "provider" || key.name === "escape" || key.name === "return" ||
101
+ key.name === "up" || key.name === "down" || key.name === "pageup" || key.name === "pagedown") {
102
+ handleWizardKeys(str, key, ctx)
256
103
  return
257
104
  }
258
- // text steps: block scroll/history, remaining edit keys fall through to normal input logic below
259
- if (key.name === "up" || key.name === "down" || key.name === "pageup" || key.name === "pagedown") return
260
- }
261
-
262
- // page scroll
263
- if (key.name === "pageup") {
264
- // At the top of the conversation → load the next earlier history page
265
- // (lazy restore, parity with VS Code's scroll-back loadOlder).
266
- if (state._hasOlder && loadOlder && state.scroll >= convMaxScroll(state)) {
267
- loadOlder()
268
- } else {
269
- state.scroll += Math.max(1, ((state.dims?.get() ?? {}).rows ?? (process.stdout.rows || 24)) - 8)
270
- }
271
- state._followTail = false // 2026-08-31:用户上滚 = 暂停流式跟随
272
- render()
273
- return
274
- }
275
- if (key.name === "pagedown") {
276
- state.scroll = Math.max(0, state.scroll - Math.max(1, ((state.dims?.get() ?? {}).rows ?? (process.stdout.rows || 24)) - 8))
277
- if (state.scroll === 0) state._followTail = true // 滚回底部恢复跟随
278
- render()
279
- return
280
- }
281
-
282
- if (state.processing) {
283
- // busy 门禁(INPUT-LOCK C'——2026-09-09 + INPUT-LOCK-BEHAVIOR-REVISED——2026-09-09):
284
- // processing(含 digest——单一判据)输入不禁——吞提交不吞字符(打字照常回显);Enter
285
- // 提交吞 + busy 提示——斜杠同禁发(白名单已删——/exit 也发不出——退出靠 Ctrl+C 终端
286
- // 层通道——门禁前不误伤);空 Enter 静默(text 非空才吞);多行换行
287
- // (meta/enter——编辑)照常放行。**第 31 批**:↑↓ 不再在此全屏蔽——分流下沉到下方
288
- // 三规则块(竖移放行 / 历史导航禁;TUI-INPUT-BOX.md §3);Tab 仍吞(死条件在案——原样保留)。
289
- if (key.name === "tab") return
290
- const isSend = (key.name === "return" && !key.meta) || (str === "\r" && !key.meta)
291
- if (isSend && state.input.join("").trim()) {
292
- pushLine(`[主会话处理中 —— 消息未发送(回合结束后请重按 Enter)]`, C.warn)
293
- render()
294
- return
295
- }
296
- }
297
-
298
- // Tab: slash-command completion (cycle candidates); other input ignored (\t would blow up input box, never inserted directly)
299
- if (key.name === "tab") {
300
- handleTab()
301
- return
302
- }
303
-
304
- // Ctrl+U: clear entire input box
305
- if ((key.name === "u" && key.ctrl) || str === "\x15") {
306
- if (state.historyIndex !== -1) state._draft = [...state.input]
307
- state.input = []
308
- state.cursor = 0
309
- render()
310
- return
311
- }
312
-
313
- // ↑/↓ 三规则(TUI-INPUT-BOX.md §3——第 31 批):① 翻历史中恒历史(多行条目不竖移)
314
- // ② 竖移优先(可视行口径——折行与 \n 同权;显示列保持 + 短行端钳制)③ 边界回落历史(↑)
315
- // /无动作(↓)。processing 期:竖移放行(纯编辑——与字符/退格/←→ 同权)、历史导航禁
316
- // (规则 ①/③ 的历史分支吞——不进入、不切换)。
317
- if (key.name === "up" || key.name === "down") {
318
- if (state.historyIndex === -1) { // 规则 ① 优先——翻历史中不竖移
319
- const cols = (state.dims?.get() ?? {}).cols ?? (process.stdout.columns || 80)
320
- const next = moveCursorVertical(state.input, state.cursor, inputContentWidth(cols), key.name)
321
- if (next !== null) {
322
- state.cursor = next // 规则 ②:竖移(草稿/历史指针零涉——D-31.6)
323
- render()
324
- return
325
- }
326
- }
327
- if (state.processing) return // 规则 ①/③ 的历史分支吞——processing 期历史导航禁
328
- }
329
-
330
- // input history(规则 ① 恒历史 + 规则 ③ 边界回落——历史语义零改)
331
- if (key.name === "up") {
332
- if (state.history.length) {
333
- // Save current input as draft when entering history mode (historyIndex === -1).
334
- // Subsequent edits while in history mode are saved separately (see printable/editing handlers).
335
- if (state.historyIndex === -1) {
336
- state._draft = [...state.input]
337
- }
338
- state.historyIndex = state.historyIndex === -1 ? state.history.length - 1 : Math.max(0, state.historyIndex - 1)
339
- state.input = [...state.history[state.historyIndex]]
340
- state.cursor = state.input.length
341
- render()
342
- }
343
- return
344
- }
345
- if (key.name === "down") {
346
- if (state.historyIndex !== -1) {
347
- state.historyIndex++
348
- if (state.historyIndex >= state.history.length) {
349
- state.historyIndex = -1
350
- // Restore the stashed draft instead of wiping back to blank
351
- state.input = state._draft ? [...state._draft] : []
352
- state._draft = null
353
- } else {
354
- state.input = [...state.history[state.historyIndex]]
355
- }
356
- state.cursor = state.input.length
357
- render()
358
- }
359
- return
360
- }
361
-
362
- // cursor movement
363
- if (key.name === "left") {
364
- state.cursor = Math.max(0, state.cursor - 1)
365
- render()
366
- return
367
- }
368
- if (key.name === "right") {
369
- state.cursor = Math.min(state.input.length, state.cursor + 1)
370
- render()
371
- return
372
- }
373
- if (key.name === "home") {
374
- state.cursor = 0
375
- render()
376
- return
377
- }
378
- if (key.name === "end") {
379
- state.cursor = state.input.length
380
- render()
381
- return
382
- }
383
-
384
- // editing
385
- if (key.name === "backspace") {
386
- if (state.cursor > 0) {
387
- state.input.splice(state.cursor - 1, 1)
388
- state.cursor--
389
- if (state.historyIndex !== -1) state._draft = [...state.input]
390
- render()
391
- }
392
- return
393
- }
394
- if (key.name === "delete") {
395
- if (state.cursor < state.input.length) {
396
- state.input.splice(state.cursor, 1)
397
- if (state.historyIndex !== -1) state._draft = [...state.input]
398
- render()
399
- }
400
- return
401
- }
402
- if (key.name === "return" || key.name === "enter" || str === "\r") {
403
- // Multiline newline (docs/design/TUI-INPUT-BOX.md §1.5): Alt+Enter / Shift+Enter
404
- // (translateShiftEnter) / Ctrl+J — the universal no-protocol fallback.
405
- if ((key.name === "return" && key.meta) || key.name === "enter") {
406
- state.input.splice(state.cursor, 0, "\n")
407
- state.cursor++
408
- render()
409
- } else {
410
- const text = state.input.join("").trim()
411
- // §17 D-S5/F3/F7 + 偏差 #1 + INPUT-LOCK 单槽化(F-6——2026-09-09):挂起态
412
- // (suspended 或释放窗口 _suspPending)Enter = 新回合输入(非打断)——填
413
- // pendingInput 单槽(至多一条待交接——busy 提交吞在 L265 门禁先拦,digest 期
414
- // 不会到这——仅挂起空闲/释放窗口触达)由挂起会话调度;输入框零干扰(F3)。
415
- // 槽满(竞态防御——不覆盖不丢失)→ 吞 + 提示,文本保留在输入框。
416
- if ((state.suspended || state._suspPending) && text && !text.startsWith("/")) {
417
- state.pendingInput ??= []
418
- if (state.pendingInput.length > 0) {
419
- pushLine(`[主会话处理中 —— 已有一条消息待发送,请等其处理完成后再发送]`, C.warn)
420
- render()
421
- return
422
- }
423
- state.input = []
424
- state.cursor = 0
425
- state.history.push(text)
426
- state.historyIndex = -1
427
- state._draft = null
428
- state.pendingInput.push(text)
429
- state._suspWake?.()
430
- render()
431
- return
432
- }
433
- submit().catch((e) => pushLine(`[error] ${e.message}`, C.error))
434
- }
435
- return
436
105
  }
437
106
 
438
- // Ctrl+V: paste clipboard text into the active text target.
439
- // Exclude meta (ESC-prefix) so Ctrl+Alt+V — which readline reports as ctrl+meta —
440
- // falls through to the image-paste branch below instead of being eaten here.
441
- if (key.ctrl && !key.alt && !key.meta && key.name === "v") {
442
- ;(async () => {
443
- const text = await readClipboardText()
444
- if (text) {
445
- insertPastedText(state, text)
446
- render()
447
- }
448
- })()
449
- return
450
- }
107
+ // 序 3:scroll 族(key-handler-scroll.mjs)——PgUp/PgDn 翻页(含 loadOlder 边界加载)/
108
+ // ↑↓ 三规则 + 历史导航 / ←→·Home·End 光标
109
+ if (key.name === "pageup" || key.name === "pagedown") { handleScrollKeys(key, ctx); return }
110
+ if (key.name === "up" || key.name === "down") { handleHistoryKeys(key, ctx); return }
111
+ if (key.name === "left" || key.name === "right" || key.name === "home" || key.name === "end") { handleCursorKeys(key, ctx); return }
451
112
 
452
- // Alt+V / Ctrl+Alt+V: paste clipboard image.
453
- // NOTE: readline reports ESC-prefixed combos as key.meta, NOT key.alt (probe-verified:
454
- // \x1b + char → { meta: true, alt: false }). Checking key.alt alone is a dead branch —
455
- // the key fell through to the printable handler which ignores meta keys, so image paste
456
- // silently did nothing. Accept meta (and alt, for terminals that set it); the text-paste
457
- // branch above excludes meta so Ctrl+Alt+V reaches here.
458
- const isPasteImage = key.name === "v" && (key.alt || key.meta)
459
- if (isPasteImage) {
460
- pasteClipboardImage(agent).catch((e) => pushLine(`[error] ${e.message}`, C.error))
113
+ // 序 4:busy 门禁(key-handler-busy.mjs)——Enter 单槽受理 + 吞面四 / Tab 吞
114
+ // (守卫 = busy 块内返回条件:processing 期仅 tab 与无 meta 的 Enter/回车 被吞,余键落空至编辑族)
115
+ if (state.processing && (key.name === "tab" || ((key.name === "return" && !key.meta) || (str === "\r" && !key.meta)))) {
116
+ handleBusyEnter(str, key, state, ctx)
461
117
  return
462
118
  }
463
119
 
464
- // printable characters / paste (str may contain multiple chars at once); Tab always converted to two spaces (\t has variable display width, would blow up input box)
465
- // \r\n may leak through in Windows raw mode and scramble the display
466
- if (str && !key.ctrl && !key.meta) {
467
- const chars = [...str.replace(/[\r\n]+/g, "").replace(/\t/g, " ")]
468
- state.input.splice(state.cursor, 0, ...chars)
469
- state.cursor += chars.length
470
- if (state.historyIndex !== -1) state._draft = [...state.input]
471
- render()
472
- }
120
+ // 序 5:编辑族(key-handler-edit.mjs)——Tab 补全 / Ctrl+U 清框 / 退格·删除 / Enter 提交 /
121
+ // 剪贴板文本与图片粘贴 / 可打印字符(末族:其后无可达分支)
122
+ handleEditKeys(str, key, ctx)
473
123
  }
474
124
  }
@@ -395,9 +395,14 @@ function buildStatusLine(state, agent, { cols, slashCommands }) {
395
395
  const ctxTokensHint = state.ctxCache.tokens > 0 ? ` ${fmtK(state.ctxCache.tokens)}` : ""
396
396
  const ctxHint = ctxPct > 0
397
397
  ? ctxPct >= 80 ? ` │ ${ansi.reset}${C.warn}context ${ctxPct}%${ctxTokensHint}${ansi.reset}${ansi.dim}` : ` │ context ${ctxPct}%${ctxTokensHint}` : ""
398
- // INPUT-LOCK-ASYNC(C'——F-3):busy(processing 含 digest)状态栏提示——取代旧
399
- // 排队提示(F-7 已删)——“主会话处理中——Enter 提交禁用”
400
- const enterHint = state.processing ? `主会话处理中 — Enter 提交禁用(字符可输入,回合结束请重按 Enter)` : "Enter: send"
398
+ // INPUT-LOCK-ASYNC(C'——F-3):busy(processing 含 digest)状态栏提示——取代旧排队提示
399
+ // (F-7 已删);F16(TUI.md §7.5)三态分流:单槽已填 ⇒ queued 段(dim 段样式 · 零注意力色对
400
+ // ——F13 豁免 · 消费即消失);槽空 ⇒ 按 busy 面分排队句(判据同 TUI-INPUT-BOX.md §4.1)。
401
+ const enterHint = state.processing
402
+ ? ((state.pendingInput?.length ?? 0) > 0 ? "已排队 1 条消息"
403
+ : state.suspended || state._suspPending ? `会话内回合处理中 — Enter 排队(本轮结束后优先发送)`
404
+ : `主会话处理中 — Enter 排队(回合结束后自动发送)`)
405
+ : "Enter: send"
401
406
  // LEDGER-SURFACE(§2.30.3.3/§2.30.3.4):L1 常驻标记——状态段簇尾(scrollHint 后、键位组前);
402
407
  // 空标记零注入(半态逐字节等价——同 :233-238 纪律);warn = 当前项目老化 > 0(警示色段包裹)
403
408
  const lg = state.ledger
@@ -224,13 +224,13 @@ export function freezeAllSubTasks(state) {
224
224
  }
225
225
  }
226
226
 
227
- /** §17.5.5 消化完成逐条冻结回收(2026-09-03 实测修订 + round1 #1 位置裁定):digest/会话内
227
+ /** 消化完成逐条冻结回收(2026-09-03 实测修订 + round1 #1 位置裁定):digest/会话内
228
228
  * 用户回合消化完 pending 条目(run 首行已注入)后调用——把"已消化但仍驻留面板"的
229
229
  * awaitingDigest 块立即冻结进流(不等池空——块回收与池空解耦;池空 freezeAllSubTasks
230
230
  * 仅兜底未消化残项)。归属不变式:会话内任何 run 开始前 pinned 块的条目必在 pending
231
231
  * (settle 即移交);run 消费后条目不在 pending 的 pinned 块 = 本 run 消化者——无需
232
- * 快照即精确归属。位置(round1 #1 裁定——与 17.5.5 早版文本的矛盾已消解,见
233
- * AGENT-LOOP.md §17.5.5):**settle 锚点 splice 落位——digest 总览文本之前**——同
232
+ * 快照即精确归属。位置(round1 #1 裁定——与早版文本的矛盾已消解;digest 面 =
233
+ * AGENT-LOOP-ASYNC-POOL.md §6.8):**settle 锚点 splice 落位——digest 总览文本之前**——同
234
234
  * §7.2 D4 修复轮/D-S8 锚点语义(T-S6/T-S14 位置断言同口径)——锚点降序逐块冻结
235
235
  * (splice 绝对位互不位移)。@returns {number} 回收块数 */
236
236
  export function freezeReclaimDigestedBlocks(state, pendingList) {
@@ -6,10 +6,10 @@
6
6
  *
7
7
  * §17(2026-09-02,AGENT-LOOP.md §9 D-S1..S9):回合尾后台池非空 → 不阻塞等待,
8
8
  * 进入挂起态——挂起空闲输入开放(Enter = 新回合填单槽 + 唤醒)、busy(processing 含
9
- * digest)输入禁用(INPUT-LOCK-ASYNC C'——提交吞——2026-09-09)、settle 事件驱动
9
+ * digest)提交亦入同槽(busy-extend 批 2026-09-22——`TUI-INPUT-BOX.md` §4.1)、settle 事件驱动
10
10
  * auto-turn 消化(手动档 organize-only / AUTO 档全语义)、池空 + 无待处理输入 → 补发
11
11
  * done 冻结自然退出。状态机行表见 AGENT-LOOP.md §9.2。
12
- * F-UC7(2026-09-19 批,AGENT-LOOP-SUBAGENT.md §6.27.12):第二开轮源 = 未 drain 的上行
12
+ * F-UC7(2026-09-19 批,AGENT-LOOP-UPSTREAM.md §6.27.12):第二开轮源 = 未 drain 的上行
13
13
  * ask(`upstreamWaiting` 谓词——ask 入队即唤醒本驱动);唤醒轮同走 auto-turn(旗标
14
14
  * `upstreamTurn` 供核域文本选择)。
15
15
  * F-UC8(2026-09-21 批,§6.27.12.13):可见提示面**按因两档**(ask 携「谁 + 啥」——
@@ -25,17 +25,17 @@ import { logEvent } from "@thincoder/core/log.mjs"
25
25
  import { C } from "./ansi.mjs"
26
26
  // ASYNC-RESULT-CONTAINER.md D1/D2:池 accessor(双池 absorb)+ pending 单容器停靠
27
27
  import { getAsyncPool, parkAsyncPending, releaseSettledEntry } from "@thincoder/core/agent-tools/async-settle.mjs"
28
- // F-UC7(AGENT-LOOP-SUBAGENT.md §6.27.12——2026-09-19 批):上行 ask 开轮谓词单点(核导出)
28
+ // F-UC7(AGENT-LOOP-UPSTREAM.md §6.27.12——2026-09-19 批):上行 ask 开轮谓词单点(核导出)
29
29
  // F-UC8(§6.27.12.13 ②——2026-09-21 批):ask 提示行携参单点(同档导出——显示面单源)
30
30
  import { upstreamAskLabelVars, upstreamWaiting } from "@thincoder/core/agent-tools/parent-channel.mjs"
31
31
  // X9(2026-09-20 端差·显示面消差批):消化收尾文案单源 = 核 i18n 容器(禁第三份字面)
32
32
  import { t } from "@thincoder/core/i18n.mjs"
33
- // 批 4 CLI-ASYNC-DISCARD(AGENT-LOOP-SUBAGENT.md §6.20):中止分支「只清已死」收尾单点
33
+ // 批 4 CLI-ASYNC-DISCARD(AGENT-LOOP-ASYNC-POOL.md §6.20):中止分支「只清已死」收尾单点
34
34
  import { discardAbortedPool, discardAbortedAdvisors } from "@thincoder/core/agent-tools/async-discard.mjs"
35
35
 
36
36
  // INPUT-LOCK-ASYNC(C'——2026-09-09,本档 INPUT-LOCK-ASYNC.md):R15 排队
37
37
  // 用户指令合并(§11.3 D-24c——攒批计划/合并文案/上限常量)整批废弃
38
- // ——busy 提交吞 + pendingInput 单槽化(至多一条待交接——单消息逐发不攒批)。
38
+ // ——pendingInput 单槽化(至多一条待交接——单消息逐发不攒批;busy 提交入槽 = busy-extend 批 2026-09-22 扩面)。
39
39
 
40
40
  /** 后台池计数(LOGGING susp/digest 事件字段——pendingN/poolN)。
41
41
  * poolN = _asyncSubagents + _asyncAdvisors(§11.2 D-24b advisor 池同面板计数——queued
@@ -80,7 +80,7 @@ export function allPendingEntries(agent) {
80
80
  return [...(agent?._pendingAsyncResults ?? [])]
81
81
  }
82
82
 
83
- // ─── §17 挂起会话(AGENT-LOOP.md §17 D-S2/D-S9 状态机行表)────────────────
83
+ // ─── 挂起会话(AGENT-LOOP-ASYNC-POOL.md §6.8 D-S2/D-S9 状态机行表)────────────────
84
84
 
85
85
  /** 后台池存活判据(D-S2/F5 口径):running/queued 子代理或后台评审,或已 settle 未注入结果
86
86
  * (pending 单容器非空 = D-S3 "未注入"),或 running consult 会话(会诊跨回合——
@@ -205,7 +205,7 @@ async function digestTurn(ctx, upstream = false) {
205
205
  * §17 挂起会话驱动(D-S9 行表;由 runAgentTurn 回合尾进入,池空自然退出):
206
206
  * - suspension:池项 settle → 入 pending → 开 auto-turn(合并消化近邻 settle);
207
207
  * 上行 ask 入队 → 唤醒 + 谓词 → 开唤醒轮(F-UC7——谓词先于池空退出判,见第 2 步);
208
- * 挂起空闲用户 Enter → pendingInput 单槽(busy 含 digest 提交吞——INPUT-LOCK-ASYNC)
208
+ * 挂起空闲用户 Enter、会话内 busy Enter → pendingInput 单槽(busy-extend 2026-09-22 同判据)
209
209
  * ——用户输入优先于 digest;
210
210
  * - auto-turn:消化中 settle 不并发开新轮(单 runAgent 循环),轮末按 pending/池态
211
211
  * 续开合并消化轮或回挂起;pendingInput 非空 → 以该消息开新回合(不触发新 digest);
@@ -240,11 +240,12 @@ export async function suspensionSession(ctx) {
240
240
  while (!state._suspAborted && !agent._sessionAbort.signal.aborted) {
241
241
  sweepSettledToPending(agent)
242
242
  // 1. 用户输入优先(D-S5):pendingInput 单槽(INPUT-LOCK-ASYNC C'——busy(processing
243
- // 含 digest)提交吞——Enter 只可能落在挂起空闲/释放窗口——至多一条待交接——driver
243
+ // 含 digest)提交亦入本槽——TUI-INPUT-BOX.md §4.1;至多一条待交接——driver
244
244
  // 消费清槽即开新回合)。R15 攒批/queue 双源已随排队机制废弃收敛(2026-09-09——
245
245
  // 单消息交接——无合并无 /cmd 分流——key-handler 只收非斜杠文本——单消息逐发)。
246
246
  if ((state.pendingInput?.length ?? 0) > 0) {
247
247
  const head = String(state.pendingInput.shift())
248
+ pushLine("[sending queued message]", C.tool) // F16 消费回执(TUI.md §7.5——driver 消费点)
248
249
  agent._suspended = false // 用户回合 = 普通回合语义(① 直注入 + settle 即冻结)
249
250
  await runAgentTurn(ctx, head, { skipSession: true })
250
251
  agent._suspended = true
@@ -290,7 +291,7 @@ export async function suspensionSession(ctx) {
290
291
  state._suspWake = null
291
292
  state.suspAbortArmed = false // round2 偏差 #4:会话退出即解除挂起中止武装(防跨会话粘滞)
292
293
  if (aborted) {
293
- // §15 abort 语义:只清已死条目(AGENT-LOOP-SUBAGENT.md §6.20——墓碑/出池/队列剔除/
294
+ // §15 abort 语义:只清已死条目(AGENT-LOOP-ASYNC-POOL.md §6.20——墓碑/出池/队列剔除/
294
295
  // 整批一次提醒;存活与已 settle 者留池消化——不注入陈旧错误)。
295
296
  discardAbortedPool(agent)
296
297
  discardAbortedAdvisors(agent)
@@ -0,0 +1,65 @@
1
+ import { makeDimsState } from "./dims.mjs"
2
+
3
+ /** TUI state 工厂(2026-09-22 structure-debt §2.2 · #159):state 字面量自 index.mjs startTUI
4
+ * 逐字搬移(仅去包裹);`dims` 种子以调用侧采样值入参(Cols/Rows 名保持字面量原样),
5
+ * `tasks` 由 agent 承接(`agent.tasks ?? []` 原地保留)。
6
+ * @param {{ cols: number, rows: number, agent: object }} deps
7
+ * @returns {object} TUI state(会话面字段 / dims 单源 / 面板回指位) */
8
+ export function createTuiState({ cols, rows, agent }) {
9
+ // 名字桥:字面量内沿用调用侧采样名(逐字搬移所需)
10
+ const startupCols = cols
11
+ const startupRows = rows
12
+ return {
13
+ lines: [], // conversation lines: { text, color }
14
+ streaming: "", // current streaming buffer
15
+ _advisorBlocks: [], // advisor ordered blocks: [{ kind: "think"|"text", text }] — preserves emission order (think ↔ tool interleaving)
16
+ input: [], // input buffer (codepoint array)
17
+ cursor: 0,
18
+ history: [],
19
+ historyIndex: -1,
20
+ _draft: null, // stashed unsent input while navigating history (restored on down past newest)
21
+ scroll: 0, // scroll lines from bottom upward
22
+ _foldScroll: new Map(), // 2026-08-31 块内滚动:foldKey → 窗口 offset(展开块 ▲▼ 翻窗)
23
+ _followTail: true, // 2026-08-31 流式跟随:渲染前 scroll=0;用户上滚暂停、到底/新消息恢复
24
+ processing: false,
25
+ controller: null, // AbortController for current agent run
26
+ permission: null, // { name, args, resolve }
27
+ permissionPreview: [], // content preview lines for permission approval (rendered above input box, without separation)
28
+ question: null, // { text, options, resolve } — agent question tool callback
29
+ picker: null, // active picker (stack top) { title, entries, lines, index, scroll, selectedLine, filter }
30
+ pickerStack: [], // picker 栈:showPicker push,Enter/Esc pop;state.picker 始终指向栈顶
31
+ pendingNotice: null, // 后台更新提示:有 picker 打开时挂起,picker 全部关闭后再弹
32
+ wizard: null, // first-launch config wizard { step, index, scroll, selectedLine, fields, error, lines }
33
+ tasks: agent.tasks ?? [], // task list from task tool (progress shown in status bar); carried over on session restore, auto-collapsed when all done
34
+ dims: makeDimsState({ cols: startupCols, rows: startupRows }), // terminal dims single source (Windows ConPTY instability, 2026-08-30) — seeded pre-raw-mode, re-sampled by event hooks only (startup retry / resize / idle watchdog)
35
+ tokens: { prompt: 0, completion: 0, cacheHit: 0, cacheMiss: 0, reasoningTokens: 0 }, // cumulative token usage (shown in status bar)
36
+ ctxCache: { len: -1, tokens: 0 }, // context utilization estimate cache (estimateTokens is O(n), only recompute when history grows)
37
+ reasoning: "", // thinking stream buffer (dimmed display)
38
+ completion: null, // Tab completion state { candidates, index }
39
+
40
+ 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: [](SUBAGENT-TAIL:嵌套子代理**守护载体**——内容行并入本块 blocks——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(SUBAGENT-TAIL 单环:内层行同环计数、单载体最旧先行——TUI.md §6)
41
+ currentTool: null, // currently executing tool name (shown in status bar)
42
+ processingStarted: 0, // current turn start time (status bar timer)
43
+ status: "Ready",
44
+ ledger: { marker: null, warn: false, scannedAt: 0 }, // LEDGER-SURFACE(§2.30.3.4):L1 标记位——ledger-surface 写 / render-frame 读
45
+ queue: [], // 交接残项单容器 [{ text }](INPUT-LOCK:submit 不再排队——仅释放窗口兜底/挂起中止残余——回合尾队列循环续发,至多一条)
46
+ interruptPrompt: null, // Ctrl+I inject box(第 31 批——TUI-INPUT-BOX.md §8): { chars: string[], cursor: number } or null
47
+ attentionAwaiting: false, // 第 33 批(TUI §14.3(f)):回合结束等待输入——链尾置位 / 用户输入清位;不落盘、不进会话
48
+ search: null, // Ctrl+F search mode: { query: "", matches: [{lineIndex, charIndex}], index: 0 } or null
49
+ expandedBlocks: new Set(), // block hashes that are expanded (Enter toggles)
50
+ foldEnabled: true, // global fold toggle — /fold on|off
51
+ exitArmed: false, // Ctrl+C double-confirm: first press arms, second (within window) exits
52
+ // Lazy history window (parity with VS Code): only the latest messages are
53
+ // materialized on restore; PgUp-at-top loads earlier pages via loadOlder.
54
+ _historyLoaded: 0, // messages loaded from the TAIL of _fullHistory
55
+ _historyTotal: 0, // total messages in the restored session
56
+ _hasOlder: false, // more earlier messages remain unloaded
57
+ _linesChars: 0, // TUI-OOM-ROOTCAUSE(§15.3.2):state.lines 全部行与载体文本总量(字符账——唯一新增状态位)
58
+ _agent: null, // TUI state → agent 回指挂载点:startTUI 装配时置 agent 引用——
59
+ // ① SYNC-CANCEL ⏹ 门控读 state._agent._syncChildAborts(subagent-panel.mjs,2026-09-09);
60
+ // ② CLI-ACTIVITY-DEBLOAT F-3(2026-09-10):agent._tuiState = state 反向挂载——
61
+ // action:"panel" 经 ctx.state(= agent._tuiState)**读时现算**面板块(computePanelBlocks
62
+ // ——subTasks 活值纯推导——单账本)。headless/VS Code 无此装配 = 无面板 → panel 动作
63
+ // 恒降级池视图(CLI-only 完整能力——AC-P4)。
64
+ }
65
+ }