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.
- package/CHANGELOG.md +19 -0
- package/package.json +1 -1
- package/src/advisor/run.mjs +9 -11
- package/src/agent/dispatch.mjs +38 -13
- package/src/agent.mjs +34 -0
- package/src/cli/make-agent.mjs +11 -5
- 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/prompts/discipline.md +43 -0
- package/src/provider/anthropic.mjs +51 -18
- package/src/provider/core.mjs +121 -36
- package/src/provider/google.mjs +41 -15
- 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 +35 -8
- package/src/tools/shared.mjs +5 -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/fold-block.mjs +59 -11
- package/src/tui/index.mjs +56 -45
- package/src/tui/key-handler.mjs +3 -1
- package/src/tui/mouse.mjs +46 -7
- package/src/tui/render-conversation.mjs +225 -122
- package/src/tui/render-loop.mjs +10 -0
- package/src/tui/startup.mjs +1 -1
- package/src/tui/subagent-blocks.mjs +5 -1
- package/src/tui/tool-args.mjs +4 -0
package/src/tools/shared.mjs
CHANGED
|
@@ -447,10 +447,12 @@ export function htmlToText(html) {
|
|
|
447
447
|
.trim()
|
|
448
448
|
}
|
|
449
449
|
|
|
450
|
-
/** Execute a git command. maxBuffer 10MB prevents large diff/log overflow; on overflow, returns truncated partial output rather than empty.
|
|
451
|
-
|
|
450
|
+
/** Execute a git command. maxBuffer 10MB prevents large diff/log overflow; on overflow, returns truncated partial output rather than empty.
|
|
451
|
+
* config: optional array of `-c key=value` overrides (e.g. ["http.proxy=http://10.2.2.112:3128"]) —
|
|
452
|
+
* inserted verbatim after `git`, so network actions (push/fetch/pull/ls-remote) can route through a proxy. */
|
|
453
|
+
export function runGit(cwd, cmdArgs, config = []) {
|
|
452
454
|
try {
|
|
453
|
-
return execFileSync("git", cmdArgs, { cwd, encoding: "utf8", maxBuffer: 10 * 1024 * 1024, stdio: ["ignore", "pipe", "ignore"] }).trim().replace(/\r/g, "")
|
|
455
|
+
return execFileSync("git", [...config, ...cmdArgs], { cwd, encoding: "utf8", maxBuffer: 10 * 1024 * 1024, stdio: ["ignore", "pipe", "ignore"] }).trim().replace(/\r/g, "")
|
|
454
456
|
} catch (e) {
|
|
455
457
|
// maxBuffer overflow: e.stdout contains partial collected output — return it
|
|
456
458
|
// (callers show "(truncated)"-style tails). ALL OTHER errors (non-git repo,
|
package/src/tools/system.mjs
CHANGED
|
@@ -31,6 +31,22 @@ function applyLineFilter(output, filter) {
|
|
|
31
31
|
return truncate(lines.join("\n"))
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/** POSIX-only constructs that cmd.exe reads literally (and thus breaks). Detected
|
|
35
|
+
* so the agent is told IMMEDIATELY instead of chasing a confusing failure — warning
|
|
36
|
+
* only, never a block (the approval layer is the real gate, same as destructive-command policy). */
|
|
37
|
+
function posixSyntaxHint(command) {
|
|
38
|
+
const hits = []
|
|
39
|
+
if (/\$\([^)]*\)/.test(command)) hits.push("$(...)")
|
|
40
|
+
if (command.includes("`")) hits.push("backtick")
|
|
41
|
+
if (/;\s+/.test(command)) hits.push("';' separators (cmd.exe needs && or newline)")
|
|
42
|
+
if (/2>\s*\/dev\/null|>\s*\/dev\/null|&>\s*\/dev\/null/.test(command)) hits.push("/dev/null (use NUL)")
|
|
43
|
+
if (/'.*'/.test(command)) hits.push("single quotes (cmd.exe doesn't group)")
|
|
44
|
+
if (/\$\{[A-Za-z_]/.test(command)) hits.push("${VAR} (use %VAR%)")
|
|
45
|
+
if (!hits.length) return ""
|
|
46
|
+
return "[hint: POSIX-only construct(s) detected — " + hits.join(", ") + ". Current shell is cmd.exe; these will NOT work. Use && / NUL / %VAR%, or use the execute tool (node) for complex logic]"
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
34
50
|
// ====================================================================
|
|
35
51
|
// bash — command execution with safety gates
|
|
36
52
|
// ====================================================================
|
|
@@ -259,7 +275,9 @@ export const bashTool = {
|
|
|
259
275
|
shell: ctx.agent?.config?.shell ?? null,
|
|
260
276
|
})
|
|
261
277
|
const filtered = args.filter ? applyLineFilter(result, args.filter) : result
|
|
262
|
-
|
|
278
|
+
const hint = posixSyntaxHint(args.command)
|
|
279
|
+
const body = guard ? `${guard.notice}\n\n${filtered}` : filtered
|
|
280
|
+
return hint ? `${hint}\n${body}` : body
|
|
263
281
|
},
|
|
264
282
|
}
|
|
265
283
|
|
package/src/tools/web.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { DESC, truncate, stripTags, htmlToText, isPrivateHost } from "./shared.mjs";
|
|
2
2
|
import { URL } from "node:url";
|
|
3
|
-
import {
|
|
3
|
+
import { proxyFetch } from "../proxy.mjs";
|
|
4
4
|
|
|
5
5
|
export const UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
|
|
6
6
|
const FETCH_TIMEOUT = 15_000
|
|
@@ -41,8 +41,9 @@ const ENGINES = [{ name: "bing", label: "Bing", url: bingUrl, extract: extractBi
|
|
|
41
41
|
const ENGINE_NAMES = ENGINES.map(e => e.name)
|
|
42
42
|
|
|
43
43
|
/** Structured search via Tavily (optional — config.websearch.apiKey). Returns
|
|
44
|
-
* { engine, results } or null to fall back to Bing HTML scraping.
|
|
45
|
-
|
|
44
|
+
* { engine, results } or null to fall back to Bing HTML scraping.
|
|
45
|
+
* proxyUri: explicit per-call proxy (args.proxy) — never the config.json one. */
|
|
46
|
+
async function fetchTavily(query, limit, ctx, proxyUri) {
|
|
46
47
|
const apiKey = ctx?.agent?.config?.websearch?.apiKey
|
|
47
48
|
if (!apiKey) return null
|
|
48
49
|
const ctrl = new AbortController()
|
|
@@ -53,7 +54,7 @@ async function fetchTavily(query, limit, ctx) {
|
|
|
53
54
|
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${apiKey}` },
|
|
54
55
|
body: JSON.stringify({ query, search_depth: "basic", max_results: limit, include_answer: false, include_raw_content: false }),
|
|
55
56
|
signal: ctrl.signal,
|
|
56
|
-
},
|
|
57
|
+
}, proxyUri)
|
|
57
58
|
if (!response.ok) return null
|
|
58
59
|
const data = await response.json()
|
|
59
60
|
const results = (Array.isArray(data.results) ? data.results : []).map((r) => ({
|
|
@@ -64,14 +65,14 @@ async function fetchTavily(query, limit, ctx) {
|
|
|
64
65
|
finally { clearTimeout(timer) }
|
|
65
66
|
}
|
|
66
67
|
|
|
67
|
-
async function fetchEngine(engine, query, page,
|
|
68
|
+
async function fetchEngine(engine, query, page, proxyUri) {
|
|
68
69
|
const ctrl = new AbortController()
|
|
69
70
|
const timer = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT)
|
|
70
71
|
try {
|
|
71
72
|
const response = await proxyFetch(engine.url(query, page), {
|
|
72
73
|
headers: { "User-Agent": engine.ua, "Accept-Language": "en-US,en;q=0.9,zh-CN;q=0.8" },
|
|
73
74
|
signal: ctrl.signal,
|
|
74
|
-
},
|
|
75
|
+
}, proxyUri)
|
|
75
76
|
if (!response.ok) return null
|
|
76
77
|
const html = await response.text()
|
|
77
78
|
const results = engine.extract(html)
|
|
@@ -90,6 +91,7 @@ export const websearchTool = {
|
|
|
90
91
|
limit: { type: "number", description: "Max results (default 8, max 20)" },
|
|
91
92
|
engine: { type: "string", enum: ENGINE_NAMES, description: "Specific engine — \"bing\" (Bing). Omit to search all engines concurrently." },
|
|
92
93
|
page: { type: "number", description: "Page number for pagination (1-based, default 1). Only used when engine is specified." },
|
|
94
|
+
proxy: { type: "string", description: "http://host:port explicit proxy (optional) — use ONLY when passed; no proxy = direct. config.json proxy is NOT auto-applied (2026-08-31 ruling: fixed config breaks domestic sites)" },
|
|
93
95
|
},
|
|
94
96
|
required: ["query"],
|
|
95
97
|
},
|
|
@@ -97,20 +99,23 @@ export const websearchTool = {
|
|
|
97
99
|
async execute(args, ctx) {
|
|
98
100
|
const limit = Math.min(args.limit ?? 8, 20)
|
|
99
101
|
const page = Math.max(1, args.page ?? 1)
|
|
102
|
+
// 2026-08-31 ruling: proxy is a PER-CALL decision (args.proxy), never the config.json
|
|
103
|
+
// fixed configuration. Model picks by target: github/foreign → pass proxy; gitee/domestic → omit.
|
|
104
|
+
const proxyUri = args.proxy ?? null
|
|
100
105
|
// Structured search first when a Tavily key is configured — stable, dated,
|
|
101
106
|
// no HTML scraping. Falls back to Bing silently.
|
|
102
|
-
const tavily = await fetchTavily(args.query, limit, ctx)
|
|
107
|
+
const tavily = await fetchTavily(args.query, limit, ctx, proxyUri)
|
|
103
108
|
if (tavily && tavily.results.length > 0) {
|
|
104
109
|
return truncate(tavily.results.slice(0, limit).map((r, i) => `${i + 1}. [tavily] ${r.title}\n ${r.href}\n ${r.snippet}`).join("\n\n"))
|
|
105
110
|
}
|
|
106
111
|
if (args.engine) {
|
|
107
112
|
const engine = ENGINES.find(e => e.name === args.engine)
|
|
108
113
|
if (!engine) return `Unknown engine '${args.engine}'. Available: ${ENGINE_NAMES.join(", ")}`
|
|
109
|
-
const fetched = await fetchEngine(engine, args.query, page,
|
|
114
|
+
const fetched = await fetchEngine(engine, args.query, page, proxyUri)
|
|
110
115
|
if (!fetched || fetched.results.length === 0) return "(no results)"
|
|
111
116
|
return truncate(fetched.results.slice(0, limit).map((r, i) => `${i + 1}. [${engine.label}] ${r.title}\n ${r.href}\n ${r.snippet}`).join("\n\n"))
|
|
112
117
|
}
|
|
113
|
-
const promises = ENGINES.map(e => fetchEngine(e, args.query, 1,
|
|
118
|
+
const promises = ENGINES.map(e => fetchEngine(e, args.query, 1, proxyUri))
|
|
114
119
|
const fetched = (await Promise.all(promises)).filter(Boolean)
|
|
115
120
|
if (fetched.length === 0) return "(no results)"
|
|
116
121
|
const merged = [], indexes = fetched.map(() => 0)
|
|
@@ -156,16 +161,39 @@ export function resolveRedirectTarget(loc, baseUrl) {
|
|
|
156
161
|
return { target }
|
|
157
162
|
}
|
|
158
163
|
|
|
164
|
+
/** Heuristic: a fetch result that is a near-empty shell or a region-block page hides
|
|
165
|
+
* the real content. Flags the pattern so the model switches strategy instead of
|
|
166
|
+
* re-fetching blind (2026-08-31: 21-char MiniMax SPA shell + "App unavailable in
|
|
167
|
+
* region" Claude page both wasted an hour of round trips). */
|
|
168
|
+
export function detectSparseHtml(html, text) {
|
|
169
|
+
if (text.length >= 300) return ""
|
|
170
|
+
if (/unavailable in (your )?region|not available in (your )?region|app-unavailable|enable.?javascript|just a moment|attention required|cf-browser-verification/i.test(html)) {
|
|
171
|
+
return "\n\n[fetch hint: page looks region-blocked or JS-gated (body <300 chars). Try a search MCP tool (configured provider) or the site's .md/raw/mirror endpoint]"
|
|
172
|
+
}
|
|
173
|
+
if (/<div[^>]+id="(app|root)"[^>]*>\s*<\/div>|id="app"|id="root"/i.test(html)) {
|
|
174
|
+
return "\n\n[fetch hint: page is a JS-rendered SPA shell (body <300 chars) — content loads client-side. Try a search MCP tool or the site's .md/API endpoint]"
|
|
175
|
+
}
|
|
176
|
+
return ""
|
|
177
|
+
}
|
|
178
|
+
|
|
159
179
|
export const fetchTool = {
|
|
160
180
|
name: "fetch",
|
|
161
181
|
description: DESC("fetch"),
|
|
162
|
-
parameters: {
|
|
182
|
+
parameters: {
|
|
183
|
+
type: "object",
|
|
184
|
+
properties: {
|
|
185
|
+
url: { type: "string", description: "http/https URL" },
|
|
186
|
+
proxy: { type: "string", description: "http://host:port explicit proxy (optional) — use ONLY when passed; no proxy = direct. config.json proxy is NOT auto-applied (2026-08-31 ruling); pick per target (github/foreign sites need a proxy, gitee/domestic don't)" },
|
|
187
|
+
},
|
|
188
|
+
required: ["url"],
|
|
189
|
+
},
|
|
163
190
|
readonly: true,
|
|
164
|
-
async execute(args,
|
|
191
|
+
async execute(args, _ctx) {
|
|
165
192
|
if (!/^https?:\/\//.test(args.url)) throw new Error("url must start with http:// or https://")
|
|
166
193
|
if (isPrivateUrl(args.url)) throw new Error("fetch blocked: internal/private/metadata addresses are not allowed")
|
|
167
194
|
try {
|
|
168
|
-
|
|
195
|
+
// 2026-08-31 ruling: per-call decision — args.proxy when passed, direct otherwise.
|
|
196
|
+
const proxyUri = args.proxy ?? null
|
|
169
197
|
const response = await proxyFetch(args.url, { headers: { "User-Agent": UA } }, proxyUri)
|
|
170
198
|
if (!response.ok) {
|
|
171
199
|
if ([301, 302, 307, 308].includes(response.status)) {
|
|
@@ -179,14 +207,16 @@ export const fetchTool = {
|
|
|
179
207
|
if (!r2.ok) throw new Error(`fetch failed: HTTP ${r2.status}`)
|
|
180
208
|
const ct2 = headerOf(r2, "content-type") ?? ""
|
|
181
209
|
const b2 = await r2.text()
|
|
182
|
-
|
|
210
|
+
if (ct2.includes("text/html")) { const t = htmlToText(b2); return truncate(t + detectSparseHtml(b2, t)) }
|
|
211
|
+
return truncate(b2)
|
|
183
212
|
}
|
|
184
213
|
}
|
|
185
214
|
throw new Error(`fetch failed: HTTP ${response.status}`)
|
|
186
215
|
}
|
|
187
216
|
const ct = headerOf(response, "content-type") ?? ""
|
|
188
217
|
const body = await response.text()
|
|
189
|
-
|
|
218
|
+
if (ct.includes("text/html")) { const t = htmlToText(body); return truncate(t + detectSparseHtml(body, t)) }
|
|
219
|
+
return truncate(body)
|
|
190
220
|
} catch (e) { throw new Error(`fetch failed: ${e.cause?.code ?? e.message}`, { cause: e }) }
|
|
191
221
|
},
|
|
192
222
|
}
|
package/src/tools/websearch.md
CHANGED
|
@@ -5,9 +5,11 @@ Parameters:
|
|
|
5
5
|
- limit: Max results (default 8, max 20)
|
|
6
6
|
- engine: Specific engine to use — "bing" (Bing). Omit to search all engines concurrently.
|
|
7
7
|
- page: Page number for pagination (1-based, default 1). Only used when engine is specified.
|
|
8
|
+
- proxy: http://host:port explicit proxy (optional) — use ONLY when passed; no proxy = direct. config.json proxy is NOT auto-applied (2026-08-31 ruling); Bing/foreign sites usually need a proxy, domestic targets don't
|
|
8
9
|
|
|
9
10
|
Notes:
|
|
10
11
|
- Before searching the web, call `memory_search` first — you may already know the answer from a previous session. Only reach for websearch if memory comes up empty.
|
|
11
12
|
- Use this for information that is NOT in the local codebase — current docs, error messages, API references
|
|
12
13
|
- Follow up with `fetch` to read full pages from the results
|
|
13
|
-
-
|
|
14
|
+
- **Weak engine warning**: Bing's index is noisy for technical queries — if a first websearch returns irrelevant/townhall-grade results, DO NOT retry the same query. Configure a search MCP tool (e.g. `glm-websearch` via the MCP config) for technical lookups; websearch is the fallback.
|
|
15
|
+
- Proxy: NOT auto-applied from config.json (2026-08-31 ruling). Pass `proxy: "http://host:port"` explicitly when the target needs one; omit for domestic targets.
|
package/src/tui/fold-block.mjs
CHANGED
|
@@ -36,8 +36,31 @@ export function isExpanded(state, foldKey) {
|
|
|
36
36
|
/** Bidirectional toggle (mouse click / future keyboard path share this). */
|
|
37
37
|
export function toggleFoldBlock(state, foldKey) {
|
|
38
38
|
state.expandedBlocks ??= new Set()
|
|
39
|
-
if (state.expandedBlocks.has(foldKey))
|
|
40
|
-
|
|
39
|
+
if (state.expandedBlocks.has(foldKey)) {
|
|
40
|
+
state.expandedBlocks.delete(foldKey)
|
|
41
|
+
state._foldScroll?.delete(foldKey) // 2026-08-31 会诊 kimi:收起清理块内滚动残留(防 Map 缓涨)
|
|
42
|
+
} else {
|
|
43
|
+
state.expandedBlocks.add(foldKey)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** 块内滚动:展开态窗口起点(2026-08-31 用户需求——60% 封顶保留、块内可滚动读全文)。
|
|
48
|
+
* 存在 = state._foldScroll: Map<foldKey, offset>(渲染读、点击/滚轮写)。 */
|
|
49
|
+
export function foldScrollOffset(state, foldKey) {
|
|
50
|
+
return state._foldScroll?.get(foldKey) ?? 0
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 块内滚动步长:▲/▼ 控制行 = 一整窗(winH);滚轮 = 3 行(与外部会话滚动节拍一致)。
|
|
54
|
+
* dir=+1 向下(offset 增)、-1 向上;upper = 合法上限(total-winH),提供时写时钳制
|
|
55
|
+
* (2026-08-31 会诊 glm:▲▼ 过冲原靠渲染 clamp 收敛→每次点击双重建缓存)。 */
|
|
56
|
+
export function scrollFoldBlock(state, foldKey, dir, step, upper) {
|
|
57
|
+
state._foldScroll ??= new Map()
|
|
58
|
+
const prev = state._foldScroll.get(foldKey) ?? 0
|
|
59
|
+
let next = prev + dir * Math.max(1, Math.floor(step))
|
|
60
|
+
if (upper != null) next = Math.min(next, upper)
|
|
61
|
+
next = Math.max(0, next)
|
|
62
|
+
state._foldScroll.set(foldKey, next)
|
|
63
|
+
return next
|
|
41
64
|
}
|
|
42
65
|
|
|
43
66
|
/** Fold marker line: bold-cyan icon + "click to …" phrase underlined (clickable affordance).
|
|
@@ -191,18 +214,43 @@ export function renderExpandedBlock({ body, foldKey, state, maxRows, label, cols
|
|
|
191
214
|
})
|
|
192
215
|
const out = [blankLine(), foldHintLine(`▼ … ${label} — click to collapse`, foldKey)]
|
|
193
216
|
const cap = foldCapRows(maxRows)
|
|
194
|
-
if (lined.length <= cap) {
|
|
217
|
+
if (lined.length <= cap - 5) {
|
|
218
|
+
// 2026-08-31 用户需求:整块(含控制行)≤ 60% 屏时全量显示
|
|
195
219
|
out.push(...lined)
|
|
196
220
|
return out
|
|
197
221
|
}
|
|
198
|
-
//
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
222
|
+
// 块高 60% 封顶保留(用户 2026-08-30 拍板),正文改窗口显示(2026-08-31):
|
|
223
|
+
// 窗口 = cap 内可读行数(扣除 blank+顶部控制+▲+▼+底部收起 = 5 行开销);
|
|
224
|
+
// ▲/▼ 控制行点击翻窗(_foldScrollUp/_foldScrollDown 标记,mouse.mjs 分派)——
|
|
225
|
+
// 滚动读全文、收起控制行永远在块尾。
|
|
226
|
+
const winH = Math.max(1, cap - 5)
|
|
227
|
+
const total = lined.length
|
|
228
|
+
const offset = Math.min(Math.max(0, foldScrollOffset(state, foldKey)), Math.max(0, total - winH))
|
|
229
|
+
// 2026-08-31:clamp 写回状态——否则 scrollFoldBlock 越界累积(渲染只看 clamped、事件判定
|
|
230
|
+
// 却看原值),handleWheel 会误判"未到边界"→ 永远命中块 → 穿不出块 → 会话顶/懒加载不可达
|
|
231
|
+
if (state._foldScroll?.get(foldKey) !== offset) {
|
|
232
|
+
state._foldScroll ??= new Map()
|
|
233
|
+
state._foldScroll.set(foldKey, offset)
|
|
234
|
+
}
|
|
235
|
+
const window = lined.slice(offset, offset + winH).map((l) => ({
|
|
236
|
+
...l,
|
|
237
|
+
_foldBlock: foldKey, // 2026-08-31 滚轮命中标记:每行自描述所属块(mouse handleWheel 用——无需区间簿记)
|
|
238
|
+
_foldWindow: winH,
|
|
239
|
+
_foldTotal: total,
|
|
240
|
+
}))
|
|
241
|
+
if (offset > 0) {
|
|
242
|
+
out.push({
|
|
243
|
+
text: `▲ 上方还有 ${offset} 行(点击向上翻窗)`,
|
|
244
|
+
color: C.dim, _skipDimFold: true, _foldScrollUp: foldKey, _foldWindow: winH, _foldTotal: total,
|
|
245
|
+
})
|
|
246
|
+
}
|
|
247
|
+
out.push(...window)
|
|
248
|
+
if (offset + winH < total) {
|
|
249
|
+
out.push({
|
|
250
|
+
text: `▼ 下方还有 ${total - offset - winH} 行(点击向下翻窗)`,
|
|
251
|
+
color: C.dim, _skipDimFold: true, _foldScrollDown: foldKey, _foldWindow: winH, _foldTotal: total,
|
|
252
|
+
})
|
|
253
|
+
}
|
|
206
254
|
out.push(foldHintLine(`▼ … ${label} — click to collapse`, foldKey))
|
|
207
255
|
return out
|
|
208
256
|
}
|
package/src/tui/index.mjs
CHANGED
|
@@ -29,9 +29,9 @@ import { createPickers } from "./pickers.mjs"
|
|
|
29
29
|
import { runDistill as runDistillImpl } from "./distill-cmd.mjs"
|
|
30
30
|
import { createInteraction } from "./interaction.mjs"
|
|
31
31
|
import { pasteClipboardImage as pasteClipboardImageImpl, insertPastedText, translateShiftEnter, stripKeyboardProtocol } from "./clipboard.mjs"
|
|
32
|
-
import { parseMouseClicks, handleMouseClick } from "./mouse.mjs"
|
|
32
|
+
import { parseMouseClicks, handleMouseClick, handleWheel } from "./mouse.mjs"
|
|
33
33
|
import { runAgentTurn } from "./agent-turn.mjs"
|
|
34
|
-
import { createKeyHandler } from "./key-handler.mjs"
|
|
34
|
+
import { createKeyHandler, convMaxScroll } from "./key-handler.mjs"
|
|
35
35
|
import { showStartup, backgroundIndex, historyToLines, HISTORY_PAGE_MESSAGES } from "./startup.mjs"
|
|
36
36
|
import { countConvLines } from "./render-conversation.mjs"
|
|
37
37
|
import { createConfigHelpers } from "./config-helpers.mjs"
|
|
@@ -77,6 +77,8 @@ export async function startTUI(agent, opts = {}) {
|
|
|
77
77
|
historyIndex: -1,
|
|
78
78
|
_draft: null, // stashed unsent input while navigating history (restored on down past newest)
|
|
79
79
|
scroll: 0, // scroll lines from bottom upward
|
|
80
|
+
_foldScroll: new Map(), // 2026-08-31 块内滚动:foldKey → 窗口 offset(展开块 ▲▼ 翻窗)
|
|
81
|
+
_followTail: true, // 2026-08-31 流式跟随:渲染前 scroll=0;用户上滚暂停、到底/新消息恢复
|
|
80
82
|
processing: false,
|
|
81
83
|
controller: null, // AbortController for current agent run
|
|
82
84
|
permission: null, // { name, args, resolve }
|
|
@@ -134,8 +136,41 @@ export async function startTUI(agent, opts = {}) {
|
|
|
134
136
|
let pasteMode = false
|
|
135
137
|
let pasteAccum = ""
|
|
136
138
|
|
|
139
|
+
/** 懒加载更早历史(2026-08-31 用户约定:"滚动到头自动加载"——滚轮/PgUp 到顶皆触发;
|
|
140
|
+
* 2026-08-31 前只挂 PgUp 键 = 违约,滚轮到头无反应)。加载后滚动补偿保持锚定。
|
|
141
|
+
* 外层作用域:data 回调(滚轮分支)与 createKeyHandler ctx(PgUp 分支)共用。 */
|
|
142
|
+
const loadOlder = () => {
|
|
143
|
+
if (!state._hasOlder) return
|
|
144
|
+
const full = agent._fullHistory ?? []
|
|
145
|
+
const loaded = state._historyLoaded
|
|
146
|
+
const start = Math.max(0, full.length - loaded - HISTORY_PAGE_MESSAGES)
|
|
147
|
+
const end = full.length - loaded
|
|
148
|
+
if (start >= end) return
|
|
149
|
+
|
|
150
|
+
const d = state.dims ? state.dims.get() : {}
|
|
151
|
+
const cols = d.cols ?? ((state.dims?.get() ?? {}).cols ?? (process.stdout.columns || 80))
|
|
152
|
+
const before = countConvLines(state, cols, d.rows ?? (process.stdout.rows || 24))
|
|
153
|
+
|
|
154
|
+
if (state.lines[0]?.text?.startsWith("… ")) state.lines.shift()
|
|
155
|
+
state._lineIdCounter = state._lineIdCounter ?? 0
|
|
156
|
+
const older = historyToLines(full, start, end)
|
|
157
|
+
for (const l of older) l._lineId = ++state._lineIdCounter
|
|
158
|
+
state.lines.unshift(...older)
|
|
159
|
+
state._historyLoaded += end - start
|
|
160
|
+
state._hasOlder = start > 0
|
|
161
|
+
if (state._hasOlder) {
|
|
162
|
+
state.lines.unshift({ text: `… ${start} more earlier messages (scroll to top to load)`, color: C.dim })
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const after = countConvLines(state, cols, (state.dims?.get() ?? {}).rows ?? (process.stdout.rows || 24))
|
|
166
|
+
state.scroll += Math.max(0, after - before)
|
|
167
|
+
render()
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
|
|
137
171
|
process.stdin.on("data", (chunk) => {
|
|
138
172
|
try {
|
|
173
|
+
|
|
139
174
|
let text = mousePending + utf8Decoder.decode(chunk, { stream: true })
|
|
140
175
|
mousePending = ""
|
|
141
176
|
|
|
@@ -183,13 +218,24 @@ export async function startTUI(agent, opts = {}) {
|
|
|
183
218
|
}
|
|
184
219
|
}
|
|
185
220
|
|
|
186
|
-
// Scroll wheel: \x1b[<64
|
|
221
|
+
// Scroll wheel: \x1b[<64;col;rowM = up, \x1b[<65;col;rowM = down(3 lines each)
|
|
222
|
+
// 2026-08-31:坐标命中展开块内容行 → 块内滚动(handleWheel);未命中 → 会话滚动(现状)
|
|
187
223
|
// eslint-disable-next-line no-control-regex -- 有意为之:控制字符协议/转义序列剥离正则(ANSI/⟦ev⟧/SGR/history 双线分隔)
|
|
188
|
-
for (const m of text.matchAll(/\x1b\[<(\d+)
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
224
|
+
for (const m of text.matchAll(/\x1b\[<(\d+);(\d+);(\d+)([Mm])/g)) {
|
|
225
|
+
const button = Number(m[1])
|
|
226
|
+
if (button === 64 || button === 65) {
|
|
227
|
+
const consumed = handleWheel(mouseCtx(), button, Number(m[2]), Number(m[3]))
|
|
228
|
+
if (!consumed) {
|
|
229
|
+
if (button === 64) {
|
|
230
|
+
state.scroll += 3
|
|
231
|
+
state._followTail = false // 2026-08-31:用户上滚 = 暂停流式跟随(不抢视角)
|
|
232
|
+
// 2026-08-31 用户约定修复:滚动到头自动加载(原来只挂 PgUp 键——违约)
|
|
233
|
+
if (state._hasOlder && state.scroll >= convMaxScroll(state)) loadOlder()
|
|
234
|
+
} else {
|
|
235
|
+
state.scroll = Math.max(0, state.scroll - 3)
|
|
236
|
+
if (state.scroll === 0) state._followTail = true // 滚回底部恢复跟随
|
|
237
|
+
}
|
|
238
|
+
}
|
|
193
239
|
}
|
|
194
240
|
}
|
|
195
241
|
|
|
@@ -266,43 +312,6 @@ export async function startTUI(agent, opts = {}) {
|
|
|
266
312
|
render()
|
|
267
313
|
}
|
|
268
314
|
|
|
269
|
-
/**
|
|
270
|
-
* Load the next earlier page of restored history (lazy, parity with VS Code
|
|
271
|
-
* loadOlder). Prepends earlier source lines and compensates scroll so the
|
|
272
|
-
* visible content does not jump. Called from key-handler when PgUp hits the top.
|
|
273
|
-
*/
|
|
274
|
-
const loadOlder = () => {
|
|
275
|
-
if (!state._hasOlder) return
|
|
276
|
-
const full = agent._fullHistory ?? []
|
|
277
|
-
const loaded = state._historyLoaded
|
|
278
|
-
const start = Math.max(0, full.length - loaded - HISTORY_PAGE_MESSAGES)
|
|
279
|
-
const end = full.length - loaded
|
|
280
|
-
if (start >= end) return
|
|
281
|
-
|
|
282
|
-
const d = state.dims ? state.dims.get() : {}
|
|
283
|
-
const cols = d.cols ?? ((state.dims?.get() ?? {}).cols ?? (process.stdout.columns || 80))
|
|
284
|
-
const before = countConvLines(state, cols, d.rows ?? (process.stdout.rows || 24))
|
|
285
|
-
|
|
286
|
-
// Drop the old placeholder, prepend the older page, re-add the placeholder
|
|
287
|
-
// (with an updated count) only if more remain.
|
|
288
|
-
if (state.lines[0]?.text?.startsWith("… ")) state.lines.shift()
|
|
289
|
-
state._lineIdCounter = state._lineIdCounter ?? 0
|
|
290
|
-
const older = historyToLines(full, start, end)
|
|
291
|
-
for (const l of older) l._lineId = ++state._lineIdCounter
|
|
292
|
-
state.lines.unshift(...older)
|
|
293
|
-
state._historyLoaded += end - start
|
|
294
|
-
state._hasOlder = start > 0
|
|
295
|
-
if (state._hasOlder) {
|
|
296
|
-
state.lines.unshift({ text: `… ${start} more earlier messages (PgUp at top to load)`, color: C.dim })
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
// Scroll compensation: prepending N display rows must move scroll by N to
|
|
300
|
-
// keep the previously-visible bottom-anchored content in place.
|
|
301
|
-
const after = countConvLines(state, cols, (state.dims?.get() ?? {}).rows ?? (process.stdout.rows || 24))
|
|
302
|
-
state.scroll += Math.max(0, after - before)
|
|
303
|
-
render()
|
|
304
|
-
}
|
|
305
|
-
|
|
306
315
|
// Only emit the assistant label once per turn (on first token or first tool call)
|
|
307
316
|
let assistantLabeled = false
|
|
308
317
|
const ensureAssistantLabel = () => {
|
|
@@ -341,6 +350,7 @@ export async function startTUI(agent, opts = {}) {
|
|
|
341
350
|
state.historyIndex = -1
|
|
342
351
|
if (!wasInHistory) state._draft = null // submitted — the draft is now history. Keep draft when submitting from history mode (↓ can recover)
|
|
343
352
|
state.scroll = 0
|
|
353
|
+
state._followTail = true // 2026-08-31 会诊 deepseek:新消息恢复跟随(注释曾承诺、实现缺漏)
|
|
344
354
|
|
|
345
355
|
// Slash commands: handled locally, don't enter agent loop
|
|
346
356
|
if (text.startsWith("/")) {
|
|
@@ -448,6 +458,7 @@ export async function startTUI(agent, opts = {}) {
|
|
|
448
458
|
|
|
449
459
|
// Mouse clicks (SGR \x1b[<0;col;rowM) — picker selection + fold expansion.
|
|
450
460
|
const onMouseClick = (col, row) => handleMouseClick({ state, render, popPicker }, col, row)
|
|
461
|
+
const mouseCtx = () => ({ state, render })
|
|
451
462
|
|
|
452
463
|
// ---------------------------------------------------------- Startup screen + background indexing
|
|
453
464
|
|
package/src/tui/key-handler.mjs
CHANGED
|
@@ -6,7 +6,7 @@ import { countConvLines } from "./render-conversation.mjs"
|
|
|
6
6
|
import { QUESTION_CUSTOM } from "./interaction.mjs"
|
|
7
7
|
|
|
8
8
|
/** Current conversation max scroll offset (display lines beyond the visible panel). */
|
|
9
|
-
function convMaxScroll(state) {
|
|
9
|
+
export function convMaxScroll(state) {
|
|
10
10
|
// Single source (Windows ConPTY instability, 2026-08-30) — cached dims.
|
|
11
11
|
const d = state.dims ? state.dims.get() : {}
|
|
12
12
|
const cols = d.cols ?? ((state.dims?.get() ?? {}).cols ?? (process.stdout.columns || 80))
|
|
@@ -298,11 +298,13 @@ export function createKeyHandler(ctx) {
|
|
|
298
298
|
} else {
|
|
299
299
|
state.scroll += Math.max(1, ((state.dims?.get() ?? {}).rows ?? (process.stdout.rows || 24)) - 8)
|
|
300
300
|
}
|
|
301
|
+
state._followTail = false // 2026-08-31:用户上滚 = 暂停流式跟随
|
|
301
302
|
render()
|
|
302
303
|
return
|
|
303
304
|
}
|
|
304
305
|
if (key.name === "pagedown") {
|
|
305
306
|
state.scroll = Math.max(0, state.scroll - Math.max(1, ((state.dims?.get() ?? {}).rows ?? (process.stdout.rows || 24)) - 8))
|
|
307
|
+
if (state.scroll === 0) state._followTail = true // 滚回底部恢复跟随
|
|
306
308
|
render()
|
|
307
309
|
return
|
|
308
310
|
}
|
package/src/tui/mouse.mjs
CHANGED
|
@@ -15,8 +15,41 @@
|
|
|
15
15
|
* - folded-block hint click = expand it
|
|
16
16
|
*/
|
|
17
17
|
import { computeLayout } from "./layout.mjs"
|
|
18
|
-
import { buildConvLines } from "./render-conversation.mjs"
|
|
19
|
-
import { toggleFoldBlock } from "./fold-block.mjs"
|
|
18
|
+
import { buildConvLines, convViewport } from "./render-conversation.mjs"
|
|
19
|
+
import { toggleFoldBlock, scrollFoldBlock, foldScrollOffset } from "./fold-block.mjs"
|
|
20
|
+
|
|
21
|
+
/** 2026-08-31 滚轮事件分派(用户需求"展开块能滚动阅读全文"):坐标命中展开块内容行 →
|
|
22
|
+
* 块内逐行滚动(scrollFoldBlock ±3);未命中 → 会话滚动(调用方继续处理)。
|
|
23
|
+
* ctx: { state, render };返回 true = 已消费(块内滚动),false = 调用方走会话滚动。 */
|
|
24
|
+
export function handleWheel(ctx, button, col, row) {
|
|
25
|
+
const { state } = ctx
|
|
26
|
+
const dir = button === 64 ? -1 : 1 // 64=滚上(offset 减)、65=滚下
|
|
27
|
+
const r = row - 1
|
|
28
|
+
if (r < 0) return false
|
|
29
|
+
const dims = state.dims ? state.dims.get() : { cols: process.stdout.columns || 80, rows: process.stdout.rows || 24 }
|
|
30
|
+
const layout = computeLayout(state, dims)
|
|
31
|
+
const P = layout.panels
|
|
32
|
+
if (r < P.conversation.y || r >= P.conversation.y + P.conversation.h) return false
|
|
33
|
+
const convLines = buildConvLines(state, dims.cols, dims.rows)
|
|
34
|
+
const gIdx = convGlobalIndex(convLines.length, P.conversation.h, state.scroll ?? 0)(r - P.conversation.y)
|
|
35
|
+
if (gIdx === null) return false
|
|
36
|
+
const lineEl = convLines[gIdx]
|
|
37
|
+
if (!lineEl?._foldBlock) return false
|
|
38
|
+
// 2026-08-31 会诊 glm:标记不完整(_foldTotal 缺失=退化路径)不消费——交还会话滚动
|
|
39
|
+
// (total=0 会让下方边界守卫短路 → 卡在块里回归)
|
|
40
|
+
if (!lineEl._foldTotal) return false
|
|
41
|
+
// 2026-08-31 穿出语义:块内已到边界(向上滚在顶 / 向下滚在底)→ 交还会话滚动——
|
|
42
|
+
// 否则滚轮永远被块吃掉,会话顶/懒加载不可达(用户实测路径"经过展开块滚不到顶")
|
|
43
|
+
const before = foldScrollOffset(state, lineEl._foldBlock)
|
|
44
|
+
const winH = lineEl._foldWindow ?? 1
|
|
45
|
+
const total = lineEl._foldTotal
|
|
46
|
+
if (dir < 0 && before <= 0) return false // 块顶滚上 → 穿出(会话滚动 → 顶部自动加载)
|
|
47
|
+
if (dir > 0 && before >= total - winH) return false // 块底滚下 → 穿出
|
|
48
|
+
scrollFoldBlock(state, lineEl._foldBlock, dir, 3)
|
|
49
|
+
ctx.render?.()
|
|
50
|
+
return true
|
|
51
|
+
}
|
|
52
|
+
|
|
20
53
|
|
|
21
54
|
/** Extract left-click presses from a chunk. Returns [{ col, row }] (1-based). */
|
|
22
55
|
export function parseMouseClicks(text) {
|
|
@@ -30,13 +63,11 @@ export function parseMouseClicks(text) {
|
|
|
30
63
|
|
|
31
64
|
/** Map a 0-based screen row to a conversation line index (same math as renderConversation). */
|
|
32
65
|
export function convGlobalIndex(convLen, convH, scroll) {
|
|
33
|
-
const
|
|
34
|
-
const clamped = Math.min(scroll, maxScroll)
|
|
35
|
-
const end = convLen - clamped
|
|
36
|
-
const start = Math.max(0, end - convH) // content shorter than the panel: rows start at 0
|
|
66
|
+
const { start, pad } = convViewport(convLen, convH, scroll)
|
|
37
67
|
return (localRow) => {
|
|
38
68
|
if (localRow < 0 || localRow >= convH) return null
|
|
39
|
-
|
|
69
|
+
if (localRow < pad) return null // 顶部 pad 空行不是内容行(2026-08-31 会诊 kimi 缺陷 1)
|
|
70
|
+
const idx = start + localRow - pad
|
|
40
71
|
return idx >= 0 && idx < convLen ? idx : null
|
|
41
72
|
}
|
|
42
73
|
}
|
|
@@ -76,6 +107,14 @@ export function handleMouseClick(ctx, col, row) {
|
|
|
76
107
|
const gIdx = convGlobalIndex(convLines.length, P.conversation.h, state.scroll ?? 0)(r - P.conversation.y)
|
|
77
108
|
if (gIdx === null) return false
|
|
78
109
|
const lineEl = convLines[gIdx]
|
|
110
|
+
if (lineEl?._foldScrollUp || lineEl?._foldScrollDown) {
|
|
111
|
+
// 2026-08-31 块内滚动:▲/▼ 控制行点击翻窗(60% 封顶保留、窗口随翻滚动,全文可达)
|
|
112
|
+
scrollFoldBlock(state, lineEl._foldScrollUp ?? lineEl._foldScrollDown,
|
|
113
|
+
lineEl._foldScrollUp ? -1 : 1, lineEl._foldWindow ?? 1,
|
|
114
|
+
typeof lineEl._foldTotal === "number" ? Math.max(0, lineEl._foldTotal - (lineEl._foldWindow ?? 1)) : undefined)
|
|
115
|
+
render()
|
|
116
|
+
return true
|
|
117
|
+
}
|
|
79
118
|
if (!lineEl?._foldToggle) return false
|
|
80
119
|
// Bidirectional toggle — single source in fold-block.mjs (expand a folded
|
|
81
120
|
// block, collapse an expanded one).
|