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/render-frame.mjs
CHANGED
|
@@ -10,7 +10,7 @@ import { ansi, C, ESC } from "./ansi.mjs"
|
|
|
10
10
|
import { convCacheKey, renderConversation, countConvLines } from "./render-conversation.mjs"
|
|
11
11
|
import { sliceByWidth, stringWidth } from "./render.mjs"
|
|
12
12
|
import { specForModel } from "../config.mjs"
|
|
13
|
-
import { computeLayout } from "./layout.mjs"
|
|
13
|
+
import { computeLayout, subagentVisibleLines } from "./layout.mjs"
|
|
14
14
|
import { basename } from "node:path"
|
|
15
15
|
import { readFileSync } from "node:fs"
|
|
16
16
|
|
|
@@ -89,8 +89,10 @@ export function renderPicker(state, cols, panel, overlay) {
|
|
|
89
89
|
// 过滤提示:无filter时显示 "type to filter",有filter时显示输入内容
|
|
90
90
|
const filterHint = p ? (p.filter ? `│ ${p.filter}` : "│ type to filter") : ""
|
|
91
91
|
const rawLeft = p ? ` ❯ ${p.title} ${filterHint} ` : " ❯ Setup "
|
|
92
|
-
|
|
93
|
-
|
|
92
|
+
// 2026-08-31 会诊(advisor round1 🔵):标题行与条目行一致留 8 格余量——Ambiguous 字符
|
|
93
|
+
// (❯/│ 等)在 CJK 终端渲染 2 格,余量不足时右侧 n/m 位置指示会被截。
|
|
94
|
+
const left = sliceByWidth(rawLeft, Math.max(1, cols - 8 - stringWidth(right)))
|
|
95
|
+
const titlePad = " ".repeat(Math.max(1, cols - 8 - stringWidth(left) - stringWidth(right)))
|
|
94
96
|
out.push(`${ansi.bold}${C.tool}${left}${ansi.reset}${ansi.dim}${titlePad}${right}${ansi.reset}`)
|
|
95
97
|
const hasMoreAbove = start > 0
|
|
96
98
|
const hasMoreBelow = start + winH < total
|
|
@@ -100,10 +102,14 @@ export function renderPicker(state, cols, panel, overlay) {
|
|
|
100
102
|
const moreAbove = i === 0 && hasMoreAbove
|
|
101
103
|
const moreBelow = i === shown.length - 1 && hasMoreBelow
|
|
102
104
|
const ind = moreAbove && moreBelow ? "↑↓ more" : moreAbove ? "↑ more" : moreBelow ? "↓ more" : ""
|
|
103
|
-
|
|
105
|
+
// 2026-08-31 会诊:右边距留 8 格余量——Ambiguous 宽度字符(│/—/●/▸/…/↑↓ 等)在中文
|
|
106
|
+
// locale 终端渲染 2 格而 stringWidth 按 1 格算,/session 行最坏带 ~7 个(▸ 前缀 +
|
|
107
|
+
// │×3 + … + — + ●)→ 行宽低估 7+ 格 → 实际超宽 → 物理 wrap → 残影;8 格余量覆盖
|
|
108
|
+
// 常见低估,最坏残余由 DECAWM 关闭硬截断兜底(见 render-loop)。
|
|
109
|
+
const maxW = cols - 8 - (ind ? stringWidth(ind) + 1 : 0)
|
|
104
110
|
// 超宽行截断并加省略号
|
|
105
111
|
const text = stringWidth(l.text) > maxW ? sliceByWidth(l.text, Math.max(0, maxW - 1)) + "…" : l.text
|
|
106
|
-
const pad = ind ? " ".repeat(Math.max(1, cols -
|
|
112
|
+
const pad = ind ? " ".repeat(Math.max(1, cols - 8 - stringWidth(text) - stringWidth(ind))) : ""
|
|
107
113
|
out.push(`${l.color}${text}${ansi.reset}${ind ? `${ansi.dim}${pad}${ind}${ansi.reset}` : ""}`)
|
|
108
114
|
}
|
|
109
115
|
for (let i = shown.length; i < winH; i++) out.push("")
|
|
@@ -221,7 +227,7 @@ export function renderRows(state, agent, opts) {
|
|
|
221
227
|
const slashCommands = opts.slashCommands ?? []
|
|
222
228
|
|
|
223
229
|
const layout = computeLayout(state, { cols, rows })
|
|
224
|
-
const { W, panels, inputLayout, inputOffset, boxLines, visibleTasks, permPreviewLines, overlay } = layout
|
|
230
|
+
const { W, panels, inputLayout, inputOffset, boxLines, visibleTasks, permPreviewLines, overlay, subagentLines } = layout
|
|
225
231
|
|
|
226
232
|
const screen = new Array(rows).fill("")
|
|
227
233
|
const put = (y, lines) => {
|
|
@@ -232,6 +238,16 @@ export function renderRows(state, agent, opts) {
|
|
|
232
238
|
|
|
233
239
|
put(panels.header.y, [renderHeader(agent, cols)])
|
|
234
240
|
put(panels.conversation.y, renderConversation(state, cols, panels.conversation.h, state.scroll, rows))
|
|
241
|
+
// §7.2.1 D2: running-subagent fixed panel (between conversation and todo) —
|
|
242
|
+
// lines precomputed by layout (subagent-panel.mjs, neutral module), put
|
|
243
|
+
// directly (no double render); absent when no block is running (F6). Lines
|
|
244
|
+
// are objects ({text, color, _foldToggle/_foldBlock…} — mouse hit-testing
|
|
245
|
+
// reads the same array), converted to ANSI strings here like renderTodo.
|
|
246
|
+
if (panels.subagent) {
|
|
247
|
+
// 评审 #4:部分压缩(h < 全长)时保底截断——分隔线 + 末尾区块行(最新活动
|
|
248
|
+
// 优先),与 mouse 命中映射同一几何契约(subagentVisibleLines / subagentLineIndex)。
|
|
249
|
+
put(panels.subagent.y, subagentVisibleLines(subagentLines, panels.subagent.h).map((l) => `${l.color ?? ""}${l.text}${ansi.reset}`))
|
|
250
|
+
}
|
|
235
251
|
if (panels.todo) put(panels.todo.y, renderTodo(visibleTasks, cols))
|
|
236
252
|
if (panels.picker) put(panels.picker.y, renderPicker(state, cols, panels.picker, overlay))
|
|
237
253
|
if (panels.permission) put(panels.permission.y, renderPermission(permPreviewLines))
|
package/src/tui/render-loop.mjs
CHANGED
|
@@ -114,7 +114,7 @@ export function createRenderLoop(state, agent, ctx, pushLine, write = (s) => pro
|
|
|
114
114
|
const hasOverlay = state.permission || state.question || state.picker || state.wizard?.step === "provider"
|
|
115
115
|
const cursorSuffix = hasOverlay ? "" : `\x1b[${cursorRow};${cursorCol}H${ansi.hideCursor}`
|
|
116
116
|
|
|
117
|
-
if (out.length || cursorSuffix) write(ansi.syncUpdateStart + out.join("") + ansi.syncUpdateEnd + cursorSuffix)
|
|
117
|
+
if (out.length || cursorSuffix) write(ansi.wrapOff + ansi.syncUpdateStart + out.join("") + ansi.syncUpdateEnd + cursorSuffix + ansi.wrapOn)
|
|
118
118
|
} catch (e) {
|
|
119
119
|
// Don't let a render error crash the TUI
|
|
120
120
|
if (process.env.THINCODER_DEBUG_RENDER) process.stderr.write(`[render-error] ${e?.stack ?? e}\n`)
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* subagent-panel.mjs — 运行中子 agent 固定底部面板渲染(AGENT-LOOP.md §7.2.1 D1/D2)。
|
|
3
|
+
*
|
|
4
|
+
* 面板位于 conversation 与 todo 之间(布局顺序 header → conversation → 面板 →
|
|
5
|
+
* todo → picker → permission → queue → input → status),高度完全自适应 = 全部
|
|
6
|
+
* 运行中区块的渲染行数(F2,会话区被挤小);无运行中区块 → 返回 [](F6 空态,
|
|
7
|
+
* 无悬空分隔线)。子 agent 完成后立即冻结进会话流(subagent-blocks.mjs
|
|
8
|
+
* freezeSubTaskLines,✓ 头 + 可展开,§7.2 D4 现状不变),面板下一帧自然移除
|
|
9
|
+
* 该区块(F5)——本模块只渲染 `!done` 条目。
|
|
10
|
+
*
|
|
11
|
+
* 中立模块(D1 评审 #6):layout.mjs 调 renderSubagentPanel 预计算面板高度
|
|
12
|
+
* (subagentLines → subagentH),render-frame.mjs 直接 put 预计算行(不重复
|
|
13
|
+
* 渲染)——若本函数放 render-frame 会引入 layout↔render-frame 循环依赖。
|
|
14
|
+
*
|
|
15
|
+
* 区块渲染逻辑自 render-conversation.mjs buildConvLines runningSubs 段迁移
|
|
16
|
+
* (§7.2.1 D2):折叠头 `[▶/⏸ key · model · elapsed · turn] state`(⏸ = 等待
|
|
17
|
+
* 审批态图标,sub.approval 非空时显示)+ tail 3;展开态经 fold-block.mjs 公共
|
|
18
|
+
* 组件(renderBlockTimeline + renderExpandedBlock,60% 封顶 + 块内滚动)。
|
|
19
|
+
* 折叠状态 key = `sub-${key}` 跨 turn 保持(D5,与冻结区块同一 key——冻结边界
|
|
20
|
+
* 无缝衔接)。
|
|
21
|
+
*/
|
|
22
|
+
import { C } from "./ansi.mjs"
|
|
23
|
+
import { sliceByWidth, stringWidth } from "./render.mjs"
|
|
24
|
+
import { isExpanded, renderBlockTimeline, renderExpandedBlock, foldTailLines } from "./fold-block.mjs"
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 面板行构建(纯函数):顶部分隔线 `─` + 各运行中区块(折叠头 + tail 3 /
|
|
28
|
+
* 展开全量)。maxRows = 终端行数(展开态 60% 封顶窗口化);省略 = 不封顶
|
|
29
|
+
* (单测/无终端环境)。
|
|
30
|
+
* @returns {Array<{text: string, color: string, ...}>}
|
|
31
|
+
*/
|
|
32
|
+
export function renderSubagentPanel(state, cols, maxRows) {
|
|
33
|
+
const runningSubs = Object.values(state.subTasks ?? {}).filter((s) => !s.done)
|
|
34
|
+
if (runningSubs.length === 0) return []
|
|
35
|
+
const out = []
|
|
36
|
+
// 面板顶部边界线(现状分隔线语义迁移,§7.2.1 D2/NF2)——面板存在即画线,
|
|
37
|
+
// 无运行区块时面板整体不渲染(F6:无悬空线)。
|
|
38
|
+
out.push({ text: "─".repeat(Math.max(1, cols - 1)), color: C.dim, _skipDimFold: true })
|
|
39
|
+
for (const sub of runningSubs) {
|
|
40
|
+
const foldKey = `sub-${sub.key}`
|
|
41
|
+
// 头部摘要:`[▶ coder#1 · glm-5.3 · 45s · turn 12/100] bash — npm test`
|
|
42
|
+
// ⏸ = 等待审批态(sub.approval 非空,评审 #5 定义);图标在括号内,
|
|
43
|
+
// 与冻结头 `[✓ …]` 格式统一(任务简报 UI 决策)。
|
|
44
|
+
const icon = sub.approval ? "⏸" : "▶"
|
|
45
|
+
const elapsed = Math.floor((Date.now() - sub.started) / 1000)
|
|
46
|
+
// 评审 #1 宽度预算:模型名先单独按显示宽度截断([model] token 原样记录可长
|
|
47
|
+
// 20-30+ 字符,不截断则括号前缀宽度不可预算、状态区被挤出终端右边距);
|
|
48
|
+
// 再量括号前缀实际显示宽度,状态区按 cols - bracketWidth - 2 截断——整行
|
|
49
|
+
// ≤ cols 铁律(TUI 布局纪律:任何写入帧的行 ≤ cols)。极端窄终端下括号
|
|
50
|
+
// 前缀自身也按 cols-2 截断兜底(状态区宁可让位也不撑破帧)。
|
|
51
|
+
const modelPart = sub.model
|
|
52
|
+
? ` · ${sliceByWidth(sub.model, Math.max(8, Math.floor(cols / 3)))}`
|
|
53
|
+
: ""
|
|
54
|
+
const turnPart = sub.maxTurns > 0 ? ` · turn ${sub.turn}/${sub.maxTurns}` : ""
|
|
55
|
+
const bracket = sliceByWidth(`[${icon} ${sub.key}${modelPart} · ${elapsed}s${turnPart}]`, Math.max(1, cols - 2))
|
|
56
|
+
const bracketWidth = stringWidth(bracket)
|
|
57
|
+
let statePart
|
|
58
|
+
if (sub.approval) statePart = `等待审批: ${sub.approval}`
|
|
59
|
+
else if (sub.currentTool) statePart = sub.currentTool
|
|
60
|
+
else statePart = "thinking..."
|
|
61
|
+
const argSummary = sub.currentTool && sub.toolArgs?.command
|
|
62
|
+
? ` — ${String(sub.toolArgs.command).replace(/\s+/g, " ").trim().slice(0, 60)}`
|
|
63
|
+
: ""
|
|
64
|
+
out.push({
|
|
65
|
+
text: `${bracket} ${sliceByWidth(statePart + argSummary, Math.max(0, cols - 2 - bracketWidth))}`,
|
|
66
|
+
color: C.tool,
|
|
67
|
+
_foldToggle: foldKey,
|
|
68
|
+
})
|
|
69
|
+
if (isExpanded(state, foldKey)) {
|
|
70
|
+
// 展开态:全量活动时间线(per-kind 着色,60% 屏封顶 + 块内滚动——公共组件)。
|
|
71
|
+
const body = renderBlockTimeline(sub.blocks, cols)
|
|
72
|
+
out.push(...renderExpandedBlock({ body, foldKey, state, maxRows, cols, label: "subagent activity" }))
|
|
73
|
+
} else {
|
|
74
|
+
// 折叠态:tail 3 非空 block 行(最近活动),dim。
|
|
75
|
+
for (const line of foldTailLines(sub.blocks)) {
|
|
76
|
+
out.push({ text: `│ ${sliceByWidth(line, cols - 4)}`, color: C.dim })
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return out
|
|
81
|
+
}
|
package/src/tui/tool-events.mjs
CHANGED
|
@@ -406,7 +406,7 @@ export function buildToolCallbacks(deps) {
|
|
|
406
406
|
// the compressed version — persist it so the session file ends up compressed. Silent:
|
|
407
407
|
// a save failure must never surface after the turn already returned.
|
|
408
408
|
onDistilled: () => {
|
|
409
|
-
try { saveSessionImpl(agent, state.lines) } catch {
|
|
409
|
+
try { saveSessionImpl(agent, state.lines) } catch (e) { console.error(`[session] distilled save failed: ${e.message}`) }
|
|
410
410
|
},
|
|
411
411
|
onUsage: (usage) => {
|
|
412
412
|
state.tokens.prompt += usage.prompt_tokens ?? 0
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tui-lifecycle.mjs — TUI 生命周期终端序列(2026-08-31 advisor round1 🔴:index.mjs
|
|
3
|
+
* 超 500 行硬限拆分——启动序列、退出清理序列与退出闭包从 index.mjs 移入本模块)。
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { ansi } from "./ansi.mjs"
|
|
7
|
+
|
|
8
|
+
/** TUI 启动序列:alt buffer + 隐藏光标 + 鼠标/粘贴/键盘增强 + 禁环绕。
|
|
9
|
+
* wrapOff(DECRST 7)为 2026-08-31 会诊的最终防线:Ambiguous 宽度字符(│/—/●/▸/…/↑↓)
|
|
10
|
+
* 在中文 locale 终端渲染 2 格而 stringWidth 按 1 格算 → 行实际超宽 → 物理 wrap 污染
|
|
11
|
+
* 下一行 + \x1b[K 清错行 → picker 残影;禁环绕后超宽行硬截断在边距,不可能跨行污染。
|
|
12
|
+
* 每帧 write 再包 wrapOff/wrapOn(render-loop),退出经 writeCleanupSequence 恢复。 */
|
|
13
|
+
export function writeStartupSequence(write = (s) => process.stdout.write(s)) {
|
|
14
|
+
write(ansi.altBuffer + ansi.hideCursor + ansi.mouseOn + ansi.bracketedPasteOn + ansi.keyboardPush + ansi.modifyOtherKeysOn + ansi.wrapOff)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** TUI 清理序列:清屏 + 关闭鼠标/粘贴/键盘增强 + 退出 alt buffer + 显示光标 + 恢复环绕。 */
|
|
18
|
+
export function writeCleanupSequence(write = (s) => process.stdout.write(s)) {
|
|
19
|
+
write(ansi.clearScreen + ansi.mouseOff + ansi.bracketedPasteOff + ansi.keyboardPop + ansi.modifyOtherKeysOff + ansi.mainBuffer + ansi.showCursor + ansi.reset + ansi.wrapOn)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** 退出清理闭包:保存会话(同步)+ 关闭 MCP 子进程 + 恢复终端。幂等(cleanedUp 守卫)。 */
|
|
23
|
+
export function createExitCleanup({ agent, saveSession, closeAllMcp }) {
|
|
24
|
+
let cleanedUp = false
|
|
25
|
+
return () => {
|
|
26
|
+
if (cleanedUp) return
|
|
27
|
+
cleanedUp = true
|
|
28
|
+
// Save session before exit (synchronous write).
|
|
29
|
+
// Archiving to a slot is handled by /new and /session switch — not on every exit,
|
|
30
|
+
// otherwise simply opening and closing the TUI repeatedly would fill all slots with duplicates.
|
|
31
|
+
try {
|
|
32
|
+
saveSession(agent)
|
|
33
|
+
} catch {
|
|
34
|
+
// Save failure shouldn't block exit
|
|
35
|
+
}
|
|
36
|
+
// Kill MCP stdio subprocesses, don't leave orphans
|
|
37
|
+
try {
|
|
38
|
+
closeAllMcp(agent)
|
|
39
|
+
} catch {
|
|
40
|
+
// Can't close? fine, process is exiting anyway
|
|
41
|
+
}
|
|
42
|
+
process.stdin.setRawMode(false)
|
|
43
|
+
writeCleanupSequence()
|
|
44
|
+
}
|
|
45
|
+
}
|