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.
@@ -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
- const left = sliceByWidth(rawLeft, Math.max(1, cols - 2 - stringWidth(right)))
93
- const titlePad = " ".repeat(Math.max(1, cols - 1 - stringWidth(left) - stringWidth(right)))
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
- const maxW = cols - 1 - (ind ? stringWidth(ind) + 1 : 0)
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 - 1 - stringWidth(text) - stringWidth(ind))) : ""
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))
@@ -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
+ }
@@ -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
+ }