thincoder 0.12.50 → 0.12.52

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/CHANGELOG.md +64 -3
  2. package/README.md +2 -2
  3. package/package.json +4 -3
  4. package/src/acp/bridge.mjs +5 -0
  5. package/src/agent/dispatch.mjs +19 -7
  6. package/src/agent/helpers.mjs +13 -1
  7. package/src/agent/record-results.mjs +130 -0
  8. package/src/agent/setup.mjs +4 -7
  9. package/src/agent/spawn-child.mjs +159 -0
  10. package/src/agent-tools/consult.mjs +94 -73
  11. package/src/agent-tools/escalate.mjs +53 -62
  12. package/src/agent-tools/skill.mjs +1 -1
  13. package/src/agent-tools/subagent.mjs +39 -38
  14. package/src/agent-tools/task.mjs +0 -2
  15. package/src/agent-tools/verify.mjs +0 -1
  16. package/src/agent.mjs +27 -112
  17. package/src/config.mjs +8 -103
  18. package/src/generate-title.mjs +30 -1
  19. package/src/model-specs.mjs +108 -0
  20. package/src/prompts/advisor-round1.md +5 -6
  21. package/src/prompts/advisor-round2.md +3 -4
  22. package/src/prompts/advisor-round3.md +3 -4
  23. package/src/prompts/eng-coder.md +9 -0
  24. package/src/prompts/engineering.md +61 -9
  25. package/src/prompts/system.md +2 -2
  26. package/src/provider/core.mjs +5 -71
  27. package/src/provider/normalize.mjs +81 -0
  28. package/src/session.mjs +40 -1
  29. package/src/tools/git.mjs +3 -3
  30. package/src/tools/shared.mjs +1 -0
  31. package/src/tools/system.mjs +3 -1
  32. package/src/tui/agent-turn.mjs +37 -364
  33. package/src/tui/clipboard.mjs +3 -1
  34. package/src/tui/dims.mjs +47 -0
  35. package/src/tui/fold-block.mjs +208 -0
  36. package/src/tui/index.mjs +33 -17
  37. package/src/tui/key-handler-search.mjs +1 -1
  38. package/src/tui/key-handler.mjs +10 -6
  39. package/src/tui/layout.mjs +21 -20
  40. package/src/tui/mouse.mjs +9 -6
  41. package/src/tui/pickers.mjs +1 -1
  42. package/src/tui/render-conversation.mjs +367 -113
  43. package/src/tui/render-frame.mjs +16 -90
  44. package/src/tui/render-loop.mjs +12 -8
  45. package/src/tui/render.mjs +16 -0
  46. package/src/tui/startup.mjs +66 -13
  47. package/src/tui/subagent-blocks.mjs +327 -0
  48. package/src/tui/tool-args.mjs +67 -0
  49. package/src/tui/tool-events.mjs +459 -0
@@ -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 { formatToolSummary } from "./tool-summaries.mjs"
6
- import { ADVISOR_THINKING_PLACEHOLDER, resolveAdvisorProvider } from "../advisor/run.mjs"
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, summarize } = ctx
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 (elapsed timer)
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
- // NOTE: advisor buffers (_advisorThink/advisorStreaming) are cleared here too.
70
- // Timing safety: onToolResult flushes _advisorThink into history and empties
71
- // the buffers BEFORE onTurnEnd can call flushStream (tool result is
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
- if (!agent.title) {
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
@@ -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 { mkdir, stat, unlink } = await import("node:fs/promises")
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) => {
@@ -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
+ }