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.
- package/CHANGELOG.md +49 -0
- package/README.md +2 -2
- package/package.json +2 -2
- package/src/acp/bridge.mjs +1 -0
- package/src/advisor/run.mjs +9 -11
- package/src/agent/dispatch.mjs +38 -13
- package/src/agent/helpers.mjs +1 -1
- package/src/agent/setup.mjs +2 -2
- package/src/agent-tools/consult.mjs +0 -1
- package/src/agent-tools/skill.mjs +1 -1
- package/src/agent-tools/task.mjs +0 -2
- package/src/agent-tools/verify.mjs +0 -1
- package/src/agent.mjs +36 -3
- package/src/cli/make-agent.mjs +11 -5
- package/src/config.mjs +8 -103
- package/src/mcp/helpers.mjs +14 -5
- package/src/mcp/transport-http.mjs +79 -27
- package/src/mcp/transport-stdio.mjs +57 -3
- package/src/mcp/transport-ws.mjs +46 -12
- package/src/mcp.mjs +197 -58
- package/src/model-specs.mjs +108 -0
- package/src/prompts/discipline.md +43 -0
- package/src/prompts/system.md +1 -1
- package/src/provider/anthropic.mjs +51 -18
- package/src/provider/core.mjs +121 -102
- package/src/provider/google.mjs +41 -15
- package/src/provider/normalize.mjs +81 -0
- package/src/provider/rate.mjs +5 -0
- package/src/provider/responses.mjs +498 -0
- package/src/provider/retry.mjs +125 -0
- package/src/provider/sse.mjs +58 -24
- package/src/proxy.mjs +36 -6
- package/src/tools/bash.md +2 -2
- package/src/tools/execute.md +1 -1
- package/src/tools/execute.mjs +3 -3
- package/src/tools/fetch.md +1 -0
- package/src/tools/file.mjs +136 -11
- package/src/tools/git.md +4 -2
- package/src/tools/git.mjs +38 -11
- package/src/tools/shared.mjs +6 -3
- package/src/tools/system.mjs +19 -1
- package/src/tools/web.mjs +44 -14
- package/src/tools/websearch.md +3 -1
- package/src/tui/agent-turn.mjs +2 -10
- package/src/tui/clipboard.mjs +3 -1
- package/src/tui/dims.mjs +20 -47
- package/src/tui/fold-block.mjs +59 -11
- package/src/tui/index.mjs +65 -75
- package/src/tui/key-handler.mjs +4 -1
- package/src/tui/mouse.mjs +47 -7
- package/src/tui/render-conversation.mjs +226 -124
- package/src/tui/render-frame.mjs +7 -2
- package/src/tui/render-loop.mjs +10 -0
- package/src/tui/render.mjs +12 -1
- package/src/tui/startup.mjs +1 -2
- package/src/tui/subagent-blocks.mjs +6 -1
- package/src/tui/tool-args.mjs +4 -0
- 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,
|
|
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
|
-
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
?
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
//
|
|
269
|
-
//
|
|
270
|
-
//
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
519
|
-
const
|
|
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
|
+
}
|
package/src/tui/render-frame.mjs
CHANGED
|
@@ -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
|
|
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
|
package/src/tui/render-loop.mjs
CHANGED
|
@@ -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
|
package/src/tui/render.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/src/tui/startup.mjs
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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
|
}
|
package/src/tui/tool-args.mjs
CHANGED
|
@@ -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()
|
package/src/tui/tool-events.mjs
CHANGED
|
@@ -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
|
-
*
|
|
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,
|
|
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
|