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.
- package/CHANGELOG.md +45 -2
- package/package.json +4 -3
- package/src/acp/bridge.mjs +4 -0
- package/src/agent/dispatch.mjs +19 -7
- package/src/agent/helpers.mjs +12 -0
- package/src/agent/record-results.mjs +130 -0
- package/src/agent/setup.mjs +5 -8
- package/src/agent/spawn-child.mjs +159 -0
- package/src/agent-tools/consult.mjs +95 -73
- package/src/agent-tools/escalate.mjs +53 -62
- package/src/agent-tools/subagent.mjs +39 -38
- package/src/agent.mjs +25 -109
- package/src/generate-title.mjs +30 -1
- package/src/prompts/advisor-round1.md +5 -6
- package/src/prompts/advisor-round2.md +3 -4
- package/src/prompts/advisor-round3.md +3 -4
- package/src/prompts/eng-coder.md +10 -0
- package/src/prompts/engineering.md +81 -14
- package/src/prompts/methodology-template.md +8 -3
- package/src/prompts/system.md +1 -1
- package/src/session.mjs +48 -1
- package/src/tools/system.mjs +3 -1
- package/src/tui/agent-turn.mjs +44 -363
- package/src/tui/cmd-advisor.mjs +20 -2
- package/src/tui/cmd-eng.mjs +44 -7
- package/src/tui/dims.mjs +74 -0
- package/src/tui/fold-block.mjs +208 -0
- package/src/tui/index.mjs +53 -16
- package/src/tui/key-handler-search.mjs +1 -1
- package/src/tui/key-handler.mjs +9 -6
- package/src/tui/layout.mjs +21 -20
- package/src/tui/mouse.mjs +8 -6
- package/src/tui/pickers.mjs +1 -1
- package/src/tui/render-conversation.mjs +368 -113
- package/src/tui/render-frame.mjs +9 -88
- package/src/tui/render-loop.mjs +12 -8
- package/src/tui/render.mjs +5 -0
- package/src/tui/startup.mjs +67 -13
- package/src/tui/subagent-blocks.mjs +326 -0
- package/src/tui/tool-args.mjs +67 -0
- 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
|
-
|
|
86
|
-
subTasks: {}, // sub-agent
|
|
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
|
|
275
|
-
const
|
|
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.
|
|
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
|
package/src/tui/key-handler.mjs
CHANGED
|
@@ -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
|
-
|
|
11
|
-
const
|
|
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
|
}
|
package/src/tui/layout.mjs
CHANGED
|
@@ -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 →
|
|
7
|
-
*
|
|
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
|
-
|
|
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
|
|
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 +
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
77
|
-
//
|
|
78
|
-
|
|
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
|
}
|
package/src/tui/pickers.mjs
CHANGED
|
@@ -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
|
}
|