thincoder 0.12.63 → 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
  }
@@ -1,85 +1,32 @@
1
1
  /**
2
- * ledger-surface.mjs — TUI 台账可见面胶水(LEDGER-SURFACE 批——设计档 §2.30.3.4)。
2
+ * ledger-surface.mjs — TUI 台账可见面胶水(K-LX3 归核:实现单源 = `@thincoder/core/ledger-surface.mjs`,
3
+ * 本档只剩**动态 import + TUI 色表注入**两件事——端壳静态链不得达 `node:sqlite`(W8 契约②);
4
+ * 载入失败 = 降级不崩(N1),扫描拍重试)。
3
5
  *
4
- * 三面:① 启动行(首扫、**任一项目可动作**才出——明细行集每项目一行)② 变化行(老化首次
5
- * 越线 / 池达阈值——一次性去重 + **送达门**:未送达不记账)③ 状态位(`state.ledger`——L1
6
- * 常驻标记,`render-frame.mjs` 消费;**不受启动行门约束**——F2 按标记态照显)。
7
- * 只读台账;唯一写面 = 去重档(`~/.thincoder/ledger-notify.json`)。
8
- *
9
- * M2/KD-M2-3:核台账面**动态 import**(`@thincoder/core/ledger.mjs` 静态链 `node:sqlite`——
10
- * 静态 import 会把 `node:sqlite` 拽进端壳静态面);载入失败 = 降级不崩(N1),扫描拍重试。
6
+ * 三面语义(启动行 / 变化行 / 状态位)见核档头注;判活展示(F-LX1)亦随归核自动带上——
7
+ * 本档零实现、零复刻(KD-M2-3)。
11
8
  */
12
9
  import { C } from "./ansi.mjs"
13
10
 
14
- /** 核台账面惰性载入(KD-M2-3 动态 import——失败不缓存,下次扫描拍重试;N1 降级不崩)。 */
11
+ /** 核台账面惰性载入(动态 import——失败不缓存,下次扫描拍重试;N1 降级不崩)。 */
15
12
  let _core = null
16
13
  function loadCore() {
17
14
  if (!_core) {
18
- _core = import("@thincoder/core/ledger.mjs")
15
+ _core = import("@thincoder/core/ledger-surface.mjs")
19
16
  _core.catch(() => { _core = null })
20
17
  }
21
18
  return _core
22
19
  }
23
20
 
24
- /** 单次扫描(直驱面——timer 包装见 `startLedgerSurface`)。
25
- * `startup=true` = 会话首扫(补启动行;变化行在前、明细行在后)。 */
26
- export async function runLedgerScan({ state, agent = null, anchor = null, notifyFile = undefined, pushLine = () => {}, render = () => {}, startup = false } = {}) {
21
+ /** 单次扫描(直驱面——直通核实现;TUI 色表 `C` 注入)。 */
22
+ export async function runLedgerScan(opts = {}) {
27
23
  const core = await loadCore()
28
- const { buildScan, detailScans, discoverFamily, formatDetailLine, formatMarker, loadNotifyState, NOTIFY_FILE, notifyKey, planChangeLines, saveNotifyState } = core
29
- const base = anchor ?? agent?.cwd ?? process.cwd()
30
- const family = discoverFamily(base)
31
- const scans = []
32
- for (const p of family.projects) {
33
- try { scans.push(buildScan({ cwd: p.root })) } catch { /* 台账不可读 → 该项目跳过(余者照常——N1/T110①) */ }
34
- }
35
- const current = family.current ? scans.find((s) => s.root === family.current.root) ?? null : null
36
- const notify = loadNotifyState(notifyFile ?? NOTIFY_FILE)
37
- const plans = scans.map((s) => ({ s, plan: planChangeLines(notify.ledgers[notifyKey(s.ledger)], s) }))
38
- const changeLines = plans.flatMap(({ plan }) => plan.lines)
39
- const showStartup = startup && scans.some((s) => s.actionable)
40
- const rows = showStartup
41
- ? [...changeLines, ...detailScans(scans, current).map((s) => ({ text: formatDetailLine(s), warn: s.actionable }))]
42
- : changeLines
43
- let delivered = true
44
- for (const line of rows) {
45
- try { pushLine(line.text, line.warn ? C.warn : C.dim) } catch { delivered = false; break }
46
- }
47
- // 送达门(F5):有事件行 → 仅在送达后记账;无事件行 → 仅状态确有变化时静默同步(跌回 false 即重置)
48
- const changed = plans.some(({ s, plan }) => {
49
- const prev = notify.ledgers[notifyKey(s.ledger)]
50
- return !prev || JSON.stringify(prev.aged ?? []) !== JSON.stringify(plan.next.aged) || Boolean(prev.threshold) !== plan.next.threshold
51
- })
52
- if (changeLines.length ? delivered : changed) {
53
- const ledgers = { ...notify.ledgers }
54
- for (const { s, plan } of plans) ledgers[notifyKey(s.ledger)] = { ...plan.next, updatedAt: Date.now() }
55
- saveNotifyState(notifyFile ?? NOTIFY_FILE, { version: 1, ledgers })
56
- }
57
- state.ledger = { marker: current ? formatMarker(current) : null, warn: Boolean(current && current.aged > 0), scannedAt: Date.now() }
58
- render()
24
+ return core.runLedgerScan({ ...opts, colors: C })
59
25
  }
60
26
 
61
- /** TUI 挂载:首扫(`setImmediate`——不抢首帧)+ 周期(`REFRESH_MS`;`state.processing` 期间跳过本轮)。 */
27
+ /** TUI 挂载(首扫 + 周期——直通核实现;载入失败 → 空句柄 = 无周期,N1 降级不崩)。 */
62
28
  export function startLedgerSurface(ctx = {}) {
63
- let disposed = false
64
- let timer = null
65
- const tick = async (startup) => {
66
- if (disposed) return
67
- if (!startup && ctx.state?.processing) return // 扫描不打断回合帧
68
- try { await runLedgerScan({ ...ctx, startup }) } catch { /* 可见面尽力——不崩 TUI(N1) */ }
69
- }
70
- setImmediate(async () => {
71
- if (disposed) return
72
- tick(true)
73
- const core = await loadCore().catch(() => null)
74
- if (disposed || !core) return // 载入失败 → 无周期(N1——降级不崩)
75
- timer = setInterval(() => tick(false), core.REFRESH_MS)
76
- timer.unref?.()
77
- })
78
- return {
79
- dispose() {
80
- disposed = true
81
- if (timer) clearInterval(timer)
82
- timer = null
83
- },
84
- }
29
+ return loadCore()
30
+ .then((core) => core.startLedgerSurface({ ...ctx, colors: C }))
31
+ .catch(() => ({ dispose() {} }))
85
32
  }
@@ -395,14 +395,23 @@ 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
404
409
  const ledgerHint = lg?.marker
405
410
  ? ` │ ${lg.warn ? `${ansi.reset}${C.warn}${lg.marker}${ansi.reset}${ansi.dim}` : lg.marker}`
406
411
  : ""
407
- return ` ${statusText}${taskHint}${turnHint}${tokenHint}${ctxHint}${scrollHint}${ledgerHint} │ ${enterHint} │ /: commands │ wheel/PgUp/PgDn: scroll │ Ctrl+I: inject │ Ctrl+C: exit (×2)`
412
+ // D8(2026-09-21 块标题行对齐批——会话标题常显 · `docs/cli/design/TUI.md` §7.4):状态段簇尾
413
+ // (ledgerHint 后、键位组前);取值 = `agent.title` 活读(每帧 recompute)· 空值零注入。
414
+ const titleRaw = typeof agent.title === "string" ? agent.title.trim() : ""
415
+ const titleHint = titleRaw ? ` │ ${stringWidth(titleRaw) > 40 ? sliceByWidth(titleRaw, 39) + "…" : titleRaw}` : ""
416
+ return ` ${statusText}${taskHint}${turnHint}${tokenHint}${ctxHint}${scrollHint}${ledgerHint}${titleHint} │ ${enterHint} │ /: commands │ wheel/PgUp/PgDn: scroll │ Ctrl+I: inject │ Ctrl+C: exit (×2)`
408
417
  }
@@ -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) {