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
package/src/tui/agent-turn.mjs
CHANGED
|
@@ -1,51 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent-turn.mjs — runAgentTurn:一个用户回合的驱动器(submit / 队列递归入口)。
|
|
3
|
+
*
|
|
4
|
+
* 2026-08-30 拆分(回回 500 行硬限):回合生命周期(状态复位 → runAgent 循环 →
|
|
5
|
+
* 错误/Continue/中断处理 → finally 收尾 → 队列)留在这里;工具事件 → TUI 状态的
|
|
6
|
+
* 回调装配(onToken/onReasoning/onToolCall/onToolResult/onToolOutput/onTurnEnd 等
|
|
7
|
+
* + flushStream)在 tool-events.mjs buildToolCallbacks;子agent 区块缓冲与完成冻结
|
|
8
|
+
* (routeSub* / finishSubTask / freeze*SubTasks)在 subagent-blocks.mjs;标题生成
|
|
9
|
+
* 在 generate-title.mjs ensureSessionTitle。
|
|
10
|
+
*/
|
|
1
11
|
import { runAgent, ContinueError } from "../agent.mjs"
|
|
2
12
|
import { saveSession } from "../session.mjs"
|
|
3
|
-
import { sliceByWidth } from "./render.mjs"
|
|
4
13
|
import { ansi, C } from "./ansi.mjs"
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
14
|
+
import { buildToolCallbacks, sweepToolBlocks } from "./tool-events.mjs"
|
|
15
|
+
import { freezeAllSubTasks } from "./subagent-blocks.mjs"
|
|
16
|
+
import { ensureSessionTitle } from "../generate-title.mjs"
|
|
7
17
|
|
|
8
18
|
/** Exit-flush bound for the async end-of-run distillation (SEND-STALL-DISTILL §2.5):
|
|
9
19
|
* wait at most this long for the in-flight distill before the final session save —
|
|
10
20
|
* never let shutdown hang on the background summary call. */
|
|
11
21
|
const DISTILL_FLUSH_TIMEOUT_MS = 5000
|
|
12
22
|
|
|
13
|
-
/** Tool execution start timestamps (performance.now ms), keyed by tool name. */
|
|
14
|
-
const _toolTicks = Object.create(null)
|
|
15
|
-
|
|
16
|
-
/** Per-tool streaming preview line limits — tools with verbose output get more lines.
|
|
17
|
-
* NOTE: `advisor` is intentionally NOT pruned by the live-line mechanism: its
|
|
18
|
-
* streaming returns early (kind-split into _advisorThink/advisorStreaming) and
|
|
19
|
-
* is rendered full-length in render-conversation. The entry is kept for
|
|
20
|
-
* symmetry with the map's other tools. */
|
|
21
|
-
const LIVE_LINE_LIMITS = {
|
|
22
|
-
bash: 10,
|
|
23
|
-
advisor: 15,
|
|
24
|
-
read: 3,
|
|
25
|
-
grep: 8,
|
|
26
|
-
glob: 8,
|
|
27
|
-
search: 8,
|
|
28
|
-
websearch: 8,
|
|
29
|
-
code_search: 8,
|
|
30
|
-
doc_search: 8,
|
|
31
|
-
}
|
|
32
|
-
|
|
33
23
|
/** Execute one agent conversation turn (triggered by submit or queue).
|
|
34
24
|
* Extracted from index.mjs: agent loop + callback construction + error handling + queue processing.
|
|
35
25
|
* ctx: { agent, state, pushLine, pushLabel, render, scheduleRender,
|
|
36
26
|
* ensureAssistantLabel, askPermission, askQuestion,
|
|
37
27
|
* handleSlash, summarize } */
|
|
38
28
|
export async function runAgentTurn(ctx, text) {
|
|
39
|
-
const { agent, state, pushLine, pushLabel, render, scheduleRender, ensureAssistantLabel, askPermission, askQuestion, handleSlash
|
|
29
|
+
const { agent, state, pushLine, pushLabel, render, scheduleRender, ensureAssistantLabel, askPermission, askQuestion, handleSlash } = ctx
|
|
40
30
|
// 可注入覆盖(测试用);默认走真实实现
|
|
41
31
|
const runAgentImpl = ctx.runAgent ?? runAgent
|
|
42
32
|
const saveSessionImpl = ctx.saveSession ?? saveSession
|
|
43
|
-
// A new user message starts a new turn: auto-expanded completed replies from the
|
|
44
|
-
// previous turn (kept open so the user could read them) collapse now.
|
|
45
|
-
for (const idx of state._autoExpand ?? []) {
|
|
46
|
-
state.expandedBlocks?.delete(`long-${idx}`)
|
|
47
|
-
}
|
|
48
|
-
state._autoExpand = []
|
|
49
33
|
pushLabel(`❯ You:`, ansi.bold + C.user)
|
|
50
34
|
pushLine(text, C.text)
|
|
51
35
|
|
|
@@ -55,333 +39,28 @@ export async function runAgentTurn(ctx, text) {
|
|
|
55
39
|
state.streaming = ""
|
|
56
40
|
state.reasoning = ""
|
|
57
41
|
state._advisorBlocks = []
|
|
58
|
-
state.subTasks
|
|
42
|
+
// NOTE (§7.2 D4): state.subTasks is intentionally NOT reset here — subagent
|
|
43
|
+
// activity blocks persist across turns (the user can still expand a finished
|
|
44
|
+
// child's block from a previous turn). Child tool calls never enter the parent
|
|
45
|
+
// history, so the blocks are the only trace of child activity; memory is
|
|
46
|
+
// bounded by the N2 per-child 500-line ring buffer.
|
|
59
47
|
state.currentTool = null
|
|
60
48
|
state.processingStarted = Date.now()
|
|
61
49
|
state.controller = new AbortController()
|
|
62
50
|
state.interruptPrompt = null
|
|
63
|
-
// Refresh status bar every second during processing
|
|
51
|
+
// Refresh status bar every second during processing; also refresh when any
|
|
52
|
+
// subagent block is still running so its header elapsed ticks (§7.2 D4 —
|
|
53
|
+
// no new timer, the existing ticker carries it). Blocks stay visible after
|
|
54
|
+
// the turn ends, but frozen headers don't need 1s refreshes.
|
|
55
|
+
const subRunning = () => Object.values(state.subTasks ?? {}).some((s) => !s.done)
|
|
64
56
|
const ticker = setInterval(() => {
|
|
65
|
-
if (state.processing) render()
|
|
57
|
+
if (state.processing || subRunning()) render()
|
|
66
58
|
}, 1000)
|
|
67
59
|
render()
|
|
68
60
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
// dispatched inside executeToolCalls; onTurnEnd fires after the turn loop
|
|
73
|
-
// resumes). If a future change calls flushStream mid-advisor-execution the
|
|
74
|
-
// in-progress thinking WOULD be lost — keep the ordering, or flush here too.
|
|
75
|
-
const flushStream = () => {
|
|
76
|
-
if (state.reasoning) {
|
|
77
|
-
const idx = state.lines.length
|
|
78
|
-
pushLine(state.reasoning, C.reason)
|
|
79
|
-
// Completed reasoning stays expanded (user is reading it) until the next turn
|
|
80
|
-
state.expandedBlocks ??= new Set()
|
|
81
|
-
state.expandedBlocks.add(`long-${idx}`)
|
|
82
|
-
state._autoExpand ??= []
|
|
83
|
-
state._autoExpand.push(idx)
|
|
84
|
-
state.reasoning = ""
|
|
85
|
-
}
|
|
86
|
-
if (state.streaming) {
|
|
87
|
-
const idx = state.lines.length
|
|
88
|
-
pushLine(state.streaming, C.text)
|
|
89
|
-
// Completed main output stays expanded (user is reading it) until the next turn
|
|
90
|
-
state.expandedBlocks ??= new Set()
|
|
91
|
-
state.expandedBlocks.add(`long-${idx}`)
|
|
92
|
-
state._autoExpand ??= []
|
|
93
|
-
state._autoExpand.push(idx)
|
|
94
|
-
state.streaming = ""
|
|
95
|
-
}
|
|
96
|
-
state._advisorBlocks = []
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
const callbacks = {
|
|
100
|
-
onToken: (t) => {
|
|
101
|
-
// Subagent streaming: prefix format role#id/ → extract id, update subTask streaming text
|
|
102
|
-
const subMatch = t.match(/^([\w-]+)#(\d+)\//)
|
|
103
|
-
if (subMatch) {
|
|
104
|
-
const key = `${subMatch[1]}#${subMatch[2]}`
|
|
105
|
-
const payload = t.slice(subMatch[0].length)
|
|
106
|
-
if (!state.subTasks[key]) {
|
|
107
|
-
state.subTasks[key] = { key, role: subMatch[1], text: "", tool: null, done: false, started: Date.now() }
|
|
108
|
-
}
|
|
109
|
-
// `[model]<name>` metadata token: record the subagent's model (may differ from the
|
|
110
|
-
// parent's) — shown in the subagent header, NOT appended to its content stream.
|
|
111
|
-
// Only treat as metadata when the model isn't set yet (it's always the FIRST token);
|
|
112
|
-
// a child content token that happens to start with "[model]" must not be swallowed.
|
|
113
|
-
if (payload.startsWith("[model]") && state.subTasks[key].model === undefined) {
|
|
114
|
-
state.subTasks[key].model = payload.slice(7)
|
|
115
|
-
scheduleRender()
|
|
116
|
-
return
|
|
117
|
-
}
|
|
118
|
-
state.subTasks[key].text += payload
|
|
119
|
-
if (state.subTasks[key].text.length > 2000) {
|
|
120
|
-
state.subTasks[key].text = state.subTasks[key].text.slice(-2000)
|
|
121
|
-
}
|
|
122
|
-
scheduleRender()
|
|
123
|
-
return
|
|
124
|
-
}
|
|
125
|
-
ensureAssistantLabel()
|
|
126
|
-
state.streaming += t
|
|
127
|
-
scheduleRender()
|
|
128
|
-
},
|
|
129
|
-
onReasoning: (t) => {
|
|
130
|
-
// Subagent reasoning tokens also carry role#id/ prefix, go into subTasks panel
|
|
131
|
-
const subMatch = t.match(/^([\w-]+)#(\d+)\//)
|
|
132
|
-
if (subMatch) {
|
|
133
|
-
const key = `${subMatch[1]}#${subMatch[2]}`
|
|
134
|
-
if (!state.subTasks[key]) {
|
|
135
|
-
state.subTasks[key] = { key, role: subMatch[1], text: "", tool: null, done: false, started: Date.now() }
|
|
136
|
-
}
|
|
137
|
-
scheduleRender()
|
|
138
|
-
return
|
|
139
|
-
}
|
|
140
|
-
ensureAssistantLabel()
|
|
141
|
-
state.reasoning += t
|
|
142
|
-
scheduleRender()
|
|
143
|
-
},
|
|
144
|
-
onToolCall: (name, args) => {
|
|
145
|
-
// Subagent tool call: prefix role#id/toolName → update subTask current tool
|
|
146
|
-
const subMatch = name.match(/^([\w-]+)#(\d+)\//)
|
|
147
|
-
if (subMatch) {
|
|
148
|
-
const key = `${subMatch[1]}#${subMatch[2]}`
|
|
149
|
-
const toolName = name.slice(subMatch[0].length)
|
|
150
|
-
if (!state.subTasks[key]) {
|
|
151
|
-
state.subTasks[key] = { key, role: subMatch[1], text: "", tool: null, done: false, started: Date.now() }
|
|
152
|
-
}
|
|
153
|
-
state.subTasks[key].tool = toolName
|
|
154
|
-
state.subTasks[key].toolArgs = args
|
|
155
|
-
state.subTasks[key].text = ""
|
|
156
|
-
scheduleRender()
|
|
157
|
-
return
|
|
158
|
-
}
|
|
159
|
-
// Redundant with flushStream() below (it clears both buffers) — kept as
|
|
160
|
-
// defense-in-depth so a future flushStream change cannot leak advisor
|
|
161
|
-
// buffers into the next tool's view.
|
|
162
|
-
if (name === "advisor") { state._advisorBlocks = [] }
|
|
163
|
-
flushStream()
|
|
164
|
-
ensureAssistantLabel()
|
|
165
|
-
state.currentTool = name
|
|
166
|
-
// Advisor's effective model (resolved once for the status line + inline title below).
|
|
167
|
-
const advModel = name === "advisor" ? (() => { try { return resolveAdvisorProvider(agent).model } catch { return null } })() : null
|
|
168
|
-
// Update status bar with current tool and key arguments for user visibility
|
|
169
|
-
if (name === "bash" && args.command) {
|
|
170
|
-
const cmd = args.command.replace(/\s+/g, " ").trim()
|
|
171
|
-
state.status = `Running: ${cmd.length > 50 ? cmd.slice(0, 50) + "…" : cmd}`
|
|
172
|
-
} else if ((name === "read" || name === "write" || name === "edit" || name === "grep" || name === "glob") && args.path) {
|
|
173
|
-
state.status = `${name}: ${args.path}`
|
|
174
|
-
} else if (name === "grep" && args.pattern) {
|
|
175
|
-
state.status = `grep: ${args.pattern}`
|
|
176
|
-
} else if (name === "glob" && args.pattern) {
|
|
177
|
-
state.status = `glob: ${args.pattern}`
|
|
178
|
-
} else if (name === "websearch" && args.query) {
|
|
179
|
-
state.status = `search: ${args.query.length > 40 ? args.query.slice(0, 40) + "…" : args.query}`
|
|
180
|
-
} else if (name === "advisor") {
|
|
181
|
-
state.status = `advisor review (round ${(agent._advisorRound || 0) + 1}${advModel ? " · " + advModel : ""})`
|
|
182
|
-
} else {
|
|
183
|
-
state.status = `tool: ${name}`
|
|
184
|
-
}
|
|
185
|
-
// Advisor: tag the round in the tool title — the model's own "第N轮" narration
|
|
186
|
-
// is unreliable (it glues onto the previous line), so the round belongs here.
|
|
187
|
-
// Also show the advisor's effective model (it may differ from the main agent's).
|
|
188
|
-
const roundTag = name === "advisor" ? ` (round ${(agent._advisorRound || 0) + 1}${advModel ? " · " + advModel : ""})` : ""
|
|
189
|
-
const argSummary = summarize(args)
|
|
190
|
-
// Inline block title — panel tools get both the title AND the
|
|
191
|
-
// streaming output panel, complementary display.
|
|
192
|
-
const color = ({ advisor: C.advisor, bash: C.warn, verify: C.tool }[name] ?? C.text)
|
|
193
|
-
pushLine(`❯ ${name}${roundTag}${argSummary ? ` ${argSummary}` : ""}`, color)
|
|
194
|
-
_toolTicks[name] = performance.now()
|
|
195
|
-
},
|
|
196
|
-
onToolResult: (name, result) => {
|
|
197
|
-
state.currentTool = null
|
|
198
|
-
// Subagent complete: mark earliest running subTask as done
|
|
199
|
-
const isSubagent = name === "subagent"
|
|
200
|
-
if (isSubagent) {
|
|
201
|
-
const running = Object.entries(state.subTasks)
|
|
202
|
-
.filter(([, s]) => !s.done)
|
|
203
|
-
.sort(([, a], [, b]) => a.started - b.started)
|
|
204
|
-
if (running.length > 0) {
|
|
205
|
-
const [key] = running[0]
|
|
206
|
-
state.subTasks[key].done = true
|
|
207
|
-
state.subTasks[key].tool = null
|
|
208
|
-
}
|
|
209
|
-
// Subagent report preview (max 8 lines) displayed directly in conversation
|
|
210
|
-
const lines = result.split("\n")
|
|
211
|
-
const preview = lines.slice(0, 8).map((l) => l.slice(0, 120)).join("\n")
|
|
212
|
-
if (preview) pushLine(preview, C.dim)
|
|
213
|
-
if (lines.length > 8) pushLine(` ... (${lines.length - 8} more lines)`, C.dim)
|
|
214
|
-
// Clear done entries from panel after 3 seconds
|
|
215
|
-
setTimeout(() => {
|
|
216
|
-
for (const key of Object.keys(state.subTasks)) {
|
|
217
|
-
if (state.subTasks[key].done) delete state.subTasks[key]
|
|
218
|
-
}
|
|
219
|
-
if (state.processing) render()
|
|
220
|
-
}, 3000)
|
|
221
|
-
}
|
|
222
|
-
if (!isSubagent && name !== "advisor") {
|
|
223
|
-
// Remove live streaming lines — done line handles the summary.
|
|
224
|
-
for (let i = state.lines.length - 1; i >= 0; i--) {
|
|
225
|
-
if (state.lines[i]._live === name) state.lines.splice(i, 1)
|
|
226
|
-
}
|
|
227
|
-
const summary = formatToolSummary(name, result)
|
|
228
|
-
if (summary) pushLine(` ${summary}`, C.dim)
|
|
229
|
-
}
|
|
230
|
-
if (name === "advisor") {
|
|
231
|
-
// The review's thinking must survive into the conversation history like
|
|
232
|
-
// the main agent's reasoning (flushStream does for state.reasoning) —
|
|
233
|
-
// discarding it left the thought process visible only mid-review, then
|
|
234
|
-
// gone. Flush BEFORE the done line so the block sits above it.
|
|
235
|
-
// NOTE (rendering): the flushed block has NO "│ " gutter prefix while
|
|
236
|
-
// the live streaming view adds one — same convention as the main
|
|
237
|
-
// agent's reasoning (live gutter, history plain). Intentional.
|
|
238
|
-
const blocks = state._advisorBlocks ?? []
|
|
239
|
-
if (blocks.length > 0) {
|
|
240
|
-
// Flush the ordered blocks in sequence — thinking and tool progress
|
|
241
|
-
// alternate in history exactly as they were emitted. The live
|
|
242
|
-
// "[thinking…]" placeholders are stripped (wait indicators, not
|
|
243
|
-
// review content); literal replaceAll of the shared constant can
|
|
244
|
-
// never drift.
|
|
245
|
-
const text = blocks
|
|
246
|
-
.map((b) => b.text.replaceAll(ADVISOR_THINKING_PLACEHOLDER, ""))
|
|
247
|
-
.join("")
|
|
248
|
-
.replace(/\n{3,}/g, "\n\n")
|
|
249
|
-
.trim()
|
|
250
|
-
if (text) {
|
|
251
|
-
const idx = state.lines.length
|
|
252
|
-
pushLine(text, C.reason)
|
|
253
|
-
// Completed review output stays expanded (user is reading it).
|
|
254
|
-
state.expandedBlocks ??= new Set()
|
|
255
|
-
state.expandedBlocks.add("long-" + idx)
|
|
256
|
-
state._autoExpand ??= []
|
|
257
|
-
state._autoExpand.push(idx)
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
// Done line for ALL tools (panel area abolished — inline only).
|
|
262
|
-
if (!isSubagent) {
|
|
263
|
-
const elapsed = _toolTicks[name] ? ` (${Math.round(performance.now() - _toolTicks[name])}ms)` : ""
|
|
264
|
-
const summary = formatToolSummary(name, result)
|
|
265
|
-
const tail = summary ? ` → ${sliceByWidth(summary, 60)}` : ""
|
|
266
|
-
pushLine(`❯ ${name} — done${elapsed}${tail}`, C.dim)
|
|
267
|
-
}
|
|
268
|
-
delete _toolTicks[name]
|
|
269
|
-
},
|
|
270
|
-
onToolOutput: (name, chunk) => {
|
|
271
|
-
// All tools use inline conversation blocks — panel area is abolished.
|
|
272
|
-
// Stream up to 5 preview lines; the full result is in the tool message.
|
|
273
|
-
const part = typeof chunk === "string"
|
|
274
|
-
? { kind: "text", text: chunk.trimEnd() }
|
|
275
|
-
: { kind: chunk?.kind ?? "text", text: String(chunk?.text ?? "").trimEnd() }
|
|
276
|
-
if (!part.text) return
|
|
277
|
-
if (name === "advisor") {
|
|
278
|
-
// Accumulate to buffer — formatTables + wrapText in render-conversation
|
|
279
|
-
// handles markdown formatting, same as main agent response.
|
|
280
|
-
// NOTE: the advisor tool ALWAYS emits {kind, text} objects (run.mjs's
|
|
281
|
-
// emit() wrapper) — a raw string chunk is never think; if that ever
|
|
282
|
-
// changes, plain-string think would land in advisorStreaming.
|
|
283
|
-
// ORDERED block buffer — preserves the interleaved emission order
|
|
284
|
-
// (think → tool → think → … → final). Two separate buffers (_advisorThink
|
|
285
|
-
// vs advisorStreaming) rendered think-block-then-main-block, which
|
|
286
|
-
// regrouped ALL thinking above ALL tool progress — the alternating
|
|
287
|
-
// timeline was destroyed. Consecutive chunks of the same kind merge
|
|
288
|
-
// into one block; kind flips start a new block; render walks the
|
|
289
|
-
// blocks in order with per-kind colors.
|
|
290
|
-
const isString = typeof chunk === "string"
|
|
291
|
-
const raw = isString ? chunk : String(chunk?.text ?? "")
|
|
292
|
-
const kind = isString ? "text" : (chunk?.kind ?? "text")
|
|
293
|
-
const blocks = state._advisorBlocks ??= []
|
|
294
|
-
const last = blocks.at(-1)
|
|
295
|
-
if (last && last.kind === kind) last.text += raw
|
|
296
|
-
else blocks.push({ kind, text: raw })
|
|
297
|
-
scheduleRender()
|
|
298
|
-
return
|
|
299
|
-
}
|
|
300
|
-
// Rolling output — show latest N lines with fold marker per tool.
|
|
301
|
-
// _live marker per tool enables per-tool pruning without affecting other content.
|
|
302
|
-
const color = ({ think: C.reason, tool: C.tool }[part.kind] ?? C.dim)
|
|
303
|
-
for (const line of part.text.split("\n")) {
|
|
304
|
-
const trimmed = line.trimEnd()
|
|
305
|
-
if (!trimmed) continue
|
|
306
|
-
state.lines.push({ text: `│ ${trimmed}`, color, _live: name })
|
|
307
|
-
}
|
|
308
|
-
// Prune: keep at most N lines + "│ …" fold marker per tool (configurable, tool-specific)
|
|
309
|
-
const configLimit = agent.config?.agent?.streamPreviewLines
|
|
310
|
-
const toolLimit = LIVE_LINE_LIMITS[name]
|
|
311
|
-
const previewLines = configLimit ?? toolLimit ?? 5
|
|
312
|
-
let count = 0
|
|
313
|
-
let hasFold = false
|
|
314
|
-
for (let i = state.lines.length - 1; i >= 0; i--) {
|
|
315
|
-
if (state.lines[i]._live === name) {
|
|
316
|
-
if (++count > previewLines) {
|
|
317
|
-
if (!hasFold) {
|
|
318
|
-
state.lines[i] = { text: "│ …", color: C.dim, _live: name }
|
|
319
|
-
hasFold = true; count = previewLines
|
|
320
|
-
} else {
|
|
321
|
-
state.lines.splice(i, 1)
|
|
322
|
-
}
|
|
323
|
-
}
|
|
324
|
-
}
|
|
325
|
-
}
|
|
326
|
-
scheduleRender()
|
|
327
|
-
},
|
|
328
|
-
onPermissionRequest: (name, args) => askPermission(name, args),
|
|
329
|
-
onQuestion: (text, options) => askQuestion(text, options),
|
|
330
|
-
onCompress: () => {
|
|
331
|
-
pushLine(" [context] Context too long, auto-compacted (early conversation summarized by LLM, task state preserved)", C.warn)
|
|
332
|
-
},
|
|
333
|
-
// Async distillation landed (SEND-STALL-DISTILL §2.3): the machine line was replaced by
|
|
334
|
-
// the compressed version — persist it so the session file ends up compressed. Silent:
|
|
335
|
-
// a save failure must never surface after the turn already returned.
|
|
336
|
-
onDistilled: () => {
|
|
337
|
-
try { saveSessionImpl(agent, state.lines) } catch { /* 静默 */ }
|
|
338
|
-
},
|
|
339
|
-
onUsage: (usage) => {
|
|
340
|
-
state.tokens.prompt += usage.prompt_tokens ?? 0
|
|
341
|
-
state.tokens.completion += usage.completion_tokens ?? 0
|
|
342
|
-
state.tokens.cacheHit += usage.prompt_cache_hit_tokens ?? 0
|
|
343
|
-
state.tokens.cacheMiss += usage.prompt_cache_miss_tokens ?? 0
|
|
344
|
-
state.tokens.reasoningTokens += usage.completion_tokens_details?.reasoning_tokens ?? 0
|
|
345
|
-
},
|
|
346
|
-
// Throttle wait (active gate / 429 backoff): show in status bar so user knows it's not frozen
|
|
347
|
-
onWait: ({ phase, seconds }) => {
|
|
348
|
-
if (phase === "gate") state.status = `TPM throttle wait ~${seconds}s`
|
|
349
|
-
else if (phase === "overloaded") state.status = `Server overloaded, retrying in ${seconds}s`
|
|
350
|
-
else state.status = `Rate-limited 429, retry in ${seconds}s`
|
|
351
|
-
render()
|
|
352
|
-
},
|
|
353
|
-
onTaskUpdate: (items) => {
|
|
354
|
-
state.tasks = items
|
|
355
|
-
const done = items.filter((i) => i.status === "done").length
|
|
356
|
-
// Leave trace with current task title: reviewing history shows what was in progress
|
|
357
|
-
const current = items.find((i) => i.status === "in_progress")
|
|
358
|
-
pushLine(` [task] ${done}/${items.length}${current ? ` ▶ ${current.title}` : ""}`, C.dim)
|
|
359
|
-
render()
|
|
360
|
-
},
|
|
361
|
-
// Incremental save: flush to disk every 5 tool turns — mid-crash loss window shrinks from an entire round to a few turns
|
|
362
|
-
onTurnEnd: (() => {
|
|
363
|
-
let n = 0
|
|
364
|
-
return () => {
|
|
365
|
-
// Flush pending reasoning/streaming before the next turn starts.
|
|
366
|
-
// Guard pushbacks (verify/advisor) continue the agent loop without
|
|
367
|
-
// returning to the TUI — without flushing, old thinking bleeds into
|
|
368
|
-
// the next turn and the guard reminder is invisible.
|
|
369
|
-
flushStream()
|
|
370
|
-
// Mirror the last system-reminder from agent.history so guard
|
|
371
|
-
// pushback messages appear in the conversation at the right spot.
|
|
372
|
-
const last = agent.history.at(-1)
|
|
373
|
-
if (last?.role === "user" && typeof last.content === "string" && last.content.startsWith("[System reminder:")) {
|
|
374
|
-
// Reminders can embed long prior tables — show only the first lines
|
|
375
|
-
// (the full text is in agent.history); 3 lines + ellipsis.
|
|
376
|
-
const lines = last.content.split("\n")
|
|
377
|
-
const shown = lines.length > 3 ? lines.slice(0, 3).join("\n") + "\n…" : last.content
|
|
378
|
-
pushLine(shown, C.warn)
|
|
379
|
-
}
|
|
380
|
-
if (++n % 5 !== 0) return
|
|
381
|
-
try { saveSessionImpl(agent, state.lines) } catch (e) { console.error(`[session] incremental save failed: ${e.message}`) }
|
|
382
|
-
}
|
|
383
|
-
})(),
|
|
384
|
-
}
|
|
61
|
+
const { callbacks, flushStream } = buildToolCallbacks({
|
|
62
|
+
agent, state, pushLine, render, scheduleRender, ensureAssistantLabel, askPermission, askQuestion, saveSessionImpl,
|
|
63
|
+
})
|
|
385
64
|
|
|
386
65
|
// try/finally: every exit path — including an unexpected throw inside the catch
|
|
387
66
|
// block (e.g. the continue-permission UI) — must stop the ticker and reset state,
|
|
@@ -436,8 +115,15 @@ export async function runAgentTurn(ctx, text) {
|
|
|
436
115
|
} finally {
|
|
437
116
|
clearInterval(ticker)
|
|
438
117
|
state.processing = false
|
|
439
|
-
state.subTasks = {}
|
|
440
118
|
state._advisorBlocks = []
|
|
119
|
+
// Interrupted runs (Ctrl+C abort / error mid-turn): still-running child blocks
|
|
120
|
+
// would linger as pinned ghosts above the input box — freeze them like normal
|
|
121
|
+
// completions so the trace scrolls away with the conversation (2026-08-30).
|
|
122
|
+
freezeAllSubTasks(state)
|
|
123
|
+
// Tool-block carriers get the same sweep (P0-2, 2026-08-30 consult): without
|
|
124
|
+
// an onToolResult their header would say "running" forever; ticks are cleared
|
|
125
|
+
// so no stale start time leaks into the next turn.
|
|
126
|
+
sweepToolBlocks(state)
|
|
441
127
|
state.controller = null
|
|
442
128
|
state.status = "Ready"
|
|
443
129
|
// FR1: status bar must recover immediately — the awaits below (title-gen, distill flush,
|
|
@@ -449,20 +135,7 @@ export async function runAgentTurn(ctx, text) {
|
|
|
449
135
|
state.tasks = []
|
|
450
136
|
}
|
|
451
137
|
// Auto-generate session title from the first user message (once per session)
|
|
452
|
-
|
|
453
|
-
try {
|
|
454
|
-
const { generateTitle } = await import("../generate-title.mjs")
|
|
455
|
-
const firstUser = (agent._fullHistory ?? agent.history).find(
|
|
456
|
-
(m) => m.role === "user" && typeof m.content === "string" && !m.content.startsWith("[System reminder:"),
|
|
457
|
-
)
|
|
458
|
-
if (firstUser) {
|
|
459
|
-
const title = await generateTitle(firstUser.content, agent.provider)
|
|
460
|
-
if (title) agent.title = title
|
|
461
|
-
}
|
|
462
|
-
} catch {
|
|
463
|
-
// Title generation failure is non-fatal
|
|
464
|
-
}
|
|
465
|
-
}
|
|
138
|
+
await ensureSessionTitle(agent)
|
|
466
139
|
// Exit flush (SEND-STALL-DISTILL §2.5): the round-end distillation runs async — before
|
|
467
140
|
// the final save, give it a bounded window to land the compressed history on disk.
|
|
468
141
|
// The next turn's runAgent would await it anyway; this covers the real exit path
|
package/src/tui/clipboard.mjs
CHANGED
|
@@ -102,6 +102,7 @@ export function insertPastedText(state, rawText) {
|
|
|
102
102
|
* Terminals without enhancement send a bare \r for Shift+Enter — nothing to translate
|
|
103
103
|
* (degrades to a normal submit; Alt+Enter remains the fallback). */
|
|
104
104
|
export function translateShiftEnter(text) {
|
|
105
|
+
// eslint-disable-next-line no-control-regex -- 有意为之:控制字符协议/转义序列剥离正则(ANSI/⟦ev⟧/SGR/history 双线分隔)
|
|
105
106
|
return text.replace(/\x1b\[13;2u/g, "\x1b\r").replace(/\x1b\[27;2;13~/g, "\x1b\r")
|
|
106
107
|
}
|
|
107
108
|
|
|
@@ -110,6 +111,7 @@ export function translateShiftEnter(text) {
|
|
|
110
111
|
* modifyOtherKeys: \x1b[27;mod;key~ — function keys
|
|
111
112
|
* Call AFTER translateShiftEnter (which already handles Shift+Enter). */
|
|
112
113
|
export function stripKeyboardProtocol(text) {
|
|
114
|
+
// eslint-disable-next-line no-control-regex -- 有意为之:控制字符协议/转义序列剥离正则(ANSI/⟦ev⟧/SGR/history 双线分隔)
|
|
113
115
|
return text.replace(/\x1b\[\d+;\d+u/g, "").replace(/\x1b\[27;\d+;\d+~/g, "")
|
|
114
116
|
}
|
|
115
117
|
|
|
@@ -119,7 +121,7 @@ export function stripKeyboardProtocol(text) {
|
|
|
119
121
|
export async function pasteClipboardImage(ctx) {
|
|
120
122
|
const { agent, state, pushLine, render } = ctx
|
|
121
123
|
const { execFile } = await import("node:child_process")
|
|
122
|
-
const {
|
|
124
|
+
const { stat, unlink } = await import("node:fs/promises")
|
|
123
125
|
const { join } = await import("node:path")
|
|
124
126
|
|
|
125
127
|
const run = (cmd, args) => new Promise((resolve, reject) => {
|
package/src/tui/dims.mjs
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dims.mjs — terminal dimension single source (2026-08-30; simplified 2026-08-31).
|
|
3
|
+
*
|
|
4
|
+
* One dimension source for every render/interaction path. Consumers read the
|
|
5
|
+
* CACHED dims via get() — never process.stdout.columns/rows directly.
|
|
6
|
+
* Sampling happens in event hooks only (startup seed, resize) — never in the
|
|
7
|
+
* render path (guard test enforces this).
|
|
8
|
+
*
|
|
9
|
+
* Terminal semantics (2026-08-31 simplification): the earlier ConPTY unstable-
|
|
10
|
+
* size hypothesis (sample-and-hold, asymmetric acceptance, double-confirm
|
|
11
|
+
* shrink with settle windows, idle watchdog) was built on a misdiagnosis —
|
|
12
|
+
* the real 2026-08-30 narrow-streaming bug was missing `cols` args at
|
|
13
|
+
* fold-block call sites (component default 80), and a resize event is a
|
|
14
|
+
* genuine dimension change on any terminal. The double-confirm rule even
|
|
15
|
+
* broke window drag-to-shrink (a drag ends with ONE final resize event).
|
|
16
|
+
* Remaining defense kept: sane-gate (cols>=40, rows>=10) drops falsy/unusable
|
|
17
|
+
* reads (headless / no TTY), and any sane sample — larger OR smaller — is
|
|
18
|
+
* accepted immediately. A later real resize corrects the cache naturally.
|
|
19
|
+
*/
|
|
20
|
+
function defaultSample() {
|
|
21
|
+
return {
|
|
22
|
+
cols: Number(process.stdout.columns),
|
|
23
|
+
rows: Number(process.stdout.rows),
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function makeDimsState(initial = {}, sampleFn = defaultSample, onChange = null) {
|
|
28
|
+
let dims = {
|
|
29
|
+
cols: Number(initial.cols) || 80,
|
|
30
|
+
rows: Number(initial.rows) || 24,
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
/** Cached dims (what every render path must use). */
|
|
34
|
+
get: () => dims,
|
|
35
|
+
/** Sample (event hooks only: startup seed, resize). Falsy/unusable → keep last good. */
|
|
36
|
+
refresh: () => {
|
|
37
|
+
const s = sampleFn()
|
|
38
|
+
const c = Number(s.cols)
|
|
39
|
+
const r = Number(s.rows)
|
|
40
|
+
if (!(c >= 40 && r >= 10)) return dims // falsy/stale-unusable → keep last good
|
|
41
|
+
if (c === dims.cols && r === dims.rows) return dims
|
|
42
|
+
dims = { cols: c, rows: r }
|
|
43
|
+
onChange?.(dims)
|
|
44
|
+
return dims
|
|
45
|
+
},
|
|
46
|
+
}
|
|
47
|
+
}
|