thincoder 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +6 -1
  2. package/package.json +1 -1
  3. package/src/cli/make-agent.mjs +7 -0
  4. package/src/config.mjs +46 -19
  5. package/src/context.mjs +18 -0
  6. package/src/prompts/coder.md +5 -2
  7. package/src/prompts/discipline.md +8 -5
  8. package/src/prompts/system.md +8 -5
  9. package/src/provider/anthropic.mjs +190 -0
  10. package/src/provider/core.mjs +36 -4
  11. package/src/provider/google.mjs +197 -0
  12. package/src/proxy.mjs +236 -0
  13. package/src/tools/codemode.mjs +178 -0
  14. package/src/tools/fetch.md +2 -1
  15. package/src/tools/git.mjs +120 -154
  16. package/src/tools/index.mjs +11 -9
  17. package/src/tools/linter.mjs +46 -32
  18. package/src/tools/lsp.mjs +317 -0
  19. package/src/tools/web.mjs +103 -82
  20. package/src/tools/websearch.md +5 -3
  21. package/src/tui/agent-turn.mjs +12 -13
  22. package/src/tui/cmd-advisor.mjs +29 -41
  23. package/src/tui/cmd-clear.mjs +11 -17
  24. package/src/tui/cmd-config.mjs +226 -142
  25. package/src/tui/cmd-extract.mjs +1 -1
  26. package/src/tui/cmd-fold.mjs +2 -3
  27. package/src/tui/cmd-goal.mjs +58 -27
  28. package/src/tui/cmd-help.mjs +3 -1
  29. package/src/tui/cmd-mcp.mjs +178 -142
  30. package/src/tui/cmd-model.mjs +15 -4
  31. package/src/tui/cmd-new.mjs +7 -13
  32. package/src/tui/cmd-restore.mjs +12 -16
  33. package/src/tui/cmd-session.mjs +28 -32
  34. package/src/tui/cmd-think.mjs +75 -50
  35. package/src/tui/cmd-undo.mjs +19 -23
  36. package/src/tui/cmd-upgrade.mjs +22 -26
  37. package/src/tui/index.mjs +64 -38
  38. package/src/tui/key-handler.mjs +48 -18
  39. package/src/tui/layout.mjs +12 -2
  40. package/src/tui/pickers.mjs +151 -182
  41. package/src/tui/render-frame.mjs +29 -11
  42. package/src/tui/slash-commands.mjs +26 -16
@@ -1,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
  }
@@ -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.thinkOnValue ?? "enabled"
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 start = Math.max(0, Math.min(overlay.scroll, Math.max(0, overlay.lines.length - winH)))
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
- const title = state.picker ? ` ❯ ${state.picker.title} ` : " ❯ Setup "
152
- out.push(`${ansi.bold}${C.tool}${title}${ansi.reset}${ansi.dim}${state.picker ? "(↑↓ navigate, Enter confirm, Esc cancel)" : ""}${ansi.reset}`)
153
- for (const l of shown) {
154
- out.push(`${l.color}${sliceByWidth(l.text, cols - 1)}${ansi.reset}`)
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 = beforeWidth
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 " ↑↓: select │ Enter: confirm │ Esc: cancel"
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 ctxThreshold = agent.config?.agent?.compactThreshold ?? 100_000
438
- const ctxPct = Math.round((state.ctxCache.tokens / ctxThreshold) * 100)
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
- * openPicker, openModelPicker, setProviderKey, runDistill,
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
- /** Command handler mapping table */
59
- const HANDLERS = {
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 [cmd] = text.split(/\s+/)
94
- // high-frequency command aliases
95
- const aliases = { "/h": "/help", "/x": "/exit", "/m": "/model", "/p": "/plan", "/t": "/think", "/c": "/clear", "/n": "/new" }
96
- const resolved = aliases[cmd] ?? cmd
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: ${cmd} (/help for available commands)`, C.error)
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
- return SLASH_COMMANDS.filter((c) => c.name.startsWith(parts[0])).map((c) => c.name)
115
+ const prefix = parts[0].toLowerCase()
116
+ return SLASH_COMMANDS.filter((c) => c.name.startsWith(prefix)).map((c) => c.name)
112
117
  }
113
- const cmd = parts[0]
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") return match(["low", "high", "max"])
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", "set"])
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", "url", "ws", "remove", "connect", "list"])
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
  }