thincoder 0.12.51 → 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.
Files changed (58) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/README.md +2 -2
  3. package/package.json +2 -2
  4. package/src/acp/bridge.mjs +1 -0
  5. package/src/advisor/run.mjs +9 -11
  6. package/src/agent/dispatch.mjs +38 -13
  7. package/src/agent/helpers.mjs +1 -1
  8. package/src/agent/setup.mjs +2 -2
  9. package/src/agent-tools/consult.mjs +0 -1
  10. package/src/agent-tools/skill.mjs +1 -1
  11. package/src/agent-tools/task.mjs +0 -2
  12. package/src/agent-tools/verify.mjs +0 -1
  13. package/src/agent.mjs +36 -3
  14. package/src/cli/make-agent.mjs +11 -5
  15. package/src/config.mjs +8 -103
  16. package/src/mcp/helpers.mjs +14 -5
  17. package/src/mcp/transport-http.mjs +79 -27
  18. package/src/mcp/transport-stdio.mjs +57 -3
  19. package/src/mcp/transport-ws.mjs +46 -12
  20. package/src/mcp.mjs +197 -58
  21. package/src/model-specs.mjs +108 -0
  22. package/src/prompts/discipline.md +43 -0
  23. package/src/prompts/system.md +1 -1
  24. package/src/provider/anthropic.mjs +51 -18
  25. package/src/provider/core.mjs +121 -102
  26. package/src/provider/google.mjs +41 -15
  27. package/src/provider/normalize.mjs +81 -0
  28. package/src/provider/rate.mjs +5 -0
  29. package/src/provider/responses.mjs +498 -0
  30. package/src/provider/retry.mjs +125 -0
  31. package/src/provider/sse.mjs +58 -24
  32. package/src/proxy.mjs +36 -6
  33. package/src/tools/bash.md +2 -2
  34. package/src/tools/execute.md +1 -1
  35. package/src/tools/execute.mjs +3 -3
  36. package/src/tools/fetch.md +1 -0
  37. package/src/tools/file.mjs +136 -11
  38. package/src/tools/git.md +4 -2
  39. package/src/tools/git.mjs +38 -11
  40. package/src/tools/shared.mjs +6 -3
  41. package/src/tools/system.mjs +19 -1
  42. package/src/tools/web.mjs +44 -14
  43. package/src/tools/websearch.md +3 -1
  44. package/src/tui/agent-turn.mjs +2 -10
  45. package/src/tui/clipboard.mjs +3 -1
  46. package/src/tui/dims.mjs +20 -47
  47. package/src/tui/fold-block.mjs +59 -11
  48. package/src/tui/index.mjs +65 -75
  49. package/src/tui/key-handler.mjs +4 -1
  50. package/src/tui/mouse.mjs +47 -7
  51. package/src/tui/render-conversation.mjs +226 -124
  52. package/src/tui/render-frame.mjs +7 -2
  53. package/src/tui/render-loop.mjs +10 -0
  54. package/src/tui/render.mjs +12 -1
  55. package/src/tui/startup.mjs +1 -2
  56. package/src/tui/subagent-blocks.mjs +6 -1
  57. package/src/tui/tool-args.mjs +4 -0
  58. package/src/tui/tool-events.mjs +2 -4
@@ -12,7 +12,7 @@
12
12
  import { ansi, C } from "./ansi.mjs"
13
13
  import { formatTables, sanitizeDisplay, sliceByWidth, wrapText } from "./render.mjs"
14
14
  import {
15
- isExpanded, foldHintLine, blankLine, renderExpandedBlock, renderBlockTimeline,
15
+ isExpanded, foldHintLine, renderExpandedBlock, renderBlockTimeline,
16
16
  renderMathAndMarkdown, foldCapRows, renderFoldedHead, foldTailLines,
17
17
  } from "./fold-block.mjs"
18
18
  import { ADVISOR_THINKING_PLACEHOLDER } from "../advisor/run.mjs"
@@ -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,13 +225,12 @@ 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).
82
232
  * No indent — flush with the content below it; the caller adds a blank line BEFORE it
83
233
  * so the control line stands apart from unrelated content (reported UX). */
84
- // foldHintLine/blankLine moved to fold-block.mjs (shared with the component).
85
234
 
86
235
  function highlightSearchMatches(text, query, matchesInLine, globalCurrentIndex, allMatches, lineIndex) {
87
236
  if (!matchesInLine || matchesInLine.length === 0 || !query) return text
@@ -190,47 +339,45 @@ function buildConvLines(state, cols, maxRows) {
190
339
  // Frozen subagent activity block (§7.2 D4, 2026-08-30): rendered as its own
191
340
  // collapsible section — clickable expand/collapse like the running block.
192
341
  if (l._frozenSubTask) {
193
- 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
+ }
194
357
  continue
195
358
  }
196
359
  // ONE BLOCK PER TOOL CALL (2026-08-30 user ruling): header = name+args+
197
360
  // live status, body = args JSON + streaming output + result. Folded =
198
361
  // ▶ name args · status/summary; expanded = 60%-capped body (shared component).
199
362
  if (l._toolBlock) {
363
+ // 2026-08-31 段缓存:工具块签名含三缓冲长度+done/elapsed/summary+该块展开态——
364
+ // 流式 append 使 output.length 变 → 失效;real 会话 106 个工具块每帧全量 wrap 实测
365
+ // 32ms(rebuild 40ms 的大头),入缓存后命中只算签名拼接。
200
366
  const b = l._toolBlock
201
- // Stable key from the line's own id (P1 2026-08-30): the line may shift
202
- // index when loadOlder unshifts older pages — positional tool-${i} would
203
- // re-bind the expand state to a different tool block.
204
- const foldKey = `tool-${l._lineId ?? i}`
205
- const status = !b.done
206
- ? "running"
207
- : `${b.elapsed !== null ? b.elapsed + "ms" : ""}${b.summary ? (b.elapsed !== null ? " · " : "") + sliceByWidth(b.summary, 50) : ""}`.trim() || "done"
208
- if (isExpanded(state, foldKey)) {
209
- const body = []
210
- const pushWrapped = (raw, color) => {
211
- for (const w of wrapText(raw, cols - 4)) body.push({ text: " " + w, color, _skipDimFold: true })
212
- }
213
- for (const jl of b.argsJson) pushWrapped(jl, C.dim)
214
- for (const ol of b.output) pushWrapped(ol, C.tool)
215
- if (b.result) for (const rl of b.result) pushWrapped(rl, C.dim)
216
- 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)
217
377
  } else {
218
- // Head MUST be width-bounded: argsSummary for unknown/MCP tools is a
219
- // JSON.stringify dump that can be thousands of chars — an overwide header
220
- // row makes the terminal soft-wrap mid-frame, shifting every panel below
221
- // (the "code breaks the input box border" report, 2026-08-30).
222
- const headText = sliceByWidth(
223
- `❯ ${b.name}${b.roundTag || ""}${b.argsSummary ? " " + b.argsSummary : ""} · ${status}`,
224
- Math.max(20, cols - 2),
225
- )
226
- const body = []
227
- for (const jl of b.argsJson) for (const w of wrapText(jl, cols - 4)) body.push({ text: w, color: C.dim, _skipDimFold: true })
228
- 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 })
229
- // Result lines join the tail pool too — restore carrier has no output
230
- // rows, so without this its folded tail showed only args JSON and the
231
- // result vanished from the folded view (parity bug, 2026-08-30).
232
- 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 })
233
- 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)
234
381
  }
235
382
  continue
236
383
  }
@@ -239,93 +386,37 @@ function buildConvLines(state, cols, maxRows) {
239
386
  // rendered, no gutter — review history convention kept from the flat era),
240
387
  // 60% cap via the shared component.
241
388
  if (l._frozenAdvisor) {
242
- const frozenAdvKey = `advisor-done-${i}`
243
- if (isExpanded(state, frozenAdvKey)) {
244
- const body = []
245
- const rendered = renderMathAndMarkdown(sanitizeDisplay(l._frozenAdvisor))
246
- for (const line of formatTables(rendered, cols - 1)) {
247
- for (const wrapped of wrapText(line, cols - 1)) {
248
- body.push({ text: wrapped, color: C.reason, _skipDimFold: true })
249
- }
250
- }
251
- 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)
252
401
  } else {
253
- convLines.push({
254
- text: `▶ [advisor · review done] click to expand`,
255
- color: C.fold,
256
- _foldToggle: frozenAdvKey,
257
- })
402
+ const rows = buildFrozenAdvSeg(state, l, i, cols, maxRows)
403
+ _lineSegCache.set(l, { textRef: l._frozenAdvisor, sig: aSig, rows })
404
+ convLines.push(...rows)
258
405
  }
259
406
  continue
260
407
  }
261
- let text = l.text
262
-
263
- // Apply search highlighting
264
- if (state.search && state.search.query && l._searchMatches) {
265
- text = highlightSearchMatches(text, state.search.query, l._searchMatches, state.search.index, state.search.matches, i)
266
- }
267
408
 
268
- // Long-message folding (2026-08-30 user ruling): MAIN OUTPUT / user messages
269
- // (C.text) NEVER fold — primary conversation content is read by scrolling,
270
- // not by expanding; a folded core answer hid the actual result behind a
271
- // click. Foldable subjects narrow to THINKING (C.reason) and dim tool
272
- // summaries the auxiliary streams. (This re-enacts the pre-0.12.7 rule
273
- // for main output only; the 0.12.7 "revert" had reopened folding for it.)
274
- // Keyed by the source-line index (`long-${i}`) so the toggle survives
275
- // re-renders.
276
- const longKey = `long-${i}`
277
- // Single source of truth: the producer stamps _kind ("thinking" / "text" /
278
- // "tool") — buildConvLines READS the stamp instead of GUESSING from color.
279
- // Three producers (live flushStream / restored historyToLines / injected
280
- // lines) now emit the identical grammar; the renderer is one place.
281
- // Fallback: unstamped lines keep the legacy color-based inference (defensive
282
- // for any path this refactor missed — empty until proven otherwise).
283
- const isReasoning = l._kind === "thinking" || (l._kind === undefined && l.color === C.reason)
284
- // Foldable classes: thinking (ALWAYS — threshold 0) and dim auxiliaries.
285
- // "text" (main output / user messages) NEVER folds.
286
- const foldable = isReasoning || (l._kind === "tool" || (l._kind === undefined && l.color === C.dim) || (l._kind === undefined && l.color !== C.text && l.color !== C.reason))
287
- const threshold = isReasoning ? 0 : LONG_FOLD_LINES
288
- const folded = foldable && state.foldEnabled !== false && !state.expandedBlocks?.has(longKey)
289
- const block = []
290
- // Lightweight markdown display (IK5VW3): render BEFORE measuring — the
291
- // table column math (formatTables) and wrapping must see the RENDERED
292
- // text (ANSI consumes zero display width; the width functions are
293
- // ANSI-aware). Rendering after wrapping measured raw markdown
294
- // (`**bold**` = 8) against displayed text (4) and sliced markers
295
- // mid-sequence — the table misalignment the user kept reporting.
296
- const renderedText = renderMathAndMarkdown(sanitizeDisplay(text))
297
- for (const line of formatTables(renderedText, cols - 1)) {
298
- for (const wrapped of wrapText(line, cols - 1)) {
299
- block.push({ text: wrapped, color: l.color, _foldId: l._foldId, _src: i })
300
- }
301
- }
302
- if (folded && block.length > threshold) {
303
- // FOLDED — unified form (fold-block.mjs renderFoldedHead, 2026-08-30 user
304
- // ruling): named identity header + last 3 lines. Replaces the legacy
305
- // [first 4, anonymous ▶ at the ellipsis, last] whose orphaned-looking
306
- // "… N more lines" segment confused the scrollback.
307
- const kind = l.color === C.reason ? "thinking" : l.color === C.dim ? "tool output" : "message"
308
- convLines.push(...renderFoldedHead({
309
- header: foldHintLine(`▶ ${kind} · ${block.length} lines — click to expand`, longKey, i),
310
- body: block, cols,
311
- }))
312
- } else if (foldable && block.length > threshold) {
313
- if (state.foldEnabled === false) {
314
- // Folding fully off — content already fully visible; a "click to
315
- // collapse" hint would be misleading (toggling has no effect).
316
- convLines.push(...block)
317
- } else {
318
- // EXPANDED thinking/dim long block via the shared component: blank + ▼
319
- // control at the HEAD, content, 60% cap with a bottom collapse control.
320
- // DIM blocks must not re-trigger the consecutive-dim folding below
321
- // (folding stacked on folding — reported regression).
322
- if (l.color === C.dim) {
323
- for (const line of block) line._skipDimFold = true
324
- }
325
- convLines.push(...renderExpandedBlock({ body: block, foldKey: longKey, state, maxRows, cols, label: `${block.length} lines` }))
326
- }
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)
327
416
  } else {
328
- 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)
329
420
  }
330
421
  // Trailing blank after a main-output segment (user request 2026-08-30) —
331
422
  // landed after the segment's rendered content.
@@ -464,7 +555,6 @@ function buildConvLines(state, cols, maxRows) {
464
555
  }
465
556
  // Fold long blocks (> 8 consecutive dim lines)
466
557
  const FOLD_LINES = 8
467
- let foldCounter = 0
468
558
  const folded = []
469
559
  let i = 0
470
560
  while (i < convLines.length) {
@@ -476,7 +566,10 @@ function buildConvLines(state, cols, maxRows) {
476
566
  // Expanded long-fold blocks are exempt — otherwise folding stacks on folding
477
567
  const hasExpandedLong = convLines.slice(i, j).some((l) => l._skipDimFold)
478
568
  if (blockLen > FOLD_LINES && !hasExpandedLong) {
479
- 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}`
480
573
  if (state.foldEnabled !== false && !state.expandedBlocks?.has(foldKey)) {
481
574
  // FOLDED — unified named-header + last-3 form (same ruling as the
482
575
  // long-message fold above).
@@ -515,13 +608,22 @@ export { buildConvLines }
515
608
 
516
609
  export function renderConversation(state, cols, visibleH, scroll, maxRows) {
517
610
  const convLines = buildConvLines(state, cols, maxRows)
518
- const maxScroll = Math.max(0, convLines.length - visibleH)
519
- const clamped = Math.min(scroll, maxScroll)
520
- const end = convLines.length - clamped
521
- const visible = convLines.slice(Math.max(0, end - visibleH), end)
522
- const pad = visibleH - visible.length
611
+ const { start, end, pad } = convViewport(convLines.length, visibleH, scroll)
612
+ const visible = convLines.slice(start, end)
523
613
  const out = []
524
614
  for (let p = 0; p < pad; p++) out.push("")
525
615
  for (const l of visible) out.push(`${l.color ?? ""}${l.text}${ansi.reset}`)
526
616
  return out
527
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
+ }
@@ -8,13 +8,18 @@
8
8
  */
9
9
  import { ansi, C, ESC } from "./ansi.mjs"
10
10
  import { convCacheKey, renderConversation, countConvLines } from "./render-conversation.mjs"
11
- import { sliceByWidth, stringWidth, sanitizeDisplay } from "./render.mjs"
11
+ import { sliceByWidth, stringWidth } from "./render.mjs"
12
12
  import { specForModel } from "../config.mjs"
13
13
  import { computeLayout } from "./layout.mjs"
14
14
  import { basename } from "node:path"
15
+ import { readFileSync } from "node:fs"
15
16
 
16
17
  export { convCacheKey, renderConversation, countConvLines } from "./render-conversation.mjs"
17
18
 
19
+ // Module-load read, once per process (same pattern as cmd-upgrade.mjs): the
20
+ // header shows the installed version next to the logo (user request 2026-08-31).
21
+ const THINCODER_VERSION = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8")).version
22
+
18
23
  // ---------- status bar slash-command hints ----------
19
24
  const SLASH_HINTS = {
20
25
  "/config": "open config menu",
@@ -42,7 +47,7 @@ export function renderHeader(agent, cols) {
42
47
  const thinkBadge = t?.type === "disabled" ? "│ think: off"
43
48
  : effort ? `│ think: ${effort}`
44
49
  : t?.type === thinkOnValue ? "│ think: on" : ""
45
- return `${ansi.bold}${C.tool} ThinCoder ${ansi.reset}${ansi.dim}│ ${sliceByWidth(model, 30)}${thinkBadge ? " " + thinkBadge : ""} │ ${sliceByWidth(basename(agent.cwd), Math.max(10, cols - 60))}${ansi.reset}`
50
+ return `${ansi.bold}${C.tool} ThinCoder ${ansi.reset}${ansi.dim}${THINCODER_VERSION} │ ${sliceByWidth(model, 30)}${thinkBadge ? " " + thinkBadge : ""} │ ${sliceByWidth(basename(agent.cwd), Math.max(10, cols - 60))}${ansi.reset}`
46
51
  }
47
52
 
48
53
  /** Todo/task panel. Returns empty array when no tasks visible. The first row is
@@ -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
@@ -203,6 +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
207
  const ANSI_SEQUENCE = /\x1b\[[0-9;?]*[a-zA-Z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[()][0-9A-B]|\x1b[=>#][0-9]?/
207
208
  // Global variant for replace()/split(); the non-global one keeps match.index for slicing
208
209
  const ANSI_SEQUENCE_RE = new RegExp(ANSI_SEQUENCE.source, "g")
@@ -211,12 +212,22 @@ export function sanitizeDisplay(s) {
211
212
  .replace(ANSI_SEQUENCE_RE, "")
212
213
  // §7.2 D5 fallback: an unparsed ⟦ev⟧ event token must never reach the grid —
213
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 双线分隔)
214
216
  .replace(/⟦ev⟧[^\x1e\x1d]*\x1e[^\x1e\x1d]*\x1e[^\x1e\x1d]*\x1e[^\x1e\x1d]*\x1e?/g, "")
215
- .replace(/⟦ev⟧[^\x1e\x1d]*/g, "")
217
+ // GitHub-#4-class pitfall (2026-08-31): the residue strip used to be
218
+ // /⟦ev⟧[^\x1e\x1d]*/ — "swallow to end of line/string", which ATE REAL
219
+ // CONTENT when the user-visible text legitimately contains the literal
220
+ // sentinel (e.g. a table describing the ACP bridge's ⟦ev⟧ stripping —
221
+ // everything from the sentinel to the end vanished on screen). Real relay
222
+ // tokens start with a phase word (turn/approval); a bare sentinel with no
223
+ // letters attached is not a live token. Strip only sentinel+letters.
224
+ .replace(/⟦ev⟧[A-Za-z]*/g, "")
225
+ // eslint-disable-next-line no-control-regex -- 有意为之:控制字符协议/转义序列剥离正则(ANSI/⟦ev⟧/SGR/history 双线分隔)
216
226
  .replace(/[\x1d\x1e]/g, "")
217
227
  .replace(/\r\n/g, "\n")
218
228
  .replace(/\r/g, "\n")
219
229
  .replace(/\t/g, " ")
230
+ // eslint-disable-next-line no-control-regex -- 有意为之:控制字符协议/转义序列剥离正则(ANSI/⟦ev⟧/SGR/history 双线分隔)
220
231
  .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "")
221
232
  .replace(/\n+$/, "")
222
233
  }
@@ -1,14 +1,13 @@
1
1
  import { listSlots } from "../session.mjs"
2
2
  import { ansi, C } from "./ansi.mjs"
3
3
  import { describeToolArgs, toolArgsLines } from "./tool-args.mjs"
4
- import { sliceByWidth } from "./render.mjs"
5
4
  import { slimToolResultForDisplay } from "./tool-events.mjs"
6
5
 
7
6
  /** Lazy history window (parity with VS Code HISTORY_PAGE_SIZE): first paint loads
8
7
  * the latest INITIAL_HISTORY_MESSAGES, then PgUp-at-top loads HISTORY_PAGE_MESSAGES
9
8
  * more. Rebuilding an 8000-message session eagerly froze startup + first render. */
10
9
  export const INITIAL_HISTORY_MESSAGES = 200
11
- export const HISTORY_PAGE_MESSAGES = 50
10
+ export const HISTORY_PAGE_MESSAGES = 20
12
11
 
13
12
  /**
14
13
  * Convert history[startIdx, endIdx) into conversation source lines (label lines
@@ -12,10 +12,12 @@
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+)\//
18
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 双线分隔)
19
21
  export const SUB_EVENT_RE = /^⟦ev⟧(turn|approval)\x1e([^\x1e]*)\x1e([^\x1e]*)\x1e([^\x1e]*)\x1e?([\s\S]*)$/
20
22
  /** N2: per-child display-line ring buffer cap — oldest lines drop with a marker. */
21
23
  export const SUB_BLOCK_LINE_LIMIT = 500
@@ -306,7 +308,10 @@ export function routeSubToolCall(state, name, args, scheduleRender) {
306
308
  sub.currentTool = name.slice(subMatch[0].length)
307
309
  sub.toolArgs = args
308
310
  sub.approval = null
309
- 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 })
310
315
  scheduleRender()
311
316
  return true
312
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()
@@ -12,7 +12,6 @@
12
12
  * flushStream 同时返回给调用方(回合循环 / onTurnEnd 共用)。纯回调装配,无终端副作用
13
13
  * (除经 deps 注入的 pushLine/render)。
14
14
  */
15
- import { sliceByWidth } from "./render.mjs"
16
15
  import { C } from "./ansi.mjs"
17
16
  import { formatToolSummary } from "./tool-summaries.mjs"
18
17
  import { describeToolArgs, toolArgsLines } from "./tool-args.mjs"
@@ -129,10 +128,9 @@ function findToolBlock(state, name, toolId) {
129
128
 
130
129
 
131
130
  /** Build the agent callbacks + the shared flushStream for one turn.
132
- * deps: { agent, state, pushLine, render, scheduleRender, ensureAssistantLabel,
133
- * askPermission, askQuestion, summarize, saveSessionImpl } */
131
+ * askPermission, askQuestion, saveSessionImpl } */
134
132
  export function buildToolCallbacks(deps) {
135
- const { agent, state, pushLine, render, scheduleRender, ensureAssistantLabel, askPermission, askQuestion, summarize, saveSessionImpl } = deps
133
+ const { agent, state, pushLine, render, scheduleRender, ensureAssistantLabel, askPermission, askQuestion, saveSessionImpl } = deps
136
134
 
137
135
  // NOTE: advisor buffers (_advisorThink/advisorStreaming) are cleared here too.
138
136
  // Timing safety: onToolResult flushes _advisorThink into history and empties