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
@@ -181,30 +181,21 @@ export function convCacheKey(state, maxRows) {
181
181
  // Content prefix in the signature: same kind+length with different content
182
182
  // would otherwise collide (stale render); 8 chars disambiguate in practice.
183
183
  const blocksSig = (state._advisorBlocks ?? []).map((b) => `${b.kind}:${b.text?.length ?? 0}:${String(b.text ?? "").slice(0, 8)}`).join(",")
184
- // Subagent activity blocks (§7.2 D5): O(1) counter-style signature a running
185
- // epoch (any block/header change bumps it, subagent-blocks.mjs appendSubBlock /
186
- // finishSubTask) + per-child totals. Running children also fold in a
187
- // second-granularity elapsed part (see below). NOT a full text concat: N3
188
- // forbids O(n) signature cost on every token.
189
- let subSig = ""
190
- for (const key in state.subTasks) {
191
- const s = state.subTasks[key]
192
- // Running children include a second-granularity elapsed component: the 1s
193
- // ticker re-renders to tick the header countdown without this the cache
194
- // would hit and the "45s" display would freeze during silent stretches
195
- // (long child tool runs with no chunks). Done children are constant — no
196
- // per-second invalidation for them.
197
- const elapsedPart = s.done ? "" : `:${Math.floor((Date.now() - s.started) / 1000)}`
198
- subSig += `${key}:${s.done ? 1 : 0}${elapsedPart}:${s.turn}/${s.maxTurns}:${s.currentTool ?? ""}:${s.approval ?? ""}:${s.blockEpoch ?? 0}:${s.model ?? ""};`
199
- }
200
- // Frozen blocks ride state.lines ({_frozenSubTask}) — the lines.length part of
201
- // this key covers their existence; expanding/collapsing one flips expandedBlocks
202
- // (covered by `exp`). One extra: the last frozen payload's header depends on
203
- // blocks content which never changes post-freeze — nothing more needed.
204
- // Same single pass also builds the per-line COLOR-CLASS signature: foldability
205
- // is decided by color class since main output (C.text) never folds while
206
- // thinking/dim do (2026-08-30) — two states differing only in line color used
207
- // to collide on this key and serve a stale cached render.
184
+ // NOTE (§7.2.1): running subagent blocks are NOT part of the conversation
185
+ // anymore they render in the fixed bottom panel (subagent-panel.mjs,
186
+ // uncached per frame: the 1s ticker refreshes the panel's elapsed display).
187
+ // The old subSig (blockEpoch/turn/elapsed invalidation) is removed: the panel
188
+ // re-renders independently, so child activity must NOT invalidate the
189
+ // conversation cache (that would rebuild the whole conversation per child
190
+ // token exactly what the 2026-08-31 lazy-load optimization eliminated).
191
+ // Frozen blocks ride state.lines ({_frozenSubTask}) — the lines.length part
192
+ // of this key covers their existence; expanding/collapsing one flips
193
+ // expandedBlocks (covered by `exp`). One extra: the last frozen payload's
194
+ // header depends on blocks content which never changes post-freeze nothing
195
+ // more needed. Same single pass also builds the per-line COLOR-CLASS
196
+ // signature: foldability is decided by color class since main output (C.text)
197
+ // never folds while thinking/dim do (2026-08-30) two states differing only
198
+ // in line color used to collide on this key and serve a stale cached render.
208
199
  // Tool-block carriers ({_toolBlock}) contribute their BUFFER SIZE signature:
209
200
  // output/result arrays mutate in place (streaming appends, result landing),
210
201
  // and carrier text is always "" — without this the cache serves a stale block
@@ -225,7 +216,7 @@ export function convCacheKey(state, maxRows) {
225
216
  // this the cache would serve the pre-search rows and highlight would never
226
217
  // appear (P0-1, 2026-08-30 consult). query+index covers match navigation.
227
218
  const searchPart = state.search?.query ? `${state.search.query}:${state.search.index ?? 0}` : ""
228
- return `${state.lines.length}|${lastLine?.text.length ?? 0}|${state.streaming.length}|${state.reasoning.length}|${blocksSig}|${subSig}|${frozenSig}|${toolSig}|${colorSig}|${state.foldEnabled !== false ? "f" : "u"}|${exp}|${capPart}|${searchPart}|${foldScrollSig}`
219
+ 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}`
229
220
  }
230
221
 
231
222
  /** Fold marker line: bold-cyan icon + "click to …" phrase underlined (clickable affordance).
@@ -257,14 +248,16 @@ function highlightSearchMatches(text, query, matchesInLine, globalCurrentIndex,
257
248
  }
258
249
 
259
250
  /** Render a FROZEN child activity block carried on a state.lines entry
260
- * (subagent-blocks.mjs freezeSubTaskLines pushes {_frozenSubTask: sub}). Identical
261
- * interaction to the running tail section: folded = `[✓ coder#1 · glm-5.3 ·
262
- * done 45s · turn 12/100] click to expand` header + tail 3 block lines;
263
- * expanded = blank + control + full timeline (60% screen cap via the shared
264
- * component capped view ends in a reachable collapse control). Toggle key
265
- * `sub-${key}` the SAME key the live section uses, so fold state carries
266
- * across the freeze boundary seamlessly (user ruled 2026-08-30: frozen stays
267
- * clickable full design interaction, not a dim-lines fallback). */
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). */
268
261
  function frozenSubTaskLines(state, sub, cols, maxRows) {
269
262
  const foldKey = `sub-${sub.key}`
270
263
  const elapsed = Math.floor(((sub.doneAt ?? Date.now()) - sub.started) / 1000)
@@ -337,7 +330,8 @@ function buildConvLines(state, cols, maxRows) {
337
330
  blankAfter = !nextMain
338
331
  }
339
332
  // Frozen subagent activity block (§7.2 D4, 2026-08-30): rendered as its own
340
- // collapsible section clickable expand/collapse like the running block.
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).
341
335
  if (l._frozenSubTask) {
342
336
  // 2026-08-31 段缓存:frozenSubTask 冻结后内容不变——签名含 sub.key + blocks 计数
343
337
  const fKey = `sub-${l._frozenSubTask.key}`
@@ -425,65 +419,14 @@ function buildConvLines(state, cols, maxRows) {
425
419
  blankAfter = false
426
420
  }
427
421
  }
428
- // ── Subagent activity blocks (§7.2 D4) — RUNNING blocks only ──────────────
429
- // Rendered BEFORE the advisor blocks section. A child's block lives here only
430
- // while it runs: on completion onToolResult freezes the block into state.lines
431
- // (subagent-blocks.mjs freezeSubTaskLines) so it scrolls away with the conversation
432
- // instead of staying pinned above the input box ("ghost" report 2026-08-30).
433
- // Default folded = header summary line (▶ role#id · model · elapsed · turn
434
- // n/max | current state) + tail 3 block lines; expanded = shared component
435
- // (full timeline, 60% screen cap).
436
- const runningSubs = Object.values(state.subTasks ?? {}).filter((s) => !s.done)
437
- if (runningSubs.length > 0) {
438
- // Divider between the conversation body and the running-subagent band
439
- // (user request 2026-08-30, mirroring the task-panel divider in
440
- // render-frame renderTodo). Only when at least one block actually renders —
441
- // done children are frozen into state.lines above, so a divider for an
442
- // empty band would hang over the section boundary.
443
- // Unconditional divider (task-panel style): the preceding main-output
444
- // trailing blank is breathing room, the divider is the section boundary —
445
- // both belong. Only dedupe against another divider (idempotent re-render).
446
- if (convLines.at(-1)?.color !== C.dim || !convLines.at(-1)?.text?.startsWith("─")) {
447
- convLines.push({ text: "─".repeat(Math.max(1, cols - 1)), color: C.dim, _skipDimFold: true })
448
- }
449
- for (const sub of runningSubs) {
450
- // Done children never reach this loop (filtered above): they are frozen
451
- // into state.lines by subagent-blocks.mjs freezeSubTaskLines and removed
452
- // from subTasks — a restored leftover would otherwise pin at the tail.
453
- const foldKey = `sub-${sub.key}`
454
- // Header summary: `[▶ coder#1 · glm-5.3 · 45s · turn 12/100] bash — npm test`
455
- const icon = sub.approval ? "⏸" : "▶"
456
- const elapsed = Math.floor(((sub.done ? sub.doneAt : Date.now()) - sub.started) / 1000)
457
- const modelPart = sub.model ? ` · ${sub.model}` : ""
458
- const turnPart = sub.maxTurns > 0 ? ` · turn ${sub.turn}/${sub.maxTurns}` : ""
459
- let statePart
460
- if (sub.approval) statePart = `等待审批: ${sub.approval}`
461
- else if (sub.done) {
462
- statePart = `done ${elapsed}s${sub.lastError ? ` — ${sub.lastError}` : ""}`
463
- } else if (sub.currentTool) statePart = sub.currentTool
464
- else statePart = "thinking..."
465
- const argSummary = sub.currentTool && sub.toolArgs?.command
466
- ? ` — ${String(sub.toolArgs.command).replace(/\s+/g, " ").trim().slice(0, 60)}`
467
- : ""
468
- convLines.push({
469
- text: `[${icon} ${sub.key}${modelPart} · ${elapsed}s${turnPart}] ${sliceByWidth(statePart + argSummary, Math.max(20, cols - 30))}`,
470
- color: sub.done ? C.dim : C.tool,
471
- _foldToggle: foldKey,
472
- })
473
- if (isExpanded(state, foldKey)) {
474
- // Full activity timeline via the shared component (per-kind colors,
475
- // 60% screen cap — the header control may sit above the viewport once
476
- // expanded, the capped bottom control stays reachable).
477
- const body = renderBlockTimeline(sub.blocks, cols)
478
- convLines.push(...renderExpandedBlock({ body, foldKey, state, maxRows, cols, label: "subagent activity" }))
479
- } else {
480
- // Folded: tail 3 non-empty block lines (most recent activity), dim.
481
- for (const line of foldTailLines(sub.blocks)) {
482
- convLines.push({ text: `│ ${sliceByWidth(line, cols - 4)}`, color: C.dim })
483
- }
484
- }
485
- }
486
- }
422
+ // ── Subagent activity blocks (§7.2.1 D2) — RUNNING blocks MOVED to the fixed
423
+ // bottom panel (subagent-panel.mjs renderSubagentPanel, layout.mjs precomputes
424
+ // the panel height; render-frame puts it between conversation and todo).
425
+ // buildConvLines no longer renders running children: on completion
426
+ // onToolResult freezes the block into state.lines (subagent-blocks.mjs
427
+ // freezeSubTaskLines) and it scrolls away with the conversation (D4 现状).
428
+ // Frozen blocks render above via the _frozenSubTask branch (frozenSubTaskLines).
429
+
487
430
  if (state.reasoning) {
488
431
  // Live thinking streams INSIDE the unified box (user ruling 2026-08-30:
489
432
  // "思考过程中为什么不是直接进这个框" — the flat tail render was a
@@ -9,8 +9,8 @@
9
9
  import { ansi, C, ESC } from "./ansi.mjs"
10
10
  import { convCacheKey, renderConversation, countConvLines } from "./render-conversation.mjs"
11
11
  import { sliceByWidth, stringWidth } from "./render.mjs"
12
- import { specForModel } from "../config.mjs"
13
- import { computeLayout } from "./layout.mjs"
12
+ import { providerSpec } from "../config.mjs"
13
+ import { computeLayout, subagentVisibleLines } from "./layout.mjs"
14
14
  import { basename } from "node:path"
15
15
  import { readFileSync } from "node:fs"
16
16
 
@@ -39,11 +39,15 @@ const SLASH_HINTS = {
39
39
 
40
40
  /** Header panel (always 1 line). */
41
41
  export function renderHeader(agent, cols) {
42
- const model = agent.provider.model
43
- const spec = specForModel(model)
42
+ // 2026-09-02 Q1(SESSION.md §8):provider 可为 null(无效 provider 被清空后用户 Esc 取消重选)
43
+ // —— 可选链守卫,头部显示 "no provider" 占位
44
+ const model = agent.provider?.model ?? "no provider"
45
+ // providerSpec(PROVIDER.md §15):模型信息显示跟随 provider 级 context 覆盖(D-C5);
46
+ // provider 为 null 时返回默认 spec 且不产生 "no provider" 查表警告(specForModel("") 空串不告警)
47
+ const spec = providerSpec(agent.provider)
44
48
  const thinkOnValue = spec.thinkEnabledValue ?? "enabled"
45
- const t = agent.provider.thinking
46
- const effort = agent.provider.reasoningEffort
49
+ const t = agent.provider?.thinking
50
+ const effort = agent.provider?.reasoningEffort
47
51
  const thinkBadge = t?.type === "disabled" ? "│ think: off"
48
52
  : effort ? `│ think: ${effort}`
49
53
  : t?.type === thinkOnValue ? "│ think: on" : ""
@@ -89,8 +93,10 @@ export function renderPicker(state, cols, panel, overlay) {
89
93
  // 过滤提示:无filter时显示 "type to filter",有filter时显示输入内容
90
94
  const filterHint = p ? (p.filter ? `│ ${p.filter}` : "│ type to filter") : ""
91
95
  const rawLeft = p ? ` ❯ ${p.title} ${filterHint} ` : " ❯ Setup "
92
- const left = sliceByWidth(rawLeft, Math.max(1, cols - 2 - stringWidth(right)))
93
- const titlePad = " ".repeat(Math.max(1, cols - 1 - stringWidth(left) - stringWidth(right)))
96
+ // 2026-08-31 会诊(advisor round1 🔵):标题行与条目行一致留 8 格余量——Ambiguous 字符
97
+ // (❯/│ 等)在 CJK 终端渲染 2 格,余量不足时右侧 n/m 位置指示会被截。
98
+ const left = sliceByWidth(rawLeft, Math.max(1, cols - 8 - stringWidth(right)))
99
+ const titlePad = " ".repeat(Math.max(1, cols - 8 - stringWidth(left) - stringWidth(right)))
94
100
  out.push(`${ansi.bold}${C.tool}${left}${ansi.reset}${ansi.dim}${titlePad}${right}${ansi.reset}`)
95
101
  const hasMoreAbove = start > 0
96
102
  const hasMoreBelow = start + winH < total
@@ -100,10 +106,14 @@ export function renderPicker(state, cols, panel, overlay) {
100
106
  const moreAbove = i === 0 && hasMoreAbove
101
107
  const moreBelow = i === shown.length - 1 && hasMoreBelow
102
108
  const ind = moreAbove && moreBelow ? "↑↓ more" : moreAbove ? "↑ more" : moreBelow ? "↓ more" : ""
103
- const maxW = cols - 1 - (ind ? stringWidth(ind) + 1 : 0)
109
+ // 2026-08-31 会诊:右边距留 8 格余量——Ambiguous 宽度字符(│/—/●/▸/…/↑↓ 等)在中文
110
+ // locale 终端渲染 2 格而 stringWidth 按 1 格算,/session 行最坏带 ~7 个(▸ 前缀 +
111
+ // │×3 + … + — + ●)→ 行宽低估 7+ 格 → 实际超宽 → 物理 wrap → 残影;8 格余量覆盖
112
+ // 常见低估,最坏残余由 DECAWM 关闭硬截断兜底(见 render-loop)。
113
+ const maxW = cols - 8 - (ind ? stringWidth(ind) + 1 : 0)
104
114
  // 超宽行截断并加省略号
105
115
  const text = stringWidth(l.text) > maxW ? sliceByWidth(l.text, Math.max(0, maxW - 1)) + "…" : l.text
106
- const pad = ind ? " ".repeat(Math.max(1, cols - 1 - stringWidth(text) - stringWidth(ind))) : ""
116
+ const pad = ind ? " ".repeat(Math.max(1, cols - 8 - stringWidth(text) - stringWidth(ind))) : ""
107
117
  out.push(`${l.color}${text}${ansi.reset}${ind ? `${ansi.dim}${pad}${ind}${ansi.reset}` : ""}`)
108
118
  }
109
119
  for (let i = shown.length; i < winH; i++) out.push("")
@@ -221,7 +231,7 @@ export function renderRows(state, agent, opts) {
221
231
  const slashCommands = opts.slashCommands ?? []
222
232
 
223
233
  const layout = computeLayout(state, { cols, rows })
224
- const { W, panels, inputLayout, inputOffset, boxLines, visibleTasks, permPreviewLines, overlay } = layout
234
+ const { W, panels, inputLayout, inputOffset, boxLines, visibleTasks, permPreviewLines, overlay, subagentLines } = layout
225
235
 
226
236
  const screen = new Array(rows).fill("")
227
237
  const put = (y, lines) => {
@@ -232,6 +242,16 @@ export function renderRows(state, agent, opts) {
232
242
 
233
243
  put(panels.header.y, [renderHeader(agent, cols)])
234
244
  put(panels.conversation.y, renderConversation(state, cols, panels.conversation.h, state.scroll, rows))
245
+ // §7.2.1 D2: running-subagent fixed panel (between conversation and todo) —
246
+ // lines precomputed by layout (subagent-panel.mjs, neutral module), put
247
+ // directly (no double render); absent when no block is running (F6). Lines
248
+ // are objects ({text, color, _foldToggle/_foldBlock…} — mouse hit-testing
249
+ // reads the same array), converted to ANSI strings here like renderTodo.
250
+ if (panels.subagent) {
251
+ // 评审 #4:部分压缩(h < 全长)时保底截断——分隔线 + 末尾区块行(最新活动
252
+ // 优先),与 mouse 命中映射同一几何契约(subagentVisibleLines / subagentLineIndex)。
253
+ put(panels.subagent.y, subagentVisibleLines(subagentLines, panels.subagent.h).map((l) => `${l.color ?? ""}${l.text}${ansi.reset}`))
254
+ }
235
255
  if (panels.todo) put(panels.todo.y, renderTodo(visibleTasks, cols))
236
256
  if (panels.picker) put(panels.picker.y, renderPicker(state, cols, panels.picker, overlay))
237
257
  if (panels.permission) put(panels.permission.y, renderPermission(permPreviewLines))
@@ -272,7 +292,9 @@ function inputBoxStyle(state) {
272
292
  borderColor = C.tool; title = " Question "
273
293
  } else if (state.permission) {
274
294
  borderColor = C.warn
275
- title = state.permission.name === "continue" ? " Continue? (y/n) " : ` Allow ${state.permission.name}? (y/n/a) `
295
+ title = state.permission.name === "continue" ? " Continue? (y/n) "
296
+ : state.permission.batch ? ` Allow ${state.permission.name}? (a/o/n) `
297
+ : ` Allow ${state.permission.name}? (y/n/a) `
276
298
  } else if (state.picker) {
277
299
  title = " Select "
278
300
  } else if (state.wizard) {
@@ -296,9 +318,9 @@ function buildStatusLine(state, agent, { cols, slashCommands }) {
296
318
  : " Type answer then Enter │ Esc: cancel"
297
319
  }
298
320
  if (state.permission) {
299
- return state.permission.name === "continue"
300
- ? " y: continue │ n: stop"
301
- : " y: approve │ n: deny │ a: approve all (AUTO)"
321
+ if (state.permission.name === "continue") return " y: continue │ n: stop"
322
+ if (state.permission.batch) return " a: approve all o: one by one │ n: deny"
323
+ return " y: approve │ n: deny │ a: approve all (AUTO)"
302
324
  }
303
325
  if (state.picker) return " type: filter │ ↑↓/PgUp/PgDn: select │ Enter: confirm │ Esc: cancel"
304
326
  if (state.wizard) {
@@ -330,7 +352,9 @@ function buildStatusLine(state, agent, { cols, slashCommands }) {
330
352
  const elapsed = state.processing ? ` ${Math.floor((Date.now() - state.processingStarted) / 1000)}s` : ""
331
353
  const toolHint = state.currentTool ? ` ${state.currentTool}…` : ""
332
354
  const statusText = state.processing ? `${state.status}${toolHint}${elapsed}` : state.status
333
- const modelContext = specForModel(agent.provider.model).context
355
+ // 2026-09-02 Q1(SESSION.md §8):provider 可为 null —— providerSpec(null) 保守 128K 不抛错
356
+ // 2026-09-02 §15:context 窗口跟随 providers[].context 覆盖(T-C6——百分比基于覆盖后的窗口)
357
+ const modelContext = providerSpec(agent.provider).context
334
358
  const ctxPct = Math.round((state.ctxCache.tokens / modelContext) * 100)
335
359
  const ctxTokensHint = state.ctxCache.tokens > 0 ? ` ${fmtK(state.ctxCache.tokens)}` : ""
336
360
  const ctxHint = ctxPct > 0
@@ -114,7 +114,7 @@ export function createRenderLoop(state, agent, ctx, pushLine, write = (s) => pro
114
114
  const hasOverlay = state.permission || state.question || state.picker || state.wizard?.step === "provider"
115
115
  const cursorSuffix = hasOverlay ? "" : `\x1b[${cursorRow};${cursorCol}H${ansi.hideCursor}`
116
116
 
117
- if (out.length || cursorSuffix) write(ansi.syncUpdateStart + out.join("") + ansi.syncUpdateEnd + cursorSuffix)
117
+ if (out.length || cursorSuffix) write(ansi.wrapOff + ansi.syncUpdateStart + out.join("") + ansi.syncUpdateEnd + cursorSuffix + ansi.wrapOn)
118
118
  } catch (e) {
119
119
  // Don't let a render error crash the TUI
120
120
  if (process.env.THINCODER_DEBUG_RENDER) process.stderr.write(`[render-error] ${e?.stack ?? e}\n`)
@@ -203,7 +203,7 @@ export function layoutInput(chars, cursor, width) {
203
203
  * Display-layer only — raw tool results the model sees are unchanged; dirty displays already in session
204
204
  * are also cleaned during replay.
205
205
  */
206
- // eslint-disable-next-line no-control-regex -- 有意为之:控制字符协议/转义序列剥离正则(ANSI/⟦ev⟧/SGR/history 双线分隔)
206
+ // 有意为之:控制字符协议/转义序列剥离正则(ANSI/⟦ev⟧/SGR/history 双线分隔)
207
207
  const ANSI_SEQUENCE = /\x1b\[[0-9;?]*[a-zA-Z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[()][0-9A-B]|\x1b[=>#][0-9]?/
208
208
  // Global variant for replace()/split(); the non-global one keeps match.index for slicing
209
209
  const ANSI_SEQUENCE_RE = new RegExp(ANSI_SEQUENCE.source, "g")
@@ -212,7 +212,7 @@ export function sanitizeDisplay(s) {
212
212
  .replace(ANSI_SEQUENCE_RE, "")
213
213
  // §7.2 D5 fallback: an unparsed ⟦ev⟧ event token must never reach the grid —
214
214
  // strip the sentinel + its RS-wrapped payload (⟦ev⟧turn\x1e…\x1e / bare RS/GS chars).
215
- // eslint-disable-next-line no-control-regex -- 有意为之:控制字符协议/转义序列剥离正则(ANSI/⟦ev⟧/SGR/history 双线分隔)
215
+ // 有意为之:控制字符协议/转义序列剥离正则(ANSI/⟦ev⟧/SGR/history 双线分隔)
216
216
  .replace(/⟦ev⟧[^\x1e\x1d]*\x1e[^\x1e\x1d]*\x1e[^\x1e\x1d]*\x1e[^\x1e\x1d]*\x1e?/g, "")
217
217
  // GitHub-#4-class pitfall (2026-08-31): the residue strip used to be
218
218
  // /⟦ev⟧[^\x1e\x1d]*/ — "swallow to end of line/string", which ATE REAL
@@ -222,12 +222,12 @@ export function sanitizeDisplay(s) {
222
222
  // tokens start with a phase word (turn/approval); a bare sentinel with no
223
223
  // letters attached is not a live token. Strip only sentinel+letters.
224
224
  .replace(/⟦ev⟧[A-Za-z]*/g, "")
225
- // eslint-disable-next-line no-control-regex -- 有意为之:控制字符协议/转义序列剥离正则(ANSI/⟦ev⟧/SGR/history 双线分隔)
225
+ // 有意为之:控制字符协议/转义序列剥离正则(ANSI/⟦ev⟧/SGR/history 双线分隔)
226
226
  .replace(/[\x1d\x1e]/g, "")
227
227
  .replace(/\r\n/g, "\n")
228
228
  .replace(/\r/g, "\n")
229
229
  .replace(/\t/g, " ")
230
- // eslint-disable-next-line no-control-regex -- 有意为之:控制字符协议/转义序列剥离正则(ANSI/⟦ev⟧/SGR/history 双线分隔)
230
+ // 有意为之:控制字符协议/转义序列剥离正则(ANSI/⟦ev⟧/SGR/history 双线分隔)
231
231
  .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "")
232
232
  .replace(/\n+$/, "")
233
233
  }
@@ -140,12 +140,14 @@ export function showStartup(ctx) {
140
140
  const { agent, state, opts, pushLine, pushLabel, render, startWizard } = ctx
141
141
 
142
142
  // Startup screen
143
- if (!agent.provider.apiKey) {
143
+ // 2026-09-02 Q1(SESSION.md §8 D-S2):provider 可为 null(无效 provider 被清空后用户 Esc 取消重选)
144
+ // —— 可选链守卫;`!apiKey` 触发既有 wizard(其 provider 菜单列出已存在 providers,可选中恢复,F3)
145
+ if (!agent.provider?.apiKey) {
144
146
  pushLabel(`Welcome to ThinCoder!`, ansi.bold + C.tool)
145
147
  pushLine("No API key configured yet — entering initial setup (Esc to skip anytime)", C.text)
146
148
  startWizard()
147
149
  } else {
148
- pushLine(`Welcome to ThinCoder. Provider: ${agent.activeProvider} / ${agent.provider.model}`, C.dim)
150
+ pushLine(`Welcome to ThinCoder. Provider: ${agent.activeProvider} / ${agent.provider?.model ?? "(none)"}`, C.dim)
149
151
  }
150
152
  pushLine(`Tools: ${agent.tools.map((t) => t.name).join(", ")}`, C.dim)
151
153
 
@@ -16,9 +16,12 @@ import { describeToolArgs } from "./tool-args.mjs"
16
16
 
17
17
  /** `role#id/` prefix router — hyphen included since the eng-coder fix (2026-08-21). */
18
18
  export const SUB_PREFIX_RE = /^([\w-]+)#(\d+)\//
19
- /** ⟦ev⟧ event token parser (D1/D2): `⟦ev⟧<name>\x1e<n>\x1e<max>\x1e<phase>\x1e<detail>`. */
20
- // eslint-disable-next-line no-control-regex -- 有意为之:控制字符协议/转义序列剥离正则(ANSI/⟦ev⟧/SGR/history 双线分隔)
21
- export const SUB_EVENT_RE = /^⟦ev⟧(turn|approval)\x1e([^\x1e]*)\x1e([^\x1e]*)\x1e([^\x1e]*)\x1e?([\s\S]*)$/
19
+ /** ⟦ev⟧ event token parser (D1/D2): `⟦ev⟧<name>\x1e<n>\x1e<max>\x1e<phase>\x1e<detail>`.
20
+ * phase "done" (§15 D-A3): async child finished — settle-time emits (each entry,
21
+ * it so the child's block freezes (the spawn tool result is a status JSON and
22
+ * must not freeze a still-running block). */
23
+ // 有意为之:控制字符协议/转义序列剥离正则(ANSI/⟦ev⟧/SGR/history 双线分隔)
24
+ export const SUB_EVENT_RE = /^⟦ev⟧(turn|approval|done)\x1e([^\x1e]*)\x1e([^\x1e]*)\x1e([^\x1e]*)\x1e?([\s\S]*)$/
22
25
  /** N2: per-child display-line ring buffer cap — oldest lines drop with a marker. */
23
26
  export const SUB_BLOCK_LINE_LIMIT = 500
24
27
  /** N1: render-layer throttle for child tool-output appends (generation relays verbatim). */
@@ -267,8 +270,21 @@ export function routeSubToken(state, t, scheduleRender) {
267
270
  if (!sub) return true // frozen tombstone — late token from an aborted child: drop
268
271
  const payload = t.slice(subMatch[0].length)
269
272
  // ⟦ev⟧ event token: turn/approval progress → header ONLY (never blocks,
270
- // never the main stream — D1).
273
+ // never the main stream — D1). phase "done" (§15 D-A3): the async child
274
+ // finished — freeze its block (it stayed live through the background run).
271
275
  if (payload.startsWith("⟦ev⟧")) {
276
+ const ev = payload.match(SUB_EVENT_RE)
277
+ if (ev?.[1] === "done") {
278
+ sub.done = true
279
+ sub.doneAt = Date.now()
280
+ sub.currentTool = null
281
+ sub.approval = null
282
+ sub.blockEpoch = (sub.blockEpoch ?? 0) + 1
283
+ freezeSubTaskLines(state, sub)
284
+ delete state.subTasks[sub.key]
285
+ scheduleRender()
286
+ return true
287
+ }
272
288
  applySubEvent(sub, payload)
273
289
  scheduleRender()
274
290
  return true
@@ -329,3 +345,102 @@ export function routeSubToolOutput(state, name, part, scheduleRender) {
329
345
  throttleSubRender(scheduleRender)
330
346
  return true
331
347
  }
348
+
349
+ // ─── Compression panel (CONTEXT-COMPACTION.md §7 D-C2) ─────────────────────
350
+ // The compression session reuses the subagent block machinery — user ruling
351
+ // "压缩会话像子agent 面板那样显示". While the summary call is in flight the panel
352
+ // lives in state.subTasks (role "compress") and renders in the running subagent
353
+ // panel (subagent-panel.mjs) — the existing 1s turn ticker (agent-turn.mjs
354
+ // subRunning) makes the elapsed header tick; on completion/fallback it freezes
355
+ // into the stream as a collapsible block via freezeSubTaskLines, exactly like a
356
+ // finished child. The panel carries STATUS ONLY (D-C2): the summary body is a
357
+ // machine artifact and never enters the blocks.
358
+
359
+ let _compressSeq = 0
360
+
361
+ /** Live compression panel (role "compress", not done) or null. */
362
+ function liveCompressPanel(state) {
363
+ return Object.values(state.subTasks ?? {}).find((s) => s.role === "compress" && !s.done) ?? null
364
+ }
365
+
366
+ /**
367
+ * Open (or reset — retry) the compression panel. Fired by onCompressStart BEFORE
368
+ * the summary LLM call. info.messages = number of history messages being
369
+ * summarized ("summarizing N messages" stage label). Each attempt restarts the
370
+ * elapsed ticker (panel.started); the previous attempt's failure line stays in
371
+ * the block timeline so the retry history is visible.
372
+ */
373
+ export function ensureCompressPanel(state, info = {}) {
374
+ state.subTasks ??= {}
375
+ let panel = liveCompressPanel(state)
376
+ if (!panel) {
377
+ panel = {
378
+ key: `compress#${++_compressSeq}`,
379
+ role: "compress",
380
+ model: undefined,
381
+ started: Date.now(), done: false, doneAt: null,
382
+ blocks: [], currentTool: null, toolArgs: null,
383
+ turn: 0, maxTurns: 0, approval: null,
384
+ lastError: null, dropped: 0,
385
+ }
386
+ state.subTasks[panel.key] = panel
387
+ }
388
+ // Per-attempt reset (D-C2 state machine): retries return to "Compressing…",
389
+ // the elapsed ticker restarts from this attempt's start.
390
+ panel.done = false
391
+ panel.doneAt = null
392
+ panel.lastError = null
393
+ panel.started = Date.now()
394
+ panel.currentTool = "compressing context…"
395
+ const messages = Number.isInteger(info.messages) && info.messages >= 0 ? info.messages : "?"
396
+ appendSubBlock(panel, "status", "Compressing context…\n", { fresh: true })
397
+ appendSubBlock(panel, "meta", `summarizing ${messages} messages\n`, { fresh: true })
398
+ return panel
399
+ }
400
+
401
+ /** Failure state (onCompressFail): error text ONLY — no degradation note. The
402
+ * fallback note is bound to 3 CONSECUTIVE failures (markCompressFallback) and
403
+ * must not appear on a single failure (D-C2 state machine). */
404
+ export function markCompressFailed(state, error) {
405
+ const panel = liveCompressPanel(state)
406
+ if (!panel) return
407
+ const text = error?.message ? String(error.message) : String(error ?? "unknown error")
408
+ panel.lastError = text
409
+ appendSubBlock(panel, "err", `Compression failed: ${text}\n`, { fresh: true })
410
+ }
411
+
412
+ /** Freeze the compression panel into the stream (collapsible, subagent-style —
413
+ * same carrier/fold-key as a finished child) and release the live entry. */
414
+ function freezeCompressPanel(state, panel) {
415
+ freezeSubTaskLines(state, panel)
416
+ delete state.subTasks[panel.key]
417
+ }
418
+
419
+ /** Completion state (onCompress, LLM summary): "Compressed: N tokens freed →
420
+ * summary (Xs)" — N = pre-compression estimate − post-compression estimate,
421
+ * Xs = elapsed seconds. Block frozen + collapsible (T2). */
422
+ export function markCompressDone(state, info = {}) {
423
+ const panel = liveCompressPanel(state)
424
+ if (!panel) return
425
+ const tokensFreed = Number.isFinite(info.tokensFreed) ? Math.max(0, Math.round(info.tokensFreed)) : 0
426
+ const seconds = Number.isFinite(info.elapsedMs) ? Math.max(0, Math.round(info.elapsedMs / 1000)) : 0
427
+ panel.done = true
428
+ panel.doneAt = Date.now()
429
+ panel.currentTool = null
430
+ appendSubBlock(panel, "status", `Compressed: ${tokensFreed} tokens freed → summary (${seconds}s)\n`, { fresh: true })
431
+ freezeCompressPanel(state, panel)
432
+ }
433
+
434
+ /** Fallback state (onCompress with mode:"fallback"): the 3-consecutive-failures
435
+ * degradation note "Compression failed — fallback: truncated to N messages"
436
+ * (N = tail messages retained). Frozen + collapsible (T3b). */
437
+ export function markCompressFallback(state, info = {}) {
438
+ const panel = liveCompressPanel(state)
439
+ if (!panel) return
440
+ const tailMessages = Number.isFinite(info.tailMessages) ? Math.round(info.tailMessages) : "?"
441
+ panel.done = true
442
+ panel.doneAt = Date.now()
443
+ panel.currentTool = null
444
+ appendSubBlock(panel, "err", `Compression failed — fallback: truncated to ${tailMessages} messages\n`, { fresh: true })
445
+ freezeCompressPanel(state, panel)
446
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * subagent-panel.mjs — 运行中子 agent 固定底部面板渲染(AGENT-LOOP.md §7.2.1 D1/D2)。
3
+ *
4
+ * 面板位于 conversation 与 todo 之间(布局顺序 header → conversation → 面板 →
5
+ * todo → picker → permission → queue → input → status),高度完全自适应 = 全部
6
+ * 运行中区块的渲染行数(F2,会话区被挤小);无运行中区块 → 返回 [](F6 空态,
7
+ * 无悬空分隔线)。子 agent 完成后立即冻结进会话流(subagent-blocks.mjs
8
+ * freezeSubTaskLines,✓ 头 + 可展开,§7.2 D4 现状不变),面板下一帧自然移除
9
+ * 该区块(F5)——本模块只渲染 `!done` 条目。
10
+ *
11
+ * 中立模块(D1 评审 #6):layout.mjs 调 renderSubagentPanel 预计算面板高度
12
+ * (subagentLines → subagentH),render-frame.mjs 直接 put 预计算行(不重复
13
+ * 渲染)——若本函数放 render-frame 会引入 layout↔render-frame 循环依赖。
14
+ *
15
+ * 区块渲染逻辑自 render-conversation.mjs buildConvLines runningSubs 段迁移
16
+ * (§7.2.1 D2):折叠头 `[▶/⏸ key · model · elapsed · turn] state`(⏸ = 等待
17
+ * 审批态图标,sub.approval 非空时显示)+ tail 3;展开态经 fold-block.mjs 公共
18
+ * 组件(renderBlockTimeline + renderExpandedBlock,60% 封顶 + 块内滚动)。
19
+ * 折叠状态 key = `sub-${key}` 跨 turn 保持(D5,与冻结区块同一 key——冻结边界
20
+ * 无缝衔接)。
21
+ */
22
+ import { C } from "./ansi.mjs"
23
+ import { sliceByWidth, stringWidth } from "./render.mjs"
24
+ import { isExpanded, renderBlockTimeline, renderExpandedBlock, foldTailLines } from "./fold-block.mjs"
25
+
26
+ /**
27
+ * 面板行构建(纯函数):顶部分隔线 `─` + 各运行中区块(折叠头 + tail 3 /
28
+ * 展开全量)。maxRows = 终端行数(展开态 60% 封顶窗口化);省略 = 不封顶
29
+ * (单测/无终端环境)。
30
+ * @returns {Array<{text: string, color: string, ...}>}
31
+ */
32
+ export function renderSubagentPanel(state, cols, maxRows) {
33
+ const runningSubs = Object.values(state.subTasks ?? {}).filter((s) => !s.done)
34
+ if (runningSubs.length === 0) return []
35
+ const out = []
36
+ // 面板顶部边界线(现状分隔线语义迁移,§7.2.1 D2/NF2)——面板存在即画线,
37
+ // 无运行区块时面板整体不渲染(F6:无悬空线)。
38
+ out.push({ text: "─".repeat(Math.max(1, cols - 1)), color: C.dim, _skipDimFold: true })
39
+ for (const sub of runningSubs) {
40
+ const foldKey = `sub-${sub.key}`
41
+ // 头部摘要:`[▶ coder#1 · glm-5.3 · 45s · turn 12/100] bash — npm test`
42
+ // ⏸ = 等待审批态(sub.approval 非空,评审 #5 定义);图标在括号内,
43
+ // 与冻结头 `[✓ …]` 格式统一(任务简报 UI 决策)。
44
+ const icon = sub.approval ? "⏸" : "▶"
45
+ const elapsed = Math.floor((Date.now() - sub.started) / 1000)
46
+ // 评审 #1 宽度预算:模型名先单独按显示宽度截断([model] token 原样记录可长
47
+ // 20-30+ 字符,不截断则括号前缀宽度不可预算、状态区被挤出终端右边距);
48
+ // 再量括号前缀实际显示宽度,状态区按 cols - bracketWidth - 2 截断——整行
49
+ // ≤ cols 铁律(TUI 布局纪律:任何写入帧的行 ≤ cols)。极端窄终端下括号
50
+ // 前缀自身也按 cols-2 截断兜底(状态区宁可让位也不撑破帧)。
51
+ const modelPart = sub.model
52
+ ? ` · ${sliceByWidth(sub.model, Math.max(8, Math.floor(cols / 3)))}`
53
+ : ""
54
+ const turnPart = sub.maxTurns > 0 ? ` · turn ${sub.turn}/${sub.maxTurns}` : ""
55
+ const bracket = sliceByWidth(`[${icon} ${sub.key}${modelPart} · ${elapsed}s${turnPart}]`, Math.max(1, cols - 2))
56
+ const bracketWidth = stringWidth(bracket)
57
+ let statePart
58
+ if (sub.approval) statePart = `等待审批: ${sub.approval}`
59
+ else if (sub.currentTool) statePart = sub.currentTool
60
+ else statePart = "thinking..."
61
+ const argSummary = sub.currentTool && sub.toolArgs?.command
62
+ ? ` — ${String(sub.toolArgs.command).replace(/\s+/g, " ").trim().slice(0, 60)}`
63
+ : ""
64
+ out.push({
65
+ text: `${bracket} ${sliceByWidth(statePart + argSummary, Math.max(0, cols - 2 - bracketWidth))}`,
66
+ color: C.tool,
67
+ _foldToggle: foldKey,
68
+ })
69
+ if (isExpanded(state, foldKey)) {
70
+ // 展开态:全量活动时间线(per-kind 着色,60% 屏封顶 + 块内滚动——公共组件)。
71
+ const body = renderBlockTimeline(sub.blocks, cols)
72
+ out.push(...renderExpandedBlock({ body, foldKey, state, maxRows, cols, label: "subagent activity" }))
73
+ } else {
74
+ // 折叠态:tail 3 非空 block 行(最近活动),dim。
75
+ for (const line of foldTailLines(sub.blocks)) {
76
+ out.push({ text: `│ ${sliceByWidth(line, cols - 4)}`, color: C.dim })
77
+ }
78
+ }
79
+ }
80
+ return out
81
+ }