thincoder 0.12.67 → 0.12.69

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 (64) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +21 -5
  3. package/bin/thincoder.mjs +41 -344
  4. package/package.json +2 -2
  5. package/src/acp/bridge.mjs +5 -2
  6. package/src/acp/handlers-session.mjs +12 -1
  7. package/src/cli/make-agent.mjs +44 -119
  8. package/src/cli/memory-command.mjs +45 -3
  9. package/src/command-interactive.mjs +196 -0
  10. package/src/command-table.mjs +185 -0
  11. package/src/completions.mjs +36 -12
  12. package/src/crash-reports.mjs +42 -20
  13. package/src/distill.mjs +3 -3
  14. package/src/heap-watch.mjs +5 -14
  15. package/src/tui/agent-turn.mjs +88 -50
  16. package/src/tui/clipboard.mjs +74 -30
  17. package/src/tui/cmd-advisor.mjs +39 -13
  18. package/src/tui/cmd-clear.mjs +2 -2
  19. package/src/tui/cmd-config.mjs +17 -1
  20. package/src/tui/cmd-eng.mjs +1 -1
  21. package/src/tui/cmd-mcp.mjs +1 -1
  22. package/src/tui/cmd-new.mjs +2 -2
  23. package/src/tui/cmd-reindex.mjs +1 -1
  24. package/src/tui/cmd-session.mjs +13 -5
  25. package/src/tui/cmd-think.mjs +35 -17
  26. package/src/tui/cmd-timers.mjs +33 -0
  27. package/src/tui/conversation-writer.mjs +2 -2
  28. package/src/tui/display-budget.mjs +9 -9
  29. package/src/tui/fold-block.mjs +1 -1
  30. package/src/tui/index.mjs +54 -18
  31. package/src/tui/interaction.mjs +4 -2
  32. package/src/tui/key-handler-ctrlc.mjs +1 -1
  33. package/src/tui/key-handler-search.mjs +1 -1
  34. package/src/tui/key-handler.mjs +1 -1
  35. package/src/tui/key-modes.mjs +1 -1
  36. package/src/tui/layout.mjs +5 -5
  37. package/src/tui/lifecycle-records.mjs +199 -0
  38. package/src/tui/model-picker.mjs +16 -199
  39. package/src/tui/mouse.mjs +3 -3
  40. package/src/tui/pickers.mjs +6 -2
  41. package/src/tui/provider-admin.mjs +213 -0
  42. package/src/tui/queued-merge.mjs +4 -60
  43. package/src/tui/queued-pickup.mjs +3 -3
  44. package/src/tui/render-conversation.mjs +13 -4
  45. package/src/tui/render-frame.mjs +43 -6
  46. package/src/tui/render-loop.mjs +2 -3
  47. package/src/tui/render-segments.mjs +2 -2
  48. package/src/tui/render.mjs +1 -1
  49. package/src/tui/slash-commands.mjs +3 -0
  50. package/src/tui/startup.mjs +28 -3
  51. package/src/tui/subagent-blocks.mjs +27 -19
  52. package/src/tui/subagent-children.mjs +5 -5
  53. package/src/tui/subagent-freeze.mjs +39 -37
  54. package/src/tui/subagent-panel.mjs +7 -7
  55. package/src/tui/suspension-drive.mjs +172 -259
  56. package/src/tui/timer-watch.mjs +64 -0
  57. package/src/tui/tool-args.mjs +10 -71
  58. package/src/tui/tool-display.mjs +6 -6
  59. package/src/tui/tool-events.mjs +33 -28
  60. package/src/tui/tui-lifecycle.mjs +13 -4
  61. package/src/tui/tui-state.mjs +3 -2
  62. package/src/tui/turn-face.mjs +4 -1
  63. package/src/tui/wizard.mjs +7 -2
  64. package/src/tui/wrapped-spawn.mjs +3 -3
@@ -1,3 +1,9 @@
1
+ import { mkdirSync, writeFileSync } from "node:fs"
2
+ import { readFile, stat, unlink } from "node:fs/promises"
3
+ import { tmpdir } from "node:os"
4
+ import { join } from "node:path"
5
+ import { IMAGE_MAX_BYTES, savePastedImages } from "@thincoder/core/attachments.mjs"
6
+ import { cleanupOldToolResults } from "@thincoder/core/agent/helpers.mjs"
1
7
  import { C } from "./ansi.mjs"
2
8
 
3
9
  /** Windows clipboard-read command: force UTF-8 console output so Get-Clipboard's bytes
@@ -132,49 +138,87 @@ export function stripKeyboardProtocol(text) {
132
138
  return text.replace(/\x1b\[\d+;\d+u/g, "").replace(/\x1b\[27;\d+;\d+~/g, "")
133
139
  }
134
140
 
135
- /** Ctrl+V / Alt+V: read clipboard image → write temp file in working directory → insert read_image command into input box.
136
- * Extracted from index.mjs.
137
- * ctx: { agent, state, pushLine, render } */
138
- export async function pasteClipboardImage(ctx) {
139
- const { agent, state, pushLine, render } = ctx
141
+ /** 平台剪贴板图像捕获(缺省实现——写 staging ∥ 无图抛):Windows = PowerShell
142
+ * System.Windows.Forms(无图 ⇒ exit 1 ⇒ 抛);macOS = osascript PNGf;其余 = xclip → wl-paste 回退。
143
+ * 截图直接写 `dest`(系统临时区 staging——不入项目树)。 */
144
+ async function captureClipboardImage(dest) {
140
145
  const { execFile } = await import("node:child_process")
141
- const { stat, unlink } = await import("node:fs/promises")
142
- const { join } = await import("node:path")
143
-
144
146
  const run = (cmd, args) => new Promise((resolve, reject) => {
145
147
  execFile(cmd, args, { timeout: 10000 }, (err, stdout) => { if (err) reject(err); else resolve(stdout) })
146
148
  })
147
-
148
- const dest = join(agent.cwd, `.thincoder-paste-${Date.now()}.png`)
149
149
  const isWin = process.platform === "win32"
150
150
  const isMac = process.platform === "darwin"
151
+ if (isWin) {
152
+ const psScript = `Add-Type -AssemblyName System.Windows.Forms; if ([System.Windows.Forms.Clipboard]::ContainsImage()) { [System.Windows.Forms.Clipboard]::GetImage().Save('${dest.replace(/\\/g, "\\\\")}', [System.Drawing.Imaging.ImageFormat]::Png); exit 0 } else { exit 1 }`
153
+ await run("powershell", ["-NoProfile", "-Command", psScript])
154
+ } else if (isMac) {
155
+ const script = `try; set f to (POSIX file "${dest}"); set img to the clipboard as «class PNGf»; set fd to open for access f with write permission; write img to fd; close access fd; end try`
156
+ await run("osascript", ["-e", script])
157
+ } else {
158
+ await run("bash", ["-c", `xclip -selection clipboard -t image/png -o > "${dest}" 2>/dev/null || { which wl-paste >/dev/null 2>&1 && wl-paste -t image/png > "${dest}" 2>/dev/null; } || exit 1`])
159
+ }
160
+ }
151
161
 
162
+ /** 贴图 buffer ⇒ 核正路落盘(**唯一写盘者 = 核 `savePastedImages`**):写时扫除
163
+ * (`cleanupOldToolResults(<cwd>/.thincoder/tmp)`——3 天窗 mtime 回收;扫除失败不影响落盘)⇒
164
+ * 构 dataURL ⇒ 调核 `savePastedImages(dataUrls, cwd, { fs })` ⇒ 路径 ∥ null(核弃项 ∥ 写败)。
165
+ * @returns {Promise<string|null>} `<cwd>/.thincoder/tmp/paste-<id>-0.png` 绝对路径 / null */
166
+ export async function savePastedImageBuffer(buffer, cwd) {
167
+ const tmpDir = join(cwd, ".thincoder", "tmp")
168
+ await cleanupOldToolResults(tmpDir)
169
+ const dataUrl = `data:image/png;base64,${buffer.toString("base64")}`
170
+ // 契约「路径 ∥ null」——核内异常(未接线等)归「写败」径,不越出本函数
152
171
  try {
153
- if (isWin) {
154
- const psScript = `Add-Type -AssemblyName System.Windows.Forms; if ([System.Windows.Forms.Clipboard]::ContainsImage()) { [System.Windows.Forms.Clipboard]::GetImage().Save('${dest.replace(/\\/g, "\\\\")}', [System.Drawing.Imaging.ImageFormat]::Png); exit 0 } else { exit 1 }`
155
- await run("powershell", ["-NoProfile", "-Command", psScript])
156
- } else if (isMac) {
157
- const script = `try; set f to (POSIX file "${dest}"); set img to the clipboard as «class PNGf»; set fd to open for access f with write permission; write img to fd; close access fd; end try`
158
- await run("osascript", ["-e", script])
159
- } else {
160
- await run("bash", ["-c", `xclip -selection clipboard -t image/png -o > "${dest}" 2>/dev/null || { which wl-paste >/dev/null 2>&1 && wl-paste -t image/png > "${dest}" 2>/dev/null; } || exit 1`])
161
- }
172
+ const { paths } = savePastedImages([dataUrl], cwd, { fs: { mkdirSync, writeFileSync } })
173
+ return paths[0] ?? null
162
174
  } catch {
163
- pushLine("Clipboard does not contain an image, or clipboard access failed", C.dim)
164
- try { await unlink(dest) } catch {}
165
- return
175
+ return null
166
176
  }
177
+ }
167
178
 
168
- const st = await stat(dest).catch(() => null)
179
+ /** Ctrl+V / Alt+V:读剪贴板图像 → 系统临时区 staging(不入项目树)→ 读 buffer ⇒ staging 用毕即清
180
+ * (读取后——与失败径同口径)⇒ `savePastedImageBuffer` 落 `<cwd>/.thincoder/tmp/paste-<id>-0.png`
181
+ * → 插入 `read_image <最终绝对路径>`。
182
+ * 失败径(dim 提示行 ×3):无图(捕获抛 ∥ 零字节)· 超阈(`IMAGE_MAX_BYTES` 单源早筛——stat 后
183
+ * 读前,不读巨件)· 写败(核落盘 ≤0 项)——均零插入、返回 null。
184
+ * @param {{agent:{cwd:string}, state, pushLine:Function, render:Function}} ctx
185
+ * @param {((dest:string)=>Promise<void>)|null} [captureImpl] — 捕获面测试缝(写 staging ∥ 无图抛);
186
+ * 缺省 null ⇒ 平台捕获实现(生产调用点零改;参数作用域——零跨调用残留)
187
+ * @returns {Promise<{insert:string, path:string}|null>} 成功 = 插入文本 + 最终绝对路径;失败 = null */
188
+ export async function pasteClipboardImage(ctx, captureImpl = null) {
189
+ const { agent, state, pushLine, render } = ctx
190
+ const staging = join(tmpdir(), `thincoder-paste-${Date.now()}.png`)
191
+ const capture = captureImpl ?? captureClipboardImage
192
+ try {
193
+ await capture(staging)
194
+ } catch {
195
+ pushLine("Clipboard does not contain an image, or clipboard access failed", C.dim)
196
+ try { await unlink(staging) } catch {}
197
+ return null
198
+ }
199
+ const st = await stat(staging).catch(() => null)
169
200
  if (!st || st.size === 0) {
170
201
  pushLine("Clipboard does not contain an image, or clipboard access failed", C.dim)
171
- try { await unlink(dest) } catch {}
172
- return
202
+ try { await unlink(staging) } catch {}
203
+ return null
173
204
  }
174
-
175
- const cmd = `read_image ${dest}`
176
- state.input.splice(state.cursor, 0, ...[...cmd])
177
- state.cursor += cmd.length
178
- pushLine(`[image pasted → ${dest}]`, C.tool)
205
+ if (st.size > IMAGE_MAX_BYTES) {
206
+ pushLine("Clipboard image too large (max 15MB)", C.dim)
207
+ try { await unlink(staging) } catch {}
208
+ return null
209
+ }
210
+ const buffer = await readFile(staging)
211
+ try { await unlink(staging) } catch {}
212
+ const path = await savePastedImageBuffer(buffer, agent.cwd)
213
+ if (!path) {
214
+ pushLine("Clipboard image could not be saved (write failed)", C.dim)
215
+ return null
216
+ }
217
+ const insert = `read_image ${path}`
218
+ const chars = [...insert] // codepoint 数组(同 insertPastedText 口径——cwd 含星平面字符时游标不漂)
219
+ state.input.splice(state.cursor, 0, ...chars)
220
+ state.cursor += chars.length
221
+ pushLine(`[image pasted → ${path}]`, C.tool)
179
222
  render()
223
+ return { insert, path }
180
224
  }
@@ -4,11 +4,16 @@
4
4
  import { readFileSync } from "node:fs"
5
5
  import { ansi, C } from "./ansi.mjs"
6
6
  import { activeSlot, slotPath } from "@thincoder/core/session.mjs"
7
+ // off 形族单源(`docs/core/design/MODEL-SPECS.md` §16.2 / §16.3 / §16.4)——取形与「有效 off 路径」
8
+ // 判据一律自本档取,本档零副本(叶档:零 import 纯函数)。
9
+ import { thinkOffShape, thinkOffPath } from "@thincoder/core/think-off.mjs"
7
10
  import { writeSessionFile } from "./cmd-eng.mjs"
8
11
 
9
12
  export async function handleAdvisorCommand(ctx) {
10
13
  const { agent, showPicker, pushLine, pushLabel } = ctx
11
14
  const cfg = agent.config.advisor ??= {}
15
+ // 入口预载规格面(懒加载面零改:不做顶层静态 import)——同步的状态行(`advisorStatus`)亦须取 spec。
16
+ const { specForModel } = await import("@thincoder/core/config.mjs")
12
17
  // Deprecated field cleanup (2026-08-21): advisor.enabled is no longer read
13
18
  // anywhere; drop it here so persist() never re-writes the stale flag.
14
19
  delete cfg.enabled
@@ -17,13 +22,20 @@ export async function handleAdvisorCommand(ctx) {
17
22
  if (ctx.persistRaw) {
18
23
  await ctx.persistRaw((raw) => {
19
24
  raw.agent ??= {}
20
- raw.agent.advisor = cfg
25
+ // advisor.guard is session-level (#382 同口径收 · slot authority — VSC parity): the guard
26
+ // never rides the config write — only the config-scoped keys (model/thinking/effort/timeout)
27
+ // persist;盘上既有 guard 值原样保留(回退面不动 —— 读链 = 槽 ?? config)。
28
+ const scoped = { ...cfg }
29
+ delete scoped.guard
30
+ const diskAdvisor = typeof raw.agent.advisor === "object" && raw.agent.advisor !== null ? raw.agent.advisor : {}
31
+ raw.agent.advisor = { ...diskAdvisor, ...scoped }
21
32
  })
22
33
  }
23
34
  }
24
35
 
25
- // Guard-only dual write (2026-08-29 — advisor.guard is session-level): the guard goes into
26
- // the CURRENT session slot first (shared with VS Code), the config.json mirror follows.
36
+ // Guard-only slot write (2026-08-29 — advisor.guard is session-level; #382 同口径收): the guard
37
+ // goes into the CURRENT session slot (shared with VS Code) — the slot is the single authority;
38
+ // no config.json mirror write (与 VSC 面板写面同判——guard 不入 config 写面).
27
39
  // Other advisor keys (model/thinking/effort/timeout) stay config-scoped — persist() only.
28
40
  const persistGuard = async () => {
29
41
  try {
@@ -33,19 +45,23 @@ export async function handleAdvisorCommand(ctx) {
33
45
  data.advisor = { ...(typeof data.advisor === "object" && data.advisor !== null ? data.advisor : {}), guard: cfg.guard === true }
34
46
  writeSessionFile(p, data)
35
47
  }
36
- } catch { /* slot missing/unreadable — config mirror still written */ }
37
- await persist()
48
+ } catch { /* slot missing/unreadable — guard stays in-memory for this session (zero config write) */ }
38
49
  }
39
50
 
40
51
  // Lazy model cache — fetched once per /advisor session
41
52
  let modelCache = null
42
53
 
43
54
  // ── State helpers ──
55
+ /** 状态行(§16.4):无有效 off 路径(`thinkOffPath === false`)⇒ **不报 off**——残留 off 标记
56
+ * (`null` / `{type:"disabled"}`)按「未设档」渲染:有档报 `on (<档>)`,无档报 `on`
57
+ * (服务端恒思考 / 形不发字段 ⇒ 「Think: off」会是假断言)。 */
44
58
  function advisorStatus() {
45
59
  const curModel = cfg.model || agent.provider.model
46
- const thinkInfo = cfg.thinking === null ? "off"
47
- : cfg.thinking?.type === "disabled" ? "off"
60
+ const offPath = thinkOffPath(specForModel(getEffectiveModel(agent, cfg)))
61
+ const offMarked = cfg.thinking === null || cfg.thinking?.type === "disabled"
62
+ const thinkInfo = offPath && offMarked ? "off"
48
63
  : cfg.reasoningEffort ? `on (${cfg.reasoningEffort})`
64
+ : offMarked ? "on"
49
65
  : cfg.thinking ? `on (${cfg.thinking.type})` : "(main)"
50
66
  return `Advisor | Model: ${curModel} | Think: ${thinkInfo}`
51
67
  }
@@ -55,12 +71,14 @@ export async function handleAdvisorCommand(ctx) {
55
71
  }
56
72
 
57
73
  /** off 形落盘(`think_off` 与归一后的 `effort_none` 共用——单一口径,防同状态两公式):
58
- * 自定义开值族(`thinkEnabledValue` ≠ "enabled")取 `null`(NF1 显式 off);其余取 `{type:"disabled"}`。 */
74
+ * 取形 = 单源 `thinkOffShape`(`@thincoder/core/think-off.mjs`;§15.4-2 族别判据 / §16.2 生产者表)——
75
+ * effort 族(`thinkApi === "effort"`)⇒ `thinking: null`(载荷层 off 门首款要求;`{type:"disabled"}`
76
+ * 不开门 ⇒ 关思考静默失效);其余(type 族默认 / 自定义开值族)⇒ `{type:"disabled"}`(达载荷层)。
77
+ * 三支同清 `reasoningEffort`(§16.6-⑵:off 标记与档位不得同盘——F2 违约族)。 */
59
78
  async function applyThinkOff() {
60
- const { specForModel } = await import("@thincoder/core/config.mjs")
61
79
  const spec = specForModel(getEffectiveModel(agent, cfg))
62
- const isCustomThink = (spec.thinkEnabledValue ?? "enabled") !== "enabled"
63
- cfg.thinking = isCustomThink ? null : { type: "disabled" }
80
+ cfg.thinking = thinkOffShape(spec)
81
+ delete cfg.reasoningEffort
64
82
  }
65
83
 
66
84
  // ── Model picker sub-loop ──
@@ -107,7 +125,6 @@ export async function handleAdvisorCommand(ctx) {
107
125
  pushLabel("❯ Advisor", ansi.bold + C.tool)
108
126
  pushLine("Thinking: using main model settings", C.tool)
109
127
  } else if (c.action === "think_on") {
110
- const { specForModel } = await import("@thincoder/core/config.mjs")
111
128
  const spec = specForModel(getEffectiveModel(agent, cfg))
112
129
  cfg.thinking = { type: spec.thinkEnabledValue ?? "enabled" }
113
130
  if (spec.thinkApi === "effort") delete cfg.thinking
@@ -129,6 +146,10 @@ export async function handleAdvisorCommand(ctx) {
129
146
  pushLabel("❯ Advisor", ansi.bold + C.tool)
130
147
  pushLine("Thinking: OFF", C.tool)
131
148
  } else if (c.action.startsWith("effort_")) {
149
+ // #346-①(§16.4 表末行):选档 = 要思考 ⇒ 先清 `thinking === null` 标记(对齐
150
+ // `cmd-think.mjs:119-120` 先例 / §15.4-2 末句);`{type:"disabled"}` 不动(同先例;
151
+ // 残余登记 = §16.6-⑸——type 族 off ⇒ 选档 后该标记保持)。
152
+ if (cfg.thinking === null) delete cfg.thinking
132
153
  cfg.reasoningEffort = c.action.slice(7)
133
154
  await persist()
134
155
  pushLabel("❯ Advisor", ansi.bold + C.tool)
@@ -247,6 +268,7 @@ async function buildThinkingEntries(agent, cfg) {
247
268
  const thinkOnValue = spec.thinkEnabledValue ?? "enabled"
248
269
  const isCustomThink = thinkOnValue !== "enabled"
249
270
  const isEffortOnly = spec.thinkApi === "effort"
271
+ const offPath = thinkOffPath(spec)
250
272
  const effortLevels = spec.reasoningEffortEnum ?? ["high", "max"]
251
273
 
252
274
  const curEffort = cfg.reasoningEffort ?? providerForDefaults.reasoningEffort
@@ -260,7 +282,11 @@ async function buildThinkingEntries(agent, cfg) {
260
282
  if (!isEffortOnly) {
261
283
  entries.push({ type: "header", text: "Thinking mode" })
262
284
  entries.push({ type: "item", text: `Enabled ${thinkingEnabled ? "← current" : ""}`, action: "think_on" })
263
- entries.push({ type: "item", text: `Disabled ${(curThinking?.type === "disabled" || curThinking === null) ? "← current" : ""}`, action: "think_off" })
285
+ // §16.4:无有效 off 路径(服务端强制族 / 形不发字段)⇒ Disabled 项**不渲染**;
286
+ // Enabled 项保留 = 残留 `{type:"disabled"}` 标记的恢复径(`/think on` 同旨)。
287
+ if (offPath) {
288
+ entries.push({ type: "item", text: `Disabled ${(curThinking?.type === "disabled" || curThinking === null) ? "← current" : ""}`, action: "think_off" })
289
+ }
264
290
  }
265
291
  entries.push({ type: "header", text: "Reasoning effort" })
266
292
  for (const level of effortLevels) {
@@ -9,14 +9,14 @@ export async function handleClearCommand(ctx) {
9
9
  ], { defaultIndex: 1 })
10
10
  if (e?.action === "yes") {
11
11
  state.lines = []
12
- state._linesChars = 0 // TUI-OOM-ROOTCAUSE(§15.3.2):行集清空 → 字符账同步归零
12
+ state._linesChars = 0 // TUI-OOM-ROOTCAUSE(TUI-SESSION-VIEW.md §5.3):行集清空 → 字符账同步归零
13
13
  state.streaming = ""
14
14
  render()
15
15
  }
16
16
  return
17
17
  }
18
18
  state.lines = []
19
- state._linesChars = 0 // TUI-OOM-ROOTCAUSE(§15.3.2):行集清空 → 字符账同步归零
19
+ state._linesChars = 0 // TUI-OOM-ROOTCAUSE(TUI-SESSION-VIEW.md §5.3):行集清空 → 字符账同步归零
20
20
  state.streaming = ""
21
21
  render()
22
22
  }
@@ -35,6 +35,7 @@ export async function handleConfigCommand(ctx, args = []) {
35
35
  let ac = agent.config?.agent ?? {}
36
36
  let ec = agent.config?.embedding ?? {}
37
37
  let tc = agent.config?.traces ?? {}
38
+ let dc = agent.config?.diagnostics ?? {}
38
39
 
39
40
  // agent.config.proxy 已被 loadConfig 归一化为 { uri, web, model } | undefined
40
41
  function proxySummary() {
@@ -250,7 +251,7 @@ export async function handleConfigCommand(ctx, args = []) {
250
251
  }
251
252
  }
252
253
 
253
- // ── 并发池子菜单(§11.1/§11.2——agent.poolLimits——读/改三域;保存经 saveProxy
254
+ // ── 并发池子菜单(agent.poolLimits——读/改三域;保存经 saveProxy
254
255
  // 落盘 + reloadConfig 热应用——下个 spawn 生效)──
255
256
  async function poolMenu() {
256
257
  let poolIdx = 0
@@ -341,6 +342,7 @@ export async function handleConfigCommand(ctx, args = []) {
341
342
  ac = agent.config?.agent ?? {}
342
343
  ec = agent.config?.embedding ?? {}
343
344
  tc = agent.config?.traces ?? {}
345
+ dc = agent.config?.diagnostics ?? {}
344
346
  const consultCount = (ac.consultModels ?? []).length
345
347
  const pl = ac.poolLimits ?? {}
346
348
  const cur = poolCur(pl, DEFAULTS)
@@ -354,6 +356,7 @@ export async function handleConfigCommand(ctx, args = []) {
354
356
  { type: "item", text: `agent.verifyGuard = ${ac.verifyGuard === true ? "on" : "off"}`, action: "agent.verifyGuard" },
355
357
  { type: "item", text: `traces.enabled = ${tc.enabled === false ? "off" : "on"}(轨迹存档——发布默认关——隐私)`, action: "traces.enabled" },
356
358
  { type: "item", text: `traces.retentionHours = ${tc.retentionHours ?? 24} h(超期文件启动时清理)`, action: "traces.retentionHours" },
359
+ { type: "item", text: `diagnostics.heapSnapshot = ${dc.heapSnapshot === true ? "on" : "off"}(堆快照采集——默认关——桌面会话即时生效)`, action: "diagnostics.heapSnapshot" },
357
360
  { type: "item", text: `agent.consultModels = ${consultCount} model(s)${consultCount ? ` (${(ac.consultModels ?? []).map((m) => m.provider + ":" + m.model).join(", ")})` : ""}`, action: "consult" },
358
361
  { type: "item", text: `agent.consultTurns = ${ac.consultTurns ?? 40}`, action: "agent.consultTurns" },
359
362
  { type: "item", text: `agent.consultTimeoutMs = ${Math.round((ac.consultTimeoutMs ?? 600000) / 60000)} min`, action: "agent.consultTimeoutMs" },
@@ -377,6 +380,7 @@ export async function handleConfigCommand(ctx, args = []) {
377
380
  pushLine(`agent.verifyGuard: ${ac.verifyGuard === true ? "on" : "off"}`, C.dim)
378
381
  pushLine(`traces.enabled: ${tc.enabled === false ? "off" : "on"}(默认 off——发布隐私——本地分析可开)`, C.dim)
379
382
  pushLine(`traces.retentionHours: ${tc.retentionHours ?? 24}(超期文件启动清理——D-TR10)`, C.dim)
383
+ pushLine(`diagnostics.heapSnapshot: ${dc.heapSnapshot === true ? "on" : "off"}(堆快照采集——默认关——桌面会话即时生效)`, C.dim)
380
384
  pushLine(`agent.consultModels: ${(ac.consultModels ?? []).map((m) => `${m.provider}:${m.model}${m.effort ? ` (${m.effort})` : ""}`).join(", ") || "(none)"}`, C.dim)
381
385
  pushLine(`agent.poolLimits: { engCoder: ${cur("engCoder")}, other: ${cur("other")}, advisor: ${cur("advisor")} }(async 分域上限——默认 4/4/4——agent.poolLimits 可配)`, C.dim)
382
386
  pushLine(`agent.consultTurns: ${ac.consultTurns ?? 40}`, C.dim)
@@ -437,6 +441,18 @@ export async function handleConfigCommand(ctx, args = []) {
437
441
  continue // 回主菜单(Esc 退出)
438
442
  }
439
443
 
444
+ if (choice.action === "diagnostics.heapSnapshot") {
445
+ const newVal = dc.heapSnapshot !== true // 显式写布尔(默认关——fail-closed 显示判定同向)
446
+ try {
447
+ await saveProxy((raw) => {
448
+ raw.diagnostics = { ...(raw.diagnostics ?? {}), heapSnapshot: newVal }
449
+ })
450
+ pushLabel("❯ Config", ansi.bold + C.tool)
451
+ pushLine(`diagnostics.heapSnapshot = ${newVal ? "on" : "off"}(桌面会话即时生效 ∕ 本进程下次启动)`, C.tool)
452
+ } catch (error) { pushLine(`Save failed: ${error.message}`, C.error) }
453
+ continue // 回主菜单(Esc 退出)
454
+ }
455
+
440
456
  // Embedding model is fixed (BAAI/bge-m3, SiliconFlow) — no picker; it's over-engineering
441
457
  // to expose model choice when the vector index format assumes one embedding space.
442
458
 
@@ -52,7 +52,7 @@ export async function handleEngCommand(ctx) {
52
52
  // FR31 ③ / KD10(翻转点②·CLI `/eng`):ON ⇒ 清 plan 残留(内存位 + 未注入的 plan 提示语);
53
53
  // OFF 方向不动(普通模式零改——FR31 边界)。提示行仅在真清到东西时出(不喧宾)。
54
54
  const planReset = agent.config.agent.engineering ? clearPlanMode(agent) : false
55
- // §11.2 D-24b: per-review instances die with the mode (fresh convergence cycles
55
+ // AGENT-LOOP-ASYNC-POOL.md §6.10 D-24b: per-review instances die with the mode (fresh convergence cycles
56
56
  // on the next toggle).
57
57
  agent._advisorRuns = new Map()
58
58
  if (!agent.config.agent.engineering) {
@@ -181,7 +181,7 @@ Return ONLY the JSON object:`,
181
181
  }],
182
182
  tools: [],
183
183
  signal: AbortSignal.timeout(15_000),
184
- // §18.6 D-TR4/D-TR6(2026-09-04 fix round1):/mcp ai 生成调用经 chat() 唯一
184
+ // TRACES.md §6.1 D-TR4/D-TR6(2026-09-04 fix round1):/mcp ai 生成调用经 chat() 唯一
185
185
  // 采集点——补轨迹元数据 + traces 开关透传(agent.config.traces.enabled——
186
186
  // 关=不落盘必须全覆盖——与 agent.mjs/context.mjs 同模式)
187
187
  logCtx: {
@@ -15,12 +15,12 @@ export async function handleNewCommand(ctx) {
15
15
  // slot(实锤 .19/.3 双副本)。_slot 更新为粘性新槽位。
16
16
  resetSessionState(agent)
17
17
  agent._slot = slot
18
- // §14.3.4:/new 绑定新槽记录存储(baseHistory 空——identity 此时为 null = 待固化;
18
+ // SESSION.md §6.14:/new 绑定新槽记录存储(baseHistory 空——identity 此时为 null = 待固化;
19
19
  // 首次保存/首条追加时补写现场身份)
20
20
  bindRecordStore(agent, { slotFile: slotPath(agent.cwd, slot), identity: agent._sessionStart ?? null, baseHistory: [] })
21
21
  state.tasks = []
22
22
  state.lines = []
23
- state._linesChars = 0 // TUI-OOM-ROOTCAUSE(§15.3.2):行集清空 → 字符账同步归零
23
+ state._linesChars = 0 // TUI-OOM-ROOTCAUSE(TUI-SESSION-VIEW.md §5.3):行集清空 → 字符账同步归零
24
24
  state.streaming = ""
25
25
  render()
26
26
  pushLine(`New session started (slot ${slot}; /session to switch back)`, C.dim)
@@ -45,7 +45,7 @@ export async function handleReindexCommand(ctx) {
45
45
  const unlisted = cr?.unlistedExts?.count ? cr.unlistedExts : dr?.unlistedExts
46
46
  if (unlisted?.count > 0) {
47
47
  const sample = unlisted.exts.map((e) => e.ext).join(" ")
48
- pushLine(` ${unlisted.count} file(s) with unlisted extension(s) skipped (${sample}) — declare them in .thincoder/conventions.json (index.codeExtensions / index.docExtensions) to index them`, C.warn)
48
+ pushLine(` ${unlisted.count} file(s) with unlisted extension(s) skipped (${sample}) — declare them in PROJECT-MANIFEST.json (index.codeExtensions / index.docExtensions) to index them`, C.warn)
49
49
  }
50
50
  pushLine(`[reindex] Done, ${total} entries total. Vectors will be lazily generated on next search.`, C.tool)
51
51
  }
@@ -1,4 +1,4 @@
1
- import { listSlots, switchToSlot, applySession, renameSlot, activeSlot, slotOccupancy, readEndMarker, sessionDescriptor, loadSlotFile } from "@thincoder/core/session.mjs"
1
+ import { listSlots, switchToSlot, applySession, renameSlot, activeSlot, slotOccupancy, readEndMarker, sessionDescriptor, loadSlotFile, ledgerHealth } from "@thincoder/core/session.mjs"
2
2
  import { resolveEngineeringManifest } from "@thincoder/core/manifest.mjs"
3
3
  import { ansi, C } from "./ansi.mjs"
4
4
  import { restoreLines } from "./startup.mjs"
@@ -20,7 +20,7 @@ export async function handleRenameCommand(ctx, args) {
20
20
  pushLine(`Title too long (max 80 chars)`, C.error)
21
21
  return
22
22
  }
23
- // §12.2.5:renameSlot 契约 { ok, reason }——失败原因对用户可见(F3)
23
+ // renameSlot 契约 { ok, reason }——失败原因对用户可见(F3)
24
24
  const r = renameSlot(agent.cwd, slot, title)
25
25
  if (!r.ok) {
26
26
  const why = {
@@ -58,7 +58,14 @@ export async function handleSessionCommand(ctx) {
58
58
  return `${dt.getMonth() + 1}/${dt.getDate()} ${String(dt.getHours()).padStart(2, "0")}:${String(dt.getMinutes()).padStart(2, "0")}`
59
59
  }
60
60
  const truncate = (s, n) => (stringWidth(s) <= n ? s : sliceByWidth(s, Math.max(1, n - 1)) + "…")
61
+ // F-L4(账本可靠批 · SESSION.md §6.25 判据句 4):账本异常 ⇒ 列表头部警示行(正常 ⇒ 一行不加
62
+ // ——负断言);在场条件 = `refused > 0 ∨ scene`(不继承会话计数条件)。文案 = 三端同句(zh 在册字面)。
63
+ const lh = ledgerHealth(agent.cwd)
64
+ const ledgerWarn = (lh.refused > 0 || lh.scene)
65
+ ? `会话账本异常(${lh.refused > 0 ? lh.lastReason : "scene"})——打开会话即自动补回${lh.scene ? ";损坏现场档保留 30 天" : ""}`
66
+ : null
61
67
  const entries = [
68
+ ...(ledgerWarn ? [{ type: "header", text: ledgerWarn }] : []),
62
69
  { type: "header", text: `Sessions (● = active, ↑↓ select, Enter switch, Esc cancel; /rename <title> renames the active one)` },
63
70
  ...slots.map((s) => {
64
71
  // 2026-08-31 会诊:行宽必须按显示宽度截断(原按 UTF-16 length 截 40 = 中文 80 格,
@@ -66,7 +73,8 @@ export async function handleSessionCommand(ctx) {
66
73
  // wrap → 残影);title 此前完全不截断(可任意长)也是超宽源,一并宽度截断。
67
74
  const rawLabel = s.title || (s.firstMessage ? `"${s.firstMessage}"` : "(empty)")
68
75
  const label = truncate(rawLabel, 36)
69
- const turns = s.turnCount > 0 ? `${s.turnCount} turns` : "0 turns"
76
+ // 计数不可得(`null`)⇒ 「— turns」占位(禁显示 0——真 0 与未知可分;§6.25 判据句 4)
77
+ const turns = s.turnCount == null ? "— turns" : `${s.turnCount} turns`
70
78
  const when = shortDate(s.updatedAt)
71
79
  const model = s.activeProvider ? ` — ${s.activeProvider}` : ""
72
80
  const marker = s.slot === highlightSlot ? " ●" : ""
@@ -114,11 +122,11 @@ export async function handleSessionCommand(ctx) {
114
122
  // #41 准翻列:附着动作 = 翻转面自己赋值(`agent.manifest` ← 判据结果——相位行当回合起活)。
115
123
  if (manifestAfterSwitch) agent.manifest = manifestAfterSwitch
116
124
  // Rebuild from history (lazy) — the display snapshot is deprecated.
117
- // §14.3.6:描述符 { history(尾窗+±1), total, base }——标签口径 = total(非窗口长度);
125
+ // SESSION.md §6.14:描述符 { history(尾窗+±1), total, base }——标签口径 = total(非窗口长度);
118
126
  // 未绑定(占用分支——模式 F)回退全量数组。
119
127
  const desc = sessionDescriptor(agent, data)
120
128
  state.lines = []
121
- state._linesChars = 0 // TUI-OOM-ROOTCAUSE(§15.3.2):切槽重建行集 → 字符账归零,restoreLines 重新入账
129
+ state._linesChars = 0 // TUI-OOM-ROOTCAUSE(TUI-SESSION-VIEW.md §5.3):切槽重建行集 → 字符账归零,restoreLines 重新入账
122
130
  restoreLines(state, desc)
123
131
  state.tasks = agent.tasks ?? []
124
132
  if (state.tasks.length > 0 && state.tasks.every((t) => t.status === "done")) {
@@ -2,6 +2,9 @@
2
2
  * Interactive loop UX — stays in menu after each action, Esc to exit.
3
3
  * ctx: { agent, showPicker, syncProviderField, pushLine, pushLabel } */
4
4
  import { ansi, C } from "./ansi.mjs"
5
+ // off 形族单源(`docs/core/design/MODEL-SPECS.md` §16.2 / §16.4)——取形与「有效 off 路径」判据
6
+ // 一律自本档取,本档零副本(叶档:零 import 纯函数)。
7
+ import { thinkOffShape, thinkOffPath } from "@thincoder/core/think-off.mjs"
5
8
 
6
9
  export async function handleThinkCommand(ctx, args = []) {
7
10
  const { agent, showPicker, syncProviderField, pushLine, pushLabel } = ctx
@@ -14,11 +17,26 @@ export async function handleThinkCommand(ctx, args = []) {
14
17
  const thinkOnValue = spec.thinkEnabledValue ?? "enabled"
15
18
  const isCustomThink = thinkOnValue !== "enabled"
16
19
  const effortLevels = spec.reasoningEffortEnum ?? ["high", "max"]
20
+ // 有效 off 路径(判据单源 = `@thincoder/core/think-off.mjs`;§16.4)——无 ⇒ 不提供 / 不宣称 off。
21
+ const offPath = thinkOffPath(spec)
22
+
23
+ /** 环内头与回执共用的状态式(§16.4):无有效 off 路径 ⇒ 恒报 ON——残留 off 标记(`null` /
24
+ * `{type:"disabled"}`)不生效(服务端恒思考 / 形不发字段),「Thinking: OFF」会是假断言。
25
+ * `thinking:null` 是显式 off 标记(NF1 约定)——不得落入"未设置即 ON"的默认显示(评审 #3);
26
+ * 用 `!== null` 而非 `!= null`:undefined(从未设置)须保持既有 ON 显示(qwen3.x 服务端默认开思考)。 */
27
+ const thinkingOn = () => !offPath || cur.thinking?.type === thinkOnValue
28
+ || (cur.thinking !== null && cur.thinking?.type === undefined && !isCustomThink)
17
29
 
18
30
  const autoThinkEnabled = agent.config?.agent?.autoThink === true
19
31
  const sub = args[0]?.toLowerCase()
20
32
  if (sub === "on" || sub === "off") {
21
33
  if (autoThinkEnabled) { pushLine("Auto-think is ON — manual settings are overridden each turn; turn Auto off first via /think", C.error); return }
34
+ // §16.4:无有效 off 路径 ⇒ 拒绝该动作(不写盘)——服务端恒思考 / 形不发字段,供一个不生效
35
+ // 的入口 + 措辞免责 = 半实现(§16.12 项 3)。`/think on` 与档位面零改。
36
+ if (sub === "off" && !offPath) {
37
+ pushLine("No off path for this model — thinking cannot be turned off here (use /think effort <level>)", C.error)
38
+ return
39
+ }
22
40
  await applyThink({ action: sub }, agent, syncProviderField, spec, isEffortOnly, isCustomThink, thinkOnValue)
23
41
  pushLabel("❯ Think", ansi.bold + C.tool)
24
42
  pushLine(`Thinking: ${sub}`, C.tool)
@@ -43,16 +61,16 @@ export async function handleThinkCommand(ctx, args = []) {
43
61
  let mainIdx = 0
44
62
  for (;;) {
45
63
  const autoOn = agent.config?.agent?.autoThink === true
46
- // thinking:null 是显式 off 标记(NF1 约定)——不得落入"未设置即 ON"的默认显示(评审 #3)。
47
- // 用 !== null 而非 != null:undefined(从未设置)须保持既有 ON 显示(qwen3.x 服务端默认开思考)。
48
- const thinkingEnabled = cur.thinking?.type === thinkOnValue
49
- || (cur.thinking !== null && cur.thinking?.type === undefined && !isCustomThink)
64
+ // 头 / 回执同式(`thinkingOn` 单口径)——状态与回执不得各自成式。
65
+ const thinkingEnabled = thinkingOn()
50
66
 
51
67
  const entries = [
52
68
  { type: "header", text: `Auto: ${autoOn ? "ON" : "OFF"} | Thinking: ${thinkingEnabled ? "ON" : "OFF"} | Effort: ${cur.reasoningEffort || "—"}` },
53
69
  { type: "item", text: `Auto: ${autoOn ? "ON" : "OFF"}`, action: "auto" },
54
70
  ]
55
- if (!isEffortOnly && !autoOn) {
71
+ // Thinking 开关项:非 effort 族 ∧ 有有效 off 路径才渲染(§16.4——无有效 off 路径族不提供该动作,
72
+ // 入口在位 = 对无效操作的假告知)。
73
+ if (!isEffortOnly && offPath && !autoOn) {
56
74
  entries.push({ type: "item", text: `Thinking: ${thinkingEnabled ? "ON" : "OFF"}`, action: thinkingEnabled ? "off" : "on" })
57
75
  }
58
76
  if (!autoOn) {
@@ -66,10 +84,6 @@ export async function handleThinkCommand(ctx, args = []) {
66
84
  if (!e) return // Esc
67
85
  mainIdx = Math.max(0, entries.filter((en) => en.type === "item").indexOf(e))
68
86
 
69
- const prevAuto = autoOn
70
- const prevThinking = thinkingEnabled
71
- const prevEffort = cur.reasoningEffort
72
-
73
87
  await applyThink(e, agent, syncProviderField, spec, isEffortOnly, isCustomThink, thinkOnValue)
74
88
 
75
89
  // Feedback
@@ -81,11 +95,9 @@ export async function handleThinkCommand(ctx, args = []) {
81
95
  } else if (e.action === "effort") {
82
96
  pushLine(e.level === "none" ? "Thinking: OFF" : `Reasoning effort: ${e.level}`, C.tool)
83
97
  } else {
84
- // 与菜单头(:47-48)同式:`thinking:null`(NF1 显式 off)必须落 OFF——本式缺 `!== null` 守卫时
85
- // effort 型 off 后回执误报 ON(同一状态、两处公式——单一口径 = 面板式)。
86
- const nowEnabled = cur.thinking?.type === thinkOnValue
87
- || (cur.thinking !== null && cur.thinking?.type === undefined && !isCustomThink)
88
- pushLine(`Thinking: ${nowEnabled ? "ON" : "OFF"}`, C.tool)
98
+ // 与菜单头(`thinkingOn`)同式:`thinking:null`(NF1 显式 off)落 OFF;无有效 off 路径族
99
+ // 恒报 ON(§16.4——残留 off 标记不生效;本式缺守卫时 effort 型 off 后回执误报 ON = 红)。
100
+ pushLine(`Thinking: ${thinkingOn() ? "ON" : "OFF"}`, C.tool)
89
101
  }
90
102
  }
91
103
  }
@@ -93,7 +105,9 @@ export async function handleThinkCommand(ctx, args = []) {
93
105
  /** Shared apply logic — extracted from handleThinkCommand for reuse in both fast path and loop.
94
106
  * Named export (2026-09-20): the on-default-effort rule (§2.8) is unit-tested by driving this
95
107
  * directly with a synthetic spec — behavior otherwise unchanged.
96
- * Effort 档位 `"none"` = 关思考 ⇒ 入口归一到 off 路径(与菜单 off 项同形——见内注释)。 */
108
+ * Effort 档位 `"none"` = 关思考 ⇒ 入口归一到 off 路径(与菜单 off 项同形——见内注释)。
109
+ * `spec` 同时供 off 取形(单源 = `@thincoder/core/think-off.mjs`);`isCustomThink` 参数为调用面
110
+ * 既有形状保留(off 支取形已归单源,本档不再自行分族)。 */
97
111
  export async function applyThink(e, agent, syncProviderField, spec, isEffortOnly, isCustomThink, thinkOnValue) {
98
112
  const cur = agent.provider
99
113
  // `effort` 档位取 `"none"` = **关思考**(非“强度零”):与本面 off 动作同语义 ⇒ 归一到 off 路径
@@ -124,7 +138,8 @@ export async function applyThink(e, agent, syncProviderField, spec, isEffortOnly
124
138
  if (isEffortOnly) {
125
139
  // Explicit off persists thinking:null (NF1 convention — distinguishable from autoThink's
126
140
  // delete/undefined); "on" deletes the marker so enable_thinking maps from effort again.
127
- if (!enable) { cur.thinking = null; delete cur.reasoningEffort }
141
+ // off 取形同走单源(`thinkOffShape`——effort 族 ⇒ `null`;本支与下方非 effort 支同源零副本)。
142
+ if (!enable) { cur.thinking = thinkOffShape(spec); delete cur.reasoningEffort }
128
143
  // "on" 默认 effort 取 spec 枚举的**首个非 "none"** 档(批次档 §1.8-② / 设计 §2.8 D-9):硬编码 "high"
129
144
  // 对 qwen3.8-max(enum xhigh/medium/low)无效,会被 core.mjs 枚举校验 throw(400 前置);而直接取枚举
130
145
  // 首项在新档(首项 "none")等于把思考关掉 ⇒ 跳过 "none";枚举全 "none" 时与无枚举同型 ⇒ 回退 "high"
@@ -137,7 +152,10 @@ export async function applyThink(e, agent, syncProviderField, spec, isEffortOnly
137
152
  cur.thinking = { type: thinkOnValue }
138
153
  if (!cur.reasoningEffort) cur.reasoningEffort = "high"
139
154
  } else {
140
- cur.thinking = isCustomThink ? undefined : { type: "disabled" }
155
+ // off 取形 = 单源 `thinkOffShape`(§15.4-2 / §16.2 生产者表第 2 行):非 effort 族 ⇒
156
+ // `{type:"disabled"}`——自定义开值族随 §15.4-2 表改判由 `undefined`(不发字段 = 服务端
157
+ // 默认 = 想)改本形(达载荷层 = 真 off)。
158
+ cur.thinking = thinkOffShape(spec)
141
159
  delete cur.reasoningEffort
142
160
  }
143
161
  await syncProviderField(agent.activeProvider, "thinking", cur.thinking)
@@ -0,0 +1,33 @@
1
+ /**
2
+ * cmd-timers.mjs — `/timers`:在途 timer 只读列表(最薄)。
3
+ *
4
+ * 机制单源 = `docs/core/design/AGENT-LOOP-ASYNC-POOL.md` §6.30;显示形态单源 = `docs/cli/design/TUI.md`
5
+ * §7.6(逐条 dim 行 `⏰<i> · 剩余 <mm:ss> · <message 首行(截断)>`;零在途 ⇒ 一行 `无在途 timer`)。
6
+ * **取消面不做**(取消 = 控制面——门 / 回执 / 持久化语义;停轮 = Ctrl+C,停机制 = `agent.timerWake` 关)。
7
+ * 取值 = `agent._pendingTimers` 活读(每次调用现算——零缓存);剩余按 `expiresAt - now` 现算。
8
+ */
9
+ import { C } from "./ansi.mjs"
10
+ import { sanitizeDisplay, sliceByWidth, stringWidth } from "./render.mjs"
11
+
12
+ /** 剩余 mm:ss(到期未送达 ⇒ 夹到 `00:00`——到期态另由状态行警示色承载,见 TUI.md §7.6)。 */
13
+ function remainText(expiresAt, now) {
14
+ const sec = Math.max(0, Math.ceil((expiresAt - now) / 1000))
15
+ return `${String(Math.floor(sec / 60)).padStart(2, "0")}:${String(sec % 60).padStart(2, "0")}`
16
+ }
17
+
18
+ /** message 首行(截断——60 显示列 + `…`,同会话标题段口径)。 */
19
+ function headText(message) {
20
+ const head = sanitizeDisplay(String(message ?? "").split("\n")[0])
21
+ return stringWidth(head) > 60 ? sliceByWidth(head, 59) + "…" : head
22
+ }
23
+
24
+ export async function handleTimersCommand(ctx) {
25
+ const { agent, pushLine } = ctx
26
+ const timers = agent?._pendingTimers ?? []
27
+ if (timers.length === 0) {
28
+ pushLine("无在途 timer", C.dim)
29
+ return
30
+ }
31
+ const now = Date.now()
32
+ timers.forEach((t, i) => pushLine(`⏰${i + 1} · 剩余 ${remainText(t.expiresAt, now)} · ${headText(t.message)}`, C.dim))
33
+ }
@@ -11,7 +11,7 @@ import { shiftFreezeAnchors } from "./subagent-blocks.mjs"
11
11
  * assistantLabeled: boolean }} */
12
12
  export function createConversationWriter({ state, render }) {
13
13
  const pushLine = (text, color, kind) => {
14
- // TUI-OOM-ROOTCAUSE(TUI.md §15.3.1/§15.3.3):行额度单点——行文本过 capLine
14
+ // TUI-OOM-ROOTCAUSE(TUI-SESSION-VIEW.md §5.1/§5.3):行额度单点——行文本过 capLine
15
15
  // (LINE_MAX_CHARS——单行巨内容/无换行巨 chunk 的堵口)+ 总量账 + 预算对账。
16
16
  const line = { text: capLine(text), color, _kind: kind }
17
17
  state.lines.push(line)
@@ -31,7 +31,7 @@ export function createConversationWriter({ state, render }) {
31
31
 
32
32
  /** Message block label: blank line + label line. Breathing space between user/assistant messages */
33
33
  const pushLabel = (text, color) => {
34
- // 行额度同 pushLine(§15.3.1——恢复行/标签行同口径)
34
+ // 行额度同 pushLine(§5.1——恢复行/标签行同口径)
35
35
  if (state.lines.length > 0) {
36
36
  const blank = { text: "", color: C.dim }
37
37
  state.lines.push(blank)