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.
- package/README.md +6 -1
- package/package.json +1 -1
- package/src/cli/make-agent.mjs +7 -0
- package/src/config.mjs +46 -19
- package/src/context.mjs +18 -0
- package/src/prompts/coder.md +5 -2
- package/src/prompts/discipline.md +8 -5
- package/src/prompts/system.md +8 -5
- package/src/provider/anthropic.mjs +190 -0
- package/src/provider/core.mjs +36 -4
- package/src/provider/google.mjs +197 -0
- package/src/proxy.mjs +236 -0
- package/src/tools/codemode.mjs +178 -0
- package/src/tools/fetch.md +2 -1
- package/src/tools/git.mjs +120 -154
- package/src/tools/index.mjs +11 -9
- package/src/tools/linter.mjs +46 -32
- package/src/tools/lsp.mjs +317 -0
- package/src/tools/web.mjs +103 -82
- package/src/tools/websearch.md +5 -3
- package/src/tui/agent-turn.mjs +12 -13
- package/src/tui/cmd-advisor.mjs +29 -41
- 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 +64 -38
- package/src/tui/key-handler.mjs +48 -18
- package/src/tui/layout.mjs +12 -2
- package/src/tui/pickers.mjs +151 -182
- package/src/tui/render-frame.mjs +29 -11
- package/src/tui/slash-commands.mjs +26 -16
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
|
}
|
package/src/tui/render-frame.mjs
CHANGED
|
@@ -35,7 +35,7 @@ const SLASH_HINTS = {
|
|
|
35
35
|
export function renderHeader(agent, cols) {
|
|
36
36
|
const model = agent.provider.model
|
|
37
37
|
const spec = specForModel(model)
|
|
38
|
-
const thinkOnValue = spec.
|
|
38
|
+
const thinkOnValue = spec.thinkEnabledValue ?? "enabled"
|
|
39
39
|
const t = agent.provider.thinking
|
|
40
40
|
const effort = agent.provider.reasoningEffort
|
|
41
41
|
const thinkBadge = t?.type === "disabled" ? "│ think: off"
|
|
@@ -146,12 +146,29 @@ export function renderPicker(state, cols, panel, overlay) {
|
|
|
146
146
|
if (!panel || !overlay) return []
|
|
147
147
|
const out = []
|
|
148
148
|
const winH = panel.h - 1
|
|
149
|
-
const
|
|
149
|
+
const total = overlay.lines.length
|
|
150
|
+
const start = Math.max(0, Math.min(overlay.scroll, Math.max(0, total - winH)))
|
|
150
151
|
const shown = overlay.lines.slice(start, start + winH)
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
152
|
+
// 标题行:左侧标题 + filter(截断防撑破帧),右侧位置指示(按键提示在状态栏,不重复)
|
|
153
|
+
const p = state.picker
|
|
154
|
+
const right = p && p.filteredItems?.length ? `${p.index + 1}/${p.filteredItems.length} ` : ""
|
|
155
|
+
const rawLeft = p ? ` ❯ ${p.title}${p.filter ? ` filter: ${p.filter}` : ""} ` : " ❯ Setup "
|
|
156
|
+
const left = sliceByWidth(rawLeft, Math.max(1, cols - 2 - stringWidth(right)))
|
|
157
|
+
const titlePad = " ".repeat(Math.max(1, cols - 1 - stringWidth(left) - stringWidth(right)))
|
|
158
|
+
out.push(`${ansi.bold}${C.tool}${left}${ansi.reset}${ansi.dim}${titlePad}${right}${ansi.reset}`)
|
|
159
|
+
const hasMoreAbove = start > 0
|
|
160
|
+
const hasMoreBelow = start + winH < total
|
|
161
|
+
for (let i = 0; i < shown.length; i++) {
|
|
162
|
+
const l = shown[i]
|
|
163
|
+
// 可视窗上方/下方有更多内容时,在首行/末行右侧给 dim 提示;单行窗口两个方向都有则合并指示
|
|
164
|
+
const moreAbove = i === 0 && hasMoreAbove
|
|
165
|
+
const moreBelow = i === shown.length - 1 && hasMoreBelow
|
|
166
|
+
const ind = moreAbove && moreBelow ? "↑↓ more" : moreAbove ? "↑ more" : moreBelow ? "↓ more" : ""
|
|
167
|
+
const maxW = cols - 1 - (ind ? stringWidth(ind) + 1 : 0)
|
|
168
|
+
// 超宽行截断并加省略号
|
|
169
|
+
const text = stringWidth(l.text) > maxW ? sliceByWidth(l.text, Math.max(0, maxW - 1)) + "…" : l.text
|
|
170
|
+
const pad = ind ? " ".repeat(Math.max(1, cols - 1 - stringWidth(text) - stringWidth(ind))) : ""
|
|
171
|
+
out.push(`${l.color}${text}${ansi.reset}${ind ? `${ansi.dim}${pad}${ind}${ansi.reset}` : ""}`)
|
|
155
172
|
}
|
|
156
173
|
for (let i = shown.length; i < winH; i++) out.push("")
|
|
157
174
|
return out
|
|
@@ -193,7 +210,7 @@ export function renderInputBox(state, W, boxLines, cols, inputLayout, inputOffse
|
|
|
193
210
|
if (li === curLine && curCol >= 0) {
|
|
194
211
|
const beforeWidth = Math.min(curCol, stringWidth(content))
|
|
195
212
|
const before = sliceByWidth(content, beforeWidth)
|
|
196
|
-
const atIdx =
|
|
213
|
+
const atIdx = before.length // character index (not display width — CJK chars diverge)
|
|
197
214
|
const at = content[atIdx] ?? " "
|
|
198
215
|
const after = content.slice(atIdx + 1)
|
|
199
216
|
content = before + `${ansi.reset}\x1b[7m${at}\x1b[27m${ansi.reset}` + after
|
|
@@ -406,7 +423,7 @@ function buildStatusLine(state, agent, { cols, slashCommands }) {
|
|
|
406
423
|
? " y: continue │ n: stop"
|
|
407
424
|
: " y: approve │ n: deny │ a: approve all (AUTO)"
|
|
408
425
|
}
|
|
409
|
-
if (state.picker) return "
|
|
426
|
+
if (state.picker) return " type: filter │ ↑↓/PgUp/PgDn: select │ Enter: confirm │ Esc: cancel"
|
|
410
427
|
if (state.wizard) {
|
|
411
428
|
return state.wizard.step === "provider"
|
|
412
429
|
? " ↑↓: select │ Enter: confirm │ Esc: skip"
|
|
@@ -434,10 +451,11 @@ function buildStatusLine(state, agent, { cols, slashCommands }) {
|
|
|
434
451
|
const elapsed = state.processing ? ` ${Math.floor((Date.now() - state.processingStarted) / 1000)}s` : ""
|
|
435
452
|
const toolHint = state.currentTool ? ` ${state.currentTool}…` : ""
|
|
436
453
|
const statusText = state.processing ? `${state.status}${toolHint}${elapsed}` : state.status
|
|
437
|
-
const
|
|
438
|
-
const ctxPct = Math.round((state.ctxCache.tokens /
|
|
454
|
+
const modelContext = specForModel(agent.provider.model).context
|
|
455
|
+
const ctxPct = Math.round((state.ctxCache.tokens / modelContext) * 100)
|
|
456
|
+
const ctxTokensHint = state.ctxCache.tokens > 0 ? ` ${fmtK(state.ctxCache.tokens)}` : ""
|
|
439
457
|
const ctxHint = ctxPct > 0
|
|
440
|
-
? ctxPct >= 80 ? ` │ ${ansi.reset}${C.warn}context ${ctxPct}%${ansi.reset}${ansi.dim}` : ` │ context ${ctxPct}
|
|
458
|
+
? ctxPct >= 80 ? ` │ ${ansi.reset}${C.warn}context ${ctxPct}%${ctxTokensHint}${ansi.reset}${ansi.dim}` : ` │ context ${ctxPct}%${ctxTokensHint}` : ""
|
|
441
459
|
const queueHint = state.queue.length > 0 ? ` │ queue: ${state.queue.length}` : ""
|
|
442
460
|
return ` ${statusText}${taskHint}${tokenHint}${ctxHint}${queueHint}${scrollHint} │ Enter: send${state.processing ? " (queue)" : ""} │ /: commands │ wheel/PgUp/PgDn: scroll │ Ctrl+I: inject │ Ctrl+C: exit`
|
|
443
461
|
}
|
|
@@ -4,11 +4,12 @@
|
|
|
4
4
|
*
|
|
5
5
|
* ctx object is injected by index.mjs and forwarded to each handler:
|
|
6
6
|
* { agent, state, distillOpts, pushLine, pushLabel, render,
|
|
7
|
-
*
|
|
7
|
+
* showPicker, closePicker, openModelPicker, setProviderKey, runDistill,
|
|
8
8
|
* persistRaw, syncProviderField, maskKey, exit, SLASH_COMMANDS }
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { C } from "./ansi.mjs"
|
|
12
|
+
import { specForModel } from "../config.mjs"
|
|
12
13
|
import { handleClearCommand } from "./cmd-clear.mjs"
|
|
13
14
|
import { handleNewCommand } from "./cmd-new.mjs"
|
|
14
15
|
import { handleExitCommand } from "./cmd-exit.mjs"
|
|
@@ -55,8 +56,11 @@ export const SLASH_COMMANDS = [
|
|
|
55
56
|
{ name: "/help", group: "System", desc: "this list" },
|
|
56
57
|
]
|
|
57
58
|
|
|
58
|
-
/**
|
|
59
|
-
const
|
|
59
|
+
/** High-frequency command aliases (single source of truth — also used by index.mjs and cmd-help.mjs) */
|
|
60
|
+
export const SLASH_ALIASES = { "/h": "/help", "/x": "/exit", "/m": "/model", "/p": "/plan", "/t": "/think", "/c": "/clear", "/n": "/new" }
|
|
61
|
+
|
|
62
|
+
/** Command → handler mapping table (exported for tests) */
|
|
63
|
+
export const HANDLERS = {
|
|
60
64
|
"/clear": handleClearCommand,
|
|
61
65
|
"/new": handleNewCommand,
|
|
62
66
|
"/exit": handleExitCommand,
|
|
@@ -90,27 +94,29 @@ export function createSlashCommands(ctx) {
|
|
|
90
94
|
const handlerCtx = { ...ctx, SLASH_COMMANDS }
|
|
91
95
|
|
|
92
96
|
async function handleSlash(text) {
|
|
93
|
-
const [
|
|
94
|
-
//
|
|
95
|
-
const
|
|
96
|
-
const resolved =
|
|
97
|
+
const [rawCmd, ...args] = text.split(/\s+/)
|
|
98
|
+
// case-insensitive matching + alias resolution
|
|
99
|
+
const cmd = rawCmd.toLowerCase()
|
|
100
|
+
const resolved = SLASH_ALIASES[cmd] ?? cmd
|
|
97
101
|
const handler = HANDLERS[resolved]
|
|
98
102
|
if (handler) {
|
|
99
|
-
await handler(handlerCtx)
|
|
103
|
+
await handler(handlerCtx, args)
|
|
100
104
|
return
|
|
101
105
|
}
|
|
102
|
-
ctx.pushLine(`Unknown command: ${
|
|
106
|
+
ctx.pushLine(`Unknown command: ${rawCmd} (/help for available commands)`, C.error)
|
|
103
107
|
}
|
|
104
108
|
|
|
105
109
|
/** Tab completion candidates: command names / subcommands / provider names / preset names / think params */
|
|
106
110
|
function completions(input) {
|
|
107
111
|
if (!input.startsWith("/")) return []
|
|
108
112
|
const parts = input.split(/\s+/)
|
|
109
|
-
// still typing the first token: complete command names
|
|
113
|
+
// still typing the first token: complete command names (case-insensitive)
|
|
110
114
|
if (parts.length === 1) {
|
|
111
|
-
|
|
115
|
+
const prefix = parts[0].toLowerCase()
|
|
116
|
+
return SLASH_COMMANDS.filter((c) => c.name.startsWith(prefix)).map((c) => c.name)
|
|
112
117
|
}
|
|
113
|
-
|
|
118
|
+
// aliases resolve to their target command, so `/m <Tab>` completes /model args
|
|
119
|
+
const cmd = SLASH_ALIASES[parts[0].toLowerCase()] ?? parts[0].toLowerCase()
|
|
114
120
|
const last = parts.at(-1) // when trailing space, list all candidates
|
|
115
121
|
const head = parts.slice(0, -1).join(" ")
|
|
116
122
|
const argIndex = parts.length - 2 // which parameter is being typed (0-based)
|
|
@@ -118,13 +124,17 @@ export function createSlashCommands(ctx) {
|
|
|
118
124
|
if (cmd === "/model" && argIndex === 0) return match(agent.providers.map((p) => p.name))
|
|
119
125
|
if (cmd === "/think") {
|
|
120
126
|
if (argIndex === 0) return match(["on", "off", "effort"])
|
|
121
|
-
if (argIndex === 1 && parts[1] === "effort")
|
|
127
|
+
if (argIndex === 1 && parts[1].toLowerCase() === "effort") {
|
|
128
|
+
// effort enum is model-specific — take it from the current model's spec
|
|
129
|
+
const levels = specForModel(agent.provider?.model).reasoningEffortEnum ?? ["high", "max"]
|
|
130
|
+
return match(levels)
|
|
131
|
+
}
|
|
122
132
|
}
|
|
123
|
-
if (cmd === "/config" && argIndex === 0) return match(["embedkey"
|
|
133
|
+
if (cmd === "/config" && argIndex === 0) return match(["embedkey"])
|
|
124
134
|
if (cmd === "/goal" && argIndex === 0) return match(["set", "cancel"])
|
|
125
135
|
if (cmd === "/mcp") {
|
|
126
|
-
if (argIndex === 0) return match(["add", "
|
|
127
|
-
if (argIndex === 1 && (parts[1] === "remove" || parts[1] === "connect")) return match((agent.config?.mcp?.servers ?? []).map((s) => s.name))
|
|
136
|
+
if (argIndex === 0) return match(["add", "http", "ws", "stdio", "ai", "remove", "connect", "list"])
|
|
137
|
+
if (argIndex === 1 && (parts[1]?.toLowerCase() === "remove" || parts[1]?.toLowerCase() === "connect")) return match((agent.config?.mcp?.servers ?? []).map((s) => s.name))
|
|
128
138
|
}
|
|
129
139
|
return []
|
|
130
140
|
}
|