thincoder 0.11.0 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/package.json +1 -1
  2. package/src/advisor.mjs +360 -72
  3. package/src/agent/helpers.mjs +7 -3
  4. package/src/agent/setup.mjs +2 -2
  5. package/src/agent-tools/advisor.mjs +36 -0
  6. package/src/agent-tools/plan.mjs +53 -2
  7. package/src/agent-tools/subagent.mjs +7 -1
  8. package/src/agent-tools/timer.mjs +1 -1
  9. package/src/agent-tools/verify.mjs +1 -0
  10. package/src/agent-tools.mjs +1 -0
  11. package/src/agent.mjs +73 -21
  12. package/src/auto-think.mjs +23 -5
  13. package/src/config.mjs +1 -1
  14. package/src/prompts/advisor-round1.md +23 -0
  15. package/src/prompts/advisor-round2.md +26 -0
  16. package/src/prompts/advisor-round3.md +24 -0
  17. package/src/prompts/coder.md +1 -0
  18. package/src/prompts/discipline.md +15 -1
  19. package/src/prompts/explore.md +2 -0
  20. package/src/prompts/plan.md +2 -0
  21. package/src/prompts/system.md +5 -1
  22. package/src/provider/anthropic.mjs +4 -4
  23. package/src/provider/core.mjs +6 -126
  24. package/src/provider/google.mjs +4 -2
  25. package/src/provider/sse.mjs +112 -0
  26. package/src/tools/bash.md +8 -0
  27. package/src/tools/codemode.mjs +5 -16
  28. package/src/tools/edit.md +8 -0
  29. package/src/tools/git.mjs +9 -6
  30. package/src/tools/read.md +7 -0
  31. package/src/tools/shared.mjs +16 -0
  32. package/src/tools/system.mjs +14 -10
  33. package/src/tools/web.mjs +21 -16
  34. package/src/tui/agent-turn.mjs +76 -64
  35. package/src/tui/cmd-advisor.mjs +119 -18
  36. package/src/tui/index.mjs +7 -187
  37. package/src/tui/key-handler.mjs +8 -2
  38. package/src/tui/layout.mjs +1 -1
  39. package/src/tui/render-conversation.mjs +92 -0
  40. package/src/tui/render-frame.mjs +27 -103
  41. package/src/tui/render-loop.mjs +181 -0
@@ -1,56 +1,157 @@
1
- /** /advisor command: toggle advisor on/off, select model.
2
- * ctx: { agent, showPicker, pushLine } */
1
+ /** /advisor command: toggle advisor on/off, select model, configure thinking.
2
+ * ctx: { agent, showPicker, pushLine, persistRaw } */
3
3
  import { C } from "./ansi.mjs"
4
4
 
5
5
  export async function handleAdvisorCommand(ctx) {
6
6
  const { agent, showPicker, pushLine } = ctx
7
7
  const cfg = agent.config.advisor ??= {}
8
8
  const enabled = cfg.enabled === true
9
- const curProvider = cfg.provider || agent.activeProvider
9
+ const curProvider = cfg.provider || "(main)"
10
10
  const curModel = cfg.model || agent.provider.model
11
+ const thinkInfo = cfg.thinking === null ? "off"
12
+ : cfg.thinking?.type === "disabled" ? "off"
13
+ : cfg.reasoningEffort ? `on (${cfg.reasoningEffort})`
14
+ : cfg.thinking ? `on (${cfg.thinking.type})` : "(main)"
11
15
 
12
16
  const entries = [
13
17
  { type: "item", text: `Advisor: ${enabled ? "ON" : "OFF"}`, action: "toggle" },
14
- { type: "item", text: `Model: ${curProvider}/${curModel}`, action: "model" },
18
+ { type: "item", text: `Model: ${curModel}`, action: "model", note: `Provider: ${curProvider}` },
19
+ { type: "item", text: `Thinking: ${thinkInfo}`, action: "thinking" },
15
20
  ]
16
21
 
17
22
  const e = await showPicker("Advisor", entries)
18
23
  if (!e) return
24
+
25
+ const persist = async () => {
26
+ if (ctx.persistRaw) {
27
+ await ctx.persistRaw((raw) => {
28
+ raw.agent ??= {}
29
+ raw.agent.advisor = cfg
30
+ })
31
+ }
32
+ }
33
+
19
34
  if (e.action === "toggle") {
20
35
  cfg.enabled = !cfg.enabled
21
36
  agent._pendingReminders = agent._pendingReminders ?? []
22
37
  if (cfg.enabled) {
23
- agent._pendingReminders.push("[System reminder: Advisor review is now ON. After each turn your output will be reviewed, and observations may be injected as system reminders. Treat them critically they are observations, not commands.]")
38
+ agent._pendingReminders.push("[System reminder: Advisor review is now ON. You can call the `advisor` tool to get an independent code review before finalising your work. The advisor is an independent read-only sub-agent that explores the codebase, runs git diff, reads files, and traces callers via grep/lsp.]")
24
39
  } else {
25
- agent._pendingReminders.push("[System reminder: Advisor review is now OFF. Future turns will not be reviewed automatically.]")
40
+ agent._pendingReminders.push("[System reminder: Advisor review is now OFF. The `advisor` tool will not produce results.]")
26
41
  }
42
+ await persist().catch(err => pushLine(`[error] Advisor toggle: ${err.message}`, C.error))
27
43
  } else if (e.action === "model") {
28
- await openAdvisorModelPicker(ctx).catch(err => pushLine(`[error] ${err.message}`, C.error))
44
+ await openAdvisorModelPicker(ctx, persist).catch(err => pushLine(`[error] ${err.message}`, C.error))
45
+ } else if (e.action === "thinking") {
46
+ await openAdvisorThinkingPicker(ctx, persist).catch(err => pushLine(`[error] ${err.message}`, C.error))
29
47
  }
30
48
  }
31
49
 
32
- async function openAdvisorModelPicker(ctx) {
50
+ async function openAdvisorModelPicker(ctx, persist) {
33
51
  const { agent, showPicker, pushLine } = ctx
34
52
  const providers = agent.providers || []
53
+ const cfg = agent.config.advisor ??= {}
35
54
 
36
- // Build flat list: each provider's name + a "use current model" entry
37
- const entries = []
55
+ const entries = [
56
+ { type: "item", text: "Use main model", action: "inherit", marker: !cfg.provider ? "●" : "" },
57
+ ]
38
58
  for (const p of providers) {
39
- const mark = p.name === agent.activeProvider ? "* " : " "
40
- entries.push({ type: "item", text: `${mark}${p.name} ${p.baseURL}`, action: "set_provider", provider: p.name, model: p.model })
59
+ entries.push({ type: "header", text: p.name, note: `${p.baseURL}${agent.activeProvider === p.name ? " ← active" : ""} loading…` })
60
+ const mark = cfg.provider === p.name && cfg.model === p.model ? "" : " "
61
+ entries.push({ type: "item", text: `${mark}${p.model}`, action: "switch", provider: p.name, model: p.model })
41
62
  }
42
63
 
64
+ // Fetch models first, then show picker
65
+ await fetchAdvisorModels(entries, providers, agent)
66
+
43
67
  const e = await showPicker("Advisor Model", entries)
44
- if (e?.action !== "set_provider") return
45
- const cfg = agent.config.advisor ??= {}
46
- if (e.provider === agent.activeProvider && e.model === agent.provider.model) {
47
- // Same as main — clear override (use main pool)
68
+ if (!e) return
69
+
70
+ if (e.action === "inherit") {
48
71
  delete cfg.provider
49
72
  delete cfg.model
50
- pushLine(`Advisor: 使用主模型 (${agent.activeProvider}/${agent.provider.model})`, C.dim)
51
- } else {
73
+ pushLine("Advisor: using main model", C.dim)
74
+ } else if (e.action === "switch") {
52
75
  cfg.provider = e.provider
53
76
  cfg.model = e.model
54
77
  pushLine(`Advisor: ${e.provider}/${e.model}`, C.dim)
55
78
  }
79
+ await persist()
80
+ }
81
+
82
+ async function fetchAdvisorModels(entries, providers, agent) {
83
+ const { listModels } = await import("../provider/index.mjs")
84
+ await Promise.all(providers.map(async (p) => {
85
+ try {
86
+ const envKey = { deepseek: "DEEPSEEK_API_KEY", openai: "OPENAI_API_KEY" }[p.name]
87
+ let apiKey = p.apiKey
88
+ if (!apiKey && envKey && process.env[envKey]) apiKey = process.env[envKey]
89
+ if (!apiKey) apiKey = process.env.THINCODER_API_KEY
90
+ const models = await listModels({ baseURL: p.baseURL, apiKey: apiKey ?? "" }, { signal: AbortSignal.timeout(10000) })
91
+ const at = entries.findLastIndex((e) => e.type === "header" && e.text === p.name)
92
+ if (at < 0) return
93
+ entries.splice(at + 2, 0, ...models
94
+ .filter((m) => m !== p.model)
95
+ .map((m) => ({ type: "item", text: ` ${m}`, action: "switch", provider: p.name, model: m })))
96
+ const header = entries[at]
97
+ header.note = `${p.baseURL}${p.apiKey ? "" : " (no key)"}${agent.activeProvider === p.name ? " ← active" : ""}`
98
+ } catch (err) {
99
+ const header = entries.find((e) => e.type === "header" && e.text === p.name)
100
+ if (header) header.note = `${p.baseURL}${p.apiKey ? "" : " (no key)"}${agent.activeProvider === p.name ? " ← active" : ""} (fetch failed: ${err.message.slice(0, 40)})`
101
+ }
102
+ }))
103
+ }
104
+
105
+ async function openAdvisorThinkingPicker(ctx, persist) {
106
+ const { agent, showPicker, pushLine } = ctx
107
+ const { specForModel } = await import("../config.mjs")
108
+ const cfg = agent.config.advisor ??= {}
109
+
110
+ const providerForDefaults = cfg.provider
111
+ ? agent.providers?.find(p => p.name === cfg.provider) || agent.provider
112
+ : agent.provider
113
+ const effectiveModel = cfg.model || providerForDefaults.model
114
+ const spec = specForModel(effectiveModel)
115
+ const thinkOnValue = spec.thinkEnabledValue ?? "enabled"
116
+ const isCustomThink = thinkOnValue !== "enabled"
117
+ const isEffortOnly = spec.thinkApi === "effort"
118
+ const effortLevels = spec.reasoningEffortEnum ?? ["high", "max"]
119
+
120
+ const curEffort = cfg.reasoningEffort ?? providerForDefaults.reasoningEffort
121
+ const curThinking = cfg.thinking ?? providerForDefaults.thinking
122
+ const thinkingEnabled = curThinking?.type === thinkOnValue
123
+ || (curThinking?.type === undefined && !isCustomThink)
124
+
125
+ const entries = [
126
+ { type: "item", text: "Use main model settings", action: "inherit" },
127
+ ]
128
+ if (!isEffortOnly) {
129
+ entries.push({ type: "header", text: "Thinking mode" })
130
+ entries.push({ type: "item", text: `Enabled ${thinkingEnabled ? "← current" : ""}`, action: "think_on" })
131
+ entries.push({ type: "item", text: `Disabled ${curThinking?.type === "disabled" || curThinking === null ? "← current" : ""}`, action: "think_off" })
132
+ }
133
+ entries.push({ type: "header", text: "Reasoning effort" })
134
+ for (const level of effortLevels) {
135
+ entries.push({ type: "item", text: `${level} ${curEffort === level ? "← current" : ""}`, action: `effort_${level}` })
136
+ }
137
+
138
+ const e = await showPicker("Advisor Thinking", entries)
139
+ if (!e) return
140
+
141
+ if (e.action === "inherit") {
142
+ delete cfg.thinking
143
+ delete cfg.reasoningEffort
144
+ pushLine("Advisor: using main model thinking settings", C.dim)
145
+ } else if (e.action === "think_on") {
146
+ cfg.thinking = { type: thinkOnValue }
147
+ if (isEffortOnly) delete cfg.thinking
148
+ pushLine(`Advisor: thinking ON (${thinkOnValue})`, C.dim)
149
+ } else if (e.action === "think_off") {
150
+ cfg.thinking = isCustomThink ? null : { type: "disabled" }
151
+ pushLine("Advisor: thinking OFF", C.dim)
152
+ } else if (e.action.startsWith("effort_")) {
153
+ cfg.reasoningEffort = e.action.slice(7)
154
+ pushLine(`Advisor: reasoning effort = ${cfg.reasoningEffort}`, C.dim)
155
+ }
156
+ await persist()
56
157
  }
package/src/tui/index.mjs CHANGED
@@ -5,7 +5,7 @@
5
5
  *
6
6
  * Large logic blocks extracted to independent modules:
7
7
  * agent-turn.mjs — agent loop + callback construction
8
- * key-handler.mjs keyboard event dispatch
8
+ * render-loop.mjs frame scheduler + incremental panel rendering
9
9
  * startup.mjs — startup screen + session restore + background indexing
10
10
  * interaction.mjs — permission approval + Q&A
11
11
  * pickers.mjs — generic list picker + model picker
@@ -20,15 +20,8 @@ import { emitKeypressEvents } from "node:readline"
20
20
  import { PassThrough } from "node:stream"
21
21
  import { saveSession, archiveCurrent, listSlots } from "../session.mjs"
22
22
  import { closeAllMcp } from "../mcp.mjs"
23
- import { estimateTokens } from "../context.mjs"
24
23
  import { ansi, C } from "./ansi.mjs"
25
- import {
26
- renderFrame, countConvLines, convCacheKey,
27
- renderHeader, renderConversation, renderTodo, renderSubagent,
28
- renderOutput, renderPermission, renderQueue, renderPicker,
29
- renderInputBox, renderStatus,
30
- } from "./render-frame.mjs"
31
- import { computeLayout } from "./layout.mjs"
24
+ import { createRenderLoop } from "./render-loop.mjs"
32
25
  import { SLASH_COMMANDS, SLASH_ALIASES, createSlashCommands } from "./slash-commands.mjs"
33
26
  import { createWizard } from "./wizard.mjs"
34
27
  import { createPickers } from "./pickers.mjs"
@@ -247,184 +240,11 @@ export async function startTUI(agent, opts = {}) {
247
240
 
248
241
  // ---------------------------------------------------------- Render
249
242
 
250
- // Panel cache for incremental rendering: panelName → { y, h, content }
251
- const panelCache = new Map()
252
- let lastCols = 0, lastRows = 0
253
- let lastConvKey = "", lastConvCols = 0, lastConvScroll = -1
254
- const convLineCache = [] // line-level cache for conversation panel (per-line diff)
255
- let renderRequested = false, renderTimer = null, lastRenderAt = 0
256
- const MIN_RENDER_INTERVAL_MS = 16 // ~60fps cap, matching pi-tui
257
-
258
- function scheduleRender() {
259
- if (renderTimer) return
260
- const elapsed = performance.now() - lastRenderAt
261
- const delay = Math.max(0, MIN_RENDER_INTERVAL_MS - elapsed)
262
- renderTimer = setTimeout(() => {
263
- renderTimer = null
264
- if (!renderRequested) return
265
- renderRequested = false
266
- lastRenderAt = performance.now()
267
- doRender()
268
- if (renderRequested) scheduleRender() // more requests arrived during render
269
- }, delay)
270
- }
271
-
272
- /** Rate-limited render entry point. All call sites use this. */
273
- function render() {
274
- if (renderRequested) return
275
- renderRequested = true
276
- // process.nextTick merges multiple synchronous render() calls
277
- // within the same tick into a single scheduleRender call.
278
- process.nextTick(() => scheduleRender())
279
- }
280
-
281
- /** Build ANSI content for a panel at its layout position. Returns null if unchanged. */
282
- function buildPanel(name, panelLayout, lines, cacheKey) {
283
- if (!panelLayout) {
284
- if (panelCache.has(name)) panelCache.delete(name)
285
- return null
286
- }
287
- const content = lines.join("\r\n")
288
- const cached = panelCache.get(name)
289
- const effectiveKey = cacheKey ?? content
290
- if (cached && cached.y === panelLayout.y && cached.h === panelLayout.h && cached.key === effectiveKey) return null
291
- const rows = []
292
- for (let i = 0; i < panelLayout.h; i++) {
293
- rows.push(`\x1b[${panelLayout.y + 1 + i};1H${lines[i] ?? ""}\x1b[K`)
294
- }
295
- panelCache.set(name, { y: panelLayout.y, h: panelLayout.h, key: effectiveKey })
296
- return rows.join("")
297
- }
298
-
299
- /** Detect if panel layout structure changed (appeared/disappeared/shifted).
300
- * Only checks panels that are ALREADY cached — new panels (not yet written)
301
- * are not a structural change; the incremental path will write them naturally. */
302
- function layoutStructureChanged(layout) {
303
- for (const [name, cached] of panelCache) {
304
- const p = layout.panels[name] ?? null
305
- if (p == null) return true // cached panel disappeared → layout changed
306
- if (p.y !== cached.y || p.h !== cached.h) return true // shifted/resized
307
- }
308
- return false
309
- }
310
-
311
- function doRender() {
312
- try {
313
- const dims = { cols: process.stdout.columns || startupCols, rows: process.stdout.rows || startupRows }
314
- const layout = computeLayout(state, dims)
315
- const { W, panels, inputLayout, inputOffset, boxLines, visibleTasks, allSubs, permPreviewLines, overlay } = layout
316
-
317
- // Side effects: clamp scroll + overlay + update ctxCache
318
- const convLines = countConvLines(state, dims.cols)
319
- state.scroll = Math.min(state.scroll, Math.max(0, convLines - panels.conversation.h))
320
- if (overlay && panels.picker) {
321
- const winH = panels.picker.h - 1
322
- if (overlay.selectedLine < overlay.scroll) overlay.scroll = overlay.selectedLine
323
- if (overlay.selectedLine >= overlay.scroll + winH) overlay.scroll = overlay.selectedLine - winH + 1
324
- }
325
- if (state.ctxCache.len !== agent.history.length) {
326
- state.ctxCache = { len: agent.history.length, tokens: estimateTokens(agent.history) }
327
- }
328
-
329
- // 后台更新提示:等 picker/权限确认/提问弹层全部关闭后再弹,不硬抢
330
- // (key-handler 分支顺序 permission → question → picker,任一激活时弹了也摸不到)
331
- if (pendingNoticeReady(state)) {
332
- const notice = state.pendingNotice
333
- state.pendingNotice = null
334
- showUpdateNotice(notice).catch((e) => pushLine(`[error] ${e.message}`, C.error))
335
- }
336
-
337
- // Terminal resize or panel layout shift → full redraw (using legacy renderFrame).
338
- // Don't clear panelCache — update positions so the next incremental check
339
- // sees correct Y/h. Content keys will be stale, forcing a one-time rewrite
340
- // per panel on the next frame (much cheaper than another full redraw).
341
- if (dims.cols !== lastCols || dims.rows !== lastRows || layoutStructureChanged(layout)) {
342
- lastCols = dims.cols; lastRows = dims.rows
343
- // Update cached panel positions (content stays stale → next frame rewrites)
344
- for (const [name, panelLayout] of Object.entries(panels)) {
345
- if (!panelLayout) { panelCache.delete(name); continue }
346
- const cached = panelCache.get(name)
347
- if (cached) { cached.y = panelLayout.y; cached.h = panelLayout.h }
348
- }
349
- const isStreaming = state.processing && !state.permission && !state.question && !state.picker
350
- const isWizard = state.wizard?.step === "provider"
351
- // Content + cursor in a single write. Hardware cursor stays hidden —
352
- // the visual cursor is drawn in the input box as SGR reverse video.
353
- // Position for IME, hide for visual (matching pi-tui).
354
- const { frame, cursorRow, cursorCol } = renderFrame(state, agent, { cols: dims.cols, rows: dims.rows, slashCommands: SLASH_COMMANDS })
355
- if (isStreaming) {
356
- process.stdout.write(ansi.syncUpdateStart + ansi.home + frame + ansi.clearToEnd + ansi.syncUpdateEnd + `\x1b[${cursorRow};${cursorCol}H${ansi.hideCursor}`)
357
- } else if (isWizard) {
358
- process.stdout.write(ansi.syncUpdateStart + ansi.home + frame + ansi.clearToEnd + ansi.syncUpdateEnd + ansi.hideCursor)
359
- } else {
360
- process.stdout.write(ansi.syncUpdateStart + ansi.home + frame + ansi.clearToEnd + ansi.syncUpdateEnd + `\x1b[${cursorRow};${cursorCol}H${ansi.hideCursor}`)
361
- }
362
- return
363
- }
364
-
365
- // ---- Incremental rendering (layout stable) ----
366
- // pi-tui pattern: content inside sync-update block; cursor outside.
367
- // DECSET 2026 buffers all panel writes and renders them atomically.
368
- // Cursor hide/show/position MUST be outside — otherwise the terminal's
369
- // internal cursor state machine and the sync render buffer can disagree.
370
- const out = []
371
- const push = (s) => { if (s != null) out.push(s) }
372
-
373
- // Always-visible panels
374
- push(buildPanel("header", panels.header, [renderHeader(agent, dims.cols)]))
375
- push(buildPanel("status", panels.status, [renderStatus(state, agent, dims.cols, SLASH_COMMANDS)]))
376
- push(buildPanel("inputBox", panels.inputBox, renderInputBox(state, W, boxLines, dims.cols, inputLayout, inputOffset)))
377
-
378
- // Conversation: line-level cache — only push changed lines
379
- const convKey = convCacheKey(state)
380
- const convChanged = convKey !== lastConvKey || dims.cols !== lastConvCols || state.scroll !== lastConvScroll
381
- if (convChanged) {
382
- lastConvKey = convKey; lastConvCols = dims.cols; lastConvScroll = state.scroll
383
- const lines = renderConversation(state, dims.cols, panels.conversation.h, state.scroll)
384
- const y = panels.conversation.y + 1
385
- for (let i = 0; i < lines.length; i++) {
386
- if (lines[i] !== convLineCache[i]) {
387
- out.push(`\x1b[${y + i};1H${lines[i]}\x1b[K`)
388
- convLineCache[i] = lines[i]
389
- }
390
- }
391
- if (convLineCache.length > lines.length) {
392
- for (let i = lines.length; i < convLineCache.length; i++) {
393
- out.push(`\x1b[${y + i};1H\x1b[K`)
394
- }
395
- }
396
- convLineCache.length = lines.length
397
- }
398
-
399
- // Conditional panels
400
- push(buildPanel("todo", panels.todo, renderTodo(visibleTasks, dims.cols)))
401
- push(buildPanel("subagent", panels.subagent, renderSubagent(allSubs, W)))
402
- push(buildPanel("output", panels.output, renderOutput(state, W, panels.output?.h ?? 0)))
403
- push(buildPanel("permission", panels.permission, renderPermission(permPreviewLines)))
404
- if (panels.queue) push(buildPanel("queue", panels.queue, [renderQueue(state, W)]))
405
- else panelCache.delete("queue")
406
- if (panels.picker) push(buildPanel("picker", panels.picker, renderPicker(state, dims.cols, panels.picker, overlay)))
407
- else panelCache.delete("picker")
408
-
409
- // Determine cursor suffix — appended to the same write() as the sync block.
410
- // MUST position the cursor at the input box even when hidden: the terminal's
411
- // cursor position determines where the IME candidate window appears.
412
- // pi-tui's positionHardwareCursor does the same — positions first, then
413
- // decides show/hide based on showHardwareCursor.
414
- // Hardware cursor stays hidden — the visual cursor is drawn in the input
415
- // box text as SGR reverse video (matching pi-tui's approach).
416
- // We still position the hardware cursor for IME candidate window placement.
417
- const cr = panels.inputBox.y + 1 + (inputLayout.cursorLine - inputOffset) + 1
418
- const cc = 3 + inputLayout.cursorCol
419
- const hasOverlay = state.permission || state.question || state.picker || state.wizard?.step === "provider"
420
- const cursorSuffix = hasOverlay ? "" : `\x1b[${cr};${cc}H${ansi.hideCursor}`
421
-
422
- // Single write: sync markers + content + cursor — atomic as far as the terminal is concerned
423
- if (out.length || cursorSuffix) process.stdout.write(ansi.syncUpdateStart + out.join("") + ansi.syncUpdateEnd + cursorSuffix)
424
- } catch (e) {
425
- // Don't let a render error crash the TUI
426
- }
427
- }
243
+ const renderLoop = createRenderLoop(state, agent,
244
+ { startupDims: { cols: startupCols, rows: startupRows }, SLASH_COMMANDS,
245
+ pendingNoticeReady, get showUpdateNotice() { return showUpdateNotice } },
246
+ pushLine)
247
+ const { render, scheduleRender } = renderLoop
428
248
 
429
249
  process.stdout.on("resize", () => {
430
250
  try { render() } catch { /* resize error — ignore */ }
@@ -140,8 +140,14 @@ export function createKeyHandler(ctx) {
140
140
  const msg = (state.interruptPrompt.text ?? "").trim()
141
141
  state.interruptPrompt = null
142
142
  if (msg) {
143
- pushLine(` [inject] ${msg}`, C.warn)
144
- state.controller.abort({ interrupt: true, message: msg })
143
+ // Guard: if the turn already finished while the user was typing, the controller
144
+ // may have been replaced or already aborted — don't abort a live turn by mistake.
145
+ if (state.processing && state.controller && !state.controller.signal.aborted) {
146
+ pushLine(` [inject] ${msg}`, C.warn)
147
+ state.controller.abort({ interrupt: true, message: msg })
148
+ } else {
149
+ pushLine(` [inject — turn ended, message queued] ${msg}`, C.dim)
150
+ }
145
151
  render()
146
152
  }
147
153
  } else if (key.name === "backspace") {
@@ -70,7 +70,7 @@ export function computeLayout(state, { cols, rows }) {
70
70
  : 0
71
71
 
72
72
  // Tool output panels: max 8 lines per panel, capped at reasonable total
73
- const panels = Object.values(state.outputPanels).filter((p) => !p.done)
73
+ const panels = Object.values(state.outputPanels).filter((p) => !p.done || p._pendingDone)
74
74
  const outputPanelsH = panels.length > 0 ? Math.min(panels.length * 8, rows - 10) : 0
75
75
 
76
76
  // Permission preview (height depends on wrapped content)
@@ -0,0 +1,92 @@
1
+ /**
2
+ * render-conversation.mjs — conversation panel line builder
3
+ * Extracted from render-frame.mjs.
4
+ */
5
+ import { ansi, C } from "./ansi.mjs"
6
+ import { formatTables, sanitizeDisplay, wrapText } from "./render.mjs"
7
+
8
+ let _convCache = { key: "", cols: 0, lines: [] }
9
+
10
+ export function convCacheKey(state) {
11
+ const lastLine = state.lines.length > 0 ? state.lines[state.lines.length - 1] : null
12
+ return `${state.lines.length}|${lastLine?.text.length ?? 0}|${state.streaming.length}|${state.reasoning.length}|${Object.keys(state.toolStreams).length}|${state.foldEnabled !== false ? "f" : "u"}`
13
+ }
14
+
15
+ function buildConvLines(state, cols) {
16
+ const key = convCacheKey(state)
17
+ if (_convCache.key === key && _convCache.cols === cols) return _convCache.lines
18
+
19
+ const convLines = []
20
+ for (const l of state.lines) {
21
+ for (const line of formatTables(sanitizeDisplay(l.text), cols - 1)) {
22
+ for (const wrapped of wrapText(line, cols - 1)) {
23
+ convLines.push({ text: wrapped, color: l.color, _foldId: l._foldId })
24
+ }
25
+ }
26
+ }
27
+ if (state.reasoning) {
28
+ for (const wrapped of wrapText(sanitizeDisplay(state.reasoning), cols - 1)) {
29
+ convLines.push({ text: wrapped, color: C.reason })
30
+ }
31
+ }
32
+ if (state.streaming) {
33
+ for (const line of formatTables(sanitizeDisplay(state.streaming), cols - 1)) {
34
+ for (const wrapped of wrapText(line, cols - 1)) {
35
+ convLines.push({ text: wrapped, color: C.text })
36
+ }
37
+ }
38
+ }
39
+ const allStreams = Object.values(state.toolStreams).join("")
40
+ if (allStreams) {
41
+ const tail = sanitizeDisplay(allStreams.slice(-4000))
42
+ for (const wrapped of wrapText(tail, cols - 1)) {
43
+ convLines.push({ text: wrapped, color: C.dim })
44
+ }
45
+ }
46
+
47
+ // Fold long blocks (> 8 consecutive dim lines)
48
+ const FOLD_LINES = 8
49
+ let foldCounter = 0
50
+ const folded = []
51
+ let i = 0
52
+ while (i < convLines.length) {
53
+ const line = convLines[i]
54
+ if (line.color === C.dim) {
55
+ let j = i
56
+ while (j < convLines.length && convLines[j].color === C.dim) j++
57
+ const blockLen = j - i
58
+ if (blockLen > FOLD_LINES) {
59
+ const foldKey = `fold-${foldCounter++}`
60
+ if (state.foldEnabled !== false && !state.expandedBlocks?.has(foldKey)) {
61
+ folded.push(convLines[i])
62
+ if (blockLen > 2) folded.push(convLines[i + 1])
63
+ folded.push({ text: ` … ${blockLen - 2} more lines — Enter to expand`, color: C.fold, _foldToggle: foldKey })
64
+ i = j
65
+ continue
66
+ }
67
+ }
68
+ }
69
+ folded.push(line)
70
+ i++
71
+ }
72
+
73
+ _convCache = { key, cols, lines: folded }
74
+ return folded
75
+ }
76
+
77
+ export function countConvLines(state, cols) {
78
+ return buildConvLines(state, cols).length
79
+ }
80
+
81
+ export function renderConversation(state, cols, visibleH, scroll) {
82
+ const convLines = buildConvLines(state, cols)
83
+ const maxScroll = Math.max(0, convLines.length - visibleH)
84
+ const clamped = Math.min(scroll, maxScroll)
85
+ const end = convLines.length - clamped
86
+ const visible = convLines.slice(Math.max(0, end - visibleH), end)
87
+ const pad = visibleH - visible.length
88
+ const out = []
89
+ for (let p = 0; p < pad; p++) out.push("")
90
+ for (const l of visible) out.push(`${l.color ?? ""}${l.text}${ansi.reset}`)
91
+ return out
92
+ }