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.
- package/CHANGELOG.md +32 -0
- package/README.md +102 -42
- package/bin/thincoder.mjs +18 -0
- package/package.json +2 -2
- package/src/acp/handlers-session.mjs +25 -1
- package/src/acp/handlers-slots.mjs +1 -1
- package/src/tui/agent-turn.mjs +5 -2
- package/src/tui/cmd-advisor.mjs +1 -1
- package/src/tui/cmd-eng.mjs +2 -1
- package/src/tui/cmd-mcp-form.mjs +11 -5
- package/src/tui/conversation-writer.mjs +62 -0
- package/src/tui/index.mjs +43 -305
- 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/render-frame.mjs +8 -3
- package/src/tui/subagent-freeze.mjs +3 -3
- package/src/tui/suspension-drive.mjs +10 -9
- package/src/tui/tui-state.mjs +65 -0
- package/src/tui/turn-face.mjs +64 -0
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|