thincoder 0.8.11 → 0.8.13

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 (55) hide show
  1. package/README.md +27 -0
  2. package/bin/thincoder.mjs +115 -0
  3. package/package.json +1 -1
  4. package/src/advisor.mjs +105 -0
  5. package/src/agent/dispatch.mjs +35 -0
  6. package/src/agent/setup.mjs +9 -10
  7. package/src/agent-tools/subagent.mjs +1 -1
  8. package/src/agent-tools/timer.mjs +41 -0
  9. package/src/agent-tools/verify.mjs +165 -56
  10. package/src/agent-tools.mjs +1 -0
  11. package/src/agent.mjs +128 -21
  12. package/src/auto-think.mjs +83 -0
  13. package/src/cli/make-agent.mjs +9 -0
  14. package/src/config.mjs +18 -18
  15. package/src/context.mjs +3 -1
  16. package/src/distill.mjs +19 -4
  17. package/src/embedding.mjs +3 -1
  18. package/src/git/checkpoint.mjs +2 -1
  19. package/src/git/gitmem.mjs +8 -2
  20. package/src/markdown.mjs +1 -1
  21. package/src/mcp/transport-http.mjs +11 -4
  22. package/src/memory/code-index.mjs +2 -2
  23. package/src/memory/code-sync.mjs +92 -35
  24. package/src/memory/core.mjs +10 -1
  25. package/src/memory/docs.mjs +25 -28
  26. package/src/memory/schema.mjs +16 -3
  27. package/src/prompts/coder.md +7 -4
  28. package/src/prompts/discipline.md +47 -15
  29. package/src/prompts/main.md +15 -11
  30. package/src/prompts/system.md +33 -7
  31. package/src/provider/core.mjs +142 -15
  32. package/src/provider/index.mjs +1 -1
  33. package/src/rules.mjs +53 -0
  34. package/src/session.mjs +9 -3
  35. package/src/tools/file.mjs +114 -5
  36. package/src/tools/hashline_edit.md +12 -0
  37. package/src/tools/index.mjs +6 -4
  38. package/src/tools/linter.md +13 -0
  39. package/src/tools/linter.mjs +146 -0
  40. package/src/tools/patch.mjs +7 -3
  41. package/src/tools/read.md +3 -2
  42. package/src/tools/repomap.mjs +19 -10
  43. package/src/tools/shared.mjs +7 -0
  44. package/src/tools/system.mjs +18 -4
  45. package/src/tui/agent-turn.mjs +17 -2
  46. package/src/tui/ansi.mjs +5 -0
  47. package/src/tui/cmd-advisor.mjs +68 -0
  48. package/src/tui/cmd-think.mjs +36 -10
  49. package/src/tui/index.mjs +167 -54
  50. package/src/tui/key-handler.mjs +36 -1
  51. package/src/tui/layout.mjs +6 -4
  52. package/src/tui/pickers.mjs +15 -15
  53. package/src/tui/render-frame.mjs +240 -167
  54. package/src/tui/slash-commands.mjs +3 -0
  55. package/src/tools/repomap-parse.mjs +0 -168
@@ -0,0 +1,68 @@
1
+ /** /advisor command: toggle advisor on/off, select model.
2
+ * ctx: { agent, openPicker, pushLine } */
3
+ import { C } from "./ansi.mjs"
4
+
5
+ export async function handleAdvisorCommand(ctx) {
6
+ const { agent, openPicker, pushLine } = ctx
7
+ const cfg = agent.config.advisor ??= {}
8
+ const enabled = cfg.enabled === true
9
+ const curProvider = cfg.provider || agent.activeProvider
10
+ const curModel = cfg.model || agent.provider.model
11
+
12
+ const entries = [
13
+ { type: "item", text: `Advisor: ${enabled ? "ON" : "OFF"}`, action: "toggle" },
14
+ { type: "item", text: `Model: ${curProvider}/${curModel}`, action: "model" },
15
+ ]
16
+
17
+ openPicker({
18
+ title: "Advisor",
19
+ entries,
20
+ onSelect: async (e) => {
21
+ if (e.action === "toggle") {
22
+ cfg.enabled = !cfg.enabled
23
+ agent._pendingReminders = agent._pendingReminders ?? []
24
+ if (cfg.enabled) {
25
+ agent._pendingReminders.push("[系统提醒: Advisor 审查已开启。每轮操作后,你的输出将被审查,观察结果可能作为系统提醒注入。请批判性参考——这是观察,不是命令。]")
26
+ } else {
27
+ agent._pendingReminders.push("[系统提醒: Advisor 审查已关闭。后续轮次不再自动审查。]")
28
+ }
29
+ } else if (e.action === "model") {
30
+ await openAdvisorModelPicker(ctx).catch(err => pushLine(`[error] ${err.message}`, C.error))
31
+ }
32
+ },
33
+ })
34
+ }
35
+
36
+ async function openAdvisorModelPicker(ctx) {
37
+ const { agent, openPicker, pushLine } = ctx
38
+ const providers = agent.providers || []
39
+
40
+ // Build flat list: each provider's name + a "use current model" entry
41
+ const entries = []
42
+ let idx = 0
43
+ for (const p of providers) {
44
+ const mark = p.name === agent.activeProvider ? "* " : " "
45
+ entries.push({ type: "item", text: `${mark}${p.name} — ${p.baseURL}`, action: "set_provider", provider: p.name, model: p.model })
46
+ idx++
47
+ }
48
+
49
+ openPicker({
50
+ title: "Advisor Model",
51
+ entries,
52
+ onSelect: async (e) => {
53
+ if (e.action === "set_provider") {
54
+ const cfg = agent.config.advisor ??= {}
55
+ if (e.provider === agent.activeProvider && e.model === agent.provider.model) {
56
+ // Same as main — clear override (use main pool)
57
+ delete cfg.provider
58
+ delete cfg.model
59
+ pushLine(`Advisor: 使用主模型 (${agent.activeProvider}/${agent.provider.model})`, C.dim)
60
+ } else {
61
+ cfg.provider = e.provider
62
+ cfg.model = e.model
63
+ pushLine(`Advisor: ${e.provider}/${e.model}`, C.dim)
64
+ }
65
+ }
66
+ },
67
+ })
68
+ }
@@ -4,20 +4,26 @@
4
4
  export async function handleThinkCommand(ctx) {
5
5
  const { agent, openPicker, syncProviderField } = ctx
6
6
  const cur = agent.provider
7
- const thinkingEnabled = cur.thinking?.type === "enabled" || cur.thinking?.type === undefined
8
7
  const { specForModel } = await import("../config.mjs")
9
8
  const spec = specForModel(cur.model)
10
9
  const isEffortOnly = spec.thinkApi === "effort"
10
+ const thinkOnValue = spec.thinkOnValue ?? "enabled"
11
+ const isCustomThink = thinkOnValue !== "enabled"
12
+ // "enabled" when thinking.type matches the model's enabled value, or when thinking is absent and the model is NOT a custom-think model (defaults to on for standard models)
13
+ const thinkingEnabled = cur.thinking?.type === thinkOnValue || (cur.thinking?.type === undefined && !isCustomThink)
11
14
  const entries = []
15
+ // Auto-think: classify difficulty per-prompt and auto-set reasoning effort
16
+ const autoThinkEnabled = agent.config?.agent?.autoThink === true
17
+ entries.push({ type: "item", text: `Auto: ${autoThinkEnabled ? "ON" : "OFF"}`, action: "auto" })
12
18
  if (!isEffortOnly) {
13
- entries.push({ type: "item", text: `Thinking: ${thinkingEnabled ? "ON" : "OFF"}`, action: thinkingEnabled ? "off" : "on" })
19
+ if (!autoThinkEnabled) entries.push({ type: "item", text: `Thinking: ${thinkingEnabled ? "ON" : "OFF"}`, action: thinkingEnabled ? "off" : "on" })
14
20
  }
15
- if (spec.reasoningEffortEnum) {
21
+ if (spec.reasoningEffortEnum && !autoThinkEnabled) {
16
22
  for (const level of spec.reasoningEffortEnum) {
17
23
  const mark = cur.reasoningEffort === level ? "▸ " : " "
18
24
  entries.push({ type: "item", text: `${mark}effort: ${level}`, action: "effort", level })
19
25
  }
20
- } else {
26
+ } else if (!autoThinkEnabled) {
21
27
  entries.push({ type: "item", text: "effort: high", action: "effort", level: "high" })
22
28
  entries.push({ type: "item", text: "effort: max", action: "effort", level: "max" })
23
29
  }
@@ -25,7 +31,19 @@ export async function handleThinkCommand(ctx) {
25
31
  title: "Think",
26
32
  entries,
27
33
  onSelect: async (e) => {
28
- if (e.action === "effort") {
34
+ if (e.action === "auto") {
35
+ const cfg = agent.config.agent ??= {}
36
+ cfg.autoThink = !cfg.autoThink
37
+ agent._pendingReminders = agent._pendingReminders ?? []
38
+ if (cfg.autoThink) {
39
+ // Turn off manual effort — auto will set it per-turn
40
+ delete cur.reasoningEffort
41
+ await syncProviderField("reasoningEffort", undefined)
42
+ agent._pendingReminders.push("[System reminder: Auto-think is now ON. Reasoning effort will be automatically set per-task based on difficulty classification.]")
43
+ } else {
44
+ agent._pendingReminders.push("[System reminder: Auto-think is now OFF. Reasoning effort will remain at its current manual setting.]")
45
+ }
46
+ } else if (e.action === "effort") {
29
47
  cur.reasoningEffort = e.level
30
48
  await syncProviderField("reasoningEffort", e.level)
31
49
  } else {
@@ -36,12 +54,20 @@ export async function handleThinkCommand(ctx) {
36
54
  if (!enable) await syncProviderField("reasoningEffort", undefined)
37
55
  else await syncProviderField("reasoningEffort", cur.reasoningEffort)
38
56
  } else {
39
- cur.thinking = enable ? { type: "enabled" } : { type: "disabled" }
40
- if (!enable) delete cur.reasoningEffort
41
- else if (!cur.reasoningEffort) cur.reasoningEffort = "high"
57
+ if (enable) {
58
+ cur.thinking = { type: thinkOnValue }
59
+ if (!cur.reasoningEffort) cur.reasoningEffort = "high"
60
+ } else {
61
+ // Custom-think models (MiniMax "adaptive") don't support "disabled" — remove the field instead
62
+ cur.thinking = isCustomThink ? undefined : { type: "disabled" }
63
+ delete cur.reasoningEffort
64
+ }
42
65
  await syncProviderField("thinking", cur.thinking)
43
- if (!enable) await syncProviderField("reasoningEffort", undefined)
44
- else await syncProviderField("reasoningEffort", cur.reasoningEffort)
66
+ if (enable) {
67
+ await syncProviderField("reasoningEffort", cur.reasoningEffort)
68
+ } else {
69
+ await syncProviderField("reasoningEffort", undefined)
70
+ }
45
71
  }
46
72
  }
47
73
  },
package/src/tui/index.mjs CHANGED
@@ -22,7 +22,12 @@ import { saveSession, archiveCurrent, listSlots } from "../session.mjs"
22
22
  import { closeAllMcp } from "../mcp.mjs"
23
23
  import { estimateTokens } from "../context.mjs"
24
24
  import { ansi, C } from "./ansi.mjs"
25
- import { renderFrame, countConvLines } from "./render-frame.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"
26
31
  import { computeLayout } from "./layout.mjs"
27
32
  import { SLASH_COMMANDS, createSlashCommands } from "./slash-commands.mjs"
28
33
  import { createWizard } from "./wizard.mjs"
@@ -63,7 +68,7 @@ export async function startTUI(agent, opts = {}) {
63
68
  picker: null, // model picker { entries, lines, index, scroll, selectedLine }
64
69
  wizard: null, // first-launch config wizard { step, index, scroll, selectedLine, fields, error, lines }
65
70
  tasks: agent.tasks ?? [], // task list from task tool (progress shown in status bar); carried over on session restore, auto-collapsed when all done
66
- tokens: { prompt: 0, completion: 0, cacheHit: 0, cacheMiss: 0 }, // cumulative token usage (shown in status bar)
71
+ tokens: { prompt: 0, completion: 0, cacheHit: 0, cacheMiss: 0, reasoningTokens: 0 }, // cumulative token usage (shown in status bar)
67
72
  ctxCache: { len: -1, tokens: 0 }, // context utilization estimate cache (estimateTokens is O(n), only recompute when history grows)
68
73
  reasoning: "", // thinking stream buffer (dimmed display)
69
74
  completion: null, // Tab completion state { candidates, index }
@@ -74,6 +79,7 @@ export async function startTUI(agent, opts = {}) {
74
79
  processingStarted: 0, // current turn start time (status bar timer)
75
80
  status: "Ready",
76
81
  queue: [], // queued messages while processing: [{ text }], auto-dequeued when current turn finishes
82
+ interruptPrompt: null, // Ctrl+I interrupt message input: { text: "" } or null
77
83
  }
78
84
 
79
85
  // On session restore, if all tasks are completed, auto-collapse the todo panel (match runtime behavior)
@@ -190,7 +196,7 @@ export async function startTUI(agent, opts = {}) {
190
196
  // Can't close? fine, process is exiting anyway
191
197
  }
192
198
  process.stdin.setRawMode(false)
193
- process.stdout.write(ansi.mouseOff + ansi.bracketedPasteOff + ansi.mainBuffer + ansi.showCursor + ansi.reset)
199
+ process.stdout.write(ansi.clearScreen + ansi.mouseOff + ansi.bracketedPasteOff + ansi.mainBuffer + ansi.showCursor + ansi.reset)
194
200
  }
195
201
  process.on("exit", cleanup)
196
202
 
@@ -221,66 +227,171 @@ export async function startTUI(agent, opts = {}) {
221
227
 
222
228
  // ---------------------------------------------------------- Render
223
229
 
224
- // Frame dedup + streaming rate limit: skip re-rendering unchanged frames (prevents flicker);
225
- // merge token flood to ~25fps
226
- let lastFrame = ""
227
- let lastCursorRow = -1, lastCursorCol = -1
228
- let renderTimer = null
230
+ // Panel cache for incremental rendering: panelName { y, h, content }
231
+ const panelCache = new Map()
232
+ let lastCols = 0, lastRows = 0
233
+ let lastConvKey = "", lastConvCols = 0, lastConvScroll = -1
234
+ const convLineCache = [] // line-level cache for conversation panel (per-line diff)
235
+ let renderRequested = false, renderTimer = null, lastRenderAt = 0
236
+ const MIN_RENDER_INTERVAL_MS = 16 // ~60fps cap, matching pi-tui
229
237
 
230
238
  function scheduleRender() {
231
239
  if (renderTimer) return
240
+ const elapsed = performance.now() - lastRenderAt
241
+ const delay = Math.max(0, MIN_RENDER_INTERVAL_MS - elapsed)
232
242
  renderTimer = setTimeout(() => {
233
243
  renderTimer = null
234
- render()
235
- }, 40)
244
+ if (!renderRequested) return
245
+ renderRequested = false
246
+ lastRenderAt = performance.now()
247
+ doRender()
248
+ if (renderRequested) scheduleRender() // more requests arrived during render
249
+ }, delay)
236
250
  }
237
251
 
252
+ /** Rate-limited render entry point. All call sites use this. */
238
253
  function render() {
239
- try {
240
- // Side effects: reset scroll + update ctxCache + clamp overlay scroll
241
- // (renderFrame is pure, side effects concentrated here)
242
- const dims = { cols: process.stdout.columns || 80, rows: process.stdout.rows || 24 }
243
- const layout = computeLayout(state, dims)
244
- // clamp conversation scroll
245
- const convLines = countConvLines(state, dims.cols)
246
- const maxScroll = Math.max(0, convLines - layout.panels.conversation.h)
247
- state.scroll = Math.min(state.scroll, maxScroll)
248
- // clamp overlay scroll
249
- const overlay = state.picker ?? state.wizard
250
- if (overlay && layout.panels.picker) {
251
- const winH = layout.panels.picker.h - 1
252
- if (overlay.selectedLine < overlay.scroll) overlay.scroll = overlay.selectedLine
253
- if (overlay.selectedLine >= overlay.scroll + winH) overlay.scroll = overlay.selectedLine - winH + 1
254
+ if (renderRequested) return
255
+ renderRequested = true
256
+ // process.nextTick merges multiple synchronous render() calls
257
+ // within the same tick into a single scheduleRender call.
258
+ process.nextTick(() => scheduleRender())
259
+ }
260
+
261
+ /** Build ANSI content for a panel at its layout position. Returns null if unchanged. */
262
+ function buildPanel(name, panelLayout, lines, cacheKey) {
263
+ if (!panelLayout) {
264
+ if (panelCache.has(name)) panelCache.delete(name)
265
+ return null
254
266
  }
255
- // update ctxCache
256
- if (state.ctxCache.len !== agent.history.length) {
257
- state.ctxCache = { len: agent.history.length, tokens: estimateTokens(agent.history) }
267
+ const content = lines.join("\r\n")
268
+ const cached = panelCache.get(name)
269
+ const effectiveKey = cacheKey ?? content
270
+ if (cached && cached.y === panelLayout.y && cached.h === panelLayout.h && cached.key === effectiveKey) return null
271
+ const rows = []
272
+ for (let i = 0; i < panelLayout.h; i++) {
273
+ rows.push(`\x1b[${panelLayout.y + 1 + i};1H${lines[i] ?? ""}\x1b[K`)
258
274
  }
275
+ panelCache.set(name, { y: panelLayout.y, h: panelLayout.h, key: effectiveKey })
276
+ return rows.join("")
277
+ }
259
278
 
260
- const { frame, cursorRow, cursorCol } = renderFrame(state, agent, {
261
- ...dims,
262
- slashCommands: SLASH_COMMANDS,
263
- })
264
- if (frame !== lastFrame) {
265
- lastFrame = frame
266
- // Single write: home + hide cursor + frame + clear-tail + cursor position — prevents flicker
267
- let out = ansi.home + ansi.hideCursor + frame + ansi.clearToEnd
268
- if (state.permission || state.question || state.picker || state.wizard?.step === "provider") {
269
- out += ansi.hideCursor
270
- } else {
271
- out += `\x1b[${cursorRow};${cursorCol}H${ansi.showCursor}`
272
- lastCursorRow = cursorRow
273
- lastCursorCol = cursorCol
279
+ /** Detect if panel layout structure changed (appeared/disappeared/shifted).
280
+ * Only checks panels that are ALREADY cached — new panels (not yet written)
281
+ * are not a structural change; the incremental path will write them naturally. */
282
+ function layoutStructureChanged(layout) {
283
+ for (const [name, cached] of panelCache) {
284
+ const p = layout.panels[name] ?? null
285
+ if (p == null) return true // cached panel disappeared layout changed
286
+ if (p.y !== cached.y || p.h !== cached.h) return true // shifted/resized
287
+ }
288
+ return false
289
+ }
290
+
291
+ function doRender() {
292
+ try {
293
+ const dims = { cols: process.stdout.columns || 80, rows: process.stdout.rows || 24 }
294
+ const layout = computeLayout(state, dims)
295
+ const { W, panels, inputLayout, inputOffset, boxLines, visibleTasks, allSubs, permPreviewLines, overlay } = layout
296
+
297
+ // Side effects: clamp scroll + overlay + update ctxCache
298
+ const convLines = countConvLines(state, dims.cols)
299
+ state.scroll = Math.min(state.scroll, Math.max(0, convLines - panels.conversation.h))
300
+ if (overlay && panels.picker) {
301
+ const winH = panels.picker.h - 1
302
+ if (overlay.selectedLine < overlay.scroll) overlay.scroll = overlay.selectedLine
303
+ if (overlay.selectedLine >= overlay.scroll + winH) overlay.scroll = overlay.selectedLine - winH + 1
274
304
  }
275
- process.stdout.write(out)
276
- } else if (cursorRow !== lastCursorRow || cursorCol !== lastCursorCol) {
277
- // Frame unchanged but cursor moved (e.g. arrow keys) — only reposition cursor
278
- lastCursorRow = cursorRow
279
- lastCursorCol = cursorCol
280
- if (!(state.permission || state.question || state.picker || state.wizard?.step === "provider")) {
281
- process.stdout.write(`\x1b[${cursorRow};${cursorCol}H${ansi.showCursor}`)
305
+ if (state.ctxCache.len !== agent.history.length) {
306
+ state.ctxCache = { len: agent.history.length, tokens: estimateTokens(agent.history) }
282
307
  }
283
- }
308
+
309
+ // Terminal resize or panel layout shift → full redraw (using legacy renderFrame).
310
+ // Don't clear panelCache — update positions so the next incremental check
311
+ // sees correct Y/h. Content keys will be stale, forcing a one-time rewrite
312
+ // per panel on the next frame (much cheaper than another full redraw).
313
+ if (dims.cols !== lastCols || dims.rows !== lastRows || layoutStructureChanged(layout)) {
314
+ lastCols = dims.cols; lastRows = dims.rows
315
+ // Update cached panel positions (content stays stale → next frame rewrites)
316
+ for (const [name, panelLayout] of Object.entries(panels)) {
317
+ if (!panelLayout) { panelCache.delete(name); continue }
318
+ const cached = panelCache.get(name)
319
+ if (cached) { cached.y = panelLayout.y; cached.h = panelLayout.h }
320
+ }
321
+ const isStreaming = state.processing && !state.permission && !state.question && !state.picker
322
+ const isWizard = state.wizard?.step === "provider"
323
+ // Content + cursor in a single write. Hardware cursor stays hidden —
324
+ // the visual cursor is drawn in the input box as SGR reverse video.
325
+ // Position for IME, hide for visual (matching pi-tui).
326
+ if (isStreaming) {
327
+ process.stdout.write(ansi.syncUpdateStart + ansi.home + frame + ansi.clearToEnd + ansi.syncUpdateEnd + `\x1b[${cursorRow};${cursorCol}H${ansi.hideCursor}`)
328
+ } else if (isWizard) {
329
+ process.stdout.write(ansi.syncUpdateStart + ansi.home + frame + ansi.clearToEnd + ansi.syncUpdateEnd + ansi.hideCursor)
330
+ } else {
331
+ process.stdout.write(ansi.syncUpdateStart + ansi.home + frame + ansi.clearToEnd + ansi.syncUpdateEnd + `\x1b[${cursorRow};${cursorCol}H${ansi.hideCursor}`)
332
+ }
333
+ return
334
+ }
335
+
336
+ // ---- Incremental rendering (layout stable) ----
337
+ // pi-tui pattern: content inside sync-update block; cursor outside.
338
+ // DECSET 2026 buffers all panel writes and renders them atomically.
339
+ // Cursor hide/show/position MUST be outside — otherwise the terminal's
340
+ // internal cursor state machine and the sync render buffer can disagree.
341
+ const out = []
342
+ const push = (s) => { if (s != null) out.push(s) }
343
+
344
+ // Always-visible panels
345
+ push(buildPanel("header", panels.header, [renderHeader(agent, dims.cols)]))
346
+ push(buildPanel("status", panels.status, [renderStatus(state, agent, dims.cols, SLASH_COMMANDS)]))
347
+ push(buildPanel("inputBox", panels.inputBox, renderInputBox(state, W, boxLines, dims.cols, inputLayout, inputOffset)))
348
+
349
+ // Conversation: line-level cache — only push changed lines
350
+ const convKey = convCacheKey(state)
351
+ const convChanged = convKey !== lastConvKey || dims.cols !== lastConvCols || state.scroll !== lastConvScroll
352
+ if (convChanged) {
353
+ lastConvKey = convKey; lastConvCols = dims.cols; lastConvScroll = state.scroll
354
+ const lines = renderConversation(state, dims.cols, panels.conversation.h, state.scroll)
355
+ const y = panels.conversation.y + 1
356
+ for (let i = 0; i < lines.length; i++) {
357
+ if (lines[i] !== convLineCache[i]) {
358
+ out.push(`\x1b[${y + i};1H${lines[i]}\x1b[K`)
359
+ convLineCache[i] = lines[i]
360
+ }
361
+ }
362
+ if (convLineCache.length > lines.length) {
363
+ for (let i = lines.length; i < convLineCache.length; i++) {
364
+ out.push(`\x1b[${y + i};1H\x1b[K`)
365
+ }
366
+ }
367
+ convLineCache.length = lines.length
368
+ }
369
+
370
+ // Conditional panels
371
+ push(buildPanel("todo", panels.todo, renderTodo(visibleTasks, dims.cols)))
372
+ push(buildPanel("subagent", panels.subagent, renderSubagent(allSubs, W)))
373
+ push(buildPanel("output", panels.output, renderOutput(state, W, panels.output?.h ?? 0)))
374
+ push(buildPanel("permission", panels.permission, renderPermission(permPreviewLines)))
375
+ if (panels.queue) push(buildPanel("queue", panels.queue, [renderQueue(state, W)]))
376
+ else panelCache.delete("queue")
377
+ if (panels.picker) push(buildPanel("picker", panels.picker, renderPicker(state, dims.cols, panels.picker, overlay)))
378
+ else panelCache.delete("picker")
379
+
380
+ // Determine cursor suffix — appended to the same write() as the sync block.
381
+ // MUST position the cursor at the input box even when hidden: the terminal's
382
+ // cursor position determines where the IME candidate window appears.
383
+ // pi-tui's positionHardwareCursor does the same — positions first, then
384
+ // decides show/hide based on showHardwareCursor.
385
+ // Hardware cursor stays hidden — the visual cursor is drawn in the input
386
+ // box text as SGR reverse video (matching pi-tui's approach).
387
+ // We still position the hardware cursor for IME candidate window placement.
388
+ const cr = panels.inputBox.y + 1 + (inputLayout.cursorLine - inputOffset) + 1
389
+ const cc = 3 + inputLayout.cursorCol
390
+ const hasOverlay = state.permission || state.question || state.picker || state.wizard?.step === "provider"
391
+ const cursorSuffix = hasOverlay ? "" : `\x1b[${cr};${cc}H${ansi.hideCursor}`
392
+
393
+ // Single write: sync markers + content + cursor — atomic as far as the terminal is concerned
394
+ if (out.length || cursorSuffix) process.stdout.write(ansi.syncUpdateStart + out.join("") + ansi.syncUpdateEnd + cursorSuffix)
284
395
  } catch (e) {
285
396
  // Don't let a render error crash the TUI
286
397
  }
@@ -312,6 +423,7 @@ export async function startTUI(agent, opts = {}) {
312
423
  const safeDuringProcessing = new Set(["/help", "/exit", "/model", "/think", "/config", "/skills", "/mcp", "/goal", "/session"])
313
424
  if (safeDuringProcessing.has(resolved0)) {
314
425
  await handleSlash(text)
426
+ render()
315
427
  } else {
316
428
  state.queue.push({ text })
317
429
  render()
@@ -319,14 +431,15 @@ export async function startTUI(agent, opts = {}) {
319
431
  return
320
432
  }
321
433
  await handleSlash(text)
434
+ render()
322
435
  return
323
436
  }
324
437
 
325
- // While processing: queue for later, don't execute immediately
438
+ // While processing: queue for later, don't execute immediately.
439
+ // The queue panel (renderQueue) already shows pending items — don't also
440
+ // push to the conversation area, or the text scrolls up with streaming tokens.
326
441
  if (state.processing) {
327
442
  state.queue.push({ text })
328
- pushLabel(`❯ You: (queued #${state.queue.length})`, ansi.bold + C.user)
329
- pushLine(text, C.dim)
330
443
  render()
331
444
  return
332
445
  }
@@ -344,7 +457,7 @@ export async function startTUI(agent, opts = {}) {
344
457
 
345
458
  // Agent loop: implemented in agent-turn.mjs
346
459
  const turnCtx = {
347
- agent, state, pushLine, pushLabel, render, scheduleRender, ensureAssistantLabel,
460
+ agent, state, pushLine, pushLabel, render, scheduleRender: render, ensureAssistantLabel,
348
461
  askPermission, askQuestion, handleSlash: null, summarize,
349
462
  get assistantLabeled() { return assistantLabeled },
350
463
  set assistantLabeled(v) { assistantLabeled = v },
@@ -91,7 +91,10 @@ export function createKeyHandler(ctx) {
91
91
  insertPastedText(state, text)
92
92
  render()
93
93
  }
94
- }).catch(() => { q._pasting = false })
94
+ }).catch((e) => {
95
+ q._pasting = false
96
+ console.error(`[tui] clipboard paste failed: ${e.message}`)
97
+ })
95
98
  } else if (str && !key.ctrl && !key.meta) {
96
99
  q.answer = (q.answer ?? "") + str
97
100
  render()
@@ -111,6 +114,38 @@ export function createKeyHandler(ctx) {
111
114
  setTimeout(() => process.exit(0), 100)
112
115
  }
113
116
 
117
+ // Ctrl+I (or Tab during processing): interrupt and inject a message
118
+ if ((key.ctrl && !key.alt && key.name === "i") || (key.name === "tab" && state.processing && !state.interruptPrompt)) {
119
+ if (state.processing && state.controller && !state.interruptPrompt) {
120
+ state.interruptPrompt = { text: "" }
121
+ render()
122
+ }
123
+ return
124
+ }
125
+
126
+ // Interrupt prompt mode: type message, Enter to inject, Esc to cancel
127
+ if (state.interruptPrompt) {
128
+ if (key.name === "escape") {
129
+ state.interruptPrompt = null
130
+ render()
131
+ } else if (key.name === "return") {
132
+ const msg = (state.interruptPrompt.text ?? "").trim()
133
+ state.interruptPrompt = null
134
+ if (msg) {
135
+ pushLine(` [inject] ${msg}`, C.warn)
136
+ state.controller.abort({ interrupt: true, message: msg })
137
+ render()
138
+ }
139
+ } else if (key.name === "backspace") {
140
+ state.interruptPrompt.text = state.interruptPrompt.text.slice(0, -1)
141
+ render()
142
+ } else if (str && !key.ctrl && !key.meta) {
143
+ state.interruptPrompt.text += str.replace(/[\r\n]+/g, "")
144
+ render()
145
+ }
146
+ return
147
+ }
148
+
114
149
  // generic list picker: ↑↓ move, Enter confirm, Esc cancel
115
150
  if (state.picker) {
116
151
  const items = state.picker?.entries.filter((e) => e.type === "item") ?? []
@@ -3,8 +3,8 @@
3
3
  * Computes position and height of each panel from state + terminal dimensions.
4
4
  * Does not modify state — side effects are performed by the caller before rendering.
5
5
  *
6
- * Panel layout (top to bottom):
7
- * header → conversation → subagent → output → todo → permission → queue → input → status
6
+ * header conversation subagent → output → todo → picker → permission → queue → input → status
7
+ * header → conversation → todo → subagent → output → picker → permission → queue → input → status
8
8
  * Fixed panels deducted first, conditional panels allocated by priority, remaining space to conversation.
9
9
  */
10
10
  import { layoutInput, wrapText } from "./render.mjs"
@@ -22,7 +22,9 @@ export function computeLayout(state, { cols, rows }) {
22
22
  const W = Math.max(20, cols - 1)
23
23
 
24
24
  // --- input box ---
25
- const inputLayout = layoutInput(state.input, state.cursor, W - 4)
25
+ const inputBuf = state.interruptPrompt ? [...state.interruptPrompt.text] : state.input
26
+ const inputCursor = state.interruptPrompt ? inputBuf.length : state.cursor
27
+ const inputLayout = layoutInput(inputBuf, inputCursor, W - 4)
26
28
  let inputOffset = 0
27
29
  if (inputLayout.lines.length > MAX_INPUT_LINES) {
28
30
  inputOffset = Math.min(inputLayout.cursorLine, inputLayout.lines.length - MAX_INPUT_LINES)
@@ -98,8 +100,8 @@ export function computeLayout(state, { cols, rows }) {
98
100
  const conversation = { y, h: convH }; y += convH
99
101
  const subagent = subPanelH > 0 ? { y, h: subPanelH } : null; y += subPanelH
100
102
  const output = outputPanelsH > 0 ? { y, h: outputPanelsH } : null; y += outputPanelsH
101
- const picker = pickerH > 0 ? { y, h: pickerH } : null; y += pickerH
102
103
  const todo = taskPanelH > 0 ? { y, h: taskPanelH } : null; y += taskPanelH
104
+ const picker = pickerH > 0 ? { y, h: pickerH } : null; y += pickerH
103
105
  const permission = permPreviewH > 0 ? { y, h: permPreviewH } : null; y += permPreviewH
104
106
  const queue = queueH > 0 ? { y, h: queueH } : null; y += queueH
105
107
  const inputBox = { y, h: inputBoxH }; y += inputBoxH
@@ -185,27 +185,27 @@ export function createPickers(ctx) {
185
185
  openPicker({
186
186
  title: "Add Provider",
187
187
  entries: presetEntries,
188
- onCancel: () => openModelPicker().catch(() => {}),
188
+ onCancel: () => openModelPicker().catch((e) => pushLine(`[error] ${e.message}`, C.error)),
189
189
  onSelect: async (se) => {
190
190
  if (se.kind === "custom") {
191
191
  const name = await askQuestion("Enter provider name:")
192
- if (!name) { openModelPicker().catch(() => {}); return }
193
- if (agent.providers.some((p) => p.name === name)) { openModelPicker().catch(() => {}); return }
192
+ if (!name) { openModelPicker().catch((e) => pushLine(`[error] ${e.message}`, C.error)); return }
193
+ if (agent.providers.some((p) => p.name === name)) { openModelPicker().catch((e) => pushLine(`[error] ${e.message}`, C.error)); return }
194
194
  const baseURLRaw = await askQuestion("Enter baseURL (e.g. https://api.example.com/v1):")
195
- if (!baseURLRaw) { openModelPicker().catch(() => {}); return }
195
+ if (!baseURLRaw) { openModelPicker().catch((e) => pushLine(`[error] ${e.message}`, C.error)); return }
196
196
  const baseURL = baseURLRaw.replace(/\/+$/, "")
197
- if (!/^https?:\/\//.test(baseURL)) { openModelPicker().catch(() => {}); return }
197
+ if (!/^https?:\/\//.test(baseURL)) { openModelPicker().catch((e) => pushLine(`[error] ${e.message}`, C.error)); return }
198
198
  const model = await askQuestion("Enter model name:")
199
- if (!model) { openModelPicker().catch(() => {}); return }
199
+ if (!model) { openModelPicker().catch((e) => pushLine(`[error] ${e.message}`, C.error)); return }
200
200
  agent.providers.push({ name, baseURL, model })
201
201
  await persistRaw((raw) => { raw.providers = agent.providers })
202
202
  const key = await askQuestion(`Enter API key for ${name} (leave empty to skip):`)
203
203
  if (key) { await setProviderKey(name, key) }
204
- openModelPicker().catch(() => {})
204
+ openModelPicker().catch((e) => pushLine(`[error] ${e.message}`, C.error))
205
205
  return
206
206
  }
207
207
  // preset
208
- if (agent.providers.some((p) => p.name === se.name)) { openModelPicker().catch(() => {}); return }
208
+ if (agent.providers.some((p) => p.name === se.name)) { openModelPicker().catch((e) => pushLine(`[error] ${e.message}`, C.error)); return }
209
209
  const preset = PRESETS[se.name]
210
210
  const providerCfg = { name: se.name, baseURL: preset.baseURL, model: preset.model }
211
211
  if (preset.thinking) providerCfg.thinking = preset.thinking
@@ -217,7 +217,7 @@ export function createPickers(ctx) {
217
217
  await persistRaw((raw) => { raw.providers = agent.providers })
218
218
  const presetKey = await askQuestion(`Enter API key for ${se.name} (leave empty to skip):`)
219
219
  if (presetKey) await setProviderKey(se.name, presetKey)
220
- openModelPicker().catch(() => {})
220
+ openModelPicker().catch((e) => pushLine(`[error] ${e.message}`, C.error))
221
221
  },
222
222
  })
223
223
  }
@@ -225,7 +225,7 @@ export function createPickers(ctx) {
225
225
  /** Remove provider (cannot remove the currently active one) */
226
226
  async function removeProviderFlow() {
227
227
  const candidates = agent.providers.filter((p) => p.name !== agent.activeProvider)
228
- if (candidates.length === 0) { openModelPicker().catch(() => {}); return }
228
+ if (candidates.length === 0) { openModelPicker().catch((e) => pushLine(`[error] ${e.message}`, C.error)); return }
229
229
  const removeEntries = [
230
230
  { type: "header", text: "Select provider to remove (current one cannot be removed)" },
231
231
  ...candidates.map((p) => ({ type: "item", text: `${p.name} (${p.model})`, name: p.name })),
@@ -233,12 +233,12 @@ export function createPickers(ctx) {
233
233
  openPicker({
234
234
  title: "Remove Provider",
235
235
  entries: removeEntries,
236
- onCancel: () => openModelPicker().catch(() => {}),
236
+ onCancel: () => openModelPicker().catch((e) => pushLine(`[error] ${e.message}`, C.error)),
237
237
  onSelect: async (se) => {
238
238
  const at = agent.providers.findIndex((p) => p.name === se.name)
239
239
  agent.providers.splice(at, 1)
240
240
  await persistRaw((raw) => { raw.providers = agent.providers })
241
- openModelPicker().catch(() => {})
241
+ openModelPicker().catch((e) => pushLine(`[error] ${e.message}`, C.error))
242
242
  },
243
243
  })
244
244
  }
@@ -256,12 +256,12 @@ export function createPickers(ctx) {
256
256
  openPicker({
257
257
  title: "Configure API Key",
258
258
  entries: keyEntries,
259
- onCancel: () => openModelPicker().catch(() => {}),
259
+ onCancel: () => openModelPicker().catch((e) => pushLine(`[error] ${e.message}`, C.error)),
260
260
  onSelect: async (se) => {
261
261
  const key = await askQuestion(`Enter API key for ${se.name}:`)
262
- if (!key) { openModelPicker().catch(() => {}); return }
262
+ if (!key) { openModelPicker().catch((e) => pushLine(`[error] ${e.message}`, C.error)); return }
263
263
  await setProviderKey(se.name, key)
264
- openModelPicker().catch(() => {})
264
+ openModelPicker().catch((e) => pushLine(`[error] ${e.message}`, C.error))
265
265
  },
266
266
  })
267
267
  }