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/CHANGELOG.md +42 -0
- package/README.md +132 -79
- package/bin/thincoder.mjs +38 -12
- package/package.json +3 -3
- package/src/acp/handlers-session.mjs +42 -1
- package/src/acp/handlers-slots.mjs +1 -1
- package/src/cli/make-agent.mjs +17 -16
- package/src/tui/agent-turn.mjs +5 -2
- package/src/tui/cmd-advisor.mjs +1 -1
- package/src/tui/cmd-eng.mjs +12 -1
- package/src/tui/cmd-mcp-form.mjs +11 -5
- package/src/tui/cmd-new.mjs +1 -1
- package/src/tui/cmd-plan.mjs +9 -0
- package/src/tui/conversation-writer.mjs +62 -0
- package/src/tui/index.mjs +43 -297
- package/src/tui/input-face.mjs +187 -0
- package/src/tui/key-handler-busy.mjs +42 -0
- package/src/tui/key-handler-ctrlc.mjs +118 -0
- package/src/tui/key-handler-edit.mjs +115 -0
- package/src/tui/key-handler-modals.mjs +87 -0
- package/src/tui/key-handler-scroll.mjs +110 -0
- package/src/tui/key-handler.mjs +32 -382
- package/src/tui/ledger-surface.mjs +14 -67
- package/src/tui/render-frame.mjs +13 -4
- package/src/tui/subagent-freeze.mjs +3 -3
- package/src/tui/suspension-drive.mjs +35 -24
- package/src/tui/tui-lifecycle.mjs +9 -0
- package/src/tui/tui-state.mjs +65 -0
- package/src/tui/turn-face.mjs +64 -0
- package/src/tui/wrapped-spawn.mjs +6 -2
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 {
|
|
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 {
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
import {
|
|
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
|
-
//
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
-
//
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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: () => {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|