thincoder 0.12.53 → 0.12.58

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 (82) hide show
  1. package/CHANGELOG.md +74 -0
  2. package/bin/thincoder.mjs +17 -3
  3. package/package.json +3 -7
  4. package/src/acp/bridge.mjs +1 -1
  5. package/src/acp.mjs +60 -18
  6. package/src/advisor/messages.mjs +4 -2
  7. package/src/advisor/run.mjs +2 -2
  8. package/src/agent/dispatch.mjs +66 -26
  9. package/src/agent/helpers.mjs +13 -2
  10. package/src/agent/setup.mjs +16 -3
  11. package/src/agent/spawn-child.mjs +3 -1
  12. package/src/agent-tools/advisor.mjs +19 -9
  13. package/src/agent-tools/eng.mjs +2 -0
  14. package/src/agent-tools/subagent-check.mjs +107 -0
  15. package/src/agent-tools/subagent.mjs +205 -42
  16. package/src/agent.mjs +68 -3
  17. package/src/cli/make-agent.mjs +25 -0
  18. package/src/cli/memory-command.mjs +28 -7
  19. package/src/config.mjs +120 -8
  20. package/src/context.mjs +28 -7
  21. package/src/escape.mjs +110 -22
  22. package/src/git/checkpoint.mjs +32 -6
  23. package/src/mcp/transport-http.mjs +13 -1
  24. package/src/mcp.mjs +52 -7
  25. package/src/memory/core.mjs +78 -10
  26. package/src/memory/docs.mjs +33 -7
  27. package/src/memory.mjs +1 -1
  28. package/src/model-specs.mjs +23 -0
  29. package/src/prompts/discipline.md +17 -3
  30. package/src/prompts/engineering.md +62 -5
  31. package/src/prompts/main.md +1 -0
  32. package/src/prompts/system.md +2 -1
  33. package/src/provider/anthropic.mjs +7 -5
  34. package/src/provider/core.mjs +90 -26
  35. package/src/provider/google.mjs +57 -24
  36. package/src/provider/normalize.mjs +1 -1
  37. package/src/provider/rate.mjs +0 -2
  38. package/src/provider/responses.mjs +8 -13
  39. package/src/provider/sse.mjs +20 -0
  40. package/src/session-migrate.mjs +6 -0
  41. package/src/session-slots.mjs +361 -0
  42. package/src/session.mjs +282 -306
  43. package/src/tools/apply_patch.md +2 -0
  44. package/src/tools/bash.md +2 -2
  45. package/src/tools/edit-batch.mjs +104 -0
  46. package/src/tools/edit.md +3 -0
  47. package/src/tools/execute.md +4 -4
  48. package/src/tools/execute.mjs +14 -22
  49. package/src/tools/file.mjs +17 -55
  50. package/src/tools/file_ops.md +1 -1
  51. package/src/tools/git-checkpoint.mjs +143 -0
  52. package/src/tools/git-ext.mjs +173 -0
  53. package/src/tools/git.md +21 -8
  54. package/src/tools/git.mjs +55 -177
  55. package/src/tools/lint.md +1 -1
  56. package/src/tools/linter.mjs +9 -37
  57. package/src/tools/patch.mjs +1 -1
  58. package/src/tools/shared.mjs +7 -20
  59. package/src/tui/agent-turn.mjs +3 -3
  60. package/src/tui/ansi.mjs +2 -0
  61. package/src/tui/clipboard.mjs +2 -2
  62. package/src/tui/cmd-eng.mjs +1 -0
  63. package/src/tui/cmd-mcp-form.mjs +197 -0
  64. package/src/tui/cmd-mcp.mjs +255 -114
  65. package/src/tui/cmd-new.mjs +6 -6
  66. package/src/tui/cmd-restore.mjs +27 -6
  67. package/src/tui/cmd-session.mjs +17 -4
  68. package/src/tui/index.mjs +28 -27
  69. package/src/tui/interaction.mjs +28 -1
  70. package/src/tui/key-handler.mjs +14 -2
  71. package/src/tui/layout.mjs +81 -25
  72. package/src/tui/mouse.mjs +41 -2
  73. package/src/tui/pickers.mjs +62 -4
  74. package/src/tui/render-conversation.mjs +36 -93
  75. package/src/tui/render-frame.mjs +40 -16
  76. package/src/tui/render-loop.mjs +1 -1
  77. package/src/tui/render.mjs +4 -4
  78. package/src/tui/startup.mjs +4 -2
  79. package/src/tui/subagent-blocks.mjs +119 -4
  80. package/src/tui/subagent-panel.mjs +81 -0
  81. package/src/tui/tool-events.mjs +61 -16
  82. package/src/tui/tui-lifecycle.mjs +45 -0
package/src/tui/index.mjs CHANGED
@@ -25,6 +25,7 @@ import { createRenderLoop } from "./render-loop.mjs"
25
25
  import { makeDimsState } from "./dims.mjs"
26
26
  import { SLASH_COMMANDS, SLASH_ALIASES, createSlashCommands } from "./slash-commands.mjs"
27
27
  import { createWizard } from "./wizard.mjs"
28
+ import { writeStartupSequence, createExitCleanup } from "./tui-lifecycle.mjs"
28
29
  import { createPickers } from "./pickers.mjs"
29
30
  import { runDistill as runDistillImpl } from "./distill-cmd.mjs"
30
31
  import { createInteraction } from "./interaction.mjs"
@@ -47,6 +48,22 @@ export function pendingNoticeReady(state) {
47
48
  return Boolean(state.pendingNotice && !state.picker && !state.permission && !state.question)
48
49
  }
49
50
 
51
+ /**
52
+ * SESSION.md §8 D-S2 — TUI 启动首帧前的 provider 重选流程:
53
+ * provider 无效(`_providerInvalid` 标记或 provider 为 null)→ 先弹模型选择 picker
54
+ * (复用 openModelPicker,展示当前可用 providers);用户选定后继续正常启动。
55
+ * 选择取消(Esc)→ 仍进入 TUI,推送提示行("未配置有效 provider,可用 /model 选择或
56
+ * /provider 配置")——绝不因无 provider 拒绝进入。返回 true 表示弹过选择流程。
57
+ */
58
+ export async function promptProviderIfInvalid(agent, openModelPicker, pushLine) {
59
+ if (!(agent._providerInvalid || !agent.provider)) return false
60
+ await openModelPicker()
61
+ if (!agent.provider) {
62
+ pushLine("未配置有效 provider,可用 /model 选择或 /provider 配置", C.warn)
63
+ }
64
+ return true
65
+ }
66
+
50
67
  /**
51
68
  * Start the TUI, taking over the terminal until exit.
52
69
  * agent: return value of createAgent
@@ -129,7 +146,7 @@ export async function startTUI(agent, opts = {}) {
129
146
  // kitty push (\x1b[>1u): Shift+Enter → \x1b[13;2u (Windows Terminal 1.19+, VS Code, kitty, iTerm2)
130
147
  // modifyOtherKeys lvl 2 (\x1b[>4;2m): Shift+Enter → \x1b[27;2;13~ (mintty / Git Bash)
131
148
  // translateShiftEnter (stdin layer) maps both to \x1b\r → meta+return → multiline branch.
132
- process.stdout.write(ansi.altBuffer + ansi.hideCursor + ansi.mouseOn + ansi.bracketedPasteOn + ansi.keyboardPush + ansi.modifyOtherKeysOn)
149
+ writeStartupSequence()
133
150
 
134
151
  const utf8Decoder = new TextDecoder("utf-8", { fatal: false })
135
152
 
@@ -220,7 +237,7 @@ export async function startTUI(agent, opts = {}) {
220
237
 
221
238
  // Scroll wheel: \x1b[<64;col;rowM = up, \x1b[<65;col;rowM = down(3 lines each)
222
239
  // 2026-08-31:坐标命中展开块内容行 → 块内滚动(handleWheel);未命中 → 会话滚动(现状)
223
- // eslint-disable-next-line no-control-regex -- 有意为之:控制字符协议/转义序列剥离正则(ANSI/⟦ev⟧/SGR/history 双线分隔)
240
+ // 有意为之:控制字符协议/转义序列剥离正则(ANSI/⟦ev⟧/SGR/history 双线分隔)
224
241
  for (const m of text.matchAll(/\x1b\[<(\d+);(\d+);(\d+)([Mm])/g)) {
225
242
  const button = Number(m[1])
226
243
  if (button === 64 || button === 65) {
@@ -250,9 +267,9 @@ export async function startTUI(agent, opts = {}) {
250
267
  }
251
268
 
252
269
  // Strip complete mouse sequences; keep incomplete tail for reassembly with next chunk
253
- // eslint-disable-next-line no-control-regex -- 有意为之:控制字符协议/转义序列剥离正则(ANSI/⟦ev⟧/SGR/history 双线分隔)
270
+ // 有意为之:控制字符协议/转义序列剥离正则(ANSI/⟦ev⟧/SGR/history 双线分隔)
254
271
  text = text.replace(/\x1b\[<\d+;\d+;\d+[Mm]/g, "")
255
- // eslint-disable-next-line no-control-regex -- 有意为之:控制字符协议/转义序列剥离正则(ANSI/⟦ev⟧/SGR/history 双线分隔)
272
+ // 有意为之:控制字符协议/转义序列剥离正则(ANSI/⟦ev⟧/SGR/history 双线分隔)
256
273
  const tail = text.match(/\x1b\[<[\d;]*$/)
257
274
  if (tail) {
258
275
  mousePending = tail[0]
@@ -273,27 +290,7 @@ export async function startTUI(agent, opts = {}) {
273
290
  }
274
291
  })
275
292
 
276
- let cleanedUp = false
277
- const cleanup = () => {
278
- if (cleanedUp) return
279
- cleanedUp = true
280
- // Save session before exit (synchronous write).
281
- // Archiving to a slot is handled by /new and /session switch — not on every exit,
282
- // otherwise simply opening and closing the TUI repeatedly would fill all slots with duplicates.
283
- try {
284
- saveSession(agent)
285
- } catch {
286
- // Save failure shouldn't block exit
287
- }
288
- // Kill MCP stdio subprocesses, don't leave orphans
289
- try {
290
- closeAllMcp(agent)
291
- } catch {
292
- // Can't close? fine, process is exiting anyway
293
- }
294
- process.stdin.setRawMode(false)
295
- process.stdout.write(ansi.clearScreen + ansi.mouseOff + ansi.bracketedPasteOff + ansi.keyboardPop + ansi.modifyOtherKeysOff + ansi.mainBuffer + ansi.showCursor + ansi.reset)
296
- }
293
+ const cleanup = createExitCleanup({ agent, saveSession, closeAllMcp })
297
294
  process.on("exit", cleanup)
298
295
 
299
296
  const pushLine = (text, color, kind) => {
@@ -387,7 +384,7 @@ export async function startTUI(agent, opts = {}) {
387
384
  }
388
385
 
389
386
  // Interaction primitives: permission approval + Q&A input, implemented in interaction.mjs
390
- const { askPermission, askQuestion } = createInteraction({
387
+ const { askPermission, askQuestion, askBatchPermission } = createInteraction({
391
388
  agent, state, pushLine, pushLabel, render, summarize,
392
389
  })
393
390
 
@@ -397,7 +394,7 @@ export async function startTUI(agent, opts = {}) {
397
394
  // Agent loop: implemented in agent-turn.mjs
398
395
  const turnCtx = {
399
396
  agent, state, pushLine, pushLabel, render, scheduleRender: render, ensureAssistantLabel,
400
- askPermission, askQuestion, handleSlash: null,
397
+ askPermission, askQuestion, askBatchPermission, handleSlash: null,
401
398
  get assistantLabeled() { return assistantLabeled },
402
399
  set assistantLabeled(v) { assistantLabeled = v },
403
400
  }
@@ -462,6 +459,10 @@ export async function startTUI(agent, opts = {}) {
462
459
 
463
460
  // ---------------------------------------------------------- Startup screen + background indexing
464
461
 
462
+ // SESSION.md §8 D-S2:startTUI 首帧前 —— provider 无效(_providerInvalid / provider 为 null)
463
+ // → 先弹模型选择 picker(keyStream 已挂 keypress,Esc/Enter 可用);Esc → 提示行,仍进 TUI
464
+ await promptProviderIfInvalid(agent, () => openModelPicker(), pushLine)
465
+
465
466
  showStartup({ agent, state, opts, pushLine, pushLabel, render, startWizard })
466
467
  backgroundIndex({ agent, state, render })
467
468
 
@@ -62,6 +62,33 @@ export function createInteraction(ctx) {
62
62
  })
63
63
  }
64
64
 
65
+ /** Batch permission ask (AGENT-LOOP.md §16 D-B1): one merged prompt covering N
66
+ * non-readonly tools from the same toolCalls array — approve all / one by one /
67
+ * deny. Verdicts resolve as "approveAll" | "oneByOne" | "deny". */
68
+ const BATCH_PREVIEW_LINES_PER_TOOL = 8
69
+ function askBatchPermission({ tools, count }) {
70
+ if (agent.autoApprove) {
71
+ pushLine(` [auto] ${count} tools: ${tools.map((t) => t.name).join(", ")}`, C.warn)
72
+ return Promise.resolve("approveAll")
73
+ }
74
+ const names = [...new Set(tools.map((t) => t.name))]
75
+ const label = `${count} tools need permission: ${names.join(", ")}`
76
+ // Preview: one compact block per tool (name header + first N formatted lines)
77
+ state.permissionPreview = [
78
+ label,
79
+ ...tools.flatMap((t) => {
80
+ const lines = formatPermission(t.name, t.args)
81
+ const shown = lines.slice(0, BATCH_PREVIEW_LINES_PER_TOOL)
82
+ return [` ${t.name}:`, ...shown.map((l) => ` ${l}`), ...(lines.length > shown.length ? [` … (${lines.length - shown.length} more lines)`] : [])]
83
+ }),
84
+ ]
85
+ return new Promise((resolve) => {
86
+ state.permission = { name: label, args: {}, resolve, batch: { tools, count } }
87
+ state.status = `Waiting: ${label}`
88
+ render()
89
+ })
90
+ }
91
+
65
92
  function askQuestion(text, options = []) {
66
93
  // only one question at a time: question is a read-only tool on a parallel channel,
67
94
  // a second concurrent one is rejected directly; otherwise the later one overwrites
@@ -91,5 +118,5 @@ export function createInteraction(ctx) {
91
118
  })
92
119
  }
93
120
 
94
- return { askPermission, askQuestion, formatPermission }
121
+ return { askPermission, askQuestion, formatPermission, askBatchPermission }
95
122
  }
@@ -26,15 +26,27 @@ export function createKeyHandler(ctx) {
26
26
 
27
27
  return function onKeypress(str, key = {}) {
28
28
  // permission confirm state: y approve / n deny / a approve + turn ON AUTO (no further prompts)
29
+ // batch state (§16 D-B1): a approve all / o one by one / n deny (Esc = deny)
29
30
  if (state.permission) {
30
31
  const answer = (str || "").toLowerCase()
31
32
  const isContinue = state.permission.name === "continue"
32
- const validKeys = isContinue ? ["y", "n"] : ["y", "n", "a"]
33
+ const isBatch = Boolean(state.permission.batch)
34
+ const validKeys = isContinue ? ["y", "n"] : isBatch ? ["a", "o", "n"] : ["y", "n", "a"]
33
35
  if (validKeys.includes(answer) || key.name === "escape") {
34
36
  const { resolve, name } = state.permission
35
37
  state.permission = null
36
38
  state.permissionPreview = []
37
39
  state.status = "Processing..."
40
+ if (isBatch) {
41
+ // Merged batch ask: resolve the verdict string; dispatch applies it
42
+ // (approveAll = batch-scope allowance only, NOT the persistent AUTO flag).
43
+ const verdict = answer === "a" ? "approveAll" : answer === "o" ? "oneByOne" : "deny"
44
+ const tone = verdict === "approveAll" ? C.dim : C.error
45
+ pushLine(` [${verdict === "approveAll" ? "approved" : verdict === "deny" ? "denied" : "one by one"}] ${name}`, tone)
46
+ resolve(verdict)
47
+ render()
48
+ return
49
+ }
38
50
  if (answer === "a" && !isContinue) {
39
51
  agent.autoApprove = true
40
52
  agent._pendingReminders = agent._pendingReminders ?? []
@@ -255,7 +267,7 @@ export function createKeyHandler(ctx) {
255
267
  } else if (str && !key.ctrl && !key.meta) {
256
268
  // 输入即过滤;粘贴的多行文本先去换行(与输入框清洗口径一致),仍含控制字符则整段丢弃
257
269
  const text = str.replace(/[\r\n]+/g, "")
258
- // eslint-disable-next-line no-control-regex -- 有意为之:控制字符协议/转义序列剥离正则(ANSI/⟦ev⟧/SGR/history 双线分隔)
270
+ // 有意为之:控制字符协议/转义序列剥离正则(ANSI/⟦ev⟧/SGR/history 双线分隔)
259
271
  if (text && !/[\x00-\x1f\x7f]/.test(text)) applyFilter(p.filter + text)
260
272
  }
261
273
  return
@@ -3,13 +3,17 @@
3
3
  * Computes position and height of each panel from state + terminal dimensions.
4
4
  * Does not modify state — side effects are performed by the caller before rendering.
5
5
  *
6
- * header → conversation → todo → picker → permission → queue → input → status
7
- * Subagent activity renders INSIDE the conversation as collapsible blocks
8
- * (AGENT-LOOP.md §7.2 D4) — no dedicated subagent/output panels anymore.
6
+ * header → conversation → subagent 面板 → todo → picker → permission → queue → input → status
7
+ * Running subagent activity renders in a FIXED bottom panel between the
8
+ * conversation and the todo panel (AGENT-LOOP.md §7.2.1) — full adaptive height
9
+ * (the conversation shrinks); compressed away first on small terminals (to 0 =
10
+ * hidden, data stays in the buffer). Done children are frozen into the
11
+ * conversation stream (§7.2 D4, unchanged). Output panels abolished (§7.2 D6).
9
12
  * Fixed panels deducted first, conditional panels allocated by priority, remaining space to conversation.
10
13
  */
11
14
  import { layoutInput, wrapText } from "./render.mjs"
12
15
  import { QUESTION_CUSTOM } from "./interaction.mjs"
16
+ import { renderSubagentPanel } from "./subagent-panel.mjs"
13
17
 
14
18
  /** 防御:question options 声明为 string[],但 LLM 可能误传对象;取 label/text/title 兜底,避免渲染 "[object Object]"。 */
15
19
  function optText(opt) {
@@ -75,9 +79,20 @@ export function computeLayout(state, { cols, rows }) {
75
79
  const taskPanelH = visibleTasks.length > 0 ? visibleTasks.length + 1 : 0
76
80
  // Squeeze target: the divider line yields first under small terminals (the
77
81
  // task rows themselves never compress away — put() truncates by panel h).
78
- let todoFinalH = taskPanelH
79
82
 
80
- // Subagent activity: rendered inside the conversation (§7.2 D4) no panel slot.
83
+ // Subagent panel (§7.2.1 D1): RUNNING blocks only, between conversation and
84
+ // todo. Height = the FULL rendered height of every running block (F2 — fully
85
+ // adaptive, no cap; the conversation shrinks accordingly). Precomputed here
86
+ // via renderSubagentPanel (neutral module, no layout↔render-frame cycle);
87
+ // render-frame puts `subagentLines` directly (no double render). Done
88
+ // children are frozen into state.lines (subagent-blocks.mjs
89
+ // freezeSubTaskLines) and excluded here — the panel only shows running blocks.
90
+ let subagentLines = []
91
+ let subagentH = 0
92
+ if (Object.values(state.subTasks ?? {}).some((s) => !s.done)) {
93
+ subagentLines = renderSubagentPanel(state, cols, rows)
94
+ subagentH = subagentLines.length
95
+ }
81
96
 
82
97
  // Permission preview (height depends on wrapped content)
83
98
  let permPreviewLines = []
@@ -97,32 +112,47 @@ export function computeLayout(state, { cols, rows }) {
97
112
  const queueH = state.queue.length > 0 && state.processing ? 1 : 0
98
113
 
99
114
  // --- elastic panel: conversation takes remaining space ---
100
- const fixedH = headerH + inputBoxH + statusH + pickerH + taskPanelH + permPreviewH + queueH
115
+ const fixedH = headerH + inputBoxH + statusH + pickerH + taskPanelH + permPreviewH + queueH + subagentH
101
116
  let convH = Math.max(1, rows - fixedH)
102
117
 
103
- // 小终端高度补偿:先压 conversation 到最小 1 行,再压 picker 到最小 3 行,
104
- // 仍溢出再压 permission preview 到最小 1 行(仅标题)。
118
+ // 小终端高度补偿:subagent 面板最先让位(可至 0 隐藏,活动仍进缓冲区不丢),
119
+ // 再压 conversation 到最小 1 行,再压 picker 到最小 3 行,仍溢出再压
120
+ // permission preview 到最小 1 行(仅标题)——输入框/状态栏/会话区保留。
121
+ let subagentFinalH = subagentH
105
122
  let pickerFinalH = pickerH
106
123
  let permFinalH = permPreviewH
124
+ let todoFinalH = taskPanelH
107
125
  const overflow = fixedH + convH - rows
108
126
  if (overflow > 0) {
109
- if (pickerH > 0) {
110
- pickerFinalH = Math.max(Math.min(3, pickerH), pickerH - overflow)
111
- }
112
- const afterPicker = fixedH - pickerH + pickerFinalH
113
- convH = Math.max(1, rows - afterPicker)
114
- const remaining = afterPicker + convH - rows
115
- if (remaining > 0 && permPreviewH > 0) {
116
- permFinalH = Math.max(1, permPreviewH - remaining)
117
- convH = Math.max(1, rows - (afterPicker - permPreviewH + permFinalH))
127
+ // 压缩链第 1 级(§7.2.1 NF1/评审 #2 措辞统一):subagent 面板最先让位——
128
+ // 可压缩至 0 隐藏(运行中活动仍在缓冲区不丢),输入框/状态栏/会话区不可挤没。
129
+ let afterSub = fixedH
130
+ if (subagentH > 0) {
131
+ subagentFinalH = Math.max(0, subagentH - overflow)
132
+ afterSub = fixedH - subagentH + subagentFinalH
133
+ convH = Math.max(1, rows - afterSub)
118
134
  }
119
- // 压缩链末级:todo 面板的分隔线行让位(任务行保留——put h 截断自动
120
- // 丢弃第一行的分隔线,2026-08-30 用户请求加的 divider 不得在小终端挤掉输入框)。
121
- const afterPerm = afterPicker - permPreviewH + permFinalH
122
- const finalOverflow = afterPerm + convH - rows
123
- if (finalOverflow > 0 && taskPanelH > visibleTasks.length) {
124
- todoFinalH = Math.max(visibleTasks.length, taskPanelH - finalOverflow)
125
- convH = Math.max(1, rows - (afterPerm - taskPanelH + todoFinalH))
135
+ // 压缩链第 2/3 级(既有逻辑):picker 最小 3 行,permission → 最小 1 行
136
+ const overflow2 = afterSub + convH - rows
137
+ if (overflow2 > 0) {
138
+ if (pickerH > 0) {
139
+ pickerFinalH = Math.max(Math.min(3, pickerH), pickerH - overflow2)
140
+ }
141
+ const afterPicker = afterSub - pickerH + pickerFinalH
142
+ convH = Math.max(1, rows - afterPicker)
143
+ const remaining = afterPicker + convH - rows
144
+ if (remaining > 0 && permPreviewH > 0) {
145
+ permFinalH = Math.max(1, permPreviewH - remaining)
146
+ convH = Math.max(1, rows - (afterPicker - permPreviewH + permFinalH))
147
+ }
148
+ // 压缩链末级:todo 面板的分隔线行让位(任务行保留——put 按 h 截断自动
149
+ // 丢弃第一行的分隔线,2026-08-30 用户请求加的 divider 不得在小终端挤掉输入框)。
150
+ const afterPerm = afterPicker - permPreviewH + permFinalH
151
+ const finalOverflow = afterPerm + convH - rows
152
+ if (finalOverflow > 0 && taskPanelH > visibleTasks.length) {
153
+ todoFinalH = Math.max(visibleTasks.length, taskPanelH - finalOverflow)
154
+ convH = Math.max(1, rows - (afterPerm - taskPanelH + todoFinalH))
155
+ }
126
156
  }
127
157
  }
128
158
 
@@ -130,6 +160,7 @@ export function computeLayout(state, { cols, rows }) {
130
160
  let y = 0
131
161
  const header = { y, h: headerH }; y += headerH
132
162
  const conversation = { y, h: convH }; y += convH
163
+ const subagent = subagentFinalH > 0 ? { y, h: subagentFinalH } : null; y += subagentFinalH
133
164
  const todo = todoFinalH > 0 ? { y, h: todoFinalH } : null; y += todoFinalH
134
165
  const picker = pickerFinalH > 0 ? { y, h: pickerFinalH } : null; y += pickerFinalH
135
166
  const permission = permFinalH > 0 ? { y, h: permFinalH } : null; y += permFinalH
@@ -139,13 +170,38 @@ export function computeLayout(state, { cols, rows }) {
139
170
 
140
171
  return {
141
172
  W, cols, rows,
142
- panels: { header, conversation, picker, todo, permission, queue, inputBox, status },
173
+ panels: { header, conversation, subagent, picker, todo, permission, queue, inputBox, status },
143
174
  // precomputed content (affects height, reused during render)
144
175
  inputLayout,
145
176
  inputOffset,
146
177
  boxLines,
147
178
  visibleTasks,
148
179
  permPreviewLines,
180
+ subagentLines,
149
181
  overlay,
150
182
  }
151
183
  }
184
+
185
+ /**
186
+ * §7.2.1 评审 #4:面板部分压缩(subagentFinalH < subagentLines.length)时 render-frame
187
+ * 实际显示的行——**保底截断**:分隔线 + 末尾 (h-1) 行(最新启动区块优先;小终端上仍
188
+ * 能看到最新子 agent 活动,旧 slice(0,h) 保留顶部会把最新活动裁掉)。分隔线始终保留
189
+ * (面板边界语义)。h ≥ 全长 → 原样;h = 0 → [](layout 侧 panels.subagent 已为 null,
190
+ * 防御分支);h = 1 → 只显示分隔线(极端小终端)。
191
+ */
192
+ export function subagentVisibleLines(subagentLines, h) {
193
+ if (h >= subagentLines.length) return subagentLines
194
+ if (h <= 0) return []
195
+ if (h === 1) return [subagentLines[0]]
196
+ return [subagentLines[0], ...subagentLines.slice(-(h - 1))]
197
+ }
198
+
199
+ /** 与 subagentVisibleLines 同一几何契约:可见面板行(0-based 行内坐标)→
200
+ * subagentLines 索引(mouse 命中映射用——保底截断后可见行 ≠ 前 h 行);
201
+ * localRow 越界 → -1。 */
202
+ export function subagentLineIndex(subagentLines, h, localRow) {
203
+ if (localRow < 0 || localRow >= h) return -1
204
+ if (h >= subagentLines.length) return localRow
205
+ if (localRow === 0) return 0
206
+ return subagentLines.length - h + localRow
207
+ }
package/src/tui/mouse.mjs CHANGED
@@ -14,7 +14,7 @@
14
14
  * - picker option click = select it
15
15
  * - folded-block hint click = expand it
16
16
  */
17
- import { computeLayout } from "./layout.mjs"
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
20
 
@@ -29,6 +29,24 @@ export function handleWheel(ctx, button, col, row) {
29
29
  const dims = state.dims ? state.dims.get() : { cols: process.stdout.columns || 80, rows: process.stdout.rows || 24 }
30
30
  const layout = computeLayout(state, dims)
31
31
  const P = layout.panels
32
+ // §7.2.1 D4: 固定子agent 面板(conversation 与 todo 之间)——面板行默认穿出
33
+ // 滚会话(F3,与 todo 面板同型);命中展开区块内容行(_foldBlock 标记)→
34
+ // 块内滚动(现状能力不丢)。
35
+ if (P.subagent && r >= P.subagent.y && r < P.subagent.y + P.subagent.h) {
36
+ // 评审 #4:面板部分压缩(保底截断)后可见行 = 分隔线 + 末尾行——命中映射经
37
+ // subagentLineIndex(与 render-frame 同一几何契约),不再把行内坐标直接当索引用。
38
+ const lineEl = layout.subagentLines[subagentLineIndex(layout.subagentLines, P.subagent.h, r - P.subagent.y)]
39
+ if (!lineEl?._foldBlock || !lineEl._foldTotal) return false
40
+ // 穿出语义与会话区块一致:块内到边界(顶滚上 / 底滚下)→ 交还会话滚动
41
+ const before = foldScrollOffset(state, lineEl._foldBlock)
42
+ const winH = lineEl._foldWindow ?? 1
43
+ const total = lineEl._foldTotal
44
+ if (dir < 0 && before <= 0) return false
45
+ if (dir > 0 && before >= total - winH) return false
46
+ scrollFoldBlock(state, lineEl._foldBlock, dir, 3)
47
+ ctx.render?.()
48
+ return true
49
+ }
32
50
  if (r < P.conversation.y || r >= P.conversation.y + P.conversation.h) return false
33
51
  const convLines = buildConvLines(state, dims.cols, dims.rows)
34
52
  const gIdx = convGlobalIndex(convLines.length, P.conversation.h, state.scroll ?? 0)(r - P.conversation.y)
@@ -54,7 +72,7 @@ export function handleWheel(ctx, button, col, row) {
54
72
  /** Extract left-click presses from a chunk. Returns [{ col, row }] (1-based). */
55
73
  export function parseMouseClicks(text) {
56
74
  const out = []
57
- // eslint-disable-next-line no-control-regex -- 有意为之:控制字符协议/转义序列剥离正则(ANSI/⟦ev⟧/SGR/history 双线分隔)
75
+ // 有意为之:控制字符协议/转义序列剥离正则(ANSI/⟦ev⟧/SGR/history 双线分隔)
58
76
  for (const m of text.matchAll(/\x1b\[<0;(\d+);(\d+)M/g)) {
59
77
  out.push({ col: Number(m[1]), row: Number(m[2]) })
60
78
  }
@@ -101,6 +119,27 @@ export function handleMouseClick(ctx, col, row) {
101
119
  return true
102
120
  }
103
121
 
122
+ // ── §7.2.1 D4: 固定子agent 面板(conversation 与 todo 之间)——折叠/展开/翻窗
123
+ // 坐标映射到面板行(与 todo 面板同型;layout.subagentLines = 面板渲染行)。
124
+ if (P.subagent && r >= P.subagent.y && r < P.subagent.y + P.subagent.h) {
125
+ // 评审 #4:保底截断后可见行 ≠ 前 h 行——命中映射与 render-frame 同一几何契约
126
+ // (subagentLineIndex:分隔线 + 末尾区块行优先)。
127
+ const lineEl = layout.subagentLines[subagentLineIndex(layout.subagentLines, P.subagent.h, r - P.subagent.y)]
128
+ if (lineEl?._foldScrollUp || lineEl?._foldScrollDown) {
129
+ // ▲/▼ 控制行点击翻窗(60% 封顶保留、窗口随翻滚动,全文可达)
130
+ scrollFoldBlock(state, lineEl._foldScrollUp ?? lineEl._foldScrollDown,
131
+ lineEl._foldScrollUp ? -1 : 1, lineEl._foldWindow ?? 1,
132
+ typeof lineEl._foldTotal === "number" ? Math.max(0, lineEl._foldTotal - (lineEl._foldWindow ?? 1)) : undefined)
133
+ render()
134
+ return true
135
+ }
136
+ if (!lineEl?._foldToggle) return false
137
+ // 双向切换(fold-block.mjs 单源):折叠头展开 / ▼ 控制收起
138
+ toggleFoldBlock(state, lineEl._foldToggle)
139
+ render()
140
+ return true
141
+ }
142
+
104
143
  // ── Conversation: click a fold marker (expand hint or collapse marker) toggles it ──
105
144
  if (r >= P.conversation.y && r < P.conversation.y + P.conversation.h) {
106
145
  const convLines = buildConvLines(state, dims.cols, dims.rows)
@@ -1,5 +1,5 @@
1
1
  import { sliceByWidth } from "./render.mjs"
2
- import { PROVIDER_PRESETS as PRESETS } from "../config.mjs"
2
+ import { PROVIDER_PRESETS as PRESETS, providerSpec } from "../config.mjs"
3
3
  import { computeLayout } from "./layout.mjs"
4
4
 
5
5
  /** Generic list picker + model/provider management.
@@ -121,6 +121,14 @@ export function createPickers(ctx) {
121
121
  return e.action === "switch" ? `switch:${e.provider}:${e.model}` : `action:${e.action}`
122
122
  }
123
123
 
124
+ /** 模型信息显示的 context 窗口(PROVIDER.md §15 D-C5):K 单位形态跟随覆盖值
125
+ * (config K 是二进制 K:128 → 128×1024 = 131072 → 显示 "128K");≥1M tokens 用 M 形态
126
+ * (1_000_000 → "1M"),与 spec 值的大窗口惯例一致。 */
127
+ function fmtContextK(tokens) {
128
+ if (tokens >= 1_000_000) return `${Math.round(tokens / 1_048_576)}M`
129
+ return `${Math.round(tokens / 1024)}K`
130
+ }
131
+
124
132
  /** Get API key for a provider (config.json only — env vars are not a key source) */
125
133
  function getApiKey(providerName, providerConfig) {
126
134
  return providerConfig.apiKey
@@ -148,6 +156,8 @@ export function createPickers(ctx) {
148
156
  await removeProviderFlow()
149
157
  } else if (e.action === "key") {
150
158
  await setKeyFlow()
159
+ } else if (e.action === "context") {
160
+ await setContextFlow()
151
161
  }
152
162
  }
153
163
  }
@@ -188,9 +198,11 @@ export function createPickers(ctx) {
188
198
  const marker = active ? "●" : ""
189
199
  const note = active ? " ← current" : ""
190
200
  const keyStatus = p.apiKey ? "" : " (no key)"
201
+ // context 窗口显示(PROVIDER.md §15 D-C5/T-C6):跟随 providers[].context 覆盖
202
+ const ctxTag = ` (ctx ${fmtContextK(providerSpec(p).context)})`
191
203
  entries.push({
192
204
  type: "item",
193
- text: `${p.name.padEnd(12)} ${currentModel}${note}`,
205
+ text: `${p.name.padEnd(12)} ${currentModel}${ctxTag}${note}`,
194
206
  action: "open-models",
195
207
  provider: p.name,
196
208
  marker,
@@ -201,6 +213,7 @@ export function createPickers(ctx) {
201
213
  entries.push({ type: "item", text: "Add provider…", action: "add" })
202
214
  if (agent.providers.length > 1) entries.push({ type: "item", text: "Remove provider…", action: "remove" })
203
215
  entries.push({ type: "item", text: "Set / change API key…", action: "key" })
216
+ entries.push({ type: "item", text: "Set context window (K units)…", action: "context" })
204
217
  return entries
205
218
  }
206
219
 
@@ -294,7 +307,8 @@ export function createPickers(ctx) {
294
307
  agent.provider = { ...target }
295
308
  if (agent.config?.agent?.compactThresholdAuto) {
296
309
  const { resolveCompactThreshold } = await import("../config.mjs")
297
- agent.config.agent.compactThreshold = resolveCompactThreshold(null, item.model).value
310
+ // provider 对象(非 model 字符串)——阈值跟随 providers[].context 覆盖(PROVIDER.md §15 T-C2)
311
+ agent.config.agent.compactThreshold = resolveCompactThreshold(null, target).value
298
312
  }
299
313
  await persistRaw((raw) => {
300
314
  // 落盘前剥离运行时注入的 proxyUri(由 loadConfig + injectProxy 在加载时重建)
@@ -382,6 +396,50 @@ export function createPickers(ctx) {
382
396
  await persistRaw((raw) => { raw.providers = agent.providers })
383
397
  }
384
398
 
399
+ /** /model provider 管理:context 窗口字段(K 单位,PROVIDER.md §15 D-C4/D-C5)——
400
+ * picker 选 provider + 表单输入(复用 syncProviderField 的落盘模式:改 agent.providers 目标项
401
+ * → persistRaw 全量写盘);空输入清空(回 spec 值);非法输入报错不落盘(D-C1 语义同 loadConfig)。 */
402
+ async function setContextFlow() {
403
+ const se = await showPicker("Set Context Window", [
404
+ { type: "header", text: "Select provider" },
405
+ ...agent.providers.map((p) => ({
406
+ type: "item",
407
+ text: `${p.name} (ctx ${fmtContextK(providerSpec(p).context)})`,
408
+ name: p.name,
409
+ })),
410
+ ])
411
+ if (!se?.name) return // Esc
412
+ const target = agent.providers.find((p) => p.name === se.name)
413
+ if (!target) return
414
+ const current = Number.isInteger(target.context) && target.context > 0 ? target.context : null
415
+ const val = (await askQuestion(
416
+ `Context window for ${se.name} in K units (current: ${current ? `${current}K` : "spec default"} — e.g. 128 = 128K; empty to clear):`
417
+ ))?.trim() ?? ""
418
+ if (val === "") {
419
+ delete target.context
420
+ } else {
421
+ const n = Number(val)
422
+ if (!Number.isInteger(n) || n <= 0) {
423
+ pushLine(`Invalid context: "${val}" — must be a positive integer in K units (e.g. 128 = 128K)`, C.error)
424
+ return
425
+ }
426
+ target.context = n
427
+ }
428
+ if (se.name === agent.activeProvider) {
429
+ // 运行时 provider 同步(同 setProviderKey 先例:只补 context,不重建对象以免丢 activeModel 覆盖)
430
+ if (target.context === undefined) delete agent.provider.context
431
+ else agent.provider.context = target.context
432
+ if (agent.config?.agent?.compactThresholdAuto) {
433
+ const { resolveCompactThreshold } = await import("../config.mjs")
434
+ agent.config.agent.compactThreshold = resolveCompactThreshold(null, agent.provider).value
435
+ }
436
+ }
437
+ await persistRaw((raw) => { raw.providers = agent.providers })
438
+ pushLine(target.context !== undefined
439
+ ? `ctx = ${target.context}K (${target.context * 1024} tokens)`
440
+ : `context cleared — using model spec (${fmtContextK(providerSpec(target).context)})`, C.tool)
441
+ }
442
+
385
443
 
386
444
  /** Slot-bound model picker: two-level provider → model selection that RETURNS
387
445
  * { provider, model } instead of writing main-session state — used by /submodel
@@ -411,5 +469,5 @@ export function createPickers(ctx) {
411
469
  }
412
470
  }
413
471
 
414
- return { showPicker, closePicker, popPicker, renderPickerLines, openModelPicker, selectModel, setProviderKey, pickModelForSlot }
472
+ return { showPicker, closePicker, popPicker, renderPickerLines, openModelPicker, selectModel, setProviderKey, setContextFlow, pickModelForSlot }
415
473
  }