thincoder 0.12.64 → 0.12.66

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