thincoder 0.12.50 → 0.12.52

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.
Files changed (49) hide show
  1. package/CHANGELOG.md +64 -3
  2. package/README.md +2 -2
  3. package/package.json +4 -3
  4. package/src/acp/bridge.mjs +5 -0
  5. package/src/agent/dispatch.mjs +19 -7
  6. package/src/agent/helpers.mjs +13 -1
  7. package/src/agent/record-results.mjs +130 -0
  8. package/src/agent/setup.mjs +4 -7
  9. package/src/agent/spawn-child.mjs +159 -0
  10. package/src/agent-tools/consult.mjs +94 -73
  11. package/src/agent-tools/escalate.mjs +53 -62
  12. package/src/agent-tools/skill.mjs +1 -1
  13. package/src/agent-tools/subagent.mjs +39 -38
  14. package/src/agent-tools/task.mjs +0 -2
  15. package/src/agent-tools/verify.mjs +0 -1
  16. package/src/agent.mjs +27 -112
  17. package/src/config.mjs +8 -103
  18. package/src/generate-title.mjs +30 -1
  19. package/src/model-specs.mjs +108 -0
  20. package/src/prompts/advisor-round1.md +5 -6
  21. package/src/prompts/advisor-round2.md +3 -4
  22. package/src/prompts/advisor-round3.md +3 -4
  23. package/src/prompts/eng-coder.md +9 -0
  24. package/src/prompts/engineering.md +61 -9
  25. package/src/prompts/system.md +2 -2
  26. package/src/provider/core.mjs +5 -71
  27. package/src/provider/normalize.mjs +81 -0
  28. package/src/session.mjs +40 -1
  29. package/src/tools/git.mjs +3 -3
  30. package/src/tools/shared.mjs +1 -0
  31. package/src/tools/system.mjs +3 -1
  32. package/src/tui/agent-turn.mjs +37 -364
  33. package/src/tui/clipboard.mjs +3 -1
  34. package/src/tui/dims.mjs +47 -0
  35. package/src/tui/fold-block.mjs +208 -0
  36. package/src/tui/index.mjs +33 -17
  37. package/src/tui/key-handler-search.mjs +1 -1
  38. package/src/tui/key-handler.mjs +10 -6
  39. package/src/tui/layout.mjs +21 -20
  40. package/src/tui/mouse.mjs +9 -6
  41. package/src/tui/pickers.mjs +1 -1
  42. package/src/tui/render-conversation.mjs +367 -113
  43. package/src/tui/render-frame.mjs +16 -90
  44. package/src/tui/render-loop.mjs +12 -8
  45. package/src/tui/render.mjs +16 -0
  46. package/src/tui/startup.mjs +66 -13
  47. package/src/tui/subagent-blocks.mjs +327 -0
  48. package/src/tui/tool-args.mjs +67 -0
  49. package/src/tui/tool-events.mjs +459 -0
@@ -0,0 +1,208 @@
1
+ /**
2
+ * fold-block.mjs — 公共可折叠区块渲染组件(2026-08-30,自 render-conversation.mjs 抽出)。
3
+ *
4
+ * 背景(用户报告):折叠区块展开后长度不受限——超长区块展开时折叠控制行被挤出
5
+ * 屏幕,点不到、收不回。本组件统一所有可折叠区块的「展开态」渲染并施加
6
+ * **屏幕高度 60% 的展开封顶**:封顶时在区块底部渲染第二个折叠控制行——区块
7
+ * 最高占屏 60%,底控件必落在视口内,展开永远可逆。
8
+ *
9
+ * 消费方(render-conversation.mjs 六处折叠点):
10
+ * 1. 子agent 活动区块(运行中 / 冻结,AGENT-LOOP §7.2 D4)
11
+ * 2. advisor 评审块(运行中 _advisorBlocks / 冻结 _frozenAdvisor)
12
+ * 3. 长消息折叠(long-N)/ 连续 dim 折叠(fold-N)
13
+ *
14
+ * 分工边界:折叠态有两种既有形态(头部+tail3 / 前4+中置▶+末行),由调用方
15
+ * 保留各自语义;本组件统一展开态、封顶、控制行、tail 提取、blocks→行时间线。
16
+ */
17
+ import { C } from "./ansi.mjs"
18
+ import { formatTables, sanitizeDisplay, sliceByWidth, stringWidth, wrapText } from "./render.mjs"
19
+ import { renderMarkdownInline, renderMarkdownHeading } from "./markdown.mjs"
20
+ import { renderMathInline, renderMathBlock } from "./math.mjs"
21
+
22
+ /** Expanded-section height cap: 60% of the terminal's row count (user ruled
23
+ * 2026-08-30 — the collapse control must stay reachable after expanding).
24
+ * rows unknown (tests / odd environments) → Infinity = uncapped. */
25
+ export function foldCapRows(rows) {
26
+ if (!rows) return Infinity
27
+ return Math.max(1, Math.floor(rows * 0.6))
28
+ }
29
+
30
+ /** Fold-state read, single source: foldEnabled===false (unfold-all mode) forces
31
+ * every block expanded; otherwise the per-block key decides. */
32
+ export function isExpanded(state, foldKey) {
33
+ return state.foldEnabled === false || (state.expandedBlocks?.has(foldKey) ?? false)
34
+ }
35
+
36
+ /** Bidirectional toggle (mouse click / future keyboard path share this). */
37
+ export function toggleFoldBlock(state, foldKey) {
38
+ state.expandedBlocks ??= new Set()
39
+ if (state.expandedBlocks.has(foldKey)) state.expandedBlocks.delete(foldKey)
40
+ else state.expandedBlocks.add(foldKey)
41
+ }
42
+
43
+ /** Fold marker line: bold-cyan icon + "click to …" phrase underlined (clickable affordance).
44
+ * No indent — flush with the content below it; callers add a blank line BEFORE the
45
+ * expanded-state control so it stands apart from unrelated content (reported UX). */
46
+
47
+ /** Extract the last ≤n non-empty lines across the trailing blocks (folded-tail
48
+ * preview). Shared by subagent/advisor folded forms — was 3 hand-written copies
49
+ * in render-conversation (P1 收编, 2026-08-30). */
50
+ export function foldTailLines(blocks, n = 3, { strip = [] } = {}) {
51
+ const out = []
52
+ for (let bi = blocks.length - 1; bi >= 0 && out.length < n; bi--) {
53
+ let text = sanitizeDisplay(blocks[bi].text ?? "")
54
+ for (const marker of strip) text = text.replaceAll(marker, "")
55
+ const lines = text.split("\n").filter((l) => l.trim())
56
+ for (let li = lines.length - 1; li >= 0 && out.length < n; li--) out.unshift(lines[li])
57
+ }
58
+ return out
59
+ }
60
+
61
+ export function foldHintLine(text, foldKey, srcIdx) {
62
+ const withUnderline = text.replace(/(click to (?:expand|collapse))/, "\x1b[4m$1\x1b[24m")
63
+ return { text: withUnderline, color: C.fold, _foldToggle: foldKey, _src: srcIdx }
64
+ }
65
+
66
+ /**
67
+ * FOLDED-state rendering — the unified other half of the interaction (2026-08-30
68
+ * user ruling: EVERY collapsible section folds to "named header + last 3 lines,
69
+ * expands to a 60%-capped view"). Replaces the legacy long-message form
70
+ * [first 4, ▶ at the ellipsis, last 1] whose anonymous "… N more lines" header
71
+ * read as orphaned segments in the scrollback (user report 2026-08-30).
72
+ * header: the caller's control/summary line ({text, color, _foldToggle}) —
73
+ * subagent/advisor keep their bracket identity headers, long-message folds get
74
+ * `▶ <kind> · N lines — click to expand`. Tail rows are dimmed and carry
75
+ * _skipDimFold (never re-enter the consecutive-dim folder).
76
+ */
77
+ export function renderFoldedHead({ header, body, tailLines = 3, cols = 80 }) {
78
+ const tail = body
79
+ .filter((l) => l.text && l.text.trim())
80
+ .slice(-tailLines)
81
+ .map((l) => ({
82
+ ...l,
83
+ text: sliceByWidth(`│ ${l.text.replace(/^(?:│ ?| {2}│ ?| {2})/, "")}`, cols - 2),
84
+ color: C.dim,
85
+ _skipDimFold: true,
86
+ }))
87
+ return [header, ...tail]
88
+ }
89
+
90
+ /** Blank separator before a fold control line (uncolored — must not join consecutive-dim folding). */
91
+ export function blankLine() {
92
+ return { text: "", color: "" }
93
+ }
94
+
95
+ /**
96
+ * Render markdown markers to ANSI, then pad the line tail back to the pre-render
97
+ * display width (moved from render-conversation.mjs — see that file's history for
98
+ * the formatTables misalignment incident this compensation fixes).
99
+ */
100
+ export function renderMarkdownPreservingWidth(text) {
101
+ return text.split("\n").map((line) => {
102
+ const rendered = renderMarkdownInline(renderMarkdownHeading(line))
103
+ const diff = stringWidth(line) - stringWidth(rendered)
104
+ return diff > 0 ? rendered + " ".repeat(diff) : rendered
105
+ }).join("\n")
106
+ }
107
+
108
+ // Math runs BEFORE markdown (TUI.md §9.1D): `$...$`/`$$...$$` are opaque to markdown
109
+ // (so `x**2` inside a formula isn't misread as bold), and the Unicode approximation
110
+ // is measured by renderMarkdownPreservingWidth's width-compensation math.
111
+ export function renderMathAndMarkdown(text) {
112
+ return renderMarkdownPreservingWidth(renderMathInline(renderMathBlock(text)))
113
+ }
114
+
115
+ /**
116
+ * blocks[] → colored, guttered, wrapped timeline lines. Shared by subagent
117
+ * (running + frozen) and advisor (live + frozen) blocks — was 4 near-identical
118
+ * copies across render-conversation. Per-kind contract:
119
+ * think → raw lines (no markdown), C.reason
120
+ * text → math+markdown → formatTables, C.text
121
+ * tool → formatTables raw, C.tool
122
+ * meta → plain lines (no wrap — marker is short), C.dim
123
+ * opts: { gutter = "│ ", pad = 3, stripPlaceholder = false, headBlank = false }
124
+ * gutter/pad: frozen advisor renders full-width with no gutter (gutter "", pad 1).
125
+ * stripPlaceholder: advisor streams embed ADVISOR_THINKING_PLACEHOLDER markers —
126
+ * stripped in every advisor view (live expanded previously kept them; unified).
127
+ * headBlank: lead with a blank separator line (renderExpandedBlock default form).
128
+ * All lines carry _skipDimFold: true — the consecutive-dim folder must never
129
+ * nest on top of an expanded block (reported stacking regression).
130
+ */export function renderBlockTimeline(blocks, cols, opts = {}) {
131
+ // strip: array of literal substrings removed from every block text (callers
132
+ // pass advisor-specific markers — the component itself has no advisor deps).
133
+ const { gutter = "│ ", pad = 3, strip = [] } = opts
134
+ const out = []
135
+ for (const block of blocks) {
136
+ const color = { think: C.reason, tool: C.tool, text: C.text, meta: C.dim }[block.kind] ?? C.dim
137
+ let source = sanitizeDisplay(block.text ?? "")
138
+ for (const marker of strip) source = source.replaceAll(marker, "")
139
+ if (block.kind === "meta") {
140
+ for (const line of source.split("\n")) out.push({ text: `${gutter}${line}`, color, _skipDimFold: true })
141
+ continue
142
+ }
143
+ const rows = block.kind === "think"
144
+ ? source.split("\n")
145
+ : formatTables(block.kind === "text" ? renderMathAndMarkdown(source) : source, cols - pad)
146
+ for (const line of rows) {
147
+ for (const wrapped of wrapText(line, cols - pad)) {
148
+ out.push({ text: `${gutter}${wrapped}`, color, _skipDimFold: true })
149
+ }
150
+ }
151
+ }
152
+ return out
153
+ }
154
+
155
+ /**
156
+ * EXPANDED-state rendering of one collapsible section — the unified half of the
157
+ * interaction. Layout:
158
+ * foldEnabled=false → raw body only (hints would lie: toggling is a no-op;
159
+ * the 60% cap equally does not apply to unfold-all mode)
160
+ * body ≤ cap → [blank] + ▼ control + all body
161
+ * body > cap → [blank] + ▼ control + first (cap-4) lines + cap marker +
162
+ * ▼ control AT THE BOTTOM (the reachable one — the whole
163
+ * point of the cap; the top control may sit above the
164
+ * viewport when the block starts off-screen)
165
+ * opts: { body, foldKey, state, maxRows, label, headBlank = true }
166
+ * body: pre-rendered body lines ({text, color, ...}); label: header phrase
167
+ * used in both control lines (e.g. "subagent activity", "12 lines");
168
+ * headBlank=false (tool-args blocks): no leading blank — the args block
169
+ * belongs tightly to its ❯ title line.
170
+ *
171
+ * LEFT RULE LINE (2026-08-30 user request): every body row gets a `│ ` gutter
172
+ * prefix so the block's content reads as one bordered region, distinct from
173
+ * surrounding conversation. renderExpandedBlock OWNS the gutter: body callers
174
+ * pass RAW rows (no leading `│ `/indent of their own) — double gutters are
175
+ * stripped defensively.
176
+ */
177
+ export function renderExpandedBlock({ body, foldKey, state, maxRows, label, cols = 80 }) {
178
+ if (state.foldEnabled === false) return body.slice()
179
+ // Strip caller-side gutters/indents, then apply the single owned gutter.
180
+ // HARD WIDTH BOUND: caller rows may already be cols-1 wide (wrapped at
181
+ // cols-1 upstream); adding the 2-char gutter would overflow by one column —
182
+ // the exact "one char past the border" bug (user report 2026-08-30). The
183
+ // component owns the final width: every row is sliced to cols-2 AFTER the
184
+ // gutter, so the frame never sees an overwide row.
185
+ const lined = body.map((l) => {
186
+ // Empty rows keep the rule line unbroken: a bare "│" (no trailing space)
187
+ // paints the same column without introducing trailing whitespace.
188
+ if (!l.text || !l.text.trim()) return { ...l, text: "│", _skipDimFold: true }
189
+ const raw = l.text.replace(/^(?:│ ?| {2}│ ?| {2})/, "")
190
+ return { ...l, text: sliceByWidth(`│ ${raw}`, cols - 2) }
191
+ })
192
+ const out = [blankLine(), foldHintLine(`▼ … ${label} — click to collapse`, foldKey)]
193
+ const cap = foldCapRows(maxRows)
194
+ if (lined.length <= cap) {
195
+ out.push(...lined)
196
+ return out
197
+ }
198
+ // Reserve room for blank + top control + cap marker + bottom control.
199
+ const keep = Math.max(1, cap - 4)
200
+ out.push(...lined.slice(0, keep))
201
+ out.push({
202
+ text: `│ … ${body.length - keep} more lines — expansion capped at 60% of screen (collapse to re-expand)`,
203
+ color: C.dim,
204
+ _skipDimFold: true,
205
+ })
206
+ out.push(foldHintLine(`▼ … ${label} — click to collapse`, foldKey))
207
+ return out
208
+ }
package/src/tui/index.mjs CHANGED
@@ -22,6 +22,7 @@ import { saveSession } from "../session.mjs"
22
22
  import { closeAllMcp } from "../mcp.mjs"
23
23
  import { ansi, C } from "./ansi.mjs"
24
24
  import { createRenderLoop } from "./render-loop.mjs"
25
+ import { makeDimsState } from "./dims.mjs"
25
26
  import { SLASH_COMMANDS, SLASH_ALIASES, createSlashCommands } from "./slash-commands.mjs"
26
27
  import { createWizard } from "./wizard.mjs"
27
28
  import { createPickers } from "./pickers.mjs"
@@ -58,6 +59,14 @@ export async function startTUI(agent, opts = {}) {
58
59
 
59
60
  const distillOpts = opts
60
61
 
62
+ // Capture terminal dimensions BEFORE raw mode & alt buffer switch as the
63
+ // state.dims seed (ConPTY reads are unstable: falsy at startup, stale-small
64
+ // during output activity). Refresh happens ONLY in event hooks — startup
65
+ // convergence retry, the 300ms delayed resample, resize events, the idle
66
+ // watchdog, agent-turn finally — never in the render path (2026-08-30).
67
+ const startupCols = process.stdout.columns || 80
68
+ const startupRows = process.stdout.rows || 24
69
+
61
70
  const state = {
62
71
  lines: [], // conversation lines: { text, color }
63
72
  streaming: "", // current streaming buffer
@@ -78,12 +87,13 @@ export async function startTUI(agent, opts = {}) {
78
87
  pendingNotice: null, // 后台更新提示:有 picker 打开时挂起,picker 全部关闭后再弹
79
88
  wizard: null, // first-launch config wizard { step, index, scroll, selectedLine, fields, error, lines }
80
89
  tasks: agent.tasks ?? [], // task list from task tool (progress shown in status bar); carried over on session restore, auto-collapsed when all done
90
+ dims: makeDimsState({ cols: startupCols, rows: startupRows }), // terminal dims single source (Windows ConPTY instability, 2026-08-30) — seeded pre-raw-mode, re-sampled by event hooks only (startup retry / resize / idle watchdog)
81
91
  tokens: { prompt: 0, completion: 0, cacheHit: 0, cacheMiss: 0, reasoningTokens: 0 }, // cumulative token usage (shown in status bar)
82
92
  ctxCache: { len: -1, tokens: 0 }, // context utilization estimate cache (estimateTokens is O(n), only recompute when history grows)
83
93
  reasoning: "", // thinking stream buffer (dimmed display)
84
94
  completion: null, // Tab completion state { candidates, index }
85
- _autoExpand: [], // indices of completed replies kept expanded; cleared when the next user turn starts
86
- subTasks: {}, // sub-agent panel: { roleName: { role, text, done } }, one line per role, marked done briefly after completion
95
+
96
+ subTasks: {}, // sub-agent activity blocks (§7.2 D4): { "coder#1": { key, role, model, started, done, doneAt, blocks: [{kind,text}], currentTool, toolArgs, turn, maxTurns, approval, lastError, dropped, blockEpoch } } — rendered as collapsible in-conversation blocks; persists across turns (blocks are the child activity's ONLY carrier — child tool calls never enter the parent history); bounded by the N2 500-line per-child ring buffer
87
97
  currentTool: null, // currently executing tool name (shown in status bar)
88
98
  processingStarted: 0, // current turn start time (status bar timer)
89
99
  status: "Ready",
@@ -111,11 +121,6 @@ export async function startTUI(agent, opts = {}) {
111
121
  const keyStream = new PassThrough()
112
122
  let mousePending = "" // incomplete mouse sequence tail spanning chunks
113
123
  let lastRenderedScroll = 0
114
- // Capture terminal dimensions before raw mode & alt buffer switch.
115
- // On Windows, process.stdout.columns/rows can briefly return falsy after the mode switch
116
- // (ConPTY buffer transition), causing the ||80/||24 fallback to produce a cramped initial layout.
117
- const startupCols = process.stdout.columns || 80
118
- const startupRows = process.stdout.rows || 24
119
124
  emitKeypressEvents(keyStream)
120
125
  process.stdin.setRawMode(true)
121
126
  // Keyboard enhancement — enable BOTH protocols (unsupported terminals ignore them):
@@ -179,6 +184,7 @@ export async function startTUI(agent, opts = {}) {
179
184
  }
180
185
 
181
186
  // Scroll wheel: \x1b[<64;…M = scroll up, \x1b[<65;…M = scroll down (3 lines each)
187
+ // eslint-disable-next-line no-control-regex -- 有意为之:控制字符协议/转义序列剥离正则(ANSI/⟦ev⟧/SGR/history 双线分隔)
182
188
  for (const m of text.matchAll(/\x1b\[<(\d+);\d+;\d+([Mm])/g)) {
183
189
  if (Number(m[1]) === 64) {
184
190
  state.scroll += 3
@@ -198,7 +204,9 @@ export async function startTUI(agent, opts = {}) {
198
204
  }
199
205
 
200
206
  // Strip complete mouse sequences; keep incomplete tail for reassembly with next chunk
207
+ // eslint-disable-next-line no-control-regex -- 有意为之:控制字符协议/转义序列剥离正则(ANSI/⟦ev⟧/SGR/history 双线分隔)
201
208
  text = text.replace(/\x1b\[<\d+;\d+;\d+[Mm]/g, "")
209
+ // eslint-disable-next-line no-control-regex -- 有意为之:控制字符协议/转义序列剥离正则(ANSI/⟦ev⟧/SGR/history 双线分隔)
202
210
  const tail = text.match(/\x1b\[<[\d;]*$/)
203
211
  if (tail) {
204
212
  mousePending = tail[0]
@@ -242,8 +250,8 @@ export async function startTUI(agent, opts = {}) {
242
250
  }
243
251
  process.on("exit", cleanup)
244
252
 
245
- const pushLine = (text, color) => {
246
- state.lines.push({ text, color })
253
+ const pushLine = (text, color, kind) => {
254
+ state.lines.push({ text, color, _kind: kind })
247
255
  if (state.lines.length > 5000) {
248
256
  state.lines.splice(0, 1000)
249
257
  state.lines.unshift({ text: `... [earlier messages trimmed — ${state.lines.length} lines remaining]`, color: C.dim })
@@ -271,13 +279,17 @@ export async function startTUI(agent, opts = {}) {
271
279
  const end = full.length - loaded
272
280
  if (start >= end) return
273
281
 
274
- const cols = process.stdout.columns || 80
275
- const before = countConvLines(state, cols)
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))
276
285
 
277
286
  // Drop the old placeholder, prepend the older page, re-add the placeholder
278
287
  // (with an updated count) only if more remain.
279
288
  if (state.lines[0]?.text?.startsWith("… ")) state.lines.shift()
280
- state.lines.unshift(...historyToLines(full, start, end))
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)
281
293
  state._historyLoaded += end - start
282
294
  state._hasOlder = start > 0
283
295
  if (state._hasOlder) {
@@ -286,7 +298,7 @@ export async function startTUI(agent, opts = {}) {
286
298
 
287
299
  // Scroll compensation: prepending N display rows must move scroll by N to
288
300
  // keep the previously-visible bottom-anchored content in place.
289
- const after = countConvLines(state, cols)
301
+ const after = countConvLines(state, cols, (state.dims?.get() ?? {}).rows ?? (process.stdout.rows || 24))
290
302
  state.scroll += Math.max(0, after - before)
291
303
  render()
292
304
  }
@@ -308,8 +320,13 @@ export async function startTUI(agent, opts = {}) {
308
320
  pushLine)
309
321
  const { render, scheduleRender } = renderLoop
310
322
 
323
+ // Resize events are genuine dimension changes on every terminal (2026-08-31
324
+ // simplification: the earlier settle-timer/double-confirm machinery was built
325
+ // on the misdiagnosed ConPTY-stale hypothesis and even stalled drag-shrink).
326
+ // Any sane sample — larger or smaller — is accepted immediately; the cache
327
+ // self-corrects on the next real resize.
311
328
  process.stdout.on("resize", () => {
312
- try { render() } catch { /* resize error — ignore */ }
329
+ try { state.dims.refresh(); render() } catch { /* resize error — ignore */ }
313
330
  })
314
331
 
315
332
  // ---------------------------------------------------------- Submit
@@ -319,9 +336,8 @@ export async function startTUI(agent, opts = {}) {
319
336
  if (!text) return
320
337
  state.input = []
321
338
  state.cursor = 0
322
- state.history.push(text)
323
339
  const wasInHistory = state.historyIndex !== -1
324
- state.history.push(text)
340
+ state.history.push(text) // review #3 fix: single push (was duplicated — every submit appeared twice in ↑/↓ history)
325
341
  state.historyIndex = -1
326
342
  if (!wasInHistory) state._draft = null // submitted — the draft is now history. Keep draft when submitting from history mode (↓ can recover)
327
343
  state.scroll = 0
@@ -371,7 +387,7 @@ export async function startTUI(agent, opts = {}) {
371
387
  // Agent loop: implemented in agent-turn.mjs
372
388
  const turnCtx = {
373
389
  agent, state, pushLine, pushLabel, render, scheduleRender: render, ensureAssistantLabel,
374
- askPermission, askQuestion, handleSlash: null, summarize,
390
+ askPermission, askQuestion, handleSlash: null,
375
391
  get assistantLabeled() { return assistantLabeled },
376
392
  set assistantLabeled(v) { assistantLabeled = v },
377
393
  }
@@ -36,7 +36,7 @@ export function scrollToMatch(state, lineIndex) {
36
36
  for (let i = 0; i < lineIndex && i < state.lines.length; i++) {
37
37
  estimatedLine += Math.max(1, Math.ceil((state.lines[i].text?.length || 0) / 80))
38
38
  }
39
- const rows = process.stdout.rows || 24
39
+ const rows = (state.dims?.get() ?? {}).rows ?? (process.stdout.rows || 24)
40
40
  const visibleH = Math.max(5, rows - 10) // reserve space for header, input, status, etc.
41
41
  // scroll is number of lines hidden above viewport
42
42
  // We want estimatedLine to be near the bottom of the viewport
@@ -7,10 +7,12 @@ import { QUESTION_CUSTOM } from "./interaction.mjs"
7
7
 
8
8
  /** Current conversation max scroll offset (display lines beyond the visible panel). */
9
9
  function convMaxScroll(state) {
10
- const cols = process.stdout.columns || 80
11
- const rows = process.stdout.rows || 24
10
+ // Single source (Windows ConPTY instability, 2026-08-30) — cached dims.
11
+ const d = state.dims ? state.dims.get() : {}
12
+ const cols = d.cols ?? ((state.dims?.get() ?? {}).cols ?? (process.stdout.columns || 80))
13
+ const rows = d.rows ?? (process.stdout.rows || 24)
12
14
  const layout = computeLayout(state, { cols, rows })
13
- return Math.max(0, countConvLines(state, cols) - layout.panels.conversation.h)
15
+ return Math.max(0, countConvLines(state, cols, rows) - layout.panels.conversation.h)
14
16
  }
15
17
 
16
18
  /** Keyboard event dispatch: permission confirm / question / picker / wizard / edit / scroll / history / paste.
@@ -154,6 +156,7 @@ export function createKeyHandler(ctx) {
154
156
  // 延迟退出可注入(测试传大值并清理定时器,避免定时器在 mock 恢复后调到真 process.exit)
155
157
  ctx.exitTimer = setTimeout(() => process.exit(0), ctx.exitDelay ?? 100)
156
158
  ctx.exitTimer.unref?.()
159
+ return // review #5 fix: exiting — don't fall through to later branches
157
160
  }
158
161
 
159
162
  // F1: 显示快捷键帮助
@@ -218,7 +221,7 @@ export function createKeyHandler(ctx) {
218
221
  const items = p.filteredItems ?? p.entries.filter((e) => e.type === "item")
219
222
  // 可视窗高度:直接取 layout 算出的实际 picker 面板高(含小终端 pickerFinalH 压缩),减标题行。
220
223
  // 单一数据源,避免与 layout.mjs 公式漂移
221
- const winH = Math.max(1, (computeLayout(state, { cols: process.stdout.columns || 80, rows: process.stdout.rows || 24 }).panels.picker?.h ?? p.lines.length + 1) - 1)
224
+ const winH = Math.max(1, (computeLayout(state, { cols: (state.dims?.get() ?? {}).cols ?? (process.stdout.columns || 80), rows: (state.dims?.get() ?? {}).rows ?? ((state.dims?.get() ?? {}).rows ?? (process.stdout.rows || 24)) }).panels.picker?.h ?? p.lines.length + 1) - 1)
222
225
  const applyFilter = (f) => {
223
226
  p.filter = f
224
227
  p.index = 0
@@ -252,6 +255,7 @@ export function createKeyHandler(ctx) {
252
255
  } else if (str && !key.ctrl && !key.meta) {
253
256
  // 输入即过滤;粘贴的多行文本先去换行(与输入框清洗口径一致),仍含控制字符则整段丢弃
254
257
  const text = str.replace(/[\r\n]+/g, "")
258
+ // eslint-disable-next-line no-control-regex -- 有意为之:控制字符协议/转义序列剥离正则(ANSI/⟦ev⟧/SGR/history 双线分隔)
255
259
  if (text && !/[\x00-\x1f\x7f]/.test(text)) applyFilter(p.filter + text)
256
260
  }
257
261
  return
@@ -292,13 +296,13 @@ export function createKeyHandler(ctx) {
292
296
  if (state._hasOlder && loadOlder && state.scroll >= convMaxScroll(state)) {
293
297
  loadOlder()
294
298
  } else {
295
- state.scroll += Math.max(1, (process.stdout.rows || 24) - 8)
299
+ state.scroll += Math.max(1, ((state.dims?.get() ?? {}).rows ?? (process.stdout.rows || 24)) - 8)
296
300
  }
297
301
  render()
298
302
  return
299
303
  }
300
304
  if (key.name === "pagedown") {
301
- state.scroll = Math.max(0, state.scroll - Math.max(1, (process.stdout.rows || 24) - 8))
305
+ state.scroll = Math.max(0, state.scroll - Math.max(1, ((state.dims?.get() ?? {}).rows ?? (process.stdout.rows || 24)) - 8))
302
306
  render()
303
307
  return
304
308
  }
@@ -3,8 +3,9 @@
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 → subagent → output → todo → picker → permission → queue → input → status
7
- * header conversation todo subagent output → picker → permission → queue → input → status
6
+ * header → conversation → todo → picker → permission → queue → input → status
7
+ * Subagent activity renders INSIDE the conversation as collapsible blocks
8
+ * (AGENT-LOOP.md §7.2 D4) — no dedicated subagent/output panels anymore.
8
9
  * Fixed panels deducted first, conditional panels allocated by priority, remaining space to conversation.
9
10
  */
10
11
  import { layoutInput, wrapText } from "./render.mjs"
@@ -19,7 +20,6 @@ function optText(opt) {
19
20
 
20
21
  const MAX_INPUT_LINES = 5
21
22
  const MAX_TASK_LINES = 5
22
- export const MAX_SUB_LINES = 4
23
23
  const QWIN = 5
24
24
 
25
25
  /**
@@ -70,18 +70,14 @@ export function computeLayout(state, { cols, rows }) {
70
70
  const done = state.tasks.filter((t) => t.status === "done")
71
71
  visibleTasks = [...inProgress, ...pending, ...done].slice(0, MAX_TASK_LINES)
72
72
  }
73
- const taskPanelH = visibleTasks.length
73
+ // +1 for the divider line separating the todo panel from the conversation
74
+ // (user request 2026-08-30).
75
+ const taskPanelH = visibleTasks.length > 0 ? visibleTasks.length + 1 : 0
76
+ // Squeeze target: the divider line yields first under small terminals (the
77
+ // task rows themselves never compress away — put() truncates by panel h).
78
+ let todoFinalH = taskPanelH
74
79
 
75
- // Subagent (only visible during processing; height matches what's actually rendered)
76
- const allSubs = state.processing ? Object.values(state.subTasks) : []
77
- const subPanelH = allSubs.length > 0
78
- ? Math.min(allSubs.length, MAX_SUB_LINES) + (allSubs.length > MAX_SUB_LINES ? 1 : 0)
79
- : 0
80
-
81
- // Tool output panels: max 9 lines per panel (1 title + 8 content), capped at reasonable total.
82
- // Done panels stay visible until their closeAt grace elapses (render loop prunes them).
83
- const panels = Object.values(state.outputPanels ?? {}).filter((p) => !p.done || (p.closeAt ?? 0) > Date.now())
84
- const outputPanelsH = panels.length > 0 ? Math.min(panels.length * 9, rows - 10) : 0
80
+ // Subagent activity: rendered inside the conversation (§7.2 D4) no panel slot.
85
81
 
86
82
  // Permission preview (height depends on wrapped content)
87
83
  let permPreviewLines = []
@@ -101,7 +97,7 @@ export function computeLayout(state, { cols, rows }) {
101
97
  const queueH = state.queue.length > 0 && state.processing ? 1 : 0
102
98
 
103
99
  // --- elastic panel: conversation takes remaining space ---
104
- const fixedH = headerH + inputBoxH + statusH + pickerH + taskPanelH + subPanelH + outputPanelsH + permPreviewH + queueH
100
+ const fixedH = headerH + inputBoxH + statusH + pickerH + taskPanelH + permPreviewH + queueH
105
101
  let convH = Math.max(1, rows - fixedH)
106
102
 
107
103
  // 小终端高度补偿:先压 conversation 到最小 1 行,再压 picker 到最小 3 行,
@@ -120,15 +116,21 @@ export function computeLayout(state, { cols, rows }) {
120
116
  permFinalH = Math.max(1, permPreviewH - remaining)
121
117
  convH = Math.max(1, rows - (afterPicker - permPreviewH + permFinalH))
122
118
  }
119
+ // 压缩链末级:todo 面板的分隔线行让位(任务行保留——put 按 h 截断自动
120
+ // 丢弃第一行的分隔线,2026-08-30 用户请求加的 divider 不得在小终端挤掉输入框)。
121
+ const afterPerm = afterPicker - permPreviewH + permFinalH
122
+ const finalOverflow = afterPerm + convH - rows
123
+ if (finalOverflow > 0 && taskPanelH > visibleTasks.length) {
124
+ todoFinalH = Math.max(visibleTasks.length, taskPanelH - finalOverflow)
125
+ convH = Math.max(1, rows - (afterPerm - taskPanelH + todoFinalH))
126
+ }
123
127
  }
124
128
 
125
129
  // --- Y coordinates (0-indexed, +1 when used with ANSI) ---
126
130
  let y = 0
127
131
  const header = { y, h: headerH }; y += headerH
128
132
  const conversation = { y, h: convH }; y += convH
129
- const subagent = subPanelH > 0 ? { y, h: subPanelH } : null; y += subPanelH
130
- const output = outputPanelsH > 0 ? { y, h: outputPanelsH } : null; y += outputPanelsH
131
- const todo = taskPanelH > 0 ? { y, h: taskPanelH } : null; y += taskPanelH
133
+ const todo = todoFinalH > 0 ? { y, h: todoFinalH } : null; y += todoFinalH
132
134
  const picker = pickerFinalH > 0 ? { y, h: pickerFinalH } : null; y += pickerFinalH
133
135
  const permission = permFinalH > 0 ? { y, h: permFinalH } : null; y += permFinalH
134
136
  const queue = queueH > 0 ? { y, h: queueH } : null; y += queueH
@@ -137,13 +139,12 @@ export function computeLayout(state, { cols, rows }) {
137
139
 
138
140
  return {
139
141
  W, cols, rows,
140
- panels: { header, conversation, picker, todo, subagent, output, permission, queue, inputBox, status },
142
+ panels: { header, conversation, picker, todo, permission, queue, inputBox, status },
141
143
  // precomputed content (affects height, reused during render)
142
144
  inputLayout,
143
145
  inputOffset,
144
146
  boxLines,
145
147
  visibleTasks,
146
- allSubs,
147
148
  permPreviewLines,
148
149
  overlay,
149
150
  }
package/src/tui/mouse.mjs CHANGED
@@ -16,10 +16,12 @@
16
16
  */
17
17
  import { computeLayout } from "./layout.mjs"
18
18
  import { buildConvLines } from "./render-conversation.mjs"
19
+ import { toggleFoldBlock } from "./fold-block.mjs"
19
20
 
20
21
  /** Extract left-click presses from a chunk. Returns [{ col, row }] (1-based). */
21
22
  export function parseMouseClicks(text) {
22
23
  const out = []
24
+ // eslint-disable-next-line no-control-regex -- 有意为之:控制字符协议/转义序列剥离正则(ANSI/⟦ev⟧/SGR/history 双线分隔)
23
25
  for (const m of text.matchAll(/\x1b\[<0;(\d+);(\d+)M/g)) {
24
26
  out.push({ col: Number(m[1]), row: Number(m[2]) })
25
27
  }
@@ -48,7 +50,9 @@ export function handleMouseClick(ctx, col, row) {
48
50
  const { state, render } = ctx
49
51
  const r = row - 1 // 0-based screen row
50
52
  if (r < 0) return false
51
- const dims = { cols: process.stdout.columns || 80, rows: process.stdout.rows || 24 }
53
+ // Single source (Windows ConPTY instability, 2026-08-30): the cached dims,
54
+ // never a live read that can flip between stale and fresh values.
55
+ const dims = state.dims ? state.dims.get() : { cols: process.stdout.columns || 80, rows: process.stdout.rows || 24 }
52
56
  const layout = computeLayout(state, dims)
53
57
  const P = layout.panels
54
58
 
@@ -68,15 +72,14 @@ export function handleMouseClick(ctx, col, row) {
68
72
 
69
73
  // ── Conversation: click a fold marker (expand hint or collapse marker) toggles it ──
70
74
  if (r >= P.conversation.y && r < P.conversation.y + P.conversation.h) {
71
- const convLines = buildConvLines(state, dims.cols)
75
+ const convLines = buildConvLines(state, dims.cols, dims.rows)
72
76
  const gIdx = convGlobalIndex(convLines.length, P.conversation.h, state.scroll ?? 0)(r - P.conversation.y)
73
77
  if (gIdx === null) return false
74
78
  const lineEl = convLines[gIdx]
75
79
  if (!lineEl?._foldToggle) return false
76
- state.expandedBlocks ??= new Set()
77
- // Bidirectional: click expands a folded block, collapses an expanded one
78
- if (state.expandedBlocks.has(lineEl._foldToggle)) state.expandedBlocks.delete(lineEl._foldToggle)
79
- else state.expandedBlocks.add(lineEl._foldToggle)
80
+ // Bidirectional toggle — single source in fold-block.mjs (expand a folded
81
+ // block, collapse an expanded one).
82
+ toggleFoldBlock(state, lineEl._foldToggle)
80
83
  render()
81
84
  return true
82
85
  }
@@ -77,7 +77,7 @@ export function createPickers(ctx) {
77
77
  // Fallback to a reasonable default when computeLayout can't run (e.g. test mocks without full state)
78
78
  let winH
79
79
  try {
80
- winH = Math.max(1, (computeLayout(state, { cols: process.stdout.columns || 80, rows: process.stdout.rows || 24 }).panels.picker?.h ?? lines.length + 1) - 1)
80
+ winH = Math.max(1, (computeLayout(state, { cols: (state.dims?.get() ?? {}).cols ?? (process.stdout.columns || 80), rows: (state.dims?.get() ?? {}).rows ?? (process.stdout.rows || 24) }).panels.picker?.h ?? lines.length + 1) - 1)
81
81
  } catch {
82
82
  winH = 8 // safe fallback for test mocks
83
83
  }