thincoder 0.12.52 → 0.12.53

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.
@@ -24,10 +24,160 @@ export { _rmpw as _renderMarkdownPreservingWidth }
24
24
 
25
25
  let _convCache = { key: "", cols: 0, lines: [] }
26
26
 
27
+ /** 2026-08-31 懒加载卡顿优化②:段级行体缓存(行对象→conv 行数组)。
28
+ * 与行级 wrapRowsCached 分层:wrap 缓存只省 markdown/换行重算(streaming 行 text 变失效),
29
+ * 段缓存省**整个行体的折叠/展开/窗口化组装**——loadOlder unshift 后尾部 987 行段全命中,
30
+ * rebuild 从 25.7ms → ~8ms 量级。签名由 lineSegSig 集中计算(该段输出的所有决定因素)。 */
31
+ const _lineSegCache = new WeakMap()
32
+
33
+ /** 段签名:段输出的所有决定因素(漏一项 → 缓存失效不全 → 显示 stale)。
34
+ * 返回 { textRef, sig }——text 用**引用比较**(O(1);streaming 同对象 text 变 → 新引用
35
+ * ≠ 旧引用 → 失效),其余短字段(列宽/行数上限/颜色/种类/折行 id/lineId/foldEnabled/
36
+ * 该段折叠展开态+offset/search)拼接——**不**把 l.text 全量拼进 sig(987 行 × KB 级
37
+ * 字符串拼接实测 20ms,等于没优化)。 */
38
+ function lineSegSig(state, l, i, cols, maxRows) {
39
+ const longKey = `long-${l._lineId ?? i}`
40
+ const expanded = state.expandedBlocks?.has(longKey) ? 1 : 0
41
+ const offset = state._foldScroll?.get(longKey) ?? 0
42
+ const searchSig = state.search?.query
43
+ ? `${state.search.query}:${state.search.index ?? 0}:${l._searchMatches?.length ?? 0}`
44
+ : ""
45
+ return {
46
+ textRef: l.text,
47
+ sig: [
48
+ cols, maxRows ?? 0, l.color ?? "", l._kind ?? "", l._foldId ?? "",
49
+ l._lineId ?? "", state.foldEnabled === false ? 0 : 1, expanded, offset, searchSig,
50
+ ].join("|"),
51
+ }
52
+ }
53
+
54
+ /** 普通源行 → conv 行数组(行体;不含前后空行——空行由 buildConvLines 外层逻辑补)。
55
+ * 从 buildConvLines 循环抽出(2026-08-31 段缓存);逻辑与原位置逐字同构。 */
56
+ function buildLineSeg(state, l, i, cols, maxRows) {
57
+ const LONG_FOLD_LINES = 12
58
+ const out = []
59
+ let text = l.text
60
+
61
+ // Apply search highlighting
62
+ if (state.search && state.search.query && l._searchMatches) {
63
+ text = highlightSearchMatches(text, state.search.query, l._searchMatches, state.search.index, state.search.matches, i)
64
+ }
65
+
66
+ const longKey = `long-${l._lineId ?? i}`
67
+ const isReasoning = l._kind === "thinking" || (l._kind === undefined && l.color === C.reason)
68
+ const foldable = isReasoning || (l._kind === "tool" || (l._kind === undefined && l.color === C.dim) || (l._kind === undefined && l.color !== C.text && l.color !== C.reason))
69
+ const threshold = isReasoning ? 0 : LONG_FOLD_LINES
70
+ const folded = foldable && state.foldEnabled !== false && !state.expandedBlocks?.has(longKey)
71
+ const block = []
72
+ const renderedRows = wrapRowsCached(state, l, text, cols)
73
+ for (const wrapped of renderedRows) {
74
+ block.push({ text: wrapped, color: l.color, _foldId: l._foldId, _src: i })
75
+ }
76
+ if (folded && block.length > threshold) {
77
+ const kind = l.color === C.reason ? "thinking" : l.color === C.dim ? "tool output" : "message"
78
+ out.push(...renderFoldedHead({
79
+ header: foldHintLine(`▶ ${kind} · ${block.length} lines — click to expand`, longKey, i),
80
+ body: block, cols,
81
+ }))
82
+ } else if (foldable && block.length > threshold) {
83
+ if (state.foldEnabled === false) {
84
+ out.push(...block)
85
+ } else {
86
+ if (l.color === C.dim) {
87
+ for (const line of block) line._skipDimFold = true
88
+ }
89
+ out.push(...renderExpandedBlock({ body: block, foldKey: longKey, state, maxRows, cols, label: `${block.length} lines` }))
90
+ }
91
+ } else {
92
+ out.push(...block)
93
+ }
94
+ return out
95
+ }
96
+
97
+ /** 工具块段(2026-08-31 段缓存抽出——真实会话 106 个工具块每帧全量 wrap 32ms 的根治)。
98
+ * 返回 conv 行数组(工具块的折叠头+尾/展开窗口+控制行),逻辑与原 L221-258 逐字同构。 */
99
+ function buildToolBlockSeg(state, l, i, cols, maxRows) {
100
+ const b = l._toolBlock
101
+ const foldKey = `tool-${l._lineId ?? i}`
102
+ const out = []
103
+ const status = !b.done
104
+ ? "running"
105
+ : `${b.elapsed !== null ? b.elapsed + "ms" : ""}${b.summary ? (b.elapsed !== null ? " · " : "") + sliceByWidth(b.summary, 50) : ""}`.trim() || "done"
106
+ if (isExpanded(state, foldKey)) {
107
+ const body = []
108
+ const pushWrapped = (raw, color) => {
109
+ for (const w of wrapText(raw, cols - 4)) body.push({ text: " " + w, color, _skipDimFold: true })
110
+ }
111
+ for (const jl of b.argsJson) pushWrapped(jl, C.dim)
112
+ for (const ol of b.output) pushWrapped(ol, C.tool)
113
+ if (b.result) for (const rl of b.result) pushWrapped(rl, C.dim)
114
+ out.push(...renderExpandedBlock({ body, foldKey, state, maxRows, cols, label: `${b.name}${b.roundTag || ""} ${b.argsSummary}`.trim() }))
115
+ } else {
116
+ const headText = sliceByWidth(
117
+ `❯ ${b.name}${b.roundTag || ""}${b.argsSummary ? " " + b.argsSummary : ""} · ${status}`,
118
+ Math.max(20, cols - 2),
119
+ )
120
+ const body = []
121
+ for (const jl of b.argsJson) for (const w of wrapText(jl, cols - 4)) body.push({ text: w, color: C.dim, _skipDimFold: true })
122
+ for (const ol of b.output.slice(-3)) for (const w of wrapText(ol, cols - 4)) body.push({ text: w, color: C.dim, _skipDimFold: true })
123
+ if (b.result) for (const rl of b.result) for (const w of wrapText(rl, cols - 4)) body.push({ text: w, color: C.dim, _skipDimFold: true })
124
+ out.push(...renderFoldedHead({ header: { text: headText, color: C.tool, _foldToggle: foldKey }, body, cols }))
125
+ }
126
+ return out
127
+ }
128
+
129
+ /** frozenAdvisor 段(2026-08-31 段缓存抽出)——foldKey 升级为 _lineId 派生(同 long-N 判例)。 */
130
+ function buildFrozenAdvSeg(state, l, i, cols, maxRows) {
131
+ const frozenAdvKey = `advisor-done-${l._lineId ?? i}`
132
+ const out = []
133
+ if (isExpanded(state, frozenAdvKey)) {
134
+ const body = []
135
+ const rendered = renderMathAndMarkdown(sanitizeDisplay(l._frozenAdvisor))
136
+ for (const line of formatTables(rendered, cols - 1)) {
137
+ for (const wrapped of wrapText(line, cols - 1)) {
138
+ body.push({ text: wrapped, color: C.reason, _skipDimFold: true })
139
+ }
140
+ }
141
+ out.push(...renderExpandedBlock({ body, foldKey: frozenAdvKey, state, maxRows, cols, label: "[advisor · review done]" }))
142
+ } else {
143
+ out.push({
144
+ text: `▶ [advisor · review done] … click to expand`,
145
+ color: C.fold,
146
+ _foldToggle: frozenAdvKey,
147
+ })
148
+ }
149
+ return out
150
+ }
151
+
152
+
153
+ /** 2026-08-31 懒加载卡顿根因修复:行级 wrap/markdown 渲染缓存。
154
+ * buildConvLines 全量重建 O(总行数)——真实 200 条历史 → 987 conv 行 94ms、loadOlder 后
155
+ * 111ms(主线程阻塞卡顿)。行对象 + cols + 加工后 text(含 search 高亮注入)为键,
156
+ * 已有行直接复用——loadOlder/prepend 只算新增行;streaming 行 text 变自动失效;
157
+ * 行对象在 unshift 间引用稳定(行级隔离,无跨 state 串扰)。 */
158
+ const _wrapCache = new WeakMap()
159
+
160
+ function wrapRowsCached(state, line, text, cols) {
161
+ const hit = _wrapCache.get(line)
162
+ if (hit && hit.cols === cols && hit.text === text && hit.color === line.color) return hit.rows
163
+ const renderedText = renderMathAndMarkdown(sanitizeDisplay(text))
164
+ const rows = []
165
+ for (const l of formatTables(renderedText, cols - 1)) {
166
+ for (const wrapped of wrapText(l, cols - 1)) rows.push(wrapped)
167
+ }
168
+ _wrapCache.set(line, { cols, text, color: line.color, rows })
169
+ return rows
170
+ }
171
+
172
+
27
173
  export function convCacheKey(state, maxRows) {
28
174
  const lastLine = state.lines.length > 0 ? state.lines[state.lines.length - 1] : null
29
175
  // expandedBlocks participates: expanding/folding a block must invalidate the cache
30
176
  const exp = state.expandedBlocks ? [...state.expandedBlocks].sort().join(",") : ""
177
+ // 2026-08-31 块内滚动:_foldScroll(foldKey→offset)参与签名——翻窗必须重新渲染
178
+ const foldScrollSig = state._foldScroll
179
+ ? [...state._foldScroll.entries()].sort((a, b) => a[0] < b[0] ? -1 : 1).map(([k, v]) => `${k}:${v}`).join(",")
180
+ : ""
31
181
  // Content prefix in the signature: same kind+length with different content
32
182
  // would otherwise collide (stale render); 8 chars disambiguate in practice.
33
183
  const blocksSig = (state._advisorBlocks ?? []).map((b) => `${b.kind}:${b.text?.length ?? 0}:${String(b.text ?? "").slice(0, 8)}`).join(",")
@@ -75,7 +225,7 @@ export function convCacheKey(state, maxRows) {
75
225
  // this the cache would serve the pre-search rows and highlight would never
76
226
  // appear (P0-1, 2026-08-30 consult). query+index covers match navigation.
77
227
  const searchPart = state.search?.query ? `${state.search.query}:${state.search.index ?? 0}` : ""
78
- 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}`
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}`
79
229
  }
80
230
 
81
231
  /** Fold marker line: bold-cyan icon + "click to …" phrase underlined (clickable affordance).
@@ -189,47 +339,45 @@ function buildConvLines(state, cols, maxRows) {
189
339
  // Frozen subagent activity block (§7.2 D4, 2026-08-30): rendered as its own
190
340
  // collapsible section — clickable expand/collapse like the running block.
191
341
  if (l._frozenSubTask) {
192
- convLines.push(...frozenSubTaskLines(state, l._frozenSubTask, cols, maxRows))
342
+ // 2026-08-31 段缓存:frozenSubTask 冻结后内容不变——签名含 sub.key + blocks 计数
343
+ const fKey = `sub-${l._frozenSubTask.key}`
344
+ const fSig = [
345
+ cols, maxRows ?? 0, l._frozenSubTask.key, l._frozenSubTask.blocks?.length ?? 0,
346
+ l._frozenSubTask.done ? 1 : 0, state.foldEnabled === false ? 0 : 1,
347
+ state.expandedBlocks?.has(fKey) ? 1 : 0, state._foldScroll?.get(fKey) ?? 0,
348
+ ].join("|")
349
+ const fHit = _lineSegCache.get(l)
350
+ if (fHit && fHit.textRef === l._frozenSubTask && fHit.sig === fSig) {
351
+ convLines.push(...fHit.rows)
352
+ } else {
353
+ const rows = frozenSubTaskLines(state, l._frozenSubTask, cols, maxRows)
354
+ _lineSegCache.set(l, { textRef: l._frozenSubTask, sig: fSig, rows })
355
+ convLines.push(...rows)
356
+ }
193
357
  continue
194
358
  }
195
359
  // ONE BLOCK PER TOOL CALL (2026-08-30 user ruling): header = name+args+
196
360
  // live status, body = args JSON + streaming output + result. Folded =
197
361
  // ▶ name args · status/summary; expanded = 60%-capped body (shared component).
198
362
  if (l._toolBlock) {
363
+ // 2026-08-31 段缓存:工具块签名含三缓冲长度+done/elapsed/summary+该块展开态——
364
+ // 流式 append 使 output.length 变 → 失效;real 会话 106 个工具块每帧全量 wrap 实测
365
+ // 32ms(rebuild 40ms 的大头),入缓存后命中只算签名拼接。
199
366
  const b = l._toolBlock
200
- // Stable key from the line's own id (P1 2026-08-30): the line may shift
201
- // index when loadOlder unshifts older pages — positional tool-${i} would
202
- // re-bind the expand state to a different tool block.
203
- const foldKey = `tool-${l._lineId ?? i}`
204
- const status = !b.done
205
- ? "running"
206
- : `${b.elapsed !== null ? b.elapsed + "ms" : ""}${b.summary ? (b.elapsed !== null ? " · " : "") + sliceByWidth(b.summary, 50) : ""}`.trim() || "done"
207
- if (isExpanded(state, foldKey)) {
208
- const body = []
209
- const pushWrapped = (raw, color) => {
210
- for (const w of wrapText(raw, cols - 4)) body.push({ text: " " + w, color, _skipDimFold: true })
211
- }
212
- for (const jl of b.argsJson) pushWrapped(jl, C.dim)
213
- for (const ol of b.output) pushWrapped(ol, C.tool)
214
- if (b.result) for (const rl of b.result) pushWrapped(rl, C.dim)
215
- convLines.push(...renderExpandedBlock({ body, foldKey, state, maxRows, cols, label: `${b.name}${b.roundTag || ""} ${b.argsSummary}`.trim() }))
367
+ const toolFoldKey = `tool-${l._lineId ?? i}`
368
+ const tSig = [
369
+ cols, maxRows ?? 0, b.argsJson?.length ?? 0, b.output?.length ?? 0, b.result?.length ?? 0,
370
+ b.done ? 1 : 0, b.elapsed ?? "", b.summary ?? "", b.name ?? "", b.roundTag ?? "",
371
+ l._lineId ?? "", state.foldEnabled === false ? 0 : 1,
372
+ state.expandedBlocks?.has(toolFoldKey) ? 1 : 0, state._foldScroll?.get(toolFoldKey) ?? 0,
373
+ ].join("|")
374
+ const tHit = _lineSegCache.get(l)
375
+ if (tHit && tHit.textRef === b && tHit.sig === tSig) {
376
+ convLines.push(...tHit.rows)
216
377
  } else {
217
- // Head MUST be width-bounded: argsSummary for unknown/MCP tools is a
218
- // JSON.stringify dump that can be thousands of chars — an overwide header
219
- // row makes the terminal soft-wrap mid-frame, shifting every panel below
220
- // (the "code breaks the input box border" report, 2026-08-30).
221
- const headText = sliceByWidth(
222
- `❯ ${b.name}${b.roundTag || ""}${b.argsSummary ? " " + b.argsSummary : ""} · ${status}`,
223
- Math.max(20, cols - 2),
224
- )
225
- const body = []
226
- for (const jl of b.argsJson) for (const w of wrapText(jl, cols - 4)) body.push({ text: w, color: C.dim, _skipDimFold: true })
227
- for (const ol of b.output.slice(-3)) for (const w of wrapText(ol, cols - 4)) body.push({ text: w, color: C.dim, _skipDimFold: true })
228
- // Result lines join the tail pool too — restore carrier has no output
229
- // rows, so without this its folded tail showed only args JSON and the
230
- // result vanished from the folded view (parity bug, 2026-08-30).
231
- if (b.result) for (const rl of b.result) for (const w of wrapText(rl, cols - 4)) body.push({ text: w, color: C.dim, _skipDimFold: true })
232
- convLines.push(...renderFoldedHead({ header: { text: headText, color: C.tool, _foldToggle: foldKey }, body, cols }))
378
+ const rows = buildToolBlockSeg(state, l, i, cols, maxRows)
379
+ _lineSegCache.set(l, { textRef: b, sig: tSig, rows })
380
+ convLines.push(...rows)
233
381
  }
234
382
  continue
235
383
  }
@@ -238,93 +386,37 @@ function buildConvLines(state, cols, maxRows) {
238
386
  // rendered, no gutter — review history convention kept from the flat era),
239
387
  // 60% cap via the shared component.
240
388
  if (l._frozenAdvisor) {
241
- const frozenAdvKey = `advisor-done-${i}`
242
- if (isExpanded(state, frozenAdvKey)) {
243
- const body = []
244
- const rendered = renderMathAndMarkdown(sanitizeDisplay(l._frozenAdvisor))
245
- for (const line of formatTables(rendered, cols - 1)) {
246
- for (const wrapped of wrapText(line, cols - 1)) {
247
- body.push({ text: wrapped, color: C.reason, _skipDimFold: true })
248
- }
249
- }
250
- convLines.push(...renderExpandedBlock({ body, foldKey: frozenAdvKey, state, maxRows, cols, label: "[advisor · review done]" }))
389
+ // 2026-08-31 段缓存:frozenAdvisor 文本冻结不变——签名含文本长度 + 展开态
390
+ // 注:foldKey advisor-done-${i}(位置键)升级为 advisor-done-${_lineId ?? i}
391
+ // (同 long-N 判例——loadOlder unshift 后不重绑)
392
+ const frozenAdvKey = `advisor-done-${l._lineId ?? i}`
393
+ const aSig = [
394
+ cols, maxRows ?? 0, (l._frozenAdvisor ?? "").length,
395
+ state.foldEnabled === false ? 0 : 1,
396
+ state.expandedBlocks?.has(frozenAdvKey) ? 1 : 0, state._foldScroll?.get(frozenAdvKey) ?? 0,
397
+ ].join("|")
398
+ const aHit = _lineSegCache.get(l)
399
+ if (aHit && aHit.textRef === l._frozenAdvisor && aHit.sig === aSig) {
400
+ convLines.push(...aHit.rows)
251
401
  } else {
252
- convLines.push({
253
- text: `▶ [advisor · review done] click to expand`,
254
- color: C.fold,
255
- _foldToggle: frozenAdvKey,
256
- })
402
+ const rows = buildFrozenAdvSeg(state, l, i, cols, maxRows)
403
+ _lineSegCache.set(l, { textRef: l._frozenAdvisor, sig: aSig, rows })
404
+ convLines.push(...rows)
257
405
  }
258
406
  continue
259
407
  }
260
- let text = l.text
261
-
262
- // Apply search highlighting
263
- if (state.search && state.search.query && l._searchMatches) {
264
- text = highlightSearchMatches(text, state.search.query, l._searchMatches, state.search.index, state.search.matches, i)
265
- }
266
408
 
267
- // Long-message folding (2026-08-30 user ruling): MAIN OUTPUT / user messages
268
- // (C.text) NEVER fold — primary conversation content is read by scrolling,
269
- // not by expanding; a folded core answer hid the actual result behind a
270
- // click. Foldable subjects narrow to THINKING (C.reason) and dim tool
271
- // summaries the auxiliary streams. (This re-enacts the pre-0.12.7 rule
272
- // for main output only; the 0.12.7 "revert" had reopened folding for it.)
273
- // Keyed by the source-line index (`long-${i}`) so the toggle survives
274
- // re-renders.
275
- const longKey = `long-${i}`
276
- // Single source of truth: the producer stamps _kind ("thinking" / "text" /
277
- // "tool") — buildConvLines READS the stamp instead of GUESSING from color.
278
- // Three producers (live flushStream / restored historyToLines / injected
279
- // lines) now emit the identical grammar; the renderer is one place.
280
- // Fallback: unstamped lines keep the legacy color-based inference (defensive
281
- // for any path this refactor missed — empty until proven otherwise).
282
- const isReasoning = l._kind === "thinking" || (l._kind === undefined && l.color === C.reason)
283
- // Foldable classes: thinking (ALWAYS — threshold 0) and dim auxiliaries.
284
- // "text" (main output / user messages) NEVER folds.
285
- const foldable = isReasoning || (l._kind === "tool" || (l._kind === undefined && l.color === C.dim) || (l._kind === undefined && l.color !== C.text && l.color !== C.reason))
286
- const threshold = isReasoning ? 0 : LONG_FOLD_LINES
287
- const folded = foldable && state.foldEnabled !== false && !state.expandedBlocks?.has(longKey)
288
- const block = []
289
- // Lightweight markdown display (IK5VW3): render BEFORE measuring — the
290
- // table column math (formatTables) and wrapping must see the RENDERED
291
- // text (ANSI consumes zero display width; the width functions are
292
- // ANSI-aware). Rendering after wrapping measured raw markdown
293
- // (`**bold**` = 8) against displayed text (4) and sliced markers
294
- // mid-sequence — the table misalignment the user kept reporting.
295
- const renderedText = renderMathAndMarkdown(sanitizeDisplay(text))
296
- for (const line of formatTables(renderedText, cols - 1)) {
297
- for (const wrapped of wrapText(line, cols - 1)) {
298
- block.push({ text: wrapped, color: l.color, _foldId: l._foldId, _src: i })
299
- }
300
- }
301
- if (folded && block.length > threshold) {
302
- // FOLDED — unified form (fold-block.mjs renderFoldedHead, 2026-08-30 user
303
- // ruling): named identity header + last 3 lines. Replaces the legacy
304
- // [first 4, anonymous ▶ at the ellipsis, last] whose orphaned-looking
305
- // "… N more lines" segment confused the scrollback.
306
- const kind = l.color === C.reason ? "thinking" : l.color === C.dim ? "tool output" : "message"
307
- convLines.push(...renderFoldedHead({
308
- header: foldHintLine(`▶ ${kind} · ${block.length} lines — click to expand`, longKey, i),
309
- body: block, cols,
310
- }))
311
- } else if (foldable && block.length > threshold) {
312
- if (state.foldEnabled === false) {
313
- // Folding fully off — content already fully visible; a "click to
314
- // collapse" hint would be misleading (toggling has no effect).
315
- convLines.push(...block)
316
- } else {
317
- // EXPANDED thinking/dim long block via the shared component: blank + ▼
318
- // control at the HEAD, content, 60% cap with a bottom collapse control.
319
- // DIM blocks must not re-trigger the consecutive-dim folding below
320
- // (folding stacked on folding — reported regression).
321
- if (l.color === C.dim) {
322
- for (const line of block) line._skipDimFold = true
323
- }
324
- convLines.push(...renderExpandedBlock({ body: block, foldKey: longKey, state, maxRows, cols, label: `${block.length} lines` }))
325
- }
409
+ // ── 普通源行段(2026-08-31 懒加载卡顿优化②:段级缓存——行体 WeakMap 按行对象
410
+ // 缓存,签名含该段所有决定因素;unshift/loadOlder 后尾部段全命中,只算新增行。
411
+ // toggle/翻窗/收起只失效该块段,其它段照样命中。行为不变性是硬约束。
412
+ const seg = lineSegSig(state, l, i, cols, maxRows)
413
+ const hit = _lineSegCache.get(l)
414
+ if (hit && hit.textRef === seg.textRef && hit.sig === seg.sig) {
415
+ convLines.push(...hit.rows)
326
416
  } else {
327
- convLines.push(...block)
417
+ const rows = buildLineSeg(state, l, i, cols, maxRows)
418
+ _lineSegCache.set(l, { textRef: seg.textRef, sig: seg.sig, rows })
419
+ convLines.push(...rows)
328
420
  }
329
421
  // Trailing blank after a main-output segment (user request 2026-08-30) —
330
422
  // landed after the segment's rendered content.
@@ -463,7 +555,6 @@ function buildConvLines(state, cols, maxRows) {
463
555
  }
464
556
  // Fold long blocks (> 8 consecutive dim lines)
465
557
  const FOLD_LINES = 8
466
- let foldCounter = 0
467
558
  const folded = []
468
559
  let i = 0
469
560
  while (i < convLines.length) {
@@ -475,7 +566,10 @@ function buildConvLines(state, cols, maxRows) {
475
566
  // Expanded long-fold blocks are exempt — otherwise folding stacks on folding
476
567
  const hasExpandedLong = convLines.slice(i, j).some((l) => l._skipDimFold)
477
568
  if (blockLen > FOLD_LINES && !hasExpandedLong) {
478
- const foldKey = `fold-${foldCounter++}`
569
+ // 2026-08-31 会诊三家共识:fold-N 计数器键在 loadOlder/上游 dim 块增减时重绑——
570
+ // 用首行 _lineId 身份化(连续 dim 块首行即稳定锚);无 _lineId 时退 fold-i(防御)
571
+ const keySource = convLines[i]._lineId ?? i
572
+ const foldKey = `fold-${keySource}`
479
573
  if (state.foldEnabled !== false && !state.expandedBlocks?.has(foldKey)) {
480
574
  // FOLDED — unified named-header + last-3 form (same ruling as the
481
575
  // long-message fold above).
@@ -514,13 +608,22 @@ export { buildConvLines }
514
608
 
515
609
  export function renderConversation(state, cols, visibleH, scroll, maxRows) {
516
610
  const convLines = buildConvLines(state, cols, maxRows)
517
- const maxScroll = Math.max(0, convLines.length - visibleH)
518
- const clamped = Math.min(scroll, maxScroll)
519
- const end = convLines.length - clamped
520
- const visible = convLines.slice(Math.max(0, end - visibleH), end)
521
- const pad = visibleH - visible.length
611
+ const { start, end, pad } = convViewport(convLines.length, visibleH, scroll)
612
+ const visible = convLines.slice(start, end)
522
613
  const out = []
523
614
  for (let p = 0; p < pad; p++) out.push("")
524
615
  for (const l of visible) out.push(`${l.color ?? ""}${l.text}${ansi.reset}`)
525
616
  return out
526
617
  }
618
+
619
+ /** 视口数学单源(2026-08-31 会诊 kimi 缺陷 1——convGlobalIndex 未减 pad:
620
+ * 短会话顶部补 pad 空行后命中整体偏移,点击/滚轮落空或错行)。
621
+ * 返回 { start, end, pad }——renderConversation 与鼠标命中测试共用。 */
622
+ export function convViewport(convLen, convH, scroll) {
623
+ const maxScroll = Math.max(0, convLen - convH)
624
+ const clamped = Math.min(scroll, maxScroll)
625
+ const end = Math.max(0, convLen - clamped)
626
+ const start = Math.max(0, end - convH)
627
+ const pad = Math.max(0, convH - (end - start))
628
+ return { start, end, pad }
629
+ }
@@ -63,6 +63,16 @@ export function createRenderLoop(state, agent, ctx, pushLine, write = (s) => pro
63
63
  // restored to full width only after flush stopped the output). dims are
64
64
  // updated by: startup seed + a delayed re-sample + resize events.
65
65
  const dims = state.dims ? state.dims.get() : { cols: process.stdout.columns || startupDims.cols, rows: process.stdout.rows || startupDims.rows }
66
+ // 2026-08-31 流式跟随(会诊 kimi 简化 + deepseek/glm 锚定):_followTail 即完整语义——
67
+ // 开 → 渲染前钉底(tool/pushLine 行同样生效,无跟随空洞);关(用户上滚)→ 锚定补偿:
68
+ // scroll 是距底偏移,暂停期间 convLen 增长会把用户读的行顶走——按增长量补偿。
69
+ const convLen = countConvLines(state, dims.cols, dims.rows)
70
+ if (state._followTail) {
71
+ state.scroll = 0
72
+ } else if (state._pauseAnchorLen != null && convLen > state._pauseAnchorLen) {
73
+ state.scroll += convLen - state._pauseAnchorLen
74
+ }
75
+ state._pauseAnchorLen = convLen
66
76
 
67
77
  // NOTE (§7.2 D6): the old state.outputPanels prune is gone — output panels
68
78
  // are abolished; subagent blocks live in the conversation and are never
@@ -7,7 +7,7 @@ import { slimToolResultForDisplay } from "./tool-events.mjs"
7
7
  * the latest INITIAL_HISTORY_MESSAGES, then PgUp-at-top loads HISTORY_PAGE_MESSAGES
8
8
  * more. Rebuilding an 8000-message session eagerly froze startup + first render. */
9
9
  export const INITIAL_HISTORY_MESSAGES = 200
10
- export const HISTORY_PAGE_MESSAGES = 50
10
+ export const HISTORY_PAGE_MESSAGES = 20
11
11
 
12
12
  /**
13
13
  * Convert history[startIdx, endIdx) into conversation source lines (label lines
@@ -12,6 +12,7 @@
12
12
  */
13
13
 
14
14
  import { C } from "./ansi.mjs"
15
+ import { describeToolArgs } from "./tool-args.mjs"
15
16
 
16
17
  /** `role#id/` prefix router — hyphen included since the eng-coder fix (2026-08-21). */
17
18
  export const SUB_PREFIX_RE = /^([\w-]+)#(\d+)\//
@@ -307,7 +308,10 @@ export function routeSubToolCall(state, name, args, scheduleRender) {
307
308
  sub.currentTool = name.slice(subMatch[0].length)
308
309
  sub.toolArgs = args
309
310
  sub.approval = null
310
- appendSubBlock(sub, "tool", `❯ ${sub.currentTool}${args?.command ? " — " + String(args.command).replace(/\s+/g, " ").trim().slice(0, 80) : ""}\n`, { fresh: true })
311
+ // 2026-08-31: 工具行带参数摘要(与主 agent 工具块同款 describeToolArgs 单源)——
312
+ // 此前只显示工具名(bash 独享 "— 命令"),read/grep/glob 等全裸名。
313
+ const argsDesc = describeToolArgs(sub.currentTool, args)
314
+ appendSubBlock(sub, "tool", `❯ ${sub.currentTool}${argsDesc ? " " + argsDesc : ""}\n`, { fresh: true })
311
315
  scheduleRender()
312
316
  return true
313
317
  }
@@ -36,6 +36,10 @@ export function describeToolArgs(name, args) {
36
36
  const p = a.path ? ` in "${a.path}"` : ""
37
37
  return `/${String(pat)}/${p}`
38
38
  }
39
+ case "ls": {
40
+ const p = a.path ? String(a.path) : "."
41
+ return a.filter ? `${p} (filter: ${String(a.filter)})` : p
42
+ }
39
43
  case "websearch": return String(a.query ?? "")
40
44
  case "subagent": case "coder": case "explore": case "plan": case "eng-coder": {
41
45
  const task = String(a.task ?? "").replace(/\s+/g, " ").trim()