thincoder 0.12.2 → 0.12.4
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 +29 -6
- package/package.json +3 -3
- 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 +134 -0
- package/src/agent/dispatch.mjs +54 -7
- package/src/agent/post-turn.mjs +70 -0
- package/src/agent/setup.mjs +95 -6
- 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 +152 -161
- package/src/cli/make-agent.mjs +1 -0
- package/src/cli/setup-wizard.mjs +1 -0
- package/src/config.mjs +34 -4
- package/src/context.mjs +47 -13
- package/src/generate-title.mjs +44 -0
- 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/provider/core.mjs +58 -2
- package/src/session.mjs +291 -94
- package/src/skills.mjs +48 -15
- package/src/tools/apply_patch.md +1 -1
- package/src/tools/checklist.mjs +4 -3
- package/src/tools/codemode.mjs +23 -11
- 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 +184 -66
- package/src/tui/ansi.mjs +4 -0
- package/src/tui/clipboard.mjs +9 -0
- package/src/tui/cmd-config.mjs +14 -26
- 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 +20 -9
- package/src/tui/key-handler.mjs +177 -9
- package/src/tui/layout.mjs +5 -5
- package/src/tui/markdown.mjs +52 -0
- package/src/tui/pickers.mjs +190 -45
- package/src/tui/render-conversation.mjs +54 -13
- package/src/tui/render-frame.mjs +39 -12
- package/src/tui/render-loop.mjs +2 -1
- package/src/tui/render.mjs +13 -7
- 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)
|
|
@@ -195,7 +335,11 @@ export function createPickers(ctx) {
|
|
|
195
335
|
if (!baseURL) return
|
|
196
336
|
const model = await askQuestion("Enter model name:")
|
|
197
337
|
if (!model) return
|
|
198
|
-
|
|
338
|
+
const format = (await askQuestion("API format [openai/anthropic/google] (default: openai):")).trim().toLowerCase()
|
|
339
|
+
const cfg = { name, baseURL, model }
|
|
340
|
+
if (format === "anthropic" || format === "google") cfg.format = format
|
|
341
|
+
else if (format && format !== "openai") return // unknown format → abort
|
|
342
|
+
agent.providers.push(cfg)
|
|
199
343
|
await persistRaw((raw) => { raw.providers = agent.providers })
|
|
200
344
|
const key = await askQuestion(`Enter API key for ${name} (skip if none):`)
|
|
201
345
|
if (key) await setProviderKey(name, key)
|
|
@@ -208,6 +352,7 @@ export function createPickers(ctx) {
|
|
|
208
352
|
if (preset.reasoningEffort) cfg.reasoningEffort = preset.reasoningEffort
|
|
209
353
|
if (preset.maxTokens) cfg.maxTokens = preset.maxTokens
|
|
210
354
|
if (preset.chatPath) cfg.chatPath = preset.chatPath
|
|
355
|
+
if (preset.format) cfg.format = preset.format
|
|
211
356
|
agent.providers.push(cfg)
|
|
212
357
|
await persistRaw((raw) => { raw.providers = agent.providers })
|
|
213
358
|
const key = await askQuestion(`Enter API key for ${se.name} (skip if none):`)
|
|
@@ -4,12 +4,36 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { ansi, C } from "./ansi.mjs"
|
|
6
6
|
import { formatTables, sanitizeDisplay, wrapText } from "./render.mjs"
|
|
7
|
+
import { renderMarkdownInline, renderMarkdownHeading } from "./markdown.mjs"
|
|
7
8
|
|
|
8
9
|
let _convCache = { key: "", cols: 0, lines: [] }
|
|
9
10
|
|
|
10
11
|
export function convCacheKey(state) {
|
|
11
12
|
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}|${
|
|
13
|
+
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"}`
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function highlightSearchMatches(text, query, matchesInLine, globalCurrentIndex, allMatches, lineIndex) {
|
|
17
|
+
if (!matchesInLine || matchesInLine.length === 0 || !query) return text
|
|
18
|
+
let result = ""
|
|
19
|
+
let lastEnd = 0
|
|
20
|
+
for (const startIdx of matchesInLine) {
|
|
21
|
+
result += text.substring(lastEnd, startIdx)
|
|
22
|
+
const endIdx = startIdx + query.length
|
|
23
|
+
const matchedText = text.substring(startIdx, endIdx)
|
|
24
|
+
|
|
25
|
+
// Find global index of this match
|
|
26
|
+
const gIdx = allMatches.findIndex(m => m.lineIndex === lineIndex && m.charIndex === startIdx)
|
|
27
|
+
|
|
28
|
+
if (gIdx === globalCurrentIndex) {
|
|
29
|
+
result += `\x1b[7m${matchedText}\x1b[27m` // Reverse video for current
|
|
30
|
+
} else {
|
|
31
|
+
result += `\x1b[33m\x1b[4m${matchedText}\x1b[24m\x1b[39m` // Yellow underline for others
|
|
32
|
+
}
|
|
33
|
+
lastEnd = endIdx
|
|
34
|
+
}
|
|
35
|
+
result += text.substring(lastEnd)
|
|
36
|
+
return result
|
|
13
37
|
}
|
|
14
38
|
|
|
15
39
|
function buildConvLines(state, cols) {
|
|
@@ -17,10 +41,20 @@ function buildConvLines(state, cols) {
|
|
|
17
41
|
if (_convCache.key === key && _convCache.cols === cols) return _convCache.lines
|
|
18
42
|
|
|
19
43
|
const convLines = []
|
|
20
|
-
for (
|
|
21
|
-
|
|
44
|
+
for (let i = 0; i < state.lines.length; i++) {
|
|
45
|
+
const l = state.lines[i]
|
|
46
|
+
let text = l.text
|
|
47
|
+
|
|
48
|
+
// Apply search highlighting
|
|
49
|
+
if (state.search && state.search.query && l._searchMatches) {
|
|
50
|
+
text = highlightSearchMatches(text, state.search.query, l._searchMatches, state.search.index, state.search.matches, i)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
for (const line of formatTables(sanitizeDisplay(text), cols - 1)) {
|
|
22
54
|
for (const wrapped of wrapText(line, cols - 1)) {
|
|
23
|
-
|
|
55
|
+
// Lightweight markdown display (IK5VW3): headings bold + inline markers styled.
|
|
56
|
+
// Runs AFTER wrapping so the ANSI it inserts never skews width math.
|
|
57
|
+
convLines.push({ text: renderMarkdownInline(renderMarkdownHeading(wrapped)), color: l.color, _foldId: l._foldId })
|
|
24
58
|
}
|
|
25
59
|
}
|
|
26
60
|
}
|
|
@@ -29,21 +63,28 @@ function buildConvLines(state, cols) {
|
|
|
29
63
|
convLines.push({ text: wrapped, color: C.reason })
|
|
30
64
|
}
|
|
31
65
|
}
|
|
66
|
+
if (state._advisorThink || state.advisorStreaming) {
|
|
67
|
+
const thinkLines = state._advisorThink ? sanitizeDisplay(state._advisorThink).split("\n") : []
|
|
68
|
+
const mainLines = state.advisorStreaming
|
|
69
|
+
? formatTables(sanitizeDisplay(state.advisorStreaming), cols - 3)
|
|
70
|
+
: []
|
|
71
|
+
const allLines = [...thinkLines.map(l => ({ text: l, color: C.reason })), ...mainLines.map(l => ({ text: l, color: C.text }))]
|
|
72
|
+
const truncated = allLines.length > 5
|
|
73
|
+
if (truncated) convLines.push({ text: "│ …", color: C.dim })
|
|
74
|
+
const shown = truncated ? allLines.slice(-5) : allLines
|
|
75
|
+
for (const { text, color } of shown) {
|
|
76
|
+
for (const wrapped of wrapText(text, cols - 3)) {
|
|
77
|
+
convLines.push({ text: `│ ${wrapped}`, color })
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
32
81
|
if (state.streaming) {
|
|
33
82
|
for (const line of formatTables(sanitizeDisplay(state.streaming), cols - 1)) {
|
|
34
83
|
for (const wrapped of wrapText(line, cols - 1)) {
|
|
35
|
-
convLines.push({ text: wrapped, color: C.text })
|
|
84
|
+
convLines.push({ text: renderMarkdownInline(renderMarkdownHeading(wrapped)), color: C.text })
|
|
36
85
|
}
|
|
37
86
|
}
|
|
38
87
|
}
|
|
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
88
|
// Fold long blocks (> 8 consecutive dim lines)
|
|
48
89
|
const FOLD_LINES = 8
|
|
49
90
|
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}`)
|
|
@@ -188,6 +196,7 @@ export function renderInputBox(state, W, boxLines, cols, inputLayout, inputOffse
|
|
|
188
196
|
if (title === " Input " || title === " Processing... ") parts.push(" Ctrl+U clear ")
|
|
189
197
|
if (title === " Question ") parts.push(" Enter submit ")
|
|
190
198
|
if (title === " Inject Message ") parts.push(" Enter send, Esc cancel ")
|
|
199
|
+
parts.push(" Shift+Enter / Ctrl+J newline ")
|
|
191
200
|
parts.push(" Ctrl+V paste ")
|
|
192
201
|
parts.push(" Ctrl+I inject ")
|
|
193
202
|
const hint = parts.join("")
|
|
@@ -202,8 +211,21 @@ export function renderInputBox(state, W, boxLines, cols, inputLayout, inputOffse
|
|
|
202
211
|
const curLine = (!hasOverlay && inputLayout) ? inputLayout.cursorLine - (inputOffset ?? 0) : -1
|
|
203
212
|
const curCol = (!hasOverlay && inputLayout) ? inputLayout.cursorCol : -1
|
|
204
213
|
|
|
214
|
+
// Interrupt prompt 空文本占位符:灰色提示用户该做什么
|
|
215
|
+
const isInterruptEmpty = state.interruptPrompt && !state.interruptPrompt.text
|
|
216
|
+
const interruptPlaceholder = "Type message to inject (Enter send, Esc cancel)"
|
|
217
|
+
|
|
205
218
|
for (let li = 0; li < boxLines.length; li++) {
|
|
206
219
|
const l = boxLines[li]
|
|
220
|
+
// 第一行且 interrupt prompt 为空时,用灰色占位符替代 prompt
|
|
221
|
+
if (li === 0 && isInterruptEmpty) {
|
|
222
|
+
const prompt = "▸ "
|
|
223
|
+
const ph = `${prompt}${interruptPlaceholder}`
|
|
224
|
+
const phWidth = stringWidth(ph)
|
|
225
|
+
const fill = " ".repeat(Math.max(0, W - 4 - phWidth))
|
|
226
|
+
out.push(`${borderColor}│${ansi.reset} ${ansi.dim}${ph}${ansi.reset}${fill} ${borderColor}│${ansi.reset}`)
|
|
227
|
+
continue
|
|
228
|
+
}
|
|
207
229
|
const original = sliceByWidth(l, W - 4)
|
|
208
230
|
let content = original
|
|
209
231
|
const contentWidth = stringWidth(original)
|
|
@@ -247,9 +269,10 @@ export function renderStatus(state, agent, cols, slashCommands) {
|
|
|
247
269
|
const autoBanner = agent.autoApprove ? `${C.warn} AUTO${ansi.reset}${ansi.dim}│` : ""
|
|
248
270
|
const planBanner = agent.planMode ? `${C.tool} PLAN${ansi.reset}${ansi.dim}│` : ""
|
|
249
271
|
const advisorBanner = agent.config?.advisor?.enabled ? `${C.advisor} ADVISOR${ansi.reset}${ansi.dim}│` : ""
|
|
250
|
-
const
|
|
272
|
+
const engBanner = agent.config?.agent?.engineering ? `${C.advisor} ENG${ansi.reset}${ansi.dim}│` : ""
|
|
273
|
+
const bannerPrefix = (agent.planMode ? " PLAN│ " : "") + (agent.autoApprove ? " AUTO│ " : "") + (agent.config?.advisor?.enabled ? " ADVISOR│ " : "") + (agent.config?.agent?.engineering ? " ENG│ " : "")
|
|
251
274
|
const statusMax = cols - 1 - (bannerPrefix ? stringWidth(bannerPrefix) : 0)
|
|
252
|
-
return `${ansi.dim}${planBanner}${autoBanner}${advisorBanner}${sliceByWidth(statusLine, Math.max(10, statusMax))}${ansi.reset}`
|
|
275
|
+
return `${ansi.dim}${planBanner}${autoBanner}${advisorBanner}${engBanner}${sliceByWidth(statusLine, Math.max(10, statusMax))}${ansi.reset}`
|
|
253
276
|
}
|
|
254
277
|
|
|
255
278
|
// ====================================================================
|
|
@@ -315,7 +338,9 @@ export function renderFrame(state, agent, opts) {
|
|
|
315
338
|
function inputBoxStyle(state) {
|
|
316
339
|
let borderColor = C.tool
|
|
317
340
|
let title
|
|
318
|
-
if (state.
|
|
341
|
+
if (state.search) {
|
|
342
|
+
borderColor = C.tool; title = ` Search ${state.search.matches.length > 0 ? `(${state.search.index + 1}/${state.search.matches.length})` : state.search.query ? "(no match)" : ""} `
|
|
343
|
+
} else if (state.interruptPrompt) {
|
|
319
344
|
borderColor = C.warn; title = " Inject Message "
|
|
320
345
|
} else if (state.question) {
|
|
321
346
|
borderColor = C.tool; title = " Question "
|
|
@@ -369,6 +394,8 @@ function buildStatusLine(state, agent, { cols, slashCommands }) {
|
|
|
369
394
|
|
|
370
395
|
const taskHint = state.tasks.length > 0
|
|
371
396
|
? ` │ ✓${state.tasks.filter((t) => t.status === "done").length}/${state.tasks.length}` : ""
|
|
397
|
+
const turnHint = agent._currentTurn > 0 && agent._maxTurns > 0
|
|
398
|
+
? ` │ turn ${agent._currentTurn}/${agent._maxTurns}` : ""
|
|
372
399
|
const tk = state.tokens
|
|
373
400
|
const fmtK = (n) => (n >= 10000 ? `${Math.round(n / 1000)}k` : n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${n}`)
|
|
374
401
|
const cacheTotal = tk.cacheHit + tk.cacheMiss
|
|
@@ -383,5 +410,5 @@ function buildStatusLine(state, agent, { cols, slashCommands }) {
|
|
|
383
410
|
const ctxHint = ctxPct > 0
|
|
384
411
|
? ctxPct >= 80 ? ` │ ${ansi.reset}${C.warn}context ${ctxPct}%${ctxTokensHint}${ansi.reset}${ansi.dim}` : ` │ context ${ctxPct}%${ctxTokensHint}` : ""
|
|
385
412
|
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`
|
|
413
|
+
return ` ${statusText}${taskHint}${turnHint}${tokenHint}${ctxHint}${queueHint}${scrollHint} │ Enter: send${state.processing ? " (queue)" : ""} │ /: commands │ wheel/PgUp/PgDn: scroll │ Ctrl+I: inject │ Ctrl+C: exit (×2)`
|
|
387
414
|
}
|
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
|
|
package/src/tui/render.mjs
CHANGED
|
@@ -130,18 +130,23 @@ function renderTable(block, width) {
|
|
|
130
130
|
return out
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
-
/** Input area layout: wrap input buffer into lines, also compute cursor (row, col) position (display width)
|
|
133
|
+
/** Input area layout: wrap input buffer into lines, also compute cursor (row, col) position (display width).
|
|
134
|
+
* Every line carries a 2-column prefix so all content left-edges align: first line gets the
|
|
135
|
+
* `▸ ` prompt, continuation lines (wrap or explicit \n) get 2 spaces. Content width is
|
|
136
|
+
* `width - 2` on every line. A trailing \n flushes an empty line so the cursor's row exists —
|
|
137
|
+
* without it the box wouldn't grow for multiline input and the cursor row would be out of range. */
|
|
134
138
|
export function layoutInput(chars, cursor, width) {
|
|
135
|
-
const PROMPT = "\u25b8 "
|
|
139
|
+
const PROMPT = "\u25b8 " // first-line prefix (display width 2)
|
|
140
|
+
const CONT = " " // continuation prefix (width 2) — keeps left edge aligned
|
|
136
141
|
const lines = []
|
|
137
142
|
let cursorLine = 0
|
|
138
143
|
let cursorCol = 0
|
|
139
144
|
let cur = ""
|
|
140
145
|
let col = 0
|
|
141
146
|
let firstLine = true
|
|
142
|
-
const avail = () =>
|
|
147
|
+
const avail = () => width - 2 // every line reserves 2 cols for its prefix
|
|
143
148
|
const flush = () => {
|
|
144
|
-
lines.push((firstLine ? PROMPT :
|
|
149
|
+
lines.push((firstLine ? PROMPT : CONT) + cur)
|
|
145
150
|
firstLine = false
|
|
146
151
|
cur = ""
|
|
147
152
|
col = 0
|
|
@@ -153,19 +158,20 @@ export function layoutInput(chars, cursor, width) {
|
|
|
153
158
|
if (col + w > avail()) flush()
|
|
154
159
|
if (i === cursor) {
|
|
155
160
|
cursorLine = lines.length
|
|
156
|
-
cursorCol =
|
|
161
|
+
cursorCol = 2 + col
|
|
157
162
|
}
|
|
158
163
|
cur += ch
|
|
159
164
|
col += w
|
|
160
165
|
} else {
|
|
161
166
|
if (i === cursor) {
|
|
162
167
|
cursorLine = lines.length
|
|
163
|
-
cursorCol =
|
|
168
|
+
cursorCol = 2 + col
|
|
164
169
|
}
|
|
165
170
|
if (ch === "\n") flush()
|
|
166
171
|
}
|
|
167
172
|
}
|
|
168
|
-
|
|
173
|
+
const endsWithNewline = chars.length > 0 && chars[chars.length - 1] === "\n"
|
|
174
|
+
if (cur || lines.length === 0 || endsWithNewline) flush()
|
|
169
175
|
return { lines, cursorLine, cursorCol }
|
|
170
176
|
}
|
|
171
177
|
|