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.
package/src/tui/index.mjs CHANGED
@@ -16,31 +16,32 @@
16
16
  * distill-cmd.mjs — /distill command
17
17
  * mouse.mjs — SGR mouse parsing + dispatch assembly (createMouseDispatch)
18
18
  * update-notice.mjs — background update notice + startup check
19
+ * tui-state.mjs — TUI state literal factory(structure-debt #159)
20
+ * input-face.mjs — 输入面:stdin 流 + data 处理器 + 键盘/鼠标后置挂载入口(二段接口)
21
+ * conversation-writer.mjs — 对话写入面:pushLine / pushLabel / assistant 标签位
22
+ * turn-face.mjs — 回合面:submit / interaction / 粘贴 / turnCtx / turn
19
23
  */
20
24
 
21
- import { emitKeypressEvents } from "node:readline"
22
- import { PassThrough } from "node:stream"
23
25
  import { saveSession } from "@thincoder/core/session.mjs"
24
26
  import { closeAllMcp } from "@thincoder/core/mcp.mjs"
25
- import { ansi, C } from "./ansi.mjs"
26
27
  import { createRenderLoop } from "./render-loop.mjs"
27
- import { makeDimsState } from "./dims.mjs"
28
+ import { ansi, C } from "./ansi.mjs"
28
29
  import { SLASH_COMMANDS, createSlashCommands } from "./slash-commands.mjs"
29
30
  import { createWizard } from "./wizard.mjs"
30
- import { writeStartupSequence, setTuiActive, createExitCleanup } from "./tui-lifecycle.mjs"
31
+ import { createExitCleanup } from "./tui-lifecycle.mjs"
31
32
  import { createPickers } from "./pickers.mjs"
32
33
  import { runDistill as runDistillImpl } from "./distill-cmd.mjs"
33
- import { createInteraction } from "./interaction.mjs"
34
- import { pasteClipboardImage as pasteClipboardImageImpl, insertPastedText, translateShiftEnter, stripKeyboardProtocol } from "./clipboard.mjs"
35
- import { parseMouseClicks, handleWheel, createMouseDispatch, mouseOob } from "./mouse.mjs"
36
- import { runAgentTurn } from "./agent-turn.mjs"
37
- import { createKeyHandler, convMaxScroll, clearAttention } from "./key-handler.mjs"
34
+ // F-XR1 退出释放(EXIT-CLAIM-RELEASE · SESSION.md §6.18):核薄函数——/exit 与 Ctrl+C×2
35
+ // 同一退出语义双入口;退出恒达(永不抛出);零触碰 marker 面(F-XR2 路标保留)。
36
+ import { releaseClaimsAll } from "@thincoder/core/session-slots-manifest.mjs"
38
37
  import { showStartup, backgroundIndex, createLoadOlder } from "./startup.mjs"
39
- import { shiftFreezeAnchors } from "./subagent-blocks.mjs"
40
- import { capLine, accountLine, accountAll, syncLineBudget } from "./display-budget.mjs"
41
38
  import { createConfigHelpers } from "./config-helpers.mjs"
42
39
  import { createUpdateNotice, pendingNoticeReady } from "./update-notice.mjs"
43
40
  import { startLedgerSurface } from "./ledger-surface.mjs"
41
+ import { createTuiState } from "./tui-state.mjs"
42
+ import { createInputFace } from "./input-face.mjs"
43
+ import { createConversationWriter } from "./conversation-writer.mjs"
44
+ import { createTurnFace } from "./turn-face.mjs"
44
45
 
45
46
  export { upgradeFailureText, pendingNoticeReady } from "./update-notice.mjs"
46
47
 
@@ -84,60 +85,7 @@ export async function startTUI(agent, opts = {}) {
84
85
  // watchdog, agent-turn finally — never in the render path (2026-08-30).
85
86
  const startupCols = process.stdout.columns || 80
86
87
  const startupRows = process.stdout.rows || 24
87
-
88
- const state = {
89
- lines: [], // conversation lines: { text, color }
90
- streaming: "", // current streaming buffer
91
- _advisorBlocks: [], // advisor ordered blocks: [{ kind: "think"|"text", text }] — preserves emission order (think ↔ tool interleaving)
92
- input: [], // input buffer (codepoint array)
93
- cursor: 0,
94
- history: [],
95
- historyIndex: -1,
96
- _draft: null, // stashed unsent input while navigating history (restored on down past newest)
97
- scroll: 0, // scroll lines from bottom upward
98
- _foldScroll: new Map(), // 2026-08-31 块内滚动:foldKey → 窗口 offset(展开块 ▲▼ 翻窗)
99
- _followTail: true, // 2026-08-31 流式跟随:渲染前 scroll=0;用户上滚暂停、到底/新消息恢复
100
- processing: false,
101
- controller: null, // AbortController for current agent run
102
- permission: null, // { name, args, resolve }
103
- permissionPreview: [], // content preview lines for permission approval (rendered above input box, without separation)
104
- question: null, // { text, options, resolve } — agent question tool callback
105
- picker: null, // active picker (stack top) { title, entries, lines, index, scroll, selectedLine, filter }
106
- pickerStack: [], // picker 栈:showPicker push,Enter/Esc pop;state.picker 始终指向栈顶
107
- pendingNotice: null, // 后台更新提示:有 picker 打开时挂起,picker 全部关闭后再弹
108
- wizard: null, // first-launch config wizard { step, index, scroll, selectedLine, fields, error, lines }
109
- tasks: agent.tasks ?? [], // task list from task tool (progress shown in status bar); carried over on session restore, auto-collapsed when all done
110
- 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)
111
- tokens: { prompt: 0, completion: 0, cacheHit: 0, cacheMiss: 0, reasoningTokens: 0 }, // cumulative token usage (shown in status bar)
112
- ctxCache: { len: -1, tokens: 0 }, // context utilization estimate cache (estimateTokens is O(n), only recompute when history grows)
113
- reasoning: "", // thinking stream buffer (dimmed display)
114
- completion: null, // Tab completion state { candidates, index }
115
-
116
- 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)
117
- currentTool: null, // currently executing tool name (shown in status bar)
118
- processingStarted: 0, // current turn start time (status bar timer)
119
- status: "Ready",
120
- ledger: { marker: null, warn: false, scannedAt: 0 }, // LEDGER-SURFACE(§2.30.3.4):L1 标记位——ledger-surface 写 / render-frame 读
121
- queue: [], // 交接残项单容器 [{ text }](INPUT-LOCK:submit 不再排队——仅释放窗口兜底/挂起中止残余——回合尾队列循环续发,至多一条)
122
- interruptPrompt: null, // Ctrl+I inject box(第 31 批——TUI-INPUT-BOX.md §8): { chars: string[], cursor: number } or null
123
- attentionAwaiting: false, // 第 33 批(TUI §14.3(f)):回合结束等待输入——链尾置位 / 用户输入清位;不落盘、不进会话
124
- search: null, // Ctrl+F search mode: { query: "", matches: [{lineIndex, charIndex}], index: 0 } or null
125
- expandedBlocks: new Set(), // block hashes that are expanded (Enter toggles)
126
- foldEnabled: true, // global fold toggle — /fold on|off
127
- exitArmed: false, // Ctrl+C double-confirm: first press arms, second (within window) exits
128
- // Lazy history window (parity with VS Code): only the latest messages are
129
- // materialized on restore; PgUp-at-top loads earlier pages via loadOlder.
130
- _historyLoaded: 0, // messages loaded from the TAIL of _fullHistory
131
- _historyTotal: 0, // total messages in the restored session
132
- _hasOlder: false, // more earlier messages remain unloaded
133
- _linesChars: 0, // TUI-OOM-ROOTCAUSE(§15.3.2):state.lines 全部行与载体文本总量(字符账——唯一新增状态位)
134
- _agent: null, // TUI state → agent 回指挂载点:startTUI 装配时置 agent 引用——
135
- // ① SYNC-CANCEL ⏹ 门控读 state._agent._syncChildAborts(subagent-panel.mjs,2026-09-09);
136
- // ② CLI-ACTIVITY-DEBLOAT F-3(2026-09-10):agent._tuiState = state 反向挂载——
137
- // action:"panel" 经 ctx.state(= agent._tuiState)**读时现算**面板块(computePanelBlocks
138
- // ——subTasks 活值纯推导——单账本)。headless/VS Code 无此装配 = 无面板 → panel 动作
139
- // 恒降级池视图(CLI-only 完整能力——AC-P4)。
140
- }
88
+ const state = createTuiState({ cols: startupCols, rows: startupRows, agent })
141
89
  state._agent = agent
142
90
  agent._tuiState = state // F-3:面板视图现算通道(替代退役的手工面板镜像)
143
91
 
@@ -146,181 +94,23 @@ export async function startTUI(agent, opts = {}) {
146
94
  state.tasks = []
147
95
  }
148
96
 
149
- // Input stream goes through a filter: mouse sequences (scroll wheel) are intercepted and handled here,
150
- // stripped clean before passing to keypress parsing, preventing sequence fragments (e.g. "64;72;42M")
151
- // from leaking into the input box
152
- const keyStream = new PassThrough()
153
- let mousePending = "" // incomplete mouse sequence tail spanning chunks
154
- let lastRenderedScroll = 0
155
- emitKeypressEvents(keyStream)
156
- process.stdin.setRawMode(true)
157
- // Keyboard enhancement — enable BOTH protocols (unsupported terminals ignore them):
158
- // kitty push (\x1b[>1u): Shift+Enter → \x1b[13;2u (Windows Terminal 1.19+, VS Code, kitty, iTerm2)
159
- // modifyOtherKeys lvl 2 (\x1b[>4;2m): Shift+Enter → \x1b[27;2;13~ (mintty / Git Bash)
160
- // translateShiftEnter (stdin layer) maps both to \x1b\r → meta+return → multiline branch.
161
- writeStartupSequence()
162
- setTuiActive(true) // R25(F-R25a):终端接管完成——置 TUI 活动态(崩溃钩子恢复判定源)
163
-
164
- const utf8Decoder = new TextDecoder("utf-8", { fatal: false })
165
-
166
- let pasteMode = false
167
- let pasteAccum = ""
168
-
169
- process.stdin.on("data", (chunk) => {
170
- try {
171
-
172
- let text = mousePending + utf8Decoder.decode(chunk, { stream: true })
173
- mousePending = ""
174
-
175
- // 第 33 批(TUI §14.3(e) 鼠标点):stdin 数据到达即用户在场 ⇒ 清 attention 位
176
- // (单点覆盖下方滚轮分支与 onMouseClick;键盘入口在 key-handler)。
177
- clearAttention(state, render)
178
-
179
- // Bracketed paste: terminal wraps pasted text in \x1b[200~ ... \x1b[201~
180
- // Route pasted content to the active text target (question answer / input box) in one shot,
181
- // avoiding slow char-by-char keypress render — see insertPastedText in clipboard.mjs
182
- if (pasteMode) {
183
- const endIdx = text.indexOf("\x1b[201~")
184
- if (endIdx >= 0) {
185
- pasteAccum += text.slice(0, endIdx)
186
- pasteMode = false
187
- const pasted = pasteAccum
188
- pasteAccum = ""
189
- if (pasted) {
190
- insertPastedText(state, pasted)
191
- render()
192
- }
193
- text = text.slice(endIdx + 6)
194
- } else {
195
- pasteAccum += text
196
- return
197
- }
198
- }
199
-
200
- // Check for paste start (may appear mid-chunk alongside other input)
201
- const pasteStartIdx = text.indexOf("\x1b[200~")
202
- if (pasteStartIdx >= 0) {
203
- const before = text.slice(0, pasteStartIdx)
204
- const after = text.slice(pasteStartIdx + 6)
205
- const endIdx = after.indexOf("\x1b[201~")
206
- if (endIdx >= 0) {
207
- // Paste begin and end in the same chunk: insert pasted content directly
208
- const pasted = after.slice(0, endIdx)
209
- if (pasted) {
210
- insertPastedText(state, pasted)
211
- render()
212
- }
213
- text = before + after.slice(endIdx + 6)
214
- } else {
215
- // Paste spans multiple chunks: write prefix, enter paste mode
216
- if (before) keyStream.write(before)
217
- pasteMode = true
218
- pasteAccum = after
219
- return
220
- }
221
- }
222
-
223
- // Scroll wheel: \x1b[<64;col;rowM = up, \x1b[<65;col;rowM = down(3 lines each)
224
- // 2026-08-31:坐标命中展开块内容行 → 块内滚动(handleWheel);未命中 → 会话滚动(现状)
225
- for (const m of text.matchAll(/\x1b\[<(\d+);(\d+);(\d+)([Mm])/g)) {
226
- const button = Number(m[1])
227
- if (button === 64 || button === 65) {
228
- // F-3 sane-gate ③(RESIZE-MOUSE-LEAK-FIX):越界 wheel 禁止会话滚动——handleWheel
229
- // 对越界返回未消费会穿到 fallback 滚动(mouse.mjs 内 gate 覆盖不到此路径)
230
- const dims = state.dims ? state.dims.get() : { cols: process.stdout.columns || 80, rows: process.stdout.rows || 24 }
231
- if (mouseOob(Number(m[2]), Number(m[3]), dims)) continue
232
- const consumed = handleWheel(mouseCtx(), button, Number(m[2]), Number(m[3]))
233
- if (!consumed) {
234
- if (button === 64) {
235
- state.scroll += 3
236
- state._followTail = false // 2026-08-31:用户上滚 = 暂停流式跟随(不抢视角)
237
- // 2026-08-31 用户约定修复:滚动到头自动加载(原来只挂 PgUp 键——违约)
238
- if (state._hasOlder && state.scroll >= convMaxScroll(state)) loadOlder()
239
- } else {
240
- state.scroll = Math.max(0, state.scroll - 3)
241
- if (state.scroll === 0) state._followTail = true // 滚回底部恢复跟随
242
- }
243
- }
244
- }
245
- }
246
-
247
- // Left-click: \x1b[<0;col;rowM → picker selection / line action menu
248
- for (const click of parseMouseClicks(text)) {
249
- try {
250
- onMouseClick(click.col, click.row)
251
- } catch (e) {
252
- pushLine(`[mouse] ${e.message || e}`, C.error)
253
- render()
254
- }
255
- }
256
-
257
- // Strip complete mouse sequences; keep incomplete tail for reassembly with next chunk
258
- text = text.replace(/\x1b\[<\d+;\d+;\d+[Mm]/g, "")
259
- const tail = text.match(/\x1b\[<[\d;]*$/)
260
- if (tail) {
261
- mousePending = tail[0]
262
- text = text.slice(0, -tail[0].length)
263
- }
264
-
265
- // Shift+Enter (keyboard-enhanced terminals) → Alt+Enter path (\x1b\r = meta+return)
266
- text = translateShiftEnter(text)
267
- text = stripKeyboardProtocol(text)
268
-
269
- if (state.scroll !== lastRenderedScroll) {
270
- lastRenderedScroll = state.scroll
271
- render()
272
- }
273
- if (text) keyStream.write(text)
274
- } catch (e) {
275
- pushLine(`[input-error] ${e.message || e}`, C.error)
276
- }
97
+ // 输入面(input-face.mjs,原址 :153 早挂载):stdin 流过滤 / raw mode / 启动序列 / 解码器 +
98
+ // data 处理器整块;render / pushLine / loadOlder 以惰性取值器承接(晚定义名——TDZ 语义保持),
99
+ // 键盘 ③ 与鼠标 ④ 后置挂载入口在命令层之后按原址调用(构造期读值面——见 invariant §2.2)。
100
+ const inputFace = createInputFace({
101
+ state,
102
+ get render() { return render },
103
+ get pushLine() { return pushLine },
104
+ get loadOlder() { return loadOlder },
277
105
  })
278
106
 
279
107
  const cleanup = createExitCleanup({ agent, saveSession, closeAllMcp })
280
108
  process.on("exit", cleanup)
281
109
 
282
- const pushLine = (text, color, kind) => {
283
- // TUI-OOM-ROOTCAUSE(TUI.md §15.3.1/§15.3.3):行额度单点——行文本过 capLine
284
- // (LINE_MAX_CHARS——单行巨内容/无换行巨 chunk 的堵口)+ 总量账 + 预算对账。
285
- const line = { text: capLine(text), color, _kind: kind }
286
- state.lines.push(line)
287
- accountLine(state, line)
288
- if (state.lines.length > 5000) {
289
- state.lines.splice(0, 1000)
290
- state.lines.unshift({ text: `... [earlier messages trimmed — ${state.lines.length} lines remaining]`, color: C.dim })
291
- // 2026-09-03 修复轮(冻结锚点):头裁对在途 settled 锚点整体前移(锚点是绝对
292
- // 流位置)——不校正则池空补发冻结(freezeSubTaskLines splice)落点漂移;校正量
293
- // = 净位移(裁 1000 补 1 标记行 → −999,code review round1 #3)。
294
- shiftFreezeAnchors(state, 1000)
295
- accountAll(state) // 行对象集合已变(splice/unshift 直写)——直算重对账
296
- }
297
- syncLineBudget(state, { onTrim: (st, n) => shiftFreezeAnchors(st, n) })
298
- render()
299
- }
300
-
301
- /** Message block label: blank line + label line. Breathing space between user/assistant messages */
302
- const pushLabel = (text, color) => {
303
- // 行额度同 pushLine(§15.3.1——恢复行/标签行同口径)
304
- if (state.lines.length > 0) {
305
- const blank = { text: "", color: C.dim }
306
- state.lines.push(blank)
307
- accountLine(state, blank)
308
- }
309
- const line = { text: capLine(text), color }
310
- state.lines.push(line)
311
- accountLine(state, line)
312
- syncLineBudget(state, { onTrim: (st, n) => shiftFreezeAnchors(st, n) })
313
- render()
314
- }
315
-
316
- // Only emit the assistant label once per turn (on first token or first tool call)
317
- let assistantLabeled = false
318
- const ensureAssistantLabel = () => {
319
- if (!assistantLabeled) {
320
- assistantLabeled = true
321
- pushLabel(`❯ ThinCoder:`, ansi.bold + C.assistant)
322
- }
323
- }
110
+ // 对话写入面(conversation-writer.mjs,原址 :293):行额度单点 + 消息标签 + assistant 标签位
111
+ // (render 以惰性转发承接——renderLoop 于下方装配)
112
+ const conversation = createConversationWriter({ state, render: () => render() })
113
+ const { pushLine, pushLabel, ensureAssistantLabel } = conversation
324
114
 
325
115
  // ---------------------------------------------------------- Render
326
116
 
@@ -350,56 +140,12 @@ export async function startTUI(agent, opts = {}) {
350
140
  try { state.dims.refresh(); render() } catch { /* resize error — ignore */ }
351
141
  })
352
142
 
353
- // ---------------------------------------------------------- Submit
354
-
355
- async function submit() {
356
- const text = state.input.join("").trim()
357
- if (!text) return
358
- // INPUT-LOCK 防御(C'——2026-09-09)+ INPUT-LOCK-BEHAVIOR-REVISED(2026-09-09——白名单
359
- // 已删——busy 全拒):提交吞的门禁在 key-handler(busy Enter 已吞 + 提示,文本保留)——
360
- // submit 只在非 busy 期达此;防御直呼/上游改动:busy 期拒绝(不清输入框不吞内容)。
361
- if (state.processing) {
362
- pushLine(`[主会话处理中 —— 消息未发送(回合结束后请重按 Enter)]`, C.warn)
363
- render()
364
- return
365
- }
366
- state.input = []
367
- state.cursor = 0
368
- const wasInHistory = state.historyIndex !== -1
369
- state.history.push(text) // review #3 fix: single push (was duplicated — every submit appeared twice in ↑/↓ history)
370
- state.historyIndex = -1
371
- if (!wasInHistory) state._draft = null // submitted — the draft is now history. Keep draft when submitting from history mode (↓ can recover)
372
- state.scroll = 0
373
- state._followTail = true // 2026-08-31 会诊 deepseek:新消息恢复跟随(注释曾承诺、实现缺漏)
374
-
375
- // Slash commands: handled locally, don't enter agent loop. busy 期提交在 key-handler
376
- // 门禁被吞(白名单已删——斜杠同吞——INPUT-LOCK-BEHAVIOR-REVISED)——submit 仅非 busy 期可达。
377
- if (text.startsWith("/")) {
378
- await handleSlash(text)
379
- render()
380
- return
381
- }
382
-
383
- await turn(text)
384
- }
385
-
386
- // Interaction primitives: permission approval + Q&A input, implemented in interaction.mjs
387
- const { askPermission, askQuestion, askBatchPermission } = createInteraction({
388
- agent, state, pushLine, pushLabel, render, summarize,
143
+ // 回合面(turn-face.mjs,原址 :366):submit / interaction(权限 + 提问)/ 图片粘贴 / turnCtx / turn
144
+ const { submit, turnCtx, askPermission, askQuestion, pasteClipboardImage } = createTurnFace({
145
+ agent, state, pushLine, pushLabel, render, ensureAssistantLabel, summarize, conversation,
146
+ handleSlash: (t) => handleSlash(t),
389
147
  })
390
148
 
391
- // Clipboard image paste: implemented in clipboard.mjs
392
- const pasteClipboardImage = () => pasteClipboardImageImpl({ agent, state, pushLine, render })
393
-
394
- // Agent loop: implemented in agent-turn.mjs
395
- const turnCtx = {
396
- agent, state, pushLine, pushLabel, render, scheduleRender: render, ensureAssistantLabel,
397
- askPermission, askQuestion, askBatchPermission, handleSlash: null,
398
- get assistantLabeled() { return assistantLabeled },
399
- set assistantLabeled(v) { assistantLabeled = v },
400
- }
401
- const turn = (text) => runAgentTurn(turnCtx, text)
402
-
403
149
  // ---------------------------------------------------------- Slash Commands
404
150
 
405
151
  // Config helpers: implemented in config-helpers.mjs
@@ -430,31 +176,28 @@ export async function startTUI(agent, opts = {}) {
430
176
  setProviderKey,
431
177
  pickModelForSlot,
432
178
  runDistill,
433
- exit: () => { cleanup(); setTimeout(() => process.exit(0), 100) },
179
+ exit: () => {
180
+ cleanup()
181
+ // F-XR1 退出释放(EXIT-CLAIM-RELEASE · SESSION.md §6.18):同 key-handler 退出分支——
182
+ // 先释放(同步完成)后定时器注册(D-SE42——100ms 窗零竞态)。
183
+ releaseClaimsAll(process.cwd())
184
+ setTimeout(() => process.exit(0), 100)
185
+ },
434
186
  })
435
187
  // handleSlash is referenced by turnCtx (circular dep: submit → turn → handleSlash), backfilled here
436
188
  turnCtx.handleSlash = handleSlash
437
189
 
438
190
  // ---------------------------------------------------------- Keyboard / Mouse
439
191
 
440
- // keypress is attached to filtered keyStream: mouse sequences already intercepted and stripped upstream
441
- const onKeypress = createKeyHandler({
192
+ inputFace.mountKeys({
442
193
  agent, state, render, popPicker, renderPickerLines,
443
194
  handleSlash, handleTab, submit, pasteClipboardImage,
444
195
  wizardChooseProvider, wizardSubmitText, cancelWizard, wizardProviderItems,
445
196
  renderWizard, pushLine, cleanup, showPicker, loadOlder,
446
197
  })
447
- keyStream.on("keypress", (str, key) => {
448
- try {
449
- onKeypress(str, key)
450
- } catch (e) {
451
- pushLine(`[input-error] ${e.message || e}`, C.error)
452
- render()
453
- }
454
- })
455
198
 
456
199
  // 鼠标点击/滚轮 ctx 装配(mouse.mjs createMouseDispatch——D-S1a:cancelSubagent/onMouseClick/mouseCtx)
457
- const { onMouseClick, mouseCtx } = createMouseDispatch({ agent, state, pushLine, render, popPicker })
200
+ inputFace.mountMouse({ agent, state, pushLine, render, popPicker })
458
201
 
459
202
  // ---------------------------------------------------------- Startup screen + background indexing
460
203
 
@@ -465,7 +208,10 @@ export async function startTUI(agent, opts = {}) {
465
208
  showStartup({ agent, state, opts, pushLine, pushLabel, render, startWizard })
466
209
  // LEDGER-SURFACE(§2.30.3.4):台账可见面——首扫(setImmediate)+ 周期;dispose 挂进程退出(:278 cleanup 先例)
467
210
  const ledgerSurface = startLedgerSurface({ state, agent, pushLine, render })
468
- process.on("exit", () => ledgerSurface.dispose())
211
+ // 前置缺陷最小修(2026-09-22 structure-debt · 父侧授权 · 只修调用点):K-LX3 归核后
212
+ // `startLedgerSurface` 返回 **Promise**(resolve 出 `{ dispose }`)——原同步 `.dispose()`
213
+ // 令每次退钩抛 TypeError(`node test-startup.mjs` 前置红,HEAD 逐字同款);按 Promise 承接。
214
+ process.on("exit", () => { void Promise.resolve(ledgerSurface).then((s) => s?.dispose?.()) })
469
215
  backgroundIndex({ agent, state, render })
470
216
 
471
217
  // Check for updates (non-blocking, after startup screen)——实现 update-notice.mjs
@@ -0,0 +1,187 @@
1
+ import { PassThrough } from "node:stream"
2
+ import { readFileSync } from "node:fs"
3
+ import { emitKeypressEvents } from "node:readline"
4
+ import { C } from "./ansi.mjs"
5
+ import { insertPastedText, translateShiftEnter, stripKeyboardProtocol } from "./clipboard.mjs"
6
+ import { parseMouseClicks, handleWheel, createMouseDispatch, mouseOob } from "./mouse.mjs"
7
+ import { createKeyHandler, convMaxScroll, clearAttention } from "./key-handler.mjs"
8
+ import { writeStartupSequence, writeLoadingLine, setTuiActive } from "./tui-lifecycle.mjs"
9
+
10
+ /** 输入面(2026-09-22 structure-debt §2.2 · #159)——**二段接口**:
11
+ * ① 早挂载工厂(本函数体 = 输入流 / raw mode / 启动序列 / 解码器 + stdin data 处理器整块);
12
+ * ② 后置挂载入口 `mountKeys(deps)` / `mountMouse(deps)`——构造期读值面(createKeyHandler 构造时
13
+ * 一次解构 ctx / createMouseDispatch 参数解构),须在原址(键盘 :455 · 鼠标 :473)调用,
14
+ * 此时 18 + 5 键全已初始化;早挂载 ctx 以惰性取值器承接 render / pushLine / loadOlder
15
+ * (晚定义名——TDZ 语义逐字保持)。
16
+ * @param {{ state: object, render: Function, pushLine: Function, loadOlder: Function }} ctx
17
+ * @returns {{ keyStream: object, mountKeys: Function, mountMouse: Function }} */
18
+ export function createInputFace(ctx) {
19
+ const { state } = ctx
20
+ // 晚定义名转发(render / pushLine / loadOlder 于 startTUI 后段方初始化——调用点在事件回调内)
21
+ const render = (...a) => ctx.render(...a)
22
+ const pushLine = (...a) => ctx.pushLine(...a)
23
+ const loadOlder = (...a) => ctx.loadOlder(...a)
24
+ // 后置槽:鼠标两路(mountMouse 回填;挂载前 undefined = 原址 TDZ 语义——② 的调用点保持在 try 内)
25
+ let mouseCtx
26
+ let onMouseClick
27
+ // Input stream goes through a filter: mouse sequences (scroll wheel) are intercepted and handled here,
28
+ // stripped clean before passing to keypress parsing, preventing sequence fragments (e.g. "64;72;42M")
29
+ // from leaking into the input box
30
+ const keyStream = new PassThrough()
31
+ let mousePending = "" // incomplete mouse sequence tail spanning chunks
32
+ let lastRenderedScroll = 0
33
+ emitKeypressEvents(keyStream)
34
+ process.stdin.setRawMode(true)
35
+ // Keyboard enhancement — enable BOTH protocols (unsupported terminals ignore them):
36
+ // kitty push (\x1b[>1u): Shift+Enter → \x1b[13;2u (Windows Terminal 1.19+, VS Code, kitty, iTerm2)
37
+ // modifyOtherKeys lvl 2 (\x1b[>4;2m): Shift+Enter → \x1b[27;2;13~ (mintty / Git Bash)
38
+ // translateShiftEnter (stdin layer) maps both to \x1b\r → meta+return → multiline branch.
39
+ writeStartupSequence()
40
+ // 启动加载画面(2026-09-21 用户):冷启动剩余期(memory 同步 / 索引扫描)非黑。
41
+ // 静态读(render-frame 先例——module-load 一次 ✗ 首帧 render 覆盖本行 ✗ 无需清除逻辑)。
42
+ {
43
+ let v = ""
44
+ try { v = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8")).version } catch { /* 尽力面 */ }
45
+ writeLoadingLine(undefined, v)
46
+ }
47
+ setTuiActive(true) // R25(F-R25a):终端接管完成——置 TUI 活动态(崩溃钩子恢复判定源)
48
+
49
+ const utf8Decoder = new TextDecoder("utf-8", { fatal: false })
50
+
51
+ let pasteMode = false
52
+ let pasteAccum = ""
53
+
54
+ process.stdin.on("data", (chunk) => {
55
+ try {
56
+
57
+ let text = mousePending + utf8Decoder.decode(chunk, { stream: true })
58
+ mousePending = ""
59
+
60
+ // 第 33 批(TUI §14.3(e) 鼠标点):stdin 数据到达即用户在场 ⇒ 清 attention 位
61
+ // (单点覆盖下方滚轮分支与 onMouseClick;键盘入口在 key-handler)。
62
+ clearAttention(state, render)
63
+
64
+ // Bracketed paste: terminal wraps pasted text in \x1b[200~ ... \x1b[201~
65
+ // Route pasted content to the active text target (question answer / input box) in one shot,
66
+ // avoiding slow char-by-char keypress render — see insertPastedText in clipboard.mjs
67
+ if (pasteMode) {
68
+ const endIdx = text.indexOf("\x1b[201~")
69
+ if (endIdx >= 0) {
70
+ pasteAccum += text.slice(0, endIdx)
71
+ pasteMode = false
72
+ const pasted = pasteAccum
73
+ pasteAccum = ""
74
+ if (pasted) {
75
+ insertPastedText(state, pasted)
76
+ render()
77
+ }
78
+ text = text.slice(endIdx + 6)
79
+ } else {
80
+ pasteAccum += text
81
+ return
82
+ }
83
+ }
84
+
85
+ // Check for paste start (may appear mid-chunk alongside other input)
86
+ const pasteStartIdx = text.indexOf("\x1b[200~")
87
+ if (pasteStartIdx >= 0) {
88
+ const before = text.slice(0, pasteStartIdx)
89
+ const after = text.slice(pasteStartIdx + 6)
90
+ const endIdx = after.indexOf("\x1b[201~")
91
+ if (endIdx >= 0) {
92
+ // Paste begin and end in the same chunk: insert pasted content directly
93
+ const pasted = after.slice(0, endIdx)
94
+ if (pasted) {
95
+ insertPastedText(state, pasted)
96
+ render()
97
+ }
98
+ text = before + after.slice(endIdx + 6)
99
+ } else {
100
+ // Paste spans multiple chunks: write prefix, enter paste mode
101
+ if (before) keyStream.write(before)
102
+ pasteMode = true
103
+ pasteAccum = after
104
+ return
105
+ }
106
+ }
107
+
108
+ // Scroll wheel: \x1b[<64;col;rowM = up, \x1b[<65;col;rowM = down(3 lines each)
109
+ // 2026-08-31:坐标命中展开块内容行 → 块内滚动(handleWheel);未命中 → 会话滚动(现状)
110
+ for (const m of text.matchAll(/\x1b\[<(\d+);(\d+);(\d+)([Mm])/g)) {
111
+ const button = Number(m[1])
112
+ if (button === 64 || button === 65) {
113
+ // F-3 sane-gate ③(RESIZE-MOUSE-LEAK-FIX):越界 wheel 禁止会话滚动——handleWheel
114
+ // 对越界返回未消费会穿到 fallback 滚动(mouse.mjs 内 gate 覆盖不到此路径)
115
+ const dims = state.dims ? state.dims.get() : { cols: process.stdout.columns || 80, rows: process.stdout.rows || 24 }
116
+ if (mouseOob(Number(m[2]), Number(m[3]), dims)) continue
117
+ const consumed = handleWheel(mouseCtx(), button, Number(m[2]), Number(m[3]))
118
+ if (!consumed) {
119
+ if (button === 64) {
120
+ state.scroll += 3
121
+ state._followTail = false // 2026-08-31:用户上滚 = 暂停流式跟随(不抢视角)
122
+ // 2026-08-31 用户约定修复:滚动到头自动加载(原来只挂 PgUp 键——违约)
123
+ if (state._hasOlder && state.scroll >= convMaxScroll(state)) loadOlder()
124
+ } else {
125
+ state.scroll = Math.max(0, state.scroll - 3)
126
+ if (state.scroll === 0) state._followTail = true // 滚回底部恢复跟随
127
+ }
128
+ }
129
+ }
130
+ }
131
+
132
+ // Left-click: \x1b[<0;col;rowM → picker selection / line action menu
133
+ for (const click of parseMouseClicks(text)) {
134
+ try {
135
+ onMouseClick(click.col, click.row)
136
+ } catch (e) {
137
+ pushLine(`[mouse] ${e.message || e}`, C.error)
138
+ render()
139
+ }
140
+ }
141
+
142
+ // Strip complete mouse sequences; keep incomplete tail for reassembly with next chunk
143
+ text = text.replace(/\x1b\[<\d+;\d+;\d+[Mm]/g, "")
144
+ const tail = text.match(/\x1b\[<[\d;]*$/)
145
+ if (tail) {
146
+ mousePending = tail[0]
147
+ text = text.slice(0, -tail[0].length)
148
+ }
149
+
150
+ // Shift+Enter (keyboard-enhanced terminals) → Alt+Enter path (\x1b\r = meta+return)
151
+ text = translateShiftEnter(text)
152
+ text = stripKeyboardProtocol(text)
153
+
154
+ if (state.scroll !== lastRenderedScroll) {
155
+ lastRenderedScroll = state.scroll
156
+ render()
157
+ }
158
+ if (text) keyStream.write(text)
159
+ } catch (e) {
160
+ pushLine(`[input-error] ${e.message || e}`, C.error)
161
+ }
162
+ })
163
+
164
+ // 后置挂载入口③:键盘接线(原址 :455——18 键 deps 由调用侧按原样构造)
165
+ function mountKeys(deps) {
166
+ // keypress is attached to filtered keyStream: mouse sequences already intercepted and stripped upstream
167
+ const onKeypress = createKeyHandler(deps)
168
+ keyStream.on("keypress", (str, key) => {
169
+ try {
170
+ onKeypress(str, key)
171
+ } catch (e) {
172
+ pushLine(`[input-error] ${e.message || e}`, C.error)
173
+ render()
174
+ }
175
+ })
176
+ }
177
+
178
+ // 后置挂载入口④:鼠标 dispatch(原址 :473——5 键 deps)
179
+ function mountMouse(deps) {
180
+ // 鼠标点击/滚轮 ctx 装配(mouse.mjs createMouseDispatch——D-S1a:cancelSubagent/onMouseClick/mouseCtx)
181
+ const dispatch = createMouseDispatch(deps)
182
+ mouseCtx = dispatch.mouseCtx
183
+ onMouseClick = dispatch.onMouseClick
184
+ }
185
+
186
+ return { keyStream, mountKeys, mountMouse }
187
+ }
@@ -0,0 +1,42 @@
1
+ import { C } from "./ansi.mjs"
2
+
3
+ /** busy 门禁(2026-09-22 structure-debt §2.1 分族 4 · #226):processing(含 digest)期
4
+ * Enter 单槽受理 + 吞面四(模态 / 斜杠 / 空 / 槽满)+ 槽满提示 + 挂起面唤醒,Tab 恒吞;
5
+ * 块体自 key-handler.mjs createKeyHandler 逐字搬移(仅去缩进;函数体自持原块入口判定)。
6
+ * 路由 = 分派器 createKeyHandler(守卫 = busy 块内返回条件:tab / 无 meta 的 Enter、回车)。 */
7
+ export function handleBusyEnter(str, key, state, ctx) {
8
+ const { pushLine, render } = ctx
9
+ if (state.processing) {
10
+ // busy 门禁(INPUT-LOCK C'——2026-09-09 + F16 busy 单槽注入——2026-09-21 · busy-extend
11
+ // 扩面 2026-09-22):processing(含 digest——单一判据)输入不禁——打字照常回显;Enter
12
+ // 提交 = 单槽注入(放行判据五条 = TUI-INPUT-BOX.md §4.1:非模态 · 非斜杠 · 非空 · 槽空
13
+ // ⇒ 清框入 pendingInput——回合尾 drain / 挂起 driver 送达;挂起两态不再是吞面);
14
+ // 吞面四 = 模态 / 斜杠 / 空 / 槽满(吞 + 提示,文本保留;斜杠同禁发——白名单已删);
15
+ // 多行换行(meta/enter——编辑)照常放行;Tab 仍吞。**第 31 批**:↑↓ 分流见下方三规则块。
16
+ if (key.name === "tab") return
17
+ if ((key.name === "return" && !key.meta) || (str === "\r" && !key.meta)) {
18
+ const text = state.input.join("").trim()
19
+ if (!text) return // 条件 4:空 Enter 静默(既有)
20
+ state.pendingInput ??= []
21
+ // 条件 2/3 不满足(模态 / 斜杠——模态分派在前,此处按判据表如实守卫)→ 吞 + busy 提示
22
+ if (text.startsWith("/") || state.permission || state.question) {
23
+ pushLine(`[主会话处理中 —— 消息未发送(回合结束后请重按 Enter)]`, C.warn)
24
+ render(); return
25
+ }
26
+ // 条件 5:单槽满 = 拒绝 + 提示 + 文本保留(二次提交裁定——与挂起态槽满分支逐字同构)
27
+ if (state.pendingInput.length > 0) {
28
+ pushLine(`[主会话处理中 —— 已有一条消息待发送,请等其处理完成后再发送]`, C.warn)
29
+ render(); return
30
+ }
31
+ // 入槽(判据全满足):清框 + history 收录 + 草稿复位 + 单槽填充(§4.1 执行序)
32
+ state.input = []; state.cursor = 0
33
+ state.history.push(text)
34
+ state.historyIndex = -1
35
+ state._draft = null
36
+ state.pendingInput.push(text)
37
+ // 挂起面入槽同款唤醒(§4.1)——busy 期该槽恒 null ⇒ 零动作;普通 busy 面零唤醒
38
+ if (state.suspended || state._suspPending) state._suspWake?.()
39
+ render(); return
40
+ }
41
+ }
42
+ }