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/key-handler.mjs
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { readClipboardText, insertPastedText } from "./clipboard.mjs"
|
|
3
|
-
import { computeLayout, inputContentWidth } from "./layout.mjs"
|
|
4
|
-
import { moveCursorVertical } from "./render.mjs"
|
|
1
|
+
import { computeLayout } from "./layout.mjs"
|
|
5
2
|
import { handleSearchKey } from "./key-handler-search.mjs"
|
|
6
3
|
import { countConvLines } from "./render-conversation.mjs"
|
|
7
4
|
import { handlePermissionMode, handleQuestionMode, handleInterruptMode } from "./key-modes.mjs"
|
|
5
|
+
import { handleCtrlCFamily } from "./key-handler-ctrlc.mjs"
|
|
6
|
+
import { handlePickerKeys, handleWizardKeys } from "./key-handler-modals.mjs"
|
|
7
|
+
import { handleScrollKeys, handleHistoryKeys, handleCursorKeys } from "./key-handler-scroll.mjs"
|
|
8
|
+
import { handleBusyEnter } from "./key-handler-busy.mjs"
|
|
9
|
+
import { handleEditKeys } from "./key-handler-edit.mjs"
|
|
8
10
|
|
|
9
11
|
/** 第 33 批(TUI §14.3(e)——attention 清位):输入即在场——仅当原值为真时置假 + `render()`
|
|
10
12
|
* (已假则零副作用、零重绘)。键盘入口与本文件(`onKeypress`)与鼠标输入路径(index.mjs
|
|
@@ -28,12 +30,15 @@ export function convMaxScroll(state) {
|
|
|
28
30
|
|
|
29
31
|
/** Keyboard event dispatch: permission confirm / question / picker / wizard / edit / scroll / history / paste.
|
|
30
32
|
* Extracted from index.mjs.
|
|
33
|
+
* 2026-09-22 structure-debt §2.1(#226):五族块体迁出为 key-handler-{ctrlc,modals,scroll,busy,edit}.mjs
|
|
34
|
+
* (逐字搬移);本档保留 attention 清位 / 三模态前置委派 / F1 表 / Ctrl+I 入口 / interrupt 模态 +
|
|
35
|
+
* 五族顺序委派(委派守卫 = 原块入口条件;族体自持同一入口判定,可直接调用)。
|
|
31
36
|
* ctx: { agent, state, render, renderPickerLines, popPicker,
|
|
32
37
|
* handleSlash, handleTab, submit, pasteClipboardImage,
|
|
33
38
|
* wizardChooseProvider, wizardSubmitText, cancelWizard, wizardProviderItems,
|
|
34
39
|
* renderWizard, pushLine, cleanup, showPicker } */
|
|
35
40
|
export function createKeyHandler(ctx) {
|
|
36
|
-
const { agent, state, render,
|
|
41
|
+
const { agent, state, render, pushLine, showPicker } = ctx
|
|
37
42
|
|
|
38
43
|
return function onKeypress(str, key = {}) {
|
|
39
44
|
// 第 33 批(TUI §14.3(e) 键盘点):模态分派**之前**——任意按键 = 用户在场 ⇒ 清 attention
|
|
@@ -50,109 +55,9 @@ export function createKeyHandler(ctx) {
|
|
|
50
55
|
// Search mode: Ctrl+F to enter, Ctrl+N/Ctrl+P (or Ctrl+G/Ctrl+R) navigate, Esc exit
|
|
51
56
|
if (handleSearchKey(str, key, state, render)) return
|
|
52
57
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
popPicker(null)
|
|
57
|
-
return
|
|
58
|
-
}
|
|
59
|
-
// §17.6(2026-09-03):武装窗口内第二次 Ctrl+C = 显式全停(D-C4 /abort 语义)。
|
|
60
|
-
// 在状态路由前检查(picker 取消分支例外——picker 打开时 Ctrl+C 语义 = 取消
|
|
61
|
-
// picker,武装让位;场景极窄——3s 窗口内需另有 picker 被打开)——两次按下之间
|
|
62
|
-
// 状态会迁移(首按停回合 → 释放窗口/挂起会话启动),武装必须跨态桥接:只查挂起
|
|
63
|
-
// 分支会让非挂起 processing 首按后的二按落入空闲退出分支(三按误退出应用)。
|
|
64
|
-
if (state.suspAbortArmed) {
|
|
65
|
-
if (ctx.suspArmTimer) clearTimeout(ctx.suspArmTimer)
|
|
66
|
-
state.suspAbortArmed = false
|
|
67
|
-
// 无可停目标(无 processing 回合 / 无会话 abort 集合 / 未挂起)时不吞键:
|
|
68
|
-
// 首按停的是普通回合(无后台池)——二按落回下方空闲退出分支(exitArmed 双
|
|
69
|
-
// 确认),不打印误导性的 "[Aborting background subagents…]"(advisor round1
|
|
70
|
-
// 🟡——停回合后连按退出的第二次按下被空转吞掉 → 退出从 3 按变 4 按)。
|
|
71
|
-
const hasStopTarget = (state.processing && state.controller) ||
|
|
72
|
-
(agent._sessionAbortAll?.length ?? 0) > 0 ||
|
|
73
|
-
(agent._sessionAbort != null && !agent._sessionAbort.signal.aborted) ||
|
|
74
|
-
state.suspended
|
|
75
|
-
if (hasStopTarget) {
|
|
76
|
-
// 当前回合平 abort(无 interrupt——命中 agent.mjs 回合收尾清池分支——全停)
|
|
77
|
-
// §20.3 站点 #11(第 24 批):整批 / 会话级停 = stop(reason 载荷)
|
|
78
|
-
if (state.processing && state.controller) state.controller.abort({ abortTrigger: "stop", abortDetail: "session-stop" })
|
|
79
|
-
// 会话 abort 集合 = 链条内全部 controller(含 Ctrl+I/ContinueError 重建的旧
|
|
80
|
-
// controller——children 不逃逸,round1 偏差 #3)。挂起态才标记 _suspAborted +
|
|
81
|
-
// 唤醒 driver(driver 收尾清池)——非挂起语境置位会粘滞阻塞未来挂起会话重入
|
|
82
|
-
// (round2 偏差 #1 语义)。
|
|
83
|
-
for (const c of agent._sessionAbortAll ?? (agent._sessionAbort ? [agent._sessionAbort] : [])) c?.abort({ abortTrigger: "stop", abortDetail: "session-stop" })
|
|
84
|
-
if (state.suspended) {
|
|
85
|
-
state._suspAborted = true
|
|
86
|
-
state._suspWake?.()
|
|
87
|
-
}
|
|
88
|
-
pushLine("[Aborting background subagents…]", C.warn)
|
|
89
|
-
render()
|
|
90
|
-
return
|
|
91
|
-
}
|
|
92
|
-
// 无目标可停 → 落空继续到下方分支(processing 已停 → 空闲态 exitArmed 双确认)
|
|
93
|
-
}
|
|
94
|
-
if (state.suspended) {
|
|
95
|
-
// §17 D-S9 + round2 偏差 #4(2026-09-02)+ §17.6 修订(2026-09-03):挂起态
|
|
96
|
-
// Ctrl+C = 武装窗口两级中止——一次按键直接清池中止全部后台子代理的误触代价高
|
|
97
|
-
// (digest 刷屏时用户可能只想停住当前回合);仿空闲态退出武装语义(同下方空闲态
|
|
98
|
-
// exitArmed 双确认分支)。§17.6 修订 ①:中止当前回合的 abort 带 interrupt
|
|
99
|
-
// (无 message)——此前平 abort 命中 agent.mjs 回合收尾清池分支(aborted &&
|
|
100
|
-
// !reason.interrupt → 无条件 _asyncSubagents.clear()),一次首按即误杀全部后台
|
|
101
|
-
// 子代理(2026-09-03 用户两次实测——提示"再按才杀"与实际"一次就全杀"不符);
|
|
102
|
-
// interrupt 排除清池分支——会话与后台保留(agent-turn 区分:无 message 停回合
|
|
103
|
-
// 不续跑):
|
|
104
|
-
// ① 未武装 + 有回合在跑(digest 消化/会话内回合):仅中止当前回合
|
|
105
|
-
// (controller.abort({ interrupt: true }))——会话继续、后台子代理不受
|
|
106
|
-
// 影响,回挂起等待;
|
|
107
|
-
// ② 未武装 + 纯挂起等待:仅提示不清池;
|
|
108
|
-
// ③ 3s 武装窗口内再次 Ctrl+C:彻底中止——上方统一块(二按语义不变)。
|
|
109
|
-
if (state.processing && state.controller) {
|
|
110
|
-
state.controller.abort({ interrupt: true }) // ① 仅中止当前回合(digest/会话内回合),不碰后台池
|
|
111
|
-
pushLine("[stopped current turn — press Ctrl+C again within 3s to abort all background subagents]", C.warn)
|
|
112
|
-
} else {
|
|
113
|
-
const bgActive = [...(agent._asyncSubagents?.values() ?? [])].filter((e) => e.status === "running").length + (agent._asyncQueue?.length ?? 0)
|
|
114
|
-
pushLine(`[abort] Press Ctrl+C again within 3s to abort all background subagents (${bgActive} running)`, C.warn)
|
|
115
|
-
}
|
|
116
|
-
state.suspAbortArmed = true
|
|
117
|
-
if (ctx.suspArmTimer) clearTimeout(ctx.suspArmTimer)
|
|
118
|
-
ctx.suspArmTimer = setTimeout(() => { state.suspAbortArmed = false }, ctx.exitArmDelay ?? 3000)
|
|
119
|
-
ctx.suspArmTimer.unref?.()
|
|
120
|
-
render()
|
|
121
|
-
return
|
|
122
|
-
}
|
|
123
|
-
if (state.processing && state.controller) {
|
|
124
|
-
// §17.6 D-C1(2026-09-03 紧急修复——processing 态曾无武装窗口,第一按直接平
|
|
125
|
-
// abort() → 命中 agent.mjs 回合收尾清池分支 → 一次 Ctrl+C 误杀全部后台子代理,
|
|
126
|
-
// 用户两次实测被坑):首按 = interrupt 语义(无 message——停当前回合不续跑——
|
|
127
|
-
// interrupt 排除 agent.mjs 清池分支——后台池保留——agent-turn 区分不重建续跑)
|
|
128
|
-
// + 武装 3s(复用 suspArmTimer/exitArmDelay——与挂起态同构——过期自动复位);
|
|
129
|
-
// 窗口内再按 = 上方统一块全停(平 abort → agent.mjs 清池)。
|
|
130
|
-
state.controller.abort({ interrupt: true })
|
|
131
|
-
pushLine("[stopped current turn — press Ctrl+C again within 3s to abort all background subagents]", C.warn)
|
|
132
|
-
state.suspAbortArmed = true
|
|
133
|
-
if (ctx.suspArmTimer) clearTimeout(ctx.suspArmTimer)
|
|
134
|
-
ctx.suspArmTimer = setTimeout(() => { state.suspAbortArmed = false }, ctx.exitArmDelay ?? 3000)
|
|
135
|
-
ctx.suspArmTimer.unref?.()
|
|
136
|
-
render()
|
|
137
|
-
return
|
|
138
|
-
}
|
|
139
|
-
// 防误触:空闲态第一次 Ctrl+C 仅提示并武装,窗口内再按才真正退出
|
|
140
|
-
if (!state.exitArmed) {
|
|
141
|
-
state.exitArmed = true
|
|
142
|
-
if (ctx.exitArmTimer) clearTimeout(ctx.exitArmTimer)
|
|
143
|
-
ctx.exitArmTimer = setTimeout(() => { state.exitArmed = false }, ctx.exitArmDelay ?? 3000)
|
|
144
|
-
ctx.exitArmTimer.unref?.()
|
|
145
|
-
pushLine("[exit] Press Ctrl+C again within 3s to exit", C.warn)
|
|
146
|
-
render()
|
|
147
|
-
return
|
|
148
|
-
}
|
|
149
|
-
if (ctx.exitArmTimer) clearTimeout(ctx.exitArmTimer)
|
|
150
|
-
cleanup()
|
|
151
|
-
// 延迟退出可注入(测试传大值并清理定时器,避免定时器在 mock 恢复后调到真 process.exit)
|
|
152
|
-
ctx.exitTimer = setTimeout(() => process.exit(0), ctx.exitDelay ?? 100)
|
|
153
|
-
ctx.exitTimer.unref?.()
|
|
154
|
-
return // review #5 fix: exiting — don't fall through to later branches
|
|
155
|
-
}
|
|
58
|
+
// 序 1:Ctrl+C 族(key-handler-ctrlc.mjs)——picker 取消 / 武装窗口全停 / 挂起态两级中止 /
|
|
59
|
+
// 回合 interrupt / 空闲退出武装(守卫 = 原块入口条件)
|
|
60
|
+
if (key.ctrl && key.name === "c") { handleCtrlCFamily(str, key, ctx); return }
|
|
156
61
|
|
|
157
62
|
// F1: 显示快捷键帮助
|
|
158
63
|
if (key.name === "f1" && !state.picker && !state.permission && !state.question) {
|
|
@@ -186,289 +91,34 @@ export function createKeyHandler(ctx) {
|
|
|
186
91
|
// key-modes.mjs handleInterruptMode(D-S4)
|
|
187
92
|
if (handleInterruptMode(str, key, { state, pushLine, render })) return
|
|
188
93
|
|
|
189
|
-
//
|
|
190
|
-
if (state.picker) {
|
|
191
|
-
const p = state.picker
|
|
192
|
-
const items = p.filteredItems ?? p.entries.filter((e) => e.type === "item")
|
|
193
|
-
// 可视窗高度:直接取 layout 算出的实际 picker 面板高(含小终端 pickerFinalH 压缩),减标题行。
|
|
194
|
-
// 单一数据源,避免与 layout.mjs 公式漂移
|
|
195
|
-
const winH = Math.max(1, (computeLayout(state, { cols: (state.dims?.get() ?? {}).cols ?? (process.stdout.columns || 80), rows: (state.dims?.get() ?? {}).rows ?? ((state.dims?.get() ?? {}).rows ?? (process.stdout.rows || 24)) }).panels.picker?.h ?? p.lines.length + 1) - 1)
|
|
196
|
-
const applyFilter = (f) => {
|
|
197
|
-
p.filter = f
|
|
198
|
-
p.index = 0
|
|
199
|
-
p.scroll = 0
|
|
200
|
-
renderPickerLines()
|
|
201
|
-
}
|
|
202
|
-
if (key.name === "escape") {
|
|
203
|
-
popPicker(null)
|
|
204
|
-
} else if (key.name === "up" && items.length) {
|
|
205
|
-
p.index = (p.index - 1 + items.length) % items.length
|
|
206
|
-
renderPickerLines()
|
|
207
|
-
} else if (key.name === "down" && items.length) {
|
|
208
|
-
p.index = (p.index + 1) % items.length
|
|
209
|
-
renderPickerLines()
|
|
210
|
-
} else if (key.name === "pageup" && items.length) {
|
|
211
|
-
p.index = Math.max(0, p.index - winH)
|
|
212
|
-
renderPickerLines()
|
|
213
|
-
} else if (key.name === "pagedown" && items.length) {
|
|
214
|
-
p.index = Math.min(items.length - 1, p.index + winH)
|
|
215
|
-
renderPickerLines()
|
|
216
|
-
} else if (key.name === "home" && items.length) {
|
|
217
|
-
p.index = 0
|
|
218
|
-
renderPickerLines()
|
|
219
|
-
} else if (key.name === "end" && items.length) {
|
|
220
|
-
p.index = items.length - 1
|
|
221
|
-
renderPickerLines()
|
|
222
|
-
} else if (key.name === "backspace") {
|
|
223
|
-
if (p.filter) applyFilter(p.filter.slice(0, -1))
|
|
224
|
-
} else if ((key.name === "return" || key.name === "enter" || str === "\r") && items.length) {
|
|
225
|
-
popPicker(items[p.index]) // 选中即关闭
|
|
226
|
-
} else if (str && !key.ctrl && !key.meta) {
|
|
227
|
-
// 输入即过滤;粘贴的多行文本先去换行(与输入框清洗口径一致),仍含控制字符则整段丢弃
|
|
228
|
-
const text = str.replace(/[\r\n]+/g, "")
|
|
229
|
-
if (text && !/[\x00-\x1f\x7f]/.test(text)) applyFilter(p.filter + text)
|
|
230
|
-
}
|
|
231
|
-
return
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
// initial config wizard: menu step ↑↓/Enter/Esc; text step Enter submit, Esc cancel, edit keys fall through to normal input
|
|
94
|
+
// 序 2:模态族(key-handler-modals.mjs)——picker 导航 / 初始配置 wizard
|
|
95
|
+
if (state.picker) { handlePickerKeys(str, key, ctx); return }
|
|
235
96
|
if (state.wizard) {
|
|
97
|
+
// 守卫 = wizard 块内返回分支(provider 步恒返回;文本步仅 escape / return / ↑↓ / PgUp·PgDn 返回,
|
|
98
|
+
// 其余编辑键落空至编辑族 —— 守卫项与 key-handler-modals.mjs 块内返回点逐条对应)
|
|
236
99
|
const w = state.wizard
|
|
237
|
-
if (key.name === "escape"
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
}
|
|
241
|
-
if (w.step === "provider") {
|
|
242
|
-
const items = wizardProviderItems()
|
|
243
|
-
if (key.name === "up" && items.length) {
|
|
244
|
-
w.index = (w.index - 1 + items.length) % items.length
|
|
245
|
-
renderWizard()
|
|
246
|
-
} else if (key.name === "down" && items.length) {
|
|
247
|
-
w.index = (w.index + 1) % items.length
|
|
248
|
-
renderWizard()
|
|
249
|
-
} else if ((key.name === "return" || key.name === "enter" || str === "\r") && items.length) {
|
|
250
|
-
wizardChooseProvider(items[w.index])
|
|
251
|
-
}
|
|
252
|
-
return
|
|
253
|
-
}
|
|
254
|
-
if (key.name === "return") {
|
|
255
|
-
wizardSubmitText()
|
|
100
|
+
if (w.step === "provider" || key.name === "escape" || key.name === "return" ||
|
|
101
|
+
key.name === "up" || key.name === "down" || key.name === "pageup" || key.name === "pagedown") {
|
|
102
|
+
handleWizardKeys(str, key, ctx)
|
|
256
103
|
return
|
|
257
104
|
}
|
|
258
|
-
// text steps: block scroll/history, remaining edit keys fall through to normal input logic below
|
|
259
|
-
if (key.name === "up" || key.name === "down" || key.name === "pageup" || key.name === "pagedown") return
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
// page scroll
|
|
263
|
-
if (key.name === "pageup") {
|
|
264
|
-
// At the top of the conversation → load the next earlier history page
|
|
265
|
-
// (lazy restore, parity with VS Code's scroll-back loadOlder).
|
|
266
|
-
if (state._hasOlder && loadOlder && state.scroll >= convMaxScroll(state)) {
|
|
267
|
-
loadOlder()
|
|
268
|
-
} else {
|
|
269
|
-
state.scroll += Math.max(1, ((state.dims?.get() ?? {}).rows ?? (process.stdout.rows || 24)) - 8)
|
|
270
|
-
}
|
|
271
|
-
state._followTail = false // 2026-08-31:用户上滚 = 暂停流式跟随
|
|
272
|
-
render()
|
|
273
|
-
return
|
|
274
|
-
}
|
|
275
|
-
if (key.name === "pagedown") {
|
|
276
|
-
state.scroll = Math.max(0, state.scroll - Math.max(1, ((state.dims?.get() ?? {}).rows ?? (process.stdout.rows || 24)) - 8))
|
|
277
|
-
if (state.scroll === 0) state._followTail = true // 滚回底部恢复跟随
|
|
278
|
-
render()
|
|
279
|
-
return
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
if (state.processing) {
|
|
283
|
-
// busy 门禁(INPUT-LOCK C'——2026-09-09 + INPUT-LOCK-BEHAVIOR-REVISED——2026-09-09):
|
|
284
|
-
// processing(含 digest——单一判据)输入不禁——吞提交不吞字符(打字照常回显);Enter
|
|
285
|
-
// 提交吞 + busy 提示——斜杠同禁发(白名单已删——/exit 也发不出——退出靠 Ctrl+C 终端
|
|
286
|
-
// 层通道——门禁前不误伤);空 Enter 静默(text 非空才吞);多行换行
|
|
287
|
-
// (meta/enter——编辑)照常放行。**第 31 批**:↑↓ 不再在此全屏蔽——分流下沉到下方
|
|
288
|
-
// 三规则块(竖移放行 / 历史导航禁;TUI-INPUT-BOX.md §3);Tab 仍吞(死条件在案——原样保留)。
|
|
289
|
-
if (key.name === "tab") return
|
|
290
|
-
const isSend = (key.name === "return" && !key.meta) || (str === "\r" && !key.meta)
|
|
291
|
-
if (isSend && state.input.join("").trim()) {
|
|
292
|
-
pushLine(`[主会话处理中 —— 消息未发送(回合结束后请重按 Enter)]`, C.warn)
|
|
293
|
-
render()
|
|
294
|
-
return
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
// Tab: slash-command completion (cycle candidates); other input ignored (\t would blow up input box, never inserted directly)
|
|
299
|
-
if (key.name === "tab") {
|
|
300
|
-
handleTab()
|
|
301
|
-
return
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
// Ctrl+U: clear entire input box
|
|
305
|
-
if ((key.name === "u" && key.ctrl) || str === "\x15") {
|
|
306
|
-
if (state.historyIndex !== -1) state._draft = [...state.input]
|
|
307
|
-
state.input = []
|
|
308
|
-
state.cursor = 0
|
|
309
|
-
render()
|
|
310
|
-
return
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
// ↑/↓ 三规则(TUI-INPUT-BOX.md §3——第 31 批):① 翻历史中恒历史(多行条目不竖移)
|
|
314
|
-
// ② 竖移优先(可视行口径——折行与 \n 同权;显示列保持 + 短行端钳制)③ 边界回落历史(↑)
|
|
315
|
-
// /无动作(↓)。processing 期:竖移放行(纯编辑——与字符/退格/←→ 同权)、历史导航禁
|
|
316
|
-
// (规则 ①/③ 的历史分支吞——不进入、不切换)。
|
|
317
|
-
if (key.name === "up" || key.name === "down") {
|
|
318
|
-
if (state.historyIndex === -1) { // 规则 ① 优先——翻历史中不竖移
|
|
319
|
-
const cols = (state.dims?.get() ?? {}).cols ?? (process.stdout.columns || 80)
|
|
320
|
-
const next = moveCursorVertical(state.input, state.cursor, inputContentWidth(cols), key.name)
|
|
321
|
-
if (next !== null) {
|
|
322
|
-
state.cursor = next // 规则 ②:竖移(草稿/历史指针零涉——D-31.6)
|
|
323
|
-
render()
|
|
324
|
-
return
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
|
-
if (state.processing) return // 规则 ①/③ 的历史分支吞——processing 期历史导航禁
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
// input history(规则 ① 恒历史 + 规则 ③ 边界回落——历史语义零改)
|
|
331
|
-
if (key.name === "up") {
|
|
332
|
-
if (state.history.length) {
|
|
333
|
-
// Save current input as draft when entering history mode (historyIndex === -1).
|
|
334
|
-
// Subsequent edits while in history mode are saved separately (see printable/editing handlers).
|
|
335
|
-
if (state.historyIndex === -1) {
|
|
336
|
-
state._draft = [...state.input]
|
|
337
|
-
}
|
|
338
|
-
state.historyIndex = state.historyIndex === -1 ? state.history.length - 1 : Math.max(0, state.historyIndex - 1)
|
|
339
|
-
state.input = [...state.history[state.historyIndex]]
|
|
340
|
-
state.cursor = state.input.length
|
|
341
|
-
render()
|
|
342
|
-
}
|
|
343
|
-
return
|
|
344
|
-
}
|
|
345
|
-
if (key.name === "down") {
|
|
346
|
-
if (state.historyIndex !== -1) {
|
|
347
|
-
state.historyIndex++
|
|
348
|
-
if (state.historyIndex >= state.history.length) {
|
|
349
|
-
state.historyIndex = -1
|
|
350
|
-
// Restore the stashed draft instead of wiping back to blank
|
|
351
|
-
state.input = state._draft ? [...state._draft] : []
|
|
352
|
-
state._draft = null
|
|
353
|
-
} else {
|
|
354
|
-
state.input = [...state.history[state.historyIndex]]
|
|
355
|
-
}
|
|
356
|
-
state.cursor = state.input.length
|
|
357
|
-
render()
|
|
358
|
-
}
|
|
359
|
-
return
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
// cursor movement
|
|
363
|
-
if (key.name === "left") {
|
|
364
|
-
state.cursor = Math.max(0, state.cursor - 1)
|
|
365
|
-
render()
|
|
366
|
-
return
|
|
367
|
-
}
|
|
368
|
-
if (key.name === "right") {
|
|
369
|
-
state.cursor = Math.min(state.input.length, state.cursor + 1)
|
|
370
|
-
render()
|
|
371
|
-
return
|
|
372
|
-
}
|
|
373
|
-
if (key.name === "home") {
|
|
374
|
-
state.cursor = 0
|
|
375
|
-
render()
|
|
376
|
-
return
|
|
377
|
-
}
|
|
378
|
-
if (key.name === "end") {
|
|
379
|
-
state.cursor = state.input.length
|
|
380
|
-
render()
|
|
381
|
-
return
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
// editing
|
|
385
|
-
if (key.name === "backspace") {
|
|
386
|
-
if (state.cursor > 0) {
|
|
387
|
-
state.input.splice(state.cursor - 1, 1)
|
|
388
|
-
state.cursor--
|
|
389
|
-
if (state.historyIndex !== -1) state._draft = [...state.input]
|
|
390
|
-
render()
|
|
391
|
-
}
|
|
392
|
-
return
|
|
393
|
-
}
|
|
394
|
-
if (key.name === "delete") {
|
|
395
|
-
if (state.cursor < state.input.length) {
|
|
396
|
-
state.input.splice(state.cursor, 1)
|
|
397
|
-
if (state.historyIndex !== -1) state._draft = [...state.input]
|
|
398
|
-
render()
|
|
399
|
-
}
|
|
400
|
-
return
|
|
401
|
-
}
|
|
402
|
-
if (key.name === "return" || key.name === "enter" || str === "\r") {
|
|
403
|
-
// Multiline newline (docs/design/TUI-INPUT-BOX.md §1.5): Alt+Enter / Shift+Enter
|
|
404
|
-
// (translateShiftEnter) / Ctrl+J — the universal no-protocol fallback.
|
|
405
|
-
if ((key.name === "return" && key.meta) || key.name === "enter") {
|
|
406
|
-
state.input.splice(state.cursor, 0, "\n")
|
|
407
|
-
state.cursor++
|
|
408
|
-
render()
|
|
409
|
-
} else {
|
|
410
|
-
const text = state.input.join("").trim()
|
|
411
|
-
// §17 D-S5/F3/F7 + 偏差 #1 + INPUT-LOCK 单槽化(F-6——2026-09-09):挂起态
|
|
412
|
-
// (suspended 或释放窗口 _suspPending)Enter = 新回合输入(非打断)——填
|
|
413
|
-
// pendingInput 单槽(至多一条待交接——busy 提交吞在 L265 门禁先拦,digest 期
|
|
414
|
-
// 不会到这——仅挂起空闲/释放窗口触达)由挂起会话调度;输入框零干扰(F3)。
|
|
415
|
-
// 槽满(竞态防御——不覆盖不丢失)→ 吞 + 提示,文本保留在输入框。
|
|
416
|
-
if ((state.suspended || state._suspPending) && text && !text.startsWith("/")) {
|
|
417
|
-
state.pendingInput ??= []
|
|
418
|
-
if (state.pendingInput.length > 0) {
|
|
419
|
-
pushLine(`[主会话处理中 —— 已有一条消息待发送,请等其处理完成后再发送]`, C.warn)
|
|
420
|
-
render()
|
|
421
|
-
return
|
|
422
|
-
}
|
|
423
|
-
state.input = []
|
|
424
|
-
state.cursor = 0
|
|
425
|
-
state.history.push(text)
|
|
426
|
-
state.historyIndex = -1
|
|
427
|
-
state._draft = null
|
|
428
|
-
state.pendingInput.push(text)
|
|
429
|
-
state._suspWake?.()
|
|
430
|
-
render()
|
|
431
|
-
return
|
|
432
|
-
}
|
|
433
|
-
submit().catch((e) => pushLine(`[error] ${e.message}`, C.error))
|
|
434
|
-
}
|
|
435
|
-
return
|
|
436
105
|
}
|
|
437
106
|
|
|
438
|
-
//
|
|
439
|
-
//
|
|
440
|
-
|
|
441
|
-
if (key.
|
|
442
|
-
|
|
443
|
-
const text = await readClipboardText()
|
|
444
|
-
if (text) {
|
|
445
|
-
insertPastedText(state, text)
|
|
446
|
-
render()
|
|
447
|
-
}
|
|
448
|
-
})()
|
|
449
|
-
return
|
|
450
|
-
}
|
|
107
|
+
// 序 3:scroll 族(key-handler-scroll.mjs)——PgUp/PgDn 翻页(含 loadOlder 边界加载)/
|
|
108
|
+
// ↑↓ 三规则 + 历史导航 / ←→·Home·End 光标
|
|
109
|
+
if (key.name === "pageup" || key.name === "pagedown") { handleScrollKeys(key, ctx); return }
|
|
110
|
+
if (key.name === "up" || key.name === "down") { handleHistoryKeys(key, ctx); return }
|
|
111
|
+
if (key.name === "left" || key.name === "right" || key.name === "home" || key.name === "end") { handleCursorKeys(key, ctx); return }
|
|
451
112
|
|
|
452
|
-
//
|
|
453
|
-
//
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
// silently did nothing. Accept meta (and alt, for terminals that set it); the text-paste
|
|
457
|
-
// branch above excludes meta so Ctrl+Alt+V reaches here.
|
|
458
|
-
const isPasteImage = key.name === "v" && (key.alt || key.meta)
|
|
459
|
-
if (isPasteImage) {
|
|
460
|
-
pasteClipboardImage(agent).catch((e) => pushLine(`[error] ${e.message}`, C.error))
|
|
113
|
+
// 序 4:busy 门禁(key-handler-busy.mjs)——Enter 单槽受理 + 吞面四 / Tab 吞
|
|
114
|
+
// (守卫 = busy 块内返回条件:processing 期仅 tab 与无 meta 的 Enter/回车 被吞,余键落空至编辑族)
|
|
115
|
+
if (state.processing && (key.name === "tab" || ((key.name === "return" && !key.meta) || (str === "\r" && !key.meta)))) {
|
|
116
|
+
handleBusyEnter(str, key, state, ctx)
|
|
461
117
|
return
|
|
462
118
|
}
|
|
463
119
|
|
|
464
|
-
//
|
|
465
|
-
//
|
|
466
|
-
|
|
467
|
-
const chars = [...str.replace(/[\r\n]+/g, "").replace(/\t/g, " ")]
|
|
468
|
-
state.input.splice(state.cursor, 0, ...chars)
|
|
469
|
-
state.cursor += chars.length
|
|
470
|
-
if (state.historyIndex !== -1) state._draft = [...state.input]
|
|
471
|
-
render()
|
|
472
|
-
}
|
|
120
|
+
// 序 5:编辑族(key-handler-edit.mjs)——Tab 补全 / Ctrl+U 清框 / 退格·删除 / Enter 提交 /
|
|
121
|
+
// 剪贴板文本与图片粘贴 / 可打印字符(末族:其后无可达分支)
|
|
122
|
+
handleEditKeys(str, key, ctx)
|
|
473
123
|
}
|
|
474
124
|
}
|
|
@@ -1,85 +1,32 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* ledger-surface.mjs — TUI 台账可见面胶水(
|
|
2
|
+
* ledger-surface.mjs — TUI 台账可见面胶水(K-LX3 归核:实现单源 = `@thincoder/core/ledger-surface.mjs`,
|
|
3
|
+
* 本档只剩**动态 import + TUI 色表注入**两件事——端壳静态链不得达 `node:sqlite`(W8 契约②);
|
|
4
|
+
* 载入失败 = 降级不崩(N1),扫描拍重试)。
|
|
3
5
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* 常驻标记,`render-frame.mjs` 消费;**不受启动行门约束**——F2 按标记态照显)。
|
|
7
|
-
* 只读台账;唯一写面 = 去重档(`~/.thincoder/ledger-notify.json`)。
|
|
8
|
-
*
|
|
9
|
-
* M2/KD-M2-3:核台账面**动态 import**(`@thincoder/core/ledger.mjs` 静态链 `node:sqlite`——
|
|
10
|
-
* 静态 import 会把 `node:sqlite` 拽进端壳静态面);载入失败 = 降级不崩(N1),扫描拍重试。
|
|
6
|
+
* 三面语义(启动行 / 变化行 / 状态位)见核档头注;判活展示(F-LX1)亦随归核自动带上——
|
|
7
|
+
* 本档零实现、零复刻(KD-M2-3)。
|
|
11
8
|
*/
|
|
12
9
|
import { C } from "./ansi.mjs"
|
|
13
10
|
|
|
14
|
-
/**
|
|
11
|
+
/** 核台账面惰性载入(动态 import——失败不缓存,下次扫描拍重试;N1 降级不崩)。 */
|
|
15
12
|
let _core = null
|
|
16
13
|
function loadCore() {
|
|
17
14
|
if (!_core) {
|
|
18
|
-
_core = import("@thincoder/core/ledger.mjs")
|
|
15
|
+
_core = import("@thincoder/core/ledger-surface.mjs")
|
|
19
16
|
_core.catch(() => { _core = null })
|
|
20
17
|
}
|
|
21
18
|
return _core
|
|
22
19
|
}
|
|
23
20
|
|
|
24
|
-
/**
|
|
25
|
-
|
|
26
|
-
export async function runLedgerScan({ state, agent = null, anchor = null, notifyFile = undefined, pushLine = () => {}, render = () => {}, startup = false } = {}) {
|
|
21
|
+
/** 单次扫描(直驱面——直通核实现;TUI 色表 `C` 注入)。 */
|
|
22
|
+
export async function runLedgerScan(opts = {}) {
|
|
27
23
|
const core = await loadCore()
|
|
28
|
-
|
|
29
|
-
const base = anchor ?? agent?.cwd ?? process.cwd()
|
|
30
|
-
const family = discoverFamily(base)
|
|
31
|
-
const scans = []
|
|
32
|
-
for (const p of family.projects) {
|
|
33
|
-
try { scans.push(buildScan({ cwd: p.root })) } catch { /* 台账不可读 → 该项目跳过(余者照常——N1/T110①) */ }
|
|
34
|
-
}
|
|
35
|
-
const current = family.current ? scans.find((s) => s.root === family.current.root) ?? null : null
|
|
36
|
-
const notify = loadNotifyState(notifyFile ?? NOTIFY_FILE)
|
|
37
|
-
const plans = scans.map((s) => ({ s, plan: planChangeLines(notify.ledgers[notifyKey(s.ledger)], s) }))
|
|
38
|
-
const changeLines = plans.flatMap(({ plan }) => plan.lines)
|
|
39
|
-
const showStartup = startup && scans.some((s) => s.actionable)
|
|
40
|
-
const rows = showStartup
|
|
41
|
-
? [...changeLines, ...detailScans(scans, current).map((s) => ({ text: formatDetailLine(s), warn: s.actionable }))]
|
|
42
|
-
: changeLines
|
|
43
|
-
let delivered = true
|
|
44
|
-
for (const line of rows) {
|
|
45
|
-
try { pushLine(line.text, line.warn ? C.warn : C.dim) } catch { delivered = false; break }
|
|
46
|
-
}
|
|
47
|
-
// 送达门(F5):有事件行 → 仅在送达后记账;无事件行 → 仅状态确有变化时静默同步(跌回 false 即重置)
|
|
48
|
-
const changed = plans.some(({ s, plan }) => {
|
|
49
|
-
const prev = notify.ledgers[notifyKey(s.ledger)]
|
|
50
|
-
return !prev || JSON.stringify(prev.aged ?? []) !== JSON.stringify(plan.next.aged) || Boolean(prev.threshold) !== plan.next.threshold
|
|
51
|
-
})
|
|
52
|
-
if (changeLines.length ? delivered : changed) {
|
|
53
|
-
const ledgers = { ...notify.ledgers }
|
|
54
|
-
for (const { s, plan } of plans) ledgers[notifyKey(s.ledger)] = { ...plan.next, updatedAt: Date.now() }
|
|
55
|
-
saveNotifyState(notifyFile ?? NOTIFY_FILE, { version: 1, ledgers })
|
|
56
|
-
}
|
|
57
|
-
state.ledger = { marker: current ? formatMarker(current) : null, warn: Boolean(current && current.aged > 0), scannedAt: Date.now() }
|
|
58
|
-
render()
|
|
24
|
+
return core.runLedgerScan({ ...opts, colors: C })
|
|
59
25
|
}
|
|
60
26
|
|
|
61
|
-
/** TUI
|
|
27
|
+
/** TUI 挂载(首扫 + 周期——直通核实现;载入失败 → 空句柄 = 无周期,N1 降级不崩)。 */
|
|
62
28
|
export function startLedgerSurface(ctx = {}) {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
if (disposed) return
|
|
67
|
-
if (!startup && ctx.state?.processing) return // 扫描不打断回合帧
|
|
68
|
-
try { await runLedgerScan({ ...ctx, startup }) } catch { /* 可见面尽力——不崩 TUI(N1) */ }
|
|
69
|
-
}
|
|
70
|
-
setImmediate(async () => {
|
|
71
|
-
if (disposed) return
|
|
72
|
-
tick(true)
|
|
73
|
-
const core = await loadCore().catch(() => null)
|
|
74
|
-
if (disposed || !core) return // 载入失败 → 无周期(N1——降级不崩)
|
|
75
|
-
timer = setInterval(() => tick(false), core.REFRESH_MS)
|
|
76
|
-
timer.unref?.()
|
|
77
|
-
})
|
|
78
|
-
return {
|
|
79
|
-
dispose() {
|
|
80
|
-
disposed = true
|
|
81
|
-
if (timer) clearInterval(timer)
|
|
82
|
-
timer = null
|
|
83
|
-
},
|
|
84
|
-
}
|
|
29
|
+
return loadCore()
|
|
30
|
+
.then((core) => core.startLedgerSurface({ ...ctx, colors: C }))
|
|
31
|
+
.catch(() => ({ dispose() {} }))
|
|
85
32
|
}
|
package/src/tui/render-frame.mjs
CHANGED
|
@@ -395,14 +395,23 @@ function buildStatusLine(state, agent, { cols, slashCommands }) {
|
|
|
395
395
|
const ctxTokensHint = state.ctxCache.tokens > 0 ? ` ${fmtK(state.ctxCache.tokens)}` : ""
|
|
396
396
|
const ctxHint = ctxPct > 0
|
|
397
397
|
? ctxPct >= 80 ? ` │ ${ansi.reset}${C.warn}context ${ctxPct}%${ctxTokensHint}${ansi.reset}${ansi.dim}` : ` │ context ${ctxPct}%${ctxTokensHint}` : ""
|
|
398
|
-
// INPUT-LOCK-ASYNC(C'——F-3):busy(processing 含 digest
|
|
399
|
-
//
|
|
400
|
-
|
|
398
|
+
// INPUT-LOCK-ASYNC(C'——F-3):busy(processing 含 digest)状态栏提示——取代旧排队提示
|
|
399
|
+
// (F-7 已删);F16(TUI.md §7.5)三态分流:单槽已填 ⇒ queued 段(dim 段样式 · 零注意力色对
|
|
400
|
+
// ——F13 豁免 · 消费即消失);槽空 ⇒ 按 busy 面分排队句(判据同 TUI-INPUT-BOX.md §4.1)。
|
|
401
|
+
const enterHint = state.processing
|
|
402
|
+
? ((state.pendingInput?.length ?? 0) > 0 ? "已排队 1 条消息"
|
|
403
|
+
: state.suspended || state._suspPending ? `会话内回合处理中 — Enter 排队(本轮结束后优先发送)`
|
|
404
|
+
: `主会话处理中 — Enter 排队(回合结束后自动发送)`)
|
|
405
|
+
: "Enter: send"
|
|
401
406
|
// LEDGER-SURFACE(§2.30.3.3/§2.30.3.4):L1 常驻标记——状态段簇尾(scrollHint 后、键位组前);
|
|
402
407
|
// 空标记零注入(半态逐字节等价——同 :233-238 纪律);warn = 当前项目老化 > 0(警示色段包裹)
|
|
403
408
|
const lg = state.ledger
|
|
404
409
|
const ledgerHint = lg?.marker
|
|
405
410
|
? ` │ ${lg.warn ? `${ansi.reset}${C.warn}${lg.marker}${ansi.reset}${ansi.dim}` : lg.marker}`
|
|
406
411
|
: ""
|
|
407
|
-
|
|
412
|
+
// D8(2026-09-21 块标题行对齐批——会话标题常显 · `docs/cli/design/TUI.md` §7.4):状态段簇尾
|
|
413
|
+
// (ledgerHint 后、键位组前);取值 = `agent.title` 活读(每帧 recompute)· 空值零注入。
|
|
414
|
+
const titleRaw = typeof agent.title === "string" ? agent.title.trim() : ""
|
|
415
|
+
const titleHint = titleRaw ? ` │ ${stringWidth(titleRaw) > 40 ? sliceByWidth(titleRaw, 39) + "…" : titleRaw}` : ""
|
|
416
|
+
return ` ${statusText}${taskHint}${turnHint}${tokenHint}${ctxHint}${scrollHint}${ledgerHint}${titleHint} │ ${enterHint} │ /: commands │ wheel/PgUp/PgDn: scroll │ Ctrl+I: inject │ Ctrl+C: exit (×2)`
|
|
408
417
|
}
|
|
@@ -224,13 +224,13 @@ export function freezeAllSubTasks(state) {
|
|
|
224
224
|
}
|
|
225
225
|
}
|
|
226
226
|
|
|
227
|
-
/**
|
|
227
|
+
/** 消化完成逐条冻结回收(2026-09-03 实测修订 + round1 #1 位置裁定):digest/会话内
|
|
228
228
|
* 用户回合消化完 pending 条目(run 首行已注入)后调用——把"已消化但仍驻留面板"的
|
|
229
229
|
* awaitingDigest 块立即冻结进流(不等池空——块回收与池空解耦;池空 freezeAllSubTasks
|
|
230
230
|
* 仅兜底未消化残项)。归属不变式:会话内任何 run 开始前 pinned 块的条目必在 pending
|
|
231
231
|
* (settle 即移交);run 消费后条目不在 pending 的 pinned 块 = 本 run 消化者——无需
|
|
232
|
-
* 快照即精确归属。位置(round1 #1
|
|
233
|
-
* AGENT-LOOP.md §
|
|
232
|
+
* 快照即精确归属。位置(round1 #1 裁定——与早版文本的矛盾已消解;digest 面 =
|
|
233
|
+
* AGENT-LOOP-ASYNC-POOL.md §6.8):**settle 锚点 splice 落位——digest 总览文本之前**——同
|
|
234
234
|
* §7.2 D4 修复轮/D-S8 锚点语义(T-S6/T-S14 位置断言同口径)——锚点降序逐块冻结
|
|
235
235
|
* (splice 绝对位互不位移)。@returns {number} 回收块数 */
|
|
236
236
|
export function freezeReclaimDigestedBlocks(state, pendingList) {
|