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,15 +1,15 @@
1
1
  /**
2
- * display-budget.mjs — TUI 显示层额度(TUI-OOM-ROOTCAUSE 批——TUI.md §15.3)。
2
+ * display-budget.mjs — TUI 显示层额度(TUI-OOM-ROOTCAUSE 批——TUI-SESSION-VIEW.md §5)。
3
3
  *
4
- * 职责(§15.3.2):常量单源(D-TB4)+ 行/载体计长(`lineChars`)+ `state.lines` 总量
4
+ * 职责(§5.1/§5.2):常量单源(D-TB4)+ 行/载体计长(`lineChars`)+ `state.lines` 总量
5
5
  * 对账(`syncLineBudget`)。纯函数本体(`capText` / `appendCappedText`)住
6
- * `thincoder-core/text-budget.mjs`(零依赖)——与 agent 侧捕获共用(AGENT-LOOP.md §23.3.1,D2 单源);
6
+ * `thincoder-core/text-budget.mjs`(零依赖)——与 agent 侧捕获共用(AGENT-LOOP.md §6.15,D2 单源);
7
7
  * 本模块只承载 TUI 面常量与对账。
8
8
  *
9
9
  * 依赖方向:本模块只引 `./ansi.mjs` + `@thincoder/core/text-budget.mjs`(叶子——不引 subagent 族,
10
10
  * 冻结锚点平移由调用方经 `onTrim` 注入,避环)。
11
11
  *
12
- * 计量口径 = UTF-16 码元(`String.length`——TUI.md §15.2 表 1);行数维(5000 行环 /
12
+ * 计量口径 = UTF-16 码元(`String.length`——TUI-SESSION-VIEW.md §5.1);行数维(5000 行环 /
13
13
  * 500 行块环 / 200 条目环)**原样保留**,字符维为第二维(D-TB5——既有 N5/N6 语义不动)。
14
14
  */
15
15
  import { C } from "./ansi.mjs"
@@ -17,7 +17,7 @@ import { capText, appendCappedText, fillMarker } from "@thincoder/core/text-budg
17
17
 
18
18
  export { capText }
19
19
 
20
- // ─── 常量(§15.3.1——单源;数值改动一处生效)────────────────────────────────
20
+ // ─── 常量(§5.1——单源;数值改动一处生效)────────────────────────────────
21
21
 
22
22
  /** 单行(pushLine/pushLabel/恢复行/流式 flush 行)字符额度。 */
23
23
  export const LINE_MAX_CHARS = 64_000
@@ -42,7 +42,7 @@ export const LINES_TRIM_FLOOR = 200
42
42
  /** 搜索匹配计数上限(超出截断 + 提示行)。 */
43
43
  export const SEARCH_MATCH_CAP = 10_000
44
44
 
45
- // ─── 标记形态(逐字——§15.3.2 进测试断言)──────────────────────────────────
45
+ // ─── 标记形态(逐字——§5.1 进测试断言)──────────────────────────────────
46
46
 
47
47
  export const LINE_TRUNC_MARKER = "… [line truncated: N chars omitted]"
48
48
  export const MIDDLE_TRUNC_MARKER = "… [middle truncated: N chars omitted]"
@@ -76,7 +76,7 @@ export function appendCapped(prev, add, opts) {
76
76
  return appendCappedText(prev, add, opts)
77
77
  }
78
78
 
79
- /** 评审文本单次截断(§15.3.1:头 32K + 尾 96K 保裁决尾部 + 中段标记——与累积侧同源)。 */
79
+ /** 评审文本单次截断(§5.1:头 32K + 尾 96K 保裁决尾部 + 中段标记——与累积侧同源)。 */
80
80
  export function capAdvisorText(text) {
81
81
  return capText(text, { max: ADVISOR_TEXT_MAX_CHARS, keepHead: 32_000, keepTail: 96_000, marker: MIDDLE_TRUNC_MARKER })
82
82
  }
@@ -109,7 +109,7 @@ export function capLines(lines, max, marker = MIDDLE_TRUNC_MARKER) {
109
109
  return out
110
110
  }
111
111
 
112
- // ─── 计长与总量对账(§15.3.2 / §15.3.4)───────────────────────────────────
112
+ // ─── 计长与总量对账(§5.2 / §5.4)───────────────────────────────────
113
113
 
114
114
  const arrChars = (a) => {
115
115
  if (!Array.isArray(a)) return 0
@@ -166,7 +166,7 @@ export function accountAll(state) {
166
166
  const RECEIPT_RESERVE_CHARS = 96
167
167
 
168
168
  /**
169
- * `state.lines` 总量对账(§15.3.3 落点表末行):超 `LINES_CHAR_BUDGET` → **最小步进**裁头 +
169
+ * `state.lines` 总量对账(§5.3 落点表末行):超 `LINES_CHAR_BUDGET` → **最小步进**裁头 +
170
170
  * 收据行,且不使 `lines.length` 低于 `LINES_TRIM_FLOOR`(触底 = 内层 break——接受超额,
171
171
  * 不靠守卫退出:FLOOR+1 且残余超额时守卫仍为真)。每轮裁「至额度内所需最少行数」
172
172
  * (need = 超额 + 收据上界预留;上限 = 可裁至保底的行数);`onTrim(state, removedCount)` 由
@@ -7,7 +7,7 @@
7
7
  * 最高占屏 60%,底控件必落在视口内,展开永远可逆。
8
8
  *
9
9
  * 消费方(render-conversation.mjs 六处折叠点):
10
- * 1. 子agent 活动区块(运行中 / 冻结,AGENT-LOOP §7.2 D4)
10
+ * 1. 子agent 活动区块(运行中 / 冻结,docs/cli/design/TUI.md §6.8)
11
11
  * 2. advisor 评审块(运行中 _advisorBlocks / 冻结 _frozenAdvisor)
12
12
  * 3. 长消息折叠(long-N)/ 连续 dim 折叠(fold-N)
13
13
  *
package/src/tui/index.mjs CHANGED
@@ -42,28 +42,45 @@ import { createTuiState } from "./tui-state.mjs"
42
42
  import { createInputFace } from "./input-face.mjs"
43
43
  import { createConversationWriter } from "./conversation-writer.mjs"
44
44
  import { createTurnFace } from "./turn-face.mjs"
45
+ import { createTimerWatch, fireTimerWake } from "./timer-watch.mjs"
45
46
 
46
47
  export { upgradeFailureText, pendingNoticeReady } from "./update-notice.mjs"
47
48
 
48
49
  /**
49
- * SESSION.md §6.8 D-S2 — TUI 启动首帧前的 provider 重选流程:
50
- * provider 无效(`_providerInvalid` 标记或 provider 为 null)→ 先弹模型选择 picker
51
- * (复用 openModelPicker,展示当前可用 providers);用户选定后继续正常启动。
52
- * 选择取消(Esc)→ 仍进入 TUI,推送提示行("未配置有效 provider,可用 /model 配置
53
- * 渠道与模型")——绝不因无 provider 拒绝进入。返回 true 表示弹过选择流程。
50
+ * SESSION.md §6.8 D-S2 — TUI 启动首帧前的 provider 分流(判据单源 = PROVIDER.md §6.22):
51
+ * · **invalid 类**(`state === "invalid"` ∨ `providerInvalidReason` 非空——核「provider 不完整面」
52
+ * 快照的合成式)→ 先弹模型选择 picker(复用 openModelPicker,展示当前可用 providers);
53
+ * 用户选定后继续正常启动。选择取消(Esc)→ 仍进入 TUI,推送提示行(措辞指渠道 ∕ 密钥——
54
+ * 绝不因无 provider 拒绝进入)。
55
+ * · **`fallback`**(无有效 defaultModel 但可运行)→ **不弹 picker**(不打断)+ 提示行明示
56
+ * (「尚未设置默认模型:本次使用 `<渠道>[:<模型>]`——…」;`model` 缺省 ⇒ 仅渠道名)。
57
+ * 返回 true 表示弹过选择流程。
54
58
  */
55
59
  export async function promptProviderIfInvalid(agent, openModelPicker, pushLine) {
56
- if (!(agent._providerInvalid || !agent.provider)) return false
57
- await openModelPicker()
58
- if (!agent.provider) {
59
- // MODEL-MERGE-SESSION 引导 A(F-6):空槽 + defaultModel 未设 → 提示 /config 默认模型入口
60
- // (index.mjs 本行为 D-S2 取消提示扩展——设计文件清单外——随批上报)
61
- const hasProviders = (agent.providers?.length ?? 0) > 0
62
- pushLine(hasProviders && !agent.config?.defaultModel
63
- ? "尚未设置默认模型(config.defaultModel——新会话起点):/config → 默认模型 设置一次;/model 仅改本会话"
64
- : "未配置有效 provider,可用 /model 配置渠道与模型", C.warn)
60
+ const cfg = agent.config ?? {}
61
+ // invalid 类(合成式——单源 = PROVIDER.md §6.22):`state === "invalid"` ∨ `providerInvalidReason`
62
+ // 非空(核 provider 不完整面快照);`!agent.provider`(`{}` 已被上游清成 null)同判。
63
+ // 结构不全(缺 baseURL 等)不在本门——归发送失败面(#840 `provider` 类词);负向锁 = PROVIDER.md:437。
64
+ const invalidClass = cfg.providerState === "invalid"
65
+ || (typeof cfg.providerInvalidReason === "string" && cfg.providerInvalidReason !== "")
66
+ || !agent.provider
67
+ if (invalidClass) {
68
+ await openModelPicker()
69
+ if (!agent.provider) {
70
+ // D-S2 取消提示(#841 措辞收正 = 渠道 ∕ 密钥——配置入口 /config)
71
+ pushLine("未配置有效渠道(渠道 ∥ 密钥缺位):/config 设置渠道与密钥;/model 仅改本会话", C.warn)
72
+ }
73
+ return true
65
74
  }
66
- return true
75
+ if (cfg.providerState === "fallback") {
76
+ const name = agent.provider?.name
77
+ if (name) {
78
+ const model = typeof agent.provider.model === "string" && agent.provider.model ? agent.provider.model : null
79
+ pushLine(`尚未设置默认模型:本次使用 \`${name}${model ? `:${model}` : ""}\`——/config → 默认模型 设置一次;/model 仅改本会话`, C.warn)
80
+ }
81
+ return false
82
+ }
83
+ return false
67
84
  }
68
85
 
69
86
  /**
@@ -141,10 +158,26 @@ export async function startTUI(agent, opts = {}) {
141
158
  })
142
159
 
143
160
  // 回合面(turn-face.mjs,原址 :366):submit / interaction(权限 + 提问)/ 图片粘贴 / turnCtx / turn
144
- const { submit, turnCtx, askPermission, askQuestion, pasteClipboardImage } = createTurnFace({
161
+ const { submit, turn, turnCtx, askPermission, askQuestion, pasteClipboardImage } = createTurnFace({
145
162
  agent, state, pushLine, pushLabel, render, ensureAssistantLabel, summarize, conversation,
146
163
  handleSlash: (t) => handleSlash(t),
147
164
  })
165
+ // timer 空闲自唤醒闩(AGENT-LOOP-ASYNC-POOL.md §6.30.5 载体②):装配点 = 回合面之后(`turn` 已就绪);
166
+ // 触发 ⇒ 到期批送达 + 开 timer 轮(顶层链——`{ autoTurn: true, timerTurn: true }`);
167
+ // 开关关(`agent.timerWake` false)⇒ 闩惰性(sync 内判——零注册)。
168
+ turnCtx.timerWatch = createTimerWatch({
169
+ agent,
170
+ // 火面异常兜底(先例 = key-handler `submit().catch(...)`):无人值守的空闲轮报错不得走
171
+ // 进程级 `unhandledRejection`(闩已自撤)——落错误行 + 重同步(在途项仍在 ⇒ 链尾/此处再武装)。
172
+ onFire: () => fireTimerWake(turnCtx, { runTurn: turn }).catch((e) => {
173
+ pushLine(`[error] ${e?.message ?? e}`, C.error)
174
+ turnCtx.timerWatch?.sync()
175
+ }),
176
+ })
177
+ // #448①「关闭后补评估」(KD-6):用户自发模态(picker ∕ wizard)全部退场 ⇒ 闩重武装——模态期被
178
+ // 抑制的到期件即达(无在途 / 开关关 ⇒ `sync()` 自判零注册——幂等)。两关闭点(picker 栈空 ∕
179
+ // wizard 退场)同注本回调;抑制本体 = `timer-watch.mjs` `fireTimerWake` 模态门(同谓词 `modalOpen`)。
180
+ const reevalTimerWake = () => { turnCtx.timerWatch?.sync() }
148
181
 
149
182
  // ---------------------------------------------------------- Slash Commands
150
183
 
@@ -154,12 +187,14 @@ export async function startTUI(agent, opts = {}) {
154
187
  // Model picker + generic picker: implemented in pickers.mjs
155
188
  const { closePicker, showPicker, popPicker, renderPickerLines, openModelPicker, selectModel, setProviderKey, pickModelForSlot, confirmDelete } = createPickers({
156
189
  agent, state, render, ansi, C, pushLine, pushLabel, persistRaw, askQuestion, maskKey,
190
+ onModalClose: reevalTimerWake, // #448①「关闭后补评估」(picker 栈空点)
157
191
  })
158
192
 
159
193
  // First-launch config wizard: implemented in wizard.mjs, closure deps passed via ctx
160
194
  const { startWizard, renderWizard, wizardChooseProvider, wizardSubmitText, cancelWizard, wizardProviderItems } = createWizard({
161
195
  agent, state, pushLine, pushLabel, render, persistRaw,
162
196
  openModelPicker: () => openModelPicker(),
197
+ onModalClose: reevalTimerWake, // #448①「关闭后补评估」(wizard 退场点)
163
198
  })
164
199
 
165
200
  // /distill: impl in distill-cmd.mjs, ctx-passed
@@ -201,8 +236,9 @@ export async function startTUI(agent, opts = {}) {
201
236
 
202
237
  // ---------------------------------------------------------- Startup screen + background indexing
203
238
 
204
- // SESSION.md §6.8 D-S2:startTUI 首帧前 —— provider 无效(_providerInvalid / provider 为 null)
205
- // → 先弹模型选择 picker(keyStream 已挂 keypress,Esc/Enter 可用);Esc → 提示行,仍进 TUI
239
+ // SESSION.md §6.8 D-S2:startTUI 首帧前——按核统一态分流(单源 = PROVIDER.md §6.22):
240
+ // invalid 类 → 先弹模型选择 picker(keyStream 已挂 keypress,Esc/Enter 可用);Esc → 提示行,仍进 TUI;
241
+ // fallback → 不弹 picker + 提示行明示(本次使用 `<渠道>[:<模型>]`)
206
242
  await promptProviderIfInvalid(agent, () => openModelPicker(), pushLine)
207
243
 
208
244
  showStartup({ agent, state, opts, pushLine, pushLabel, render, startWizard })
@@ -63,7 +63,9 @@ export function createInteraction(ctx) {
63
63
 
64
64
  function askPermission(name, args) {
65
65
  // auto mode: fully authorized, no more prompts
66
- if (agent.autoApprove) {
66
+ // TURN-CAP-CONTINUE.md §1 #7 / D-TC15(2026-09-26):`continue` 不在 AUTO 授权面——续期恒须人答;
67
+ // 无人值守档(depth-0 digest / AUTO)撞帽即收口(静默自续 = F8 禁止)。
68
+ if (agent.autoApprove && name !== "continue") {
67
69
  const argSummary = summarize(args)
68
70
  pushLine(` [auto] ${name}${argSummary ? ` ${argSummary}` : ""}`, C.warn)
69
71
  return Promise.resolve(true)
@@ -80,7 +82,7 @@ export function createInteraction(ctx) {
80
82
  })
81
83
  }
82
84
 
83
- /** Batch permission ask (AGENT-LOOP.md §16 D-B1): one merged prompt covering N
85
+ /** Batch permission ask (D-B1): one merged prompt covering N
84
86
  * non-readonly tools from the same toolCalls array — approve all / one by one /
85
87
  * deny. Verdicts resolve as "approveAll" | "oneByOne" | "deny". */
86
88
  const BATCH_PREVIEW_LINES_PER_TOOL = 8
@@ -33,7 +33,7 @@ export function handleCtrlCFamily(str, key, ctx) {
33
33
  state.suspended
34
34
  if (hasStopTarget) {
35
35
  // 当前回合平 abort(无 interrupt——命中 agent.mjs 回合收尾清池分支——全停)
36
- // §20.3 站点 #11(第 24 批):整批 / 会话级停 = stop(reason 载荷)
36
+ // AGENT-LOOP-SUBAGENT.md §6.12 站点 #11(第 24 批):整批 / 会话级停 = stop(reason 载荷)
37
37
  if (state.processing && state.controller) state.controller.abort({ abortTrigger: "stop", abortDetail: "session-stop" })
38
38
  // 会话 abort 集合 = 链条内全部 controller(含 Ctrl+I/ContinueError 重建的旧
39
39
  // controller——children 不逃逸,round1 偏差 #3)。挂起态才标记 _suspAborted +
@@ -1,5 +1,5 @@
1
1
  /** Perform search on state.lines, update state.search.matches and clamp index
2
- * TUI-OOM-ROOTCAUSE(TUI.md §15.3.1 SEARCH_MATCH_CAP):匹配计数上限——超限截断 +
2
+ * TUI-OOM-ROOTCAUSE(TUI-SESSION-VIEW.md §5.1 SEARCH_MATCH_CAP):匹配计数上限——超限截断 +
3
3
  * `state.search.capped` 提示标志(渲染面提示行;次要无界面)。
4
4
  */
5
5
  import { SEARCH_MATCH_CAP } from "./display-budget.mjs"
@@ -44,7 +44,7 @@ export function createKeyHandler(ctx) {
44
44
  // 第 33 批(TUI §14.3(e) 键盘点):模态分派**之前**——任意按键 = 用户在场 ⇒ 清 attention
45
45
  // 位(仅复位呈现态字段,按键语义 / 模态判定 / busy 门禁零改)。
46
46
  clearAttention(state, render)
47
- // permission confirm: y/n/a (a = approve + AUTO ON); batch (§16 D-B1): a/o/n (Esc = deny)
47
+ // permission confirm: y/n/a (a = approve + AUTO ON); batch (D-B1): a/o/n (Esc = deny)
48
48
  // ——模态实现 key-modes.mjs handlePermissionMode(2026-09-03 D-S4)
49
49
  if (handlePermissionMode(str, key, { state, agent, pushLine, render })) return
50
50
 
@@ -34,7 +34,7 @@ export function denyModalForOwner(state, key, { pushLine, render } = {}) {
34
34
  return true
35
35
  }
36
36
 
37
- /** permission 确认模态:y/n/a(a = approve + AUTO ON);batch(§16 D-B1):a/o/n(Esc = deny)。
37
+ /** permission 确认模态:y/n/a(a = approve + AUTO ON);batch(D-B1):a/o/n(Esc = deny)。
38
38
  * `continue` / `retry` = y/n-only 族(判据单源 `isYesNoModal`——X8 跟进①:`retry` 框不
39
39
  * 广告 `a`,零会话级 AUTO 副作用)。
40
40
  * consume:valid 键或 Esc 走 resolve 分支;其余键静默吞掉(模态独占)。 */
@@ -5,10 +5,10 @@
5
5
  *
6
6
  * header → conversation → subagent 面板 → todo → picker → permission → input → status
7
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
8
+ * conversation and the todo panel (docs/cli/design/TUI.md §6.8) — full adaptive height
9
9
  * (the conversation shrinks); compressed away first on small terminals (to 0 =
10
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).
11
+ * conversation stream (unchanged). Output panels abolished.
12
12
  * Fixed panels deducted first, conditional panels allocated by priority, remaining space to conversation.
13
13
  */
14
14
  import { layoutInput, wrapText } from "./render.mjs"
@@ -109,7 +109,7 @@ export function computeLayout(state, { cols, rows }) {
109
109
  // Squeeze target: the divider line yields first under small terminals (the
110
110
  // task rows themselves never compress away — put() truncates by panel h).
111
111
 
112
- // Subagent panel (§7.2.1 D1): RUNNING blocks only, between conversation and
112
+ // Subagent panel (docs/cli/design/TUI.md §6.8 D1): RUNNING blocks only, between conversation and
113
113
  // todo. Height = the FULL rendered height of every running block (F2 — fully
114
114
  // adaptive, no cap; the conversation shrinks accordingly). Precomputed here
115
115
  // via renderSubagentPanel (neutral module, no layout↔render-frame cycle);
@@ -152,7 +152,7 @@ export function computeLayout(state, { cols, rows }) {
152
152
  let todoFinalH = taskPanelH
153
153
  const overflow = fixedH + convH - rows
154
154
  if (overflow > 0) {
155
- // 压缩链第 1 级(§7.2.1 NF1/评审 #2 措辞统一):subagent 面板最先让位——
155
+ // 压缩链第 1 级(docs/cli/design/TUI.md §6.8 NF1/评审 #2 措辞统一):subagent 面板最先让位——
156
156
  // 可压缩至 0 隐藏(运行中活动仍在缓冲区不丢),输入框/状态栏/会话区不可挤没。
157
157
  let afterSub = fixedH
158
158
  if (subagentH > 0) {
@@ -212,7 +212,7 @@ export function computeLayout(state, { cols, rows }) {
212
212
  }
213
213
 
214
214
  /**
215
- * §7.2.1 评审 #4:面板部分压缩(subagentFinalH < subagentLines.length)时 render-frame
215
+ * docs/cli/design/TUI.md §6.8 评审 #4:面板部分压缩(subagentFinalH < subagentLines.length)时 render-frame
216
216
  * 实际显示的行——**保底截断**:分隔线 + 末尾 (h-1) 行(最新启动区块优先;小终端上仍
217
217
  * 能看到最新子 agent 活动,旧 slice(0,h) 保留顶部会把最新活动裁掉)。分隔线始终保留
218
218
  * (面板边界语义)。h ≥ 全长 → 原样;h = 0 → [](layout 侧 panels.subagent 已为 null,
@@ -0,0 +1,199 @@
1
+ /**
2
+ * lifecycle-records.mjs — 消化生命周期面记录承载件(#726 跨端消化面恢复批 —— CLI 承接面)。
3
+ *
4
+ * 机制单源 = `docs/core/design/SESSION.md` §6.26(记录形 ∥ 写缝 ∥ 读缝 ∥ 重建义务 ∥ 容差登记);
5
+ * CLI 承接细则 = `docs/cli/design/TUI-SESSION-VIEW.md` §6。本档 = 三写点 + 恢复面的**单一实现**:
6
+ * ① 记录形构建:`digest` 三型(start ∥ cap ∥ end)∥ `subagent` 快照(meta ∥ rows——rows 保尾 ≤500);
7
+ * ② 痕行文本:digest 记录的可见行文案——live(`digestTurn` ∥ cap 分支)与重建(`historyToLines`)同调;
8
+ * ③ 重建:`subagent` 记录 ⇒ `_frozenSubTask` 合成件(渲染端零改消费——折叠头 + tail-3);
9
+ * ④ 终态行回扫:end 记录页内缺席本轮 start ⇒ `store.page` 向更早逐段回读补齐 `n`(跨页零损)。
10
+ * 写点三处 = `suspension-drive.mjs` `digestTurn` ∥ `agent-turn.mjs` cap 分支 ∥ `subagent-freeze.mjs` `freezeSubTaskLines`。
11
+ */
12
+ import { t } from "@thincoder/core/i18n.mjs"
13
+ import { RECORD_SEG_MESSAGES } from "@thincoder/core/session-store.mjs"
14
+ import { C } from "./ansi.mjs"
15
+
16
+ /** rows 保尾上界(显示行——§6.26「有界保尾」:超界弃最旧 ⇒ 前置省略标记行,标记自身不占额度)。 */
17
+ export const RECORD_ROWS_MAX_LINES = 500
18
+
19
+ /** 写缝载体(`state._agent` ∥ `ctx.agent`——端侧钉定二源,生产同对象;双缺 ⇒ null ⇒ 核口零动作)。 */
20
+ export function recordCarrier(state, agent) {
21
+ return state?._agent ?? agent ?? null
22
+ }
23
+
24
+ // ─── ① 记录形构建(写点三处)─────────────────────────────────────────────
25
+
26
+ /** digest 起跑记录(起跑两行同点):`n` = 起跑待消化数(可 0);`tier` 两档;ask 档携 `from`/`msg`。 */
27
+ export function digestStartRecord({ n, upstream, ask }) {
28
+ return { kind: "digest", status: "start", n, tier: upstream ? "ask" : "digest", ...(ask ?? {}) }
29
+ }
30
+
31
+ /** digest 撞帽记录(cap 行同点;CLI 现无 auto 档产者——读取面按契约前向兼容)。 */
32
+ export function digestCapRecord(turns) {
33
+ return { kind: "digest", status: "cap", mode: "stop", turns }
34
+ }
35
+
36
+ /** digest 收尾记录(终态行同点):`ok` ∥ `ms`(行文案 `seconds` 由同一 `ms` 派生——同值单算式)。 */
37
+ export function digestEndRecord({ ok, ms }) {
38
+ return { kind: "digest", status: "end", ok, ms }
39
+ }
40
+
41
+ /** subagent 归档快照记录(冻结载体行插入同点):meta = 冻结时点块头事实(含 `pool`/`queued?`——§6.26)
42
+ * ∥ rows = 块内容行集(保尾 ≤500)。#790:`key` 写面归一 = 规范形 `sub:<role>#<id>`。 */
43
+ export function subagentRecord(sub) {
44
+ const localKey = String(sub.key ?? "")
45
+ const meta = {
46
+ key: localKey.startsWith("sub:") ? localKey : `sub:${localKey}`,
47
+ role: sub.role ?? null,
48
+ model: sub.model ?? null,
49
+ startedAt: sub.started ?? null,
50
+ doneAt: sub.doneAt ?? null,
51
+ turn: sub.turn ?? null,
52
+ maxTurns: sub.maxTurns ?? null,
53
+ status: sub.stopped ? "stopped" : sub.lastError ? "error" : "done",
54
+ }
55
+ if (sub.queued) meta.queued = true // 排队(未启动)⇒ 重建面 waiting 标注(`pool` 不写——未知)
56
+ else meta.pool = sub.async === true // 非排队显式两写(VSC 头段在场性判据 = `pool != null`——缺省会藏段)
57
+ if (sub.lastError) meta.error = sub.lastError // §6.26 meta `error?`(块头事实——重建面还原冻结头 errPart)
58
+ return { kind: "subagent", meta, rows: boundRows(rowsOf(sub)).map(({ kind, text }) => ({ kind, text })) }
59
+ }
60
+
61
+ /** 块内容行集(条目形 `{kind,text}` 同备;trim 标记随行——保尾裁量时不占额度)。 */
62
+ function rowsOf(sub) {
63
+ const out = []
64
+ for (const b of sub.blocks ?? []) {
65
+ if (!b || typeof b.text !== "string") continue
66
+ const row = { kind: typeof b.kind === "string" ? b.kind : "text", text: b.text }
67
+ if (b._trimMarker === true) row._trimMarker = true
68
+ out.push(row)
69
+ }
70
+ return out
71
+ }
72
+
73
+ /** 行显示行数(核 `countBlockLines` 同式:按 `\n` 切分、文末换行不计)。 */
74
+ function rowLines(row) {
75
+ const lines = String(row?.text ?? "").split("\n")
76
+ return lines[lines.length - 1] === "" ? lines.length - 1 : lines.length
77
+ }
78
+
79
+ /** rows 保尾上界(§6.26「有界保尾」):超界弃最旧 ⇒ 前置省略标记行(`N` = 实弃显示行数、
80
+ * 标记自身不占额度);单行超界保末行(行 = 最小单元,不切行中——桌面归档先例同式)。 */
81
+ function boundRows(rows) {
82
+ const list = Array.isArray(rows) ? rows : []
83
+ const isMarker = (r) => r?._trimMarker === true
84
+ const cost = (r) => (isMarker(r) ? 0 : rowLines(r))
85
+ let total = 0
86
+ let start = list.length
87
+ for (let i = list.length - 1; i >= 0; i -= 1) {
88
+ const n = cost(list[i])
89
+ if (total + n > RECORD_ROWS_MAX_LINES) break
90
+ total += n
91
+ start = i
92
+ }
93
+ if (start === list.length && list.length > 0) start = list.length - 1 // 单行超界:保末行(保尾)
94
+ const kept = [...list.slice(0, start).filter(isMarker), ...list.slice(start)]
95
+ if (kept.length === list.length) return list // 未超界:原引用(零写)
96
+ let dropped = 0
97
+ for (let i = 0; i < start; i += 1) dropped += cost(list[i])
98
+ return [{ kind: "meta", text: `… [rows truncated: ${dropped} lines omitted]` }, ...kept]
99
+ }
100
+
101
+ // ─── ② 痕行文本(live ∥ 重建同调——单一实现零副本)─────────────────────────
102
+
103
+ /** digest 记录 ⇒ 痕行(文案与活流同算式)。`startN` = end 行的本轮起跑数(调用面解析——
104
+ * 页内直用 ∥ 存储回扫);缺省 / 非正数 / `ms` 非数 ⇒ 零行(活流同门 `pend0 > 0`)。 */
105
+ export function digestTraceLines(rec, startN = null) {
106
+ if (rec?.kind !== "digest") return []
107
+ if (rec.status === "start") {
108
+ const ask = rec.tier === "ask" && rec.from !== undefined ? { from: rec.from, msg: rec.msg } : null
109
+ const lines = [{ text: ask ? t("digest.turnLabelAsk", ask) : t("digest.turnLabel"), color: C.dim }]
110
+ if (rec.n > 0) lines.push({ text: t("digest.start", { n: rec.n }), color: C.dim })
111
+ return lines
112
+ }
113
+ if (rec.status === "cap") return [{ text: t("digest.capStop", { turns: rec.turns }), color: C.warn }]
114
+ if (rec.status === "end") {
115
+ if (!Number.isFinite(startN) || startN <= 0 || !Number.isFinite(rec.ms)) return []
116
+ const seconds = (rec.ms / 1000).toFixed(1) // 与记录 `ms` 同值单算式
117
+ return [{ text: t(rec.ok === false ? "digest.aborted" : "digest.done", { n: startN, seconds }), color: C.dim }]
118
+ }
119
+ return []
120
+ }
121
+
122
+ // ─── ③ 重建(读面):subagent 记录 ⇒ 合成件 ────────────────────────────────
123
+
124
+ /** subagent 记录 ⇒ `_frozenSubTask` 合成件(重建面;渲染端零改消费——折叠头 + tail-3 原式)。
125
+ * meta 块头事实逐项回填(渲染端读面:started→elapsed ∥ turn/maxTurns ∥ stopped ∥ error→errPart);
126
+ * #790:`pool`/`queued` ⇒ CLI 本地两事实(`async`/`queued`)∥ `key` 剥 `sub:` 前缀(显示 = 本地无前缀形)。 */
127
+ export function synthSubTask(rec) {
128
+ const meta = rec?.meta ?? {}
129
+ const rows = Array.isArray(rec?.rows) ? rec.rows : []
130
+ const blocks = rows.map((r) => ({ kind: typeof r?.kind === "string" ? r.kind : "text", text: String(r?.text ?? "") }))
131
+ const doneAt = Number.isFinite(meta.doneAt) ? meta.doneAt : null
132
+ const started = Number.isFinite(meta.startedAt) ? meta.startedAt : (doneAt ?? 0)
133
+ return {
134
+ key: String(meta.key ?? "").replace(/^sub:/, ""), // #790:读面容旧形——剥离判别(折叠键 ∥ 显示 = 本地无前缀形)
135
+ role: meta.role ?? null,
136
+ model: meta.model ?? null,
137
+ started,
138
+ done: true,
139
+ doneAt: doneAt ?? started,
140
+ turn: meta.turn ?? null,
141
+ maxTurns: meta.maxTurns ?? null,
142
+ async: meta.pool === true, // #790:记录 `pool` ⇒ 折叠头模式词(§6.26 命名映射:记录 pool ⇄ 本地 async)
143
+ queued: meta.queued === true, // #790:排队冻结 ⇒ 折叠头 waiting 标注
144
+ // #795 残项批:停止面词集扩三词(stopped ∥ cancelled ∥ terminated——词面判据单源 = §6.26;VSC/桌面写 cancelled)。
145
+ stopped: meta.status === "stopped" || meta.status === "cancelled"
146
+ || meta.status === "terminated",
147
+ lastError: typeof meta.error === "string" ? meta.error : null,
148
+ blocks,
149
+ _charCount: blocks.reduce((n, b) => n + b.text.length, 0), // 行集字符和(§5.4 账实一致——恢复直算同源)
150
+ }
151
+ }
152
+
153
+ // ─── ④ 终态行回扫(重建面:跨页分裂 ⇒ 存储逐段回读)─────────────────────────
154
+
155
+ /** 页内(含 ±1 页沿)本轮 start 直用:自 `idx` 向前命中最近 digest start ⇒ 其 `n`(无 ⇒ null)。 */
156
+ function pageStartN(records, idx) {
157
+ for (let j = idx - 1; j >= 0; j -= 1) {
158
+ const r = records[j]
159
+ if (r?.kind === "digest" && r.status === "start") return typeof r.n === "number" ? r.n : null
160
+ }
161
+ return null
162
+ }
163
+
164
+ /** end 记录本轮 start 的 `n`(重建面解析序 = 预解析注记 `_startN`(跨页回扫产物)→ 页内回扫)。 */
165
+ export function resolvedStartN(records, idx) {
166
+ const m = records?.[idx]
167
+ if (m?._startN !== undefined) return m._startN
168
+ return pageStartN(records, idx)
169
+ }
170
+
171
+ /** 存储回扫:`store.page` 向更早逐段回读,至命中本轮 start 止(跨页零损——容差①于 CLI 不成立)。 */
172
+ export function scanStoreForStartN(store, endIdx) {
173
+ if (!store?.page || !Number.isFinite(endIdx) || endIdx <= 0) return null
174
+ let hi = Math.floor(endIdx)
175
+ while (hi > 0) {
176
+ const lo = Math.max(0, hi - RECORD_SEG_MESSAGES)
177
+ let messages = []
178
+ try { messages = store.page(lo, hi, { margin: 0 })?.messages ?? [] } catch { return null }
179
+ for (let k = messages.length - 1; k >= 0; k -= 1) {
180
+ const r = messages[k]
181
+ if (r?.kind === "digest" && r.status === "start") return typeof r.n === "number" ? r.n : null
182
+ }
183
+ hi = lo
184
+ }
185
+ return null
186
+ }
187
+
188
+ /** 预解析(调用面 = `restoreLines` ∥ `createLoadOlder`——持有存储读口):页内(含 ±1 页沿)缺席
189
+ * 本轮 start 的 end ⇒ 回扫 `n` 并注记 `_startN`(重建注记——不动存储形)。 */
190
+ export function resolveSplitTerminalNs(records, base, store) {
191
+ if (!Array.isArray(records) || !store?.page || !Number.isFinite(base)) return
192
+ for (let k = 0; k < records.length; k += 1) {
193
+ const m = records[k]
194
+ if (m?.kind !== "digest" || m.status !== "end" || m._startN !== undefined) continue
195
+ if (pageStartN(records, k) !== null) continue // 页内直用——零回扫
196
+ const n = scanStoreForStartN(store, base + k)
197
+ if (n !== null) m._startN = n
198
+ }
199
+ }