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.
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/advisor.mjs +360 -72
- package/src/agent/helpers.mjs +7 -3
- package/src/agent/setup.mjs +2 -2
- package/src/agent-tools/advisor.mjs +36 -0
- package/src/agent-tools/plan.mjs +53 -2
- package/src/agent-tools/subagent.mjs +7 -1
- package/src/agent-tools/timer.mjs +1 -1
- package/src/agent-tools/verify.mjs +1 -0
- package/src/agent-tools.mjs +1 -0
- package/src/agent.mjs +73 -21
- package/src/auto-think.mjs +23 -5
- package/src/cli/make-agent.mjs +7 -0
- package/src/config.mjs +47 -20
- package/src/prompts/advisor-round1.md +23 -0
- package/src/prompts/advisor-round2.md +26 -0
- package/src/prompts/advisor-round3.md +24 -0
- package/src/prompts/coder.md +6 -2
- package/src/prompts/discipline.md +23 -6
- package/src/prompts/explore.md +2 -0
- package/src/prompts/plan.md +2 -0
- package/src/prompts/system.md +13 -6
- package/src/provider/anthropic.mjs +190 -0
- package/src/provider/core.mjs +42 -130
- package/src/provider/google.mjs +199 -0
- package/src/provider/sse.mjs +112 -0
- package/src/proxy.mjs +236 -0
- package/src/tools/bash.md +8 -0
- package/src/tools/codemode.mjs +5 -16
- package/src/tools/edit.md +8 -0
- package/src/tools/fetch.md +2 -1
- package/src/tools/git.mjs +125 -156
- package/src/tools/index.mjs +9 -9
- package/src/tools/linter.mjs +46 -32
- package/src/tools/read.md +7 -0
- package/src/tools/shared.mjs +16 -0
- package/src/tools/system.mjs +14 -10
- package/src/tools/web.mjs +115 -89
- package/src/tools/websearch.md +5 -3
- package/src/tui/agent-turn.mjs +86 -75
- package/src/tui/cmd-advisor.mjs +138 -49
- package/src/tui/cmd-clear.mjs +11 -17
- package/src/tui/cmd-config.mjs +226 -142
- package/src/tui/cmd-extract.mjs +1 -1
- package/src/tui/cmd-fold.mjs +2 -3
- package/src/tui/cmd-goal.mjs +58 -27
- package/src/tui/cmd-help.mjs +3 -1
- package/src/tui/cmd-mcp.mjs +178 -142
- package/src/tui/cmd-model.mjs +15 -4
- package/src/tui/cmd-new.mjs +7 -13
- package/src/tui/cmd-restore.mjs +12 -16
- package/src/tui/cmd-session.mjs +28 -32
- package/src/tui/cmd-think.mjs +75 -50
- package/src/tui/cmd-undo.mjs +19 -23
- package/src/tui/cmd-upgrade.mjs +22 -26
- package/src/tui/index.mjs +61 -215
- package/src/tui/key-handler.mjs +56 -20
- package/src/tui/layout.mjs +13 -3
- package/src/tui/pickers.mjs +151 -182
- package/src/tui/render-conversation.mjs +92 -0
- package/src/tui/render-frame.mjs +56 -114
- package/src/tui/render-loop.mjs +181 -0
- package/src/tui/slash-commands.mjs +26 -16
package/src/tui/key-handler.mjs
CHANGED
|
@@ -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,
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
136
|
-
|
|
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:
|
|
163
|
+
// generic list picker: ↑↓/PgUp/PgDn/Home/End 导航,输入即过滤,Enter 选中,Esc 取消
|
|
150
164
|
if (state.picker) {
|
|
151
|
-
const
|
|
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
|
-
|
|
177
|
+
popPicker(null)
|
|
154
178
|
} else if (key.name === "up" && items.length) {
|
|
155
|
-
|
|
179
|
+
p.index = (p.index - 1 + items.length) % items.length
|
|
156
180
|
renderPickerLines()
|
|
157
181
|
} else if (key.name === "down" && items.length) {
|
|
158
|
-
|
|
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 === "
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
}
|
package/src/tui/layout.mjs
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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
|
package/src/tui/pickers.mjs
CHANGED
|
@@ -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 +
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
|
|
11
|
+
state.pickerStack ??= []
|
|
11
12
|
|
|
12
|
-
/**
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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.
|
|
21
|
-
state.picker = null
|
|
22
|
-
render()
|
|
32
|
+
while (state.pickerStack.length) popPicker(null)
|
|
23
33
|
}
|
|
24
34
|
|
|
25
|
-
/**
|
|
26
|
-
|
|
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
|
-
|
|
37
|
-
|
|
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 =
|
|
71
|
+
p.selectedLine = selLine
|
|
48
72
|
render()
|
|
49
73
|
}
|
|
50
74
|
|
|
51
|
-
|
|
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
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
107
|
+
/** 后台拉取模型列表并 splice 进 entries;更新时按 entryKey 恢复用户光标下的选中项 */
|
|
108
|
+
async function fetchModels(entries) {
|
|
75
109
|
const { listModels } = await import("../provider/index.mjs")
|
|
76
|
-
await Promise.all(
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
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
|
-
|
|
157
|
-
agent.config.agent.compactThreshold = value
|
|
166
|
+
agent.config.agent.compactThreshold = resolveCompactThreshold(null, item.model).value
|
|
158
167
|
}
|
|
159
168
|
await persistRaw((raw) => {
|
|
160
|
-
|
|
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
|
|
181
|
+
const entries = [
|
|
173
182
|
{ type: "header", text: "Select a preset provider" },
|
|
174
|
-
...Object.entries(PRESETS)
|
|
175
|
-
.
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
|
229
|
-
const
|
|
230
|
-
{ type: "header", text: "Select provider to remove
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
|
249
|
-
{ type: "header", text: "Select provider
|
|
250
|
-
...agent.providers.map((p) => ({
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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 {
|
|
247
|
+
return { showPicker, closePicker, popPicker, renderPickerLines, openModelPicker, selectModel, setProviderKey }
|
|
279
248
|
}
|