thincoder 0.9.0 → 0.11.0

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 (42) hide show
  1. package/README.md +6 -1
  2. package/package.json +1 -1
  3. package/src/cli/make-agent.mjs +7 -0
  4. package/src/config.mjs +46 -19
  5. package/src/context.mjs +18 -0
  6. package/src/prompts/coder.md +5 -2
  7. package/src/prompts/discipline.md +8 -5
  8. package/src/prompts/system.md +8 -5
  9. package/src/provider/anthropic.mjs +190 -0
  10. package/src/provider/core.mjs +36 -4
  11. package/src/provider/google.mjs +197 -0
  12. package/src/proxy.mjs +236 -0
  13. package/src/tools/codemode.mjs +178 -0
  14. package/src/tools/fetch.md +2 -1
  15. package/src/tools/git.mjs +120 -154
  16. package/src/tools/index.mjs +11 -9
  17. package/src/tools/linter.mjs +46 -32
  18. package/src/tools/lsp.mjs +317 -0
  19. package/src/tools/web.mjs +103 -82
  20. package/src/tools/websearch.md +5 -3
  21. package/src/tui/agent-turn.mjs +12 -13
  22. package/src/tui/cmd-advisor.mjs +29 -41
  23. package/src/tui/cmd-clear.mjs +11 -17
  24. package/src/tui/cmd-config.mjs +226 -142
  25. package/src/tui/cmd-extract.mjs +1 -1
  26. package/src/tui/cmd-fold.mjs +2 -3
  27. package/src/tui/cmd-goal.mjs +58 -27
  28. package/src/tui/cmd-help.mjs +3 -1
  29. package/src/tui/cmd-mcp.mjs +178 -142
  30. package/src/tui/cmd-model.mjs +15 -4
  31. package/src/tui/cmd-new.mjs +7 -13
  32. package/src/tui/cmd-restore.mjs +12 -16
  33. package/src/tui/cmd-session.mjs +28 -32
  34. package/src/tui/cmd-think.mjs +75 -50
  35. package/src/tui/cmd-undo.mjs +19 -23
  36. package/src/tui/cmd-upgrade.mjs +22 -26
  37. package/src/tui/index.mjs +64 -38
  38. package/src/tui/key-handler.mjs +48 -18
  39. package/src/tui/layout.mjs +12 -2
  40. package/src/tui/pickers.mjs +151 -182
  41. package/src/tui/render-frame.mjs +29 -11
  42. 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
@@ -29,7 +29,7 @@ import {
29
29
  renderInputBox, renderStatus,
30
30
  } from "./render-frame.mjs"
31
31
  import { computeLayout } from "./layout.mjs"
32
- import { SLASH_COMMANDS, createSlashCommands } from "./slash-commands.mjs"
32
+ import { SLASH_COMMANDS, SLASH_ALIASES, createSlashCommands } from "./slash-commands.mjs"
33
33
  import { createWizard } from "./wizard.mjs"
34
34
  import { createPickers } from "./pickers.mjs"
35
35
  import { runDistill as runDistillImpl } from "./distill-cmd.mjs"
@@ -40,6 +40,17 @@ import { createKeyHandler } from "./key-handler.mjs"
40
40
  import { showStartup, backgroundIndex } from "./startup.mjs"
41
41
  import { createConfigHelpers } from "./config-helpers.mjs"
42
42
 
43
+ /** 升级失败提示文案:附 npm 输出尾部(最多 3 行),方便定位失败原因。 */
44
+ export function upgradeFailureText(code, output) {
45
+ const tail = (output ?? "").trimEnd().split("\n").slice(-3).join("\n")
46
+ return `✗ Upgrade failed (exit ${code}). Run \`thincoder upgrade\` manually.${tail ? `\n${tail}` : ""}`
47
+ }
48
+
49
+ /** 后台更新提示可弹出的条件:无任何交互弹层(picker/permission/question)激活。 */
50
+ export function pendingNoticeReady(state) {
51
+ return Boolean(state.pendingNotice && !state.picker && !state.permission && !state.question)
52
+ }
53
+
43
54
  /**
44
55
  * Start the TUI, taking over the terminal until exit.
45
56
  * agent: return value of createAgent
@@ -65,7 +76,9 @@ export async function startTUI(agent, opts = {}) {
65
76
  permission: null, // { name, args, resolve }
66
77
  permissionPreview: [], // content preview lines for permission approval (rendered above input box, without separation)
67
78
  question: null, // { text, options, resolve } — agent question tool callback
68
- picker: null, // model picker { entries, lines, index, scroll, selectedLine }
79
+ picker: null, // active picker (stack top) { title, entries, lines, index, scroll, selectedLine, filter }
80
+ pickerStack: [], // picker 栈:showPicker push,Enter/Esc pop;state.picker 始终指向栈顶
81
+ pendingNotice: null, // 后台更新提示:有 picker 打开时挂起,picker 全部关闭后再弹
69
82
  wizard: null, // first-launch config wizard { step, index, scroll, selectedLine, fields, error, lines }
70
83
  tasks: agent.tasks ?? [], // task list from task tool (progress shown in status bar); carried over on session restore, auto-collapsed when all done
71
84
  tokens: { prompt: 0, completion: 0, cacheHit: 0, cacheMiss: 0, reasoningTokens: 0 }, // cumulative token usage (shown in status bar)
@@ -95,6 +108,11 @@ export async function startTUI(agent, opts = {}) {
95
108
  const keyStream = new PassThrough()
96
109
  let mousePending = "" // incomplete mouse sequence tail spanning chunks
97
110
  let lastRenderedScroll = 0
111
+ // Capture terminal dimensions before raw mode & alt buffer switch.
112
+ // On Windows, process.stdout.columns/rows can briefly return falsy after the mode switch
113
+ // (ConPTY buffer transition), causing the ||80/||24 fallback to produce a cramped initial layout.
114
+ const startupCols = process.stdout.columns || 80
115
+ const startupRows = process.stdout.rows || 24
98
116
  emitKeypressEvents(keyStream)
99
117
  process.stdin.setRawMode(true)
100
118
  process.stdout.write(ansi.altBuffer + ansi.hideCursor + ansi.mouseOn + ansi.bracketedPasteOn)
@@ -292,7 +310,7 @@ export async function startTUI(agent, opts = {}) {
292
310
 
293
311
  function doRender() {
294
312
  try {
295
- const dims = { cols: process.stdout.columns || 80, rows: process.stdout.rows || 24 }
313
+ const dims = { cols: process.stdout.columns || startupCols, rows: process.stdout.rows || startupRows }
296
314
  const layout = computeLayout(state, dims)
297
315
  const { W, panels, inputLayout, inputOffset, boxLines, visibleTasks, allSubs, permPreviewLines, overlay } = layout
298
316
 
@@ -308,6 +326,14 @@ export async function startTUI(agent, opts = {}) {
308
326
  state.ctxCache = { len: agent.history.length, tokens: estimateTokens(agent.history) }
309
327
  }
310
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
+
311
337
  // Terminal resize or panel layout shift → full redraw (using legacy renderFrame).
312
338
  // Don't clear panelCache — update positions so the next incremental check
313
339
  // sees correct Y/h. Content keys will be stale, forcing a one-time rewrite
@@ -325,6 +351,7 @@ export async function startTUI(agent, opts = {}) {
325
351
  // Content + cursor in a single write. Hardware cursor stays hidden —
326
352
  // the visual cursor is drawn in the input box as SGR reverse video.
327
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 })
328
355
  if (isStreaming) {
329
356
  process.stdout.write(ansi.syncUpdateStart + ansi.home + frame + ansi.clearToEnd + ansi.syncUpdateEnd + `\x1b[${cursorRow};${cursorCol}H${ansi.hideCursor}`)
330
357
  } else if (isWizard) {
@@ -417,11 +444,10 @@ export async function startTUI(agent, opts = {}) {
417
444
  // Slash commands: handled locally, don't enter agent loop
418
445
  if (text.startsWith("/")) {
419
446
  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
447
+ // While processing: allowlisted commands execute directly (they only touch
448
+ // local TUI/agent config, never the in-flight turn); the rest are queued
449
+ const cmd0 = text.split(/\s+/)[0].toLowerCase()
450
+ const resolved0 = SLASH_ALIASES[cmd0] ?? cmd0
425
451
  const safeDuringProcessing = new Set(["/help", "/exit", "/model", "/think", "/config", "/skills", "/mcp", "/goal", "/session"])
426
452
  if (safeDuringProcessing.has(resolved0)) {
427
453
  await handleSlash(text)
@@ -472,7 +498,7 @@ export async function startTUI(agent, opts = {}) {
472
498
  const { persistRaw, syncProviderField, maskKey } = createConfigHelpers(agent)
473
499
 
474
500
  // Model picker + generic picker: implemented in pickers.mjs
475
- const { openPicker, closePicker, renderPickerLines, openModelPicker, setProviderKey } = createPickers({
501
+ const { closePicker, showPicker, popPicker, renderPickerLines, openModelPicker, selectModel, setProviderKey } = createPickers({
476
502
  agent, state, render, ansi, C, pushLine, pushLabel, persistRaw, askQuestion, maskKey,
477
503
  })
478
504
 
@@ -489,9 +515,10 @@ export async function startTUI(agent, opts = {}) {
489
515
  const { handleSlash, completions, handleTab } = createSlashCommands({
490
516
  agent, state, distillOpts,
491
517
  pushLine, pushLabel, render,
492
- openPicker, askQuestion, askPermission,
518
+ showPicker, closePicker, askQuestion, askPermission,
493
519
  persistRaw, syncProviderField, maskKey,
494
520
  openModelPicker: () => openModelPicker(),
521
+ selectModel,
495
522
  setProviderKey,
496
523
  runDistill,
497
524
  exit: () => { cleanup(); setTimeout(() => process.exit(0), 100) },
@@ -503,7 +530,7 @@ export async function startTUI(agent, opts = {}) {
503
530
 
504
531
  // keypress is attached to filtered keyStream: mouse sequences already intercepted and stripped upstream
505
532
  const onKeypress = createKeyHandler({
506
- agent, state, render, closePicker, renderPickerLines,
533
+ agent, state, render, popPicker, renderPickerLines,
507
534
  handleSlash, handleTab, submit, pasteClipboardImage,
508
535
  wizardChooseProvider, wizardSubmitText, cancelWizard, wizardProviderItems,
509
536
  renderWizard, pushLine, cleanup,
@@ -523,6 +550,30 @@ export async function startTUI(agent, opts = {}) {
523
550
  backgroundIndex({ agent, state, render })
524
551
 
525
552
  // Check for updates (non-blocking, after startup screen)
553
+ // 有 picker 打开时不硬抢:挂到 state.pendingNotice,picker 全部关闭后由 doRender 弹出
554
+ const showUpdateNotice = async (result) => {
555
+ const sel = await showPicker(`Update available: ${result.local} → ${result.latest}`, [
556
+ { type: "header", text: `thincoder ${result.latest} is available (current: ${result.local})` },
557
+ { type: "item", text: "Upgrade now", action: "upgrade" },
558
+ { type: "item", text: "Later", action: "later" },
559
+ ])
560
+ if (sel?.action !== "upgrade") return
561
+ pushLabel(`❯ Upgrade`, ansi.bold + C.tool)
562
+ pushLine(`Upgrading to ${result.latest}...`, C.tool)
563
+ const { exec } = await import("node:child_process")
564
+ const cp = exec("npm install -g thincoder@latest", { windowsHide: true })
565
+ let stdout = ""
566
+ cp.stdout?.on("data", (d) => { stdout += d })
567
+ cp.stderr?.on("data", (d) => { stdout += d })
568
+ cp.on("close", (code) => {
569
+ if (code === 0) {
570
+ pushLine(`✓ Upgraded to ${result.latest}. Restart to apply.`, C.tool)
571
+ } else {
572
+ pushLine(upgradeFailureText(code, stdout), C.error)
573
+ }
574
+ render()
575
+ })
576
+ }
526
577
  ;(async () => {
527
578
  try {
528
579
  const { readFileSync } = await import("node:fs")
@@ -535,33 +586,8 @@ export async function startTUI(agent, opts = {}) {
535
586
  pushLine(`Tip: thincoder ${result.latest} is available (run /upgrade later or restart)`, C.dim)
536
587
  render()
537
588
  } 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
- })
589
+ state.pendingNotice = result
590
+ render()
565
591
  }
566
592
  }
567
593
  } catch { /* network error or timeout — silently skip */ }
@@ -1,14 +1,15 @@
1
1
  import { ansi, C } from "./ansi.mjs"
2
2
  import { readClipboardText, insertPastedText } from "./clipboard.mjs"
3
+ import { computeLayout } from "./layout.mjs"
3
4
 
4
5
  /** Keyboard event dispatch: permission confirm / question / picker / wizard / edit / scroll / history / paste.
5
6
  * Extracted from index.mjs.
6
- * ctx: { agent, state, render, renderPickerLines, closePicker,
7
+ * ctx: { agent, state, render, renderPickerLines, popPicker,
7
8
  * handleSlash, handleTab, submit, pasteClipboardImage,
8
9
  * wizardChooseProvider, wizardSubmitText, cancelWizard, wizardProviderItems,
9
10
  * renderWizard, pushLine, cleanup } */
10
11
  export function createKeyHandler(ctx) {
11
- const { agent, state, render, closePicker, renderPickerLines, handleSlash, handleTab, submit, pasteClipboardImage, wizardChooseProvider, wizardSubmitText, cancelWizard, wizardProviderItems, renderWizard, pushLine, cleanup } = ctx
12
+ const { agent, state, render, popPicker, renderPickerLines, handleSlash, handleTab, submit, pasteClipboardImage, wizardChooseProvider, wizardSubmitText, cancelWizard, wizardProviderItems, renderWizard, pushLine, cleanup } = ctx
12
13
 
13
14
  return function onKeypress(str, key = {}) {
14
15
  // permission confirm state: y approve / n deny / a approve + turn ON AUTO (no further prompts)
@@ -104,6 +105,11 @@ export function createKeyHandler(ctx) {
104
105
  }
105
106
 
106
107
  if (key.ctrl && key.name === "c") {
108
+ // picker 打开时 Ctrl+C = 取消当前 picker(等同 Esc),不杀进程
109
+ if (state.picker) {
110
+ popPicker(null)
111
+ return
112
+ }
107
113
  if (state.processing && state.controller) {
108
114
  state.controller.abort()
109
115
  pushLine("[Aborting…]", C.warn)
@@ -111,7 +117,9 @@ export function createKeyHandler(ctx) {
111
117
  return
112
118
  }
113
119
  cleanup()
114
- setTimeout(() => process.exit(0), 100)
120
+ // 延迟退出可注入(测试传大值并清理定时器,避免定时器在 mock 恢复后调到真 process.exit
121
+ ctx.exitTimer = setTimeout(() => process.exit(0), ctx.exitDelay ?? 100)
122
+ ctx.exitTimer.unref?.()
115
123
  }
116
124
 
117
125
  // Ctrl+I (or Tab during processing): interrupt and inject a message
@@ -146,25 +154,47 @@ export function createKeyHandler(ctx) {
146
154
  return
147
155
  }
148
156
 
149
- // generic list picker: ↑↓ move, Enter confirm, Esc cancel
157
+ // generic list picker: ↑↓/PgUp/PgDn/Home/End 导航,输入即过滤,Enter 选中,Esc 取消
150
158
  if (state.picker) {
151
- const items = state.picker?.entries.filter((e) => e.type === "item") ?? []
159
+ const p = state.picker
160
+ const items = p.filteredItems ?? p.entries.filter((e) => e.type === "item")
161
+ // 可视窗高度:直接取 layout 算出的实际 picker 面板高(含小终端 pickerFinalH 压缩),减标题行。
162
+ // 单一数据源,避免与 layout.mjs 公式漂移
163
+ const winH = Math.max(1, (computeLayout(state, { cols: process.stdout.columns || 80, rows: process.stdout.rows || 24 }).panels.picker?.h ?? p.lines.length + 1) - 1)
164
+ const applyFilter = (f) => {
165
+ p.filter = f
166
+ p.index = 0
167
+ p.scroll = 0
168
+ renderPickerLines()
169
+ }
152
170
  if (key.name === "escape") {
153
- closePicker()
171
+ popPicker(null)
154
172
  } else if (key.name === "up" && items.length) {
155
- state.picker.index = (state.picker.index - 1 + items.length) % items.length
173
+ p.index = (p.index - 1 + items.length) % items.length
156
174
  renderPickerLines()
157
175
  } else if (key.name === "down" && items.length) {
158
- state.picker.index = (state.picker.index + 1) % items.length
176
+ p.index = (p.index + 1) % items.length
177
+ renderPickerLines()
178
+ } else if (key.name === "pageup" && items.length) {
179
+ p.index = Math.max(0, p.index - winH)
159
180
  renderPickerLines()
160
- } else if (key.name === "return" && items.length) {
161
- const selected = items[state.picker.index]
162
- const handler = state.picker.onSelect
163
- state.picker = null // close picker first, avoid picker still being present during onSelect render
164
- // onSelect is async (e.g. removing a provider writes file), catch errors so they aren't swallowed
165
- Promise.resolve(handler?.(selected)).catch((err) => {
166
- pushLine(`[error] ${err.message}`, C.error)
167
- }).finally(() => render())
181
+ } else if (key.name === "pagedown" && items.length) {
182
+ p.index = Math.min(items.length - 1, p.index + winH)
183
+ renderPickerLines()
184
+ } else if (key.name === "home" && items.length) {
185
+ p.index = 0
186
+ renderPickerLines()
187
+ } else if (key.name === "end" && items.length) {
188
+ p.index = items.length - 1
189
+ renderPickerLines()
190
+ } else if (key.name === "backspace") {
191
+ if (p.filter) applyFilter(p.filter.slice(0, -1))
192
+ } else if ((key.name === "return" || key.name === "enter" || str === "\r") && items.length) {
193
+ popPicker(items[p.index]) // 选中即关闭
194
+ } else if (str && !key.ctrl && !key.meta) {
195
+ // 输入即过滤;粘贴的多行文本先去换行(与输入框清洗口径一致),仍含控制字符则整段丢弃
196
+ const text = str.replace(/[\r\n]+/g, "")
197
+ if (text && !/[\x00-\x1f\x7f]/.test(text)) applyFilter(p.filter + text)
168
198
  }
169
199
  return
170
200
  }
@@ -184,7 +214,7 @@ export function createKeyHandler(ctx) {
184
214
  } else if (key.name === "down" && items.length) {
185
215
  w.index = (w.index + 1) % items.length
186
216
  renderWizard()
187
- } else if (key.name === "return" && items.length) {
217
+ } else if ((key.name === "return" || key.name === "enter" || str === "\r") && items.length) {
188
218
  wizardChooseProvider(items[w.index])
189
219
  }
190
220
  return
@@ -300,7 +330,7 @@ export function createKeyHandler(ctx) {
300
330
  }
301
331
  return
302
332
  }
303
- if (key.name === "return") {
333
+ if (key.name === "return" || key.name === "enter" || str === "\r") {
304
334
  submit().catch((e) => pushLine(`[error] ${e.message}`, C.error))
305
335
  return
306
336
  }
@@ -92,7 +92,17 @@ export function computeLayout(state, { cols, rows }) {
92
92
 
93
93
  // --- elastic panel: conversation takes remaining space ---
94
94
  const fixedH = headerH + inputBoxH + statusH + pickerH + taskPanelH + subPanelH + outputPanelsH + permPreviewH + queueH
95
- const convH = Math.max(1, rows - fixedH)
95
+ let convH = Math.max(1, rows - fixedH)
96
+
97
+ // 小终端高度补偿(best-effort,不保证总行数 ≤ rows):先压 conversation 到最小 1 行,
98
+ // 仍超出再压 picker 到最小 3 行。极端情况(permission preview + tasks 等同屏)补偿后仍可能
99
+ // 溢出 —— 其余面板不强行裁剪,由渲染层自行截断
100
+ let pickerFinalH = pickerH
101
+ const overflow = fixedH + convH - rows
102
+ if (overflow > 0 && pickerH > 0) {
103
+ pickerFinalH = Math.max(Math.min(3, pickerH), pickerH - overflow)
104
+ convH = Math.max(1, rows - (fixedH - pickerH + pickerFinalH))
105
+ }
96
106
 
97
107
  // --- Y coordinates (0-indexed, +1 when used with ANSI) ---
98
108
  let y = 0
@@ -101,7 +111,7 @@ export function computeLayout(state, { cols, rows }) {
101
111
  const subagent = subPanelH > 0 ? { y, h: subPanelH } : null; y += subPanelH
102
112
  const output = outputPanelsH > 0 ? { y, h: outputPanelsH } : null; y += outputPanelsH
103
113
  const todo = taskPanelH > 0 ? { y, h: taskPanelH } : null; y += taskPanelH
104
- const picker = pickerH > 0 ? { y, h: pickerH } : null; y += pickerH
114
+ const picker = pickerFinalH > 0 ? { y, h: pickerFinalH } : null; y += pickerFinalH
105
115
  const permission = permPreviewH > 0 ? { y, h: permPreviewH } : null; y += permPreviewH
106
116
  const queue = queueH > 0 ? { y, h: queueH } : null; y += queueH
107
117
  const inputBox = { y, h: inputBoxH }; y += inputBoxH