thincoder 0.12.60 → 0.12.61
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 +24 -1
- package/README.md +8 -6
- package/bin/thincoder.mjs +27 -122
- package/package.json +3 -2
- package/src/abort-provenance.mjs +116 -0
- package/src/acp/bridge.mjs +38 -17
- package/src/acp.mjs +6 -1
- package/src/advisor/citations.mjs +83 -21
- package/src/advisor/compaction.mjs +174 -0
- package/src/advisor/loop.mjs +293 -0
- package/src/advisor/messages.mjs +36 -134
- package/src/advisor/project-context.mjs +194 -0
- package/src/advisor/repos.mjs +17 -40
- package/src/advisor/run.mjs +124 -329
- package/src/advisor/truncate.mjs +57 -0
- package/src/advisor.mjs +3 -2
- package/src/agent/completion.mjs +1 -1
- package/src/agent/dispatch.mjs +47 -12
- package/src/agent/helpers.mjs +71 -13
- package/src/agent/record-results.mjs +13 -5
- package/src/agent/relay-prefix.mjs +39 -0
- package/src/agent/run-stages.mjs +21 -6
- package/src/agent/setup-reminders.mjs +16 -9
- package/src/agent/setup.mjs +92 -128
- package/src/agent/spawn-child.mjs +26 -9
- package/src/agent-tools/advisor-async.mjs +70 -180
- package/src/agent-tools/advisor-settle.mjs +231 -0
- package/src/agent-tools/advisor.mjs +69 -20
- package/src/agent-tools/batch-segment.mjs +195 -0
- package/src/agent-tools/consult.mjs +23 -10
- package/src/agent-tools/design-token.mjs +14 -1
- package/src/agent-tools/digest-budget.mjs +76 -0
- package/src/agent-tools/eng.mjs +3 -3
- package/src/agent-tools/escalate-async.mjs +16 -13
- package/src/agent-tools/read-history.mjs +13 -3
- package/src/agent-tools/review-streak.mjs +93 -0
- package/src/agent-tools/settings.mjs +130 -17
- package/src/agent-tools/subagent-actions.mjs +15 -6
- package/src/agent-tools/subagent-async.mjs +66 -14
- package/src/agent-tools/subagent-panel.mjs +22 -15
- package/src/agent-tools/subagent-run.mjs +9 -6
- package/src/agent-tools/subagent-scheduler.mjs +57 -8
- package/src/agent-tools/subagent-spawn.mjs +63 -16
- package/src/agent-tools/subagent.mjs +175 -49
- package/src/agent-tools/verify.mjs +13 -34
- package/src/agent-tools.mjs +1 -0
- package/src/agent.mjs +38 -21
- package/src/cli/distill-command.mjs +2 -2
- package/src/cli/make-agent.mjs +23 -7
- package/src/cli/memory-command.mjs +2 -2
- package/src/cli/setup-wizard.mjs +29 -9
- package/src/completions.mjs +114 -0
- package/src/config-migrate.mjs +70 -0
- package/src/config.mjs +132 -63
- package/src/conventions.mjs +223 -0
- package/src/crash-reports.mjs +7 -2
- package/src/expand-home.mjs +16 -0
- package/src/generate-title.mjs +1 -1
- package/src/hooks.mjs +7 -3
- package/src/memory/code-index.mjs +9 -3
- package/src/memory/code-sync.mjs +70 -31
- package/src/memory/delete.mjs +2 -0
- package/src/memory/docs.mjs +10 -6
- package/src/memory/file-walk.mjs +109 -0
- package/src/memory/schema.mjs +15 -3
- package/src/model-ref.mjs +66 -0
- package/src/model-specs.mjs +42 -8
- package/src/prompt-overlays.mjs +73 -16
- package/src/prompts/advisor-design.md +18 -8
- package/src/prompts/advisor-round1.md +7 -1
- package/src/prompts/advisor-round2.md +13 -2
- package/src/prompts/advisor-round3.md +13 -2
- package/src/prompts/common.md +115 -0
- package/src/prompts/consult-base.md +2 -0
- package/src/prompts/discipline-engineering.md +217 -0
- package/src/prompts/discipline-normal.md +179 -0
- package/src/prompts/persona-coder.md +21 -0
- package/src/prompts/persona-eng-coder.md +37 -0
- package/src/prompts/persona-eng-designer.md +55 -0
- package/src/prompts/persona-engineering.md +54 -0
- package/src/prompts/persona-explore.md +15 -0
- package/src/prompts/persona-normal.md +27 -0
- package/src/prompts/persona-plan.md +26 -0
- package/src/provider/anthropic.mjs +4 -4
- package/src/provider/core.mjs +13 -32
- package/src/provider/errors.mjs +26 -1
- package/src/provider/google.mjs +5 -6
- package/src/provider/index.mjs +2 -1
- package/src/provider/list-models.mjs +93 -0
- package/src/provider/rate.mjs +2 -1
- package/src/provider/responses.mjs +5 -3
- package/src/provider/sse.mjs +3 -4
- package/src/proxy.mjs +9 -14
- package/src/session-slots.mjs +7 -2
- package/src/session.mjs +59 -38
- package/src/token-ttl.mjs +2 -1
- package/src/tools/{system.mjs → bash.mjs} +6 -243
- package/src/tools/file.mjs +30 -10
- package/src/tools/git.md +1 -1
- package/src/tools/git.mjs +15 -34
- package/src/tools/index.mjs +4 -2
- package/src/tools/ops.mjs +20 -7
- package/src/tools/question.md +1 -0
- package/src/tools/question.mjs +26 -0
- package/src/tools/read.md +1 -1
- package/src/tools/read_image.md +1 -1
- package/src/tools/search.mjs +236 -0
- package/src/tui/agent-turn.mjs +32 -13
- package/src/tui/ansi.mjs +2 -0
- package/src/tui/clipboard.mjs +7 -1
- package/src/tui/cmd-advisor.mjs +3 -2
- package/src/tui/cmd-config.mjs +108 -37
- package/src/tui/cmd-eng.mjs +11 -27
- package/src/tui/cmd-exit.mjs +6 -8
- package/src/tui/cmd-model.mjs +14 -12
- package/src/tui/cmd-reindex.mjs +7 -0
- package/src/tui/cmd-submodel.mjs +8 -5
- package/src/tui/cmd-undo.mjs +4 -3
- package/src/tui/index.mjs +38 -37
- package/src/tui/key-handler.mjs +61 -17
- package/src/tui/key-modes.mjs +86 -8
- package/src/tui/layout.mjs +18 -10
- package/src/tui/model-catalog.mjs +89 -0
- package/src/tui/model-picker.mjs +498 -0
- package/src/tui/mouse.mjs +47 -10
- package/src/tui/pickers.mjs +28 -410
- package/src/tui/render-frame.mjs +32 -16
- package/src/tui/render-loop.mjs +2 -0
- package/src/tui/render-segments.mjs +5 -19
- package/src/tui/render.mjs +37 -5
- package/src/tui/slash-commands.mjs +2 -2
- package/src/tui/subagent-blocks.mjs +69 -90
- package/src/tui/subagent-children.mjs +50 -64
- package/src/tui/subagent-freeze.mjs +40 -43
- package/src/tui/subagent-panel.mjs +21 -66
- package/src/tui/suspension-drive.mjs +28 -82
- package/src/tui/tool-args.mjs +4 -2
- package/src/tui/tool-display.mjs +4 -3
- package/src/tui/tool-events.mjs +23 -16
- package/src/tui/tui-lifecycle.mjs +18 -5
- package/src/tui/wizard.mjs +52 -18
- package/src/tui/wrapped-spawn.mjs +38 -0
- package/src/prompts/coder.md +0 -13
- package/src/prompts/discipline.md +0 -84
- package/src/prompts/eng-coder.md +0 -19
- package/src/prompts/engineering-sub.md +0 -14
- package/src/prompts/engineering.md +0 -87
- package/src/prompts/explore.md +0 -12
- package/src/prompts/main.md +0 -34
- package/src/prompts/methodology-template.md +0 -38
- package/src/prompts/plan.md +0 -9
- package/src/prompts/system.md +0 -44
package/src/tui/cmd-eng.mjs
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
/** /eng command: toggle engineering mode.
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
import {
|
|
2
|
+
* No prerequisites (FR11): the mode works on any project — no methodology file,
|
|
3
|
+
* no docs tree, no git. The retired gate demanded a methodology file and offered
|
|
4
|
+
* to create it from a template that no longer ships — that option crashed
|
|
5
|
+
* (PORTABILITY P14). ctx: { agent, pushLine, pushLabel } */
|
|
6
|
+
import { readFileSync, writeFileSync, mkdirSync, renameSync, unlinkSync } from "node:fs"
|
|
7
|
+
import { dirname } from "node:path"
|
|
7
8
|
import { ansi, C } from "./ansi.mjs"
|
|
8
9
|
import { activeSlot, slotPath } from "../session.mjs"
|
|
9
10
|
import { purgeExpiredDesignTokens } from "../token-ttl.mjs"
|
|
10
11
|
|
|
11
|
-
const templateDir = join(fileURLToPath(import.meta.url), "..", "..", "prompts")
|
|
12
12
|
import { ENG_OFF_REMINDER } from "../agent.mjs"
|
|
13
13
|
|
|
14
14
|
/** Atomic slot write (same shape as session.mjs writeSessionFile — kept local to avoid a
|
|
@@ -26,29 +26,13 @@ export function writeSessionFile(p, data) {
|
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
export async function handleEngCommand(ctx) {
|
|
29
|
-
const { agent, pushLine, pushLabel
|
|
29
|
+
const { agent, pushLine, pushLabel } = ctx
|
|
30
30
|
agent.config.agent ??= {}
|
|
31
|
-
const methodologyPath = join(agent.cwd, "METHODOLOGY.md")
|
|
32
|
-
|
|
33
|
-
// Toggle on: check METHODOLOGY.md exists
|
|
34
|
-
if (!agent.config.agent.engineering) {
|
|
35
|
-
if (!existsSync(methodologyPath)) {
|
|
36
|
-
pushLabel("❯ Eng", ansi.bold + C.tool)
|
|
37
|
-
pushLine("METHODOLOGY.md not found in project root.", C.warn)
|
|
38
|
-
const choice = await showPicker("Create METHODOLOGY.md?", [
|
|
39
|
-
{ type: "header", text: "Engineering mode requires a methodology file" },
|
|
40
|
-
{ type: "item", text: "Yes, create from template", action: "create" },
|
|
41
|
-
{ type: "item", text: "No, cancel", action: "cancel" },
|
|
42
|
-
])
|
|
43
|
-
if (!choice || choice.action !== "create") return
|
|
44
|
-
const src = join(templateDir, "methodology-template.md")
|
|
45
|
-
copyFileSync(src, methodologyPath)
|
|
46
|
-
pushLine(`Created METHODOLOGY.md (from template) → edit it to fit your project`, C.tool)
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
31
|
|
|
32
|
+
// Toggle on/off — no prerequisite check (FR11: engineering mode depends on no
|
|
33
|
+
// project file at all; a missing docs tree or git repo must not block it).
|
|
50
34
|
agent.config.agent.engineering = !agent.config.agent.engineering
|
|
51
|
-
// §
|
|
35
|
+
// §11.2 D-24b: per-review instances die with the mode (fresh convergence cycles
|
|
52
36
|
// on the next toggle).
|
|
53
37
|
agent._advisorRuns = new Map()
|
|
54
38
|
if (!agent.config.agent.engineering) {
|
|
@@ -67,7 +51,7 @@ export async function handleEngCommand(ctx) {
|
|
|
67
51
|
pushLabel("❯ Eng", ansi.bold + C.tool)
|
|
68
52
|
pushLine(`Engineering mode: ${agent.config.agent.engineering ? "ON" : "OFF"} (session)`, C.tool)
|
|
69
53
|
if (agent.config.agent.engineering) {
|
|
70
|
-
pushLine(` →
|
|
54
|
+
pushLine(` → design-before-code enforced (design review + user approval before code)`, C.dim)
|
|
71
55
|
if (clearedExpired > 0) {
|
|
72
56
|
pushLine(` → cleared ${clearedExpired} expired design token${clearedExpired === 1 ? "" : "s"}; valid tokens from prior reviews stay usable`, C.dim)
|
|
73
57
|
}
|
package/src/tui/cmd-exit.mjs
CHANGED
|
@@ -1,9 +1,7 @@
|
|
|
1
|
-
/** /exit command: exit TUI
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
export async function handleExitCommand(_ctx) {
|
|
8
|
-
process.exit(0)
|
|
1
|
+
/** /exit command: exit TUI via ctx.exit(F-2——RESIZE-MOUSE-LEAK-FIX——与 Ctrl+C 同退出
|
|
2
|
+
* 形态:cleanup 同步执行 + 延迟 exit——非同派发路径)。不再直调 process.exit(0)(零提前量——
|
|
3
|
+
* 清理押 'exit' 事件);handleSlash 返回后的无条件 render() 由 render-loop tuiActive 守卫
|
|
4
|
+
* 抑制(cleanup 已清活动态——不把 TUI 帧重绘到已恢复的主屏)。 */
|
|
5
|
+
export async function handleExitCommand(ctx) {
|
|
6
|
+
ctx.exit?.() // ctx.exit 由 index.mjs 装配(cleanup + 100ms 延迟 exit)
|
|
9
7
|
}
|
package/src/tui/cmd-model.mjs
CHANGED
|
@@ -1,23 +1,25 @@
|
|
|
1
1
|
import { C } from "./ansi.mjs"
|
|
2
|
+
import { parseModelRef } from "../config.mjs"
|
|
2
3
|
|
|
3
|
-
/** /model command: open model picker, or switch
|
|
4
|
+
/** /model command: open the session model picker, or switch the SESSION model directly via
|
|
5
|
+
* `/model <provider>:<model>`(MODEL-MERGE-SESSION——裁定③:裸 provider 拒——显式 p:m;
|
|
6
|
+
* MODEL-SELECTION v2:显式复合一律放行——仅[空值/裸值/未知 provider]无效(M4),候选清单
|
|
7
|
+
* 由运行期拉取决定——不再有成员校验)。
|
|
8
|
+
* /model 是会话级操作(写槽——不写 config)——config 默认模型走 /config → 默认模型。
|
|
4
9
|
* ctx: { agent, openModelPicker, selectModel, pushLine } */
|
|
5
10
|
export async function handleModelCommand(ctx, args = []) {
|
|
6
|
-
const raw = args[0]?.
|
|
11
|
+
const raw = args[0]?.trim()
|
|
7
12
|
if (!raw) {
|
|
8
13
|
ctx.openModelPicker().catch((e) => ctx.pushLine(`[error] ${e.message}`, C.error))
|
|
9
14
|
return
|
|
10
15
|
}
|
|
11
|
-
//
|
|
12
|
-
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
if (!target) {
|
|
18
|
-
const available = ctx.agent.providers.map((p) => p.name).join(", ")
|
|
19
|
-
ctx.pushLine(`Unknown provider: ${providerName} (available: ${available})`, C.error)
|
|
16
|
+
// 首冒号复合解析(parseModelRef——裸 provider / 未知 provider / 空模型段 → ok:false;
|
|
17
|
+
// 候选外/多冒号一律放行——M4)
|
|
18
|
+
const parsed = parseModelRef(raw, ctx.agent.providers)
|
|
19
|
+
if (!parsed.ok) {
|
|
20
|
+
ctx.pushLine(`[error] ${parsed.reason}`, C.error)
|
|
21
|
+
ctx.pushLine(`/model 用法: /model provider:model(会话级——config 默认走 /config → 默认模型)`, C.dim)
|
|
20
22
|
return
|
|
21
23
|
}
|
|
22
|
-
await ctx.selectModel({ provider:
|
|
24
|
+
await ctx.selectModel({ provider: parsed.provider.name, model: parsed.model }).catch((e) => ctx.pushLine(`[error] ${e.message}`, C.error))
|
|
23
25
|
}
|
package/src/tui/cmd-reindex.mjs
CHANGED
|
@@ -40,5 +40,12 @@ export async function handleReindexCommand(ctx) {
|
|
|
40
40
|
])
|
|
41
41
|
pushLine(` code: ${cr.total} files, +${cr.updated} ~${cr.skipped} -${cr.removed}`, C.dim)
|
|
42
42
|
pushLine(` doc: ${dr.total} files, +${dr.updated} ~${dr.skipped} -${dr.removed}`, C.dim)
|
|
43
|
+
// Unlisted extensions (PORTABILITY PO-9): files the index skipped because no
|
|
44
|
+
// index claims their extension — visible here + declarable in the project.
|
|
45
|
+
const unlisted = cr?.unlistedExts?.count ? cr.unlistedExts : dr?.unlistedExts
|
|
46
|
+
if (unlisted?.count > 0) {
|
|
47
|
+
const sample = unlisted.exts.map((e) => e.ext).join(" ")
|
|
48
|
+
pushLine(` ${unlisted.count} file(s) with unlisted extension(s) skipped (${sample}) — declare them in .thincoder/conventions.json (index.codeExtensions / index.docExtensions) to index them`, C.warn)
|
|
49
|
+
}
|
|
43
50
|
pushLine(`[reindex] Done, ${total} entries total. Vectors will be lazily generated on next search.`, C.tool)
|
|
44
51
|
}
|
package/src/tui/cmd-submodel.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { ansi, C } from "./ansi.mjs"
|
|
2
2
|
|
|
3
3
|
/** Subagent role slots — each has an independent model override slot */
|
|
4
|
-
export const SUBMODEL_SLOTS = ["explore", "plan", "coder", "eng-coder"]
|
|
4
|
+
export const SUBMODEL_SLOTS = ["explore", "plan", "coder", "eng-coder", "eng-designer"]
|
|
5
5
|
|
|
6
6
|
/** Human-readable effective display with inheritance source. role=null → global slot.
|
|
7
7
|
* source semantics: "type" = role-specific override (subagentModels[role]),
|
|
@@ -21,12 +21,14 @@ function slotDisplay(agent, role) {
|
|
|
21
21
|
|
|
22
22
|
/** /submodel command: subagent model config — picker menu (default) or direct args.
|
|
23
23
|
* ctx: { agent, pushLine, showPicker, askQuestion, persistRaw, pickModelForSlot }
|
|
24
|
-
* /submodel → picker: global +
|
|
24
|
+
* /submodel → picker: global + 5 role slots
|
|
25
25
|
* /submodel <value> → set global default
|
|
26
26
|
* /submodel <type> <value> → set a role slot
|
|
27
27
|
* /submodel <type> → show a slot
|
|
28
28
|
* /submodel reset [type] → clear global (or a slot)
|
|
29
|
-
* value forms: provider:model | provider name | model name (same as subagent tool model arg)
|
|
29
|
+
* value forms: provider:model | provider name | model name (same as subagent tool model arg) —
|
|
30
|
+
* MODEL-MERGE-SESSION:会话模型 = provider:model 复合(agent.activeProvider/activeModel 双字段
|
|
31
|
+
* 恒非空——无渠道默认字段——父会话模型引用一律复合值)。 */
|
|
30
32
|
export async function handleSubmodelCommand(ctx, args = []) {
|
|
31
33
|
const { agent, pushLine, showPicker, askQuestion, persistRaw, pickModelForSlot } = ctx
|
|
32
34
|
const input = args.join(" ").trim()
|
|
@@ -85,7 +87,7 @@ export async function handleSubmodelCommand(ctx, args = []) {
|
|
|
85
87
|
return
|
|
86
88
|
}
|
|
87
89
|
|
|
88
|
-
// ── Picker menu: global +
|
|
90
|
+
// ── Picker menu: global + 5 role slots ──
|
|
89
91
|
// for(;;) loop relies on showPicker's async/Promise suspension — every iteration
|
|
90
92
|
// awaits a user choice; Esc (null) exits. Mirrors openModelPicker's menu-loop pattern.
|
|
91
93
|
for (;;) {
|
|
@@ -132,7 +134,8 @@ export async function handleSubmodelCommand(ctx, args = []) {
|
|
|
132
134
|
pushLine(`Subagent global model set to \`${value}\`.`, C.text)
|
|
133
135
|
}
|
|
134
136
|
} else if (sub.action === "parent") {
|
|
135
|
-
|
|
137
|
+
// 会话复合值语义(MODEL-MERGE-SESSION——activeModel 恒非空——不再回退渠道字段)
|
|
138
|
+
const value = `${agent.activeProvider}:${agent.activeModel ?? ""}`
|
|
136
139
|
if (role) {
|
|
137
140
|
await persist((a) => { a.subagentModels ??= {}; a.subagentModels[role] = value })
|
|
138
141
|
} else {
|
package/src/tui/cmd-undo.mjs
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { existsSync, writeFileSync, unlinkSync, readFileSync } from "node:fs"
|
|
9
|
-
import { join } from "node:path"
|
|
9
|
+
import { isAbsolute, join } from "node:path"
|
|
10
10
|
import { ansi, C } from "./ansi.mjs"
|
|
11
11
|
|
|
12
12
|
const MAX_UNDO = 50
|
|
@@ -20,7 +20,8 @@ export function snapshotForUndo(agent, toolName, args, cwd) {
|
|
|
20
20
|
const path = args.path ?? args.file
|
|
21
21
|
if (!path || typeof path !== "string") return
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
// 绝对路径直接用(path.join 对绝对段不重置——Windows 反斜杠路径也不按 "/" 切分);相对路径整段 join(cwd)。
|
|
24
|
+
const abs = isAbsolute(path) ? path : join(cwd, path)
|
|
24
25
|
let backup = null
|
|
25
26
|
try {
|
|
26
27
|
if (existsSync(abs)) {
|
|
@@ -68,7 +69,7 @@ export async function handleUndoCommand(ctx) {
|
|
|
68
69
|
const e = await showPicker("Undo", entries)
|
|
69
70
|
if (!e) return
|
|
70
71
|
const item = stack[e.idx]
|
|
71
|
-
const abs = join(agent.cwd,
|
|
72
|
+
const abs = isAbsolute(item.path) ? item.path : join(agent.cwd, item.path)
|
|
72
73
|
|
|
73
74
|
try {
|
|
74
75
|
if (item.backup === null) {
|
package/src/tui/index.mjs
CHANGED
|
@@ -25,16 +25,16 @@ import { closeAllMcp } from "../mcp.mjs"
|
|
|
25
25
|
import { ansi, C } from "./ansi.mjs"
|
|
26
26
|
import { createRenderLoop } from "./render-loop.mjs"
|
|
27
27
|
import { makeDimsState } from "./dims.mjs"
|
|
28
|
-
import { SLASH_COMMANDS,
|
|
28
|
+
import { SLASH_COMMANDS, createSlashCommands } from "./slash-commands.mjs"
|
|
29
29
|
import { createWizard } from "./wizard.mjs"
|
|
30
30
|
import { writeStartupSequence, setTuiActive, createExitCleanup } from "./tui-lifecycle.mjs"
|
|
31
31
|
import { createPickers } from "./pickers.mjs"
|
|
32
32
|
import { runDistill as runDistillImpl } from "./distill-cmd.mjs"
|
|
33
33
|
import { createInteraction } from "./interaction.mjs"
|
|
34
34
|
import { pasteClipboardImage as pasteClipboardImageImpl, insertPastedText, translateShiftEnter, stripKeyboardProtocol } from "./clipboard.mjs"
|
|
35
|
-
import { parseMouseClicks, handleWheel, createMouseDispatch } from "./mouse.mjs"
|
|
35
|
+
import { parseMouseClicks, handleWheel, createMouseDispatch, mouseOob } from "./mouse.mjs"
|
|
36
36
|
import { runAgentTurn } from "./agent-turn.mjs"
|
|
37
|
-
import { createKeyHandler, convMaxScroll } from "./key-handler.mjs"
|
|
37
|
+
import { createKeyHandler, convMaxScroll, clearAttention } from "./key-handler.mjs"
|
|
38
38
|
import { showStartup, backgroundIndex, createLoadOlder } from "./startup.mjs"
|
|
39
39
|
import { shiftFreezeAnchors } from "./subagent-blocks.mjs"
|
|
40
40
|
import { createConfigHelpers } from "./config-helpers.mjs"
|
|
@@ -53,7 +53,12 @@ export async function promptProviderIfInvalid(agent, openModelPicker, pushLine)
|
|
|
53
53
|
if (!(agent._providerInvalid || !agent.provider)) return false
|
|
54
54
|
await openModelPicker()
|
|
55
55
|
if (!agent.provider) {
|
|
56
|
-
|
|
56
|
+
// MODEL-MERGE-SESSION 引导 A(F-6):空槽 + defaultModel 未设 → 提示 /config 默认模型入口
|
|
57
|
+
// (index.mjs 本行为 D-S2 取消提示扩展——设计文件清单外——随批上报)
|
|
58
|
+
const hasProviders = (agent.providers?.length ?? 0) > 0
|
|
59
|
+
pushLine(hasProviders && !agent.config?.defaultModel
|
|
60
|
+
? "尚未设置默认模型(config.defaultModel——新会话起点):/config → 默认模型 设置一次;/model 仅改本会话"
|
|
61
|
+
: "未配置有效 provider,可用 /model 选择或 /provider 配置", C.warn)
|
|
57
62
|
}
|
|
58
63
|
return true
|
|
59
64
|
}
|
|
@@ -106,12 +111,13 @@ export async function startTUI(agent, opts = {}) {
|
|
|
106
111
|
reasoning: "", // thinking stream buffer (dimmed display)
|
|
107
112
|
completion: null, // Tab completion state { candidates, index }
|
|
108
113
|
|
|
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(冻结锚点), stopped, children: []
|
|
114
|
+
subTasks: {}, // sub-agent activity blocks (§7.2 D4): { "coder#1": { key, role, model, started, done, doneAt, blocks: [{kind,text}], currentTool, toolArgs, turn, maxTurns, approval, lastError, dropped, blockEpoch, awaitingDigest(§17 挂起中间态), _freezeAt(冻结锚点), stopped, children: [](SUBAGENT-TAIL:嵌套子代理**守护载体**——内容行并入本块 blocks——subagent-children.mjs) } } — rendered as collapsible in-conversation blocks; persists across turns (blocks are the child activity's ONLY carrier — child tool calls never enter the parent history); bounded by the N2 500-line per-child ring buffer(SUBAGENT-TAIL 单环:内层行同环计数、单载体最旧先行——TUI.md §6)
|
|
110
115
|
currentTool: null, // currently executing tool name (shown in status bar)
|
|
111
116
|
processingStarted: 0, // current turn start time (status bar timer)
|
|
112
117
|
status: "Ready",
|
|
113
|
-
queue: [], //
|
|
114
|
-
interruptPrompt: null, // Ctrl+I
|
|
118
|
+
queue: [], // 交接残项单容器 [{ text }](INPUT-LOCK:submit 不再排队——仅释放窗口兜底/挂起中止残余——回合尾队列循环续发,至多一条)
|
|
119
|
+
interruptPrompt: null, // Ctrl+I inject box(第 31 批——TUI-INPUT-BOX.md §8): { chars: string[], cursor: number } or null
|
|
120
|
+
attentionAwaiting: false, // 第 33 批(TUI §14.3(f)):回合结束等待输入——链尾置位 / 用户输入清位;不落盘、不进会话
|
|
115
121
|
search: null, // Ctrl+F search mode: { query: "", matches: [{lineIndex, charIndex}], index: 0 } or null
|
|
116
122
|
expandedBlocks: new Set(), // block hashes that are expanded (Enter toggles)
|
|
117
123
|
foldEnabled: true, // global fold toggle — /fold on|off
|
|
@@ -121,13 +127,15 @@ export async function startTUI(agent, opts = {}) {
|
|
|
121
127
|
_historyLoaded: 0, // messages loaded from the TAIL of _fullHistory
|
|
122
128
|
_historyTotal: 0, // total messages in the restored session
|
|
123
129
|
_hasOlder: false, // more earlier messages remain unloaded
|
|
124
|
-
_agent: null, //
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
//
|
|
130
|
+
_agent: null, // TUI state → agent 回指挂载点:startTUI 装配时置 agent 引用——
|
|
131
|
+
// ① SYNC-CANCEL ⏹ 门控读 state._agent._syncChildAborts(subagent-panel.mjs,2026-09-09);
|
|
132
|
+
// ② CLI-ACTIVITY-DEBLOAT F-3(2026-09-10):agent._tuiState = state 反向挂载——
|
|
133
|
+
// action:"panel" 经 ctx.state(= agent._tuiState)**读时现算**面板块(computePanelBlocks
|
|
134
|
+
// ——subTasks 活值纯推导——单账本)。headless/VS Code 无此装配 = 无面板 → panel 动作
|
|
135
|
+
// 恒降级池视图(CLI-only 完整能力——AC-P4)。
|
|
128
136
|
}
|
|
129
137
|
state._agent = agent
|
|
130
|
-
agent.
|
|
138
|
+
agent._tuiState = state // F-3:面板视图现算通道(替代退役的手工面板镜像)
|
|
131
139
|
|
|
132
140
|
// On session restore, if all tasks are completed, auto-collapse the todo panel (match runtime behavior)
|
|
133
141
|
if (state.tasks.length > 0 && state.tasks.every((t) => t.status === "done")) {
|
|
@@ -160,6 +168,10 @@ export async function startTUI(agent, opts = {}) {
|
|
|
160
168
|
let text = mousePending + utf8Decoder.decode(chunk, { stream: true })
|
|
161
169
|
mousePending = ""
|
|
162
170
|
|
|
171
|
+
// 第 33 批(TUI §14.3(e) 鼠标点):stdin 数据到达即用户在场 ⇒ 清 attention 位
|
|
172
|
+
// (单点覆盖下方滚轮分支与 onMouseClick;键盘入口在 key-handler)。
|
|
173
|
+
clearAttention(state, render)
|
|
174
|
+
|
|
163
175
|
// Bracketed paste: terminal wraps pasted text in \x1b[200~ ... \x1b[201~
|
|
164
176
|
// Route pasted content to the active text target (question answer / input box) in one shot,
|
|
165
177
|
// avoiding slow char-by-char keypress render — see insertPastedText in clipboard.mjs
|
|
@@ -209,6 +221,10 @@ export async function startTUI(agent, opts = {}) {
|
|
|
209
221
|
for (const m of text.matchAll(/\x1b\[<(\d+);(\d+);(\d+)([Mm])/g)) {
|
|
210
222
|
const button = Number(m[1])
|
|
211
223
|
if (button === 64 || button === 65) {
|
|
224
|
+
// F-3 sane-gate ③(RESIZE-MOUSE-LEAK-FIX):越界 wheel 禁止会话滚动——handleWheel
|
|
225
|
+
// 对越界返回未消费会穿到 fallback 滚动(mouse.mjs 内 gate 覆盖不到此路径)
|
|
226
|
+
const dims = state.dims ? state.dims.get() : { cols: process.stdout.columns || 80, rows: process.stdout.rows || 24 }
|
|
227
|
+
if (mouseOob(Number(m[2]), Number(m[3]), dims)) continue
|
|
212
228
|
const consumed = handleWheel(mouseCtx(), button, Number(m[2]), Number(m[3]))
|
|
213
229
|
if (!consumed) {
|
|
214
230
|
if (button === 64) {
|
|
@@ -314,6 +330,14 @@ export async function startTUI(agent, opts = {}) {
|
|
|
314
330
|
async function submit() {
|
|
315
331
|
const text = state.input.join("").trim()
|
|
316
332
|
if (!text) return
|
|
333
|
+
// INPUT-LOCK 防御(C'——2026-09-09)+ INPUT-LOCK-BEHAVIOR-REVISED(2026-09-09——白名单
|
|
334
|
+
// 已删——busy 全拒):提交吞的门禁在 key-handler(busy Enter 已吞 + 提示,文本保留)——
|
|
335
|
+
// submit 只在非 busy 期达此;防御直呼/上游改动:busy 期拒绝(不清输入框不吞内容)。
|
|
336
|
+
if (state.processing) {
|
|
337
|
+
pushLine(`[主会话处理中 —— 消息未发送(回合结束后请重按 Enter)]`, C.warn)
|
|
338
|
+
render()
|
|
339
|
+
return
|
|
340
|
+
}
|
|
317
341
|
state.input = []
|
|
318
342
|
state.cursor = 0
|
|
319
343
|
const wasInHistory = state.historyIndex !== -1
|
|
@@ -323,37 +347,14 @@ export async function startTUI(agent, opts = {}) {
|
|
|
323
347
|
state.scroll = 0
|
|
324
348
|
state._followTail = true // 2026-08-31 会诊 deepseek:新消息恢复跟随(注释曾承诺、实现缺漏)
|
|
325
349
|
|
|
326
|
-
// Slash commands: handled locally, don't enter agent loop
|
|
350
|
+
// Slash commands: handled locally, don't enter agent loop. busy 期提交在 key-handler
|
|
351
|
+
// 门禁被吞(白名单已删——斜杠同吞——INPUT-LOCK-BEHAVIOR-REVISED)——submit 仅非 busy 期可达。
|
|
327
352
|
if (text.startsWith("/")) {
|
|
328
|
-
if (state.processing) {
|
|
329
|
-
// While processing: allowlisted commands execute directly (they only touch
|
|
330
|
-
// local TUI/agent config, never the in-flight turn); the rest are queued
|
|
331
|
-
const cmd0 = text.split(/\s+/)[0].toLowerCase()
|
|
332
|
-
const resolved0 = SLASH_ALIASES[cmd0] ?? cmd0
|
|
333
|
-
const safeDuringProcessing = new Set(["/help", "/exit", "/model", "/submodel", "/shell", "/think", "/config", "/skills", "/mcp", "/goal", "/session"])
|
|
334
|
-
if (safeDuringProcessing.has(resolved0)) {
|
|
335
|
-
await handleSlash(text)
|
|
336
|
-
render()
|
|
337
|
-
} else {
|
|
338
|
-
state.queue.push({ text })
|
|
339
|
-
render()
|
|
340
|
-
}
|
|
341
|
-
return
|
|
342
|
-
}
|
|
343
353
|
await handleSlash(text)
|
|
344
354
|
render()
|
|
345
355
|
return
|
|
346
356
|
}
|
|
347
357
|
|
|
348
|
-
// While processing: queue for later, don't execute immediately.
|
|
349
|
-
// The queue panel (renderQueue) already shows pending items — don't also
|
|
350
|
-
// push to the conversation area, or the text scrolls up with streaming tokens.
|
|
351
|
-
if (state.processing) {
|
|
352
|
-
state.queue.push({ text })
|
|
353
|
-
render()
|
|
354
|
-
return
|
|
355
|
-
}
|
|
356
|
-
|
|
357
358
|
await turn(text)
|
|
358
359
|
}
|
|
359
360
|
|
package/src/tui/key-handler.mjs
CHANGED
|
@@ -1,10 +1,22 @@
|
|
|
1
1
|
import { ansi, C } from "./ansi.mjs"
|
|
2
2
|
import { readClipboardText, insertPastedText } from "./clipboard.mjs"
|
|
3
|
-
import { computeLayout } from "./layout.mjs"
|
|
3
|
+
import { computeLayout, inputContentWidth } from "./layout.mjs"
|
|
4
|
+
import { moveCursorVertical } from "./render.mjs"
|
|
4
5
|
import { handleSearchKey } from "./key-handler-search.mjs"
|
|
5
6
|
import { countConvLines } from "./render-conversation.mjs"
|
|
6
7
|
import { handlePermissionMode, handleQuestionMode, handleInterruptMode } from "./key-modes.mjs"
|
|
7
8
|
|
|
9
|
+
/** 第 33 批(TUI §14.3(e)——attention 清位):输入即在场——仅当原值为真时置假 + `render()`
|
|
10
|
+
* (已假则零副作用、零重绘)。键盘入口与本文件(`onKeypress`)与鼠标输入路径(index.mjs
|
|
11
|
+
* stdin data 处理器)两点共用;blocked 两态无需清位(实时派生)。
|
|
12
|
+
* @returns {boolean} 本次是否发生清位 */
|
|
13
|
+
export function clearAttention(state, render) {
|
|
14
|
+
if (!state.attentionAwaiting) return false
|
|
15
|
+
state.attentionAwaiting = false
|
|
16
|
+
render()
|
|
17
|
+
return true
|
|
18
|
+
}
|
|
19
|
+
|
|
8
20
|
/** Current conversation max scroll offset (display lines beyond the visible panel). */
|
|
9
21
|
export function convMaxScroll(state) {
|
|
10
22
|
const d = state.dims ? state.dims.get() : {} // Single source (ConPTY instability, 2026-08-30) — cached dims
|
|
@@ -24,6 +36,9 @@ export function createKeyHandler(ctx) {
|
|
|
24
36
|
const { agent, state, render, popPicker, renderPickerLines, handleSlash, handleTab, submit, pasteClipboardImage, wizardChooseProvider, wizardSubmitText, cancelWizard, wizardProviderItems, renderWizard, pushLine, cleanup, showPicker, loadOlder } = ctx
|
|
25
37
|
|
|
26
38
|
return function onKeypress(str, key = {}) {
|
|
39
|
+
// 第 33 批(TUI §14.3(e) 键盘点):模态分派**之前**——任意按键 = 用户在场 ⇒ 清 attention
|
|
40
|
+
// 位(仅复位呈现态字段,按键语义 / 模态判定 / busy 门禁零改)。
|
|
41
|
+
clearAttention(state, render)
|
|
27
42
|
// permission confirm: y/n/a (a = approve + AUTO ON); batch (§16 D-B1): a/o/n (Esc = deny)
|
|
28
43
|
// ——模态实现 key-modes.mjs handlePermissionMode(2026-09-03 D-S4)
|
|
29
44
|
if (handlePermissionMode(str, key, { state, agent, pushLine, render })) return
|
|
@@ -59,12 +74,13 @@ export function createKeyHandler(ctx) {
|
|
|
59
74
|
state.suspended
|
|
60
75
|
if (hasStopTarget) {
|
|
61
76
|
// 当前回合平 abort(无 interrupt——命中 agent.mjs 回合收尾清池分支——全停)
|
|
62
|
-
|
|
77
|
+
// §20.3 站点 #11(第 24 批):整批 / 会话级停 = stop(reason 载荷)
|
|
78
|
+
if (state.processing && state.controller) state.controller.abort({ abortTrigger: "stop", abortDetail: "session-stop" })
|
|
63
79
|
// 会话 abort 集合 = 链条内全部 controller(含 Ctrl+I/ContinueError 重建的旧
|
|
64
80
|
// controller——children 不逃逸,round1 偏差 #3)。挂起态才标记 _suspAborted +
|
|
65
81
|
// 唤醒 driver(driver 收尾清池)——非挂起语境置位会粘滞阻塞未来挂起会话重入
|
|
66
82
|
// (round2 偏差 #1 语义)。
|
|
67
|
-
for (const c of agent._sessionAbortAll ?? (agent._sessionAbort ? [agent._sessionAbort] : [])) c?.abort()
|
|
83
|
+
for (const c of agent._sessionAbortAll ?? (agent._sessionAbort ? [agent._sessionAbort] : [])) c?.abort({ abortTrigger: "stop", abortDetail: "session-stop" })
|
|
68
84
|
if (state.suspended) {
|
|
69
85
|
state._suspAborted = true
|
|
70
86
|
state._suspWake?.()
|
|
@@ -159,7 +175,8 @@ export function createKeyHandler(ctx) {
|
|
|
159
175
|
// Ctrl+I (or Tab during processing): interrupt and inject a message
|
|
160
176
|
if ((key.ctrl && !key.alt && key.name === "i") || (key.name === "tab" && state.processing && !state.interruptPrompt)) {
|
|
161
177
|
if (state.processing && state.controller && !state.interruptPrompt) {
|
|
162
|
-
|
|
178
|
+
// 注入框状态模型(第 31 批——TUI-INPUT-BOX.md §1 不变量 9):{ chars, cursor },空态 = { chars: [], cursor: 0 }
|
|
179
|
+
state.interruptPrompt = { chars: [], cursor: 0 }
|
|
163
180
|
render()
|
|
164
181
|
}
|
|
165
182
|
return
|
|
@@ -263,17 +280,19 @@ export function createKeyHandler(ctx) {
|
|
|
263
280
|
}
|
|
264
281
|
|
|
265
282
|
if (state.processing) {
|
|
266
|
-
//
|
|
267
|
-
|
|
268
|
-
//
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
283
|
+
// busy 门禁(INPUT-LOCK C'——2026-09-09 + INPUT-LOCK-BEHAVIOR-REVISED——2026-09-09):
|
|
284
|
+
// processing(含 digest——单一判据)输入不禁——吞提交不吞字符(打字照常回显);Enter
|
|
285
|
+
// 提交吞 + busy 提示——斜杠同禁发(白名单已删——/exit 也发不出——退出靠 Ctrl+C 终端
|
|
286
|
+
// 层通道——门禁前不误伤);空 Enter 静默(text 非空才吞);多行换行
|
|
287
|
+
// (meta/enter——编辑)照常放行。**第 31 批**:↑↓ 不再在此全屏蔽——分流下沉到下方
|
|
288
|
+
// 三规则块(竖移放行 / 历史导航禁;TUI-INPUT-BOX.md §3);Tab 仍吞(死条件在案——原样保留)。
|
|
289
|
+
if (key.name === "tab") return
|
|
290
|
+
const isSend = (key.name === "return" && !key.meta) || (str === "\r" && !key.meta)
|
|
291
|
+
if (isSend && state.input.join("").trim()) {
|
|
292
|
+
pushLine(`[主会话处理中 —— 消息未发送(回合结束后请重按 Enter)]`, C.warn)
|
|
293
|
+
render()
|
|
274
294
|
return
|
|
275
295
|
}
|
|
276
|
-
// remaining printable characters go into input box normally
|
|
277
296
|
}
|
|
278
297
|
|
|
279
298
|
// Tab: slash-command completion (cycle candidates); other input ignored (\t would blow up input box, never inserted directly)
|
|
@@ -291,7 +310,24 @@ export function createKeyHandler(ctx) {
|
|
|
291
310
|
return
|
|
292
311
|
}
|
|
293
312
|
|
|
294
|
-
//
|
|
313
|
+
// ↑/↓ 三规则(TUI-INPUT-BOX.md §3——第 31 批):① 翻历史中恒历史(多行条目不竖移)
|
|
314
|
+
// ② 竖移优先(可视行口径——折行与 \n 同权;显示列保持 + 短行端钳制)③ 边界回落历史(↑)
|
|
315
|
+
// /无动作(↓)。processing 期:竖移放行(纯编辑——与字符/退格/←→ 同权)、历史导航禁
|
|
316
|
+
// (规则 ①/③ 的历史分支吞——不进入、不切换)。
|
|
317
|
+
if (key.name === "up" || key.name === "down") {
|
|
318
|
+
if (state.historyIndex === -1) { // 规则 ① 优先——翻历史中不竖移
|
|
319
|
+
const cols = (state.dims?.get() ?? {}).cols ?? (process.stdout.columns || 80)
|
|
320
|
+
const next = moveCursorVertical(state.input, state.cursor, inputContentWidth(cols), key.name)
|
|
321
|
+
if (next !== null) {
|
|
322
|
+
state.cursor = next // 规则 ②:竖移(草稿/历史指针零涉——D-31.6)
|
|
323
|
+
render()
|
|
324
|
+
return
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
if (state.processing) return // 规则 ①/③ 的历史分支吞——processing 期历史导航禁
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// input history(规则 ① 恒历史 + 规则 ③ 边界回落——历史语义零改)
|
|
295
331
|
if (key.name === "up") {
|
|
296
332
|
if (state.history.length) {
|
|
297
333
|
// Save current input as draft when entering history mode (historyIndex === -1).
|
|
@@ -372,15 +408,23 @@ export function createKeyHandler(ctx) {
|
|
|
372
408
|
render()
|
|
373
409
|
} else {
|
|
374
410
|
const text = state.input.join("").trim()
|
|
375
|
-
// §17 D-S5/F3/F7 + 偏差 #1
|
|
376
|
-
//
|
|
411
|
+
// §17 D-S5/F3/F7 + 偏差 #1 + INPUT-LOCK 单槽化(F-6——2026-09-09):挂起态
|
|
412
|
+
// (suspended 或释放窗口 _suspPending)Enter = 新回合输入(非打断)——填
|
|
413
|
+
// pendingInput 单槽(至多一条待交接——busy 提交吞在 L265 门禁先拦,digest 期
|
|
414
|
+
// 不会到这——仅挂起空闲/释放窗口触达)由挂起会话调度;输入框零干扰(F3)。
|
|
415
|
+
// 槽满(竞态防御——不覆盖不丢失)→ 吞 + 提示,文本保留在输入框。
|
|
377
416
|
if ((state.suspended || state._suspPending) && text && !text.startsWith("/")) {
|
|
417
|
+
state.pendingInput ??= []
|
|
418
|
+
if (state.pendingInput.length > 0) {
|
|
419
|
+
pushLine(`[主会话处理中 —— 已有一条消息待发送,请等其处理完成后再发送]`, C.warn)
|
|
420
|
+
render()
|
|
421
|
+
return
|
|
422
|
+
}
|
|
378
423
|
state.input = []
|
|
379
424
|
state.cursor = 0
|
|
380
425
|
state.history.push(text)
|
|
381
426
|
state.historyIndex = -1
|
|
382
427
|
state._draft = null
|
|
383
|
-
state.pendingInput ??= []
|
|
384
428
|
state.pendingInput.push(text)
|
|
385
429
|
state._suspWake?.()
|
|
386
430
|
render()
|
package/src/tui/key-modes.mjs
CHANGED
|
@@ -8,6 +8,31 @@
|
|
|
8
8
|
import { C } from "./ansi.mjs"
|
|
9
9
|
import { readClipboardText, insertPastedText } from "./clipboard.mjs"
|
|
10
10
|
import { QUESTION_CUSTOM } from "./interaction.mjs"
|
|
11
|
+
import { inputContentWidth } from "./layout.mjs"
|
|
12
|
+
import { moveCursorVertical } from "./render.mjs"
|
|
13
|
+
|
|
14
|
+
/** SYNC-CANCEL v2 模态 deny 解绕(用户裁——2026-09-09):⏹ 定向中止 sync child 时
|
|
15
|
+
* 顺带 deny 其 pending 权限/continue 模态——ask 不观 signal(R1——targeted abort 后
|
|
16
|
+
* child 停在模态则 runChildPipeline 不返回、父回合阻塞至模态回答)。owner 判定:
|
|
17
|
+
* 工具权限 ask name 前缀 `${key}/`(subagent-spawn.mjs 装配——owner key 标识);continue
|
|
18
|
+
* ask args.agent === key(subagent.mjs askSubagentContinue 既有参数)。副作用镜像
|
|
19
|
+
* handlePermissionMode deny 分支(清除模态 + deny 轨迹 + resolve(false)——子代理随即
|
|
20
|
+
* 在 abort 检出点解绕折叠)。返回 true = 本模态属该 child 且已 deny 解绕。鼠标 ⏹ 路径
|
|
21
|
+
* (mouse.mjs cancelSubagent)调用——key-modes 是权限模态归属语义的驻点。 */
|
|
22
|
+
export function denyModalForOwner(state, key, { pushLine, render } = {}) {
|
|
23
|
+
const p = state?.permission
|
|
24
|
+
if (!p) return false
|
|
25
|
+
const owned = (typeof p.name === "string" && p.name.startsWith(`${key}/`)) || p.args?.agent === key
|
|
26
|
+
if (!owned) return false
|
|
27
|
+
const { resolve, name } = p
|
|
28
|
+
state.permission = null
|
|
29
|
+
state.permissionPreview = []
|
|
30
|
+
state.status = "Processing..."
|
|
31
|
+
pushLine?.(` [denied] ${name}`, C.error)
|
|
32
|
+
resolve(false)
|
|
33
|
+
render?.()
|
|
34
|
+
return true
|
|
35
|
+
}
|
|
11
36
|
|
|
12
37
|
/** permission 确认模态:y/n/a(a = approve + AUTO ON);batch(§16 D-B1):a/o/n(Esc = deny)。
|
|
13
38
|
* consume:valid 键或 Esc 走 resolve 分支;其余键静默吞掉(模态独占)。 */
|
|
@@ -68,10 +93,11 @@ export function handleQuestionMode(str, key, ctx) {
|
|
|
68
93
|
state.status = "Processing..."
|
|
69
94
|
render()
|
|
70
95
|
} else if (key.name === "up") {
|
|
71
|
-
|
|
96
|
+
// A4(第 20 批 §12.4 契约——同义键同形):↑↓ 环绕(原钳位——对齐 picker/wizard 既有语义)
|
|
97
|
+
q.selected = ((q.selected ?? 0) - 1 + q.options.length) % q.options.length
|
|
72
98
|
render()
|
|
73
99
|
} else if (key.name === "down") {
|
|
74
|
-
q.selected =
|
|
100
|
+
q.selected = ((q.selected ?? 0) + 1) % q.options.length
|
|
75
101
|
render()
|
|
76
102
|
} else if (key.name === "return") {
|
|
77
103
|
const answer = q.options[q.selected ?? 0]
|
|
@@ -182,16 +208,21 @@ export function handleQuestionMode(str, key, ctx) {
|
|
|
182
208
|
}
|
|
183
209
|
|
|
184
210
|
/** interruptPrompt 模态(Ctrl+I 后输入注入消息):Enter 提交 → controller.abort
|
|
185
|
-
* { interrupt: true, message };Esc
|
|
211
|
+
* { interrupt: true, message };Esc 取消;编辑键 = §7.2 最小集 + 四方向键(第 31 批:
|
|
212
|
+
* `{ chars, cursor }` codepoint 模型——TUI-INPUT-BOX.md §8.1);其余键静默吞(模态独占)。
|
|
186
213
|
* 返回 false 当模态未激活(key-handler 先检 state.interruptPrompt 入口再调)。 */
|
|
187
214
|
export function handleInterruptMode(str, key, ctx) {
|
|
188
215
|
const { state, pushLine, render } = ctx
|
|
189
216
|
if (!state.interruptPrompt) return false
|
|
217
|
+
const p = state.interruptPrompt
|
|
218
|
+
if (!Array.isArray(p.chars)) p.chars = p.chars ? [...p.chars] : [] // 形态防御(同 question 自由文本态)
|
|
219
|
+
p.cursor = Math.max(0, Math.min(p.cursor ?? p.chars.length, p.chars.length))
|
|
220
|
+
const len = p.chars.length
|
|
190
221
|
if (key.name === "escape") {
|
|
191
222
|
state.interruptPrompt = null
|
|
192
223
|
render()
|
|
193
224
|
} else if (key.name === "return") {
|
|
194
|
-
const msg =
|
|
225
|
+
const msg = p.chars.join("").trim()
|
|
195
226
|
state.interruptPrompt = null
|
|
196
227
|
if (msg) {
|
|
197
228
|
// Guard: if the turn already finished while the user was typing, the controller
|
|
@@ -204,12 +235,59 @@ export function handleInterruptMode(str, key, ctx) {
|
|
|
204
235
|
}
|
|
205
236
|
render()
|
|
206
237
|
}
|
|
207
|
-
} else if (key.name === "
|
|
208
|
-
|
|
238
|
+
} else if (key.name === "enter") {
|
|
239
|
+
// Ctrl+J(\n → name "enter")= no-op 吞——单行不变式:不插换行(§8.1)
|
|
240
|
+
} else if (key.name === "left") {
|
|
241
|
+
p.cursor = Math.max(0, p.cursor - 1)
|
|
242
|
+
render()
|
|
243
|
+
} else if (key.name === "right") {
|
|
244
|
+
p.cursor = Math.min(len, p.cursor + 1)
|
|
245
|
+
render()
|
|
246
|
+
} else if (key.name === "up" || key.name === "down") {
|
|
247
|
+
// 折行竖移(§8.1):显示列保持 + 短行端钳制;无邻行 = 吞(零副作用——无历史回落)
|
|
248
|
+
const cols = (state.dims?.get() ?? {}).cols ?? (process.stdout.columns || 80)
|
|
249
|
+
const next = moveCursorVertical(p.chars, p.cursor, inputContentWidth(cols), key.name)
|
|
250
|
+
if (next !== null) {
|
|
251
|
+
p.cursor = next
|
|
252
|
+
render()
|
|
253
|
+
}
|
|
254
|
+
} else if (key.name === "home") {
|
|
255
|
+
p.cursor = 0
|
|
209
256
|
render()
|
|
210
|
-
} else if (
|
|
211
|
-
|
|
257
|
+
} else if (key.name === "end") {
|
|
258
|
+
p.cursor = len
|
|
212
259
|
render()
|
|
260
|
+
} else if (key.ctrl && !key.alt && key.name === "u") {
|
|
261
|
+
p.chars = []
|
|
262
|
+
p.cursor = 0
|
|
263
|
+
render()
|
|
264
|
+
} else if (key.name === "backspace") {
|
|
265
|
+
if (p.cursor > 0) {
|
|
266
|
+
p.chars.splice(p.cursor - 1, 1) // codepoint 元素删除——emoji 不劈半
|
|
267
|
+
p.cursor--
|
|
268
|
+
render()
|
|
269
|
+
}
|
|
270
|
+
} else if (key.ctrl && !key.alt && !key.meta && key.name === "v") {
|
|
271
|
+
// Ctrl+V 粘贴:落 cursor 位置(\n/\r 去除、\t → 2 空格——insertPastedText 注入框分支)。
|
|
272
|
+
// 异步读剪贴板期间框可能已 Esc/Enter 关闭 → 回调守卫比对同一引用,防落主输入框
|
|
273
|
+
// (同 question 态 stale-paste 守卫语义;不加守卫会静默落 state.input)。
|
|
274
|
+
const target = p
|
|
275
|
+
readClipboardText().then((text) => {
|
|
276
|
+
if (text && state.interruptPrompt === target) {
|
|
277
|
+
insertPastedText(state, text)
|
|
278
|
+
render()
|
|
279
|
+
}
|
|
280
|
+
}).catch((e) => console.error(`[tui] clipboard paste failed: ${e.message}`))
|
|
281
|
+
} else if (str && !key.ctrl && !key.meta && key.name !== "tab" && key.name !== "enter") {
|
|
282
|
+
// 可打印字符插入光标位置(codepoint 拆字);\r\n 剥离 + \t→2 空格(单行不变式)
|
|
283
|
+
const chars = [...str.replace(/[\r\n]+/g, "").replace(/\t/g, " ")]
|
|
284
|
+
if (chars.length) {
|
|
285
|
+
p.chars.splice(p.cursor, 0, ...chars)
|
|
286
|
+
p.cursor += chars.length
|
|
287
|
+
render()
|
|
288
|
+
}
|
|
213
289
|
}
|
|
290
|
+
// 未列键(Delete/PgUp/PgDn/F1/…)一律 consume return——无 fall-through(模态独占;§8.1:
|
|
291
|
+
// Delete 不引入——与 §7.2 最小集对齐)
|
|
214
292
|
return true
|
|
215
293
|
}
|