thincoder 0.12.49 → 0.12.51

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 (41) hide show
  1. package/CHANGELOG.md +45 -2
  2. package/package.json +4 -3
  3. package/src/acp/bridge.mjs +4 -0
  4. package/src/agent/dispatch.mjs +19 -7
  5. package/src/agent/helpers.mjs +12 -0
  6. package/src/agent/record-results.mjs +130 -0
  7. package/src/agent/setup.mjs +5 -8
  8. package/src/agent/spawn-child.mjs +159 -0
  9. package/src/agent-tools/consult.mjs +95 -73
  10. package/src/agent-tools/escalate.mjs +53 -62
  11. package/src/agent-tools/subagent.mjs +39 -38
  12. package/src/agent.mjs +25 -109
  13. package/src/generate-title.mjs +30 -1
  14. package/src/prompts/advisor-round1.md +5 -6
  15. package/src/prompts/advisor-round2.md +3 -4
  16. package/src/prompts/advisor-round3.md +3 -4
  17. package/src/prompts/eng-coder.md +10 -0
  18. package/src/prompts/engineering.md +81 -14
  19. package/src/prompts/methodology-template.md +8 -3
  20. package/src/prompts/system.md +1 -1
  21. package/src/session.mjs +48 -1
  22. package/src/tools/system.mjs +3 -1
  23. package/src/tui/agent-turn.mjs +44 -363
  24. package/src/tui/cmd-advisor.mjs +20 -2
  25. package/src/tui/cmd-eng.mjs +44 -7
  26. package/src/tui/dims.mjs +74 -0
  27. package/src/tui/fold-block.mjs +208 -0
  28. package/src/tui/index.mjs +53 -16
  29. package/src/tui/key-handler-search.mjs +1 -1
  30. package/src/tui/key-handler.mjs +9 -6
  31. package/src/tui/layout.mjs +21 -20
  32. package/src/tui/mouse.mjs +8 -6
  33. package/src/tui/pickers.mjs +1 -1
  34. package/src/tui/render-conversation.mjs +368 -113
  35. package/src/tui/render-frame.mjs +9 -88
  36. package/src/tui/render-loop.mjs +12 -8
  37. package/src/tui/render.mjs +5 -0
  38. package/src/tui/startup.mjs +67 -13
  39. package/src/tui/subagent-blocks.mjs +326 -0
  40. package/src/tui/tool-args.mjs +67 -0
  41. package/src/tui/tool-events.mjs +461 -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):
@@ -242,8 +247,8 @@ export async function startTUI(agent, opts = {}) {
242
247
  }
243
248
  process.on("exit", cleanup)
244
249
 
245
- const pushLine = (text, color) => {
246
- state.lines.push({ text, color })
250
+ const pushLine = (text, color, kind) => {
251
+ state.lines.push({ text, color, _kind: kind })
247
252
  if (state.lines.length > 5000) {
248
253
  state.lines.splice(0, 1000)
249
254
  state.lines.unshift({ text: `... [earlier messages trimmed — ${state.lines.length} lines remaining]`, color: C.dim })
@@ -271,13 +276,17 @@ export async function startTUI(agent, opts = {}) {
271
276
  const end = full.length - loaded
272
277
  if (start >= end) return
273
278
 
274
- const cols = process.stdout.columns || 80
275
- const before = countConvLines(state, cols)
279
+ const d = state.dims ? state.dims.get() : {}
280
+ const cols = d.cols ?? ((state.dims?.get() ?? {}).cols ?? (process.stdout.columns || 80))
281
+ const before = countConvLines(state, cols, d.rows ?? (process.stdout.rows || 24))
276
282
 
277
283
  // Drop the old placeholder, prepend the older page, re-add the placeholder
278
284
  // (with an updated count) only if more remain.
279
285
  if (state.lines[0]?.text?.startsWith("… ")) state.lines.shift()
280
- state.lines.unshift(...historyToLines(full, start, end))
286
+ state._lineIdCounter = state._lineIdCounter ?? 0
287
+ const older = historyToLines(full, start, end)
288
+ for (const l of older) l._lineId = ++state._lineIdCounter
289
+ state.lines.unshift(...older)
281
290
  state._historyLoaded += end - start
282
291
  state._hasOlder = start > 0
283
292
  if (state._hasOlder) {
@@ -286,7 +295,7 @@ export async function startTUI(agent, opts = {}) {
286
295
 
287
296
  // Scroll compensation: prepending N display rows must move scroll by N to
288
297
  // keep the previously-visible bottom-anchored content in place.
289
- const after = countConvLines(state, cols)
298
+ const after = countConvLines(state, cols, (state.dims?.get() ?? {}).rows ?? (process.stdout.rows || 24))
290
299
  state.scroll += Math.max(0, after - before)
291
300
  render()
292
301
  }
@@ -309,8 +318,37 @@ export async function startTUI(agent, opts = {}) {
309
318
  const { render, scheduleRender } = renderLoop
310
319
 
311
320
  process.stdout.on("resize", () => {
312
- try { render() } catch { /* resize error — ignore */ }
321
+ try { state.dims.refresh(); render() } catch { /* resize error — ignore */ }
313
322
  })
323
+ // Startup convergence (2026-08-30 consult, residual fix): ConPTY can report
324
+ // a stale-small 80 at launch AND keep reporting it for a while — sawValid is
325
+ // useless as a stop condition there (80 passes the sanity gate), which let
326
+ // the session lock at 80 until the first turn's finally re-sampled. Run the
327
+ // FULL window (~4.5s): any growth is accepted immediately by the asymmetric
328
+ // rule and repaints; the window just bounds how long we keep looking.
329
+ let startupRetries = 0
330
+ const startupResampler = setInterval(() => {
331
+ try {
332
+ const before = state.dims.get()
333
+ const after = state.dims.refresh()
334
+ if (after.cols !== before.cols || after.rows !== before.rows) render()
335
+ if (++startupRetries >= 30) clearInterval(startupResampler)
336
+ } catch { /* ignore */ }
337
+ }, 150)
338
+ // Idle watchdog (2026-08-30 consult): the ONLY mid-session recovery channel.
339
+ // ConPTY reports stale-small sizes during output activity, so sampling is
340
+ // gated on idleness — skipped while processing/streaming or within 500ms of
341
+ // the last render (heavy output); a confirmed shrink also repaints.
342
+ const idleResampler = setInterval(() => {
343
+ try {
344
+ const recentRender = performance.now() - renderLoop.lastRenderAt < 500
345
+ if (state.processing || state.streaming || state.reasoning || recentRender) return
346
+ const before = state.dims.get()
347
+ const after = state.dims.refresh()
348
+ if (after.cols !== before.cols || after.rows !== before.rows) render()
349
+ } catch { /* ignore */ }
350
+ }, 2000)
351
+ idleResampler.unref?.()
314
352
 
315
353
  // ---------------------------------------------------------- Submit
316
354
 
@@ -319,9 +357,8 @@ export async function startTUI(agent, opts = {}) {
319
357
  if (!text) return
320
358
  state.input = []
321
359
  state.cursor = 0
322
- state.history.push(text)
323
360
  const wasInHistory = state.historyIndex !== -1
324
- state.history.push(text)
361
+ state.history.push(text) // review #3 fix: single push (was duplicated — every submit appeared twice in ↑/↓ history)
325
362
  state.historyIndex = -1
326
363
  if (!wasInHistory) state._draft = null // submitted — the draft is now history. Keep draft when submitting from history mode (↓ can recover)
327
364
  state.scroll = 0
@@ -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
@@ -292,13 +295,13 @@ export function createKeyHandler(ctx) {
292
295
  if (state._hasOlder && loadOlder && state.scroll >= convMaxScroll(state)) {
293
296
  loadOlder()
294
297
  } else {
295
- state.scroll += Math.max(1, (process.stdout.rows || 24) - 8)
298
+ state.scroll += Math.max(1, ((state.dims?.get() ?? {}).rows ?? (process.stdout.rows || 24)) - 8)
296
299
  }
297
300
  render()
298
301
  return
299
302
  }
300
303
  if (key.name === "pagedown") {
301
- state.scroll = Math.max(0, state.scroll - Math.max(1, (process.stdout.rows || 24) - 8))
304
+ state.scroll = Math.max(0, state.scroll - Math.max(1, ((state.dims?.get() ?? {}).rows ?? (process.stdout.rows || 24)) - 8))
302
305
  render()
303
306
  return
304
307
  }
@@ -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,6 +16,7 @@
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) {
@@ -48,7 +49,9 @@ export function handleMouseClick(ctx, col, row) {
48
49
  const { state, render } = ctx
49
50
  const r = row - 1 // 0-based screen row
50
51
  if (r < 0) return false
51
- const dims = { cols: process.stdout.columns || 80, rows: process.stdout.rows || 24 }
52
+ // Single source (Windows ConPTY instability, 2026-08-30): the cached dims,
53
+ // never a live read that can flip between stale and fresh values.
54
+ const dims = state.dims ? state.dims.get() : { cols: process.stdout.columns || 80, rows: process.stdout.rows || 24 }
52
55
  const layout = computeLayout(state, dims)
53
56
  const P = layout.panels
54
57
 
@@ -68,15 +71,14 @@ export function handleMouseClick(ctx, col, row) {
68
71
 
69
72
  // ── Conversation: click a fold marker (expand hint or collapse marker) toggles it ──
70
73
  if (r >= P.conversation.y && r < P.conversation.y + P.conversation.h) {
71
- const convLines = buildConvLines(state, dims.cols)
74
+ const convLines = buildConvLines(state, dims.cols, dims.rows)
72
75
  const gIdx = convGlobalIndex(convLines.length, P.conversation.h, state.scroll ?? 0)(r - P.conversation.y)
73
76
  if (gIdx === null) return false
74
77
  const lineEl = convLines[gIdx]
75
78
  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)
79
+ // Bidirectional toggle — single source in fold-block.mjs (expand a folded
80
+ // block, collapse an expanded one).
81
+ toggleFoldBlock(state, lineEl._foldToggle)
80
82
  render()
81
83
  return true
82
84
  }
@@ -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
  }