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.
Files changed (152) hide show
  1. package/CHANGELOG.md +24 -1
  2. package/README.md +8 -6
  3. package/bin/thincoder.mjs +27 -122
  4. package/package.json +3 -2
  5. package/src/abort-provenance.mjs +116 -0
  6. package/src/acp/bridge.mjs +38 -17
  7. package/src/acp.mjs +6 -1
  8. package/src/advisor/citations.mjs +83 -21
  9. package/src/advisor/compaction.mjs +174 -0
  10. package/src/advisor/loop.mjs +293 -0
  11. package/src/advisor/messages.mjs +36 -134
  12. package/src/advisor/project-context.mjs +194 -0
  13. package/src/advisor/repos.mjs +17 -40
  14. package/src/advisor/run.mjs +124 -329
  15. package/src/advisor/truncate.mjs +57 -0
  16. package/src/advisor.mjs +3 -2
  17. package/src/agent/completion.mjs +1 -1
  18. package/src/agent/dispatch.mjs +47 -12
  19. package/src/agent/helpers.mjs +71 -13
  20. package/src/agent/record-results.mjs +13 -5
  21. package/src/agent/relay-prefix.mjs +39 -0
  22. package/src/agent/run-stages.mjs +21 -6
  23. package/src/agent/setup-reminders.mjs +16 -9
  24. package/src/agent/setup.mjs +92 -128
  25. package/src/agent/spawn-child.mjs +26 -9
  26. package/src/agent-tools/advisor-async.mjs +70 -180
  27. package/src/agent-tools/advisor-settle.mjs +231 -0
  28. package/src/agent-tools/advisor.mjs +69 -20
  29. package/src/agent-tools/batch-segment.mjs +195 -0
  30. package/src/agent-tools/consult.mjs +23 -10
  31. package/src/agent-tools/design-token.mjs +14 -1
  32. package/src/agent-tools/digest-budget.mjs +76 -0
  33. package/src/agent-tools/eng.mjs +3 -3
  34. package/src/agent-tools/escalate-async.mjs +16 -13
  35. package/src/agent-tools/read-history.mjs +13 -3
  36. package/src/agent-tools/review-streak.mjs +93 -0
  37. package/src/agent-tools/settings.mjs +130 -17
  38. package/src/agent-tools/subagent-actions.mjs +15 -6
  39. package/src/agent-tools/subagent-async.mjs +66 -14
  40. package/src/agent-tools/subagent-panel.mjs +22 -15
  41. package/src/agent-tools/subagent-run.mjs +9 -6
  42. package/src/agent-tools/subagent-scheduler.mjs +57 -8
  43. package/src/agent-tools/subagent-spawn.mjs +63 -16
  44. package/src/agent-tools/subagent.mjs +175 -49
  45. package/src/agent-tools/verify.mjs +13 -34
  46. package/src/agent-tools.mjs +1 -0
  47. package/src/agent.mjs +38 -21
  48. package/src/cli/distill-command.mjs +2 -2
  49. package/src/cli/make-agent.mjs +23 -7
  50. package/src/cli/memory-command.mjs +2 -2
  51. package/src/cli/setup-wizard.mjs +29 -9
  52. package/src/completions.mjs +114 -0
  53. package/src/config-migrate.mjs +70 -0
  54. package/src/config.mjs +132 -63
  55. package/src/conventions.mjs +223 -0
  56. package/src/crash-reports.mjs +7 -2
  57. package/src/expand-home.mjs +16 -0
  58. package/src/generate-title.mjs +1 -1
  59. package/src/hooks.mjs +7 -3
  60. package/src/memory/code-index.mjs +9 -3
  61. package/src/memory/code-sync.mjs +70 -31
  62. package/src/memory/delete.mjs +2 -0
  63. package/src/memory/docs.mjs +10 -6
  64. package/src/memory/file-walk.mjs +109 -0
  65. package/src/memory/schema.mjs +15 -3
  66. package/src/model-ref.mjs +66 -0
  67. package/src/model-specs.mjs +42 -8
  68. package/src/prompt-overlays.mjs +73 -16
  69. package/src/prompts/advisor-design.md +18 -8
  70. package/src/prompts/advisor-round1.md +7 -1
  71. package/src/prompts/advisor-round2.md +13 -2
  72. package/src/prompts/advisor-round3.md +13 -2
  73. package/src/prompts/common.md +115 -0
  74. package/src/prompts/consult-base.md +2 -0
  75. package/src/prompts/discipline-engineering.md +217 -0
  76. package/src/prompts/discipline-normal.md +179 -0
  77. package/src/prompts/persona-coder.md +21 -0
  78. package/src/prompts/persona-eng-coder.md +37 -0
  79. package/src/prompts/persona-eng-designer.md +55 -0
  80. package/src/prompts/persona-engineering.md +54 -0
  81. package/src/prompts/persona-explore.md +15 -0
  82. package/src/prompts/persona-normal.md +27 -0
  83. package/src/prompts/persona-plan.md +26 -0
  84. package/src/provider/anthropic.mjs +4 -4
  85. package/src/provider/core.mjs +13 -32
  86. package/src/provider/errors.mjs +26 -1
  87. package/src/provider/google.mjs +5 -6
  88. package/src/provider/index.mjs +2 -1
  89. package/src/provider/list-models.mjs +93 -0
  90. package/src/provider/rate.mjs +2 -1
  91. package/src/provider/responses.mjs +5 -3
  92. package/src/provider/sse.mjs +3 -4
  93. package/src/proxy.mjs +9 -14
  94. package/src/session-slots.mjs +7 -2
  95. package/src/session.mjs +59 -38
  96. package/src/token-ttl.mjs +2 -1
  97. package/src/tools/{system.mjs → bash.mjs} +6 -243
  98. package/src/tools/file.mjs +30 -10
  99. package/src/tools/git.md +1 -1
  100. package/src/tools/git.mjs +15 -34
  101. package/src/tools/index.mjs +4 -2
  102. package/src/tools/ops.mjs +20 -7
  103. package/src/tools/question.md +1 -0
  104. package/src/tools/question.mjs +26 -0
  105. package/src/tools/read.md +1 -1
  106. package/src/tools/read_image.md +1 -1
  107. package/src/tools/search.mjs +236 -0
  108. package/src/tui/agent-turn.mjs +32 -13
  109. package/src/tui/ansi.mjs +2 -0
  110. package/src/tui/clipboard.mjs +7 -1
  111. package/src/tui/cmd-advisor.mjs +3 -2
  112. package/src/tui/cmd-config.mjs +108 -37
  113. package/src/tui/cmd-eng.mjs +11 -27
  114. package/src/tui/cmd-exit.mjs +6 -8
  115. package/src/tui/cmd-model.mjs +14 -12
  116. package/src/tui/cmd-reindex.mjs +7 -0
  117. package/src/tui/cmd-submodel.mjs +8 -5
  118. package/src/tui/cmd-undo.mjs +4 -3
  119. package/src/tui/index.mjs +38 -37
  120. package/src/tui/key-handler.mjs +61 -17
  121. package/src/tui/key-modes.mjs +86 -8
  122. package/src/tui/layout.mjs +18 -10
  123. package/src/tui/model-catalog.mjs +89 -0
  124. package/src/tui/model-picker.mjs +498 -0
  125. package/src/tui/mouse.mjs +47 -10
  126. package/src/tui/pickers.mjs +28 -410
  127. package/src/tui/render-frame.mjs +32 -16
  128. package/src/tui/render-loop.mjs +2 -0
  129. package/src/tui/render-segments.mjs +5 -19
  130. package/src/tui/render.mjs +37 -5
  131. package/src/tui/slash-commands.mjs +2 -2
  132. package/src/tui/subagent-blocks.mjs +69 -90
  133. package/src/tui/subagent-children.mjs +50 -64
  134. package/src/tui/subagent-freeze.mjs +40 -43
  135. package/src/tui/subagent-panel.mjs +21 -66
  136. package/src/tui/suspension-drive.mjs +28 -82
  137. package/src/tui/tool-args.mjs +4 -2
  138. package/src/tui/tool-display.mjs +4 -3
  139. package/src/tui/tool-events.mjs +23 -16
  140. package/src/tui/tui-lifecycle.mjs +18 -5
  141. package/src/tui/wizard.mjs +52 -18
  142. package/src/tui/wrapped-spawn.mjs +38 -0
  143. package/src/prompts/coder.md +0 -13
  144. package/src/prompts/discipline.md +0 -84
  145. package/src/prompts/eng-coder.md +0 -19
  146. package/src/prompts/engineering-sub.md +0 -14
  147. package/src/prompts/engineering.md +0 -87
  148. package/src/prompts/explore.md +0 -12
  149. package/src/prompts/main.md +0 -34
  150. package/src/prompts/methodology-template.md +0 -38
  151. package/src/prompts/plan.md +0 -9
  152. package/src/prompts/system.md +0 -44
@@ -1,14 +1,14 @@
1
1
  /** /eng command: toggle engineering mode.
2
- * Requires METHODOLOGY.md in project root. Offers to create one if missing.
3
- * ctx: { agent, pushLine, pushLabel, showPicker } */
4
- import { existsSync, copyFileSync, readFileSync, writeFileSync, mkdirSync, renameSync, unlinkSync } from "node:fs"
5
- import { join, dirname } from "node:path"
6
- import { fileURLToPath } from "node:url"
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, showPicker } = ctx
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
- // §24 D-24b: per-review instances die with the mode (fresh convergence cycles
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(` → strictly following ${methodologyPath}`, C.dim)
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
  }
@@ -1,9 +1,7 @@
1
- /** /exit command: exit TUI (same path as Ctrl+C).
2
- * Direct synchronous process.exit — prevents the post-handler render()
3
- * from redrawing the TUI over the cleaned terminal. The actual cleanup
4
- * (saveSession + closeMcp + terminal reset) runs once
5
- * via the process.on("exit") handler registered in index.mjs.
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
  }
@@ -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 provider directly via `/model <provider>[:model]`.
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]?.toLowerCase()
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
- // Parse "provider:model" syntax
12
- const colonIdx = raw.indexOf(":")
13
- const providerName = colonIdx >= 0 ? raw.slice(0, colonIdx) : raw
14
- const modelName = colonIdx >= 0 ? raw.slice(colonIdx + 1) : null
15
-
16
- const target = ctx.agent.providers.find((p) => p.name.toLowerCase() === providerName)
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: target.name, model: modelName || target.model }).catch((e) => ctx.pushLine(`[error] ${e.message}`, C.error))
24
+ await ctx.selectModel({ provider: parsed.provider.name, model: parsed.model }).catch((e) => ctx.pushLine(`[error] ${e.message}`, C.error))
23
25
  }
@@ -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
  }
@@ -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 + 4 role slots
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 + 4 role slots ──
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
- const value = `${agent.activeProvider}:${agent.activeModel ?? agent.provider?.model}`
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 {
@@ -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
- const abs = join(cwd, ...path.split("/"))
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, ...item.path.split("/"))
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, SLASH_ALIASES, createSlashCommands } from "./slash-commands.mjs"
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
- pushLine("未配置有效 provider,可用 /model 选择或 /provider 配置", C.warn)
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: [](§27 R23 嵌套子代理子块载体——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(R23:子块计入外层同配额——树级 trim
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: [], // queued messages while processing: [{ text }], auto-dequeued when current turn finishes
114
- interruptPrompt: null, // Ctrl+I interrupt message input: { text: "" } or null
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, // §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)。
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._panelSnapshot = [] // 镜像初始态 = 空面板(非"无镜像"——[] 与 undefined 区分)
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
 
@@ -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
- if (state.processing && state.controller) state.controller.abort()
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
- state.interruptPrompt = { text: "" }
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
- // during processing, allow input (queued), but block arrow-key history and Tab completion
267
- if (key.name === "tab" || key.name === "up" || key.name === "down") return
268
- // Ctrl+D: remove last item from queue
269
- if (key.ctrl && key.name === "d") {
270
- if (state.queue.length > 0) {
271
- state.queue.pop()
272
- render()
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
- // input history
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:挂起态(suspended 或释放窗口 _suspPending)Enter =
376
- // 新回合输入(非打断)——入 pendingInput 由挂起会话调度;输入框零干扰(F3)。
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()
@@ -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
- q.selected = Math.max(0, (q.selected ?? 0) - 1)
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 = Math.min(q.options.length - 1, (q.selected ?? 0) + 1)
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 取消;字符进 prompt;其余键静默吞(模态独占)。
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 = (state.interruptPrompt.text ?? "").trim()
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 === "backspace") {
208
- state.interruptPrompt.text = state.interruptPrompt.text.slice(0, -1)
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 (str && !key.ctrl && !key.meta) {
211
- state.interruptPrompt.text += str.replace(/[\r\n]+/g, "")
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
  }