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,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
@@ -132,8 +140,14 @@ export function createKeyHandler(ctx) {
132
140
  const msg = (state.interruptPrompt.text ?? "").trim()
133
141
  state.interruptPrompt = null
134
142
  if (msg) {
135
- pushLine(` [inject] ${msg}`, C.warn)
136
- state.controller.abort({ interrupt: true, message: msg })
143
+ // Guard: if the turn already finished while the user was typing, the controller
144
+ // may have been replaced or already aborted — don't abort a live turn by mistake.
145
+ if (state.processing && state.controller && !state.controller.signal.aborted) {
146
+ pushLine(` [inject] ${msg}`, C.warn)
147
+ state.controller.abort({ interrupt: true, message: msg })
148
+ } else {
149
+ pushLine(` [inject — turn ended, message queued] ${msg}`, C.dim)
150
+ }
137
151
  render()
138
152
  }
139
153
  } else if (key.name === "backspace") {
@@ -146,25 +160,47 @@ export function createKeyHandler(ctx) {
146
160
  return
147
161
  }
148
162
 
149
- // generic list picker: ↑↓ move, Enter confirm, Esc cancel
163
+ // generic list picker: ↑↓/PgUp/PgDn/Home/End 导航,输入即过滤,Enter 选中,Esc 取消
150
164
  if (state.picker) {
151
- const items = state.picker?.entries.filter((e) => e.type === "item") ?? []
165
+ const p = state.picker
166
+ const items = p.filteredItems ?? p.entries.filter((e) => e.type === "item")
167
+ // 可视窗高度:直接取 layout 算出的实际 picker 面板高(含小终端 pickerFinalH 压缩),减标题行。
168
+ // 单一数据源,避免与 layout.mjs 公式漂移
169
+ 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)
170
+ const applyFilter = (f) => {
171
+ p.filter = f
172
+ p.index = 0
173
+ p.scroll = 0
174
+ renderPickerLines()
175
+ }
152
176
  if (key.name === "escape") {
153
- closePicker()
177
+ popPicker(null)
154
178
  } else if (key.name === "up" && items.length) {
155
- state.picker.index = (state.picker.index - 1 + items.length) % items.length
179
+ p.index = (p.index - 1 + items.length) % items.length
156
180
  renderPickerLines()
157
181
  } else if (key.name === "down" && items.length) {
158
- state.picker.index = (state.picker.index + 1) % items.length
182
+ p.index = (p.index + 1) % items.length
183
+ renderPickerLines()
184
+ } else if (key.name === "pageup" && items.length) {
185
+ p.index = Math.max(0, p.index - winH)
159
186
  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())
187
+ } else if (key.name === "pagedown" && items.length) {
188
+ p.index = Math.min(items.length - 1, p.index + winH)
189
+ renderPickerLines()
190
+ } else if (key.name === "home" && items.length) {
191
+ p.index = 0
192
+ renderPickerLines()
193
+ } else if (key.name === "end" && items.length) {
194
+ p.index = items.length - 1
195
+ renderPickerLines()
196
+ } else if (key.name === "backspace") {
197
+ if (p.filter) applyFilter(p.filter.slice(0, -1))
198
+ } else if ((key.name === "return" || key.name === "enter" || str === "\r") && items.length) {
199
+ popPicker(items[p.index]) // 选中即关闭
200
+ } else if (str && !key.ctrl && !key.meta) {
201
+ // 输入即过滤;粘贴的多行文本先去换行(与输入框清洗口径一致),仍含控制字符则整段丢弃
202
+ const text = str.replace(/[\r\n]+/g, "")
203
+ if (text && !/[\x00-\x1f\x7f]/.test(text)) applyFilter(p.filter + text)
168
204
  }
169
205
  return
170
206
  }
@@ -184,7 +220,7 @@ export function createKeyHandler(ctx) {
184
220
  } else if (key.name === "down" && items.length) {
185
221
  w.index = (w.index + 1) % items.length
186
222
  renderWizard()
187
- } else if (key.name === "return" && items.length) {
223
+ } else if ((key.name === "return" || key.name === "enter" || str === "\r") && items.length) {
188
224
  wizardChooseProvider(items[w.index])
189
225
  }
190
226
  return
@@ -300,7 +336,7 @@ export function createKeyHandler(ctx) {
300
336
  }
301
337
  return
302
338
  }
303
- if (key.name === "return") {
339
+ if (key.name === "return" || key.name === "enter" || str === "\r") {
304
340
  submit().catch((e) => pushLine(`[error] ${e.message}`, C.error))
305
341
  return
306
342
  }
@@ -70,7 +70,7 @@ export function computeLayout(state, { cols, rows }) {
70
70
  : 0
71
71
 
72
72
  // Tool output panels: max 8 lines per panel, capped at reasonable total
73
- const panels = Object.values(state.outputPanels).filter((p) => !p.done)
73
+ const panels = Object.values(state.outputPanels).filter((p) => !p.done || p._pendingDone)
74
74
  const outputPanelsH = panels.length > 0 ? Math.min(panels.length * 8, rows - 10) : 0
75
75
 
76
76
  // Permission preview (height depends on wrapped content)
@@ -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
@@ -1,144 +1,154 @@
1
1
  import { sliceByWidth } from "./render.mjs"
2
2
  import { PROVIDER_PRESETS as PRESETS } from "../config.mjs"
3
3
 
4
- /** Generic list picker + unified model/provider management picker.
5
- * Receives closure dependencies via createPickers(ctx),
6
- * returns { openPicker, closePicker, renderPickerLines, openModelPicker, setProviderKey }. */
4
+ /** Generic list picker + model/provider management.
5
+ * 单一 Promise API:showPicker(title, entries, { defaultIndex }) → Promise<entry|null>。
6
+ * picker 栈:state.pickerStack,state.picker 始终指向栈顶(layout/render/key-handler 都只读 state.picker)。
7
+ * 选中即关闭(Enter = resolve + pop);Esc = pop 当前层并 resolve(null)。菜单循环由调用方 while 重开。 */
7
8
  export function createPickers(ctx) {
8
9
  const { agent, state, render, ansi, C, pushLine, persistRaw, askQuestion, maskKey } = ctx
9
10
 
10
- const pickerItems = () => state.picker?.entries.filter((e) => e.type === "item") ?? []
11
+ state.pickerStack ??= []
11
12
 
12
- /** Open a generic list picker. entries contain { type: "header"|"item", text, note?, ...extra },
13
- * onSelect receives the selected entry (with extra fields forwarded), onCancel called on Esc. */
14
- function openPicker({ title, entries, onSelect, onCancel, defaultIndex = 0 }) {
15
- state.picker = { title, entries, lines: [], index: defaultIndex, scroll: 0, selectedLine: 0, onSelect, onCancel }
16
- renderPickerLines()
13
+ /** 当前 picker 过滤后的 item 列表(filter 大小写不敏感子串匹配,header 不参与) */
14
+ function pickerItems(p) {
15
+ const f = (p.filter ?? "").toLowerCase()
16
+ return p.entries.filter((e) => e.type === "item" && (!f || e.text.toLowerCase().includes(f)))
17
17
  }
18
18
 
19
+ /** 弹出栈顶 picker 并 resolve 其 Promise。返回是否有 picker 被弹出。 */
20
+ function popPicker(value) {
21
+ const p = state.pickerStack.pop()
22
+ if (!p) return false
23
+ state.picker = state.pickerStack.at(-1) ?? null
24
+ if (state.picker) rebuildLines()
25
+ else render()
26
+ p.resolve(value)
27
+ return true
28
+ }
29
+
30
+ /** 关闭所有 picker:清空栈,挂起者全部 resolve(null)。 */
19
31
  function closePicker() {
20
- state.picker?.onCancel?.()
21
- state.picker = null
22
- render()
32
+ while (state.pickerStack.length) popPicker(null)
23
33
  }
24
34
 
25
- /** Rebuild display lines from entries and refresh */
26
- function renderPickerLines() {
35
+ /** 打开 picker,返回选中 entry(Esc/取消 null)。
36
+ * 互斥保护:入栈前把现有挂起 picker 全部 resolve(null),消除 Promise 悬挂。
37
+ * (正常嵌套是先 await 上一层返回再开新的,栈深通常为 1。) */
38
+ function showPicker(title, entries, { defaultIndex = 0 } = {}) {
39
+ closePicker()
40
+ return new Promise((resolve) => {
41
+ const itemCount = entries.filter((e) => e.type === "item").length
42
+ const index = Math.max(0, Math.min(defaultIndex, Math.max(0, itemCount - 1)))
43
+ state.picker = { title, entries, lines: [], index, scroll: 0, selectedLine: 0, filter: "", resolve }
44
+ state.pickerStack.push(state.picker)
45
+ rebuildLines()
46
+ })
47
+ }
48
+
49
+ function rebuildLines() {
27
50
  const p = state.picker
28
51
  if (!p) return
52
+ const items = pickerItems(p)
53
+ p.filteredItems = items
54
+ if (p.index >= items.length) p.index = Math.max(0, items.length - 1)
29
55
  const lines = []
30
- let row = 0
31
- let selectedLine = 0
56
+ let row = 0, selLine = 0
32
57
  for (const e of p.entries) {
33
58
  if (e.type === "header") {
34
59
  lines.push({ text: ` ${e.text}${e.note ? ` ${e.note}` : ""}`, color: ansi.bold + C.tool })
35
60
  } else {
36
- const selected = row === p.index
37
- if (selected) selectedLine = lines.length
61
+ if (!items.includes(e)) continue // filter 滤掉
62
+ const sel = row === p.index
63
+ if (sel) selLine = lines.length
38
64
  const marker = e.marker ? ` ${e.marker}` : ""
39
- lines.push({
40
- text: `${selected ? " ▸ " : " "}${e.text}${marker}`,
41
- color: selected ? ansi.bold + C.text : C.dim,
42
- })
65
+ lines.push({ text: `${sel ? " ▸ " : " "}${e.text}${marker}`, color: sel ? ansi.bold + C.text : C.dim })
43
66
  row++
44
67
  }
45
68
  }
69
+ if (p.filter && items.length === 0) lines.push({ text: " (no match)", color: C.dim })
46
70
  p.lines = lines
47
- p.selectedLine = selectedLine
71
+ p.selectedLine = selLine
48
72
  render()
49
73
  }
50
74
 
51
- // ========== unified picker: model switch + provider management ==========
75
+ function renderPickerLines() { rebuildLines() }
76
+
77
+ // === model picker ===
78
+
79
+ /** entry 唯一标识:异步更新 entries 后按它恢复选中项 */
80
+ function entryKey(e) {
81
+ if (!e) return null
82
+ return e.action === "switch" ? `switch:${e.provider}:${e.model}` : `action:${e.action}`
83
+ }
52
84
 
53
85
  async function openModelPicker() {
54
- const entries = buildModelEntries()
55
- const onSelect = async (e) => {
86
+ // 菜单循环:选中即关闭,子流程结束后重开主菜单;Esc 退出
87
+ for (;;) {
88
+ const entries = buildModelEntries()
89
+ const items = entries.filter((e) => e.type === "item")
90
+ const current = items.findIndex(
91
+ (e) => e.action === "switch" && e.provider === agent.activeProvider && e.model === agent.provider.model)
92
+ const picked = showPicker("Models & Providers", entries, { defaultIndex: Math.max(0, current) })
93
+ // 后台异步拉取各 provider 模型列表,原地更新 entries(不 await,错误仅提示)
94
+ fetchModels(entries).catch((err) => pushLine(`[model] fetch models failed: ${err.message}`, C.error))
95
+ const e = await picked
96
+ if (!e) return
56
97
  if (e.action === "switch") {
57
98
  await selectModel(e).catch((err) => pushLine(`[error] ${err.message}`, C.error))
58
- } else if (e.action === "add") {
59
- await addProviderFlow()
60
- } else if (e.action === "remove") {
61
- await removeProviderFlow()
62
- } else if (e.action === "key") {
63
- await setKeyFlow()
99
+ return
64
100
  }
101
+ if (e.action === "add") await addProviderFlow()
102
+ else if (e.action === "remove") await removeProviderFlow()
103
+ else if (e.action === "key") await setKeyFlow()
65
104
  }
66
- openPicker({ title: "Models & Providers", entries, onSelect })
67
- // default select the currently active model
68
- const current = pickerItems().findIndex(
69
- (e) => e.action === "switch" && e.provider === agent.activeProvider && e.model === agent.provider.model,
70
- )
71
- if (current >= 0) state.picker.index = current
72
- renderPickerLines()
105
+ }
73
106
 
74
- // async fetch remote model list, append under each provider
107
+ /** 后台拉取模型列表并 splice entries;更新时按 entryKey 恢复用户光标下的选中项 */
108
+ async function fetchModels(entries) {
75
109
  const { listModels } = await import("../provider/index.mjs")
76
- await Promise.all(
77
- agent.providers.map(async (p) => {
78
- try {
79
- const envKey = { deepseek: "DEEPSEEK_API_KEY", openai: "OPENAI_API_KEY" }[p.name]
80
- let apiKey = p.apiKey
81
- if (!apiKey && envKey && process.env[envKey]) apiKey = process.env[envKey]
82
- if (!apiKey) apiKey = process.env.THINCODER_API_KEY
83
- const models = await listModels(
84
- { baseURL: p.baseURL, apiKey: apiKey ?? "" },
85
- { signal: AbortSignal.timeout(10000) },
86
- )
87
- // find the model item position for this provider in entries, insert new models after it
88
- const at = entries.findLastIndex(
89
- (e) => e.type === "item" && e.action === "switch" && e.provider === p.name,
90
- )
91
- if (at >= 0) {
92
- entries.splice(
93
- at + 1,
94
- 0,
95
- ...models
96
- .filter((m) => m !== p.model)
97
- .map((m) => ({ type: "item", text: m, action: "switch", provider: p.name, model: m })),
98
- )
99
- }
100
- // update header note to remove "loading..."
101
- const header = entries.find((e) => e.type === "header" && e.text === p.name)
102
- if (header) header.note = `${p.baseURL}${p.apiKey ? "" : " (no key)"}`
103
- } catch (error) {
104
- const header = entries.find((e) => e.type === "header" && e.text === p.name)
105
- if (header) header.note = `${p.baseURL}${p.apiKey ? "" : " (no key)"} (fetch failed: ${sliceByWidth(error.message, 40)})`
106
- }
107
- if (state.picker?.entries === entries) renderPickerLines()
108
- }),
109
- )
110
+ await Promise.all(agent.providers.map(async (p) => {
111
+ let selKey = null
112
+ try {
113
+ const envKey = { deepseek: "DEEPSEEK_API_KEY", openai: "OPENAI_API_KEY" }[p.name]
114
+ let apiKey = p.apiKey
115
+ if (!apiKey && envKey && process.env[envKey]) apiKey = process.env[envKey]
116
+ if (!apiKey) apiKey = process.env.THINCODER_API_KEY
117
+ const models = await listModels({ baseURL: p.baseURL, apiKey: apiKey ?? "" }, { signal: AbortSignal.timeout(10000) })
118
+ if (state.picker?.entries !== entries) return // picker 已关或已换,不再更新
119
+ selKey = entryKey(pickerItems(state.picker)[state.picker.index])
120
+ const at = entries.findLastIndex((e) => e.type === "item" && e.action === "switch" && e.provider === p.name)
121
+ if (at >= 0) entries.splice(at + 1, 0, ...models.filter((m) => m !== p.model).map((m) => ({ type: "item", text: m, action: "switch", provider: p.name, model: m })))
122
+ const header = entries.find((e) => e.type === "header" && e.text === p.name)
123
+ if (header) header.note = `${p.baseURL}${p.apiKey ? "" : " (no key)"}`
124
+ } catch (error) {
125
+ if (state.picker?.entries !== entries) return
126
+ const header = entries.find((e) => e.type === "header" && e.text === p.name)
127
+ if (header) header.note = `${p.baseURL}${p.apiKey ? "" : " (no key)"} (fetch failed: ${sliceByWidth(error.message, 40)})`
128
+ }
129
+ // 异步 splice 会改变光标下的项:按 entry 标识恢复选中,找不到则 clamp 到合法范围
130
+ const pk = state.picker
131
+ const items = pickerItems(pk)
132
+ const restored = selKey ? items.findIndex((e) => entryKey(e) === selKey) : -1
133
+ pk.index = restored >= 0 ? restored : Math.min(pk.index, Math.max(0, items.length - 1))
134
+ rebuildLines()
135
+ }))
110
136
  }
111
137
 
112
- /** Build picker entries: each provider gets a header + model list, management actions at the bottom */
113
138
  function buildModelEntries() {
114
139
  const entries = []
115
140
  for (const p of agent.providers) {
116
141
  const active = p.name === agent.activeProvider
117
- entries.push({
118
- type: "header",
119
- text: p.name,
120
- note: `${p.baseURL}${p.apiKey ? "" : " (no key)"}${active ? " ← current" : ""} loading...`,
121
- })
122
- entries.push({
123
- type: "item",
124
- text: p.model,
125
- action: "switch",
126
- provider: p.name,
127
- model: p.model,
128
- marker: active ? "●" : "",
129
- })
142
+ entries.push({ type: "header", text: p.name, note: `${p.baseURL}${p.apiKey ? "" : " (no key)"}${active ? " ← current" : ""} loading...` })
143
+ entries.push({ type: "item", text: p.model, action: "switch", provider: p.name, model: p.model, marker: active ? "●" : "" })
130
144
  }
131
- // management actions
132
145
  entries.push({ type: "header", text: "Provider Management" })
133
146
  entries.push({ type: "item", text: "Add provider…", action: "add" })
134
- if (agent.providers.length > 1) {
135
- entries.push({ type: "item", text: "Remove provider…", action: "remove" })
136
- }
147
+ if (agent.providers.length > 1) entries.push({ type: "item", text: "Remove provider…", action: "remove" })
137
148
  entries.push({ type: "item", text: "Set / change API key…", action: "key" })
138
149
  return entries
139
150
  }
140
151
 
141
- /** Switch provider + model, persist, threshold follows model */
142
152
  async function selectModel(item) {
143
153
  closePicker()
144
154
  const target = agent.providers.find((pp) => pp.name === item.provider)
@@ -153,11 +163,11 @@ export function createPickers(ctx) {
153
163
  if (!agent.provider.apiKey) agent.provider.apiKey = process.env.THINCODER_API_KEY
154
164
  if (agent.config?.agent?.compactThresholdAuto) {
155
165
  const { resolveCompactThreshold } = await import("../config.mjs")
156
- const { value } = resolveCompactThreshold(null, item.model)
157
- agent.config.agent.compactThreshold = value
166
+ agent.config.agent.compactThreshold = resolveCompactThreshold(null, item.model).value
158
167
  }
159
168
  await persistRaw((raw) => {
160
- raw.providers = agent.providers
169
+ // 落盘前剥离运行时注入的 proxyUri(由 loadConfig + injectProxy 在加载时重建)
170
+ raw.providers = agent.providers.map(({ proxyUri: _, ...p }) => p)
161
171
  raw.activeProvider = item.provider
162
172
  })
163
173
  agent.config.activeProvider = item.provider
@@ -167,106 +177,65 @@ export function createPickers(ctx) {
167
177
  }
168
178
  }
169
179
 
170
- /** Add provider: preset menu → input key → done, or custom step-by-step input */
171
180
  async function addProviderFlow() {
172
- const presetEntries = [
181
+ const entries = [
173
182
  { type: "header", text: "Select a preset provider" },
174
- ...Object.entries(PRESETS)
175
- .filter(([name]) => !agent.providers.some((p) => p.name === name))
176
- .map(([name, p]) => ({
177
- type: "item",
178
- text: `${name.padEnd(10)} ${p.desc ?? ""} (${p.model})`,
179
- name,
180
- kind: "preset",
181
- })),
183
+ ...Object.entries(PRESETS).filter(([name]) => !agent.providers.some((p) => p.name === name))
184
+ .map(([name, p]) => ({ type: "item", text: `${name.padEnd(10)} ${p.desc ?? ""} (${p.model})`, name, kind: "preset" })),
182
185
  { type: "header", text: "Other" },
183
186
  { type: "item", text: "Custom (manual config)", name: "__custom__", kind: "custom" },
184
187
  ]
185
- openPicker({
186
- title: "Add Provider",
187
- entries: presetEntries,
188
- onCancel: () => openModelPicker().catch((e) => pushLine(`[error] ${e.message}`, C.error)),
189
- onSelect: async (se) => {
190
- if (se.kind === "custom") {
191
- const name = await askQuestion("Enter provider name:")
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
- const baseURLRaw = await askQuestion("Enter baseURL (e.g. https://api.example.com/v1):")
195
- if (!baseURLRaw) { openModelPicker().catch((e) => pushLine(`[error] ${e.message}`, C.error)); return }
196
- const baseURL = baseURLRaw.replace(/\/+$/, "")
197
- if (!/^https?:\/\//.test(baseURL)) { openModelPicker().catch((e) => pushLine(`[error] ${e.message}`, C.error)); return }
198
- const model = await askQuestion("Enter model name:")
199
- if (!model) { openModelPicker().catch((e) => pushLine(`[error] ${e.message}`, C.error)); return }
200
- agent.providers.push({ name, baseURL, model })
201
- await persistRaw((raw) => { raw.providers = agent.providers })
202
- const key = await askQuestion(`Enter API key for ${name} (leave empty to skip):`)
203
- if (key) { await setProviderKey(name, key) }
204
- openModelPicker().catch((e) => pushLine(`[error] ${e.message}`, C.error))
205
- return
206
- }
207
- // preset
208
- if (agent.providers.some((p) => p.name === se.name)) { openModelPicker().catch((e) => pushLine(`[error] ${e.message}`, C.error)); return }
209
- const preset = PRESETS[se.name]
210
- const providerCfg = { name: se.name, baseURL: preset.baseURL, model: preset.model }
211
- if (preset.thinking) providerCfg.thinking = preset.thinking
212
- if (preset.reasoningEffort) providerCfg.reasoningEffort = preset.reasoningEffort
213
- if (preset.maxTokens) providerCfg.maxTokens = preset.maxTokens
214
- if (preset.chatPath) providerCfg.chatPath = preset.chatPath
215
- if (preset.desc) providerCfg.desc = preset.desc
216
- agent.providers.push(providerCfg)
217
- await persistRaw((raw) => { raw.providers = agent.providers })
218
- const presetKey = await askQuestion(`Enter API key for ${se.name} (leave empty to skip):`)
219
- if (presetKey) await setProviderKey(se.name, presetKey)
220
- openModelPicker().catch((e) => pushLine(`[error] ${e.message}`, C.error))
221
- },
222
- })
188
+ const se = await showPicker("Add Provider", entries)
189
+ if (!se) return // Esc → 返回上级(openModelPicker 循环会重开主菜单)
190
+ if (se.kind === "custom") {
191
+ const name = await askQuestion("Enter provider name:")
192
+ if (!name) return
193
+ if (agent.providers.some((p) => p.name === name)) return
194
+ const baseURL = (await askQuestion("Enter baseURL:")).replace(/\/+$/, "")
195
+ if (!baseURL) return
196
+ const model = await askQuestion("Enter model name:")
197
+ if (!model) return
198
+ agent.providers.push({ name, baseURL, model })
199
+ await persistRaw((raw) => { raw.providers = agent.providers })
200
+ const key = await askQuestion(`Enter API key for ${name} (skip if none):`)
201
+ if (key) await setProviderKey(name, key)
202
+ return
203
+ }
204
+ const preset = PRESETS[se.name]
205
+ if (!preset || agent.providers.some((p) => p.name === se.name)) return
206
+ const cfg = { name: se.name, baseURL: preset.baseURL, model: preset.model }
207
+ if (preset.thinking) cfg.thinking = preset.thinking
208
+ if (preset.reasoningEffort) cfg.reasoningEffort = preset.reasoningEffort
209
+ if (preset.maxTokens) cfg.maxTokens = preset.maxTokens
210
+ if (preset.chatPath) cfg.chatPath = preset.chatPath
211
+ agent.providers.push(cfg)
212
+ await persistRaw((raw) => { raw.providers = agent.providers })
213
+ const key = await askQuestion(`Enter API key for ${se.name} (skip if none):`)
214
+ if (key) await setProviderKey(se.name, key)
223
215
  }
224
216
 
225
- /** Remove provider (cannot remove the currently active one) */
226
217
  async function removeProviderFlow() {
227
218
  const candidates = agent.providers.filter((p) => p.name !== agent.activeProvider)
228
- if (candidates.length === 0) { openModelPicker().catch((e) => pushLine(`[error] ${e.message}`, C.error)); return }
229
- const removeEntries = [
230
- { type: "header", text: "Select provider to remove (current one cannot be removed)" },
219
+ if (!candidates.length) return
220
+ const se = await showPicker("Remove Provider", [
221
+ { type: "header", text: "Select provider to remove" },
231
222
  ...candidates.map((p) => ({ type: "item", text: `${p.name} (${p.model})`, name: p.name })),
232
- ]
233
- openPicker({
234
- title: "Remove Provider",
235
- entries: removeEntries,
236
- onCancel: () => openModelPicker().catch((e) => pushLine(`[error] ${e.message}`, C.error)),
237
- onSelect: async (se) => {
238
- const at = agent.providers.findIndex((p) => p.name === se.name)
239
- agent.providers.splice(at, 1)
240
- await persistRaw((raw) => { raw.providers = agent.providers })
241
- openModelPicker().catch((e) => pushLine(`[error] ${e.message}`, C.error))
242
- },
243
- })
223
+ ])
224
+ if (!se) return
225
+ agent.providers.splice(agent.providers.findIndex((p) => p.name === se.name), 1)
226
+ await persistRaw((raw) => { raw.providers = agent.providers })
244
227
  }
245
228
 
246
- /** Set/change API key: select provider → enter key */
247
229
  async function setKeyFlow() {
248
- const keyEntries = [
249
- { type: "header", text: "Select provider to configure key" },
250
- ...agent.providers.map((p) => ({
251
- type: "item",
252
- text: `${p.name} ${p.apiKey ? `(has key: ${maskKey(p.apiKey)})` : "(no key)"}`,
253
- name: p.name,
254
- })),
255
- ]
256
- openPicker({
257
- title: "Configure API Key",
258
- entries: keyEntries,
259
- onCancel: () => openModelPicker().catch((e) => pushLine(`[error] ${e.message}`, C.error)),
260
- onSelect: async (se) => {
261
- const key = await askQuestion(`Enter API key for ${se.name}:`)
262
- if (!key) { openModelPicker().catch((e) => pushLine(`[error] ${e.message}`, C.error)); return }
263
- await setProviderKey(se.name, key)
264
- openModelPicker().catch((e) => pushLine(`[error] ${e.message}`, C.error))
265
- },
266
- })
230
+ const se = await showPicker("Configure API Key", [
231
+ { type: "header", text: "Select provider" },
232
+ ...agent.providers.map((p) => ({ type: "item", text: `${p.name} ${p.apiKey ? `(has key: ${maskKey(p.apiKey)})` : "(no key)"}`, name: p.name })),
233
+ ])
234
+ if (!se) return
235
+ const key = await askQuestion(`Enter API key for ${se.name}:`)
236
+ if (key) await setProviderKey(se.name, key)
267
237
  }
268
238
 
269
- /** Write key for a given provider (memory + config file); if it's the currently active one, sync runtime too */
270
239
  async function setProviderKey(name, key) {
271
240
  const target = agent.providers.find((p) => p.name === name)
272
241
  if (!target) return
@@ -275,5 +244,5 @@ export function createPickers(ctx) {
275
244
  await persistRaw((raw) => { raw.providers = agent.providers })
276
245
  }
277
246
 
278
- return { openPicker, closePicker, renderPickerLines, openModelPicker, setProviderKey }
247
+ return { showPicker, closePicker, popPicker, renderPickerLines, openModelPicker, selectModel, setProviderKey }
279
248
  }