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.
- package/CHANGELOG.md +64 -3
- package/README.md +2 -2
- package/package.json +4 -3
- package/src/acp/bridge.mjs +5 -0
- package/src/agent/dispatch.mjs +19 -7
- package/src/agent/helpers.mjs +13 -1
- package/src/agent/record-results.mjs +130 -0
- package/src/agent/setup.mjs +4 -7
- package/src/agent/spawn-child.mjs +159 -0
- package/src/agent-tools/consult.mjs +94 -73
- package/src/agent-tools/escalate.mjs +53 -62
- package/src/agent-tools/skill.mjs +1 -1
- package/src/agent-tools/subagent.mjs +39 -38
- package/src/agent-tools/task.mjs +0 -2
- package/src/agent-tools/verify.mjs +0 -1
- package/src/agent.mjs +27 -112
- package/src/config.mjs +8 -103
- package/src/generate-title.mjs +30 -1
- package/src/model-specs.mjs +108 -0
- 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 +9 -0
- package/src/prompts/engineering.md +61 -9
- package/src/prompts/system.md +2 -2
- package/src/provider/core.mjs +5 -71
- package/src/provider/normalize.mjs +81 -0
- package/src/session.mjs +40 -1
- package/src/tools/git.mjs +3 -3
- package/src/tools/shared.mjs +1 -0
- package/src/tools/system.mjs +3 -1
- package/src/tui/agent-turn.mjs +37 -364
- package/src/tui/clipboard.mjs +3 -1
- package/src/tui/dims.mjs +47 -0
- package/src/tui/fold-block.mjs +208 -0
- package/src/tui/index.mjs +33 -17
- package/src/tui/key-handler-search.mjs +1 -1
- package/src/tui/key-handler.mjs +10 -6
- package/src/tui/layout.mjs +21 -20
- package/src/tui/mouse.mjs +9 -6
- package/src/tui/pickers.mjs +1 -1
- package/src/tui/render-conversation.mjs +367 -113
- package/src/tui/render-frame.mjs +16 -90
- package/src/tui/render-loop.mjs +12 -8
- package/src/tui/render.mjs +16 -0
- package/src/tui/startup.mjs +66 -13
- package/src/tui/subagent-blocks.mjs +327 -0
- package/src/tui/tool-args.mjs +67 -0
- package/src/tui/tool-events.mjs +459 -0
|
@@ -1,70 +1,86 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* render-conversation.mjs — conversation panel line builder
|
|
3
3
|
* Extracted from render-frame.mjs.
|
|
4
|
+
*
|
|
5
|
+
* 2026-08-30: all six fold sites (frozen/running subagent blocks, frozen/live
|
|
6
|
+
* advisor, long-message, consecutive-dim) delegate their EXPANDED-state
|
|
7
|
+
* rendering to the shared fold-block.mjs component, which caps expansion at
|
|
8
|
+
* 60% of the terminal height so the collapse control always stays reachable
|
|
9
|
+
* (user report: an expanded block could push its own control off-screen).
|
|
10
|
+
* maxRows flows in from every caller; omitted/0 → uncapped (tests, odd envs).
|
|
4
11
|
*/
|
|
5
12
|
import { ansi, C } from "./ansi.mjs"
|
|
6
|
-
import { formatTables, sanitizeDisplay,
|
|
7
|
-
import {
|
|
8
|
-
|
|
13
|
+
import { formatTables, sanitizeDisplay, sliceByWidth, wrapText } from "./render.mjs"
|
|
14
|
+
import {
|
|
15
|
+
isExpanded, foldHintLine, renderExpandedBlock, renderBlockTimeline,
|
|
16
|
+
renderMathAndMarkdown, foldCapRows, renderFoldedHead, foldTailLines,
|
|
17
|
+
} from "./fold-block.mjs"
|
|
18
|
+
import { ADVISOR_THINKING_PLACEHOLDER } from "../advisor/run.mjs"
|
|
9
19
|
|
|
10
|
-
let _convCache = { key: "", cols: 0, lines: [] }
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Render markdown markers to ANSI, then pad the line tail back to the pre-render
|
|
14
|
-
* display width. Markers (`` ` ``, `**`, `~~`) vanish on render — without the
|
|
15
|
-
* compensation, table rows containing them display shorter than the column widths
|
|
16
|
-
* computed by formatTables and the borders misalign (reported regression).
|
|
17
|
-
* @param {string} text — plain text line (no ANSI yet), already wrapped
|
|
18
|
-
* @returns {string} ANSI-rendered line whose display width equals stringWidth(text)
|
|
19
|
-
*/
|
|
20
|
-
function renderMarkdownPreservingWidth(text) {
|
|
21
|
-
// Line-by-line: render + compensate per line. The per-line padding serves
|
|
22
|
-
// NON-table text (so `**bold** text` next to plain text keeps its width).
|
|
23
|
-
// Table alignment is NOT provided by the padding — formatTables strips cell
|
|
24
|
-
// padding during trim and recomputes widths from the RENDERED text (that is
|
|
25
|
-
// the render-before-measure contract).
|
|
26
|
-
return text.split("\n").map((line) => {
|
|
27
|
-
const rendered = renderMarkdownInline(renderMarkdownHeading(line))
|
|
28
|
-
const diff = stringWidth(line) - stringWidth(rendered)
|
|
29
|
-
return diff > 0 ? rendered + " ".repeat(diff) : rendered
|
|
30
|
-
}).join("\n")
|
|
31
|
-
}
|
|
32
20
|
|
|
33
|
-
//
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
function renderMathAndMarkdown(text) {
|
|
37
|
-
return renderMarkdownPreservingWidth(renderMathInline(renderMathBlock(text)))
|
|
38
|
-
}
|
|
39
|
-
// Test seam (mirrors the _-prefixed seams in run.mjs).
|
|
40
|
-
export { renderMarkdownPreservingWidth as _renderMarkdownPreservingWidth }
|
|
21
|
+
// Test seam (mirrors the _-prefixed seams in run.mjs) — moved to fold-block.mjs.
|
|
22
|
+
import { renderMarkdownPreservingWidth as _rmpw } from "./fold-block.mjs"
|
|
23
|
+
export { _rmpw as _renderMarkdownPreservingWidth }
|
|
41
24
|
|
|
25
|
+
let _convCache = { key: "", cols: 0, lines: [] }
|
|
42
26
|
|
|
43
|
-
export function convCacheKey(state) {
|
|
27
|
+
export function convCacheKey(state, maxRows) {
|
|
44
28
|
const lastLine = state.lines.length > 0 ? state.lines[state.lines.length - 1] : null
|
|
45
29
|
// expandedBlocks participates: expanding/folding a block must invalidate the cache
|
|
46
30
|
const exp = state.expandedBlocks ? [...state.expandedBlocks].sort().join(",") : ""
|
|
47
31
|
// Content prefix in the signature: same kind+length with different content
|
|
48
32
|
// would otherwise collide (stale render); 8 chars disambiguate in practice.
|
|
49
33
|
const blocksSig = (state._advisorBlocks ?? []).map((b) => `${b.kind}:${b.text?.length ?? 0}:${String(b.text ?? "").slice(0, 8)}`).join(",")
|
|
50
|
-
|
|
34
|
+
// Subagent activity blocks (§7.2 D5): O(1) counter-style signature — a running
|
|
35
|
+
// epoch (any block/header change bumps it, subagent-blocks.mjs appendSubBlock /
|
|
36
|
+
// finishSubTask) + per-child totals. Running children also fold in a
|
|
37
|
+
// second-granularity elapsed part (see below). NOT a full text concat: N3
|
|
38
|
+
// forbids O(n) signature cost on every token.
|
|
39
|
+
let subSig = ""
|
|
40
|
+
for (const key in state.subTasks) {
|
|
41
|
+
const s = state.subTasks[key]
|
|
42
|
+
// Running children include a second-granularity elapsed component: the 1s
|
|
43
|
+
// ticker re-renders to tick the header countdown — without this the cache
|
|
44
|
+
// would hit and the "45s" display would freeze during silent stretches
|
|
45
|
+
// (long child tool runs with no chunks). Done children are constant — no
|
|
46
|
+
// per-second invalidation for them.
|
|
47
|
+
const elapsedPart = s.done ? "" : `:${Math.floor((Date.now() - s.started) / 1000)}`
|
|
48
|
+
subSig += `${key}:${s.done ? 1 : 0}${elapsedPart}:${s.turn}/${s.maxTurns}:${s.currentTool ?? ""}:${s.approval ?? ""}:${s.blockEpoch ?? 0}:${s.model ?? ""};`
|
|
49
|
+
}
|
|
50
|
+
// Frozen blocks ride state.lines ({_frozenSubTask}) — the lines.length part of
|
|
51
|
+
// this key covers their existence; expanding/collapsing one flips expandedBlocks
|
|
52
|
+
// (covered by `exp`). One extra: the last frozen payload's header depends on
|
|
53
|
+
// blocks content which never changes post-freeze — nothing more needed.
|
|
54
|
+
// Same single pass also builds the per-line COLOR-CLASS signature: foldability
|
|
55
|
+
// is decided by color class since main output (C.text) never folds while
|
|
56
|
+
// thinking/dim do (2026-08-30) — two states differing only in line color used
|
|
57
|
+
// to collide on this key and serve a stale cached render.
|
|
58
|
+
// Tool-block carriers ({_toolBlock}) contribute their BUFFER SIZE signature:
|
|
59
|
+
// output/result arrays mutate in place (streaming appends, result landing),
|
|
60
|
+
// and carrier text is always "" — without this the cache serves a stale block
|
|
61
|
+
// while a tool runs (and across live→restore with equal line counts).
|
|
62
|
+
let frozenSig = ""
|
|
63
|
+
let colorSig = ""
|
|
64
|
+
let toolSig = ""
|
|
65
|
+
for (const l of state.lines) {
|
|
66
|
+
if (l._frozenSubTask) frozenSig += `${l._frozenSubTask.key};`
|
|
67
|
+
if (l._toolBlock) toolSig += `${l._toolBlock.done ? 1 : 0}:${l._toolBlock.output.length}:${l._toolBlock.result ? l._toolBlock.result.length : 0}:${l._toolBlock.summary ?? ""}:${l._toolBlock.elapsed ?? ""};`
|
|
68
|
+
colorSig += l.color === C.text ? "T" : l.color === C.dim ? "D" : l.color === C.reason ? "R" : "o"
|
|
69
|
+
}
|
|
70
|
+
// Expansion cap participates: a terminal resize changes the cap, which changes
|
|
71
|
+
// the rendered height of every expanded block — the cache must not survive it.
|
|
72
|
+
const capPart = maxRows ? `cap${foldCapRows(maxRows)}` : "cap∞"
|
|
73
|
+
// Search state participates: highlightSearchMatches re-renders the matching
|
|
74
|
+
// lines, but performSearch only mutates state.search/_searchMatches — without
|
|
75
|
+
// this the cache would serve the pre-search rows and highlight would never
|
|
76
|
+
// appear (P0-1, 2026-08-30 consult). query+index covers match navigation.
|
|
77
|
+
const searchPart = state.search?.query ? `${state.search.query}:${state.search.index ?? 0}` : ""
|
|
78
|
+
return `${state.lines.length}|${lastLine?.text.length ?? 0}|${state.streaming.length}|${state.reasoning.length}|${blocksSig}|${subSig}|${frozenSig}|${toolSig}|${colorSig}|${state.foldEnabled !== false ? "f" : "u"}|${exp}|${capPart}|${searchPart}`
|
|
51
79
|
}
|
|
52
80
|
|
|
53
81
|
/** Fold marker line: bold-cyan icon + "click to …" phrase underlined (clickable affordance).
|
|
54
82
|
* No indent — flush with the content below it; the caller adds a blank line BEFORE it
|
|
55
83
|
* so the control line stands apart from unrelated content (reported UX). */
|
|
56
|
-
function foldHintLine(text, foldKey, srcIdx) {
|
|
57
|
-
// Underline just the actionable phrase — link/button convention
|
|
58
|
-
const withUnderline = text.replace(/(click to (?:expand|collapse))/, "\x1b[4m$1\x1b[24m")
|
|
59
|
-
return { text: withUnderline, color: C.fold, _foldToggle: foldKey, _src: srcIdx }
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/** Blank separator before a fold control line (uncolored — must not join consecutive-dim folding).
|
|
63
|
-
* Only the EXPANDED state uses it (▼ sits at the block head); the folded state's ▶
|
|
64
|
-
* control line sits mid-block where the ellipsis used to be, so no separator needed. */
|
|
65
|
-
function blankLine() {
|
|
66
|
-
return { text: "", color: "" }
|
|
67
|
-
}
|
|
68
84
|
|
|
69
85
|
function highlightSearchMatches(text, query, matchesInLine, globalCurrentIndex, allMatches, lineIndex) {
|
|
70
86
|
if (!matchesInLine || matchesInLine.length === 0 || !query) return text
|
|
@@ -90,24 +106,157 @@ function highlightSearchMatches(text, query, matchesInLine, globalCurrentIndex,
|
|
|
90
106
|
return result
|
|
91
107
|
}
|
|
92
108
|
|
|
109
|
+
/** Render a FROZEN child activity block carried on a state.lines entry
|
|
110
|
+
* (subagent-blocks.mjs freezeSubTaskLines pushes {_frozenSubTask: sub}). Identical
|
|
111
|
+
* interaction to the running tail section: folded = `[✓ coder#1 · glm-5.3 ·
|
|
112
|
+
* done 45s · turn 12/100] … click to expand` header + tail 3 block lines;
|
|
113
|
+
* expanded = blank + ▼ control + full timeline (60% screen cap via the shared
|
|
114
|
+
* component — capped view ends in a reachable collapse control). Toggle key
|
|
115
|
+
* `sub-${key}` — the SAME key the live section uses, so fold state carries
|
|
116
|
+
* across the freeze boundary seamlessly (user ruled 2026-08-30: frozen stays
|
|
117
|
+
* clickable — full design interaction, not a dim-lines fallback). */
|
|
118
|
+
function frozenSubTaskLines(state, sub, cols, maxRows) {
|
|
119
|
+
const foldKey = `sub-${sub.key}`
|
|
120
|
+
const elapsed = Math.floor(((sub.doneAt ?? Date.now()) - sub.started) / 1000)
|
|
121
|
+
const modelPart = sub.model ? ` · ${sub.model}` : ""
|
|
122
|
+
const turnPart = sub.maxTurns > 0 ? ` · turn ${sub.turn}/${sub.maxTurns}` : ""
|
|
123
|
+
const errPart = sub.lastError ? ` — ${sub.lastError}` : ""
|
|
124
|
+
const icon = sub.approval ? "⏸" : "✓"
|
|
125
|
+
const header = `[${icon} ${sub.key}${modelPart} · done ${elapsed}s${turnPart}${errPart}]`
|
|
126
|
+
const out = []
|
|
127
|
+
if (isExpanded(state, foldKey)) {
|
|
128
|
+
// Expanded: shared component renders blank + ▼ control + full timeline,
|
|
129
|
+
// capped at 60% of the screen with a bottom collapse control.
|
|
130
|
+
const body = renderBlockTimeline(sub.blocks, cols)
|
|
131
|
+
out.push(...renderExpandedBlock({ body, foldKey, state, maxRows, cols, label: "subagent activity" }))
|
|
132
|
+
} else {
|
|
133
|
+
// Folded: the header line itself is the control (▶ affordance), then tail 3.
|
|
134
|
+
out.push({
|
|
135
|
+
text: `▶ ${header} … subagent activity — click to expand`,
|
|
136
|
+
color: C.dim,
|
|
137
|
+
_foldToggle: foldKey,
|
|
138
|
+
})
|
|
139
|
+
for (const line of foldTailLines(sub.blocks)) {
|
|
140
|
+
out.push({ text: `│ ${sliceByWidth(line, cols - 4)}`, color: C.dim, _skipDimFold: true })
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return out
|
|
144
|
+
}
|
|
145
|
+
|
|
93
146
|
/**
|
|
94
147
|
* Build the conversation lines for the given state.
|
|
148
|
+
* maxRows: terminal rows for the 60% expansion cap (undefined → uncapped —
|
|
149
|
+
* unit tests and callers without a terminal rely on that).
|
|
95
150
|
* NOTE: module-level _convCache is read/written as a side effect (keyed by
|
|
96
151
|
* convCacheKey + cols) — the function is pure w.r.t. its input except for
|
|
97
152
|
* that cache; direct callers outside renderConversation/countConvLines
|
|
98
153
|
* should be aware the cache persists across calls.
|
|
99
154
|
*/
|
|
100
|
-
function buildConvLines(state, cols) {
|
|
101
|
-
const key = convCacheKey(state)
|
|
155
|
+
function buildConvLines(state, cols, maxRows) {
|
|
156
|
+
const key = convCacheKey(state, maxRows)
|
|
102
157
|
if (_convCache.key === key && _convCache.cols === cols) return _convCache.lines
|
|
103
158
|
|
|
104
159
|
const convLines = []
|
|
105
|
-
// Folding constants (function scope
|
|
106
|
-
// and the consecutive-dim fold at the bottom)
|
|
160
|
+
// Folding constants (function scope)
|
|
107
161
|
const LONG_FOLD_LINES = 12
|
|
108
|
-
|
|
162
|
+
let blankAfter = false
|
|
163
|
+
// THINKING ALWAYS FOLDS (user ruling 2026-08-30, final): no threshold of any
|
|
164
|
+
// kind — row thresholds died twice on the user's real screen (12 never met on
|
|
165
|
+
// narrow, 3 never met on wide), a char threshold missed typical sentences.
|
|
166
|
+
// Thinking is process content: it renders as the named "▶ thinking" block,
|
|
167
|
+
// expand ≤60%, click back. No exceptions, streaming included.
|
|
109
168
|
for (let i = 0; i < state.lines.length; i++) {
|
|
110
169
|
const l = state.lines[i]
|
|
170
|
+
// Main-output breathing room (user request 2026-08-30): a blank line before
|
|
171
|
+
// and after each main-output segment (assistant replies / user text — the
|
|
172
|
+
// C.text rows) so the conversation body stands apart from thinking / tool /
|
|
173
|
+
// subagent blocks. Blank lines are RENDER-only (never written to
|
|
174
|
+
// state.lines) — convCacheKey is unaffected, and adjacent segments share
|
|
175
|
+
// one blank line (the trailing blank of segment N and the leading blank of
|
|
176
|
+
// segment N+1 must not stack into a double row).
|
|
177
|
+
const isMain = l._kind === "text" || (l._kind === undefined && l.color === C.text)
|
|
178
|
+
const pushBlank = () => {
|
|
179
|
+
if (convLines.at(-1)?.text !== "") convLines.push({ text: "", color: C.text })
|
|
180
|
+
}
|
|
181
|
+
if (isMain) {
|
|
182
|
+
const prev = i > 0 ? state.lines[i - 1] : null
|
|
183
|
+
const next = state.lines[i + 1]
|
|
184
|
+
const prevMain = prev && (prev._kind === "text" || (prev._kind === undefined && prev.color === C.text))
|
|
185
|
+
const nextMain = next && (next._kind === "text" || (next._kind === undefined && next.color === C.text))
|
|
186
|
+
if (!prevMain) pushBlank()
|
|
187
|
+
blankAfter = !nextMain
|
|
188
|
+
}
|
|
189
|
+
// Frozen subagent activity block (§7.2 D4, 2026-08-30): rendered as its own
|
|
190
|
+
// collapsible section — clickable expand/collapse like the running block.
|
|
191
|
+
if (l._frozenSubTask) {
|
|
192
|
+
convLines.push(...frozenSubTaskLines(state, l._frozenSubTask, cols, maxRows))
|
|
193
|
+
continue
|
|
194
|
+
}
|
|
195
|
+
// ONE BLOCK PER TOOL CALL (2026-08-30 user ruling): header = name+args+
|
|
196
|
+
// live status, body = args JSON + streaming output + result. Folded =
|
|
197
|
+
// ▶ name args · status/summary; expanded = 60%-capped body (shared component).
|
|
198
|
+
if (l._toolBlock) {
|
|
199
|
+
const b = l._toolBlock
|
|
200
|
+
// Stable key from the line's own id (P1 2026-08-30): the line may shift
|
|
201
|
+
// index when loadOlder unshifts older pages — positional tool-${i} would
|
|
202
|
+
// re-bind the expand state to a different tool block.
|
|
203
|
+
const foldKey = `tool-${l._lineId ?? i}`
|
|
204
|
+
const status = !b.done
|
|
205
|
+
? "running"
|
|
206
|
+
: `${b.elapsed !== null ? b.elapsed + "ms" : ""}${b.summary ? (b.elapsed !== null ? " · " : "") + sliceByWidth(b.summary, 50) : ""}`.trim() || "done"
|
|
207
|
+
if (isExpanded(state, foldKey)) {
|
|
208
|
+
const body = []
|
|
209
|
+
const pushWrapped = (raw, color) => {
|
|
210
|
+
for (const w of wrapText(raw, cols - 4)) body.push({ text: " " + w, color, _skipDimFold: true })
|
|
211
|
+
}
|
|
212
|
+
for (const jl of b.argsJson) pushWrapped(jl, C.dim)
|
|
213
|
+
for (const ol of b.output) pushWrapped(ol, C.tool)
|
|
214
|
+
if (b.result) for (const rl of b.result) pushWrapped(rl, C.dim)
|
|
215
|
+
convLines.push(...renderExpandedBlock({ body, foldKey, state, maxRows, cols, label: `${b.name}${b.roundTag || ""} ${b.argsSummary}`.trim() }))
|
|
216
|
+
} else {
|
|
217
|
+
// Head MUST be width-bounded: argsSummary for unknown/MCP tools is a
|
|
218
|
+
// JSON.stringify dump that can be thousands of chars — an overwide header
|
|
219
|
+
// row makes the terminal soft-wrap mid-frame, shifting every panel below
|
|
220
|
+
// (the "code breaks the input box border" report, 2026-08-30).
|
|
221
|
+
const headText = sliceByWidth(
|
|
222
|
+
`❯ ${b.name}${b.roundTag || ""}${b.argsSummary ? " " + b.argsSummary : ""} · ${status}`,
|
|
223
|
+
Math.max(20, cols - 2),
|
|
224
|
+
)
|
|
225
|
+
const body = []
|
|
226
|
+
for (const jl of b.argsJson) for (const w of wrapText(jl, cols - 4)) body.push({ text: w, color: C.dim, _skipDimFold: true })
|
|
227
|
+
for (const ol of b.output.slice(-3)) for (const w of wrapText(ol, cols - 4)) body.push({ text: w, color: C.dim, _skipDimFold: true })
|
|
228
|
+
// Result lines join the tail pool too — restore carrier has no output
|
|
229
|
+
// rows, so without this its folded tail showed only args JSON and the
|
|
230
|
+
// result vanished from the folded view (parity bug, 2026-08-30).
|
|
231
|
+
if (b.result) for (const rl of b.result) for (const w of wrapText(rl, cols - 4)) body.push({ text: w, color: C.dim, _skipDimFold: true })
|
|
232
|
+
convLines.push(...renderFoldedHead({ header: { text: headText, color: C.tool, _foldToggle: foldKey }, body, cols }))
|
|
233
|
+
}
|
|
234
|
+
continue
|
|
235
|
+
}
|
|
236
|
+
// Frozen advisor review (2026-08-30): same collapsible-box treatment —
|
|
237
|
+
// folded = one control line; expanded = the full review text (markdown
|
|
238
|
+
// rendered, no gutter — review history convention kept from the flat era),
|
|
239
|
+
// 60% cap via the shared component.
|
|
240
|
+
if (l._frozenAdvisor) {
|
|
241
|
+
const frozenAdvKey = `advisor-done-${i}`
|
|
242
|
+
if (isExpanded(state, frozenAdvKey)) {
|
|
243
|
+
const body = []
|
|
244
|
+
const rendered = renderMathAndMarkdown(sanitizeDisplay(l._frozenAdvisor))
|
|
245
|
+
for (const line of formatTables(rendered, cols - 1)) {
|
|
246
|
+
for (const wrapped of wrapText(line, cols - 1)) {
|
|
247
|
+
body.push({ text: wrapped, color: C.reason, _skipDimFold: true })
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
convLines.push(...renderExpandedBlock({ body, foldKey: frozenAdvKey, state, maxRows, cols, label: "[advisor · review done]" }))
|
|
251
|
+
} else {
|
|
252
|
+
convLines.push({
|
|
253
|
+
text: `▶ [advisor · review done] … click to expand`,
|
|
254
|
+
color: C.fold,
|
|
255
|
+
_foldToggle: frozenAdvKey,
|
|
256
|
+
})
|
|
257
|
+
}
|
|
258
|
+
continue
|
|
259
|
+
}
|
|
111
260
|
let text = l.text
|
|
112
261
|
|
|
113
262
|
// Apply search highlighting
|
|
@@ -115,16 +264,27 @@ function buildConvLines(state, cols) {
|
|
|
115
264
|
text = highlightSearchMatches(text, state.search.query, l._searchMatches, state.search.index, state.search.matches, i)
|
|
116
265
|
}
|
|
117
266
|
|
|
118
|
-
// Long-message folding
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
// the
|
|
125
|
-
//
|
|
267
|
+
// Long-message folding (2026-08-30 user ruling): MAIN OUTPUT / user messages
|
|
268
|
+
// (C.text) NEVER fold — primary conversation content is read by scrolling,
|
|
269
|
+
// not by expanding; a folded core answer hid the actual result behind a
|
|
270
|
+
// click. Foldable subjects narrow to THINKING (C.reason) and dim tool
|
|
271
|
+
// summaries — the auxiliary streams. (This re-enacts the pre-0.12.7 rule
|
|
272
|
+
// for main output only; the 0.12.7 "revert" had reopened folding for it.)
|
|
273
|
+
// Keyed by the source-line index (`long-${i}`) so the toggle survives
|
|
274
|
+
// re-renders.
|
|
126
275
|
const longKey = `long-${i}`
|
|
127
|
-
|
|
276
|
+
// Single source of truth: the producer stamps _kind ("thinking" / "text" /
|
|
277
|
+
// "tool") — buildConvLines READS the stamp instead of GUESSING from color.
|
|
278
|
+
// Three producers (live flushStream / restored historyToLines / injected
|
|
279
|
+
// lines) now emit the identical grammar; the renderer is one place.
|
|
280
|
+
// Fallback: unstamped lines keep the legacy color-based inference (defensive
|
|
281
|
+
// for any path this refactor missed — empty until proven otherwise).
|
|
282
|
+
const isReasoning = l._kind === "thinking" || (l._kind === undefined && l.color === C.reason)
|
|
283
|
+
// Foldable classes: thinking (ALWAYS — threshold 0) and dim auxiliaries.
|
|
284
|
+
// "text" (main output / user messages) NEVER folds.
|
|
285
|
+
const foldable = isReasoning || (l._kind === "tool" || (l._kind === undefined && l.color === C.dim) || (l._kind === undefined && l.color !== C.text && l.color !== C.reason))
|
|
286
|
+
const threshold = isReasoning ? 0 : LONG_FOLD_LINES
|
|
287
|
+
const folded = foldable && state.foldEnabled !== false && !state.expandedBlocks?.has(longKey)
|
|
128
288
|
const block = []
|
|
129
289
|
// Lightweight markdown display (IK5VW3): render BEFORE measuring — the
|
|
130
290
|
// table column math (formatTables) and wrapping must see the RENDERED
|
|
@@ -138,69 +298,162 @@ function buildConvLines(state, cols) {
|
|
|
138
298
|
block.push({ text: wrapped, color: l.color, _foldId: l._foldId, _src: i })
|
|
139
299
|
}
|
|
140
300
|
}
|
|
141
|
-
if (folded && block.length >
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
|
|
147
|
-
convLines.push(
|
|
148
|
-
|
|
149
|
-
|
|
301
|
+
if (folded && block.length > threshold) {
|
|
302
|
+
// FOLDED — unified form (fold-block.mjs renderFoldedHead, 2026-08-30 user
|
|
303
|
+
// ruling): named identity header + last 3 lines. Replaces the legacy
|
|
304
|
+
// [first 4, anonymous ▶ at the ellipsis, last] whose orphaned-looking
|
|
305
|
+
// "… N more lines" segment confused the scrollback.
|
|
306
|
+
const kind = l.color === C.reason ? "thinking" : l.color === C.dim ? "tool output" : "message"
|
|
307
|
+
convLines.push(...renderFoldedHead({
|
|
308
|
+
header: foldHintLine(`▶ ${kind} · ${block.length} lines — click to expand`, longKey, i),
|
|
309
|
+
body: block, cols,
|
|
310
|
+
}))
|
|
311
|
+
} else if (foldable && block.length > threshold) {
|
|
150
312
|
if (state.foldEnabled === false) {
|
|
151
313
|
// Folding fully off — content already fully visible; a "click to
|
|
152
314
|
// collapse" hint would be misleading (toggling has no effect).
|
|
153
315
|
convLines.push(...block)
|
|
154
316
|
} else {
|
|
155
|
-
// EXPANDED long block: blank
|
|
156
|
-
//
|
|
157
|
-
//
|
|
317
|
+
// EXPANDED thinking/dim long block via the shared component: blank + ▼
|
|
318
|
+
// control at the HEAD, content, 60% cap with a bottom collapse control.
|
|
319
|
+
// DIM blocks must not re-trigger the consecutive-dim folding below
|
|
320
|
+
// (folding stacked on folding — reported regression).
|
|
158
321
|
if (l.color === C.dim) {
|
|
159
322
|
for (const line of block) line._skipDimFold = true
|
|
160
323
|
}
|
|
161
|
-
convLines.push(
|
|
162
|
-
convLines.push(foldHintLine(`▼ … ${block.length} lines — click to collapse`, longKey, i))
|
|
163
|
-
convLines.push(...block)
|
|
324
|
+
convLines.push(...renderExpandedBlock({ body: block, foldKey: longKey, state, maxRows, cols, label: `${block.length} lines` }))
|
|
164
325
|
}
|
|
165
326
|
} else {
|
|
166
327
|
convLines.push(...block)
|
|
167
328
|
}
|
|
329
|
+
// Trailing blank after a main-output segment (user request 2026-08-30) —
|
|
330
|
+
// landed after the segment's rendered content.
|
|
331
|
+
if (blankAfter) {
|
|
332
|
+
pushBlank()
|
|
333
|
+
blankAfter = false
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
// ── Subagent activity blocks (§7.2 D4) — RUNNING blocks only ──────────────
|
|
337
|
+
// Rendered BEFORE the advisor blocks section. A child's block lives here only
|
|
338
|
+
// while it runs: on completion onToolResult freezes the block into state.lines
|
|
339
|
+
// (subagent-blocks.mjs freezeSubTaskLines) so it scrolls away with the conversation
|
|
340
|
+
// instead of staying pinned above the input box ("ghost" report 2026-08-30).
|
|
341
|
+
// Default folded = header summary line (▶ role#id · model · elapsed · turn
|
|
342
|
+
// n/max | current state) + tail 3 block lines; expanded = shared component
|
|
343
|
+
// (full timeline, 60% screen cap).
|
|
344
|
+
const runningSubs = Object.values(state.subTasks ?? {}).filter((s) => !s.done)
|
|
345
|
+
if (runningSubs.length > 0) {
|
|
346
|
+
// Divider between the conversation body and the running-subagent band
|
|
347
|
+
// (user request 2026-08-30, mirroring the task-panel divider in
|
|
348
|
+
// render-frame renderTodo). Only when at least one block actually renders —
|
|
349
|
+
// done children are frozen into state.lines above, so a divider for an
|
|
350
|
+
// empty band would hang over the section boundary.
|
|
351
|
+
// Unconditional divider (task-panel style): the preceding main-output
|
|
352
|
+
// trailing blank is breathing room, the divider is the section boundary —
|
|
353
|
+
// both belong. Only dedupe against another divider (idempotent re-render).
|
|
354
|
+
if (convLines.at(-1)?.color !== C.dim || !convLines.at(-1)?.text?.startsWith("─")) {
|
|
355
|
+
convLines.push({ text: "─".repeat(Math.max(1, cols - 1)), color: C.dim, _skipDimFold: true })
|
|
356
|
+
}
|
|
357
|
+
for (const sub of runningSubs) {
|
|
358
|
+
// Done children never reach this loop (filtered above): they are frozen
|
|
359
|
+
// into state.lines by subagent-blocks.mjs freezeSubTaskLines and removed
|
|
360
|
+
// from subTasks — a restored leftover would otherwise pin at the tail.
|
|
361
|
+
const foldKey = `sub-${sub.key}`
|
|
362
|
+
// Header summary: `[▶ coder#1 · glm-5.3 · 45s · turn 12/100] bash — npm test`
|
|
363
|
+
const icon = sub.approval ? "⏸" : "▶"
|
|
364
|
+
const elapsed = Math.floor(((sub.done ? sub.doneAt : Date.now()) - sub.started) / 1000)
|
|
365
|
+
const modelPart = sub.model ? ` · ${sub.model}` : ""
|
|
366
|
+
const turnPart = sub.maxTurns > 0 ? ` · turn ${sub.turn}/${sub.maxTurns}` : ""
|
|
367
|
+
let statePart
|
|
368
|
+
if (sub.approval) statePart = `等待审批: ${sub.approval}`
|
|
369
|
+
else if (sub.done) {
|
|
370
|
+
statePart = `done ${elapsed}s${sub.lastError ? ` — ${sub.lastError}` : ""}`
|
|
371
|
+
} else if (sub.currentTool) statePart = sub.currentTool
|
|
372
|
+
else statePart = "thinking..."
|
|
373
|
+
const argSummary = sub.currentTool && sub.toolArgs?.command
|
|
374
|
+
? ` — ${String(sub.toolArgs.command).replace(/\s+/g, " ").trim().slice(0, 60)}`
|
|
375
|
+
: ""
|
|
376
|
+
convLines.push({
|
|
377
|
+
text: `[${icon} ${sub.key}${modelPart} · ${elapsed}s${turnPart}] ${sliceByWidth(statePart + argSummary, Math.max(20, cols - 30))}`,
|
|
378
|
+
color: sub.done ? C.dim : C.tool,
|
|
379
|
+
_foldToggle: foldKey,
|
|
380
|
+
})
|
|
381
|
+
if (isExpanded(state, foldKey)) {
|
|
382
|
+
// Full activity timeline via the shared component (per-kind colors,
|
|
383
|
+
// 60% screen cap — the header control may sit above the viewport once
|
|
384
|
+
// expanded, the capped bottom control stays reachable).
|
|
385
|
+
const body = renderBlockTimeline(sub.blocks, cols)
|
|
386
|
+
convLines.push(...renderExpandedBlock({ body, foldKey, state, maxRows, cols, label: "subagent activity" }))
|
|
387
|
+
} else {
|
|
388
|
+
// Folded: tail 3 non-empty block lines (most recent activity), dim.
|
|
389
|
+
for (const line of foldTailLines(sub.blocks)) {
|
|
390
|
+
convLines.push({ text: `│ ${sliceByWidth(line, cols - 4)}`, color: C.dim })
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
168
394
|
}
|
|
169
395
|
if (state.reasoning) {
|
|
396
|
+
// Live thinking streams INSIDE the unified box (user ruling 2026-08-30:
|
|
397
|
+
// "思考过程中为什么不是直接进这个框" — the flat tail render was a
|
|
398
|
+
// pre-fold-era leftover). Same folded form as flushed blocks: named header
|
|
399
|
+
// + tail 3, click expands to the 60%-capped live view. The buffer grows
|
|
400
|
+
// per token; convCacheKey already includes state.reasoning.length so the
|
|
401
|
+
// box updates live. Single instance key — one live stream at a time; on
|
|
402
|
+
// flush the block re-keys to `long-{idx}` with the identical form (seamless).
|
|
403
|
+
const liveKey = "thinking-live"
|
|
404
|
+
const body = []
|
|
170
405
|
for (const wrapped of wrapText(sanitizeDisplay(state.reasoning), cols - 1)) {
|
|
171
|
-
|
|
406
|
+
body.push({ text: wrapped, color: C.reason, _skipDimFold: true })
|
|
407
|
+
}
|
|
408
|
+
const expanded = isExpanded(state, liveKey)
|
|
409
|
+
if (expanded) {
|
|
410
|
+
convLines.push(...renderExpandedBlock({ body, foldKey: liveKey, state, maxRows, cols, label: "thinking (streaming)" }))
|
|
411
|
+
} else {
|
|
412
|
+
convLines.push(...renderFoldedHead({
|
|
413
|
+
header: foldHintLine(`▶ thinking · ${body.length} lines — click to expand`, liveKey),
|
|
414
|
+
body, cols,
|
|
415
|
+
}))
|
|
172
416
|
}
|
|
173
417
|
}
|
|
174
418
|
const advisorBlocks = state._advisorBlocks ?? []
|
|
175
419
|
if (advisorBlocks.length > 0) {
|
|
176
|
-
//
|
|
177
|
-
//
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
420
|
+
// ── Advisor review block (2026-08-30): collapsible in-conversation box ──
|
|
421
|
+
// The live stream used to render flat into the conversation and flooded it
|
|
422
|
+
// (user report). Same interaction as subagent blocks: default FOLDED =
|
|
423
|
+
// header (running/done + total line count) + tail 3 block lines; expanded =
|
|
424
|
+
// shared component (▼ control + ordered per-kind timeline — think/tool/text
|
|
425
|
+
// colors kept, T-F regression; placeholder markers stripped in every view —
|
|
426
|
+
// unified with the folded tail which always stripped them). Toggle key
|
|
427
|
+
// `advisor-blocks` (single instance — one advisor runs at a time).
|
|
428
|
+
const advKey = "advisor-blocks"
|
|
429
|
+
// Total rendered line count of the timeline (cheap: rough split, no wrap math)
|
|
430
|
+
const advLineCount = advisorBlocks.reduce((n, b) => n + sanitizeDisplay(b.text).split("\n").filter((l) => l.trim()).length, 0)
|
|
431
|
+
const advHeader = `[advisor · review] ${advLineCount} lines`
|
|
432
|
+
if (isExpanded(state, advKey)) {
|
|
433
|
+
const body = renderBlockTimeline(advisorBlocks, cols, { strip: [ADVISOR_THINKING_PLACEHOLDER] })
|
|
434
|
+
convLines.push(...renderExpandedBlock({ body, foldKey: advKey, state, maxRows, cols, label: advHeader }))
|
|
435
|
+
} else {
|
|
436
|
+
// Folded: header control line + tail 3 non-empty lines from the tail
|
|
437
|
+
// blocks (most recent activity), dim — mirrors the subagent block fold.
|
|
438
|
+
convLines.push({
|
|
439
|
+
text: `▶ ${advHeader} — click to expand`,
|
|
440
|
+
color: C.fold,
|
|
441
|
+
_foldToggle: advKey,
|
|
442
|
+
})
|
|
443
|
+
for (const line of foldTailLines(advisorBlocks, 3, { strip: [ADVISOR_THINKING_PLACEHOLDER] })) {
|
|
444
|
+
convLines.push({ text: `│ ${sliceByWidth(line, cols - 4)}`, color: C.dim, _skipDimFold: true })
|
|
199
445
|
}
|
|
200
446
|
}
|
|
201
447
|
}
|
|
202
448
|
if (state.streaming) {
|
|
203
|
-
//
|
|
449
|
+
// Main-output breathing room (2026-08-30): the streaming path renders OUTSIDE
|
|
450
|
+
// the line loop (the reply is still in its buffer, not yet in state.lines),
|
|
451
|
+
// so the loop's leading blank never applied — a streamed reply showed no
|
|
452
|
+
// blank until flush landed it in lines and a later frame re-rendered. Apply
|
|
453
|
+
// the same leading blank here (the trailing blank belongs to the line path
|
|
454
|
+
// once flushed — mid-stream there is still content to come).
|
|
455
|
+
if (convLines.at(-1)?.text !== "") convLines.push({ text: "", color: C.text })
|
|
456
|
+
// Rendered BEFORE formatTables — see fold-block.mjs for the width contract.
|
|
204
457
|
const rendered = renderMathAndMarkdown(sanitizeDisplay(state.streaming))
|
|
205
458
|
for (const line of formatTables(rendered, cols - 1)) {
|
|
206
459
|
for (const wrapped of wrapText(line, cols - 1)) {
|
|
@@ -224,21 +477,22 @@ function buildConvLines(state, cols) {
|
|
|
224
477
|
if (blockLen > FOLD_LINES && !hasExpandedLong) {
|
|
225
478
|
const foldKey = `fold-${foldCounter++}`
|
|
226
479
|
if (state.foldEnabled !== false && !state.expandedBlocks?.has(foldKey)) {
|
|
227
|
-
//
|
|
228
|
-
|
|
229
|
-
folded.push(
|
|
230
|
-
|
|
480
|
+
// FOLDED — unified named-header + last-3 form (same ruling as the
|
|
481
|
+
// long-message fold above).
|
|
482
|
+
folded.push(...renderFoldedHead({
|
|
483
|
+
header: foldHintLine(`▶ tool output · ${blockLen} lines — click to expand`, foldKey),
|
|
484
|
+
body: convLines.slice(i, j), cols,
|
|
485
|
+
}))
|
|
231
486
|
i = j
|
|
232
487
|
continue
|
|
233
488
|
}
|
|
234
|
-
// EXPANDED consecutive-dim block: blank + ▼ at
|
|
489
|
+
// EXPANDED consecutive-dim block via the shared component: blank + ▼ at
|
|
490
|
+
// the HEAD, then every line, 60% cap with a bottom collapse control.
|
|
235
491
|
// foldEnabled=false → raw block, no hint (toggling would be a no-op).
|
|
236
492
|
if (state.foldEnabled === false) {
|
|
237
493
|
for (let k = i; k < j; k++) folded.push(convLines[k])
|
|
238
494
|
} else {
|
|
239
|
-
folded.push(
|
|
240
|
-
folded.push(foldHintLine(`▼ … ${blockLen} lines — click to collapse`, foldKey))
|
|
241
|
-
for (let k = i; k < j; k++) folded.push(convLines[k])
|
|
495
|
+
folded.push(...renderExpandedBlock({ body: convLines.slice(i, j), foldKey, state, maxRows, cols, label: `${blockLen} lines` }))
|
|
242
496
|
}
|
|
243
497
|
i = j
|
|
244
498
|
continue
|
|
@@ -252,14 +506,14 @@ function buildConvLines(state, cols) {
|
|
|
252
506
|
return folded
|
|
253
507
|
}
|
|
254
508
|
|
|
255
|
-
export function countConvLines(state, cols) {
|
|
256
|
-
return buildConvLines(state, cols).length
|
|
509
|
+
export function countConvLines(state, cols, maxRows) {
|
|
510
|
+
return buildConvLines(state, cols, maxRows).length
|
|
257
511
|
}
|
|
258
512
|
|
|
259
513
|
export { buildConvLines }
|
|
260
514
|
|
|
261
|
-
export function renderConversation(state, cols, visibleH, scroll) {
|
|
262
|
-
const convLines = buildConvLines(state, cols)
|
|
515
|
+
export function renderConversation(state, cols, visibleH, scroll, maxRows) {
|
|
516
|
+
const convLines = buildConvLines(state, cols, maxRows)
|
|
263
517
|
const maxScroll = Math.max(0, convLines.length - visibleH)
|
|
264
518
|
const clamped = Math.min(scroll, maxScroll)
|
|
265
519
|
const end = convLines.length - clamped
|