thincoder 0.10.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 (64) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/src/advisor.mjs +360 -72
  4. package/src/agent/helpers.mjs +7 -3
  5. package/src/agent/setup.mjs +2 -2
  6. package/src/agent-tools/advisor.mjs +36 -0
  7. package/src/agent-tools/plan.mjs +53 -2
  8. package/src/agent-tools/subagent.mjs +7 -1
  9. package/src/agent-tools/timer.mjs +1 -1
  10. package/src/agent-tools/verify.mjs +1 -0
  11. package/src/agent-tools.mjs +1 -0
  12. package/src/agent.mjs +73 -21
  13. package/src/auto-think.mjs +23 -5
  14. package/src/cli/make-agent.mjs +7 -0
  15. package/src/config.mjs +47 -20
  16. package/src/prompts/advisor-round1.md +23 -0
  17. package/src/prompts/advisor-round2.md +26 -0
  18. package/src/prompts/advisor-round3.md +24 -0
  19. package/src/prompts/coder.md +6 -2
  20. package/src/prompts/discipline.md +23 -6
  21. package/src/prompts/explore.md +2 -0
  22. package/src/prompts/plan.md +2 -0
  23. package/src/prompts/system.md +13 -6
  24. package/src/provider/anthropic.mjs +190 -0
  25. package/src/provider/core.mjs +42 -130
  26. package/src/provider/google.mjs +199 -0
  27. package/src/provider/sse.mjs +112 -0
  28. package/src/proxy.mjs +236 -0
  29. package/src/tools/bash.md +8 -0
  30. package/src/tools/codemode.mjs +5 -16
  31. package/src/tools/edit.md +8 -0
  32. package/src/tools/fetch.md +2 -1
  33. package/src/tools/git.mjs +125 -156
  34. package/src/tools/index.mjs +9 -9
  35. package/src/tools/linter.mjs +46 -32
  36. package/src/tools/read.md +7 -0
  37. package/src/tools/shared.mjs +16 -0
  38. package/src/tools/system.mjs +14 -10
  39. package/src/tools/web.mjs +115 -89
  40. package/src/tools/websearch.md +5 -3
  41. package/src/tui/agent-turn.mjs +86 -75
  42. package/src/tui/cmd-advisor.mjs +138 -49
  43. package/src/tui/cmd-clear.mjs +11 -17
  44. package/src/tui/cmd-config.mjs +226 -142
  45. package/src/tui/cmd-extract.mjs +1 -1
  46. package/src/tui/cmd-fold.mjs +2 -3
  47. package/src/tui/cmd-goal.mjs +58 -27
  48. package/src/tui/cmd-help.mjs +3 -1
  49. package/src/tui/cmd-mcp.mjs +178 -142
  50. package/src/tui/cmd-model.mjs +15 -4
  51. package/src/tui/cmd-new.mjs +7 -13
  52. package/src/tui/cmd-restore.mjs +12 -16
  53. package/src/tui/cmd-session.mjs +28 -32
  54. package/src/tui/cmd-think.mjs +75 -50
  55. package/src/tui/cmd-undo.mjs +19 -23
  56. package/src/tui/cmd-upgrade.mjs +22 -26
  57. package/src/tui/index.mjs +61 -215
  58. package/src/tui/key-handler.mjs +56 -20
  59. package/src/tui/layout.mjs +13 -3
  60. package/src/tui/pickers.mjs +151 -182
  61. package/src/tui/render-conversation.mjs +92 -0
  62. package/src/tui/render-frame.mjs +56 -114
  63. package/src/tui/render-loop.mjs +181 -0
  64. package/src/tui/slash-commands.mjs +26 -16
@@ -1,19 +1,87 @@
1
+ import { C } from "./ansi.mjs"
2
+
1
3
  /** /think command: toggle thinking mode, set reasoning effort.
2
4
  * Extracted from slash-commands.mjs.
3
- * ctx: { agent, openPicker, syncProviderField } */
4
- export async function handleThinkCommand(ctx) {
5
- const { agent, openPicker, syncProviderField } = ctx
5
+ * ctx: { agent, showPicker, syncProviderField, pushLine } */
6
+ export async function handleThinkCommand(ctx, args = []) {
7
+ const { agent, showPicker, syncProviderField, pushLine } = ctx
6
8
  const cur = agent.provider
7
9
  const { specForModel } = await import("../config.mjs")
8
10
  const spec = specForModel(cur.model)
9
11
  const isEffortOnly = spec.thinkApi === "effort"
10
- const thinkOnValue = spec.thinkOnValue ?? "enabled"
12
+ const thinkOnValue = spec.thinkEnabledValue ?? "enabled"
11
13
  const isCustomThink = thinkOnValue !== "enabled"
14
+ const effortLevels = spec.reasoningEffortEnum ?? ["high", "max"]
15
+
16
+ async function apply(e) {
17
+ if (e.action === "auto") {
18
+ const cfg = agent.config.agent ??= {}
19
+ cfg.autoThink = !cfg.autoThink
20
+ agent._pendingReminders = agent._pendingReminders ?? []
21
+ if (cfg.autoThink) {
22
+ // Turn off manual effort — auto will set it per-turn
23
+ delete cur.reasoningEffort
24
+ await syncProviderField("reasoningEffort", undefined)
25
+ agent._pendingReminders.push("[System reminder: Auto-think is now ON. Reasoning effort will be automatically set per-task based on difficulty classification.]")
26
+ } else {
27
+ agent._pendingReminders.push("[System reminder: Auto-think is now OFF. Reasoning effort will remain at its current manual setting.]")
28
+ }
29
+ } else if (e.action === "effort") {
30
+ cur.reasoningEffort = e.level
31
+ await syncProviderField("reasoningEffort", e.level)
32
+ } else {
33
+ const enable = e.action === "on"
34
+ if (isEffortOnly) {
35
+ if (!enable) delete cur.reasoningEffort
36
+ else if (!cur.reasoningEffort) cur.reasoningEffort = "high"
37
+ if (!enable) await syncProviderField("reasoningEffort", undefined)
38
+ else await syncProviderField("reasoningEffort", cur.reasoningEffort)
39
+ } else {
40
+ if (enable) {
41
+ cur.thinking = { type: thinkOnValue }
42
+ if (!cur.reasoningEffort) cur.reasoningEffort = "high"
43
+ } else {
44
+ // Custom-think models (MiniMax "adaptive") don't support "disabled" — remove the field instead
45
+ cur.thinking = isCustomThink ? undefined : { type: "disabled" }
46
+ delete cur.reasoningEffort
47
+ }
48
+ await syncProviderField("thinking", cur.thinking)
49
+ if (enable) {
50
+ await syncProviderField("reasoningEffort", cur.reasoningEffort)
51
+ } else {
52
+ await syncProviderField("reasoningEffort", undefined)
53
+ }
54
+ }
55
+ }
56
+ }
57
+
58
+ // Direct args: /think on|off │ /think effort <level>
59
+ // autoThink 开启时手动值每轮被覆盖(picker 里也隐藏了开关/effort 项),直参同样拒绝
60
+ const autoThinkEnabled = agent.config?.agent?.autoThink === true
61
+ const sub = args[0]?.toLowerCase()
62
+ if (sub === "on" || sub === "off") {
63
+ if (autoThinkEnabled) { pushLine("Auto-think is ON — manual settings are overridden each turn; turn Auto off first via /think", C.error); return }
64
+ await apply({ action: sub })
65
+ pushLine(`Thinking: ${sub}`, C.dim)
66
+ return
67
+ }
68
+ if (sub === "effort") {
69
+ const level = args[1]?.toLowerCase()
70
+ if (!level || !effortLevels.includes(level)) {
71
+ pushLine(`Usage: /think effort <${effortLevels.join("|")}>`, C.error)
72
+ return
73
+ }
74
+ if (autoThinkEnabled) { pushLine("Auto-think is ON — manual settings are overridden each turn; turn Auto off first via /think", C.error); return }
75
+ await apply({ action: "effort", level })
76
+ pushLine(`Thinking effort: ${level}`, C.dim)
77
+ return
78
+ }
79
+ if (sub) { pushLine("Usage: /think [on|off|effort <level>]", C.error); return }
80
+
12
81
  // "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
82
  const thinkingEnabled = cur.thinking?.type === thinkOnValue || (cur.thinking?.type === undefined && !isCustomThink)
14
83
  const entries = []
15
84
  // Auto-think: classify difficulty per-prompt and auto-set reasoning effort
16
- const autoThinkEnabled = agent.config?.agent?.autoThink === true
17
85
  entries.push({ type: "item", text: `Auto: ${autoThinkEnabled ? "ON" : "OFF"}`, action: "auto" })
18
86
  if (!isEffortOnly) {
19
87
  if (!autoThinkEnabled) entries.push({ type: "item", text: `Thinking: ${thinkingEnabled ? "ON" : "OFF"}`, action: thinkingEnabled ? "off" : "on" })
@@ -27,49 +95,6 @@ export async function handleThinkCommand(ctx) {
27
95
  entries.push({ type: "item", text: "effort: high", action: "effort", level: "high" })
28
96
  entries.push({ type: "item", text: "effort: max", action: "effort", level: "max" })
29
97
  }
30
- openPicker({
31
- title: "Think",
32
- entries,
33
- onSelect: async (e) => {
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") {
47
- cur.reasoningEffort = e.level
48
- await syncProviderField("reasoningEffort", e.level)
49
- } else {
50
- const enable = e.action === "on"
51
- if (isEffortOnly) {
52
- if (!enable) delete cur.reasoningEffort
53
- else if (!cur.reasoningEffort) cur.reasoningEffort = "high"
54
- if (!enable) await syncProviderField("reasoningEffort", undefined)
55
- else await syncProviderField("reasoningEffort", cur.reasoningEffort)
56
- } else {
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
- }
65
- await syncProviderField("thinking", cur.thinking)
66
- if (enable) {
67
- await syncProviderField("reasoningEffort", cur.reasoningEffort)
68
- } else {
69
- await syncProviderField("reasoningEffort", undefined)
70
- }
71
- }
72
- }
73
- },
74
- })
98
+ const e = await showPicker("Think", entries)
99
+ if (e) await apply(e)
75
100
  }
@@ -41,7 +41,7 @@ export function snapshotForUndo(agent, toolName, args, cwd) {
41
41
  }
42
42
 
43
43
  export async function handleUndoCommand(ctx) {
44
- const { agent, pushLine, openPicker } = ctx
44
+ const { agent, pushLine, showPicker } = ctx
45
45
  const stack = agent._undoStack ?? []
46
46
 
47
47
  if (stack.length === 0) {
@@ -65,27 +65,23 @@ export async function handleUndoCommand(ctx) {
65
65
  }),
66
66
  ]
67
67
 
68
- openPicker({
69
- title: "Undo",
70
- entries,
71
- onSelect: async (e) => {
72
- const item = stack[e.idx]
73
- const abs = join(agent.cwd, ...item.path.split("/"))
68
+ const e = await showPicker("Undo", entries)
69
+ if (!e) return
70
+ const item = stack[e.idx]
71
+ const abs = join(agent.cwd, ...item.path.split("/"))
74
72
 
75
- try {
76
- if (item.backup === null) {
77
- // File was created — undo deletes it
78
- if (existsSync(abs)) unlinkSync(abs)
79
- } else {
80
- // File was modified — undo restores original
81
- writeFileSync(abs, item.backup, "utf8")
82
- }
83
- // Remove this and all newer entries (can't undo out of order)
84
- stack.splice(e.idx)
85
- pushLine(`[undo] Reverted: ${item.tool} ${item.path}`, C.tool)
86
- } catch (err) {
87
- pushLine(`[undo] Failed to revert ${item.path}: ${err.message}`, C.error)
88
- }
89
- },
90
- })
73
+ try {
74
+ if (item.backup === null) {
75
+ // File was created — undo deletes it
76
+ if (existsSync(abs)) unlinkSync(abs)
77
+ } else {
78
+ // File was modified — undo restores original
79
+ writeFileSync(abs, item.backup, "utf8")
80
+ }
81
+ // Remove this and all newer entries (can't undo out of order)
82
+ stack.splice(e.idx)
83
+ pushLine(`[undo] Reverted: ${item.tool} ${item.path}`, C.tool)
84
+ } catch (err) {
85
+ pushLine(`[undo] Failed to revert ${item.path}: ${err.message}`, C.error)
86
+ }
91
87
  }
@@ -1,7 +1,9 @@
1
+ import { ansi, C } from "./ansi.mjs"
2
+
1
3
  /** /upgrade command: check for updates and optionally upgrade.
2
- * ctx: { agent, pushLine, pushLabel, openPicker, ansi, C } */
4
+ * ctx: { agent, pushLine, pushLabel, showPicker } */
3
5
  export async function handleUpgradeCommand(ctx) {
4
- const { pushLine, pushLabel, openPicker, ansi, C } = ctx
6
+ const { pushLine, pushLabel, showPicker } = ctx
5
7
  const { checkForUpdate } = await import("../upgrade.mjs")
6
8
  const { readFileSync } = await import("node:fs")
7
9
 
@@ -19,29 +21,23 @@ export async function handleUpgradeCommand(ctx) {
19
21
  return
20
22
  }
21
23
  pushLine(`thincoder ${result.latest} is available (current: ${result.local}).`, C.tool)
22
- openPicker({
23
- title: `Update: ${result.local} ${result.latest}`,
24
- entries: [
25
- { type: "header", text: `New version: ${result.latest}` },
26
- { type: "item", text: "Upgrade now", action: "upgrade" },
27
- { type: "item", text: "Later", action: "later" },
28
- ],
29
- onSelect: async (sel) => {
30
- if (sel.action === "upgrade") {
31
- pushLabel(`❯ Upgrade`, ansi.bold + C.tool)
32
- pushLine(`Upgrading to ${result.latest}...`, C.tool)
33
- const { exec } = await import("node:child_process")
34
- const cp = exec("npm install -g thincoder@latest", { windowsHide: true })
35
- cp.stdout?.on("data", () => {})
36
- cp.stderr?.on("data", () => {})
37
- cp.on("close", (code) => {
38
- if (code === 0) {
39
- pushLine(`✓ Upgraded to ${result.latest}. Restart to apply.`, C.tool)
40
- } else {
41
- pushLine(`✗ Upgrade failed (exit ${code}). Run \`thincoder upgrade\` manually.`, C.error)
42
- }
43
- })
44
- }
45
- },
24
+ const sel = await showPicker(`Update: ${result.local} → ${result.latest}`, [
25
+ { type: "header", text: `New version: ${result.latest}` },
26
+ { type: "item", text: "Upgrade now", action: "upgrade" },
27
+ { type: "item", text: "Later", action: "later" },
28
+ ])
29
+ if (sel?.action !== "upgrade") return
30
+ pushLabel(`❯ Upgrade`, ansi.bold + C.tool)
31
+ pushLine(`Upgrading to ${result.latest}...`, C.tool)
32
+ const { exec } = await import("node:child_process")
33
+ const cp = exec("npm install -g thincoder@latest", { windowsHide: true })
34
+ cp.stdout?.on("data", () => {})
35
+ cp.stderr?.on("data", () => {})
36
+ cp.on("close", (code) => {
37
+ if (code === 0) {
38
+ pushLine(`✓ Upgraded to ${result.latest}. Restart to apply.`, C.tool)
39
+ } else {
40
+ pushLine(`✗ Upgrade failed (exit ${code}). Run \`thincoder upgrade\` manually.`, C.error)
41
+ }
46
42
  })
47
43
  }
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,16 +20,9 @@ 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"
32
- import { SLASH_COMMANDS, createSlashCommands } from "./slash-commands.mjs"
24
+ import { createRenderLoop } from "./render-loop.mjs"
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"
35
28
  import { runDistill as runDistillImpl } from "./distill-cmd.mjs"
@@ -40,6 +33,17 @@ import { createKeyHandler } from "./key-handler.mjs"
40
33
  import { showStartup, backgroundIndex } from "./startup.mjs"
41
34
  import { createConfigHelpers } from "./config-helpers.mjs"
42
35
 
36
+ /** 升级失败提示文案:附 npm 输出尾部(最多 3 行),方便定位失败原因。 */
37
+ export function upgradeFailureText(code, output) {
38
+ const tail = (output ?? "").trimEnd().split("\n").slice(-3).join("\n")
39
+ return `✗ Upgrade failed (exit ${code}). Run \`thincoder upgrade\` manually.${tail ? `\n${tail}` : ""}`
40
+ }
41
+
42
+ /** 后台更新提示可弹出的条件:无任何交互弹层(picker/permission/question)激活。 */
43
+ export function pendingNoticeReady(state) {
44
+ return Boolean(state.pendingNotice && !state.picker && !state.permission && !state.question)
45
+ }
46
+
43
47
  /**
44
48
  * Start the TUI, taking over the terminal until exit.
45
49
  * agent: return value of createAgent
@@ -65,7 +69,9 @@ export async function startTUI(agent, opts = {}) {
65
69
  permission: null, // { name, args, resolve }
66
70
  permissionPreview: [], // content preview lines for permission approval (rendered above input box, without separation)
67
71
  question: null, // { text, options, resolve } — agent question tool callback
68
- picker: null, // model picker { entries, lines, index, scroll, selectedLine }
72
+ picker: null, // active picker (stack top) { title, entries, lines, index, scroll, selectedLine, filter }
73
+ pickerStack: [], // picker 栈:showPicker push,Enter/Esc pop;state.picker 始终指向栈顶
74
+ pendingNotice: null, // 后台更新提示:有 picker 打开时挂起,picker 全部关闭后再弹
69
75
  wizard: null, // first-launch config wizard { step, index, scroll, selectedLine, fields, error, lines }
70
76
  tasks: agent.tasks ?? [], // task list from task tool (progress shown in status bar); carried over on session restore, auto-collapsed when all done
71
77
  tokens: { prompt: 0, completion: 0, cacheHit: 0, cacheMiss: 0, reasoningTokens: 0 }, // cumulative token usage (shown in status bar)
@@ -95,6 +101,11 @@ export async function startTUI(agent, opts = {}) {
95
101
  const keyStream = new PassThrough()
96
102
  let mousePending = "" // incomplete mouse sequence tail spanning chunks
97
103
  let lastRenderedScroll = 0
104
+ // Capture terminal dimensions before raw mode & alt buffer switch.
105
+ // On Windows, process.stdout.columns/rows can briefly return falsy after the mode switch
106
+ // (ConPTY buffer transition), causing the ||80/||24 fallback to produce a cramped initial layout.
107
+ const startupCols = process.stdout.columns || 80
108
+ const startupRows = process.stdout.rows || 24
98
109
  emitKeypressEvents(keyStream)
99
110
  process.stdin.setRawMode(true)
100
111
  process.stdout.write(ansi.altBuffer + ansi.hideCursor + ansi.mouseOn + ansi.bracketedPasteOn)
@@ -229,175 +240,11 @@ export async function startTUI(agent, opts = {}) {
229
240
 
230
241
  // ---------------------------------------------------------- Render
231
242
 
232
- // Panel cache for incremental rendering: panelName → { y, h, content }
233
- const panelCache = new Map()
234
- let lastCols = 0, lastRows = 0
235
- let lastConvKey = "", lastConvCols = 0, lastConvScroll = -1
236
- const convLineCache = [] // line-level cache for conversation panel (per-line diff)
237
- let renderRequested = false, renderTimer = null, lastRenderAt = 0
238
- const MIN_RENDER_INTERVAL_MS = 16 // ~60fps cap, matching pi-tui
239
-
240
- function scheduleRender() {
241
- if (renderTimer) return
242
- const elapsed = performance.now() - lastRenderAt
243
- const delay = Math.max(0, MIN_RENDER_INTERVAL_MS - elapsed)
244
- renderTimer = setTimeout(() => {
245
- renderTimer = null
246
- if (!renderRequested) return
247
- renderRequested = false
248
- lastRenderAt = performance.now()
249
- doRender()
250
- if (renderRequested) scheduleRender() // more requests arrived during render
251
- }, delay)
252
- }
253
-
254
- /** Rate-limited render entry point. All call sites use this. */
255
- function render() {
256
- if (renderRequested) return
257
- renderRequested = true
258
- // process.nextTick merges multiple synchronous render() calls
259
- // within the same tick into a single scheduleRender call.
260
- process.nextTick(() => scheduleRender())
261
- }
262
-
263
- /** Build ANSI content for a panel at its layout position. Returns null if unchanged. */
264
- function buildPanel(name, panelLayout, lines, cacheKey) {
265
- if (!panelLayout) {
266
- if (panelCache.has(name)) panelCache.delete(name)
267
- return null
268
- }
269
- const content = lines.join("\r\n")
270
- const cached = panelCache.get(name)
271
- const effectiveKey = cacheKey ?? content
272
- if (cached && cached.y === panelLayout.y && cached.h === panelLayout.h && cached.key === effectiveKey) return null
273
- const rows = []
274
- for (let i = 0; i < panelLayout.h; i++) {
275
- rows.push(`\x1b[${panelLayout.y + 1 + i};1H${lines[i] ?? ""}\x1b[K`)
276
- }
277
- panelCache.set(name, { y: panelLayout.y, h: panelLayout.h, key: effectiveKey })
278
- return rows.join("")
279
- }
280
-
281
- /** Detect if panel layout structure changed (appeared/disappeared/shifted).
282
- * Only checks panels that are ALREADY cached — new panels (not yet written)
283
- * are not a structural change; the incremental path will write them naturally. */
284
- function layoutStructureChanged(layout) {
285
- for (const [name, cached] of panelCache) {
286
- const p = layout.panels[name] ?? null
287
- if (p == null) return true // cached panel disappeared → layout changed
288
- if (p.y !== cached.y || p.h !== cached.h) return true // shifted/resized
289
- }
290
- return false
291
- }
292
-
293
- function doRender() {
294
- try {
295
- const dims = { cols: process.stdout.columns || 80, rows: process.stdout.rows || 24 }
296
- const layout = computeLayout(state, dims)
297
- const { W, panels, inputLayout, inputOffset, boxLines, visibleTasks, allSubs, permPreviewLines, overlay } = layout
298
-
299
- // Side effects: clamp scroll + overlay + update ctxCache
300
- const convLines = countConvLines(state, dims.cols)
301
- state.scroll = Math.min(state.scroll, Math.max(0, convLines - panels.conversation.h))
302
- if (overlay && panels.picker) {
303
- const winH = panels.picker.h - 1
304
- if (overlay.selectedLine < overlay.scroll) overlay.scroll = overlay.selectedLine
305
- if (overlay.selectedLine >= overlay.scroll + winH) overlay.scroll = overlay.selectedLine - winH + 1
306
- }
307
- if (state.ctxCache.len !== agent.history.length) {
308
- state.ctxCache = { len: agent.history.length, tokens: estimateTokens(agent.history) }
309
- }
310
-
311
- // Terminal resize or panel layout shift → full redraw (using legacy renderFrame).
312
- // Don't clear panelCache — update positions so the next incremental check
313
- // sees correct Y/h. Content keys will be stale, forcing a one-time rewrite
314
- // per panel on the next frame (much cheaper than another full redraw).
315
- if (dims.cols !== lastCols || dims.rows !== lastRows || layoutStructureChanged(layout)) {
316
- lastCols = dims.cols; lastRows = dims.rows
317
- // Update cached panel positions (content stays stale → next frame rewrites)
318
- for (const [name, panelLayout] of Object.entries(panels)) {
319
- if (!panelLayout) { panelCache.delete(name); continue }
320
- const cached = panelCache.get(name)
321
- if (cached) { cached.y = panelLayout.y; cached.h = panelLayout.h }
322
- }
323
- const isStreaming = state.processing && !state.permission && !state.question && !state.picker
324
- const isWizard = state.wizard?.step === "provider"
325
- // Content + cursor in a single write. Hardware cursor stays hidden —
326
- // the visual cursor is drawn in the input box as SGR reverse video.
327
- // Position for IME, hide for visual (matching pi-tui).
328
- if (isStreaming) {
329
- process.stdout.write(ansi.syncUpdateStart + ansi.home + frame + ansi.clearToEnd + ansi.syncUpdateEnd + `\x1b[${cursorRow};${cursorCol}H${ansi.hideCursor}`)
330
- } else if (isWizard) {
331
- process.stdout.write(ansi.syncUpdateStart + ansi.home + frame + ansi.clearToEnd + ansi.syncUpdateEnd + ansi.hideCursor)
332
- } else {
333
- process.stdout.write(ansi.syncUpdateStart + ansi.home + frame + ansi.clearToEnd + ansi.syncUpdateEnd + `\x1b[${cursorRow};${cursorCol}H${ansi.hideCursor}`)
334
- }
335
- return
336
- }
337
-
338
- // ---- Incremental rendering (layout stable) ----
339
- // pi-tui pattern: content inside sync-update block; cursor outside.
340
- // DECSET 2026 buffers all panel writes and renders them atomically.
341
- // Cursor hide/show/position MUST be outside — otherwise the terminal's
342
- // internal cursor state machine and the sync render buffer can disagree.
343
- const out = []
344
- const push = (s) => { if (s != null) out.push(s) }
345
-
346
- // Always-visible panels
347
- push(buildPanel("header", panels.header, [renderHeader(agent, dims.cols)]))
348
- push(buildPanel("status", panels.status, [renderStatus(state, agent, dims.cols, SLASH_COMMANDS)]))
349
- push(buildPanel("inputBox", panels.inputBox, renderInputBox(state, W, boxLines, dims.cols, inputLayout, inputOffset)))
350
-
351
- // Conversation: line-level cache — only push changed lines
352
- const convKey = convCacheKey(state)
353
- const convChanged = convKey !== lastConvKey || dims.cols !== lastConvCols || state.scroll !== lastConvScroll
354
- if (convChanged) {
355
- lastConvKey = convKey; lastConvCols = dims.cols; lastConvScroll = state.scroll
356
- const lines = renderConversation(state, dims.cols, panels.conversation.h, state.scroll)
357
- const y = panels.conversation.y + 1
358
- for (let i = 0; i < lines.length; i++) {
359
- if (lines[i] !== convLineCache[i]) {
360
- out.push(`\x1b[${y + i};1H${lines[i]}\x1b[K`)
361
- convLineCache[i] = lines[i]
362
- }
363
- }
364
- if (convLineCache.length > lines.length) {
365
- for (let i = lines.length; i < convLineCache.length; i++) {
366
- out.push(`\x1b[${y + i};1H\x1b[K`)
367
- }
368
- }
369
- convLineCache.length = lines.length
370
- }
371
-
372
- // Conditional panels
373
- push(buildPanel("todo", panels.todo, renderTodo(visibleTasks, dims.cols)))
374
- push(buildPanel("subagent", panels.subagent, renderSubagent(allSubs, W)))
375
- push(buildPanel("output", panels.output, renderOutput(state, W, panels.output?.h ?? 0)))
376
- push(buildPanel("permission", panels.permission, renderPermission(permPreviewLines)))
377
- if (panels.queue) push(buildPanel("queue", panels.queue, [renderQueue(state, W)]))
378
- else panelCache.delete("queue")
379
- if (panels.picker) push(buildPanel("picker", panels.picker, renderPicker(state, dims.cols, panels.picker, overlay)))
380
- else panelCache.delete("picker")
381
-
382
- // Determine cursor suffix — appended to the same write() as the sync block.
383
- // MUST position the cursor at the input box even when hidden: the terminal's
384
- // cursor position determines where the IME candidate window appears.
385
- // pi-tui's positionHardwareCursor does the same — positions first, then
386
- // decides show/hide based on showHardwareCursor.
387
- // Hardware cursor stays hidden — the visual cursor is drawn in the input
388
- // box text as SGR reverse video (matching pi-tui's approach).
389
- // We still position the hardware cursor for IME candidate window placement.
390
- const cr = panels.inputBox.y + 1 + (inputLayout.cursorLine - inputOffset) + 1
391
- const cc = 3 + inputLayout.cursorCol
392
- const hasOverlay = state.permission || state.question || state.picker || state.wizard?.step === "provider"
393
- const cursorSuffix = hasOverlay ? "" : `\x1b[${cr};${cc}H${ansi.hideCursor}`
394
-
395
- // Single write: sync markers + content + cursor — atomic as far as the terminal is concerned
396
- if (out.length || cursorSuffix) process.stdout.write(ansi.syncUpdateStart + out.join("") + ansi.syncUpdateEnd + cursorSuffix)
397
- } catch (e) {
398
- // Don't let a render error crash the TUI
399
- }
400
- }
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
401
248
 
402
249
  process.stdout.on("resize", () => {
403
250
  try { render() } catch { /* resize error — ignore */ }
@@ -417,11 +264,10 @@ export async function startTUI(agent, opts = {}) {
417
264
  // Slash commands: handled locally, don't enter agent loop
418
265
  if (text.startsWith("/")) {
419
266
  if (state.processing) {
420
- // While processing: read-only commands (switch/view/help) execute directly;
421
- // side-effect commands (clear/new/reindex/extract) are queued
422
- const cmd0 = text.split(/\s+/)[0]
423
- const ALIASES = { "/h": "/help", "/x": "/exit", "/m": "/model", "/p": "/plan", "/t": "/think", "/c": "/clear", "/n": "/new" }
424
- const resolved0 = ALIASES[cmd0] ?? cmd0
267
+ // While processing: allowlisted commands execute directly (they only touch
268
+ // local TUI/agent config, never the in-flight turn); the rest are queued
269
+ const cmd0 = text.split(/\s+/)[0].toLowerCase()
270
+ const resolved0 = SLASH_ALIASES[cmd0] ?? cmd0
425
271
  const safeDuringProcessing = new Set(["/help", "/exit", "/model", "/think", "/config", "/skills", "/mcp", "/goal", "/session"])
426
272
  if (safeDuringProcessing.has(resolved0)) {
427
273
  await handleSlash(text)
@@ -472,7 +318,7 @@ export async function startTUI(agent, opts = {}) {
472
318
  const { persistRaw, syncProviderField, maskKey } = createConfigHelpers(agent)
473
319
 
474
320
  // Model picker + generic picker: implemented in pickers.mjs
475
- const { openPicker, closePicker, renderPickerLines, openModelPicker, setProviderKey } = createPickers({
321
+ const { closePicker, showPicker, popPicker, renderPickerLines, openModelPicker, selectModel, setProviderKey } = createPickers({
476
322
  agent, state, render, ansi, C, pushLine, pushLabel, persistRaw, askQuestion, maskKey,
477
323
  })
478
324
 
@@ -489,9 +335,10 @@ export async function startTUI(agent, opts = {}) {
489
335
  const { handleSlash, completions, handleTab } = createSlashCommands({
490
336
  agent, state, distillOpts,
491
337
  pushLine, pushLabel, render,
492
- openPicker, askQuestion, askPermission,
338
+ showPicker, closePicker, askQuestion, askPermission,
493
339
  persistRaw, syncProviderField, maskKey,
494
340
  openModelPicker: () => openModelPicker(),
341
+ selectModel,
495
342
  setProviderKey,
496
343
  runDistill,
497
344
  exit: () => { cleanup(); setTimeout(() => process.exit(0), 100) },
@@ -503,7 +350,7 @@ export async function startTUI(agent, opts = {}) {
503
350
 
504
351
  // keypress is attached to filtered keyStream: mouse sequences already intercepted and stripped upstream
505
352
  const onKeypress = createKeyHandler({
506
- agent, state, render, closePicker, renderPickerLines,
353
+ agent, state, render, popPicker, renderPickerLines,
507
354
  handleSlash, handleTab, submit, pasteClipboardImage,
508
355
  wizardChooseProvider, wizardSubmitText, cancelWizard, wizardProviderItems,
509
356
  renderWizard, pushLine, cleanup,
@@ -523,6 +370,30 @@ export async function startTUI(agent, opts = {}) {
523
370
  backgroundIndex({ agent, state, render })
524
371
 
525
372
  // Check for updates (non-blocking, after startup screen)
373
+ // 有 picker 打开时不硬抢:挂到 state.pendingNotice,picker 全部关闭后由 doRender 弹出
374
+ const showUpdateNotice = async (result) => {
375
+ const sel = await showPicker(`Update available: ${result.local} → ${result.latest}`, [
376
+ { type: "header", text: `thincoder ${result.latest} is available (current: ${result.local})` },
377
+ { type: "item", text: "Upgrade now", action: "upgrade" },
378
+ { type: "item", text: "Later", action: "later" },
379
+ ])
380
+ if (sel?.action !== "upgrade") return
381
+ pushLabel(`❯ Upgrade`, ansi.bold + C.tool)
382
+ pushLine(`Upgrading to ${result.latest}...`, C.tool)
383
+ const { exec } = await import("node:child_process")
384
+ const cp = exec("npm install -g thincoder@latest", { windowsHide: true })
385
+ let stdout = ""
386
+ cp.stdout?.on("data", (d) => { stdout += d })
387
+ cp.stderr?.on("data", (d) => { stdout += d })
388
+ cp.on("close", (code) => {
389
+ if (code === 0) {
390
+ pushLine(`✓ Upgraded to ${result.latest}. Restart to apply.`, C.tool)
391
+ } else {
392
+ pushLine(upgradeFailureText(code, stdout), C.error)
393
+ }
394
+ render()
395
+ })
396
+ }
526
397
  ;(async () => {
527
398
  try {
528
399
  const { readFileSync } = await import("node:fs")
@@ -535,33 +406,8 @@ export async function startTUI(agent, opts = {}) {
535
406
  pushLine(`Tip: thincoder ${result.latest} is available (run /upgrade later or restart)`, C.dim)
536
407
  render()
537
408
  } else {
538
- openPicker({
539
- title: `Update available: ${result.local} → ${result.latest}`,
540
- entries: [
541
- { type: "header", text: `thincoder ${result.latest} is available (current: ${result.local})` },
542
- { type: "item", text: "Upgrade now", action: "upgrade" },
543
- { type: "item", text: "Later", action: "later" },
544
- ],
545
- onSelect: async (sel) => {
546
- if (sel.action === "upgrade") {
547
- pushLabel(`❯ Upgrade`, ansi.bold + C.tool)
548
- pushLine(`Upgrading to ${result.latest}...`, C.tool)
549
- const { exec } = await import("node:child_process")
550
- const cp = exec("npm install -g thincoder@latest", { windowsHide: true })
551
- let stdout = ""
552
- cp.stdout?.on("data", (d) => { stdout += d })
553
- cp.stderr?.on("data", (d) => { stdout += d })
554
- cp.on("close", (code) => {
555
- if (code === 0) {
556
- pushLine(`✓ Upgraded to ${result.latest}. Restart to apply.`, C.tool)
557
- } else {
558
- pushLine(`✗ Upgrade failed (exit ${code}). Run \`thincoder upgrade\` manually.`, C.error)
559
- }
560
- render()
561
- })
562
- }
563
- },
564
- })
409
+ state.pendingNotice = result
410
+ render()
565
411
  }
566
412
  }
567
413
  } catch { /* network error or timeout — silently skip */ }