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
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { C } from "./ansi.mjs"
|
|
2
|
+
// F-XR1 退出释放(EXIT-CLAIM-RELEASE · SESSION.md §6.18 D-SE41):核薄函数——退出恒达
|
|
3
|
+
// (永不抛出,失败返回 false);零触碰 marker 面(F-XR2 路标保留)。
|
|
4
|
+
import { releaseClaimsAll } from "@thincoder/core/session-slots-manifest.mjs"
|
|
5
|
+
|
|
6
|
+
/** Ctrl+C 族(2026-09-22 structure-debt §2.1 分族 1 · #226):picker 取消 / 武装窗口全停 /
|
|
7
|
+
* 挂起态两级中止 / 回合 interrupt / 空闲退出武装五分支——块体自 key-handler.mjs
|
|
8
|
+
* createKeyHandler 逐字搬移(仅去缩进;函数体自持原块入口判定)。
|
|
9
|
+
* 路由 = 分派器 createKeyHandler(守卫 = 同一条原块入口条件)。 */
|
|
10
|
+
export function handleCtrlCFamily(str, key, ctx) {
|
|
11
|
+
const { agent, state, render, popPicker, pushLine, cleanup } = ctx
|
|
12
|
+
if (key.ctrl && key.name === "c") {
|
|
13
|
+
// picker 打开时 Ctrl+C = 取消当前 picker(等同 Esc),不杀进程
|
|
14
|
+
if (state.picker) {
|
|
15
|
+
popPicker(null)
|
|
16
|
+
return
|
|
17
|
+
}
|
|
18
|
+
// §17.6(2026-09-03):武装窗口内第二次 Ctrl+C = 显式全停(D-C4 /abort 语义)。
|
|
19
|
+
// 在状态路由前检查(picker 取消分支例外——picker 打开时 Ctrl+C 语义 = 取消
|
|
20
|
+
// picker,武装让位;场景极窄——3s 窗口内需另有 picker 被打开)——两次按下之间
|
|
21
|
+
// 状态会迁移(首按停回合 → 释放窗口/挂起会话启动),武装必须跨态桥接:只查挂起
|
|
22
|
+
// 分支会让非挂起 processing 首按后的二按落入空闲退出分支(三按误退出应用)。
|
|
23
|
+
if (state.suspAbortArmed) {
|
|
24
|
+
if (ctx.suspArmTimer) clearTimeout(ctx.suspArmTimer)
|
|
25
|
+
state.suspAbortArmed = false
|
|
26
|
+
// 无可停目标(无 processing 回合 / 无会话 abort 集合 / 未挂起)时不吞键:
|
|
27
|
+
// 首按停的是普通回合(无后台池)——二按落回下方空闲退出分支(exitArmed 双
|
|
28
|
+
// 确认),不打印误导性的 "[Aborting background subagents…]"(advisor round1
|
|
29
|
+
// 🟡——停回合后连按退出的第二次按下被空转吞掉 → 退出从 3 按变 4 按)。
|
|
30
|
+
const hasStopTarget = (state.processing && state.controller) ||
|
|
31
|
+
(agent._sessionAbortAll?.length ?? 0) > 0 ||
|
|
32
|
+
(agent._sessionAbort != null && !agent._sessionAbort.signal.aborted) ||
|
|
33
|
+
state.suspended
|
|
34
|
+
if (hasStopTarget) {
|
|
35
|
+
// 当前回合平 abort(无 interrupt——命中 agent.mjs 回合收尾清池分支——全停)
|
|
36
|
+
// §20.3 站点 #11(第 24 批):整批 / 会话级停 = stop(reason 载荷)
|
|
37
|
+
if (state.processing && state.controller) state.controller.abort({ abortTrigger: "stop", abortDetail: "session-stop" })
|
|
38
|
+
// 会话 abort 集合 = 链条内全部 controller(含 Ctrl+I/ContinueError 重建的旧
|
|
39
|
+
// controller——children 不逃逸,round1 偏差 #3)。挂起态才标记 _suspAborted +
|
|
40
|
+
// 唤醒 driver(driver 收尾清池)——非挂起语境置位会粘滞阻塞未来挂起会话重入
|
|
41
|
+
// (round2 偏差 #1 语义)。
|
|
42
|
+
for (const c of agent._sessionAbortAll ?? (agent._sessionAbort ? [agent._sessionAbort] : [])) c?.abort({ abortTrigger: "stop", abortDetail: "session-stop" })
|
|
43
|
+
if (state.suspended) {
|
|
44
|
+
state._suspAborted = true
|
|
45
|
+
state._suspWake?.()
|
|
46
|
+
}
|
|
47
|
+
pushLine("[Aborting background subagents…]", C.warn)
|
|
48
|
+
render()
|
|
49
|
+
return
|
|
50
|
+
}
|
|
51
|
+
// 无目标可停 → 落空继续到下方分支(processing 已停 → 空闲态 exitArmed 双确认)
|
|
52
|
+
}
|
|
53
|
+
if (state.suspended) {
|
|
54
|
+
// §17 D-S9 + round2 偏差 #4(2026-09-02)+ §17.6 修订(2026-09-03):挂起态
|
|
55
|
+
// Ctrl+C = 武装窗口两级中止——一次按键直接清池中止全部后台子代理的误触代价高
|
|
56
|
+
// (digest 刷屏时用户可能只想停住当前回合);仿空闲态退出武装语义(同下方空闲态
|
|
57
|
+
// exitArmed 双确认分支)。§17.6 修订 ①:中止当前回合的 abort 带 interrupt
|
|
58
|
+
// (无 message)——此前平 abort 命中 agent.mjs 回合收尾清池分支(aborted &&
|
|
59
|
+
// !reason.interrupt → 无条件 _asyncSubagents.clear()),一次首按即误杀全部后台
|
|
60
|
+
// 子代理(2026-09-03 用户两次实测——提示"再按才杀"与实际"一次就全杀"不符);
|
|
61
|
+
// interrupt 排除清池分支——会话与后台保留(agent-turn 区分:无 message 停回合
|
|
62
|
+
// 不续跑):
|
|
63
|
+
// ① 未武装 + 有回合在跑(digest 消化/会话内回合):仅中止当前回合
|
|
64
|
+
// (controller.abort({ interrupt: true }))——会话继续、后台子代理不受
|
|
65
|
+
// 影响,回挂起等待;
|
|
66
|
+
// ② 未武装 + 纯挂起等待:仅提示不清池;
|
|
67
|
+
// ③ 3s 武装窗口内再次 Ctrl+C:彻底中止——上方统一块(二按语义不变)。
|
|
68
|
+
if (state.processing && state.controller) {
|
|
69
|
+
state.controller.abort({ interrupt: true }) // ① 仅中止当前回合(digest/会话内回合),不碰后台池
|
|
70
|
+
pushLine("[stopped current turn — press Ctrl+C again within 3s to abort all background subagents]", C.warn)
|
|
71
|
+
} else {
|
|
72
|
+
const bgActive = [...(agent._asyncSubagents?.values() ?? [])].filter((e) => e.status === "running").length + (agent._asyncQueue?.length ?? 0)
|
|
73
|
+
pushLine(`[abort] Press Ctrl+C again within 3s to abort all background subagents (${bgActive} running)`, C.warn)
|
|
74
|
+
}
|
|
75
|
+
state.suspAbortArmed = true
|
|
76
|
+
if (ctx.suspArmTimer) clearTimeout(ctx.suspArmTimer)
|
|
77
|
+
ctx.suspArmTimer = setTimeout(() => { state.suspAbortArmed = false }, ctx.exitArmDelay ?? 3000)
|
|
78
|
+
ctx.suspArmTimer.unref?.()
|
|
79
|
+
render()
|
|
80
|
+
return
|
|
81
|
+
}
|
|
82
|
+
if (state.processing && state.controller) {
|
|
83
|
+
// §17.6 D-C1(2026-09-03 紧急修复——processing 态曾无武装窗口,第一按直接平
|
|
84
|
+
// abort() → 命中 agent.mjs 回合收尾清池分支 → 一次 Ctrl+C 误杀全部后台子代理,
|
|
85
|
+
// 用户两次实测被坑):首按 = interrupt 语义(无 message——停当前回合不续跑——
|
|
86
|
+
// interrupt 排除 agent.mjs 清池分支——后台池保留——agent-turn 区分不重建续跑)
|
|
87
|
+
// + 武装 3s(复用 suspArmTimer/exitArmDelay——与挂起态同构——过期自动复位);
|
|
88
|
+
// 窗口内再按 = 上方统一块全停(平 abort → agent.mjs 清池)。
|
|
89
|
+
state.controller.abort({ interrupt: true })
|
|
90
|
+
pushLine("[stopped current turn — press Ctrl+C again within 3s to abort all background subagents]", C.warn)
|
|
91
|
+
state.suspAbortArmed = true
|
|
92
|
+
if (ctx.suspArmTimer) clearTimeout(ctx.suspArmTimer)
|
|
93
|
+
ctx.suspArmTimer = setTimeout(() => { state.suspAbortArmed = false }, ctx.exitArmDelay ?? 3000)
|
|
94
|
+
ctx.suspArmTimer.unref?.()
|
|
95
|
+
render()
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
// 防误触:空闲态第一次 Ctrl+C 仅提示并武装,窗口内再按才真正退出
|
|
99
|
+
if (!state.exitArmed) {
|
|
100
|
+
state.exitArmed = true
|
|
101
|
+
if (ctx.exitArmTimer) clearTimeout(ctx.exitArmTimer)
|
|
102
|
+
ctx.exitArmTimer = setTimeout(() => { state.exitArmed = false }, ctx.exitArmDelay ?? 3000)
|
|
103
|
+
ctx.exitArmTimer.unref?.()
|
|
104
|
+
pushLine("[exit] Press Ctrl+C again within 3s to exit", C.warn)
|
|
105
|
+
render()
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
if (ctx.exitArmTimer) clearTimeout(ctx.exitArmTimer)
|
|
109
|
+
cleanup()
|
|
110
|
+
// F-XR1 退出释放(EXIT-CLAIM-RELEASE · SESSION.md §6.18):先于定时器注册同步完成
|
|
111
|
+
// (D-SE42——100ms 窗零竞态);退出前保存已完成 ⇒ 释放 = 收口前最后一步。
|
|
112
|
+
releaseClaimsAll(process.cwd())
|
|
113
|
+
// 延迟退出可注入(测试传大值并清理定时器,避免定时器在 mock 恢复后调到真 process.exit)
|
|
114
|
+
ctx.exitTimer = setTimeout(() => process.exit(0), ctx.exitDelay ?? 100)
|
|
115
|
+
ctx.exitTimer.unref?.()
|
|
116
|
+
return // review #5 fix: exiting — don't fall through to later branches
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { C } from "./ansi.mjs"
|
|
2
|
+
import { readClipboardText, insertPastedText } from "./clipboard.mjs"
|
|
3
|
+
|
|
4
|
+
/** 编辑族(2026-09-22 structure-debt §2.1 分族 5 · #226):Tab 补全 / Ctrl+U 清框 /
|
|
5
|
+
* 退格·删除 / Enter(多行换行 · 挂起单槽 · submit)/ 剪贴板文本与图片粘贴 / 可打印字符;
|
|
6
|
+
* 各块自 key-handler.mjs createKeyHandler 逐字搬移(仅去缩进)。
|
|
7
|
+
* 路由 = 分派器 createKeyHandler 末族(其后无可达分支 —— 块内 return 即终结本次按键)。 */
|
|
8
|
+
export function handleEditKeys(str, key, ctx) {
|
|
9
|
+
const { agent, state, render, handleTab, submit, pasteClipboardImage, pushLine } = ctx
|
|
10
|
+
// Tab: slash-command completion (cycle candidates); other input ignored (\t would blow up input box, never inserted directly)
|
|
11
|
+
if (key.name === "tab") {
|
|
12
|
+
handleTab()
|
|
13
|
+
return
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Ctrl+U: clear entire input box
|
|
17
|
+
if ((key.name === "u" && key.ctrl) || str === "\x15") {
|
|
18
|
+
if (state.historyIndex !== -1) state._draft = [...state.input]
|
|
19
|
+
state.input = []
|
|
20
|
+
state.cursor = 0
|
|
21
|
+
render()
|
|
22
|
+
return
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// editing
|
|
26
|
+
if (key.name === "backspace") {
|
|
27
|
+
if (state.cursor > 0) {
|
|
28
|
+
state.input.splice(state.cursor - 1, 1)
|
|
29
|
+
state.cursor--
|
|
30
|
+
if (state.historyIndex !== -1) state._draft = [...state.input]
|
|
31
|
+
render()
|
|
32
|
+
}
|
|
33
|
+
return
|
|
34
|
+
}
|
|
35
|
+
if (key.name === "delete") {
|
|
36
|
+
if (state.cursor < state.input.length) {
|
|
37
|
+
state.input.splice(state.cursor, 1)
|
|
38
|
+
if (state.historyIndex !== -1) state._draft = [...state.input]
|
|
39
|
+
render()
|
|
40
|
+
}
|
|
41
|
+
return
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (key.name === "return" || key.name === "enter" || str === "\r") {
|
|
45
|
+
// Multiline newline (docs/design/TUI-INPUT-BOX.md §1.5): Alt+Enter / Shift+Enter
|
|
46
|
+
// (translateShiftEnter) / Ctrl+J — the universal no-protocol fallback.
|
|
47
|
+
if ((key.name === "return" && key.meta) || key.name === "enter") {
|
|
48
|
+
state.input.splice(state.cursor, 0, "\n")
|
|
49
|
+
state.cursor++
|
|
50
|
+
render()
|
|
51
|
+
} else {
|
|
52
|
+
const text = state.input.join("").trim()
|
|
53
|
+
// §17 D-S5/F3/F7 + 偏差 #1 + INPUT-LOCK 单槽化(F-6——2026-09-09):挂起态
|
|
54
|
+
// (suspended 或释放窗口 _suspPending)Enter = 新回合输入(非打断)——填
|
|
55
|
+
// pendingInput 单槽(至多一条待交接)由挂起会话调度;挂起会话内 busy(含 digest)
|
|
56
|
+
// 提交经 busy 门禁入同槽(F16 §4.1——两分支同槽、同款清理与唤醒);输入框零干扰(F3)。
|
|
57
|
+
// 槽满(竞态防御——不覆盖不丢失)→ 吞 + 提示,文本保留在输入框。
|
|
58
|
+
if ((state.suspended || state._suspPending) && text && !text.startsWith("/")) {
|
|
59
|
+
state.pendingInput ??= []
|
|
60
|
+
if (state.pendingInput.length > 0) {
|
|
61
|
+
pushLine(`[主会话处理中 —— 已有一条消息待发送,请等其处理完成后再发送]`, C.warn)
|
|
62
|
+
render()
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
state.input = []
|
|
66
|
+
state.cursor = 0
|
|
67
|
+
state.history.push(text)
|
|
68
|
+
state.historyIndex = -1
|
|
69
|
+
state._draft = null
|
|
70
|
+
state.pendingInput.push(text)
|
|
71
|
+
state._suspWake?.()
|
|
72
|
+
render()
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
submit().catch((e) => pushLine(`[error] ${e.message}`, C.error))
|
|
76
|
+
}
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Ctrl+V: paste clipboard text into the active text target.
|
|
81
|
+
// Exclude meta (ESC-prefix) so Ctrl+Alt+V — which readline reports as ctrl+meta —
|
|
82
|
+
// falls through to the image-paste branch below instead of being eaten here.
|
|
83
|
+
if (key.ctrl && !key.alt && !key.meta && key.name === "v") {
|
|
84
|
+
;(async () => {
|
|
85
|
+
const text = await readClipboardText()
|
|
86
|
+
if (text) {
|
|
87
|
+
insertPastedText(state, text)
|
|
88
|
+
render()
|
|
89
|
+
}
|
|
90
|
+
})()
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Alt+V / Ctrl+Alt+V: paste clipboard image.
|
|
95
|
+
// NOTE: readline reports ESC-prefixed combos as key.meta, NOT key.alt (probe-verified:
|
|
96
|
+
// \x1b + char → { meta: true, alt: false }). Checking key.alt alone is a dead branch —
|
|
97
|
+
// the key fell through to the printable handler which ignores meta keys, so image paste
|
|
98
|
+
// silently did nothing. Accept meta (and alt, for terminals that set it); the text-paste
|
|
99
|
+
// branch above excludes meta so Ctrl+Alt+V reaches here.
|
|
100
|
+
const isPasteImage = key.name === "v" && (key.alt || key.meta)
|
|
101
|
+
if (isPasteImage) {
|
|
102
|
+
pasteClipboardImage(agent).catch((e) => pushLine(`[error] ${e.message}`, C.error))
|
|
103
|
+
return
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// printable characters / paste (str may contain multiple chars at once); Tab always converted to two spaces (\t has variable display width, would blow up input box)
|
|
107
|
+
// \r\n may leak through in Windows raw mode and scramble the display
|
|
108
|
+
if (str && !key.ctrl && !key.meta) {
|
|
109
|
+
const chars = [...str.replace(/[\r\n]+/g, "").replace(/\t/g, " ")]
|
|
110
|
+
state.input.splice(state.cursor, 0, ...chars)
|
|
111
|
+
state.cursor += chars.length
|
|
112
|
+
if (state.historyIndex !== -1) state._draft = [...state.input]
|
|
113
|
+
render()
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { computeLayout } from "./layout.mjs"
|
|
2
|
+
|
|
3
|
+
/** 模态族之一(2026-09-22 structure-debt §2.1 分族 2 · #226):通用列表 picker——
|
|
4
|
+
* ↑↓/PgUp/PgDn/Home/End 导航 + 输入即过滤 + Enter 选中 + Esc 取消;块体自 key-handler.mjs
|
|
5
|
+
* createKeyHandler 逐字搬移(仅去缩进;函数体自持原块入口判定)。
|
|
6
|
+
* 路由 = 分派器 createKeyHandler(守卫 = 同一条原块入口条件 `state.picker`)。 */
|
|
7
|
+
export function handlePickerKeys(str, key, ctx) {
|
|
8
|
+
const { state, renderPickerLines, popPicker } = ctx
|
|
9
|
+
// generic list picker: ↑↓/PgUp/PgDn/Home/End 导航,输入即过滤,Enter 选中,Esc 取消
|
|
10
|
+
if (state.picker) {
|
|
11
|
+
const p = state.picker
|
|
12
|
+
const items = p.filteredItems ?? p.entries.filter((e) => e.type === "item")
|
|
13
|
+
// 可视窗高度:直接取 layout 算出的实际 picker 面板高(含小终端 pickerFinalH 压缩),减标题行。
|
|
14
|
+
// 单一数据源,避免与 layout.mjs 公式漂移
|
|
15
|
+
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)
|
|
16
|
+
const applyFilter = (f) => {
|
|
17
|
+
p.filter = f
|
|
18
|
+
p.index = 0
|
|
19
|
+
p.scroll = 0
|
|
20
|
+
renderPickerLines()
|
|
21
|
+
}
|
|
22
|
+
if (key.name === "escape") {
|
|
23
|
+
popPicker(null)
|
|
24
|
+
} else if (key.name === "up" && items.length) {
|
|
25
|
+
p.index = (p.index - 1 + items.length) % items.length
|
|
26
|
+
renderPickerLines()
|
|
27
|
+
} else if (key.name === "down" && items.length) {
|
|
28
|
+
p.index = (p.index + 1) % items.length
|
|
29
|
+
renderPickerLines()
|
|
30
|
+
} else if (key.name === "pageup" && items.length) {
|
|
31
|
+
p.index = Math.max(0, p.index - winH)
|
|
32
|
+
renderPickerLines()
|
|
33
|
+
} else if (key.name === "pagedown" && items.length) {
|
|
34
|
+
p.index = Math.min(items.length - 1, p.index + winH)
|
|
35
|
+
renderPickerLines()
|
|
36
|
+
} else if (key.name === "home" && items.length) {
|
|
37
|
+
p.index = 0
|
|
38
|
+
renderPickerLines()
|
|
39
|
+
} else if (key.name === "end" && items.length) {
|
|
40
|
+
p.index = items.length - 1
|
|
41
|
+
renderPickerLines()
|
|
42
|
+
} else if (key.name === "backspace") {
|
|
43
|
+
if (p.filter) applyFilter(p.filter.slice(0, -1))
|
|
44
|
+
} else if ((key.name === "return" || key.name === "enter" || str === "\r") && items.length) {
|
|
45
|
+
popPicker(items[p.index]) // 选中即关闭
|
|
46
|
+
} else if (str && !key.ctrl && !key.meta) {
|
|
47
|
+
// 输入即过滤;粘贴的多行文本先去换行(与输入框清洗口径一致),仍含控制字符则整段丢弃
|
|
48
|
+
const text = str.replace(/[\r\n]+/g, "")
|
|
49
|
+
if (text && !/[\x00-\x1f\x7f]/.test(text)) applyFilter(p.filter + text)
|
|
50
|
+
}
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** 模态族之二(分族 2 · #226):初始配置 wizard——menu step ↑↓/Enter/Esc;text step Enter 提交 /
|
|
56
|
+
* Esc 取消,其余编辑键回落至编辑族(落空面由分派器守卫与块内返回点共同界定)。
|
|
57
|
+
* 路由 = 分派器 createKeyHandler。 */
|
|
58
|
+
export function handleWizardKeys(str, key, ctx) {
|
|
59
|
+
const { state, renderWizard, cancelWizard, wizardProviderItems, wizardChooseProvider, wizardSubmitText } = ctx
|
|
60
|
+
// initial config wizard: menu step ↑↓/Enter/Esc; text step Enter submit, Esc cancel, edit keys fall through to normal input
|
|
61
|
+
if (state.wizard) {
|
|
62
|
+
const w = state.wizard
|
|
63
|
+
if (key.name === "escape") {
|
|
64
|
+
cancelWizard()
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
if (w.step === "provider") {
|
|
68
|
+
const items = wizardProviderItems()
|
|
69
|
+
if (key.name === "up" && items.length) {
|
|
70
|
+
w.index = (w.index - 1 + items.length) % items.length
|
|
71
|
+
renderWizard()
|
|
72
|
+
} else if (key.name === "down" && items.length) {
|
|
73
|
+
w.index = (w.index + 1) % items.length
|
|
74
|
+
renderWizard()
|
|
75
|
+
} else if ((key.name === "return" || key.name === "enter" || str === "\r") && items.length) {
|
|
76
|
+
wizardChooseProvider(items[w.index])
|
|
77
|
+
}
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
if (key.name === "return") {
|
|
81
|
+
wizardSubmitText()
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
// text steps: block scroll/history, remaining edit keys fall through to normal input logic below
|
|
85
|
+
if (key.name === "up" || key.name === "down" || key.name === "pageup" || key.name === "pagedown") return
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { inputContentWidth } from "./layout.mjs"
|
|
2
|
+
import { moveCursorVertical } from "./render.mjs"
|
|
3
|
+
import { convMaxScroll } from "./key-handler.mjs"
|
|
4
|
+
|
|
5
|
+
/** scroll 族之一(2026-09-22 structure-debt §2.1 分族 3 · #226):PgUp/PgDn 翻页 + 顶部
|
|
6
|
+
* loadOlder 边界加载;块体自 key-handler.mjs createKeyHandler 逐字搬移(仅去缩进)。
|
|
7
|
+
* 路由 = 分派器 createKeyHandler(守卫 = 原块入口条件 pageup/pagedown)。 */
|
|
8
|
+
export function handleScrollKeys(key, ctx) {
|
|
9
|
+
const { state, render, loadOlder } = ctx
|
|
10
|
+
// page scroll
|
|
11
|
+
if (key.name === "pageup") {
|
|
12
|
+
// At the top of the conversation → load the next earlier history page
|
|
13
|
+
// (lazy restore, parity with VS Code's scroll-back loadOlder).
|
|
14
|
+
if (state._hasOlder && loadOlder && state.scroll >= convMaxScroll(state)) {
|
|
15
|
+
loadOlder()
|
|
16
|
+
} else {
|
|
17
|
+
state.scroll += Math.max(1, ((state.dims?.get() ?? {}).rows ?? (process.stdout.rows || 24)) - 8)
|
|
18
|
+
}
|
|
19
|
+
state._followTail = false // 2026-08-31:用户上滚 = 暂停流式跟随
|
|
20
|
+
render()
|
|
21
|
+
return
|
|
22
|
+
}
|
|
23
|
+
if (key.name === "pagedown") {
|
|
24
|
+
state.scroll = Math.max(0, state.scroll - Math.max(1, ((state.dims?.get() ?? {}).rows ?? (process.stdout.rows || 24)) - 8))
|
|
25
|
+
if (state.scroll === 0) state._followTail = true // 滚回底部恢复跟随
|
|
26
|
+
render()
|
|
27
|
+
return
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** scroll 族之二(分族 3 · #226):↑↓ 三规则(TUI-INPUT-BOX.md §3)+ 输入历史导航;
|
|
32
|
+
* 块体逐字搬移(仅去缩进)。路由 = 分派器(守卫 = up/down)。 */
|
|
33
|
+
export function handleHistoryKeys(key, ctx) {
|
|
34
|
+
const { state, render } = ctx
|
|
35
|
+
// ↑/↓ 三规则(TUI-INPUT-BOX.md §3——第 31 批):① 翻历史中恒历史(多行条目不竖移)
|
|
36
|
+
// ② 竖移优先(可视行口径——折行与 \n 同权;显示列保持 + 短行端钳制)③ 边界回落历史(↑)
|
|
37
|
+
// /无动作(↓)。processing 期:竖移放行(纯编辑——与字符/退格/←→ 同权)、历史导航禁
|
|
38
|
+
// (规则 ①/③ 的历史分支吞——不进入、不切换)。
|
|
39
|
+
if (key.name === "up" || key.name === "down") {
|
|
40
|
+
if (state.historyIndex === -1) { // 规则 ① 优先——翻历史中不竖移
|
|
41
|
+
const cols = (state.dims?.get() ?? {}).cols ?? (process.stdout.columns || 80)
|
|
42
|
+
const next = moveCursorVertical(state.input, state.cursor, inputContentWidth(cols), key.name)
|
|
43
|
+
if (next !== null) {
|
|
44
|
+
state.cursor = next // 规则 ②:竖移(草稿/历史指针零涉——D-31.6)
|
|
45
|
+
render()
|
|
46
|
+
return
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (state.processing) return // 规则 ①/③ 的历史分支吞——processing 期历史导航禁
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// input history(规则 ① 恒历史 + 规则 ③ 边界回落——历史语义零改)
|
|
53
|
+
if (key.name === "up") {
|
|
54
|
+
if (state.history.length) {
|
|
55
|
+
// Save current input as draft when entering history mode (historyIndex === -1).
|
|
56
|
+
// Subsequent edits while in history mode are saved separately (see printable/editing handlers).
|
|
57
|
+
if (state.historyIndex === -1) {
|
|
58
|
+
state._draft = [...state.input]
|
|
59
|
+
}
|
|
60
|
+
state.historyIndex = state.historyIndex === -1 ? state.history.length - 1 : Math.max(0, state.historyIndex - 1)
|
|
61
|
+
state.input = [...state.history[state.historyIndex]]
|
|
62
|
+
state.cursor = state.input.length
|
|
63
|
+
render()
|
|
64
|
+
}
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
if (key.name === "down") {
|
|
68
|
+
if (state.historyIndex !== -1) {
|
|
69
|
+
state.historyIndex++
|
|
70
|
+
if (state.historyIndex >= state.history.length) {
|
|
71
|
+
state.historyIndex = -1
|
|
72
|
+
// Restore the stashed draft instead of wiping back to blank
|
|
73
|
+
state.input = state._draft ? [...state._draft] : []
|
|
74
|
+
state._draft = null
|
|
75
|
+
} else {
|
|
76
|
+
state.input = [...state.history[state.historyIndex]]
|
|
77
|
+
}
|
|
78
|
+
state.cursor = state.input.length
|
|
79
|
+
render()
|
|
80
|
+
}
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** scroll 族之三(分族 3 · #226):←→/Home/End 光标移动;块体逐字搬移(仅去缩进)。
|
|
86
|
+
* 路由 = 分派器(守卫 = left/right/home/end)。 */
|
|
87
|
+
export function handleCursorKeys(key, ctx) {
|
|
88
|
+
const { state, render } = ctx
|
|
89
|
+
// cursor movement
|
|
90
|
+
if (key.name === "left") {
|
|
91
|
+
state.cursor = Math.max(0, state.cursor - 1)
|
|
92
|
+
render()
|
|
93
|
+
return
|
|
94
|
+
}
|
|
95
|
+
if (key.name === "right") {
|
|
96
|
+
state.cursor = Math.min(state.input.length, state.cursor + 1)
|
|
97
|
+
render()
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
if (key.name === "home") {
|
|
101
|
+
state.cursor = 0
|
|
102
|
+
render()
|
|
103
|
+
return
|
|
104
|
+
}
|
|
105
|
+
if (key.name === "end") {
|
|
106
|
+
state.cursor = state.input.length
|
|
107
|
+
render()
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
}
|