thincoder 0.12.54 → 0.12.59
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 +98 -0
- package/README.md +1 -1
- package/bin/thincoder.mjs +25 -3
- package/package.json +3 -7
- package/src/acp/bridge.mjs +132 -26
- package/src/advisor/messages.mjs +38 -3
- package/src/advisor/run.mjs +91 -53
- package/src/advisor.mjs +15 -7
- package/src/agent/dispatch.mjs +156 -39
- package/src/agent/helpers.mjs +46 -4
- package/src/agent/setup.mjs +102 -19
- package/src/agent/spawn-child.mjs +28 -1
- package/src/agent-tools/advisor.mjs +43 -11
- package/src/agent-tools/consult.mjs +37 -6
- package/src/agent-tools/eng.mjs +4 -1
- package/src/agent-tools/goal.mjs +11 -1
- package/src/agent-tools/read-history.mjs +160 -0
- package/src/agent-tools/settings.mjs +162 -0
- package/src/agent-tools/skill.mjs +2 -1
- package/src/agent-tools/subagent-actions.mjs +432 -0
- package/src/agent-tools/subagent-async.mjs +427 -0
- package/src/agent-tools/subagent-scheduler.mjs +319 -0
- package/src/agent-tools/subagent.mjs +565 -128
- package/src/agent-tools/task.mjs +4 -3
- package/src/agent-tools/timer.mjs +9 -4
- package/src/agent-tools/verify.mjs +161 -49
- package/src/agent-tools.mjs +1 -0
- package/src/agent.mjs +182 -81
- package/src/auto-think.mjs +14 -0
- package/src/cli/make-agent.mjs +27 -1
- package/src/cli/memory-command.mjs +28 -7
- package/src/cli/permission.mjs +8 -1
- package/src/config.mjs +125 -8
- package/src/context.mjs +115 -34
- package/src/distill.mjs +19 -1
- package/src/escape.mjs +82 -27
- package/src/log.mjs +195 -0
- package/src/mcp/transport-http.mjs +13 -1
- package/src/mcp.mjs +52 -7
- package/src/memory/code-sync.mjs +1 -1
- package/src/memory/core.mjs +204 -10
- package/src/memory/docs.mjs +197 -62
- package/src/memory.mjs +1 -1
- package/src/model-specs.mjs +38 -1
- package/src/prompts/advisor-design.md +46 -0
- package/src/prompts/advisor-round1.md +49 -2
- package/src/prompts/advisor-round2.md +47 -0
- package/src/prompts/advisor-round3.md +47 -0
- package/src/prompts/coder.md +22 -0
- package/src/prompts/consult-base.md +13 -0
- package/src/prompts/discipline.md +25 -6
- package/src/prompts/eng-coder.md +2 -2
- package/src/prompts/engineering-sub.md +23 -1
- package/src/prompts/engineering.md +157 -50
- package/src/prompts/explore.md +1 -2
- package/src/prompts/main.md +11 -5
- package/src/prompts/methodology-template.md +14 -0
- package/src/prompts/system.md +5 -2
- package/src/provider/anthropic.mjs +7 -5
- package/src/provider/core.mjs +104 -28
- package/src/provider/google.mjs +57 -24
- package/src/provider/normalize.mjs +1 -1
- package/src/provider/rate.mjs +0 -2
- package/src/provider/responses.mjs +8 -13
- package/src/provider/sse.mjs +20 -0
- package/src/session.mjs +15 -0
- package/src/tools/apply_patch.md +5 -1
- package/src/tools/bash.md +3 -3
- package/src/tools/delete.md +1 -0
- package/src/tools/edit-batch.mjs +92 -0
- package/src/tools/edit-diff.mjs +265 -0
- package/src/tools/edit.md +11 -6
- package/src/tools/execute.md +8 -8
- package/src/tools/execute.mjs +31 -35
- package/src/tools/file.mjs +26 -114
- package/src/tools/file_ops.md +3 -2
- package/src/tools/get_current_time.md +3 -1
- package/src/tools/git.md +1 -1
- package/src/tools/git.mjs +8 -16
- package/src/tools/hashline_edit.md +2 -0
- package/src/tools/index.mjs +3 -2
- package/src/tools/insert_after.md +2 -1
- package/src/tools/lint.md +3 -1
- package/src/tools/linter.mjs +9 -37
- package/src/tools/lsp.md +4 -1
- package/src/tools/patch.mjs +84 -13
- package/src/tools/pdf-parse-text.mjs +497 -0
- package/src/tools/pdf-parse-xref.mjs +499 -0
- package/src/tools/pdf.mjs +155 -0
- package/src/tools/question.md +2 -1
- package/src/tools/read.md +1 -0
- package/src/tools/read_pdf.md +21 -0
- package/src/tools/repomap.mjs +1 -1
- package/src/tools/shared.mjs +11 -32
- package/src/tools/system.mjs +6 -21
- package/src/tools/tree.md +2 -1
- package/src/tools/web.mjs +5 -3
- package/src/tools/websearch.md +2 -1
- package/src/tools/write.md +2 -0
- package/src/traces/trace-store.mjs +224 -0
- package/src/tui/agent-turn.mjs +387 -24
- package/src/tui/clipboard.mjs +17 -6
- package/src/tui/cmd-config.mjs +29 -9
- package/src/tui/cmd-eng.mjs +1 -0
- package/src/tui/cmd-extract.mjs +1 -1
- package/src/tui/cmd-mcp-form.mjs +197 -0
- package/src/tui/cmd-mcp.mjs +264 -114
- package/src/tui/cmd-think.mjs +1 -1
- package/src/tui/index.mjs +49 -95
- package/src/tui/interaction.mjs +41 -3
- package/src/tui/key-handler.mjs +105 -143
- package/src/tui/key-modes.mjs +215 -0
- package/src/tui/layout.mjs +22 -1
- package/src/tui/mouse.mjs +41 -1
- package/src/tui/pickers.mjs +73 -7
- package/src/tui/render-conversation.mjs +13 -161
- package/src/tui/render-frame.mjs +45 -20
- package/src/tui/render-loop.mjs +4 -1
- package/src/tui/render-segments.mjs +165 -0
- package/src/tui/render.mjs +4 -4
- package/src/tui/startup.mjs +40 -2
- package/src/tui/subagent-blocks.mjs +404 -111
- package/src/tui/subagent-panel.mjs +88 -13
- package/src/tui/tool-args.mjs +10 -2
- package/src/tui/tool-events.mjs +172 -95
- package/src/tui/update-notice.mjs +72 -0
- package/src/tui/wizard.mjs +36 -6
- package/src/agent-tools/escalate.mjs +0 -179
- package/src/tools/exec-prelude.mjs +0 -84
package/src/tui/index.mjs
CHANGED
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
* config-helpers.mjs — persistRaw / syncProviderField / maskKey
|
|
15
15
|
* clipboard.mjs — clipboard image paste
|
|
16
16
|
* distill-cmd.mjs — /distill command
|
|
17
|
+
* mouse.mjs — SGR mouse parsing + dispatch assembly (createMouseDispatch)
|
|
18
|
+
* update-notice.mjs — background update notice + startup check
|
|
17
19
|
*/
|
|
18
20
|
|
|
19
21
|
import { emitKeypressEvents } from "node:readline"
|
|
@@ -30,22 +32,30 @@ import { createPickers } from "./pickers.mjs"
|
|
|
30
32
|
import { runDistill as runDistillImpl } from "./distill-cmd.mjs"
|
|
31
33
|
import { createInteraction } from "./interaction.mjs"
|
|
32
34
|
import { pasteClipboardImage as pasteClipboardImageImpl, insertPastedText, translateShiftEnter, stripKeyboardProtocol } from "./clipboard.mjs"
|
|
33
|
-
import { parseMouseClicks,
|
|
35
|
+
import { parseMouseClicks, handleWheel, createMouseDispatch } from "./mouse.mjs"
|
|
34
36
|
import { runAgentTurn } from "./agent-turn.mjs"
|
|
35
37
|
import { createKeyHandler, convMaxScroll } from "./key-handler.mjs"
|
|
36
|
-
import { showStartup, backgroundIndex,
|
|
37
|
-
import {
|
|
38
|
+
import { showStartup, backgroundIndex, createLoadOlder } from "./startup.mjs"
|
|
39
|
+
import { shiftFreezeAnchors } from "./subagent-blocks.mjs"
|
|
38
40
|
import { createConfigHelpers } from "./config-helpers.mjs"
|
|
41
|
+
import { createUpdateNotice, pendingNoticeReady } from "./update-notice.mjs"
|
|
39
42
|
|
|
40
|
-
|
|
41
|
-
export function upgradeFailureText(code, output) {
|
|
42
|
-
const tail = (output ?? "").trimEnd().split("\n").slice(-3).join("\n")
|
|
43
|
-
return `✗ Upgrade failed (exit ${code}). Run \`thincoder upgrade\` manually.${tail ? `\n${tail}` : ""}`
|
|
44
|
-
}
|
|
43
|
+
export { upgradeFailureText, pendingNoticeReady } from "./update-notice.mjs"
|
|
45
44
|
|
|
46
|
-
/**
|
|
47
|
-
|
|
48
|
-
|
|
45
|
+
/**
|
|
46
|
+
* SESSION.md §8 D-S2 — TUI 启动首帧前的 provider 重选流程:
|
|
47
|
+
* provider 无效(`_providerInvalid` 标记或 provider 为 null)→ 先弹模型选择 picker
|
|
48
|
+
* (复用 openModelPicker,展示当前可用 providers);用户选定后继续正常启动。
|
|
49
|
+
* 选择取消(Esc)→ 仍进入 TUI,推送提示行("未配置有效 provider,可用 /model 选择或
|
|
50
|
+
* /provider 配置")——绝不因无 provider 拒绝进入。返回 true 表示弹过选择流程。
|
|
51
|
+
*/
|
|
52
|
+
export async function promptProviderIfInvalid(agent, openModelPicker, pushLine) {
|
|
53
|
+
if (!(agent._providerInvalid || !agent.provider)) return false
|
|
54
|
+
await openModelPicker()
|
|
55
|
+
if (!agent.provider) {
|
|
56
|
+
pushLine("未配置有效 provider,可用 /model 选择或 /provider 配置", C.warn)
|
|
57
|
+
}
|
|
58
|
+
return true
|
|
49
59
|
}
|
|
50
60
|
|
|
51
61
|
/**
|
|
@@ -96,7 +106,7 @@ export async function startTUI(agent, opts = {}) {
|
|
|
96
106
|
reasoning: "", // thinking stream buffer (dimmed display)
|
|
97
107
|
completion: null, // Tab completion state { candidates, index }
|
|
98
108
|
|
|
99
|
-
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 } } — 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
|
|
109
|
+
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(冻结锚点) } } — 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
|
|
100
110
|
currentTool: null, // currently executing tool name (shown in status bar)
|
|
101
111
|
processingStarted: 0, // current turn start time (status bar timer)
|
|
102
112
|
status: "Ready",
|
|
@@ -111,7 +121,13 @@ export async function startTUI(agent, opts = {}) {
|
|
|
111
121
|
_historyLoaded: 0, // messages loaded from the TAIL of _fullHistory
|
|
112
122
|
_historyTotal: 0, // total messages in the restored session
|
|
113
123
|
_hasOlder: false, // more earlier messages remain unloaded
|
|
124
|
+
_agent: null, // §19.6 D-P1 面板镜像挂载点:startTUI 装配时置 agent 引用——subagent-blocks
|
|
125
|
+
// syncPanelSnapshot 经此写 agent._panelSnapshot(块级状态变更即刷——subagent
|
|
126
|
+
// action:"panel" 读——模型可查面板视图 + 门控补发冻结)。headless/VS Code 无此
|
|
127
|
+
// 挂载 = 无镜像 → panel 动作恒降级池视图(CLI-only 完整能力——AC-P4)。
|
|
114
128
|
}
|
|
129
|
+
state._agent = agent
|
|
130
|
+
agent._panelSnapshot = [] // 镜像初始态 = 空面板(非"无镜像"——[] 与 undefined 区分)
|
|
115
131
|
|
|
116
132
|
// On session restore, if all tasks are completed, auto-collapse the todo panel (match runtime behavior)
|
|
117
133
|
if (state.tasks.length > 0 && state.tasks.every((t) => t.status === "done")) {
|
|
@@ -137,38 +153,6 @@ export async function startTUI(agent, opts = {}) {
|
|
|
137
153
|
let pasteMode = false
|
|
138
154
|
let pasteAccum = ""
|
|
139
155
|
|
|
140
|
-
/** 懒加载更早历史(2026-08-31 用户约定:"滚动到头自动加载"——滚轮/PgUp 到顶皆触发;
|
|
141
|
-
* 2026-08-31 前只挂 PgUp 键 = 违约,滚轮到头无反应)。加载后滚动补偿保持锚定。
|
|
142
|
-
* 外层作用域:data 回调(滚轮分支)与 createKeyHandler ctx(PgUp 分支)共用。 */
|
|
143
|
-
const loadOlder = () => {
|
|
144
|
-
if (!state._hasOlder) return
|
|
145
|
-
const full = agent._fullHistory ?? []
|
|
146
|
-
const loaded = state._historyLoaded
|
|
147
|
-
const start = Math.max(0, full.length - loaded - HISTORY_PAGE_MESSAGES)
|
|
148
|
-
const end = full.length - loaded
|
|
149
|
-
if (start >= end) return
|
|
150
|
-
|
|
151
|
-
const d = state.dims ? state.dims.get() : {}
|
|
152
|
-
const cols = d.cols ?? ((state.dims?.get() ?? {}).cols ?? (process.stdout.columns || 80))
|
|
153
|
-
const before = countConvLines(state, cols, d.rows ?? (process.stdout.rows || 24))
|
|
154
|
-
|
|
155
|
-
if (state.lines[0]?.text?.startsWith("… ")) state.lines.shift()
|
|
156
|
-
state._lineIdCounter = state._lineIdCounter ?? 0
|
|
157
|
-
const older = historyToLines(full, start, end)
|
|
158
|
-
for (const l of older) l._lineId = ++state._lineIdCounter
|
|
159
|
-
state.lines.unshift(...older)
|
|
160
|
-
state._historyLoaded += end - start
|
|
161
|
-
state._hasOlder = start > 0
|
|
162
|
-
if (state._hasOlder) {
|
|
163
|
-
state.lines.unshift({ text: `… ${start} more earlier messages (scroll to top to load)`, color: C.dim })
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
const after = countConvLines(state, cols, (state.dims?.get() ?? {}).rows ?? (process.stdout.rows || 24))
|
|
167
|
-
state.scroll += Math.max(0, after - before)
|
|
168
|
-
render()
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
|
|
172
156
|
process.stdin.on("data", (chunk) => {
|
|
173
157
|
try {
|
|
174
158
|
|
|
@@ -221,7 +205,6 @@ export async function startTUI(agent, opts = {}) {
|
|
|
221
205
|
|
|
222
206
|
// Scroll wheel: \x1b[<64;col;rowM = up, \x1b[<65;col;rowM = down(3 lines each)
|
|
223
207
|
// 2026-08-31:坐标命中展开块内容行 → 块内滚动(handleWheel);未命中 → 会话滚动(现状)
|
|
224
|
-
// eslint-disable-next-line no-control-regex -- 有意为之:控制字符协议/转义序列剥离正则(ANSI/⟦ev⟧/SGR/history 双线分隔)
|
|
225
208
|
for (const m of text.matchAll(/\x1b\[<(\d+);(\d+);(\d+)([Mm])/g)) {
|
|
226
209
|
const button = Number(m[1])
|
|
227
210
|
if (button === 64 || button === 65) {
|
|
@@ -251,9 +234,7 @@ export async function startTUI(agent, opts = {}) {
|
|
|
251
234
|
}
|
|
252
235
|
|
|
253
236
|
// Strip complete mouse sequences; keep incomplete tail for reassembly with next chunk
|
|
254
|
-
// eslint-disable-next-line no-control-regex -- 有意为之:控制字符协议/转义序列剥离正则(ANSI/⟦ev⟧/SGR/history 双线分隔)
|
|
255
237
|
text = text.replace(/\x1b\[<\d+;\d+;\d+[Mm]/g, "")
|
|
256
|
-
// eslint-disable-next-line no-control-regex -- 有意为之:控制字符协议/转义序列剥离正则(ANSI/⟦ev⟧/SGR/history 双线分隔)
|
|
257
238
|
const tail = text.match(/\x1b\[<[\d;]*$/)
|
|
258
239
|
if (tail) {
|
|
259
240
|
mousePending = tail[0]
|
|
@@ -282,6 +263,10 @@ export async function startTUI(agent, opts = {}) {
|
|
|
282
263
|
if (state.lines.length > 5000) {
|
|
283
264
|
state.lines.splice(0, 1000)
|
|
284
265
|
state.lines.unshift({ text: `... [earlier messages trimmed — ${state.lines.length} lines remaining]`, color: C.dim })
|
|
266
|
+
// 2026-09-03 修复轮(冻结锚点):头裁对在途 settled 锚点整体前移(锚点是绝对
|
|
267
|
+
// 流位置)——不校正则池空补发冻结(freezeSubTaskLines splice)落点漂移;校正量
|
|
268
|
+
// = 净位移(裁 1000 补 1 标记行 → −999,code review round1 #3)。
|
|
269
|
+
shiftFreezeAnchors(state, 1000)
|
|
285
270
|
}
|
|
286
271
|
render()
|
|
287
272
|
}
|
|
@@ -310,6 +295,10 @@ export async function startTUI(agent, opts = {}) {
|
|
|
310
295
|
pushLine)
|
|
311
296
|
const { render, scheduleRender } = renderLoop
|
|
312
297
|
|
|
298
|
+
// 懒加载更早历史(startup.mjs createLoadOlder——D-S1b):滚轮/PgUp 到顶自动加载;
|
|
299
|
+
// 声明在此(render 已可用)——data 回调(滚轮分支)与 createKeyHandler ctx(PgUp)共用。
|
|
300
|
+
const loadOlder = createLoadOlder({ agent, state, render })
|
|
301
|
+
|
|
313
302
|
// Resize events are genuine dimension changes on every terminal (2026-08-31
|
|
314
303
|
// simplification: the earlier settle-timer/double-confirm machinery was built
|
|
315
304
|
// on the misdiagnosed ConPTY-stale hypothesis and even stalled drag-shrink).
|
|
@@ -368,7 +357,7 @@ export async function startTUI(agent, opts = {}) {
|
|
|
368
357
|
}
|
|
369
358
|
|
|
370
359
|
// Interaction primitives: permission approval + Q&A input, implemented in interaction.mjs
|
|
371
|
-
const { askPermission, askQuestion } = createInteraction({
|
|
360
|
+
const { askPermission, askQuestion, askBatchPermission } = createInteraction({
|
|
372
361
|
agent, state, pushLine, pushLabel, render, summarize,
|
|
373
362
|
})
|
|
374
363
|
|
|
@@ -378,7 +367,7 @@ export async function startTUI(agent, opts = {}) {
|
|
|
378
367
|
// Agent loop: implemented in agent-turn.mjs
|
|
379
368
|
const turnCtx = {
|
|
380
369
|
agent, state, pushLine, pushLabel, render, scheduleRender: render, ensureAssistantLabel,
|
|
381
|
-
askPermission, askQuestion, handleSlash: null,
|
|
370
|
+
askPermission, askQuestion, askBatchPermission, handleSlash: null,
|
|
382
371
|
get assistantLabeled() { return assistantLabeled },
|
|
383
372
|
set assistantLabeled(v) { assistantLabeled = v },
|
|
384
373
|
}
|
|
@@ -437,58 +426,23 @@ export async function startTUI(agent, opts = {}) {
|
|
|
437
426
|
}
|
|
438
427
|
})
|
|
439
428
|
|
|
440
|
-
//
|
|
441
|
-
const onMouseClick
|
|
442
|
-
const mouseCtx = () => ({ state, render })
|
|
429
|
+
// 鼠标点击/滚轮 ctx 装配(mouse.mjs createMouseDispatch——D-S1a:cancelSubagent/onMouseClick/mouseCtx)
|
|
430
|
+
const { onMouseClick, mouseCtx } = createMouseDispatch({ agent, state, pushLine, render, popPicker })
|
|
443
431
|
|
|
444
432
|
// ---------------------------------------------------------- Startup screen + background indexing
|
|
445
433
|
|
|
434
|
+
// SESSION.md §8 D-S2:startTUI 首帧前 —— provider 无效(_providerInvalid / provider 为 null)
|
|
435
|
+
// → 先弹模型选择 picker(keyStream 已挂 keypress,Esc/Enter 可用);Esc → 提示行,仍进 TUI
|
|
436
|
+
await promptProviderIfInvalid(agent, () => openModelPicker(), pushLine)
|
|
437
|
+
|
|
446
438
|
showStartup({ agent, state, opts, pushLine, pushLabel, render, startWizard })
|
|
447
439
|
backgroundIndex({ agent, state, render })
|
|
448
440
|
|
|
449
|
-
// Check for updates (non-blocking, after startup screen)
|
|
450
|
-
//
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
{ type: "item", text: "Upgrade now", action: "upgrade" },
|
|
455
|
-
{ type: "item", text: "Later", action: "later" },
|
|
456
|
-
])
|
|
457
|
-
if (sel?.action !== "upgrade") return
|
|
458
|
-
pushLabel(`❯ Upgrade`, ansi.bold + C.tool)
|
|
459
|
-
pushLine(`Upgrading to ${result.latest}...`, C.tool)
|
|
460
|
-
const { exec } = await import("node:child_process")
|
|
461
|
-
const cp = exec("npm install -g thincoder@latest", { windowsHide: true })
|
|
462
|
-
let stdout = ""
|
|
463
|
-
cp.stdout?.on("data", (d) => { stdout += d })
|
|
464
|
-
cp.stderr?.on("data", (d) => { stdout += d })
|
|
465
|
-
cp.on("close", (code) => {
|
|
466
|
-
if (code === 0) {
|
|
467
|
-
pushLine(`✓ Upgraded to ${result.latest}. Restart to apply.`, C.tool)
|
|
468
|
-
} else {
|
|
469
|
-
pushLine(upgradeFailureText(code, stdout), C.error)
|
|
470
|
-
}
|
|
471
|
-
render()
|
|
472
|
-
})
|
|
473
|
-
}
|
|
474
|
-
;(async () => {
|
|
475
|
-
try {
|
|
476
|
-
const { readFileSync } = await import("node:fs")
|
|
477
|
-
const pkg = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8"))
|
|
478
|
-
const { checkForUpdate } = await import("../upgrade.mjs")
|
|
479
|
-
const result = await checkForUpdate(pkg.version)
|
|
480
|
-
if (result?.newer) {
|
|
481
|
-
// Defer: if wizard is still active, just show a dim line
|
|
482
|
-
if (state.wizard) {
|
|
483
|
-
pushLine(`Tip: thincoder ${result.latest} is available (run /upgrade later or restart)`, C.dim)
|
|
484
|
-
render()
|
|
485
|
-
} else {
|
|
486
|
-
state.pendingNotice = result
|
|
487
|
-
render()
|
|
488
|
-
}
|
|
489
|
-
}
|
|
490
|
-
} catch { /* network error or timeout — silently skip */ }
|
|
491
|
-
})()
|
|
441
|
+
// Check for updates (non-blocking, after startup screen)——实现 update-notice.mjs
|
|
442
|
+
// (D-S1c):有 picker 打开时不硬抢——挂到 state.pendingNotice,picker 全部关闭后由
|
|
443
|
+
// doRender 弹出(showUpdateNotice 经 renderLoop ctx getter 懒引用)。
|
|
444
|
+
const { showUpdateNotice, checkUpdates } = createUpdateNotice({ state, showPicker, pushLine, pushLabel, render })
|
|
445
|
+
checkUpdates()
|
|
492
446
|
}
|
|
493
447
|
|
|
494
448
|
function summarize(obj) {
|
package/src/tui/interaction.mjs
CHANGED
|
@@ -42,7 +42,16 @@ export function createInteraction(ctx) {
|
|
|
42
42
|
}
|
|
43
43
|
if (base === "delete") return [`${args.path}${args.force ? " (force: also delete tracked files)" : ""}`]
|
|
44
44
|
if (base === "subagent") return cap(args.task ?? "", 500).split("\n")
|
|
45
|
-
if (base === "
|
|
45
|
+
if (base === "memory") {
|
|
46
|
+
// §6 action-routed preview (D-M5): put shows content, batch delete/clear show gate args
|
|
47
|
+
const action = String(args.action ?? "")
|
|
48
|
+
if (action === "put") return [`[${args.type ?? ""}] ${args.title ?? ""}`, ...cap(args.content ?? "", 500).split("\n")]
|
|
49
|
+
if (action === "delete") return args.id
|
|
50
|
+
? [`delete single: ${args.id} (scope ${args.scope})`]
|
|
51
|
+
: [`batch delete scope=${args.scope} type=${args.type ?? ""} keyword=${args.keyword ?? ""}`, `confirm=${args.confirm}`]
|
|
52
|
+
if (action === "clear") return [`clear personal memory`, `scope=${args.scope} confirm=${args.confirm}`]
|
|
53
|
+
return [cap(summarize(args), 300)]
|
|
54
|
+
}
|
|
46
55
|
return [cap(summarize(args), 300)]
|
|
47
56
|
}
|
|
48
57
|
|
|
@@ -62,6 +71,33 @@ export function createInteraction(ctx) {
|
|
|
62
71
|
})
|
|
63
72
|
}
|
|
64
73
|
|
|
74
|
+
/** Batch permission ask (AGENT-LOOP.md §16 D-B1): one merged prompt covering N
|
|
75
|
+
* non-readonly tools from the same toolCalls array — approve all / one by one /
|
|
76
|
+
* deny. Verdicts resolve as "approveAll" | "oneByOne" | "deny". */
|
|
77
|
+
const BATCH_PREVIEW_LINES_PER_TOOL = 8
|
|
78
|
+
function askBatchPermission({ tools, count }) {
|
|
79
|
+
if (agent.autoApprove) {
|
|
80
|
+
pushLine(` [auto] ${count} tools: ${tools.map((t) => t.name).join(", ")}`, C.warn)
|
|
81
|
+
return Promise.resolve("approveAll")
|
|
82
|
+
}
|
|
83
|
+
const names = [...new Set(tools.map((t) => t.name))]
|
|
84
|
+
const label = `${count} tools need permission: ${names.join(", ")}`
|
|
85
|
+
// Preview: one compact block per tool (name header + first N formatted lines)
|
|
86
|
+
state.permissionPreview = [
|
|
87
|
+
label,
|
|
88
|
+
...tools.flatMap((t) => {
|
|
89
|
+
const lines = formatPermission(t.name, t.args)
|
|
90
|
+
const shown = lines.slice(0, BATCH_PREVIEW_LINES_PER_TOOL)
|
|
91
|
+
return [` ${t.name}:`, ...shown.map((l) => ` ${l}`), ...(lines.length > shown.length ? [` … (${lines.length - shown.length} more lines)`] : [])]
|
|
92
|
+
}),
|
|
93
|
+
]
|
|
94
|
+
return new Promise((resolve) => {
|
|
95
|
+
state.permission = { name: label, args: {}, resolve, batch: { tools, count } }
|
|
96
|
+
state.status = `Waiting: ${label}`
|
|
97
|
+
render()
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
|
|
65
101
|
function askQuestion(text, options = []) {
|
|
66
102
|
// only one question at a time: question is a read-only tool on a parallel channel,
|
|
67
103
|
// a second concurrent one is rejected directly; otherwise the later one overwrites
|
|
@@ -71,10 +107,12 @@ export function createInteraction(ctx) {
|
|
|
71
107
|
}
|
|
72
108
|
if (!options.length) {
|
|
73
109
|
// free text: open input mode for user typing, Enter to submit
|
|
110
|
+
// q.cursor 装配(TUI-INPUT-BOX.md §7.2/§7.5 #10b):answer 存 codepoint 数组(同
|
|
111
|
+
// state.input 语义——emoji 不劈半),进入自由文本态即 cursor = answer.length(= 0)。
|
|
74
112
|
pushLabel(`❯ Question`, ansi.bold + C.tool)
|
|
75
113
|
for (const line of text.split("\n")) pushLine(` ${line}`, C.text)
|
|
76
114
|
return new Promise((resolve) => {
|
|
77
|
-
state.question = { text, options: [], resolve }
|
|
115
|
+
state.question = { text, options: [], answer: [], cursor: 0, resolve }
|
|
78
116
|
state.status = "Waiting for answer..."
|
|
79
117
|
render()
|
|
80
118
|
})
|
|
@@ -91,5 +129,5 @@ export function createInteraction(ctx) {
|
|
|
91
129
|
})
|
|
92
130
|
}
|
|
93
131
|
|
|
94
|
-
return { askPermission, askQuestion, formatPermission }
|
|
132
|
+
return { askPermission, askQuestion, formatPermission, askBatchPermission }
|
|
95
133
|
}
|
package/src/tui/key-handler.mjs
CHANGED
|
@@ -3,12 +3,11 @@ import { readClipboardText, insertPastedText } from "./clipboard.mjs"
|
|
|
3
3
|
import { computeLayout } from "./layout.mjs"
|
|
4
4
|
import { handleSearchKey } from "./key-handler-search.mjs"
|
|
5
5
|
import { countConvLines } from "./render-conversation.mjs"
|
|
6
|
-
import {
|
|
6
|
+
import { handlePermissionMode, handleQuestionMode, handleInterruptMode } from "./key-modes.mjs"
|
|
7
7
|
|
|
8
8
|
/** Current conversation max scroll offset (display lines beyond the visible panel). */
|
|
9
9
|
export function convMaxScroll(state) {
|
|
10
|
-
// Single source (
|
|
11
|
-
const d = state.dims ? state.dims.get() : {}
|
|
10
|
+
const d = state.dims ? state.dims.get() : {} // Single source (ConPTY instability, 2026-08-30) — cached dims
|
|
12
11
|
const cols = d.cols ?? ((state.dims?.get() ?? {}).cols ?? (process.stdout.columns || 80))
|
|
13
12
|
const rows = d.rows ?? (process.stdout.rows || 24)
|
|
14
13
|
const layout = computeLayout(state, { cols, rows })
|
|
@@ -25,119 +24,99 @@ export function createKeyHandler(ctx) {
|
|
|
25
24
|
const { agent, state, render, popPicker, renderPickerLines, handleSlash, handleTab, submit, pasteClipboardImage, wizardChooseProvider, wizardSubmitText, cancelWizard, wizardProviderItems, renderWizard, pushLine, cleanup, showPicker, loadOlder } = ctx
|
|
26
25
|
|
|
27
26
|
return function onKeypress(str, key = {}) {
|
|
28
|
-
// permission confirm
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
const isContinue = state.permission.name === "continue"
|
|
32
|
-
const validKeys = isContinue ? ["y", "n"] : ["y", "n", "a"]
|
|
33
|
-
if (validKeys.includes(answer) || key.name === "escape") {
|
|
34
|
-
const { resolve, name } = state.permission
|
|
35
|
-
state.permission = null
|
|
36
|
-
state.permissionPreview = []
|
|
37
|
-
state.status = "Processing..."
|
|
38
|
-
if (answer === "a" && !isContinue) {
|
|
39
|
-
agent.autoApprove = true
|
|
40
|
-
agent._pendingReminders = agent._pendingReminders ?? []
|
|
41
|
-
agent._pendingReminders.push("[System reminder: AUTO mode is now ON. All tool calls are automatically approved. Use /auto to disable.]")
|
|
42
|
-
pushLine(` [auto] AUTO ON: tool calls no longer prompt for approval (/auto to disable)`, C.warn)
|
|
43
|
-
}
|
|
44
|
-
const approved = answer === "y" || (answer === "a" && !isContinue)
|
|
45
|
-
// leave trail: record approval/denial in conversation (continue prompt has its own output, don't duplicate)
|
|
46
|
-
if (!isContinue) {
|
|
47
|
-
pushLine(` [${approved ? "approved" : "denied"}] ${name}`, approved ? C.dim : C.error)
|
|
48
|
-
}
|
|
49
|
-
resolve(approved)
|
|
50
|
-
render()
|
|
51
|
-
}
|
|
52
|
-
return
|
|
53
|
-
}
|
|
27
|
+
// permission confirm: y/n/a (a = approve + AUTO ON); batch (§16 D-B1): a/o/n (Esc = deny)
|
|
28
|
+
// ——模态实现 key-modes.mjs handlePermissionMode(2026-09-03 D-S4)
|
|
29
|
+
if (handlePermissionMode(str, key, { state, agent, pushLine, render })) return
|
|
54
30
|
|
|
55
|
-
// question tool callback: free text / option selection
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
if (q.options.length > 0) {
|
|
59
|
-
// options mode: ↑↓ select, Enter confirm, Esc cancel
|
|
60
|
-
if (key.name === "escape") {
|
|
61
|
-
q.resolve("")
|
|
62
|
-
state.question = null
|
|
63
|
-
state.status = "Processing..."
|
|
64
|
-
render()
|
|
65
|
-
} else if (key.name === "up") {
|
|
66
|
-
q.selected = Math.max(0, (q.selected ?? 0) - 1)
|
|
67
|
-
render()
|
|
68
|
-
} else if (key.name === "down") {
|
|
69
|
-
q.selected = Math.min(q.options.length - 1, (q.selected ?? 0) + 1)
|
|
70
|
-
render()
|
|
71
|
-
} else if (key.name === "return") {
|
|
72
|
-
const answer = q.options[q.selected ?? 0]
|
|
73
|
-
if (answer === QUESTION_CUSTOM) {
|
|
74
|
-
// Switch to free-text mode — the user wants to type their own answer.
|
|
75
|
-
q.options = []
|
|
76
|
-
q.answer = ""
|
|
77
|
-
q.selected = undefined
|
|
78
|
-
state.status = "Waiting for answer..."
|
|
79
|
-
render()
|
|
80
|
-
return
|
|
81
|
-
}
|
|
82
|
-
q.resolve(answer)
|
|
83
|
-
state.question = null
|
|
84
|
-
state.status = "Processing..."
|
|
85
|
-
pushLine(` → ${answer}`, C.tool)
|
|
86
|
-
render()
|
|
87
|
-
}
|
|
88
|
-
} else {
|
|
89
|
-
// free text: type answer, Enter submit, Esc cancel
|
|
90
|
-
if (key.name === "escape") {
|
|
91
|
-
q.resolve("")
|
|
92
|
-
state.question = null
|
|
93
|
-
state.status = "Processing..."
|
|
94
|
-
render()
|
|
95
|
-
} else if (key.name === "return") {
|
|
96
|
-
if (q._pasting) return // block Enter while paste is in flight
|
|
97
|
-
const answer = (q.answer ?? "").trim()
|
|
98
|
-
q.resolve(answer || "")
|
|
99
|
-
state.question = null
|
|
100
|
-
state.status = "Processing..."
|
|
101
|
-
pushLine(` → ${answer || "(empty)"}`, C.tool)
|
|
102
|
-
render()
|
|
103
|
-
} else if (key.name === "backspace") {
|
|
104
|
-
q.answer = (q.answer ?? "").slice(0, -1)
|
|
105
|
-
render()
|
|
106
|
-
} else if (key.ctrl && !key.alt && key.name === "v") {
|
|
107
|
-
// Ctrl+V paste: read clipboard text (fires when the terminal passes Ctrl+V through
|
|
108
|
-
// as a key event; bracketed-paste terminals are handled upstream in the stdin handler)
|
|
109
|
-
if (q._pasting) return
|
|
110
|
-
q._pasting = true
|
|
111
|
-
readClipboardText().then((text) => {
|
|
112
|
-
q._pasting = false
|
|
113
|
-
if (text) {
|
|
114
|
-
insertPastedText(state, text)
|
|
115
|
-
render()
|
|
116
|
-
}
|
|
117
|
-
}).catch((e) => {
|
|
118
|
-
q._pasting = false
|
|
119
|
-
console.error(`[tui] clipboard paste failed: ${e.message}`)
|
|
120
|
-
})
|
|
121
|
-
} else if (str && !key.ctrl && !key.meta) {
|
|
122
|
-
q.answer = (q.answer ?? "") + str
|
|
123
|
-
render()
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
return
|
|
127
|
-
}
|
|
31
|
+
// question tool callback: free text / option selection——模态实现
|
|
32
|
+
// key-modes.mjs handleQuestionMode(D-S4)
|
|
33
|
+
if (handleQuestionMode(str, key, { state, pushLine, render })) return
|
|
128
34
|
|
|
129
35
|
// Search mode: Ctrl+F to enter, Ctrl+N/Ctrl+P (or Ctrl+G/Ctrl+R) navigate, Esc exit
|
|
130
36
|
if (handleSearchKey(str, key, state, render)) return
|
|
131
37
|
|
|
132
|
-
|
|
38
|
+
if (key.ctrl && key.name === "c") {
|
|
133
39
|
// picker 打开时 Ctrl+C = 取消当前 picker(等同 Esc),不杀进程
|
|
134
40
|
if (state.picker) {
|
|
135
41
|
popPicker(null)
|
|
136
42
|
return
|
|
137
43
|
}
|
|
44
|
+
// §17.6(2026-09-03):武装窗口内第二次 Ctrl+C = 显式全停(D-C4 /abort 语义)。
|
|
45
|
+
// 在状态路由前检查(picker 取消分支例外——picker 打开时 Ctrl+C 语义 = 取消
|
|
46
|
+
// picker,武装让位;场景极窄——3s 窗口内需另有 picker 被打开)——两次按下之间
|
|
47
|
+
// 状态会迁移(首按停回合 → 释放窗口/挂起会话启动),武装必须跨态桥接:只查挂起
|
|
48
|
+
// 分支会让非挂起 processing 首按后的二按落入空闲退出分支(三按误退出应用)。
|
|
49
|
+
if (state.suspAbortArmed) {
|
|
50
|
+
if (ctx.suspArmTimer) clearTimeout(ctx.suspArmTimer)
|
|
51
|
+
state.suspAbortArmed = false
|
|
52
|
+
// 无可停目标(无 processing 回合 / 无会话 abort 集合 / 未挂起)时不吞键:
|
|
53
|
+
// 首按停的是普通回合(无后台池)——二按落回下方空闲退出分支(exitArmed 双
|
|
54
|
+
// 确认),不打印误导性的 "[Aborting background subagents…]"(advisor round1
|
|
55
|
+
// 🟡——停回合后连按退出的第二次按下被空转吞掉 → 退出从 3 按变 4 按)。
|
|
56
|
+
const hasStopTarget = (state.processing && state.controller) ||
|
|
57
|
+
(agent._sessionAbortAll?.length ?? 0) > 0 ||
|
|
58
|
+
(agent._sessionAbort != null && !agent._sessionAbort.signal.aborted) ||
|
|
59
|
+
state.suspended
|
|
60
|
+
if (hasStopTarget) {
|
|
61
|
+
// 当前回合平 abort(无 interrupt——命中 agent.mjs 回合收尾清池分支——全停)
|
|
62
|
+
if (state.processing && state.controller) state.controller.abort()
|
|
63
|
+
// 会话 abort 集合 = 链条内全部 controller(含 Ctrl+I/ContinueError 重建的旧
|
|
64
|
+
// controller——children 不逃逸,round1 偏差 #3)。挂起态才标记 _suspAborted +
|
|
65
|
+
// 唤醒 driver(driver 收尾清池)——非挂起语境置位会粘滞阻塞未来挂起会话重入
|
|
66
|
+
// (round2 偏差 #1 语义)。
|
|
67
|
+
for (const c of agent._sessionAbortAll ?? (agent._sessionAbort ? [agent._sessionAbort] : [])) c?.abort()
|
|
68
|
+
if (state.suspended) {
|
|
69
|
+
state._suspAborted = true
|
|
70
|
+
state._suspWake?.()
|
|
71
|
+
}
|
|
72
|
+
pushLine("[Aborting background subagents…]", C.warn)
|
|
73
|
+
render()
|
|
74
|
+
return
|
|
75
|
+
}
|
|
76
|
+
// 无目标可停 → 落空继续到下方分支(processing 已停 → 空闲态 exitArmed 双确认)
|
|
77
|
+
}
|
|
78
|
+
if (state.suspended) {
|
|
79
|
+
// §17 D-S9 + round2 偏差 #4(2026-09-02)+ §17.6 修订(2026-09-03):挂起态
|
|
80
|
+
// Ctrl+C = 武装窗口两级中止——一次按键直接清池中止全部后台子代理的误触代价高
|
|
81
|
+
// (digest 刷屏时用户可能只想停住当前回合);仿空闲态退出武装语义(同下方空闲态
|
|
82
|
+
// exitArmed 双确认分支)。§17.6 修订 ①:中止当前回合的 abort 带 interrupt
|
|
83
|
+
// (无 message)——此前平 abort 命中 agent.mjs 回合收尾清池分支(aborted &&
|
|
84
|
+
// !reason.interrupt → 无条件 _asyncSubagents.clear()),一次首按即误杀全部后台
|
|
85
|
+
// 子代理(2026-09-03 用户两次实测——提示"再按才杀"与实际"一次就全杀"不符);
|
|
86
|
+
// interrupt 排除清池分支——会话与后台保留(agent-turn 区分:无 message 停回合
|
|
87
|
+
// 不续跑):
|
|
88
|
+
// ① 未武装 + 有回合在跑(digest 消化/会话内回合):仅中止当前回合
|
|
89
|
+
// (controller.abort({ interrupt: true }))——会话继续、后台子代理不受
|
|
90
|
+
// 影响,回挂起等待;
|
|
91
|
+
// ② 未武装 + 纯挂起等待:仅提示不清池;
|
|
92
|
+
// ③ 3s 武装窗口内再次 Ctrl+C:彻底中止——上方统一块(二按语义不变)。
|
|
93
|
+
if (state.processing && state.controller) {
|
|
94
|
+
state.controller.abort({ interrupt: true }) // ① 仅中止当前回合(digest/会话内回合),不碰后台池
|
|
95
|
+
pushLine("[stopped current turn — press Ctrl+C again within 3s to abort all background subagents]", C.warn)
|
|
96
|
+
} else {
|
|
97
|
+
const bgActive = [...(agent._asyncSubagents?.values() ?? [])].filter((e) => e.status === "running").length + (agent._asyncQueue?.length ?? 0)
|
|
98
|
+
pushLine(`[abort] Press Ctrl+C again within 3s to abort all background subagents (${bgActive} running)`, C.warn)
|
|
99
|
+
}
|
|
100
|
+
state.suspAbortArmed = true
|
|
101
|
+
if (ctx.suspArmTimer) clearTimeout(ctx.suspArmTimer)
|
|
102
|
+
ctx.suspArmTimer = setTimeout(() => { state.suspAbortArmed = false }, ctx.exitArmDelay ?? 3000)
|
|
103
|
+
ctx.suspArmTimer.unref?.()
|
|
104
|
+
render()
|
|
105
|
+
return
|
|
106
|
+
}
|
|
138
107
|
if (state.processing && state.controller) {
|
|
139
|
-
|
|
140
|
-
|
|
108
|
+
// §17.6 D-C1(2026-09-03 紧急修复——processing 态曾无武装窗口,第一按直接平
|
|
109
|
+
// abort() → 命中 agent.mjs 回合收尾清池分支 → 一次 Ctrl+C 误杀全部后台子代理,
|
|
110
|
+
// 用户两次实测被坑):首按 = interrupt 语义(无 message——停当前回合不续跑——
|
|
111
|
+
// interrupt 排除 agent.mjs 清池分支——后台池保留——agent-turn 区分不重建续跑)
|
|
112
|
+
// + 武装 3s(复用 suspArmTimer/exitArmDelay——与挂起态同构——过期自动复位);
|
|
113
|
+
// 窗口内再按 = 上方统一块全停(平 abort → agent.mjs 清池)。
|
|
114
|
+
state.controller.abort({ interrupt: true })
|
|
115
|
+
pushLine("[stopped current turn — press Ctrl+C again within 3s to abort all background subagents]", C.warn)
|
|
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?.()
|
|
141
120
|
render()
|
|
142
121
|
return
|
|
143
122
|
}
|
|
@@ -162,7 +141,7 @@ export function createKeyHandler(ctx) {
|
|
|
162
141
|
// F1: 显示快捷键帮助
|
|
163
142
|
if (key.name === "f1" && !state.picker && !state.permission && !state.question) {
|
|
164
143
|
showPicker("Keyboard Shortcuts", [
|
|
165
|
-
{ type: "item", text: "Ctrl+C — Cancel/Abort;
|
|
144
|
+
{ type: "item", text: "Ctrl+C — Cancel/Abort; first press stops the turn (or arms), twice within 3s stops all / exits" },
|
|
166
145
|
{ type: "item", text: "Ctrl+I — Interrupt and inject message" },
|
|
167
146
|
{ type: "item", text: "Ctrl+F — Search conversation history" },
|
|
168
147
|
{ type: "item", text: "Shift+Enter / Ctrl+J — Insert newline (multiline input)" },
|
|
@@ -186,34 +165,9 @@ export function createKeyHandler(ctx) {
|
|
|
186
165
|
return
|
|
187
166
|
}
|
|
188
167
|
|
|
189
|
-
// Interrupt prompt mode: type message, Enter to inject, Esc to cancel
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
state.interruptPrompt = null
|
|
193
|
-
render()
|
|
194
|
-
} else if (key.name === "return") {
|
|
195
|
-
const msg = (state.interruptPrompt.text ?? "").trim()
|
|
196
|
-
state.interruptPrompt = null
|
|
197
|
-
if (msg) {
|
|
198
|
-
// Guard: if the turn already finished while the user was typing, the controller
|
|
199
|
-
// may have been replaced or already aborted — don't abort a live turn by mistake.
|
|
200
|
-
if (state.processing && state.controller && !state.controller.signal.aborted) {
|
|
201
|
-
pushLine(` [inject] ${msg}`, C.warn)
|
|
202
|
-
state.controller.abort({ interrupt: true, message: msg })
|
|
203
|
-
} else {
|
|
204
|
-
pushLine(` [inject — turn ended, message queued] ${msg}`, C.dim)
|
|
205
|
-
}
|
|
206
|
-
render()
|
|
207
|
-
}
|
|
208
|
-
} else if (key.name === "backspace") {
|
|
209
|
-
state.interruptPrompt.text = state.interruptPrompt.text.slice(0, -1)
|
|
210
|
-
render()
|
|
211
|
-
} else if (str && !key.ctrl && !key.meta) {
|
|
212
|
-
state.interruptPrompt.text += str.replace(/[\r\n]+/g, "")
|
|
213
|
-
render()
|
|
214
|
-
}
|
|
215
|
-
return
|
|
216
|
-
}
|
|
168
|
+
// Interrupt prompt mode: type message, Enter to inject, Esc to cancel——模态实现
|
|
169
|
+
// key-modes.mjs handleInterruptMode(D-S4)
|
|
170
|
+
if (handleInterruptMode(str, key, { state, pushLine, render })) return
|
|
217
171
|
|
|
218
172
|
// generic list picker: ↑↓/PgUp/PgDn/Home/End 导航,输入即过滤,Enter 选中,Esc 取消
|
|
219
173
|
if (state.picker) {
|
|
@@ -255,7 +209,6 @@ export function createKeyHandler(ctx) {
|
|
|
255
209
|
} else if (str && !key.ctrl && !key.meta) {
|
|
256
210
|
// 输入即过滤;粘贴的多行文本先去换行(与输入框清洗口径一致),仍含控制字符则整段丢弃
|
|
257
211
|
const text = str.replace(/[\r\n]+/g, "")
|
|
258
|
-
// eslint-disable-next-line no-control-regex -- 有意为之:控制字符协议/转义序列剥离正则(ANSI/⟦ev⟧/SGR/history 双线分隔)
|
|
259
212
|
if (text && !/[\x00-\x1f\x7f]/.test(text)) applyFilter(p.filter + text)
|
|
260
213
|
}
|
|
261
214
|
return
|
|
@@ -411,19 +364,28 @@ export function createKeyHandler(ctx) {
|
|
|
411
364
|
return
|
|
412
365
|
}
|
|
413
366
|
if (key.name === "return" || key.name === "enter" || str === "\r") {
|
|
414
|
-
// Multiline newline
|
|
415
|
-
//
|
|
416
|
-
// 2. Shift+Enter: keyboard-enhanced terminals send \x1b[13;2u / \x1b[27;2;13~,
|
|
417
|
-
// translateShiftEnter maps them to \x1b\r → also meta+return
|
|
418
|
-
// 3. Ctrl+J: sends \n (0x0A), readline parses as name:"enter" — the universal
|
|
419
|
-
// fallback: \n and \r are distinct bytes in EVERY terminal, no protocol needed
|
|
420
|
-
// (legacy conhost users: Shift+Enter is a bare \r there, physically
|
|
421
|
-
// indistinguishable from Enter — Ctrl+J is their newline key)
|
|
367
|
+
// Multiline newline (docs/design/TUI-INPUT-BOX.md §1.5): Alt+Enter / Shift+Enter
|
|
368
|
+
// (translateShiftEnter) / Ctrl+J — the universal no-protocol fallback.
|
|
422
369
|
if ((key.name === "return" && key.meta) || key.name === "enter") {
|
|
423
370
|
state.input.splice(state.cursor, 0, "\n")
|
|
424
371
|
state.cursor++
|
|
425
372
|
render()
|
|
426
373
|
} else {
|
|
374
|
+
const text = state.input.join("").trim()
|
|
375
|
+
// §17 D-S5/F3/F7 + 偏差 #1:挂起态(suspended 或释放窗口 _suspPending)Enter =
|
|
376
|
+
// 新回合输入(非打断)——入 pendingInput 由挂起会话调度;输入框零干扰(F3)。
|
|
377
|
+
if ((state.suspended || state._suspPending) && text && !text.startsWith("/")) {
|
|
378
|
+
state.input = []
|
|
379
|
+
state.cursor = 0
|
|
380
|
+
state.history.push(text)
|
|
381
|
+
state.historyIndex = -1
|
|
382
|
+
state._draft = null
|
|
383
|
+
state.pendingInput ??= []
|
|
384
|
+
state.pendingInput.push(text)
|
|
385
|
+
state._suspWake?.()
|
|
386
|
+
render()
|
|
387
|
+
return
|
|
388
|
+
}
|
|
427
389
|
submit().catch((e) => pushLine(`[error] ${e.message}`, C.error))
|
|
428
390
|
}
|
|
429
391
|
return
|