thincoder 0.12.1 → 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.
Files changed (77) hide show
  1. package/README.md +18 -5
  2. package/package.json +1 -1
  3. package/src/advisor/history.mjs +112 -0
  4. package/src/advisor/messages.mjs +182 -0
  5. package/src/advisor/repos.mjs +133 -0
  6. package/src/advisor/run.mjs +346 -0
  7. package/src/advisor.mjs +109 -509
  8. package/src/agent/completion.mjs +119 -0
  9. package/src/agent/dispatch.mjs +54 -7
  10. package/src/agent/post-turn.mjs +70 -0
  11. package/src/agent/setup.mjs +93 -5
  12. package/src/agent-tools/advisor.mjs +159 -12
  13. package/src/agent-tools/eng.mjs +64 -0
  14. package/src/agent-tools/subagent.mjs +73 -3
  15. package/src/agent-tools/task.mjs +45 -6
  16. package/src/agent-tools/verify.mjs +18 -0
  17. package/src/agent-tools.mjs +1 -0
  18. package/src/agent.mjs +110 -150
  19. package/src/cli/make-agent.mjs +1 -0
  20. package/src/cli/setup-wizard.mjs +1 -0
  21. package/src/config.mjs +22 -4
  22. package/src/prompts/advisor-design.md +43 -0
  23. package/src/prompts/advisor-round1.md +11 -4
  24. package/src/prompts/advisor-round2.md +12 -7
  25. package/src/prompts/advisor-round3.md +11 -6
  26. package/src/prompts/coder.md +9 -3
  27. package/src/prompts/discipline.md +12 -96
  28. package/src/prompts/eng-coder.md +34 -0
  29. package/src/prompts/engineering-sub.md +12 -0
  30. package/src/prompts/engineering.md +96 -0
  31. package/src/prompts/main.md +1 -1
  32. package/src/prompts/methodology-template.md +39 -0
  33. package/src/prompts/plan.md +2 -2
  34. package/src/prompts/system.md +43 -61
  35. package/src/session.mjs +270 -89
  36. package/src/skills.mjs +48 -15
  37. package/src/tools/apply_patch.md +1 -1
  38. package/src/tools/codemode.mjs +10 -4
  39. package/src/tools/delete.md +1 -0
  40. package/src/tools/edit.md +1 -1
  41. package/src/tools/execute.md +5 -0
  42. package/src/tools/file.mjs +4 -0
  43. package/src/tools/git.md +15 -0
  44. package/src/tools/git.mjs +1 -6
  45. package/src/tools/lint.md +8 -0
  46. package/src/tools/linter.mjs +1 -5
  47. package/src/tools/lsp.md +7 -0
  48. package/src/tools/lsp.mjs +8 -9
  49. package/src/tools/patch.mjs +1 -29
  50. package/src/tools/read_image.md +5 -1
  51. package/src/tools/system.mjs +1 -1
  52. package/src/tools/web.mjs +3 -3
  53. package/src/tui/agent-turn.mjs +169 -66
  54. package/src/tui/cmd-config.mjs +12 -0
  55. package/src/tui/cmd-eng.mjs +44 -0
  56. package/src/tui/cmd-exit.mjs +6 -16
  57. package/src/tui/cmd-fold.mjs +3 -4
  58. package/src/tui/cmd-model.mjs +11 -6
  59. package/src/tui/cmd-new.mjs +5 -5
  60. package/src/tui/cmd-session.mjs +21 -11
  61. package/src/tui/cmd-think.mjs +1 -0
  62. package/src/tui/index.mjs +7 -6
  63. package/src/tui/key-handler.mjs +132 -4
  64. package/src/tui/layout.mjs +5 -5
  65. package/src/tui/pickers.mjs +184 -44
  66. package/src/tui/render-conversation.mjs +49 -11
  67. package/src/tui/render-frame.mjs +38 -12
  68. package/src/tui/render-loop.mjs +2 -1
  69. package/src/tui/slash-commands.mjs +11 -7
  70. package/src/tui/startup.mjs +4 -3
  71. package/src/tui/wizard.mjs +3 -0
  72. package/src/tools/checkpoint.md +0 -15
  73. package/src/tools/git_diff.md +0 -11
  74. package/src/tools/git_log.md +0 -10
  75. package/src/tools/git_status.md +0 -8
  76. package/src/tools/linter.md +0 -13
  77. package/src/tools/syntax_check.md +0 -10
@@ -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 = buildModelEntries()
135
+ const entries = buildProviderEntries()
89
136
  const items = entries.filter((e) => e.type === "item")
90
137
  const current = items.findIndex(
91
- (e) => e.action === "switch" && e.provider === agent.activeProvider && e.model === agent.provider.model)
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 === "switch") {
98
- await selectModel(e).catch((err) => pushLine(`[error] ${err.message}`, C.error))
99
- return
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
- /** 后台拉取模型列表并 splice entries;更新时按 entryKey 恢复用户光标下的选中项 */
108
- async function fetchModels(entries) {
109
- const { listModels } = await import("../provider/index.mjs")
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
- }))
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
- function buildModelEntries() {
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
- 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 ? "●" : "" })
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: "Provider Management" })
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}|${Object.keys(state.toolStreams).length}|${state.foldEnabled !== false ? "f" : "u"}`
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 (const l of state.lines) {
21
- for (const line of formatTables(sanitizeDisplay(l.text), cols - 1)) {
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
@@ -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.values(state.outputPanels).filter((p) => !p.done || (p.closeAt ?? 0) > Date.now())
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 perPanel = active.map((p) => panelLines(p).slice(-linesPerPanel))
119
- for (const lines of perPanel) {
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
- // Fill remaining rows to match panelH exactly
126
- const used = perPanel.reduce((s, lines) => s + lines.length, 0)
127
- for (let i = used; i < panelH; i++) out.push("")
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
- // 标题行:左侧标题 + filter(截断防撑破帧),右侧位置指示(按键提示在状态栏,不重复)
158
+ // 标题行:左侧标题 + 过滤输入提示/内容(截断防撑破帧),右侧位置指示
153
159
  const p = state.picker
154
160
  const right = p && p.filteredItems?.length ? `${p.index + 1}/${p.filteredItems.length} ` : ""
155
- const rawLeft = p ? ` ❯ ${p.title}${p.filter ? ` filter: ${p.filter}` : ""} ` : " ❯ Setup "
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 bannerPrefix = (agent.planMode ? " PLAN│ " : "") + (agent.autoApprove ? " AUTO│ " : "") + (agent.config?.advisor?.enabled ? " ADVISOR│ " : "")
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.interruptPrompt) {
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
  }
@@ -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: "/advisor", group: "Agent", desc: "toggle advisor review & select model" },
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: "/fold", group: "System", desc: "toggle result folding on/off" },
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: "/extract", group: "Session", desc: "extract knowledge from session" },
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: "manage MCP servers" },
53
+ { name: "/mcp", group: "Project", desc: "MCP servers (add, remove, connect, list)" },
52
54
  { name: "/reindex", group: "Project", desc: "rebuild memory index" },
53
- { name: "/restore", group: "Project", desc: "restore checkpoint" },
54
- { name: "/undo", group: "Project", desc: "undo recent file modifications" },
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))
@@ -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 archived slots exist
52
- if (listSlots(agent.cwd).length > 0) {
53
- pushLine("Tip: archived sessions available — /session to view/switch", C.dim)
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
  }
@@ -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
@@ -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
@@ -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)"
@@ -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