thincoder 0.12.58 → 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.
Files changed (114) hide show
  1. package/CHANGELOG.md +42 -1
  2. package/README.md +1 -1
  3. package/bin/thincoder.mjs +8 -0
  4. package/package.json +1 -1
  5. package/src/acp/bridge.mjs +132 -26
  6. package/src/advisor/messages.mjs +34 -1
  7. package/src/advisor/run.mjs +89 -51
  8. package/src/advisor.mjs +15 -7
  9. package/src/agent/dispatch.mjs +91 -14
  10. package/src/agent/helpers.mjs +35 -4
  11. package/src/agent/setup.mjs +90 -19
  12. package/src/agent/spawn-child.mjs +25 -0
  13. package/src/agent-tools/advisor.mjs +24 -2
  14. package/src/agent-tools/consult.mjs +37 -6
  15. package/src/agent-tools/eng.mjs +2 -1
  16. package/src/agent-tools/goal.mjs +11 -1
  17. package/src/agent-tools/read-history.mjs +160 -0
  18. package/src/agent-tools/settings.mjs +162 -0
  19. package/src/agent-tools/skill.mjs +2 -1
  20. package/src/agent-tools/subagent-actions.mjs +432 -0
  21. package/src/agent-tools/subagent-async.mjs +427 -0
  22. package/src/agent-tools/subagent-scheduler.mjs +319 -0
  23. package/src/agent-tools/subagent.mjs +467 -193
  24. package/src/agent-tools/task.mjs +4 -3
  25. package/src/agent-tools/timer.mjs +9 -4
  26. package/src/agent-tools/verify.mjs +161 -49
  27. package/src/agent-tools.mjs +1 -0
  28. package/src/agent.mjs +161 -125
  29. package/src/auto-think.mjs +14 -0
  30. package/src/cli/make-agent.mjs +2 -1
  31. package/src/cli/permission.mjs +8 -1
  32. package/src/config.mjs +5 -0
  33. package/src/context.mjs +87 -27
  34. package/src/distill.mjs +19 -1
  35. package/src/escape.mjs +6 -4
  36. package/src/log.mjs +195 -0
  37. package/src/memory/code-sync.mjs +1 -1
  38. package/src/memory/core.mjs +126 -0
  39. package/src/memory/docs.mjs +196 -87
  40. package/src/memory.mjs +1 -1
  41. package/src/model-specs.mjs +15 -1
  42. package/src/prompts/advisor-design.md +46 -0
  43. package/src/prompts/advisor-round1.md +49 -2
  44. package/src/prompts/advisor-round2.md +47 -0
  45. package/src/prompts/advisor-round3.md +47 -0
  46. package/src/prompts/coder.md +22 -0
  47. package/src/prompts/consult-base.md +13 -0
  48. package/src/prompts/discipline.md +10 -5
  49. package/src/prompts/eng-coder.md +2 -2
  50. package/src/prompts/engineering-sub.md +23 -1
  51. package/src/prompts/engineering.md +106 -56
  52. package/src/prompts/explore.md +1 -2
  53. package/src/prompts/main.md +11 -6
  54. package/src/prompts/methodology-template.md +14 -0
  55. package/src/prompts/system.md +4 -2
  56. package/src/provider/core.mjs +56 -2
  57. package/src/tools/apply_patch.md +3 -1
  58. package/src/tools/bash.md +1 -1
  59. package/src/tools/delete.md +1 -0
  60. package/src/tools/edit-batch.mjs +31 -43
  61. package/src/tools/edit-diff.mjs +265 -0
  62. package/src/tools/edit.md +10 -8
  63. package/src/tools/execute.md +7 -7
  64. package/src/tools/execute.mjs +24 -20
  65. package/src/tools/file.mjs +18 -68
  66. package/src/tools/file_ops.md +2 -1
  67. package/src/tools/get_current_time.md +3 -1
  68. package/src/tools/hashline_edit.md +2 -0
  69. package/src/tools/index.mjs +3 -2
  70. package/src/tools/insert_after.md +2 -1
  71. package/src/tools/lint.md +2 -0
  72. package/src/tools/lsp.md +4 -1
  73. package/src/tools/patch.mjs +84 -13
  74. package/src/tools/pdf-parse-text.mjs +497 -0
  75. package/src/tools/pdf-parse-xref.mjs +499 -0
  76. package/src/tools/pdf.mjs +155 -0
  77. package/src/tools/question.md +2 -1
  78. package/src/tools/read.md +1 -0
  79. package/src/tools/read_pdf.md +21 -0
  80. package/src/tools/repomap.mjs +1 -1
  81. package/src/tools/shared.mjs +4 -12
  82. package/src/tools/system.mjs +6 -21
  83. package/src/tools/tree.md +2 -1
  84. package/src/tools/web.mjs +5 -3
  85. package/src/tools/websearch.md +2 -1
  86. package/src/tools/write.md +2 -0
  87. package/src/traces/trace-store.mjs +224 -0
  88. package/src/tui/agent-turn.mjs +385 -22
  89. package/src/tui/clipboard.mjs +15 -4
  90. package/src/tui/cmd-config.mjs +29 -9
  91. package/src/tui/cmd-extract.mjs +1 -1
  92. package/src/tui/cmd-mcp.mjs +9 -0
  93. package/src/tui/cmd-think.mjs +1 -1
  94. package/src/tui/index.mjs +29 -95
  95. package/src/tui/interaction.mjs +13 -2
  96. package/src/tui/key-handler.mjs +105 -155
  97. package/src/tui/key-modes.mjs +215 -0
  98. package/src/tui/layout.mjs +22 -1
  99. package/src/tui/mouse.mjs +40 -0
  100. package/src/tui/pickers.mjs +11 -3
  101. package/src/tui/render-conversation.mjs +13 -161
  102. package/src/tui/render-frame.mjs +27 -10
  103. package/src/tui/render-loop.mjs +4 -1
  104. package/src/tui/render-segments.mjs +165 -0
  105. package/src/tui/startup.mjs +36 -0
  106. package/src/tui/subagent-blocks.mjs +322 -144
  107. package/src/tui/subagent-panel.mjs +88 -13
  108. package/src/tui/tool-args.mjs +10 -2
  109. package/src/tui/tool-events.mjs +132 -100
  110. package/src/tui/update-notice.mjs +72 -0
  111. package/src/tui/wizard.mjs +36 -6
  112. package/src/agent-tools/escalate.mjs +0 -179
  113. package/src/agent-tools/subagent-check.mjs +0 -107
  114. package/src/tools/exec-prelude.mjs +0 -84
@@ -0,0 +1,215 @@
1
+ /**
2
+ * key-modes.mjs — 按键模态层(2026-09-03 D-S4 自 key-handler.mjs 拆出):
3
+ * permission / question / interruptPrompt 三种独占模态分支。每个 handler 在模态激活时
4
+ * 消费全部按键(返回 true——含未匹配键,与拆出前无条件 return 语义一致),未激活返回 false。
5
+ * ctx: { state, agent, pushLine, render }(各 handler 按需取用)。
6
+ * 搜索模态另居 key-handler-search.mjs(既有拆分,本批不动)。
7
+ */
8
+ import { C } from "./ansi.mjs"
9
+ import { readClipboardText, insertPastedText } from "./clipboard.mjs"
10
+ import { QUESTION_CUSTOM } from "./interaction.mjs"
11
+
12
+ /** permission 确认模态:y/n/a(a = approve + AUTO ON);batch(§16 D-B1):a/o/n(Esc = deny)。
13
+ * consume:valid 键或 Esc 走 resolve 分支;其余键静默吞掉(模态独占)。 */
14
+ export function handlePermissionMode(str, key, ctx) {
15
+ const { state, agent, pushLine, render } = ctx
16
+ if (!state.permission) return false
17
+ const answer = (str || "").toLowerCase()
18
+ const isContinue = state.permission.name === "continue"
19
+ const isBatch = Boolean(state.permission.batch)
20
+ const validKeys = isContinue ? ["y", "n"] : isBatch ? ["a", "o", "n"] : ["y", "n", "a"]
21
+ if (validKeys.includes(answer) || key.name === "escape") {
22
+ const { resolve, name } = state.permission
23
+ state.permission = null
24
+ state.permissionPreview = []
25
+ state.status = "Processing..."
26
+ if (isBatch) {
27
+ // Merged batch ask: resolve the verdict string; dispatch applies it
28
+ // (approveAll = batch-scope allowance only, NOT the persistent AUTO flag).
29
+ const verdict = answer === "a" ? "approveAll" : answer === "o" ? "oneByOne" : "deny"
30
+ const tone = verdict === "approveAll" ? C.dim : C.error
31
+ pushLine(` [${verdict === "approveAll" ? "approved" : verdict === "deny" ? "denied" : "one by one"}] ${name}`, tone)
32
+ resolve(verdict)
33
+ render()
34
+ return true
35
+ }
36
+ if (answer === "a" && !isContinue) {
37
+ agent.autoApprove = true
38
+ agent._pendingReminders = agent._pendingReminders ?? []
39
+ agent._pendingReminders.push("[System reminder: AUTO mode is now ON. All tool calls are automatically approved. Use /auto to disable.]")
40
+ pushLine(` [auto] AUTO ON: tool calls no longer prompt for approval (/auto to disable)`, C.warn)
41
+ }
42
+ const approved = answer === "y" || (answer === "a" && !isContinue)
43
+ // leave trail: record approval/denial in conversation (continue prompt has its own output, don't duplicate)
44
+ if (!isContinue) {
45
+ pushLine(` [${approved ? "approved" : "denied"}] ${name}`, approved ? C.dim : C.error)
46
+ }
47
+ resolve(approved)
48
+ render()
49
+ }
50
+ return true
51
+ }
52
+
53
+ /** question 工具回调模态:自由文本(type answer, Enter submit, Esc cancel)或选项
54
+ * (↑↓ select, Enter confirm, Esc cancel——QUESTION_CUSTOM 项切自由文本模式)。
55
+ * 自由文本态编辑键契约见 TUI-INPUT-BOX.md §7.2(←→/Home/End/Ctrl+U/Backspace/中段插入/
56
+ * Ctrl+V 落 cursor/\n→空格/Ctrl+J no-op/未列键吞——无 fall-through);Esc 语义 round2 #5:
57
+ * 有 options(Custom 进入)→ 回 options 态;无 options → 中止 question。
58
+ * consume:模态激活时全部按键独占(含未匹配键——拆出前 onKeypress 尾 return)。 */
59
+ export function handleQuestionMode(str, key, ctx) {
60
+ const { state, pushLine, render } = ctx
61
+ if (!state.question) return false
62
+ const q = state.question
63
+ if (q.options.length > 0) {
64
+ // options mode: ↑↓ select, Enter confirm, Esc cancel
65
+ if (key.name === "escape") {
66
+ q.resolve("")
67
+ state.question = null
68
+ state.status = "Processing..."
69
+ render()
70
+ } else if (key.name === "up") {
71
+ q.selected = Math.max(0, (q.selected ?? 0) - 1)
72
+ render()
73
+ } else if (key.name === "down") {
74
+ q.selected = Math.min(q.options.length - 1, (q.selected ?? 0) + 1)
75
+ render()
76
+ } else if (key.name === "return") {
77
+ const answer = q.options[q.selected ?? 0]
78
+ if (answer === QUESTION_CUSTOM) {
79
+ // Switch to free-text mode — the user wants to type their own answer.
80
+ // options 列表备份(_backOptions)——自由文本态 Esc 可回 options 态(round2 #5 逃生口);
81
+ // answer/cursor 按 §7.2 初始化(codepoint 数组 + cursor = answer.length)。
82
+ q._backOptions = { options: q.options, selected: q.selected }
83
+ q.options = []
84
+ q.selected = undefined
85
+ q.answer = []
86
+ q.cursor = 0
87
+ state.status = "Waiting for answer..."
88
+ render()
89
+ return true
90
+ }
91
+ q.resolve(answer)
92
+ state.question = null
93
+ state.status = "Processing..."
94
+ pushLine(` → ${answer}`, C.tool)
95
+ render()
96
+ }
97
+ } else {
98
+ // free text: codepoint-array answer + cursor editing (TUI-INPUT-BOX.md §7.2)
99
+ if (!Array.isArray(q.answer)) q.answer = q.answer ? [...q.answer] : []
100
+ q.cursor = Math.max(0, Math.min(q.cursor ?? q.answer.length, q.answer.length))
101
+ const len = q.answer.length
102
+ if (key.name === "escape") {
103
+ // round2 #5:有 options(Custom 进入)→ 回 options 态(误触 Custom 有逃生口——
104
+ // 选择列表与选中位恢复);无 options → 中止 question(原语义)。
105
+ if (q._backOptions) {
106
+ q.options = q._backOptions.options
107
+ q.selected = q._backOptions.selected
108
+ delete q._backOptions
109
+ delete q.answer
110
+ delete q.cursor
111
+ state.status = "Waiting for choice..."
112
+ render()
113
+ } else {
114
+ q.resolve("")
115
+ state.question = null
116
+ state.status = "Processing..."
117
+ render()
118
+ }
119
+ } else if (key.name === "return") {
120
+ if (q._pasting) return true // block Enter while paste is in flight
121
+ const answer = q.answer.join("").trim()
122
+ q.resolve(answer || "")
123
+ state.question = null
124
+ state.status = "Processing..."
125
+ pushLine(` → ${answer || "(empty)"}`, C.tool)
126
+ render()
127
+ } else if (key.name === "enter") {
128
+ // Ctrl+J(\n → name "enter")= no-op 吞——不插换行无 fall-through(round2 #2)
129
+ } else if (key.name === "left") {
130
+ q.cursor = Math.max(0, q.cursor - 1)
131
+ render()
132
+ } else if (key.name === "right") {
133
+ q.cursor = Math.min(len, q.cursor + 1)
134
+ render()
135
+ } else if (key.name === "home") {
136
+ q.cursor = 0
137
+ render()
138
+ } else if (key.name === "end") {
139
+ q.cursor = len
140
+ render()
141
+ } else if (key.ctrl && !key.alt && key.name === "u") {
142
+ q.answer = []
143
+ q.cursor = 0
144
+ render()
145
+ } else if (key.name === "backspace") {
146
+ if (q.cursor > 0) {
147
+ q.answer.splice(q.cursor - 1, 1) // codepoint 元素删除——emoji 不劈半(round2 #1)
148
+ q.cursor--
149
+ render()
150
+ }
151
+ } else if (key.ctrl && !key.alt && !key.meta && key.name === "v") {
152
+ // Ctrl+V paste: read clipboard text (fires when the terminal passes Ctrl+V through
153
+ // as a key event; bracketed-paste terminals are handled upstream in the stdin handler).
154
+ // 两路径同归 insertPastedText——落 cursor 位置、\n→空格(clipboard.mjs 单行守卫)。
155
+ if (q._pasting) return true
156
+ q._pasting = true
157
+ readClipboardText().then((text) => {
158
+ q._pasting = false
159
+ if (text) {
160
+ // activeQuestion 守卫:粘贴在途用户 Esc 中止/Enter 提交后,本回调不得把
161
+ // 剪贴板内容落进主输入框(审计 F1——insertPastedText 见 stale 即丢)。
162
+ insertPastedText(state, text, q)
163
+ render()
164
+ }
165
+ }).catch((e) => {
166
+ q._pasting = false
167
+ console.error(`[tui] clipboard paste failed: ${e.message}`)
168
+ })
169
+ } else if (str && !key.ctrl && !key.meta && key.name !== "tab" && key.name !== "enter") {
170
+ // 可打印字符插入光标位置(codepoint 拆字);\r\n 剥离 + \t→2 空格(单行不变式同主输入清洗口径)
171
+ const chars = [...str.replace(/[\r\n]+/g, "").replace(/\t/g, " ")]
172
+ if (chars.length) {
173
+ q.answer.splice(q.cursor, 0, ...chars)
174
+ q.cursor += chars.length
175
+ render()
176
+ }
177
+ }
178
+ // 未列键(↑↓/PgUp/PgDn/Delete/F1/…)一律消费 return——无 fall-through 到正常编辑
179
+ // (search 穿透教训——TUI-INPUT-BOX.md §3.2 BUG-2)
180
+ }
181
+ return true
182
+ }
183
+
184
+ /** interruptPrompt 模态(Ctrl+I 后输入注入消息):Enter 提交 → controller.abort
185
+ * { interrupt: true, message };Esc 取消;字符进 prompt;其余键静默吞(模态独占)。
186
+ * 返回 false 当模态未激活(key-handler 先检 state.interruptPrompt 入口再调)。 */
187
+ export function handleInterruptMode(str, key, ctx) {
188
+ const { state, pushLine, render } = ctx
189
+ if (!state.interruptPrompt) return false
190
+ if (key.name === "escape") {
191
+ state.interruptPrompt = null
192
+ render()
193
+ } else if (key.name === "return") {
194
+ const msg = (state.interruptPrompt.text ?? "").trim()
195
+ state.interruptPrompt = null
196
+ if (msg) {
197
+ // Guard: if the turn already finished while the user was typing, the controller
198
+ // may have been replaced or already aborted — don't abort a live turn by mistake.
199
+ if (state.processing && state.controller && !state.controller.signal.aborted) {
200
+ pushLine(` [inject] ${msg}`, C.warn)
201
+ state.controller.abort({ interrupt: true, message: msg })
202
+ } else {
203
+ pushLine(` [inject — turn ended, message queued] ${msg}`, C.dim)
204
+ }
205
+ render()
206
+ }
207
+ } else if (key.name === "backspace") {
208
+ state.interruptPrompt.text = state.interruptPrompt.text.slice(0, -1)
209
+ render()
210
+ } else if (str && !key.ctrl && !key.meta) {
211
+ state.interruptPrompt.text += str.replace(/[\r\n]+/g, "")
212
+ render()
213
+ }
214
+ return true
215
+ }
@@ -26,6 +26,14 @@ const MAX_INPUT_LINES = 5
26
26
  const MAX_TASK_LINES = 5
27
27
  const QWIN = 5
28
28
 
29
+ /** question 自由文本态渲染布局:与主输入框同实现(TUI-INPUT-BOX.md §7.2 round2 #1/#6)——
30
+ * 折行展开(`▸ ` 首行 + 续行 2 空格缩进)+ 光标行列;answer 单行不变式(无 \n 字符)由
31
+ * 输入侧硬守卫(Ctrl+J no-op / 粘贴 \n→空格),此处只复用 layoutInput 的折行/光标语义,
32
+ * cap/滚动由 computeLayout 以 MAX_INPUT_LINES + questionOffset 承担(同主输入 inputOffset)。 */
33
+ export function layoutAnswer(chars, cursor, width) {
34
+ return layoutInput(chars, cursor, width)
35
+ }
36
+
29
37
  /**
30
38
  * Computes layout. Returns panel coordinates + precomputed content (height-affecting parts).
31
39
  * Pure function: does not modify state.
@@ -43,6 +51,11 @@ export function computeLayout(state, { cols, rows }) {
43
51
  }
44
52
  const inputLines = inputLayout.lines.slice(inputOffset, inputOffset + MAX_INPUT_LINES)
45
53
  let boxLines = inputLines
54
+ // question 自由文本态(无 options):boxLines = layoutAnswer 行(同主输入 cap + offset 滚动——
55
+ // 光标随行;长答案不挤压会话面板到零行——TUI-INPUT-BOX.md §7.2 round2 #6)。questionLayout/
56
+ // questionOffset 供 render-frame 反显光标与硬件定位(hasOverlay 例外细化同 §7.2)。
57
+ let questionLayout = null
58
+ let questionOffset = 0
46
59
  if (state.question) {
47
60
  const q = state.question
48
61
  if (q.options.length > 0) {
@@ -51,7 +64,13 @@ export function computeLayout(state, { cols, rows }) {
51
64
  boxLines = q.options.slice(start, start + QWIN).map((opt, i) =>
52
65
  (start + i === sel ? "▸ " : " ") + (opt === QUESTION_CUSTOM ? "✍ Custom answer…" : optText(opt)))
53
66
  } else {
54
- boxLines = ["▸ " + (q.answer ?? "")]
67
+ const answerChars = Array.isArray(q.answer) ? q.answer : q.answer ? [...q.answer] : []
68
+ const qCursor = Math.max(0, Math.min(q.cursor ?? answerChars.length, answerChars.length))
69
+ questionLayout = layoutAnswer(answerChars, qCursor, W - 4)
70
+ if (questionLayout.lines.length > MAX_INPUT_LINES) {
71
+ questionOffset = Math.min(questionLayout.cursorLine, questionLayout.lines.length - MAX_INPUT_LINES)
72
+ }
73
+ boxLines = questionLayout.lines.slice(questionOffset, questionOffset + MAX_INPUT_LINES)
55
74
  }
56
75
  }
57
76
  const inputBoxH = boxLines.length + 2
@@ -174,6 +193,8 @@ export function computeLayout(state, { cols, rows }) {
174
193
  // precomputed content (affects height, reused during render)
175
194
  inputLayout,
176
195
  inputOffset,
196
+ questionLayout,
197
+ questionOffset,
177
198
  boxLines,
178
199
  visibleTasks,
179
200
  permPreviewLines,
package/src/tui/mouse.mjs CHANGED
@@ -17,6 +17,8 @@
17
17
  import { computeLayout, subagentLineIndex } from "./layout.mjs"
18
18
  import { buildConvLines, convViewport } from "./render-conversation.mjs"
19
19
  import { toggleFoldBlock, scrollFoldBlock, foldScrollOffset } from "./fold-block.mjs"
20
+ import { cancelAsyncSubagent } from "../agent-tools/subagent-async.mjs"
21
+ import { C } from "./ansi.mjs"
20
22
 
21
23
  /** 2026-08-31 滚轮事件分派(用户需求"展开块能滚动阅读全文"):坐标命中展开块内容行 →
22
24
  * 块内逐行滚动(scrollFoldBlock ±3);未命中 → 会话滚动(调用方继续处理)。
@@ -125,6 +127,15 @@ export function handleMouseClick(ctx, col, row) {
125
127
  // 评审 #4:保底截断后可见行 ≠ 前 h 行——命中映射与 render-frame 同一几何契约
126
128
  // (subagentLineIndex:分隔线 + 末尾区块行优先)。
127
129
  const lineEl = layout.subagentLines[subagentLineIndex(layout.subagentLines, P.subagent.h, r - P.subagent.y)]
130
+ // §19.5 D-M7/D-M7b ⏹ 停止标记(round1 #6 + 用户裁定 B 形态):列级命中——⏹ 列
131
+ // 点击 = cancel(定向该子代理——ctx.cancelSubagent 直连池 abort 路径,不经模型
132
+ // 回合),不触发折叠翻转;**仅 async 区块带 _stopSub 元数据**(sync 无 ⏹——
133
+ // 其右缘点击照常走折叠翻转——D-M7b ③);⏹ 区外点击照常走折叠/翻窗。
134
+ if (lineEl?._stopSub && col >= (lineEl._stopCol ?? Infinity)) {
135
+ ctx.cancelSubagent?.(lineEl._stopSub)
136
+ render()
137
+ return true
138
+ }
128
139
  if (lineEl?._foldScrollUp || lineEl?._foldScrollDown) {
129
140
  // ▲/▼ 控制行点击翻窗(60% 封顶保留、窗口随翻滚动,全文可达)
130
141
  scrollFoldBlock(state, lineEl._foldScrollUp ?? lineEl._foldScrollDown,
@@ -164,3 +175,32 @@ export function handleMouseClick(ctx, col, row) {
164
175
 
165
176
  return false
166
177
  }
178
+
179
+ /** 鼠标装配簇(2026-09-03 D-S1a 自 index.mjs 迁入):index.mjs 只留装配调用。
180
+ * ctx: { agent, state, pushLine, render, popPicker };返回 { onMouseClick, mouseCtx }。
181
+ * cancelSubagent(⏹ 停止标记,§19.5 D-M7):UI 停止不经模型回合,直连池 abort——
182
+ * 与 action:"cancel" 同实现路径。block key "role#id" → 池条目 id。 */
183
+ export function createMouseDispatch({ agent, state, pushLine, render, popPicker }) {
184
+ const cancelSubagent = (key) => {
185
+ try {
186
+ const id = key.slice(key.lastIndexOf("#") + 1)
187
+ const r = cancelAsyncSubagent(agent, id)
188
+ if (r?.status === "error") {
189
+ // 池内无此条目但区块仍 live(!done)→ 阻塞型(sync)spawn 中——无池条目可定向
190
+ // 中止(§19.5 cancel 只针对 async 后台子代理);给出可操作指引而非神秘 unknown id
191
+ const liveBlock = state.subTasks?.[key] && !state.subTasks[key].done
192
+ pushLine(liveBlock
193
+ ? `[subagent stop] ${key} is a blocking (sync) child mid-call — targeted stop is not available; use Ctrl+C to interrupt the turn`
194
+ : `[subagent stop] ${r.error}`, C.error)
195
+ } else {
196
+ pushLine(`[subagent ${key} stop requested]`, C.warn)
197
+ }
198
+ } catch (e) {
199
+ pushLine(`[subagent stop] ${e?.message ?? String(e)}`, C.error)
200
+ }
201
+ render()
202
+ }
203
+ const onMouseClick = (col, row) => handleMouseClick({ state, render, popPicker, cancelSubagent }, col, row)
204
+ const mouseCtx = () => ({ state, render })
205
+ return { onMouseClick, mouseCtx }
206
+ }
@@ -342,10 +342,18 @@ export function createPickers(ctx) {
342
342
  if (!baseURL) return
343
343
  const model = await askQuestion("Enter model name:")
344
344
  if (!model) return
345
- const format = (await askQuestion("API format [openai/anthropic/google] (default: openai):")).trim().toLowerCase()
345
+ // D-C1(TUI.md §10.6D):API format 改 picker 选择(openai/anthropic/google——默认 index=0
346
+ // openai)——固定枚举不再手输;Esc/取消 = 中止整个 Add Provider 流程(cfg 尚未落盘——
347
+ // 无半配置,与旧手输非法 abort 同语义)。与 preset 选择器同形态——零新机制。
348
+ const format = await showPicker("API format", [
349
+ { type: "item", text: "openai", name: "openai" },
350
+ { type: "item", text: "anthropic", name: "anthropic" },
351
+ { type: "item", text: "google", name: "google" },
352
+ ])
353
+ if (!format) return // Esc/取消 → 中止流程
346
354
  const cfg = { name, baseURL, model }
347
- if (format === "anthropic" || format === "google") cfg.format = format
348
- else if (format && format !== "openai") return // unknown format → abort
355
+ if (format.name === "anthropic" || format.name === "google") cfg.format = format.name
356
+ else if (format.name !== "openai") return // 防御:未知格式(理论不可达——picker 枚举)
349
357
  agent.providers.push(cfg)
350
358
  await persistRaw((raw) => { raw.providers = agent.providers })
351
359
  const key = await askQuestion(`Enter API key for ${name} (skip if none):`)
@@ -16,6 +16,7 @@ import {
16
16
  renderMathAndMarkdown, foldCapRows, renderFoldedHead, foldTailLines,
17
17
  } from "./fold-block.mjs"
18
18
  import { ADVISOR_THINKING_PLACEHOLDER } from "../advisor/run.mjs"
19
+ import { frozenSubSeg, toolSeg, frozenAdvSeg } from "./render-segments.mjs"
19
20
 
20
21
 
21
22
  // Test seam (mirrors the _-prefixed seams in run.mjs) — moved to fold-block.mjs.
@@ -27,7 +28,9 @@ let _convCache = { key: "", cols: 0, lines: [] }
27
28
  /** 2026-08-31 懒加载卡顿优化②:段级行体缓存(行对象→conv 行数组)。
28
29
  * 与行级 wrapRowsCached 分层:wrap 缓存只省 markdown/换行重算(streaming 行 text 变失效),
29
30
  * 段缓存省**整个行体的折叠/展开/窗口化组装**——loadOlder unshift 后尾部 987 行段全命中,
30
- * rebuild 从 25.7ms → ~8ms 量级。签名由 lineSegSig 集中计算(该段输出的所有决定因素)。 */
31
+ * rebuild 从 25.7ms → ~8ms 量级。签名由 lineSegSig 集中计算(该段输出的所有决定因素)。
32
+ * 2026-09-03 D-S2:tool/frozenSub/frozenAdvisor 三段缓存随段实现迁至 render-segments.mjs
33
+ * (各段独立 WeakMap——行对象载体系互斥——此处只剩普通源行段缓存)。 */
31
34
  const _lineSegCache = new WeakMap()
32
35
 
33
36
  /** 段签名:段输出的所有决定因素(漏一项 → 缓存失效不全 → 显示 stale)。
@@ -94,61 +97,6 @@ function buildLineSeg(state, l, i, cols, maxRows) {
94
97
  return out
95
98
  }
96
99
 
97
- /** 工具块段(2026-08-31 段缓存抽出——真实会话 106 个工具块每帧全量 wrap 32ms 的根治)。
98
- * 返回 conv 行数组(工具块的折叠头+尾/展开窗口+控制行),逻辑与原 L221-258 逐字同构。 */
99
- function buildToolBlockSeg(state, l, i, cols, maxRows) {
100
- const b = l._toolBlock
101
- const foldKey = `tool-${l._lineId ?? i}`
102
- const out = []
103
- const status = !b.done
104
- ? "running"
105
- : `${b.elapsed !== null ? b.elapsed + "ms" : ""}${b.summary ? (b.elapsed !== null ? " · " : "") + sliceByWidth(b.summary, 50) : ""}`.trim() || "done"
106
- if (isExpanded(state, foldKey)) {
107
- const body = []
108
- const pushWrapped = (raw, color) => {
109
- for (const w of wrapText(raw, cols - 4)) body.push({ text: " " + w, color, _skipDimFold: true })
110
- }
111
- for (const jl of b.argsJson) pushWrapped(jl, C.dim)
112
- for (const ol of b.output) pushWrapped(ol, C.tool)
113
- if (b.result) for (const rl of b.result) pushWrapped(rl, C.dim)
114
- out.push(...renderExpandedBlock({ body, foldKey, state, maxRows, cols, label: `${b.name}${b.roundTag || ""} ${b.argsSummary}`.trim() }))
115
- } else {
116
- const headText = sliceByWidth(
117
- `❯ ${b.name}${b.roundTag || ""}${b.argsSummary ? " " + b.argsSummary : ""} · ${status}`,
118
- Math.max(20, cols - 2),
119
- )
120
- const body = []
121
- for (const jl of b.argsJson) for (const w of wrapText(jl, cols - 4)) body.push({ text: w, color: C.dim, _skipDimFold: true })
122
- for (const ol of b.output.slice(-3)) for (const w of wrapText(ol, cols - 4)) body.push({ text: w, color: C.dim, _skipDimFold: true })
123
- if (b.result) for (const rl of b.result) for (const w of wrapText(rl, cols - 4)) body.push({ text: w, color: C.dim, _skipDimFold: true })
124
- out.push(...renderFoldedHead({ header: { text: headText, color: C.tool, _foldToggle: foldKey }, body, cols }))
125
- }
126
- return out
127
- }
128
-
129
- /** frozenAdvisor 段(2026-08-31 段缓存抽出)——foldKey 升级为 _lineId 派生(同 long-N 判例)。 */
130
- function buildFrozenAdvSeg(state, l, i, cols, maxRows) {
131
- const frozenAdvKey = `advisor-done-${l._lineId ?? i}`
132
- const out = []
133
- if (isExpanded(state, frozenAdvKey)) {
134
- const body = []
135
- const rendered = renderMathAndMarkdown(sanitizeDisplay(l._frozenAdvisor))
136
- for (const line of formatTables(rendered, cols - 1)) {
137
- for (const wrapped of wrapText(line, cols - 1)) {
138
- body.push({ text: wrapped, color: C.reason, _skipDimFold: true })
139
- }
140
- }
141
- out.push(...renderExpandedBlock({ body, foldKey: frozenAdvKey, state, maxRows, cols, label: "[advisor · review done]" }))
142
- } else {
143
- out.push({
144
- text: `▶ [advisor · review done] … click to expand`,
145
- color: C.fold,
146
- _foldToggle: frozenAdvKey,
147
- })
148
- }
149
- return out
150
- }
151
-
152
100
 
153
101
  /** 2026-08-31 懒加载卡顿根因修复:行级 wrap/markdown 渲染缓存。
154
102
  * buildConvLines 全量重建 O(总行数)——真实 200 条历史 → 987 conv 行 94ms、loadOlder 后
@@ -219,9 +167,6 @@ export function convCacheKey(state, maxRows) {
219
167
  return `${state.lines.length}|${lastLine?.text.length ?? 0}|${state.streaming.length}|${state.reasoning.length}|${blocksSig}|${frozenSig}|${toolSig}|${colorSig}|${state.foldEnabled !== false ? "f" : "u"}|${exp}|${capPart}|${searchPart}|${foldScrollSig}`
220
168
  }
221
169
 
222
- /** Fold marker line: bold-cyan icon + "click to …" phrase underlined (clickable affordance).
223
- * No indent — flush with the content below it; the caller adds a blank line BEFORE it
224
- * so the control line stands apart from unrelated content (reported UX). */
225
170
 
226
171
  function highlightSearchMatches(text, query, matchesInLine, globalCurrentIndex, allMatches, lineIndex) {
227
172
  if (!matchesInLine || matchesInLine.length === 0 || !query) return text
@@ -247,45 +192,6 @@ function highlightSearchMatches(text, query, matchesInLine, globalCurrentIndex,
247
192
  return result
248
193
  }
249
194
 
250
- /** Render a FROZEN child activity block carried on a state.lines entry
251
- * (subagent-blocks.mjs freezeSubTaskLines pushes {_frozenSubTask: sub}). The
252
- * running form renders in the fixed bottom panel (§7.2.1 subagent-panel.mjs);
253
- * the frozen form stays in the stream with the same interaction: folded =
254
- * `[✓ coder#1 · glm-5.3 · done 45s · turn 12/100] … click to expand` header +
255
- * tail 3 block lines; expanded = blank + ▼ control + full timeline (60% screen
256
- * cap via the shared component — capped view ends in a reachable collapse
257
- * control). Toggle key `sub-${key}` — the SAME key the panel section uses, so
258
- * fold state carries across the freeze boundary seamlessly (user ruled
259
- * 2026-08-30: frozen stays clickable — full design interaction, not a
260
- * dim-lines fallback). */
261
- function frozenSubTaskLines(state, sub, cols, maxRows) {
262
- const foldKey = `sub-${sub.key}`
263
- const elapsed = Math.floor(((sub.doneAt ?? Date.now()) - sub.started) / 1000)
264
- const modelPart = sub.model ? ` · ${sub.model}` : ""
265
- const turnPart = sub.maxTurns > 0 ? ` · turn ${sub.turn}/${sub.maxTurns}` : ""
266
- const errPart = sub.lastError ? ` — ${sub.lastError}` : ""
267
- const icon = sub.approval ? "⏸" : "✓"
268
- const header = `[${icon} ${sub.key}${modelPart} · done ${elapsed}s${turnPart}${errPart}]`
269
- const out = []
270
- if (isExpanded(state, foldKey)) {
271
- // Expanded: shared component renders blank + ▼ control + full timeline,
272
- // capped at 60% of the screen with a bottom collapse control.
273
- const body = renderBlockTimeline(sub.blocks, cols)
274
- out.push(...renderExpandedBlock({ body, foldKey, state, maxRows, cols, label: "subagent activity" }))
275
- } else {
276
- // Folded: the header line itself is the control (▶ affordance), then tail 3.
277
- out.push({
278
- text: `▶ ${header} … subagent activity — click to expand`,
279
- color: C.dim,
280
- _foldToggle: foldKey,
281
- })
282
- for (const line of foldTailLines(sub.blocks)) {
283
- out.push({ text: `│ ${sliceByWidth(line, cols - 4)}`, color: C.dim, _skipDimFold: true })
284
- }
285
- }
286
- return out
287
- }
288
-
289
195
  /**
290
196
  * Build the conversation lines for the given state.
291
197
  * maxRows: terminal rows for the 60% expansion cap (undefined → uncapped —
@@ -329,77 +235,23 @@ function buildConvLines(state, cols, maxRows) {
329
235
  if (!prevMain) pushBlank()
330
236
  blankAfter = !nextMain
331
237
  }
332
- // Frozen subagent activity block (§7.2 D4, 2026-08-30): rendered as its own
333
- // collapsible section in the stream (running blocks live in the fixed panel,
334
- // §7.2.1 — the frozen form keeps the same clickable interaction).
238
+ // Frozen subagent activity block (§7.2 D4, 2026-08-30): frozen form keeps the
239
+ // same clickable interaction as the running panel (impl in render-segments.mjs).
335
240
  if (l._frozenSubTask) {
336
- // 2026-08-31 段缓存:frozenSubTask 冻结后内容不变——签名含 sub.key + blocks 计数
337
- const fKey = `sub-${l._frozenSubTask.key}`
338
- const fSig = [
339
- cols, maxRows ?? 0, l._frozenSubTask.key, l._frozenSubTask.blocks?.length ?? 0,
340
- l._frozenSubTask.done ? 1 : 0, state.foldEnabled === false ? 0 : 1,
341
- state.expandedBlocks?.has(fKey) ? 1 : 0, state._foldScroll?.get(fKey) ?? 0,
342
- ].join("|")
343
- const fHit = _lineSegCache.get(l)
344
- if (fHit && fHit.textRef === l._frozenSubTask && fHit.sig === fSig) {
345
- convLines.push(...fHit.rows)
346
- } else {
347
- const rows = frozenSubTaskLines(state, l._frozenSubTask, cols, maxRows)
348
- _lineSegCache.set(l, { textRef: l._frozenSubTask, sig: fSig, rows })
349
- convLines.push(...rows)
350
- }
241
+ convLines.push(...frozenSubSeg(state, l, i, cols, maxRows))
351
242
  continue
352
243
  }
353
- // ONE BLOCK PER TOOL CALL (2026-08-30 user ruling): header = name+args+
354
- // live status, body = args JSON + streaming output + result. Folded =
355
- // ▶ name args · status/summary; expanded = 60%-capped body (shared component).
244
+ // ONE BLOCK PER TOOL CALL (2026-08-30 user ruling) impl in render-segments.mjs.
356
245
  if (l._toolBlock) {
357
- // 2026-08-31 段缓存:工具块签名含三缓冲长度+done/elapsed/summary+该块展开态——
358
- // 流式 append 使 output.length 变 → 失效;real 会话 106 个工具块每帧全量 wrap 实测
359
- // 32ms(rebuild 40ms 的大头),入缓存后命中只算签名拼接。
360
- const b = l._toolBlock
361
- const toolFoldKey = `tool-${l._lineId ?? i}`
362
- const tSig = [
363
- cols, maxRows ?? 0, b.argsJson?.length ?? 0, b.output?.length ?? 0, b.result?.length ?? 0,
364
- b.done ? 1 : 0, b.elapsed ?? "", b.summary ?? "", b.name ?? "", b.roundTag ?? "",
365
- l._lineId ?? "", state.foldEnabled === false ? 0 : 1,
366
- state.expandedBlocks?.has(toolFoldKey) ? 1 : 0, state._foldScroll?.get(toolFoldKey) ?? 0,
367
- ].join("|")
368
- const tHit = _lineSegCache.get(l)
369
- if (tHit && tHit.textRef === b && tHit.sig === tSig) {
370
- convLines.push(...tHit.rows)
371
- } else {
372
- const rows = buildToolBlockSeg(state, l, i, cols, maxRows)
373
- _lineSegCache.set(l, { textRef: b, sig: tSig, rows })
374
- convLines.push(...rows)
375
- }
246
+ convLines.push(...toolSeg(state, l, i, cols, maxRows))
376
247
  continue
377
248
  }
378
- // Frozen advisor review (2026-08-30): same collapsible-box treatment —
379
- // folded = one control line; expanded = the full review text (markdown
380
- // rendered, no gutter — review history convention kept from the flat era),
381
- // 60% cap via the shared component.
249
+ // Frozen advisor review (2026-08-30): same collapsible-box treatment — impl
250
+ // in render-segments.mjs.
382
251
  if (l._frozenAdvisor) {
383
- // 2026-08-31 段缓存:frozenAdvisor 文本冻结不变——签名含文本长度 + 展开态
384
- // 注:foldKey 由 advisor-done-${i}(位置键)升级为 advisor-done-${_lineId ?? i}
385
- // (同 long-N 判例——loadOlder unshift 后不重绑)
386
- const frozenAdvKey = `advisor-done-${l._lineId ?? i}`
387
- const aSig = [
388
- cols, maxRows ?? 0, (l._frozenAdvisor ?? "").length,
389
- state.foldEnabled === false ? 0 : 1,
390
- state.expandedBlocks?.has(frozenAdvKey) ? 1 : 0, state._foldScroll?.get(frozenAdvKey) ?? 0,
391
- ].join("|")
392
- const aHit = _lineSegCache.get(l)
393
- if (aHit && aHit.textRef === l._frozenAdvisor && aHit.sig === aSig) {
394
- convLines.push(...aHit.rows)
395
- } else {
396
- const rows = buildFrozenAdvSeg(state, l, i, cols, maxRows)
397
- _lineSegCache.set(l, { textRef: l._frozenAdvisor, sig: aSig, rows })
398
- convLines.push(...rows)
399
- }
252
+ convLines.push(...frozenAdvSeg(state, l, i, cols, maxRows))
400
253
  continue
401
254
  }
402
-
403
255
  // ── 普通源行段(2026-08-31 懒加载卡顿优化②:段级缓存——行体 WeakMap 按行对象
404
256
  // 缓存,签名含该段所有决定因素;unshift/loadOlder 后尾部段全命中,只算新增行。
405
257
  // toggle/翻窗/收起只失效该块段,其它段照样命中。行为不变性是硬约束。
@@ -425,7 +277,7 @@ function buildConvLines(state, cols, maxRows) {
425
277
  // buildConvLines no longer renders running children: on completion
426
278
  // onToolResult freezes the block into state.lines (subagent-blocks.mjs
427
279
  // freezeSubTaskLines) and it scrolls away with the conversation (D4 现状).
428
- // Frozen blocks render above via the _frozenSubTask branch (frozenSubTaskLines).
280
+ // Frozen blocks render above via the _frozenSubTask branch (render-segments.mjs).
429
281
 
430
282
  if (state.reasoning) {
431
283
  // Live thinking streams INSIDE the unified box (user ruling 2026-08-30:
@@ -132,11 +132,19 @@ export function renderInputBox(state, W, boxLines, cols, inputLayout, inputOffse
132
132
  if (title === " Input " || title === " Question " || title === " Inject Message " || title === " Processing... ") {
133
133
  const parts = []
134
134
  if (title === " Input " || title === " Processing... ") parts.push(" Ctrl+U clear ")
135
- if (title === " Question ") parts.push(" Enter submit ")
135
+ if (title === " Question ") {
136
+ // question 两形态各自真实按键提示(TUI-INPUT-BOX.md §7.2——自由文本态单行不变式:
137
+ // 无换行键——Shift+Enter/Ctrl+J 不插行,提示不沿用主输入框那组)。
138
+ parts.push((state.question?.options?.length ?? 0) > 0
139
+ ? " ↑↓ select │ Enter confirm │ Esc: cancel "
140
+ : " Enter submit │ Esc: cancel │ Ctrl+U clear │ Ctrl+V paste ")
141
+ }
136
142
  if (title === " Inject Message ") parts.push(" Enter send, Esc cancel ")
137
- parts.push(" Shift+Enter / Ctrl+J newline ")
138
- parts.push(" Ctrl+V paste ")
139
- parts.push(" Ctrl+I inject ")
143
+ if (title !== " Question ") {
144
+ parts.push(" Shift+Enter / Ctrl+J newline ")
145
+ parts.push(" Ctrl+V paste ")
146
+ parts.push(" Ctrl+I inject ")
147
+ }
140
148
  const hint = parts.join("")
141
149
  topBorder = `╭─${title}${"─".repeat(Math.max(0, W - 4 - stringWidth(title) - stringWidth(hint)))}${hint}─╮`
142
150
  } else {
@@ -144,8 +152,11 @@ export function renderInputBox(state, W, boxLines, cols, inputLayout, inputOffse
144
152
  }
145
153
  const out = [`${borderColor}${topBorder}${ansi.reset}`]
146
154
 
147
- // Visual cursor position in the input box (hardware cursor stays hidden)
148
- const hasOverlay = state.permission || state.question || state.picker || state.wizard?.step === "provider"
155
+ // Visual cursor position in the input box (hardware cursor stays hidden).
156
+ // hasOverlay 例外细化(TUI-INPUT-BOX.md §7.2):question 自由文本态(无 options)保留
157
+ // 反显光标(与主输入框同路径);options/permission/picker/wizard-provider 态无光标。
158
+ const qOptionsOnly = Boolean(state.question && state.question.options.length > 0)
159
+ const hasOverlay = state.permission || qOptionsOnly || state.picker || state.wizard?.step === "provider"
149
160
  const curLine = (!hasOverlay && inputLayout) ? inputLayout.cursorLine - (inputOffset ?? 0) : -1
150
161
  const curCol = (!hasOverlay && inputLayout) ? inputLayout.cursorCol : -1
151
162
 
@@ -256,13 +267,19 @@ export function renderRows(state, agent, opts) {
256
267
  if (panels.picker) put(panels.picker.y, renderPicker(state, cols, panels.picker, overlay))
257
268
  if (panels.permission) put(panels.permission.y, renderPermission(permPreviewLines))
258
269
  if (panels.queue) put(panels.queue.y, [renderQueue(state, W)])
259
- put(panels.inputBox.y, renderInputBox(state, W, boxLines, cols, inputLayout, inputOffset))
270
+ // question 自由文本态:box 内容是 layoutAnswer 行——光标布局/滚动随 box 内容走
271
+ // (questionLayout/questionOffset);主输入 inputLayout 此时无意义(被 question 行替换)。
272
+ const qFree = Boolean(state.question && state.question.options.length === 0)
273
+ const qOptionsOnly = Boolean(state.question && state.question.options.length > 0)
274
+ const boxLayout = qFree ? (layout.questionLayout ?? layout.inputLayout) : layout.inputLayout
275
+ const boxOffset = qFree ? (layout.questionOffset ?? 0) : layout.inputOffset
276
+ put(panels.inputBox.y, renderInputBox(state, W, boxLines, cols, boxLayout, boxOffset))
260
277
  put(panels.status.y, [renderStatus(state, agent, cols, slashCommands)])
261
278
 
262
279
  let cursorRow = 0, cursorCol = 0
263
- if (!state.permission && !state.question && !state.picker && state.wizard?.step !== "provider") {
264
- cursorRow = panels.inputBox.y + 1 + (inputLayout.cursorLine - inputOffset) + 1
265
- cursorCol = 3 + inputLayout.cursorCol
280
+ if (!state.permission && !state.picker && state.wizard?.step !== "provider" && !qOptionsOnly) {
281
+ cursorRow = panels.inputBox.y + 1 + (boxLayout.cursorLine - boxOffset) + 1
282
+ cursorCol = 3 + boxLayout.cursorCol
266
283
  }
267
284
 
268
285
  return { rows: screen, cursorRow, cursorCol, layout }
@@ -111,7 +111,10 @@ export function createRenderLoop(state, agent, ctx, pushLine, write = (s) => pro
111
111
  }
112
112
  prevRows = rows
113
113
 
114
- const hasOverlay = state.permission || state.question || state.picker || state.wizard?.step === "provider"
114
+ // hasOverlay 例外细化(TUI-INPUT-BOX.md §7.2):question 自由文本态(无 options)
115
+ // 保留硬件定位(cursorSuffix 正常发——与主输入框同路径);options 态无光标。
116
+ const qOptionsOnly = Boolean(state.question && state.question.options.length > 0)
117
+ const hasOverlay = state.permission || qOptionsOnly || state.picker || state.wizard?.step === "provider"
115
118
  const cursorSuffix = hasOverlay ? "" : `\x1b[${cursorRow};${cursorCol}H${ansi.hideCursor}`
116
119
 
117
120
  if (out.length || cursorSuffix) write(ansi.wrapOff + ansi.syncUpdateStart + out.join("") + ansi.syncUpdateEnd + cursorSuffix + ansi.wrapOn)