thincoder 0.12.53 → 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 +17 -0
- package/package.json +1 -1
- package/src/acp.mjs +60 -18
- package/src/agent/setup.mjs +2 -1
- package/src/escape.mjs +43 -8
- package/src/git/checkpoint.mjs +32 -6
- package/src/prompts/discipline.md +2 -2
- package/src/provider/core.mjs +42 -0
- package/src/session-migrate.mjs +6 -0
- package/src/session-slots.mjs +361 -0
- package/src/session.mjs +267 -306
- package/src/tools/git-checkpoint.mjs +143 -0
- package/src/tools/git-ext.mjs +173 -0
- package/src/tools/git.md +20 -7
- package/src/tools/git.mjs +48 -162
- 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/index.mjs +3 -22
- package/src/tui/layout.mjs +81 -25
- package/src/tui/mouse.mjs +40 -1
- package/src/tui/render-conversation.mjs +36 -93
- package/src/tui/render-frame.mjs +22 -6
- package/src/tui/render-loop.mjs +1 -1
- package/src/tui/subagent-panel.mjs +81 -0
- package/src/tui/tool-events.mjs +1 -1
- package/src/tui/tui-lifecycle.mjs +45 -0
package/src/tui/ansi.mjs
CHANGED
|
@@ -25,6 +25,8 @@ export const ansi = {
|
|
|
25
25
|
restoreCursor: `${ESC}8`, // DECRC — restore cursor position
|
|
26
26
|
syncUpdateStart: `${ESC}[?2026h`, // DECSET 2026 — buffer output until syncUpdateEnd
|
|
27
27
|
syncUpdateEnd: `${ESC}[?2026l`, // DECRST 2026 — flush buffered output atomically
|
|
28
|
+
wrapOff: `${ESC}[?7l`, // DECRST 7 — disable auto-wrap: over-wide rows hard-truncate at the margin instead of wrapping to the next physical line (2026-08-31 会诊:Ambiguous 宽度字符如 │/—/●/▸ 在中文 locale 终端渲染 2 格而 stringWidth 按 1 格算 → 行实际超宽 → wrap 污染下一物理行 + \x1b[K 清错行 → picker 残影)
|
|
29
|
+
wrapOn: `${ESC}[?7h`, // DECSET 7 — restore auto-wrap (TUI exit; also re-enable per-frame after write)
|
|
28
30
|
reset: `${ESC}[0m`,
|
|
29
31
|
dim: `${ESC}[2m`,
|
|
30
32
|
bold: `${ESC}[1m`,
|
package/src/tui/cmd-new.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { newSession } from "../session.mjs"
|
|
1
|
+
import { newSession, resetSessionState } from "../session.mjs"
|
|
2
2
|
import { C } from "./ansi.mjs"
|
|
3
3
|
|
|
4
4
|
/** /new command: start a new session in a fresh slot.
|
|
@@ -8,11 +8,11 @@ export async function handleNewCommand(ctx) {
|
|
|
8
8
|
|
|
9
9
|
const doNewSession = () => {
|
|
10
10
|
const slot = newSession(agent.cwd)
|
|
11
|
-
|
|
12
|
-
agent.
|
|
13
|
-
|
|
14
|
-
agent
|
|
15
|
-
agent.
|
|
11
|
+
// 2026-08-31 会诊 F3:resetSessionState 清全量会话态(_fullHistory/title/_sessionStart/
|
|
12
|
+
// _engDesignToken/压缩与验证计数等)——原实现只清 agent.history,新会话首次落盘把旧
|
|
13
|
+
// 会话完整人类线 + 旧标题写进新 slot(实锤 .19/.3 双副本)。_slot 更新为粘性新槽位。
|
|
14
|
+
resetSessionState(agent)
|
|
15
|
+
agent._slot = slot
|
|
16
16
|
state.tasks = []
|
|
17
17
|
state.lines = []
|
|
18
18
|
state.streaming = ""
|
package/src/tui/cmd-restore.mjs
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { ansi, C } from "./ansi.mjs"
|
|
2
2
|
|
|
3
|
-
/** /restore command:
|
|
4
|
-
*
|
|
3
|
+
/** /restore command: pick a snapshot, then pick ONE file from it to restore.
|
|
4
|
+
* Full restore is disabled (v2) — this is the user-side per-file recovery entry
|
|
5
|
+
* (CHECKPOINT.md D8). ctx: { agent, showPicker, pushLine, pushLabel } */
|
|
5
6
|
export async function handleRestoreCommand(ctx) {
|
|
6
7
|
const { agent, showPicker, pushLine, pushLabel } = ctx
|
|
7
8
|
const { listCheckpoints, rewind, isGitRepo } = await import("../git/checkpoint.mjs")
|
|
@@ -9,25 +10,45 @@ export async function handleRestoreCommand(ctx) {
|
|
|
9
10
|
pushLine("[rewind] not a git repository, checkpoints unavailable", C.error)
|
|
10
11
|
return
|
|
11
12
|
}
|
|
13
|
+
// F6 lazy fallback——与 git 工具 checkpoint list/create 入口一致(git-checkpoint.mjs):外部
|
|
14
|
+
// git commit 后(HEAD 时间 > 最新快照)先清空过期快照,/restore 不列出 commit 前状态。
|
|
15
|
+
const { lazyClearIfCommitted } = await import("../tools/git-checkpoint.mjs")
|
|
16
|
+
await lazyClearIfCommitted(agent.cwd)
|
|
12
17
|
const cps = await listCheckpoints(agent.cwd)
|
|
13
18
|
if (cps.length === 0) {
|
|
14
19
|
pushLine("(no checkpoints — created automatically before each task)", C.dim)
|
|
15
20
|
return
|
|
16
21
|
}
|
|
22
|
+
// Level 1: pick a snapshot (untracked shown as array length — CHECKPOINT.md D8).
|
|
17
23
|
const entries = [
|
|
18
|
-
{ type: "header", text: "Checkpoints (↑↓ select, Enter
|
|
24
|
+
{ type: "header", text: "Checkpoints — 全量恢复已禁用,逐文件恢复 (↑↓ select, Enter next, Esc cancel)" },
|
|
19
25
|
...cps.slice(0, 12).map((cp) => ({
|
|
20
26
|
type: "item",
|
|
21
|
-
text: `${cp.id} ${new Date(cp.time).toLocaleString()} (+${cp.untracked} untracked files)`,
|
|
27
|
+
text: `${cp.id} ${new Date(cp.time).toLocaleString()} (+${(cp.untracked ?? []).length} untracked files)`,
|
|
22
28
|
id: cp.id,
|
|
23
29
|
})),
|
|
24
30
|
]
|
|
25
31
|
const e = await showPicker("Restore Checkpoint", entries)
|
|
26
32
|
if (!e) return
|
|
33
|
+
const cp = cps.find((c) => c.id === e.id)
|
|
34
|
+
if (!cp) return
|
|
35
|
+
// Level 2: pick a file from the snapshot's tracked/untracked merged list.
|
|
36
|
+
const files = [...(cp.tracked ?? []), ...(cp.untracked ?? [])]
|
|
37
|
+
if (files.length === 0) {
|
|
38
|
+
pushLine("该快照无文件,无法逐文件恢复", C.dim)
|
|
39
|
+
return
|
|
40
|
+
}
|
|
41
|
+
const fileEntries = [
|
|
42
|
+
{ type: "header", text: `Files in ${cp.id} (↑↓ select, Enter restore, Esc cancel)` },
|
|
43
|
+
...files.map((f) => ({ type: "item", text: f, id: f })),
|
|
44
|
+
]
|
|
45
|
+
const fe = await showPicker("Restore File", fileEntries)
|
|
46
|
+
if (!fe) return
|
|
27
47
|
try {
|
|
28
|
-
|
|
48
|
+
// v2 return: { path, type, restored } — rewind snapshots current state first (reversible).
|
|
49
|
+
const summary = await rewind(agent.cwd, cp.id, { path: fe.id })
|
|
29
50
|
pushLabel(`❯ Rewind`, ansi.bold + C.warn)
|
|
30
|
-
pushLine(`Restored
|
|
51
|
+
pushLine(`Restored ${summary.type}: ${summary.path}${summary.restored ? "" : " — nothing restored (snapshot copy missing)"}`, C.tool)
|
|
31
52
|
pushLine("(current state saved as new checkpoint; /restore again to go back)", C.dim)
|
|
32
53
|
} catch (error) {
|
|
33
54
|
pushLine(`[rewind] ${error.message}`, C.error)
|
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/index.mjs
CHANGED
|
@@ -25,6 +25,7 @@ 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"
|
|
@@ -129,7 +130,7 @@ export async function startTUI(agent, opts = {}) {
|
|
|
129
130
|
// kitty push (\x1b[>1u): Shift+Enter → \x1b[13;2u (Windows Terminal 1.19+, VS Code, kitty, iTerm2)
|
|
130
131
|
// modifyOtherKeys lvl 2 (\x1b[>4;2m): Shift+Enter → \x1b[27;2;13~ (mintty / Git Bash)
|
|
131
132
|
// translateShiftEnter (stdin layer) maps both to \x1b\r → meta+return → multiline branch.
|
|
132
|
-
|
|
133
|
+
writeStartupSequence()
|
|
133
134
|
|
|
134
135
|
const utf8Decoder = new TextDecoder("utf-8", { fatal: false })
|
|
135
136
|
|
|
@@ -273,27 +274,7 @@ export async function startTUI(agent, opts = {}) {
|
|
|
273
274
|
}
|
|
274
275
|
})
|
|
275
276
|
|
|
276
|
-
|
|
277
|
-
const cleanup = () => {
|
|
278
|
-
if (cleanedUp) return
|
|
279
|
-
cleanedUp = true
|
|
280
|
-
// Save session before exit (synchronous write).
|
|
281
|
-
// Archiving to a slot is handled by /new and /session switch — not on every exit,
|
|
282
|
-
// otherwise simply opening and closing the TUI repeatedly would fill all slots with duplicates.
|
|
283
|
-
try {
|
|
284
|
-
saveSession(agent)
|
|
285
|
-
} catch {
|
|
286
|
-
// Save failure shouldn't block exit
|
|
287
|
-
}
|
|
288
|
-
// Kill MCP stdio subprocesses, don't leave orphans
|
|
289
|
-
try {
|
|
290
|
-
closeAllMcp(agent)
|
|
291
|
-
} catch {
|
|
292
|
-
// Can't close? fine, process is exiting anyway
|
|
293
|
-
}
|
|
294
|
-
process.stdin.setRawMode(false)
|
|
295
|
-
process.stdout.write(ansi.clearScreen + ansi.mouseOff + ansi.bracketedPasteOff + ansi.keyboardPop + ansi.modifyOtherKeysOff + ansi.mainBuffer + ansi.showCursor + ansi.reset)
|
|
296
|
-
}
|
|
277
|
+
const cleanup = createExitCleanup({ agent, saveSession, closeAllMcp })
|
|
297
278
|
process.on("exit", cleanup)
|
|
298
279
|
|
|
299
280
|
const pushLine = (text, color, kind) => {
|
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,7 +14,7 @@
|
|
|
14
14
|
* - picker option click = select it
|
|
15
15
|
* - folded-block hint click = expand it
|
|
16
16
|
*/
|
|
17
|
-
import { computeLayout } from "./layout.mjs"
|
|
17
|
+
import { computeLayout, subagentLineIndex } from "./layout.mjs"
|
|
18
18
|
import { buildConvLines, convViewport } from "./render-conversation.mjs"
|
|
19
19
|
import { toggleFoldBlock, scrollFoldBlock, foldScrollOffset } from "./fold-block.mjs"
|
|
20
20
|
|
|
@@ -29,6 +29,24 @@ export function handleWheel(ctx, button, col, row) {
|
|
|
29
29
|
const dims = state.dims ? state.dims.get() : { cols: process.stdout.columns || 80, rows: process.stdout.rows || 24 }
|
|
30
30
|
const layout = computeLayout(state, dims)
|
|
31
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
|
+
}
|
|
32
50
|
if (r < P.conversation.y || r >= P.conversation.y + P.conversation.h) return false
|
|
33
51
|
const convLines = buildConvLines(state, dims.cols, dims.rows)
|
|
34
52
|
const gIdx = convGlobalIndex(convLines.length, P.conversation.h, state.scroll ?? 0)(r - P.conversation.y)
|
|
@@ -101,6 +119,27 @@ export function handleMouseClick(ctx, col, row) {
|
|
|
101
119
|
return true
|
|
102
120
|
}
|
|
103
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
|
+
|
|
104
143
|
// ── Conversation: click a fold marker (expand hint or collapse marker) toggles it ──
|
|
105
144
|
if (r >= P.conversation.y && r < P.conversation.y + P.conversation.h) {
|
|
106
145
|
const convLines = buildConvLines(state, dims.cols, dims.rows)
|
|
@@ -181,30 +181,21 @@ export function convCacheKey(state, maxRows) {
|
|
|
181
181
|
// Content prefix in the signature: same kind+length with different content
|
|
182
182
|
// would otherwise collide (stale render); 8 chars disambiguate in practice.
|
|
183
183
|
const blocksSig = (state._advisorBlocks ?? []).map((b) => `${b.kind}:${b.text?.length ?? 0}:${String(b.text ?? "").slice(0, 8)}`).join(",")
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
//
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
}
|
|
200
|
-
// Frozen blocks ride state.lines ({_frozenSubTask}) — the lines.length part of
|
|
201
|
-
// this key covers their existence; expanding/collapsing one flips expandedBlocks
|
|
202
|
-
// (covered by `exp`). One extra: the last frozen payload's header depends on
|
|
203
|
-
// blocks content which never changes post-freeze — nothing more needed.
|
|
204
|
-
// Same single pass also builds the per-line COLOR-CLASS signature: foldability
|
|
205
|
-
// is decided by color class since main output (C.text) never folds while
|
|
206
|
-
// thinking/dim do (2026-08-30) — two states differing only in line color used
|
|
207
|
-
// to collide on this key and serve a stale cached render.
|
|
184
|
+
// NOTE (§7.2.1): running subagent blocks are NOT part of the conversation
|
|
185
|
+
// anymore — they render in the fixed bottom panel (subagent-panel.mjs,
|
|
186
|
+
// uncached per frame: the 1s ticker refreshes the panel's elapsed display).
|
|
187
|
+
// The old subSig (blockEpoch/turn/elapsed invalidation) is removed: the panel
|
|
188
|
+
// re-renders independently, so child activity must NOT invalidate the
|
|
189
|
+
// conversation cache (that would rebuild the whole conversation per child
|
|
190
|
+
// token — exactly what the 2026-08-31 lazy-load optimization eliminated).
|
|
191
|
+
// Frozen blocks ride state.lines ({_frozenSubTask}) — the lines.length part
|
|
192
|
+
// of this key covers their existence; expanding/collapsing one flips
|
|
193
|
+
// expandedBlocks (covered by `exp`). One extra: the last frozen payload's
|
|
194
|
+
// header depends on blocks content which never changes post-freeze — nothing
|
|
195
|
+
// more needed. Same single pass also builds the per-line COLOR-CLASS
|
|
196
|
+
// signature: foldability is decided by color class since main output (C.text)
|
|
197
|
+
// never folds while thinking/dim do (2026-08-30) — two states differing only
|
|
198
|
+
// in line color used to collide on this key and serve a stale cached render.
|
|
208
199
|
// Tool-block carriers ({_toolBlock}) contribute their BUFFER SIZE signature:
|
|
209
200
|
// output/result arrays mutate in place (streaming appends, result landing),
|
|
210
201
|
// and carrier text is always "" — without this the cache serves a stale block
|
|
@@ -225,7 +216,7 @@ export function convCacheKey(state, maxRows) {
|
|
|
225
216
|
// this the cache would serve the pre-search rows and highlight would never
|
|
226
217
|
// appear (P0-1, 2026-08-30 consult). query+index covers match navigation.
|
|
227
218
|
const searchPart = state.search?.query ? `${state.search.query}:${state.search.index ?? 0}` : ""
|
|
228
|
-
return `${state.lines.length}|${lastLine?.text.length ?? 0}|${state.streaming.length}|${state.reasoning.length}|${blocksSig}|${
|
|
219
|
+
return `${state.lines.length}|${lastLine?.text.length ?? 0}|${state.streaming.length}|${state.reasoning.length}|${blocksSig}|${frozenSig}|${toolSig}|${colorSig}|${state.foldEnabled !== false ? "f" : "u"}|${exp}|${capPart}|${searchPart}|${foldScrollSig}`
|
|
229
220
|
}
|
|
230
221
|
|
|
231
222
|
/** Fold marker line: bold-cyan icon + "click to …" phrase underlined (clickable affordance).
|
|
@@ -257,14 +248,16 @@ function highlightSearchMatches(text, query, matchesInLine, globalCurrentIndex,
|
|
|
257
248
|
}
|
|
258
249
|
|
|
259
250
|
/** Render a FROZEN child activity block carried on a state.lines entry
|
|
260
|
-
* (subagent-blocks.mjs freezeSubTaskLines pushes {_frozenSubTask: sub}).
|
|
261
|
-
*
|
|
262
|
-
*
|
|
263
|
-
*
|
|
264
|
-
*
|
|
265
|
-
*
|
|
266
|
-
*
|
|
267
|
-
*
|
|
251
|
+
* (subagent-blocks.mjs freezeSubTaskLines pushes {_frozenSubTask: sub}). The
|
|
252
|
+
* running form renders in the fixed bottom panel (§7.2.1 subagent-panel.mjs);
|
|
253
|
+
* the frozen form stays in the stream with the same interaction: folded =
|
|
254
|
+
* `[✓ coder#1 · glm-5.3 · done 45s · turn 12/100] … click to expand` header +
|
|
255
|
+
* tail 3 block lines; expanded = blank + ▼ control + full timeline (60% screen
|
|
256
|
+
* cap via the shared component — capped view ends in a reachable collapse
|
|
257
|
+
* control). Toggle key `sub-${key}` — the SAME key the panel section uses, so
|
|
258
|
+
* fold state carries across the freeze boundary seamlessly (user ruled
|
|
259
|
+
* 2026-08-30: frozen stays clickable — full design interaction, not a
|
|
260
|
+
* dim-lines fallback). */
|
|
268
261
|
function frozenSubTaskLines(state, sub, cols, maxRows) {
|
|
269
262
|
const foldKey = `sub-${sub.key}`
|
|
270
263
|
const elapsed = Math.floor(((sub.doneAt ?? Date.now()) - sub.started) / 1000)
|
|
@@ -337,7 +330,8 @@ function buildConvLines(state, cols, maxRows) {
|
|
|
337
330
|
blankAfter = !nextMain
|
|
338
331
|
}
|
|
339
332
|
// Frozen subagent activity block (§7.2 D4, 2026-08-30): rendered as its own
|
|
340
|
-
// collapsible section
|
|
333
|
+
// collapsible section in the stream (running blocks live in the fixed panel,
|
|
334
|
+
// §7.2.1 — the frozen form keeps the same clickable interaction).
|
|
341
335
|
if (l._frozenSubTask) {
|
|
342
336
|
// 2026-08-31 段缓存:frozenSubTask 冻结后内容不变——签名含 sub.key + blocks 计数
|
|
343
337
|
const fKey = `sub-${l._frozenSubTask.key}`
|
|
@@ -425,65 +419,14 @@ function buildConvLines(state, cols, maxRows) {
|
|
|
425
419
|
blankAfter = false
|
|
426
420
|
}
|
|
427
421
|
}
|
|
428
|
-
// ── Subagent activity blocks (§7.2
|
|
429
|
-
//
|
|
430
|
-
//
|
|
431
|
-
//
|
|
432
|
-
//
|
|
433
|
-
//
|
|
434
|
-
//
|
|
435
|
-
|
|
436
|
-
const runningSubs = Object.values(state.subTasks ?? {}).filter((s) => !s.done)
|
|
437
|
-
if (runningSubs.length > 0) {
|
|
438
|
-
// Divider between the conversation body and the running-subagent band
|
|
439
|
-
// (user request 2026-08-30, mirroring the task-panel divider in
|
|
440
|
-
// render-frame renderTodo). Only when at least one block actually renders —
|
|
441
|
-
// done children are frozen into state.lines above, so a divider for an
|
|
442
|
-
// empty band would hang over the section boundary.
|
|
443
|
-
// Unconditional divider (task-panel style): the preceding main-output
|
|
444
|
-
// trailing blank is breathing room, the divider is the section boundary —
|
|
445
|
-
// both belong. Only dedupe against another divider (idempotent re-render).
|
|
446
|
-
if (convLines.at(-1)?.color !== C.dim || !convLines.at(-1)?.text?.startsWith("─")) {
|
|
447
|
-
convLines.push({ text: "─".repeat(Math.max(1, cols - 1)), color: C.dim, _skipDimFold: true })
|
|
448
|
-
}
|
|
449
|
-
for (const sub of runningSubs) {
|
|
450
|
-
// Done children never reach this loop (filtered above): they are frozen
|
|
451
|
-
// into state.lines by subagent-blocks.mjs freezeSubTaskLines and removed
|
|
452
|
-
// from subTasks — a restored leftover would otherwise pin at the tail.
|
|
453
|
-
const foldKey = `sub-${sub.key}`
|
|
454
|
-
// Header summary: `[▶ coder#1 · glm-5.3 · 45s · turn 12/100] bash — npm test`
|
|
455
|
-
const icon = sub.approval ? "⏸" : "▶"
|
|
456
|
-
const elapsed = Math.floor(((sub.done ? sub.doneAt : Date.now()) - sub.started) / 1000)
|
|
457
|
-
const modelPart = sub.model ? ` · ${sub.model}` : ""
|
|
458
|
-
const turnPart = sub.maxTurns > 0 ? ` · turn ${sub.turn}/${sub.maxTurns}` : ""
|
|
459
|
-
let statePart
|
|
460
|
-
if (sub.approval) statePart = `等待审批: ${sub.approval}`
|
|
461
|
-
else if (sub.done) {
|
|
462
|
-
statePart = `done ${elapsed}s${sub.lastError ? ` — ${sub.lastError}` : ""}`
|
|
463
|
-
} else if (sub.currentTool) statePart = sub.currentTool
|
|
464
|
-
else statePart = "thinking..."
|
|
465
|
-
const argSummary = sub.currentTool && sub.toolArgs?.command
|
|
466
|
-
? ` — ${String(sub.toolArgs.command).replace(/\s+/g, " ").trim().slice(0, 60)}`
|
|
467
|
-
: ""
|
|
468
|
-
convLines.push({
|
|
469
|
-
text: `[${icon} ${sub.key}${modelPart} · ${elapsed}s${turnPart}] ${sliceByWidth(statePart + argSummary, Math.max(20, cols - 30))}`,
|
|
470
|
-
color: sub.done ? C.dim : C.tool,
|
|
471
|
-
_foldToggle: foldKey,
|
|
472
|
-
})
|
|
473
|
-
if (isExpanded(state, foldKey)) {
|
|
474
|
-
// Full activity timeline via the shared component (per-kind colors,
|
|
475
|
-
// 60% screen cap — the header control may sit above the viewport once
|
|
476
|
-
// expanded, the capped bottom control stays reachable).
|
|
477
|
-
const body = renderBlockTimeline(sub.blocks, cols)
|
|
478
|
-
convLines.push(...renderExpandedBlock({ body, foldKey, state, maxRows, cols, label: "subagent activity" }))
|
|
479
|
-
} else {
|
|
480
|
-
// Folded: tail 3 non-empty block lines (most recent activity), dim.
|
|
481
|
-
for (const line of foldTailLines(sub.blocks)) {
|
|
482
|
-
convLines.push({ text: `│ ${sliceByWidth(line, cols - 4)}`, color: C.dim })
|
|
483
|
-
}
|
|
484
|
-
}
|
|
485
|
-
}
|
|
486
|
-
}
|
|
422
|
+
// ── Subagent activity blocks (§7.2.1 D2) — RUNNING blocks MOVED to the fixed
|
|
423
|
+
// bottom panel (subagent-panel.mjs renderSubagentPanel, layout.mjs precomputes
|
|
424
|
+
// the panel height; render-frame puts it between conversation and todo).
|
|
425
|
+
// buildConvLines no longer renders running children: on completion
|
|
426
|
+
// onToolResult freezes the block into state.lines (subagent-blocks.mjs
|
|
427
|
+
// freezeSubTaskLines) and it scrolls away with the conversation (D4 现状).
|
|
428
|
+
// Frozen blocks render above via the _frozenSubTask branch (frozenSubTaskLines).
|
|
429
|
+
|
|
487
430
|
if (state.reasoning) {
|
|
488
431
|
// Live thinking streams INSIDE the unified box (user ruling 2026-08-30:
|
|
489
432
|
// "思考过程中为什么不是直接进这个框" — the flat tail render was a
|