thincoder 0.12.2 → 0.12.3
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 +18 -5
- package/package.json +1 -1
- package/src/advisor/history.mjs +112 -0
- package/src/advisor/messages.mjs +182 -0
- package/src/advisor/repos.mjs +133 -0
- package/src/advisor/run.mjs +346 -0
- package/src/advisor.mjs +109 -509
- package/src/agent/completion.mjs +119 -0
- package/src/agent/dispatch.mjs +54 -7
- package/src/agent/post-turn.mjs +70 -0
- package/src/agent/setup.mjs +93 -5
- package/src/agent-tools/advisor.mjs +159 -12
- package/src/agent-tools/eng.mjs +64 -0
- package/src/agent-tools/subagent.mjs +73 -3
- package/src/agent-tools/task.mjs +45 -6
- package/src/agent-tools/verify.mjs +18 -0
- package/src/agent-tools.mjs +1 -0
- package/src/agent.mjs +110 -150
- package/src/cli/make-agent.mjs +1 -0
- package/src/cli/setup-wizard.mjs +1 -0
- package/src/config.mjs +22 -4
- package/src/prompts/advisor-design.md +43 -0
- package/src/prompts/advisor-round1.md +11 -4
- package/src/prompts/advisor-round2.md +12 -7
- package/src/prompts/advisor-round3.md +11 -6
- package/src/prompts/coder.md +9 -3
- package/src/prompts/discipline.md +12 -96
- package/src/prompts/eng-coder.md +34 -0
- package/src/prompts/engineering-sub.md +12 -0
- package/src/prompts/engineering.md +96 -0
- package/src/prompts/main.md +1 -1
- package/src/prompts/methodology-template.md +39 -0
- package/src/prompts/plan.md +2 -2
- package/src/prompts/system.md +43 -61
- package/src/session.mjs +270 -89
- package/src/skills.mjs +48 -15
- package/src/tools/apply_patch.md +1 -1
- package/src/tools/codemode.mjs +10 -4
- package/src/tools/delete.md +1 -0
- package/src/tools/edit.md +1 -1
- package/src/tools/execute.md +5 -0
- package/src/tools/file.mjs +4 -0
- package/src/tools/git.md +15 -0
- package/src/tools/git.mjs +1 -6
- package/src/tools/lint.md +8 -0
- package/src/tools/linter.mjs +1 -5
- package/src/tools/lsp.md +7 -0
- package/src/tools/lsp.mjs +8 -9
- package/src/tools/patch.mjs +1 -29
- package/src/tools/read_image.md +5 -1
- package/src/tools/system.mjs +1 -1
- package/src/tools/web.mjs +3 -3
- package/src/tui/agent-turn.mjs +169 -66
- package/src/tui/cmd-config.mjs +12 -0
- package/src/tui/cmd-eng.mjs +44 -0
- package/src/tui/cmd-exit.mjs +1 -1
- package/src/tui/cmd-fold.mjs +3 -4
- package/src/tui/cmd-model.mjs +11 -6
- package/src/tui/cmd-new.mjs +5 -5
- package/src/tui/cmd-session.mjs +21 -11
- package/src/tui/cmd-think.mjs +1 -0
- package/src/tui/index.mjs +7 -6
- package/src/tui/key-handler.mjs +132 -4
- package/src/tui/layout.mjs +5 -5
- package/src/tui/pickers.mjs +184 -44
- package/src/tui/render-conversation.mjs +49 -11
- package/src/tui/render-frame.mjs +38 -12
- package/src/tui/render-loop.mjs +2 -1
- package/src/tui/slash-commands.mjs +11 -7
- package/src/tui/startup.mjs +4 -3
- package/src/tui/wizard.mjs +3 -0
- package/src/tools/checkpoint.md +0 -15
- package/src/tools/git_diff.md +0 -11
- package/src/tools/git_log.md +0 -10
- package/src/tools/git_status.md +0 -8
- package/src/tools/linter.md +0 -13
- package/src/tools/syntax_check.md +0 -10
package/src/tui/pickers.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { sliceByWidth } from "./render.mjs"
|
|
2
2
|
import { PROVIDER_PRESETS as PRESETS } from "../config.mjs"
|
|
3
|
+
import { computeLayout } from "./layout.mjs"
|
|
3
4
|
|
|
4
5
|
/** Generic list picker + model/provider management.
|
|
5
6
|
* 单一 Promise API:showPicker(title, entries, { defaultIndex }) → Promise<entry|null>。
|
|
@@ -69,12 +70,48 @@ export function createPickers(ctx) {
|
|
|
69
70
|
if (p.filter && items.length === 0) lines.push({ text: " (no match)", color: C.dim })
|
|
70
71
|
p.lines = lines
|
|
71
72
|
p.selectedLine = selLine
|
|
73
|
+
|
|
74
|
+
// Auto-scroll: keep selectedLine within the visible window.
|
|
75
|
+
// Fallback to a reasonable default when computeLayout can't run (e.g. test mocks without full state)
|
|
76
|
+
let winH
|
|
77
|
+
try {
|
|
78
|
+
winH = Math.max(1, (computeLayout(state, { cols: process.stdout.columns || 80, rows: process.stdout.rows || 24 }).panels.picker?.h ?? lines.length + 1) - 1)
|
|
79
|
+
} catch {
|
|
80
|
+
winH = 8 // safe fallback for test mocks
|
|
81
|
+
}
|
|
82
|
+
if (p.selectedLine < p.scroll) p.scroll = p.selectedLine
|
|
83
|
+
if (p.selectedLine >= p.scroll + winH) p.scroll = p.selectedLine - winH + 1
|
|
84
|
+
p.scroll = Math.max(0, Math.min(p.scroll, Math.max(0, lines.length - winH)))
|
|
85
|
+
|
|
72
86
|
render()
|
|
73
87
|
}
|
|
74
88
|
|
|
75
89
|
function renderPickerLines() { rebuildLines() }
|
|
76
90
|
|
|
77
|
-
// === model picker ===
|
|
91
|
+
// === two-level model picker ===
|
|
92
|
+
|
|
93
|
+
/** Strip known version/date suffixes to get the "series" name of a model.
|
|
94
|
+
* e.g. "qwen-max-latest" → "qwen-max", "qwen-max-2024-09-19" → "qwen-max" */
|
|
95
|
+
function modelSeries(name) {
|
|
96
|
+
return name
|
|
97
|
+
.replace(/-latest$/, "")
|
|
98
|
+
.replace(/-\d{4}-\d{2}-\d{2}$/, "") // date suffix like -2024-09-19
|
|
99
|
+
.replace(/-\d{8}$/, "") // date suffix like -20240919
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Dedupe model list: group by series, keep shortest name per group.
|
|
103
|
+
* Reduces "qwen-max, qwen-max-latest, qwen-max-2024-09-19" to just "qwen-max". */
|
|
104
|
+
function dedupeModels(models) {
|
|
105
|
+
const groups = new Map()
|
|
106
|
+
for (const m of models) {
|
|
107
|
+
const series = modelSeries(m)
|
|
108
|
+
const existing = groups.get(series)
|
|
109
|
+
if (!existing || m.length < existing.length) {
|
|
110
|
+
groups.set(series, m)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return [...groups.values()].sort()
|
|
114
|
+
}
|
|
78
115
|
|
|
79
116
|
/** entry 唯一标识:异步更新 entries 后按它恢复选中项 */
|
|
80
117
|
function entryKey(e) {
|
|
@@ -82,79 +119,180 @@ export function createPickers(ctx) {
|
|
|
82
119
|
return e.action === "switch" ? `switch:${e.provider}:${e.model}` : `action:${e.action}`
|
|
83
120
|
}
|
|
84
121
|
|
|
122
|
+
/** Get API key for a provider (from config or env vars) */
|
|
123
|
+
function getApiKey(providerName, providerConfig) {
|
|
124
|
+
const envKey = { deepseek: "DEEPSEEK_API_KEY", openai: "OPENAI_API_KEY" }[providerName]
|
|
125
|
+
let apiKey = providerConfig.apiKey
|
|
126
|
+
if (!apiKey && envKey && process.env[envKey]) apiKey = process.env[envKey]
|
|
127
|
+
if (!apiKey) apiKey = process.env.THINCODER_API_KEY
|
|
128
|
+
return apiKey
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Level 1: Show provider list. Selecting a provider opens Level 2 (model list). */
|
|
85
132
|
async function openModelPicker() {
|
|
86
133
|
// 菜单循环:选中即关闭,子流程结束后重开主菜单;Esc 退出
|
|
87
134
|
for (;;) {
|
|
88
|
-
const entries =
|
|
135
|
+
const entries = buildProviderEntries()
|
|
89
136
|
const items = entries.filter((e) => e.type === "item")
|
|
90
137
|
const current = items.findIndex(
|
|
91
|
-
(e) => e.action === "
|
|
138
|
+
(e) => e.action === "open-models" && e.provider === agent.activeProvider)
|
|
92
139
|
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
140
|
const e = await picked
|
|
96
141
|
if (!e) return
|
|
97
|
-
if (e.action === "
|
|
98
|
-
|
|
99
|
-
|
|
142
|
+
if (e.action === "open-models") {
|
|
143
|
+
// Level 2: open model list for this provider
|
|
144
|
+
const modelSelected = await openModelListForProvider(e.provider)
|
|
145
|
+
if (modelSelected) return // model selected, close picker
|
|
146
|
+
// Esc from model list → return to provider list
|
|
147
|
+
} else if (e.action === "add") {
|
|
148
|
+
await addProviderFlow()
|
|
149
|
+
} else if (e.action === "remove") {
|
|
150
|
+
await removeProviderFlow()
|
|
151
|
+
} else if (e.action === "key") {
|
|
152
|
+
await setKeyFlow()
|
|
100
153
|
}
|
|
101
|
-
if (e.action === "add") await addProviderFlow()
|
|
102
|
-
else if (e.action === "remove") await removeProviderFlow()
|
|
103
|
-
else if (e.action === "key") await setKeyFlow()
|
|
104
154
|
}
|
|
105
155
|
}
|
|
106
156
|
|
|
107
|
-
/**
|
|
108
|
-
async function
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
}))
|
|
157
|
+
/** Level 2: Show model list for a specific provider. Returns true if a model was selected. */
|
|
158
|
+
async function openModelListForProvider(providerName) {
|
|
159
|
+
const providerConfig = agent.providers.find((p) => p.name === providerName)
|
|
160
|
+
if (!providerConfig) return false
|
|
161
|
+
|
|
162
|
+
const entries = buildModelEntriesForProvider(providerName, providerConfig)
|
|
163
|
+
const items = entries.filter((e) => e.type === "item")
|
|
164
|
+
const currentModel = providerName === agent.activeProvider
|
|
165
|
+
? (agent.activeModel || providerConfig.model)
|
|
166
|
+
: providerConfig.model
|
|
167
|
+
const current = items.findIndex((e) => e.model === currentModel)
|
|
168
|
+
const picked = showPicker(`${providerName} models`, entries, { defaultIndex: Math.max(0, current) })
|
|
169
|
+
|
|
170
|
+
// Async fetch models in background
|
|
171
|
+
fetchModelsForProvider(providerName, entries).catch((err) => {
|
|
172
|
+
pushLine(`[model] fetch models failed: ${err.message}`, C.error)
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
const e = await picked
|
|
176
|
+
if (!e) return false // Esc → back to provider list
|
|
177
|
+
if (e.action === "switch") {
|
|
178
|
+
await selectModel(e).catch((err) => pushLine(`[error] ${err.message}`, C.error))
|
|
179
|
+
return true
|
|
180
|
+
}
|
|
181
|
+
return false
|
|
136
182
|
}
|
|
137
183
|
|
|
138
|
-
|
|
184
|
+
/** Build entries for Level 1: provider list */
|
|
185
|
+
function buildProviderEntries() {
|
|
139
186
|
const entries = []
|
|
140
187
|
for (const p of agent.providers) {
|
|
141
188
|
const active = p.name === agent.activeProvider
|
|
142
|
-
|
|
143
|
-
|
|
189
|
+
const currentModel = active ? (agent.activeModel || p.model) : p.model
|
|
190
|
+
const marker = active ? "●" : ""
|
|
191
|
+
const note = active ? " ← current" : ""
|
|
192
|
+
const keyStatus = p.apiKey ? "" : " (no key)"
|
|
193
|
+
entries.push({
|
|
194
|
+
type: "item",
|
|
195
|
+
text: `${p.name.padEnd(12)} ${currentModel}${note}`,
|
|
196
|
+
action: "open-models",
|
|
197
|
+
provider: p.name,
|
|
198
|
+
marker,
|
|
199
|
+
note: `${p.baseURL}${keyStatus}`,
|
|
200
|
+
})
|
|
144
201
|
}
|
|
145
|
-
entries.push({ type: "header", text: "
|
|
202
|
+
entries.push({ type: "header", text: "Management" })
|
|
146
203
|
entries.push({ type: "item", text: "Add provider…", action: "add" })
|
|
147
204
|
if (agent.providers.length > 1) entries.push({ type: "item", text: "Remove provider…", action: "remove" })
|
|
148
205
|
entries.push({ type: "item", text: "Set / change API key…", action: "key" })
|
|
149
206
|
return entries
|
|
150
207
|
}
|
|
151
208
|
|
|
209
|
+
/** Build entries for Level 2: model list for a specific provider */
|
|
210
|
+
function buildModelEntriesForProvider(providerName, providerConfig) {
|
|
211
|
+
const entries = []
|
|
212
|
+
const active = providerName === agent.activeProvider
|
|
213
|
+
const currentModel = active ? (agent.activeModel || providerConfig.model) : providerConfig.model
|
|
214
|
+
const isDefaultModel = currentModel === providerConfig.model
|
|
215
|
+
|
|
216
|
+
entries.push({ type: "header", text: "Current model" })
|
|
217
|
+
entries.push({
|
|
218
|
+
type: "item",
|
|
219
|
+
text: currentModel,
|
|
220
|
+
action: "switch",
|
|
221
|
+
provider: providerName,
|
|
222
|
+
model: currentModel,
|
|
223
|
+
marker: active && isDefaultModel ? "●" : "",
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
entries.push({ type: "header", text: "Available models (loading…)" })
|
|
227
|
+
return entries
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Async fetch and splice models for a specific provider */
|
|
231
|
+
async function fetchModelsForProvider(providerName, entries) {
|
|
232
|
+
const { listModels } = await import("../provider/index.mjs")
|
|
233
|
+
const providerConfig = agent.providers.find((p) => p.name === providerName)
|
|
234
|
+
if (!providerConfig) return
|
|
235
|
+
|
|
236
|
+
let selKey = null
|
|
237
|
+
try {
|
|
238
|
+
const apiKey = getApiKey(providerName, providerConfig)
|
|
239
|
+
const models = await listModels(
|
|
240
|
+
{ baseURL: providerConfig.baseURL, apiKey: apiKey ?? "" },
|
|
241
|
+
{ signal: AbortSignal.timeout(10000) }
|
|
242
|
+
)
|
|
243
|
+
if (state.picker?.entries !== entries) return // picker closed or changed
|
|
244
|
+
selKey = entryKey(pickerItems(state.picker)[state.picker.index])
|
|
245
|
+
|
|
246
|
+
// Dedupe and filter
|
|
247
|
+
const deduped = dedupeModels(models)
|
|
248
|
+
const currentModel = providerName === agent.activeProvider
|
|
249
|
+
? (agent.activeModel || providerConfig.model)
|
|
250
|
+
: providerConfig.model
|
|
251
|
+
|
|
252
|
+
// Find the "Available models" header and splice after it
|
|
253
|
+
const headerIdx = entries.findIndex((e) => e.type === "header" && e.text.startsWith("Available models"))
|
|
254
|
+
if (headerIdx >= 0) {
|
|
255
|
+
// Update header with count info
|
|
256
|
+
const hint = deduped.length < models.length
|
|
257
|
+
? ` (${models.length} total, ${deduped.length} shown — type to filter)`
|
|
258
|
+
: ` (${deduped.length})`
|
|
259
|
+
entries[headerIdx].text = `Available models${hint}`
|
|
260
|
+
|
|
261
|
+
// Splice models after header (skip current model)
|
|
262
|
+
const newModels = deduped.filter((m) => m !== currentModel)
|
|
263
|
+
entries.splice(headerIdx + 1, 0, ...newModels.map((m) => ({
|
|
264
|
+
type: "item",
|
|
265
|
+
text: m,
|
|
266
|
+
action: "switch",
|
|
267
|
+
provider: providerName,
|
|
268
|
+
model: m,
|
|
269
|
+
})))
|
|
270
|
+
}
|
|
271
|
+
} catch (error) {
|
|
272
|
+
if (state.picker?.entries !== entries) return
|
|
273
|
+
const headerIdx = entries.findIndex((e) => e.type === "header" && e.text.startsWith("Available models"))
|
|
274
|
+
if (headerIdx >= 0) {
|
|
275
|
+
entries[headerIdx].text = `Available models (fetch failed: ${sliceByWidth(error.message, 30)})`
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Restore selection
|
|
280
|
+
const pk = state.picker
|
|
281
|
+
const items = pickerItems(pk)
|
|
282
|
+
const restored = selKey ? items.findIndex((e) => entryKey(e) === selKey) : -1
|
|
283
|
+
pk.index = restored >= 0 ? restored : Math.min(pk.index, Math.max(0, items.length - 1))
|
|
284
|
+
rebuildLines()
|
|
285
|
+
}
|
|
286
|
+
|
|
152
287
|
async function selectModel(item) {
|
|
153
288
|
closePicker()
|
|
154
289
|
const target = agent.providers.find((pp) => pp.name === item.provider)
|
|
155
290
|
if (!target) return
|
|
291
|
+
const providerDefault = target.model
|
|
156
292
|
target.model = item.model
|
|
157
293
|
agent.activeProvider = item.provider
|
|
294
|
+
// If selecting the provider's default model, clear activeModel; otherwise set it
|
|
295
|
+
agent.activeModel = item.model !== providerDefault ? item.model : null
|
|
158
296
|
agent.provider = { ...target }
|
|
159
297
|
if (!agent.provider.apiKey) {
|
|
160
298
|
const envKey = { deepseek: "DEEPSEEK_API_KEY", openai: "OPENAI_API_KEY" }[item.provider]
|
|
@@ -169,8 +307,10 @@ export function createPickers(ctx) {
|
|
|
169
307
|
// 落盘前剥离运行时注入的 proxyUri(由 loadConfig + injectProxy 在加载时重建)
|
|
170
308
|
raw.providers = agent.providers.map(({ proxyUri: _, ...p }) => p)
|
|
171
309
|
raw.activeProvider = item.provider
|
|
310
|
+
raw.activeModel = agent.activeModel || undefined // null → omit from config
|
|
172
311
|
})
|
|
173
312
|
agent.config.activeProvider = item.provider
|
|
313
|
+
agent.config.activeModel = agent.activeModel
|
|
174
314
|
if (!agent.provider.apiKey) {
|
|
175
315
|
const selKey = await askQuestion(`Enter API key for ${item.provider} (leave empty to skip):`)
|
|
176
316
|
if (selKey) await setProviderKey(item.provider, selKey)
|
|
@@ -9,7 +9,30 @@ let _convCache = { key: "", cols: 0, lines: [] }
|
|
|
9
9
|
|
|
10
10
|
export function convCacheKey(state) {
|
|
11
11
|
const lastLine = state.lines.length > 0 ? state.lines[state.lines.length - 1] : null
|
|
12
|
-
return `${state.lines.length}|${lastLine?.text.length ?? 0}|${state.streaming.length}|${state.reasoning.length}|${
|
|
12
|
+
return `${state.lines.length}|${lastLine?.text.length ?? 0}|${state.streaming.length}|${state.reasoning.length}|${state.advisorStreaming?.length ?? 0}|${state._advisorThink?.length ?? 0}|${state.foldEnabled !== false ? "f" : "u"}`
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function highlightSearchMatches(text, query, matchesInLine, globalCurrentIndex, allMatches, lineIndex) {
|
|
16
|
+
if (!matchesInLine || matchesInLine.length === 0 || !query) return text
|
|
17
|
+
let result = ""
|
|
18
|
+
let lastEnd = 0
|
|
19
|
+
for (const startIdx of matchesInLine) {
|
|
20
|
+
result += text.substring(lastEnd, startIdx)
|
|
21
|
+
const endIdx = startIdx + query.length
|
|
22
|
+
const matchedText = text.substring(startIdx, endIdx)
|
|
23
|
+
|
|
24
|
+
// Find global index of this match
|
|
25
|
+
const gIdx = allMatches.findIndex(m => m.lineIndex === lineIndex && m.charIndex === startIdx)
|
|
26
|
+
|
|
27
|
+
if (gIdx === globalCurrentIndex) {
|
|
28
|
+
result += `\x1b[7m${matchedText}\x1b[27m` // Reverse video for current
|
|
29
|
+
} else {
|
|
30
|
+
result += `\x1b[33m\x1b[4m${matchedText}\x1b[24m\x1b[39m` // Yellow underline for others
|
|
31
|
+
}
|
|
32
|
+
lastEnd = endIdx
|
|
33
|
+
}
|
|
34
|
+
result += text.substring(lastEnd)
|
|
35
|
+
return result
|
|
13
36
|
}
|
|
14
37
|
|
|
15
38
|
function buildConvLines(state, cols) {
|
|
@@ -17,8 +40,16 @@ function buildConvLines(state, cols) {
|
|
|
17
40
|
if (_convCache.key === key && _convCache.cols === cols) return _convCache.lines
|
|
18
41
|
|
|
19
42
|
const convLines = []
|
|
20
|
-
for (
|
|
21
|
-
|
|
43
|
+
for (let i = 0; i < state.lines.length; i++) {
|
|
44
|
+
const l = state.lines[i]
|
|
45
|
+
let text = l.text
|
|
46
|
+
|
|
47
|
+
// Apply search highlighting
|
|
48
|
+
if (state.search && state.search.query && l._searchMatches) {
|
|
49
|
+
text = highlightSearchMatches(text, state.search.query, l._searchMatches, state.search.index, state.search.matches, i)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
for (const line of formatTables(sanitizeDisplay(text), cols - 1)) {
|
|
22
53
|
for (const wrapped of wrapText(line, cols - 1)) {
|
|
23
54
|
convLines.push({ text: wrapped, color: l.color, _foldId: l._foldId })
|
|
24
55
|
}
|
|
@@ -29,6 +60,21 @@ function buildConvLines(state, cols) {
|
|
|
29
60
|
convLines.push({ text: wrapped, color: C.reason })
|
|
30
61
|
}
|
|
31
62
|
}
|
|
63
|
+
if (state._advisorThink || state.advisorStreaming) {
|
|
64
|
+
const thinkLines = state._advisorThink ? sanitizeDisplay(state._advisorThink).split("\n") : []
|
|
65
|
+
const mainLines = state.advisorStreaming
|
|
66
|
+
? formatTables(sanitizeDisplay(state.advisorStreaming), cols - 3)
|
|
67
|
+
: []
|
|
68
|
+
const allLines = [...thinkLines.map(l => ({ text: l, color: C.reason })), ...mainLines.map(l => ({ text: l, color: C.text }))]
|
|
69
|
+
const truncated = allLines.length > 5
|
|
70
|
+
if (truncated) convLines.push({ text: "│ …", color: C.dim })
|
|
71
|
+
const shown = truncated ? allLines.slice(-5) : allLines
|
|
72
|
+
for (const { text, color } of shown) {
|
|
73
|
+
for (const wrapped of wrapText(text, cols - 3)) {
|
|
74
|
+
convLines.push({ text: `│ ${wrapped}`, color })
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
32
78
|
if (state.streaming) {
|
|
33
79
|
for (const line of formatTables(sanitizeDisplay(state.streaming), cols - 1)) {
|
|
34
80
|
for (const wrapped of wrapText(line, cols - 1)) {
|
|
@@ -36,14 +82,6 @@ function buildConvLines(state, cols) {
|
|
|
36
82
|
}
|
|
37
83
|
}
|
|
38
84
|
}
|
|
39
|
-
const allStreams = Object.values(state.toolStreams).join("")
|
|
40
|
-
if (allStreams) {
|
|
41
|
-
const tail = sanitizeDisplay(allStreams.slice(-4000))
|
|
42
|
-
for (const wrapped of wrapText(tail, cols - 1)) {
|
|
43
|
-
convLines.push({ text: wrapped, color: C.dim })
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
|
|
47
85
|
// Fold long blocks (> 8 consecutive dim lines)
|
|
48
86
|
const FOLD_LINES = 8
|
|
49
87
|
let foldCounter = 0
|
package/src/tui/render-frame.mjs
CHANGED
|
@@ -111,20 +111,26 @@ function panelLines(p) {
|
|
|
111
111
|
/** Tool output panels (streaming output like tail -f). Returns empty when no visible output. */
|
|
112
112
|
export function renderOutput(state, W, panelH) {
|
|
113
113
|
// Same visibility predicate as computeLayout: done panels linger until closeAt
|
|
114
|
-
const active = Object.
|
|
114
|
+
const active = Object.entries(state.outputPanels ?? {}).filter(([_, p]) => !p.done || (p.closeAt ?? 0) > Date.now())
|
|
115
115
|
if (active.length === 0) return []
|
|
116
116
|
const out = []
|
|
117
117
|
const linesPerPanel = Math.max(1, Math.floor(panelH / active.length))
|
|
118
|
-
const
|
|
119
|
-
|
|
118
|
+
for (const [toolName, p] of active) {
|
|
119
|
+
const status = p.done ? `${C.dim}done${ansi.reset}` : `${C.tool}running${ansi.reset}`
|
|
120
|
+
out.push(`${C.text}❯ ${sliceByWidth(sanitizeDisplay(toolName), Math.max(10, W - 25))} — ${status}`)
|
|
121
|
+
const titleRows = 1
|
|
122
|
+
const contentRows = Math.max(0, linesPerPanel - titleRows)
|
|
123
|
+
const lines = contentRows > 0 ? panelLines(p).slice(-contentRows) : []
|
|
120
124
|
for (const l of lines) {
|
|
121
125
|
const color = PANEL_KIND_COLORS[l.kind] ?? C.dim
|
|
122
126
|
out.push(`${color} │ ${sliceByWidth(sanitizeDisplay(l.text), W - 5)}${ansi.reset}`)
|
|
123
127
|
}
|
|
124
128
|
}
|
|
125
|
-
//
|
|
126
|
-
|
|
127
|
-
|
|
129
|
+
// Safety: never exceed allocated height (could happen with many panels + small terminal)
|
|
130
|
+
if (out.length > panelH) out.length = panelH
|
|
131
|
+
// Fill remaining rows to match panelH exactly. `out.length` is accurate because
|
|
132
|
+
// every pushed entry is a non-empty string (title or content line with prefix).
|
|
133
|
+
for (let i = out.length; i < panelH; i++) out.push("")
|
|
128
134
|
return out
|
|
129
135
|
}
|
|
130
136
|
|
|
@@ -149,10 +155,12 @@ export function renderPicker(state, cols, panel, overlay) {
|
|
|
149
155
|
const total = overlay.lines.length
|
|
150
156
|
const start = Math.max(0, Math.min(overlay.scroll, Math.max(0, total - winH)))
|
|
151
157
|
const shown = overlay.lines.slice(start, start + winH)
|
|
152
|
-
// 标题行:左侧标题 +
|
|
158
|
+
// 标题行:左侧标题 + 过滤输入提示/内容(截断防撑破帧),右侧位置指示
|
|
153
159
|
const p = state.picker
|
|
154
160
|
const right = p && p.filteredItems?.length ? `${p.index + 1}/${p.filteredItems.length} ` : ""
|
|
155
|
-
|
|
161
|
+
// 过滤提示:无filter时显示 "type to filter",有filter时显示输入内容
|
|
162
|
+
const filterHint = p ? (p.filter ? `│ ${p.filter}` : "│ type to filter") : ""
|
|
163
|
+
const rawLeft = p ? ` ❯ ${p.title} ${filterHint} ` : " ❯ Setup "
|
|
156
164
|
const left = sliceByWidth(rawLeft, Math.max(1, cols - 2 - stringWidth(right)))
|
|
157
165
|
const titlePad = " ".repeat(Math.max(1, cols - 1 - stringWidth(left) - stringWidth(right)))
|
|
158
166
|
out.push(`${ansi.bold}${C.tool}${left}${ansi.reset}${ansi.dim}${titlePad}${right}${ansi.reset}`)
|
|
@@ -202,8 +210,21 @@ export function renderInputBox(state, W, boxLines, cols, inputLayout, inputOffse
|
|
|
202
210
|
const curLine = (!hasOverlay && inputLayout) ? inputLayout.cursorLine - (inputOffset ?? 0) : -1
|
|
203
211
|
const curCol = (!hasOverlay && inputLayout) ? inputLayout.cursorCol : -1
|
|
204
212
|
|
|
213
|
+
// Interrupt prompt 空文本占位符:灰色提示用户该做什么
|
|
214
|
+
const isInterruptEmpty = state.interruptPrompt && !state.interruptPrompt.text
|
|
215
|
+
const interruptPlaceholder = "Type message to inject (Enter send, Esc cancel)"
|
|
216
|
+
|
|
205
217
|
for (let li = 0; li < boxLines.length; li++) {
|
|
206
218
|
const l = boxLines[li]
|
|
219
|
+
// 第一行且 interrupt prompt 为空时,用灰色占位符替代 prompt
|
|
220
|
+
if (li === 0 && isInterruptEmpty) {
|
|
221
|
+
const prompt = "▸ "
|
|
222
|
+
const ph = `${prompt}${interruptPlaceholder}`
|
|
223
|
+
const phWidth = stringWidth(ph)
|
|
224
|
+
const fill = " ".repeat(Math.max(0, W - 4 - phWidth))
|
|
225
|
+
out.push(`${borderColor}│${ansi.reset} ${ansi.dim}${ph}${ansi.reset}${fill} ${borderColor}│${ansi.reset}`)
|
|
226
|
+
continue
|
|
227
|
+
}
|
|
207
228
|
const original = sliceByWidth(l, W - 4)
|
|
208
229
|
let content = original
|
|
209
230
|
const contentWidth = stringWidth(original)
|
|
@@ -247,9 +268,10 @@ export function renderStatus(state, agent, cols, slashCommands) {
|
|
|
247
268
|
const autoBanner = agent.autoApprove ? `${C.warn} AUTO${ansi.reset}${ansi.dim}│` : ""
|
|
248
269
|
const planBanner = agent.planMode ? `${C.tool} PLAN${ansi.reset}${ansi.dim}│` : ""
|
|
249
270
|
const advisorBanner = agent.config?.advisor?.enabled ? `${C.advisor} ADVISOR${ansi.reset}${ansi.dim}│` : ""
|
|
250
|
-
const
|
|
271
|
+
const engBanner = agent.config?.agent?.engineering ? `${C.advisor} ENG${ansi.reset}${ansi.dim}│` : ""
|
|
272
|
+
const bannerPrefix = (agent.planMode ? " PLAN│ " : "") + (agent.autoApprove ? " AUTO│ " : "") + (agent.config?.advisor?.enabled ? " ADVISOR│ " : "") + (agent.config?.agent?.engineering ? " ENG│ " : "")
|
|
251
273
|
const statusMax = cols - 1 - (bannerPrefix ? stringWidth(bannerPrefix) : 0)
|
|
252
|
-
return `${ansi.dim}${planBanner}${autoBanner}${advisorBanner}${sliceByWidth(statusLine, Math.max(10, statusMax))}${ansi.reset}`
|
|
274
|
+
return `${ansi.dim}${planBanner}${autoBanner}${advisorBanner}${engBanner}${sliceByWidth(statusLine, Math.max(10, statusMax))}${ansi.reset}`
|
|
253
275
|
}
|
|
254
276
|
|
|
255
277
|
// ====================================================================
|
|
@@ -315,7 +337,9 @@ export function renderFrame(state, agent, opts) {
|
|
|
315
337
|
function inputBoxStyle(state) {
|
|
316
338
|
let borderColor = C.tool
|
|
317
339
|
let title
|
|
318
|
-
if (state.
|
|
340
|
+
if (state.search) {
|
|
341
|
+
borderColor = C.tool; title = ` Search ${state.search.matches.length > 0 ? `(${state.search.index + 1}/${state.search.matches.length})` : state.search.query ? "(no match)" : ""} `
|
|
342
|
+
} else if (state.interruptPrompt) {
|
|
319
343
|
borderColor = C.warn; title = " Inject Message "
|
|
320
344
|
} else if (state.question) {
|
|
321
345
|
borderColor = C.tool; title = " Question "
|
|
@@ -369,6 +393,8 @@ function buildStatusLine(state, agent, { cols, slashCommands }) {
|
|
|
369
393
|
|
|
370
394
|
const taskHint = state.tasks.length > 0
|
|
371
395
|
? ` │ ✓${state.tasks.filter((t) => t.status === "done").length}/${state.tasks.length}` : ""
|
|
396
|
+
const turnHint = agent._currentTurn > 0 && agent._maxTurns > 0
|
|
397
|
+
? ` │ turn ${agent._currentTurn}/${agent._maxTurns}` : ""
|
|
372
398
|
const tk = state.tokens
|
|
373
399
|
const fmtK = (n) => (n >= 10000 ? `${Math.round(n / 1000)}k` : n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${n}`)
|
|
374
400
|
const cacheTotal = tk.cacheHit + tk.cacheMiss
|
|
@@ -383,5 +409,5 @@ function buildStatusLine(state, agent, { cols, slashCommands }) {
|
|
|
383
409
|
const ctxHint = ctxPct > 0
|
|
384
410
|
? ctxPct >= 80 ? ` │ ${ansi.reset}${C.warn}context ${ctxPct}%${ctxTokensHint}${ansi.reset}${ansi.dim}` : ` │ context ${ctxPct}%${ctxTokensHint}` : ""
|
|
385
411
|
const queueHint = state.queue.length > 0 ? ` │ queue: ${state.queue.length}` : ""
|
|
386
|
-
return ` ${statusText}${taskHint}${tokenHint}${ctxHint}${queueHint}${scrollHint} │ Enter: send${state.processing ? " (queue)" : ""} │ /: commands │ wheel/PgUp/PgDn: scroll │ Ctrl+I: inject │ Ctrl+C: exit`
|
|
412
|
+
return ` ${statusText}${taskHint}${turnHint}${tokenHint}${ctxHint}${queueHint}${scrollHint} │ Enter: send${state.processing ? " (queue)" : ""} │ /: commands │ wheel/PgUp/PgDn: scroll │ Ctrl+I: inject │ Ctrl+C: exit`
|
|
387
413
|
}
|
package/src/tui/render-loop.mjs
CHANGED
|
@@ -60,7 +60,7 @@ export function createRenderLoop(state, agent, ctx, pushLine, write = (s) => pro
|
|
|
60
60
|
|
|
61
61
|
// Expired output panels: prune once their close grace elapsed (done + closeAt in the past)
|
|
62
62
|
const now = Date.now()
|
|
63
|
-
for (const [name, p] of Object.entries(state.outputPanels)) {
|
|
63
|
+
for (const [name, p] of Object.entries(state.outputPanels ?? {})) {
|
|
64
64
|
if (p.done && (p.closeAt ?? 0) <= now) delete state.outputPanels[name]
|
|
65
65
|
}
|
|
66
66
|
|
|
@@ -103,6 +103,7 @@ export function createRenderLoop(state, agent, ctx, pushLine, write = (s) => pro
|
|
|
103
103
|
if (out.length || cursorSuffix) write(ansi.syncUpdateStart + out.join("") + ansi.syncUpdateEnd + cursorSuffix)
|
|
104
104
|
} catch (e) {
|
|
105
105
|
// Don't let a render error crash the TUI
|
|
106
|
+
if (process.env.THINCODER_DEBUG_RENDER) process.stderr.write(`[render-error] ${e?.stack ?? e}\n`)
|
|
106
107
|
}
|
|
107
108
|
}
|
|
108
109
|
|
|
@@ -31,27 +31,29 @@ import { handleHelpCommand } from "./cmd-help.mjs"
|
|
|
31
31
|
import { handleUpgradeCommand } from "./cmd-upgrade.mjs"
|
|
32
32
|
import { handleFoldCommand } from "./cmd-fold.mjs"
|
|
33
33
|
import { handleUndoCommand } from "./cmd-undo.mjs"
|
|
34
|
+
import { handleEngCommand } from "./cmd-eng.mjs"
|
|
34
35
|
|
|
35
36
|
export const SLASH_COMMANDS = [
|
|
36
37
|
{ name: "/plan", group: "Agent", desc: "toggle plan mode (design first, then implement)" },
|
|
37
38
|
{ name: "/auto", group: "Agent", desc: "toggle auto-approve" },
|
|
38
|
-
{ name: "/
|
|
39
|
+
{ name: "/eng", group: "Agent", desc: "toggle engineering mode — strict methodology enforcement" },
|
|
40
|
+
{ name: "/advisor", group: "Agent", desc: "advisor settings (toggle, model, thinking, guard)" },
|
|
39
41
|
{ name: "/model", group: "Agent", desc: "select model & manage providers" },
|
|
40
42
|
{ name: "/goal", group: "Agent", desc: "set/view/cancel long-term goal" },
|
|
41
43
|
{ name: "/think", group: "Agent", desc: "thinking mode & reasoning effort" },
|
|
42
|
-
{ name: "/config", group: "Agent", desc: "config management (embedding / agent)" },
|
|
43
44
|
{ name: "/upgrade", group: "System", desc: "check for updates & upgrade" },
|
|
44
|
-
{ name: "/
|
|
45
|
+
{ name: "/config", group: "System", desc: "agent config (embedding, proxy, turns, thresholds)" },
|
|
45
46
|
{ name: "/new", group: "Session", desc: "new session (old one archived to slot)" },
|
|
46
47
|
{ name: "/session", group: "Session", desc: "list/switch archived sessions" },
|
|
47
48
|
{ name: "/clear", group: "Session", desc: "clear screen" },
|
|
48
|
-
{ name: "/
|
|
49
|
+
{ name: "/fold", group: "Session", desc: "toggle result folding on/off" },
|
|
50
|
+
{ name: "/undo", group: "Session", desc: "undo recent file modifications" },
|
|
49
51
|
{ name: "/init", group: "Project", desc: "generate project AGENTS.md skeleton" },
|
|
50
52
|
{ name: "/skills", group: "Project", desc: "list project skills" },
|
|
51
|
-
{ name: "/mcp", group: "Project", desc: "
|
|
53
|
+
{ name: "/mcp", group: "Project", desc: "MCP servers (add, remove, connect, list)" },
|
|
52
54
|
{ name: "/reindex", group: "Project", desc: "rebuild memory index" },
|
|
53
|
-
{ name: "/
|
|
54
|
-
{ name: "/
|
|
55
|
+
{ name: "/extract", group: "Project", desc: "extract knowledge from session" },
|
|
56
|
+
{ name: "/restore", group: "System", desc: "restore checkpoint" },
|
|
55
57
|
{ name: "/exit", group: "System", desc: "exit" },
|
|
56
58
|
{ name: "/help", group: "System", desc: "this list" },
|
|
57
59
|
]
|
|
@@ -80,6 +82,7 @@ export const HANDLERS = {
|
|
|
80
82
|
"/upgrade": handleUpgradeCommand,
|
|
81
83
|
"/fold": handleFoldCommand,
|
|
82
84
|
"/undo": handleUndoCommand,
|
|
85
|
+
"/eng": handleEngCommand,
|
|
83
86
|
"/extract": handleExtractCommand,
|
|
84
87
|
"/help": handleHelpCommand,
|
|
85
88
|
}
|
|
@@ -132,6 +135,7 @@ export function createSlashCommands(ctx) {
|
|
|
132
135
|
}
|
|
133
136
|
if (cmd === "/config" && argIndex === 0) return match(["embedkey"])
|
|
134
137
|
if (cmd === "/goal" && argIndex === 0) return match(["set", "cancel"])
|
|
138
|
+
if (cmd === "/fold" && argIndex === 0) return match(["on", "off"])
|
|
135
139
|
if (cmd === "/mcp") {
|
|
136
140
|
if (argIndex === 0) return match(["add", "http", "ws", "stdio", "ai", "remove", "connect", "list"])
|
|
137
141
|
if (argIndex === 1 && (parts[1]?.toLowerCase() === "remove" || parts[1]?.toLowerCase() === "connect")) return match((agent.config?.mcp?.servers ?? []).map((s) => s.name))
|
package/src/tui/startup.mjs
CHANGED
|
@@ -48,9 +48,10 @@ export function showStartup(ctx) {
|
|
|
48
48
|
pushLabel(`── Restored previous session (${opts.restored.history.length} messages); /new for a fresh session ──`, C.warn)
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
// Hint when
|
|
52
|
-
|
|
53
|
-
|
|
51
|
+
// Hint when multiple sessions exist
|
|
52
|
+
const allSlots = listSlots(agent.cwd)
|
|
53
|
+
if (allSlots.length > 1) {
|
|
54
|
+
pushLine(`Tip: ${allSlots.length} sessions — /session to view/switch`, C.dim)
|
|
54
55
|
}
|
|
55
56
|
render()
|
|
56
57
|
}
|
package/src/tui/wizard.mjs
CHANGED
|
@@ -135,6 +135,7 @@ export function createWizard(ctx) {
|
|
|
135
135
|
if (existing) Object.assign(existing, { baseURL: f.baseURL, model: f.model, apiKey: f.key })
|
|
136
136
|
else agent.providers.push({ name: f.name, baseURL: f.baseURL, model: f.model, apiKey: f.key })
|
|
137
137
|
agent.activeProvider = f.name
|
|
138
|
+
agent.activeModel = null
|
|
138
139
|
agent.provider = { ...agent.providers.find((p) => p.name === f.name) }
|
|
139
140
|
if (agent.config?.agent?.compactThresholdAuto) {
|
|
140
141
|
const { resolveCompactThreshold } = await import("../config.mjs")
|
|
@@ -143,8 +144,10 @@ export function createWizard(ctx) {
|
|
|
143
144
|
await persistRaw((raw) => {
|
|
144
145
|
raw.providers = agent.providers
|
|
145
146
|
raw.activeProvider = f.name
|
|
147
|
+
raw.activeModel = undefined // reset to default model
|
|
146
148
|
})
|
|
147
149
|
agent.config.activeProvider = f.name
|
|
150
|
+
agent.config.activeModel = null
|
|
148
151
|
pushLabel(`❯ Setup`, ansi.bold + C.tool)
|
|
149
152
|
pushLine(`Setup complete: ${f.name} / ${f.model} (saved to config)`, C.tool)
|
|
150
153
|
// embedding key: if provided, enable vector search; if not, show how to enable later
|
package/src/tools/checkpoint.md
DELETED
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
List, create, and restore workspace snapshots (checkpoints). Git repositories only.
|
|
2
|
-
|
|
3
|
-
Parameters:
|
|
4
|
-
- action (required): "list" | "create" | "rewind" | "cat"
|
|
5
|
-
- id: snapshot id (required for rewind and cat; optional for list — when given, shows the file tree inside that snapshot)
|
|
6
|
-
- path: for rewind — restore only this single file; for cat — read this file's content from the snapshot
|
|
7
|
-
|
|
8
|
-
Notes:
|
|
9
|
-
- A checkpoint is AUTO-CREATED before every user task. If uncommitted work was destroyed (by you, a git command, or a failed refactor), use action=list then action=rewind with the latest id to recover it
|
|
10
|
-
- A checkpoint captures all uncommitted state: tracked-file changes (as a diff) plus copies of untracked files
|
|
11
|
-
- Rewind first snapshots the current state, so rewinding is itself reversible
|
|
12
|
-
- Rewind AUTO-RECOVERS: if git apply fails (corrupt patch), the pre-rewind state is restored — never lose data
|
|
13
|
-
- Create one manually before risky bulk operations
|
|
14
|
-
- list now shows which files changed (tracked + untracked); use path to recover individual files selectively
|
|
15
|
-
- cat reads a file's content from a snapshot without touching the worktree — useful for inspecting before rewinding
|
package/src/tools/git_diff.md
DELETED
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
Show git diff (unified format). Use to see uncommitted changes, staged changes, or diff against a specific ref.
|
|
2
|
-
|
|
3
|
-
Parameters:
|
|
4
|
-
- staged: Show staged changes (default false, shows working tree diff)
|
|
5
|
-
- path: File or directory to diff (default all)
|
|
6
|
-
- ref: Compare against a ref (default HEAD)
|
|
7
|
-
|
|
8
|
-
Notes:
|
|
9
|
-
- Only works inside a git repository
|
|
10
|
-
- Output is standard unified diff — LLMs understand this natively
|
|
11
|
-
- If no changes, returns "(no changes)"
|
package/src/tools/git_log.md
DELETED
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
Show recent git commit history. Use to understand the project's recent changes, conventions, and pace.
|
|
2
|
-
|
|
3
|
-
Parameters:
|
|
4
|
-
- count: Number of commits to show (default 10)
|
|
5
|
-
- path: File or directory to show history for (default all)
|
|
6
|
-
- oneline: Compact one-line-per-commit format (default false)
|
|
7
|
-
|
|
8
|
-
Notes:
|
|
9
|
-
- Only works inside a git repository
|
|
10
|
-
- Output includes hash, author, date, and message
|