thincoder 0.12.52 → 0.12.54
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 +36 -0
- package/package.json +1 -1
- package/src/acp.mjs +60 -18
- package/src/advisor/run.mjs +9 -11
- package/src/agent/dispatch.mjs +38 -13
- package/src/agent/setup.mjs +2 -1
- package/src/agent.mjs +34 -0
- package/src/cli/make-agent.mjs +11 -5
- package/src/escape.mjs +43 -8
- package/src/git/checkpoint.mjs +32 -6
- 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 +44 -1
- package/src/provider/anthropic.mjs +51 -18
- package/src/provider/core.mjs +163 -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/session-migrate.mjs +6 -0
- package/src/session-slots.mjs +361 -0
- package/src/session.mjs +267 -306
- 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-checkpoint.mjs +143 -0
- package/src/tools/git-ext.mjs +173 -0
- package/src/tools/git.md +21 -6
- package/src/tools/git.mjs +68 -155
- 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/ansi.mjs +2 -0
- package/src/tui/cmd-new.mjs +6 -6
- package/src/tui/cmd-restore.mjs +27 -6
- package/src/tui/cmd-session.mjs +17 -4
- package/src/tui/fold-block.mjs +59 -11
- package/src/tui/index.mjs +59 -67
- package/src/tui/key-handler.mjs +3 -1
- package/src/tui/layout.mjs +81 -25
- package/src/tui/mouse.mjs +86 -8
- package/src/tui/render-conversation.mjs +260 -214
- package/src/tui/render-frame.mjs +22 -6
- package/src/tui/render-loop.mjs +11 -1
- package/src/tui/startup.mjs +1 -1
- package/src/tui/subagent-blocks.mjs +5 -1
- package/src/tui/subagent-panel.mjs +81 -0
- package/src/tui/tool-args.mjs +4 -0
- package/src/tui/tool-events.mjs +1 -1
- package/src/tui/tui-lifecycle.mjs +45 -0
package/src/tui/cmd-session.mjs
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
|
-
import { listSlots, switchToSlot, applySession, renameSlot, activeSlot } from "../session.mjs"
|
|
1
|
+
import { listSlots, switchToSlot, applySession, renameSlot, activeSlot, slotOccupancy } from "../session.mjs"
|
|
2
2
|
import { ansi, C } from "./ansi.mjs"
|
|
3
3
|
import { restoreLines } from "./startup.mjs"
|
|
4
|
+
import { stringWidth, sliceByWidth } from "./render.mjs"
|
|
4
5
|
|
|
5
6
|
/** /rename <title> — rename the ACTIVE session (slot file + manifest, shared with VS Code). */
|
|
6
7
|
export async function handleRenameCommand(ctx, args) {
|
|
7
8
|
const { agent, pushLine, pushLabel, render } = ctx
|
|
8
|
-
|
|
9
|
+
// 2026-09-01 会诊 glm 🟡:用粘性槽而非 activeSlot(后者有认领副作用)——与 saveSession
|
|
10
|
+
// 的 _slot ??= activeSlot 语义一致:已钉槽直接重命名,未钉才认领。
|
|
11
|
+
const slot = agent._slot ?? activeSlot(agent.cwd)
|
|
9
12
|
const current = agent.title || "(untitled)"
|
|
10
13
|
const title = args.join(" ").trim()
|
|
11
14
|
if (!title) {
|
|
@@ -38,11 +41,15 @@ export async function handleSessionCommand(ctx) {
|
|
|
38
41
|
const dt = new Date(d)
|
|
39
42
|
return `${dt.getMonth() + 1}/${dt.getDate()} ${String(dt.getHours()).padStart(2, "0")}:${String(dt.getMinutes()).padStart(2, "0")}`
|
|
40
43
|
}
|
|
41
|
-
const truncate = (s, n) => s
|
|
44
|
+
const truncate = (s, n) => (stringWidth(s) <= n ? s : sliceByWidth(s, Math.max(1, n - 1)) + "…")
|
|
42
45
|
const entries = [
|
|
43
46
|
{ type: "header", text: `Sessions (● = active, ↑↓ select, Enter switch, Esc cancel; /rename <title> renames the active one)` },
|
|
44
47
|
...slots.map((s) => {
|
|
45
|
-
|
|
48
|
+
// 2026-08-31 会诊:行宽必须按显示宽度截断(原按 UTF-16 length 截 40 = 中文 80 格,
|
|
49
|
+
// 顶到右边距后叠加二义字符宽度低估(│/—/● 渲染 2 格算 1 格)→ 行实际超宽 → 物理
|
|
50
|
+
// wrap → 残影);title 此前完全不截断(可任意长)也是超宽源,一并宽度截断。
|
|
51
|
+
const rawLabel = s.title || (s.firstMessage ? `"${s.firstMessage}"` : "(empty)")
|
|
52
|
+
const label = truncate(rawLabel, 36)
|
|
46
53
|
const turns = s.turnCount > 0 ? `${s.turnCount} turns` : "0 turns"
|
|
47
54
|
const when = shortDate(s.updatedAt)
|
|
48
55
|
const model = s.activeProvider ? ` — ${s.activeProvider}` : ""
|
|
@@ -56,11 +63,17 @@ export async function handleSessionCommand(ctx) {
|
|
|
56
63
|
]
|
|
57
64
|
const e = await showPicker("Sessions", entries)
|
|
58
65
|
if (!e) return
|
|
66
|
+
// 2026-08-31 会诊 deepseek 🔴:目标槽被另一活进程占用时 switchToSlot 不认领(避免
|
|
67
|
+
// 劫持对方 active),本次会话仍读到数据,但下次保存会 fork 到新槽——提前提示。
|
|
68
|
+
const occ = slotOccupancy(agent.cwd, e.slot)
|
|
59
69
|
const data = switchToSlot(agent.cwd, e.slot)
|
|
60
70
|
if (!data) {
|
|
61
71
|
pushLine(`Slot ${e.slot} not found`, C.dim)
|
|
62
72
|
return
|
|
63
73
|
}
|
|
74
|
+
if (occ.occupied) {
|
|
75
|
+
pushLine(`⚠ Slot ${e.slot} is being used by another live process (${occ.owner}) — continuing here will create a new copy on the next save`, C.warn)
|
|
76
|
+
}
|
|
64
77
|
applySession(agent, data)
|
|
65
78
|
// Rebuild from history (lazy) — the display snapshot is deprecated.
|
|
66
79
|
state.lines = []
|
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
|
@@ -25,13 +25,14 @@ import { createRenderLoop } from "./render-loop.mjs"
|
|
|
25
25
|
import { makeDimsState } from "./dims.mjs"
|
|
26
26
|
import { SLASH_COMMANDS, SLASH_ALIASES, createSlashCommands } from "./slash-commands.mjs"
|
|
27
27
|
import { createWizard } from "./wizard.mjs"
|
|
28
|
+
import { writeStartupSequence, createExitCleanup } from "./tui-lifecycle.mjs"
|
|
28
29
|
import { createPickers } from "./pickers.mjs"
|
|
29
30
|
import { runDistill as runDistillImpl } from "./distill-cmd.mjs"
|
|
30
31
|
import { createInteraction } from "./interaction.mjs"
|
|
31
32
|
import { pasteClipboardImage as pasteClipboardImageImpl, insertPastedText, translateShiftEnter, stripKeyboardProtocol } from "./clipboard.mjs"
|
|
32
|
-
import { parseMouseClicks, handleMouseClick } from "./mouse.mjs"
|
|
33
|
+
import { parseMouseClicks, handleMouseClick, handleWheel } from "./mouse.mjs"
|
|
33
34
|
import { runAgentTurn } from "./agent-turn.mjs"
|
|
34
|
-
import { createKeyHandler } from "./key-handler.mjs"
|
|
35
|
+
import { createKeyHandler, convMaxScroll } from "./key-handler.mjs"
|
|
35
36
|
import { showStartup, backgroundIndex, historyToLines, HISTORY_PAGE_MESSAGES } from "./startup.mjs"
|
|
36
37
|
import { countConvLines } from "./render-conversation.mjs"
|
|
37
38
|
import { createConfigHelpers } from "./config-helpers.mjs"
|
|
@@ -77,6 +78,8 @@ export async function startTUI(agent, opts = {}) {
|
|
|
77
78
|
historyIndex: -1,
|
|
78
79
|
_draft: null, // stashed unsent input while navigating history (restored on down past newest)
|
|
79
80
|
scroll: 0, // scroll lines from bottom upward
|
|
81
|
+
_foldScroll: new Map(), // 2026-08-31 块内滚动:foldKey → 窗口 offset(展开块 ▲▼ 翻窗)
|
|
82
|
+
_followTail: true, // 2026-08-31 流式跟随:渲染前 scroll=0;用户上滚暂停、到底/新消息恢复
|
|
80
83
|
processing: false,
|
|
81
84
|
controller: null, // AbortController for current agent run
|
|
82
85
|
permission: null, // { name, args, resolve }
|
|
@@ -127,15 +130,48 @@ export async function startTUI(agent, opts = {}) {
|
|
|
127
130
|
// kitty push (\x1b[>1u): Shift+Enter → \x1b[13;2u (Windows Terminal 1.19+, VS Code, kitty, iTerm2)
|
|
128
131
|
// modifyOtherKeys lvl 2 (\x1b[>4;2m): Shift+Enter → \x1b[27;2;13~ (mintty / Git Bash)
|
|
129
132
|
// translateShiftEnter (stdin layer) maps both to \x1b\r → meta+return → multiline branch.
|
|
130
|
-
|
|
133
|
+
writeStartupSequence()
|
|
131
134
|
|
|
132
135
|
const utf8Decoder = new TextDecoder("utf-8", { fatal: false })
|
|
133
136
|
|
|
134
137
|
let pasteMode = false
|
|
135
138
|
let pasteAccum = ""
|
|
136
139
|
|
|
140
|
+
/** 懒加载更早历史(2026-08-31 用户约定:"滚动到头自动加载"——滚轮/PgUp 到顶皆触发;
|
|
141
|
+
* 2026-08-31 前只挂 PgUp 键 = 违约,滚轮到头无反应)。加载后滚动补偿保持锚定。
|
|
142
|
+
* 外层作用域:data 回调(滚轮分支)与 createKeyHandler ctx(PgUp 分支)共用。 */
|
|
143
|
+
const loadOlder = () => {
|
|
144
|
+
if (!state._hasOlder) return
|
|
145
|
+
const full = agent._fullHistory ?? []
|
|
146
|
+
const loaded = state._historyLoaded
|
|
147
|
+
const start = Math.max(0, full.length - loaded - HISTORY_PAGE_MESSAGES)
|
|
148
|
+
const end = full.length - loaded
|
|
149
|
+
if (start >= end) return
|
|
150
|
+
|
|
151
|
+
const d = state.dims ? state.dims.get() : {}
|
|
152
|
+
const cols = d.cols ?? ((state.dims?.get() ?? {}).cols ?? (process.stdout.columns || 80))
|
|
153
|
+
const before = countConvLines(state, cols, d.rows ?? (process.stdout.rows || 24))
|
|
154
|
+
|
|
155
|
+
if (state.lines[0]?.text?.startsWith("… ")) state.lines.shift()
|
|
156
|
+
state._lineIdCounter = state._lineIdCounter ?? 0
|
|
157
|
+
const older = historyToLines(full, start, end)
|
|
158
|
+
for (const l of older) l._lineId = ++state._lineIdCounter
|
|
159
|
+
state.lines.unshift(...older)
|
|
160
|
+
state._historyLoaded += end - start
|
|
161
|
+
state._hasOlder = start > 0
|
|
162
|
+
if (state._hasOlder) {
|
|
163
|
+
state.lines.unshift({ text: `… ${start} more earlier messages (scroll to top to load)`, color: C.dim })
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const after = countConvLines(state, cols, (state.dims?.get() ?? {}).rows ?? (process.stdout.rows || 24))
|
|
167
|
+
state.scroll += Math.max(0, after - before)
|
|
168
|
+
render()
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
|
|
137
172
|
process.stdin.on("data", (chunk) => {
|
|
138
173
|
try {
|
|
174
|
+
|
|
139
175
|
let text = mousePending + utf8Decoder.decode(chunk, { stream: true })
|
|
140
176
|
mousePending = ""
|
|
141
177
|
|
|
@@ -183,13 +219,24 @@ export async function startTUI(agent, opts = {}) {
|
|
|
183
219
|
}
|
|
184
220
|
}
|
|
185
221
|
|
|
186
|
-
// Scroll wheel: \x1b[<64
|
|
222
|
+
// Scroll wheel: \x1b[<64;col;rowM = up, \x1b[<65;col;rowM = down(3 lines each)
|
|
223
|
+
// 2026-08-31:坐标命中展开块内容行 → 块内滚动(handleWheel);未命中 → 会话滚动(现状)
|
|
187
224
|
// eslint-disable-next-line no-control-regex -- 有意为之:控制字符协议/转义序列剥离正则(ANSI/⟦ev⟧/SGR/history 双线分隔)
|
|
188
|
-
for (const m of text.matchAll(/\x1b\[<(\d+)
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
225
|
+
for (const m of text.matchAll(/\x1b\[<(\d+);(\d+);(\d+)([Mm])/g)) {
|
|
226
|
+
const button = Number(m[1])
|
|
227
|
+
if (button === 64 || button === 65) {
|
|
228
|
+
const consumed = handleWheel(mouseCtx(), button, Number(m[2]), Number(m[3]))
|
|
229
|
+
if (!consumed) {
|
|
230
|
+
if (button === 64) {
|
|
231
|
+
state.scroll += 3
|
|
232
|
+
state._followTail = false // 2026-08-31:用户上滚 = 暂停流式跟随(不抢视角)
|
|
233
|
+
// 2026-08-31 用户约定修复:滚动到头自动加载(原来只挂 PgUp 键——违约)
|
|
234
|
+
if (state._hasOlder && state.scroll >= convMaxScroll(state)) loadOlder()
|
|
235
|
+
} else {
|
|
236
|
+
state.scroll = Math.max(0, state.scroll - 3)
|
|
237
|
+
if (state.scroll === 0) state._followTail = true // 滚回底部恢复跟随
|
|
238
|
+
}
|
|
239
|
+
}
|
|
193
240
|
}
|
|
194
241
|
}
|
|
195
242
|
|
|
@@ -227,27 +274,7 @@ export async function startTUI(agent, opts = {}) {
|
|
|
227
274
|
}
|
|
228
275
|
})
|
|
229
276
|
|
|
230
|
-
|
|
231
|
-
const cleanup = () => {
|
|
232
|
-
if (cleanedUp) return
|
|
233
|
-
cleanedUp = true
|
|
234
|
-
// Save session before exit (synchronous write).
|
|
235
|
-
// Archiving to a slot is handled by /new and /session switch — not on every exit,
|
|
236
|
-
// otherwise simply opening and closing the TUI repeatedly would fill all slots with duplicates.
|
|
237
|
-
try {
|
|
238
|
-
saveSession(agent)
|
|
239
|
-
} catch {
|
|
240
|
-
// Save failure shouldn't block exit
|
|
241
|
-
}
|
|
242
|
-
// Kill MCP stdio subprocesses, don't leave orphans
|
|
243
|
-
try {
|
|
244
|
-
closeAllMcp(agent)
|
|
245
|
-
} catch {
|
|
246
|
-
// Can't close? fine, process is exiting anyway
|
|
247
|
-
}
|
|
248
|
-
process.stdin.setRawMode(false)
|
|
249
|
-
process.stdout.write(ansi.clearScreen + ansi.mouseOff + ansi.bracketedPasteOff + ansi.keyboardPop + ansi.modifyOtherKeysOff + ansi.mainBuffer + ansi.showCursor + ansi.reset)
|
|
250
|
-
}
|
|
277
|
+
const cleanup = createExitCleanup({ agent, saveSession, closeAllMcp })
|
|
251
278
|
process.on("exit", cleanup)
|
|
252
279
|
|
|
253
280
|
const pushLine = (text, color, kind) => {
|
|
@@ -266,43 +293,6 @@ export async function startTUI(agent, opts = {}) {
|
|
|
266
293
|
render()
|
|
267
294
|
}
|
|
268
295
|
|
|
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
296
|
// Only emit the assistant label once per turn (on first token or first tool call)
|
|
307
297
|
let assistantLabeled = false
|
|
308
298
|
const ensureAssistantLabel = () => {
|
|
@@ -341,6 +331,7 @@ export async function startTUI(agent, opts = {}) {
|
|
|
341
331
|
state.historyIndex = -1
|
|
342
332
|
if (!wasInHistory) state._draft = null // submitted — the draft is now history. Keep draft when submitting from history mode (↓ can recover)
|
|
343
333
|
state.scroll = 0
|
|
334
|
+
state._followTail = true // 2026-08-31 会诊 deepseek:新消息恢复跟随(注释曾承诺、实现缺漏)
|
|
344
335
|
|
|
345
336
|
// Slash commands: handled locally, don't enter agent loop
|
|
346
337
|
if (text.startsWith("/")) {
|
|
@@ -448,6 +439,7 @@ export async function startTUI(agent, opts = {}) {
|
|
|
448
439
|
|
|
449
440
|
// Mouse clicks (SGR \x1b[<0;col;rowM) — picker selection + fold expansion.
|
|
450
441
|
const onMouseClick = (col, row) => handleMouseClick({ state, render, popPicker }, col, row)
|
|
442
|
+
const mouseCtx = () => ({ state, render })
|
|
451
443
|
|
|
452
444
|
// ---------------------------------------------------------- Startup screen + background indexing
|
|
453
445
|
|
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/layout.mjs
CHANGED
|
@@ -3,13 +3,17 @@
|
|
|
3
3
|
* Computes position and height of each panel from state + terminal dimensions.
|
|
4
4
|
* Does not modify state — side effects are performed by the caller before rendering.
|
|
5
5
|
*
|
|
6
|
-
* header → conversation → todo → picker → permission → queue → input → status
|
|
7
|
-
*
|
|
8
|
-
* (AGENT-LOOP.md §7.2
|
|
6
|
+
* header → conversation → subagent 面板 → todo → picker → permission → queue → input → status
|
|
7
|
+
* Running subagent activity renders in a FIXED bottom panel between the
|
|
8
|
+
* conversation and the todo panel (AGENT-LOOP.md §7.2.1) — full adaptive height
|
|
9
|
+
* (the conversation shrinks); compressed away first on small terminals (to 0 =
|
|
10
|
+
* hidden, data stays in the buffer). Done children are frozen into the
|
|
11
|
+
* conversation stream (§7.2 D4, unchanged). Output panels abolished (§7.2 D6).
|
|
9
12
|
* Fixed panels deducted first, conditional panels allocated by priority, remaining space to conversation.
|
|
10
13
|
*/
|
|
11
14
|
import { layoutInput, wrapText } from "./render.mjs"
|
|
12
15
|
import { QUESTION_CUSTOM } from "./interaction.mjs"
|
|
16
|
+
import { renderSubagentPanel } from "./subagent-panel.mjs"
|
|
13
17
|
|
|
14
18
|
/** 防御:question options 声明为 string[],但 LLM 可能误传对象;取 label/text/title 兜底,避免渲染 "[object Object]"。 */
|
|
15
19
|
function optText(opt) {
|
|
@@ -75,9 +79,20 @@ export function computeLayout(state, { cols, rows }) {
|
|
|
75
79
|
const taskPanelH = visibleTasks.length > 0 ? visibleTasks.length + 1 : 0
|
|
76
80
|
// Squeeze target: the divider line yields first under small terminals (the
|
|
77
81
|
// task rows themselves never compress away — put() truncates by panel h).
|
|
78
|
-
let todoFinalH = taskPanelH
|
|
79
82
|
|
|
80
|
-
// Subagent
|
|
83
|
+
// Subagent panel (§7.2.1 D1): RUNNING blocks only, between conversation and
|
|
84
|
+
// todo. Height = the FULL rendered height of every running block (F2 — fully
|
|
85
|
+
// adaptive, no cap; the conversation shrinks accordingly). Precomputed here
|
|
86
|
+
// via renderSubagentPanel (neutral module, no layout↔render-frame cycle);
|
|
87
|
+
// render-frame puts `subagentLines` directly (no double render). Done
|
|
88
|
+
// children are frozen into state.lines (subagent-blocks.mjs
|
|
89
|
+
// freezeSubTaskLines) and excluded here — the panel only shows running blocks.
|
|
90
|
+
let subagentLines = []
|
|
91
|
+
let subagentH = 0
|
|
92
|
+
if (Object.values(state.subTasks ?? {}).some((s) => !s.done)) {
|
|
93
|
+
subagentLines = renderSubagentPanel(state, cols, rows)
|
|
94
|
+
subagentH = subagentLines.length
|
|
95
|
+
}
|
|
81
96
|
|
|
82
97
|
// Permission preview (height depends on wrapped content)
|
|
83
98
|
let permPreviewLines = []
|
|
@@ -97,32 +112,47 @@ export function computeLayout(state, { cols, rows }) {
|
|
|
97
112
|
const queueH = state.queue.length > 0 && state.processing ? 1 : 0
|
|
98
113
|
|
|
99
114
|
// --- elastic panel: conversation takes remaining space ---
|
|
100
|
-
const fixedH = headerH + inputBoxH + statusH + pickerH + taskPanelH + permPreviewH + queueH
|
|
115
|
+
const fixedH = headerH + inputBoxH + statusH + pickerH + taskPanelH + permPreviewH + queueH + subagentH
|
|
101
116
|
let convH = Math.max(1, rows - fixedH)
|
|
102
117
|
|
|
103
|
-
//
|
|
104
|
-
//
|
|
118
|
+
// 小终端高度补偿:subagent 面板最先让位(可至 0 隐藏,活动仍进缓冲区不丢),
|
|
119
|
+
// 再压 conversation 到最小 1 行,再压 picker 到最小 3 行,仍溢出再压
|
|
120
|
+
// permission preview 到最小 1 行(仅标题)——输入框/状态栏/会话区保留。
|
|
121
|
+
let subagentFinalH = subagentH
|
|
105
122
|
let pickerFinalH = pickerH
|
|
106
123
|
let permFinalH = permPreviewH
|
|
124
|
+
let todoFinalH = taskPanelH
|
|
107
125
|
const overflow = fixedH + convH - rows
|
|
108
126
|
if (overflow > 0) {
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
permFinalH = Math.max(1, permPreviewH - remaining)
|
|
117
|
-
convH = Math.max(1, rows - (afterPicker - permPreviewH + permFinalH))
|
|
127
|
+
// 压缩链第 1 级(§7.2.1 NF1/评审 #2 措辞统一):subagent 面板最先让位——
|
|
128
|
+
// 可压缩至 0 隐藏(运行中活动仍在缓冲区不丢),输入框/状态栏/会话区不可挤没。
|
|
129
|
+
let afterSub = fixedH
|
|
130
|
+
if (subagentH > 0) {
|
|
131
|
+
subagentFinalH = Math.max(0, subagentH - overflow)
|
|
132
|
+
afterSub = fixedH - subagentH + subagentFinalH
|
|
133
|
+
convH = Math.max(1, rows - afterSub)
|
|
118
134
|
}
|
|
119
|
-
//
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
135
|
+
// 压缩链第 2/3 级(既有逻辑):picker → 最小 3 行,permission → 最小 1 行
|
|
136
|
+
const overflow2 = afterSub + convH - rows
|
|
137
|
+
if (overflow2 > 0) {
|
|
138
|
+
if (pickerH > 0) {
|
|
139
|
+
pickerFinalH = Math.max(Math.min(3, pickerH), pickerH - overflow2)
|
|
140
|
+
}
|
|
141
|
+
const afterPicker = afterSub - pickerH + pickerFinalH
|
|
142
|
+
convH = Math.max(1, rows - afterPicker)
|
|
143
|
+
const remaining = afterPicker + convH - rows
|
|
144
|
+
if (remaining > 0 && permPreviewH > 0) {
|
|
145
|
+
permFinalH = Math.max(1, permPreviewH - remaining)
|
|
146
|
+
convH = Math.max(1, rows - (afterPicker - permPreviewH + permFinalH))
|
|
147
|
+
}
|
|
148
|
+
// 压缩链末级:todo 面板的分隔线行让位(任务行保留——put 按 h 截断自动
|
|
149
|
+
// 丢弃第一行的分隔线,2026-08-30 用户请求加的 divider 不得在小终端挤掉输入框)。
|
|
150
|
+
const afterPerm = afterPicker - permPreviewH + permFinalH
|
|
151
|
+
const finalOverflow = afterPerm + convH - rows
|
|
152
|
+
if (finalOverflow > 0 && taskPanelH > visibleTasks.length) {
|
|
153
|
+
todoFinalH = Math.max(visibleTasks.length, taskPanelH - finalOverflow)
|
|
154
|
+
convH = Math.max(1, rows - (afterPerm - taskPanelH + todoFinalH))
|
|
155
|
+
}
|
|
126
156
|
}
|
|
127
157
|
}
|
|
128
158
|
|
|
@@ -130,6 +160,7 @@ export function computeLayout(state, { cols, rows }) {
|
|
|
130
160
|
let y = 0
|
|
131
161
|
const header = { y, h: headerH }; y += headerH
|
|
132
162
|
const conversation = { y, h: convH }; y += convH
|
|
163
|
+
const subagent = subagentFinalH > 0 ? { y, h: subagentFinalH } : null; y += subagentFinalH
|
|
133
164
|
const todo = todoFinalH > 0 ? { y, h: todoFinalH } : null; y += todoFinalH
|
|
134
165
|
const picker = pickerFinalH > 0 ? { y, h: pickerFinalH } : null; y += pickerFinalH
|
|
135
166
|
const permission = permFinalH > 0 ? { y, h: permFinalH } : null; y += permFinalH
|
|
@@ -139,13 +170,38 @@ export function computeLayout(state, { cols, rows }) {
|
|
|
139
170
|
|
|
140
171
|
return {
|
|
141
172
|
W, cols, rows,
|
|
142
|
-
panels: { header, conversation, picker, todo, permission, queue, inputBox, status },
|
|
173
|
+
panels: { header, conversation, subagent, picker, todo, permission, queue, inputBox, status },
|
|
143
174
|
// precomputed content (affects height, reused during render)
|
|
144
175
|
inputLayout,
|
|
145
176
|
inputOffset,
|
|
146
177
|
boxLines,
|
|
147
178
|
visibleTasks,
|
|
148
179
|
permPreviewLines,
|
|
180
|
+
subagentLines,
|
|
149
181
|
overlay,
|
|
150
182
|
}
|
|
151
183
|
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* §7.2.1 评审 #4:面板部分压缩(subagentFinalH < subagentLines.length)时 render-frame
|
|
187
|
+
* 实际显示的行——**保底截断**:分隔线 + 末尾 (h-1) 行(最新启动区块优先;小终端上仍
|
|
188
|
+
* 能看到最新子 agent 活动,旧 slice(0,h) 保留顶部会把最新活动裁掉)。分隔线始终保留
|
|
189
|
+
* (面板边界语义)。h ≥ 全长 → 原样;h = 0 → [](layout 侧 panels.subagent 已为 null,
|
|
190
|
+
* 防御分支);h = 1 → 只显示分隔线(极端小终端)。
|
|
191
|
+
*/
|
|
192
|
+
export function subagentVisibleLines(subagentLines, h) {
|
|
193
|
+
if (h >= subagentLines.length) return subagentLines
|
|
194
|
+
if (h <= 0) return []
|
|
195
|
+
if (h === 1) return [subagentLines[0]]
|
|
196
|
+
return [subagentLines[0], ...subagentLines.slice(-(h - 1))]
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** 与 subagentVisibleLines 同一几何契约:可见面板行(0-based 行内坐标)→
|
|
200
|
+
* subagentLines 索引(mouse 命中映射用——保底截断后可见行 ≠ 前 h 行);
|
|
201
|
+
* localRow 越界 → -1。 */
|
|
202
|
+
export function subagentLineIndex(subagentLines, h, localRow) {
|
|
203
|
+
if (localRow < 0 || localRow >= h) return -1
|
|
204
|
+
if (h >= subagentLines.length) return localRow
|
|
205
|
+
if (localRow === 0) return 0
|
|
206
|
+
return subagentLines.length - h + localRow
|
|
207
|
+
}
|
package/src/tui/mouse.mjs
CHANGED
|
@@ -14,9 +14,60 @@
|
|
|
14
14
|
* - picker option click = select it
|
|
15
15
|
* - folded-block hint click = expand it
|
|
16
16
|
*/
|
|
17
|
-
import { computeLayout } from "./layout.mjs"
|
|
18
|
-
import { buildConvLines } from "./render-conversation.mjs"
|
|
19
|
-
import { toggleFoldBlock } from "./fold-block.mjs"
|
|
17
|
+
import { computeLayout, subagentLineIndex } from "./layout.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
|
+
// §7.2.1 D4: 固定子agent 面板(conversation 与 todo 之间)——面板行默认穿出
|
|
33
|
+
// 滚会话(F3,与 todo 面板同型);命中展开区块内容行(_foldBlock 标记)→
|
|
34
|
+
// 块内滚动(现状能力不丢)。
|
|
35
|
+
if (P.subagent && r >= P.subagent.y && r < P.subagent.y + P.subagent.h) {
|
|
36
|
+
// 评审 #4:面板部分压缩(保底截断)后可见行 = 分隔线 + 末尾行——命中映射经
|
|
37
|
+
// subagentLineIndex(与 render-frame 同一几何契约),不再把行内坐标直接当索引用。
|
|
38
|
+
const lineEl = layout.subagentLines[subagentLineIndex(layout.subagentLines, P.subagent.h, r - P.subagent.y)]
|
|
39
|
+
if (!lineEl?._foldBlock || !lineEl._foldTotal) return false
|
|
40
|
+
// 穿出语义与会话区块一致:块内到边界(顶滚上 / 底滚下)→ 交还会话滚动
|
|
41
|
+
const before = foldScrollOffset(state, lineEl._foldBlock)
|
|
42
|
+
const winH = lineEl._foldWindow ?? 1
|
|
43
|
+
const total = lineEl._foldTotal
|
|
44
|
+
if (dir < 0 && before <= 0) return false
|
|
45
|
+
if (dir > 0 && before >= total - winH) return false
|
|
46
|
+
scrollFoldBlock(state, lineEl._foldBlock, dir, 3)
|
|
47
|
+
ctx.render?.()
|
|
48
|
+
return true
|
|
49
|
+
}
|
|
50
|
+
if (r < P.conversation.y || r >= P.conversation.y + P.conversation.h) return false
|
|
51
|
+
const convLines = buildConvLines(state, dims.cols, dims.rows)
|
|
52
|
+
const gIdx = convGlobalIndex(convLines.length, P.conversation.h, state.scroll ?? 0)(r - P.conversation.y)
|
|
53
|
+
if (gIdx === null) return false
|
|
54
|
+
const lineEl = convLines[gIdx]
|
|
55
|
+
if (!lineEl?._foldBlock) return false
|
|
56
|
+
// 2026-08-31 会诊 glm:标记不完整(_foldTotal 缺失=退化路径)不消费——交还会话滚动
|
|
57
|
+
// (total=0 会让下方边界守卫短路 → 卡在块里回归)
|
|
58
|
+
if (!lineEl._foldTotal) return false
|
|
59
|
+
// 2026-08-31 穿出语义:块内已到边界(向上滚在顶 / 向下滚在底)→ 交还会话滚动——
|
|
60
|
+
// 否则滚轮永远被块吃掉,会话顶/懒加载不可达(用户实测路径"经过展开块滚不到顶")
|
|
61
|
+
const before = foldScrollOffset(state, lineEl._foldBlock)
|
|
62
|
+
const winH = lineEl._foldWindow ?? 1
|
|
63
|
+
const total = lineEl._foldTotal
|
|
64
|
+
if (dir < 0 && before <= 0) return false // 块顶滚上 → 穿出(会话滚动 → 顶部自动加载)
|
|
65
|
+
if (dir > 0 && before >= total - winH) return false // 块底滚下 → 穿出
|
|
66
|
+
scrollFoldBlock(state, lineEl._foldBlock, dir, 3)
|
|
67
|
+
ctx.render?.()
|
|
68
|
+
return true
|
|
69
|
+
}
|
|
70
|
+
|
|
20
71
|
|
|
21
72
|
/** Extract left-click presses from a chunk. Returns [{ col, row }] (1-based). */
|
|
22
73
|
export function parseMouseClicks(text) {
|
|
@@ -30,13 +81,11 @@ export function parseMouseClicks(text) {
|
|
|
30
81
|
|
|
31
82
|
/** Map a 0-based screen row to a conversation line index (same math as renderConversation). */
|
|
32
83
|
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
|
|
84
|
+
const { start, pad } = convViewport(convLen, convH, scroll)
|
|
37
85
|
return (localRow) => {
|
|
38
86
|
if (localRow < 0 || localRow >= convH) return null
|
|
39
|
-
|
|
87
|
+
if (localRow < pad) return null // 顶部 pad 空行不是内容行(2026-08-31 会诊 kimi 缺陷 1)
|
|
88
|
+
const idx = start + localRow - pad
|
|
40
89
|
return idx >= 0 && idx < convLen ? idx : null
|
|
41
90
|
}
|
|
42
91
|
}
|
|
@@ -70,12 +119,41 @@ export function handleMouseClick(ctx, col, row) {
|
|
|
70
119
|
return true
|
|
71
120
|
}
|
|
72
121
|
|
|
122
|
+
// ── §7.2.1 D4: 固定子agent 面板(conversation 与 todo 之间)——折叠/展开/翻窗
|
|
123
|
+
// 坐标映射到面板行(与 todo 面板同型;layout.subagentLines = 面板渲染行)。
|
|
124
|
+
if (P.subagent && r >= P.subagent.y && r < P.subagent.y + P.subagent.h) {
|
|
125
|
+
// 评审 #4:保底截断后可见行 ≠ 前 h 行——命中映射与 render-frame 同一几何契约
|
|
126
|
+
// (subagentLineIndex:分隔线 + 末尾区块行优先)。
|
|
127
|
+
const lineEl = layout.subagentLines[subagentLineIndex(layout.subagentLines, P.subagent.h, r - P.subagent.y)]
|
|
128
|
+
if (lineEl?._foldScrollUp || lineEl?._foldScrollDown) {
|
|
129
|
+
// ▲/▼ 控制行点击翻窗(60% 封顶保留、窗口随翻滚动,全文可达)
|
|
130
|
+
scrollFoldBlock(state, lineEl._foldScrollUp ?? lineEl._foldScrollDown,
|
|
131
|
+
lineEl._foldScrollUp ? -1 : 1, lineEl._foldWindow ?? 1,
|
|
132
|
+
typeof lineEl._foldTotal === "number" ? Math.max(0, lineEl._foldTotal - (lineEl._foldWindow ?? 1)) : undefined)
|
|
133
|
+
render()
|
|
134
|
+
return true
|
|
135
|
+
}
|
|
136
|
+
if (!lineEl?._foldToggle) return false
|
|
137
|
+
// 双向切换(fold-block.mjs 单源):折叠头展开 / ▼ 控制收起
|
|
138
|
+
toggleFoldBlock(state, lineEl._foldToggle)
|
|
139
|
+
render()
|
|
140
|
+
return true
|
|
141
|
+
}
|
|
142
|
+
|
|
73
143
|
// ── Conversation: click a fold marker (expand hint or collapse marker) toggles it ──
|
|
74
144
|
if (r >= P.conversation.y && r < P.conversation.y + P.conversation.h) {
|
|
75
145
|
const convLines = buildConvLines(state, dims.cols, dims.rows)
|
|
76
146
|
const gIdx = convGlobalIndex(convLines.length, P.conversation.h, state.scroll ?? 0)(r - P.conversation.y)
|
|
77
147
|
if (gIdx === null) return false
|
|
78
148
|
const lineEl = convLines[gIdx]
|
|
149
|
+
if (lineEl?._foldScrollUp || lineEl?._foldScrollDown) {
|
|
150
|
+
// 2026-08-31 块内滚动:▲/▼ 控制行点击翻窗(60% 封顶保留、窗口随翻滚动,全文可达)
|
|
151
|
+
scrollFoldBlock(state, lineEl._foldScrollUp ?? lineEl._foldScrollDown,
|
|
152
|
+
lineEl._foldScrollUp ? -1 : 1, lineEl._foldWindow ?? 1,
|
|
153
|
+
typeof lineEl._foldTotal === "number" ? Math.max(0, lineEl._foldTotal - (lineEl._foldWindow ?? 1)) : undefined)
|
|
154
|
+
render()
|
|
155
|
+
return true
|
|
156
|
+
}
|
|
79
157
|
if (!lineEl?._foldToggle) return false
|
|
80
158
|
// Bidirectional toggle — single source in fold-block.mjs (expand a folded
|
|
81
159
|
// block, collapse an expanded one).
|