thincoder 0.11.1 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -0
- package/package.json +1 -1
- package/src/advisor.mjs +206 -31
- package/src/agent/helpers.mjs +11 -2
- package/src/agent.mjs +14 -7
- package/src/cli/make-agent.mjs +20 -0
- package/src/mcp/transport-stdio.mjs +4 -3
- package/src/mcp.mjs +1 -1
- package/src/prompts/advisor-round1.md +5 -5
- package/src/prompts/advisor-round2.md +4 -4
- package/src/prompts/advisor-round3.md +3 -3
- package/src/prompts/discipline.md +1 -1
- package/src/skills.mjs +67 -31
- package/src/tools/shared.mjs +27 -2
- package/src/tui/agent-turn.mjs +57 -12
- package/src/tui/cmd-advisor.mjs +190 -95
- package/src/tui/cmd-auto.mjs +6 -8
- package/src/tui/cmd-mcp.mjs +4 -2
- package/src/tui/cmd-plan.mjs +6 -8
- package/src/tui/cmd-think.mjs +93 -70
- package/src/tui/index.mjs +2 -1
- package/src/tui/interaction.mjs +2 -1
- package/src/tui/key-handler.mjs +0 -2
- package/src/tui/layout.mjs +3 -2
- package/src/tui/render-frame.mjs +67 -65
- package/src/tui/render-loop.mjs +43 -114
package/src/skills.mjs
CHANGED
|
@@ -1,52 +1,80 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* skills.mjs — skill system
|
|
3
|
-
* Discovers .md skill files from .thincoder/skills/ directory,
|
|
3
|
+
* Discovers .md skill files AND subdirectory SKILL.md from .thincoder/skills/ directory,
|
|
4
4
|
* injects into system prompt for agent to load on demand.
|
|
5
5
|
* Use the skill tool to activate a specific skill; content is written into conversation history wrapped in <skill-loaded>.
|
|
6
|
+
*
|
|
7
|
+
* Supported formats:
|
|
8
|
+
* .thincoder/skills/my-skill.md (flat, name = "my-skill")
|
|
9
|
+
* .thincoder/skills/my-skill/SKILL.md (subdirectory, name = "my-skill")
|
|
6
10
|
*/
|
|
7
11
|
|
|
8
12
|
import { readFile, readdir, stat } from "node:fs/promises"
|
|
9
13
|
import { join } from "node:path"
|
|
10
14
|
|
|
15
|
+
/** Valid skill name pattern (alphanumeric + hyphens/underscores) */
|
|
16
|
+
const NAME_RE = /^[a-zA-Z0-9_-]+$/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Try to read a skill from a path, return { name, path, description } or null.
|
|
20
|
+
*/
|
|
21
|
+
async function tryReadSkill(dir, name, filePath) {
|
|
22
|
+
try {
|
|
23
|
+
const s = await stat(filePath)
|
|
24
|
+
if (!s.isFile()) return null
|
|
25
|
+
const head = await readFile(filePath, "utf8")
|
|
26
|
+
const body = head.slice(0, 400).split("\n")
|
|
27
|
+
let desc = ""
|
|
28
|
+
let inFrontmatter = false
|
|
29
|
+
for (const line of body) {
|
|
30
|
+
const t = line.trim()
|
|
31
|
+
if (t === "---") { inFrontmatter = !inFrontmatter; continue }
|
|
32
|
+
if (inFrontmatter) continue
|
|
33
|
+
if (t && !t.startsWith("#")) {
|
|
34
|
+
desc = t.slice(0, 120)
|
|
35
|
+
break
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return { name, path: filePath, description: desc || "(no description)" }
|
|
39
|
+
} catch {
|
|
40
|
+
return null
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
11
44
|
/**
|
|
12
45
|
* Scan .thincoder/skills/ directory, return skill list.
|
|
13
|
-
*
|
|
46
|
+
* Supports flat .md files and subdirectories with SKILL.md inside.
|
|
47
|
+
* Each skill: { name, path, description } — name derived from filename or directory.
|
|
14
48
|
* Returns empty array if directory is missing or empty.
|
|
15
49
|
*/
|
|
16
50
|
export async function loadSkills(cwd) {
|
|
17
51
|
const dir = join(cwd, ".thincoder", "skills")
|
|
18
52
|
let entries
|
|
19
53
|
try {
|
|
20
|
-
entries = await readdir(dir)
|
|
54
|
+
entries = await readdir(dir, { withFileTypes: true })
|
|
21
55
|
} catch {
|
|
22
56
|
return []
|
|
23
57
|
}
|
|
24
58
|
const skills = []
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
skills.push({ name: name.replace(/\.md$/, ""), path: p, description: desc || "(no description)" })
|
|
47
|
-
} catch {
|
|
48
|
-
// Read failure — skip
|
|
49
|
-
}
|
|
59
|
+
const added = new Set()
|
|
60
|
+
|
|
61
|
+
// Pass 1: subdirectories (higher priority — standard convention)
|
|
62
|
+
for (const entry of entries) {
|
|
63
|
+
if (!entry.isDirectory()) continue
|
|
64
|
+
if (!NAME_RE.test(entry.name)) continue
|
|
65
|
+
const skill = await tryReadSkill(dir, entry.name, join(dir, entry.name, "SKILL.md"))
|
|
66
|
+
if (skill) { skills.push(skill); added.add(entry.name) }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Pass 2: flat .md files (backward compat; skipped if subdirectory with same name exists)
|
|
70
|
+
for (const entry of entries) {
|
|
71
|
+
if (!entry.isFile()) continue
|
|
72
|
+
const m = entry.name.match(/^([a-zA-Z0-9_-]+)\.md$/)
|
|
73
|
+
if (!m) continue
|
|
74
|
+
const name = m[1]
|
|
75
|
+
if (added.has(name)) continue
|
|
76
|
+
const skill = await tryReadSkill(dir, name, join(dir, entry.name))
|
|
77
|
+
if (skill) { skills.push(skill); added.add(name) }
|
|
50
78
|
}
|
|
51
79
|
return skills
|
|
52
80
|
}
|
|
@@ -66,13 +94,21 @@ export function formatSkillListing(skills) {
|
|
|
66
94
|
|
|
67
95
|
/**
|
|
68
96
|
* Read the full content of a specific skill file.
|
|
97
|
+
* Tries subdirectory format (name/SKILL.md) first, then flat format (name.md).
|
|
69
98
|
* Returns text, or null if not found.
|
|
70
99
|
*/
|
|
71
100
|
export async function readSkill(cwd, name) {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
101
|
+
if (!NAME_RE.test(name)) return null
|
|
102
|
+
|
|
103
|
+
// Try subdirectory format: name/SKILL.md
|
|
104
|
+
try {
|
|
105
|
+
const p = join(cwd, ".thincoder", "skills", name, "SKILL.md")
|
|
106
|
+
return await readFile(p, "utf8")
|
|
107
|
+
} catch { /* not found, try flat */ }
|
|
108
|
+
|
|
109
|
+
// Fallback to flat format: name.md
|
|
75
110
|
try {
|
|
111
|
+
const p = join(cwd, ".thincoder", "skills", `${name}.md`)
|
|
76
112
|
return await readFile(p, "utf8")
|
|
77
113
|
} catch {
|
|
78
114
|
return null
|
package/src/tools/shared.mjs
CHANGED
|
@@ -203,9 +203,34 @@ export function shellSegments(command) {
|
|
|
203
203
|
return command.split(/&&|\|\||>>|\$\(|[;|\n<>]|`|[(]/)
|
|
204
204
|
}
|
|
205
205
|
|
|
206
|
-
/**
|
|
206
|
+
/**
|
|
207
|
+
* Blank out quoted regions (single/double/backtick) with spaces, preserving length.
|
|
208
|
+
* Lets safety checks ignore shell metacharacters inside quoted script bodies —
|
|
209
|
+
* e.g. `node -e "if (a > b) …"` comparisons are not redirections.
|
|
210
|
+
*/
|
|
211
|
+
function blankQuoted(command) {
|
|
212
|
+
let out = ""
|
|
213
|
+
let quote = null
|
|
214
|
+
for (let i = 0; i < command.length; i++) {
|
|
215
|
+
const ch = command[i]
|
|
216
|
+
if (quote) {
|
|
217
|
+
if (ch === "\\" && quote !== "'") { out += " "; i++; out += " "; continue }
|
|
218
|
+
if (ch === quote) quote = null
|
|
219
|
+
out += " "
|
|
220
|
+
} else if (ch === "'" || ch === '"' || ch === "`") {
|
|
221
|
+
quote = ch
|
|
222
|
+
out += " "
|
|
223
|
+
} else {
|
|
224
|
+
out += ch
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return out
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Detect shell output/input redirection (> >> < followed by filename) outside quoted regions */
|
|
207
231
|
export function hasFileRedirection(command) {
|
|
208
|
-
|
|
232
|
+
const bare = blankQuoted(command)
|
|
233
|
+
return /(^|[\s;&|])>{1,2}\s*\S/.test(bare) || /(^|[\s;&|])<\s*\S/.test(bare)
|
|
209
234
|
}
|
|
210
235
|
|
|
211
236
|
/** Whether a single command segment is a destructive non-git command (conservative: prefer false positives) */
|
package/src/tui/agent-turn.mjs
CHANGED
|
@@ -105,7 +105,11 @@ export async function runAgentTurn(ctx, text) {
|
|
|
105
105
|
flushStream()
|
|
106
106
|
ensureAssistantLabel()
|
|
107
107
|
state.currentTool = name
|
|
108
|
-
|
|
108
|
+
// Advisor: tag the round in the tool title — the model's own "第N轮" narration
|
|
109
|
+
// is unreliable (it glues onto the previous line), so the round belongs here.
|
|
110
|
+
const roundTag = name === "advisor" ? ` (round ${(agent._advisorRound || 0) + 1})` : ""
|
|
111
|
+
const argSummary = summarize(args)
|
|
112
|
+
pushLine(` [tool] ${name}${roundTag}${argSummary ? ` ${argSummary}` : ""}`, C.tool)
|
|
109
113
|
},
|
|
110
114
|
onToolResult: (name, result) => {
|
|
111
115
|
state.currentTool = null
|
|
@@ -137,8 +141,12 @@ export async function runAgentTurn(ctx, text) {
|
|
|
137
141
|
const panel = state.outputPanels[name]
|
|
138
142
|
if (panel) {
|
|
139
143
|
delete state.toolStreams[name]
|
|
140
|
-
panel
|
|
141
|
-
|
|
144
|
+
// Keep the panel visible for a 3s grace period (layout filters by closeAt);
|
|
145
|
+
// the render loop prunes it once expired. No defer hacks needed — row-diff
|
|
146
|
+
// repaints whatever should be on screen.
|
|
147
|
+
panel.done = true
|
|
148
|
+
panel.closeAt = Date.now() + 3000
|
|
149
|
+
scheduleRender()
|
|
142
150
|
if (name === "advisor") {
|
|
143
151
|
const text = String(result ?? "")
|
|
144
152
|
const lines = text.split("\n")
|
|
@@ -151,10 +159,8 @@ export async function runAgentTurn(ctx, text) {
|
|
|
151
159
|
const summary = formatPanelSummary(name, result)
|
|
152
160
|
if (summary) pushLine(` ${summary}`, C.dim)
|
|
153
161
|
}
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
if (state.processing) render()
|
|
157
|
-
}, 3000)
|
|
162
|
+
// Trigger a repaint after the grace period so the pruned panel disappears
|
|
163
|
+
setTimeout(() => render(), 3000)
|
|
158
164
|
} else if (stream) {
|
|
159
165
|
const tail = stream.trimEnd().slice(-4000)
|
|
160
166
|
if (tail) pushLine(tail, C.dim)
|
|
@@ -167,22 +173,45 @@ export async function runAgentTurn(ctx, text) {
|
|
|
167
173
|
},
|
|
168
174
|
onToolOutput: (name, chunk) => {
|
|
169
175
|
// Route streaming output to a panel if one exists or was requested via outputPanel flag.
|
|
176
|
+
// Chunk may be a string or { kind, text } — kind ("think" | "text" | "tool") drives
|
|
177
|
+
// per-kind coloring in renderOutput so reasoning / answer / tool progress are distinct.
|
|
170
178
|
let panel = state.outputPanels[name]
|
|
171
179
|
if (!panel) {
|
|
172
180
|
// Lazy-create panel: defensive against race conditions where setupOutputPanel
|
|
173
181
|
// hasn't fired yet or the callbacks chain dropped it (subagent relay, reconnect, etc.)
|
|
174
|
-
state.outputPanels[name] = {
|
|
182
|
+
state.outputPanels[name] = { parts: [], len: 0, done: false }
|
|
175
183
|
panel = state.outputPanels[name]
|
|
176
184
|
}
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
185
|
+
const part = typeof chunk === "string"
|
|
186
|
+
? { kind: "text", text: chunk }
|
|
187
|
+
: { kind: chunk?.kind ?? "text", text: String(chunk?.text ?? "") }
|
|
188
|
+
if (!part.text) return
|
|
189
|
+
// Separate phase transitions with a newline — think → answer → tool progress
|
|
190
|
+
// would otherwise glue onto each other mid-line.
|
|
191
|
+
const last = panel.parts[panel.parts.length - 1]
|
|
192
|
+
if (last && last.kind !== part.kind && !last.text.endsWith("\n") && !part.text.startsWith("\n")) {
|
|
193
|
+
part.text = "\n" + part.text
|
|
194
|
+
}
|
|
195
|
+
panel.parts.push(part)
|
|
196
|
+
panel.len += part.text.length
|
|
197
|
+
// Cap at 4000 chars, trimming oldest parts first
|
|
198
|
+
while (panel.len > 4000 && panel.parts.length > 1) {
|
|
199
|
+
const first = panel.parts[0]
|
|
200
|
+
const excess = panel.len - 4000
|
|
201
|
+
if (first.text.length <= excess) {
|
|
202
|
+
panel.len -= first.text.length
|
|
203
|
+
panel.parts.shift()
|
|
204
|
+
} else {
|
|
205
|
+
first.text = first.text.slice(excess)
|
|
206
|
+
panel.len -= excess
|
|
207
|
+
}
|
|
208
|
+
}
|
|
180
209
|
scheduleRender()
|
|
181
210
|
},
|
|
182
211
|
onPermissionRequest: (name, args) => askPermission(name, args),
|
|
183
212
|
onQuestion: (text, options) => askQuestion(text, options),
|
|
184
213
|
setupOutputPanel: (name) => {
|
|
185
|
-
state.outputPanels[name] = {
|
|
214
|
+
state.outputPanels[name] = { parts: [], len: 0, done: false }
|
|
186
215
|
scheduleRender()
|
|
187
216
|
},
|
|
188
217
|
onCompress: () => {
|
|
@@ -307,11 +336,27 @@ export async function runAgentTurn(ctx, text) {
|
|
|
307
336
|
/** Extract a one-line summary from a panel tool's output */
|
|
308
337
|
function formatPanelSummary(name, result) {
|
|
309
338
|
if (name === "verify") return _verifySummary(result)
|
|
339
|
+
if (name === "bash") return _bashSummary(result)
|
|
310
340
|
// Default: first non-empty line
|
|
311
341
|
const first = result.split("\n").find((l) => l.trim())
|
|
312
342
|
return first ? `${name}: ${first.slice(0, 100)}` : null
|
|
313
343
|
}
|
|
314
344
|
|
|
345
|
+
/**
|
|
346
|
+
* bash result format: "[stdout]:\n<out>\n\n[stderr]:\n<err>\n\n(exit code 0)".
|
|
347
|
+
* The first non-empty line is always the "[stdout]:" marker — useless as a summary.
|
|
348
|
+
* Show the LAST output line (usually the meaningful tail) plus the exit status.
|
|
349
|
+
*/
|
|
350
|
+
function _bashSummary(result) {
|
|
351
|
+
const isMarker = (l) => /^\[(stdout|stderr)\]:$/.test(l) || /^\((exit code|killed)/.test(l)
|
|
352
|
+
const lines = result.split("\n").map((l) => l.trim()).filter((l) => l && !isMarker(l))
|
|
353
|
+
const status = result.match(/\((?:exit code|killed)[^)]*\)/)?.[0]
|
|
354
|
+
const parts = []
|
|
355
|
+
if (lines.length > 0) parts.push(lines[lines.length - 1].slice(0, 100))
|
|
356
|
+
if (status) parts.push(status)
|
|
357
|
+
return parts.length > 0 ? `bash: ${parts.join(" ")}` : null
|
|
358
|
+
}
|
|
359
|
+
|
|
315
360
|
function _verifySummary(result) {
|
|
316
361
|
const lines = result.split("\n")
|
|
317
362
|
const summary = []
|
package/src/tui/cmd-advisor.mjs
CHANGED
|
@@ -1,26 +1,11 @@
|
|
|
1
1
|
/** /advisor command: toggle advisor on/off, select model, configure thinking.
|
|
2
|
-
*
|
|
3
|
-
|
|
2
|
+
* Interactive loop UX — stays in menu after each action, Esc to exit.
|
|
3
|
+
* ctx: { agent, showPicker, pushLine, pushLabel, persistRaw } */
|
|
4
|
+
import { ansi, C } from "./ansi.mjs"
|
|
4
5
|
|
|
5
6
|
export async function handleAdvisorCommand(ctx) {
|
|
6
|
-
const { agent, showPicker, pushLine } = ctx
|
|
7
|
+
const { agent, showPicker, pushLine, pushLabel } = ctx
|
|
7
8
|
const cfg = agent.config.advisor ??= {}
|
|
8
|
-
const enabled = cfg.enabled === true
|
|
9
|
-
const curProvider = cfg.provider || "(main)"
|
|
10
|
-
const curModel = cfg.model || agent.provider.model
|
|
11
|
-
const thinkInfo = cfg.thinking === null ? "off"
|
|
12
|
-
: cfg.thinking?.type === "disabled" ? "off"
|
|
13
|
-
: cfg.reasoningEffort ? `on (${cfg.reasoningEffort})`
|
|
14
|
-
: cfg.thinking ? `on (${cfg.thinking.type})` : "(main)"
|
|
15
|
-
|
|
16
|
-
const entries = [
|
|
17
|
-
{ type: "item", text: `Advisor: ${enabled ? "ON" : "OFF"}`, action: "toggle" },
|
|
18
|
-
{ type: "item", text: `Model: ${curModel}`, action: "model", note: `Provider: ${curProvider}` },
|
|
19
|
-
{ type: "item", text: `Thinking: ${thinkInfo}`, action: "thinking" },
|
|
20
|
-
]
|
|
21
|
-
|
|
22
|
-
const e = await showPicker("Advisor", entries)
|
|
23
|
-
if (!e) return
|
|
24
9
|
|
|
25
10
|
const persist = async () => {
|
|
26
11
|
if (ctx.persistRaw) {
|
|
@@ -31,82 +16,211 @@ export async function handleAdvisorCommand(ctx) {
|
|
|
31
16
|
}
|
|
32
17
|
}
|
|
33
18
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
await openAdvisorThinkingPicker(ctx, persist).catch(err => pushLine(`[error] ${err.message}`, C.error))
|
|
19
|
+
// Lazy model cache — fetched once per /advisor session
|
|
20
|
+
let modelCache = null
|
|
21
|
+
|
|
22
|
+
// ── State helpers ──
|
|
23
|
+
function advisorStatus() {
|
|
24
|
+
const enabled = cfg.enabled === true
|
|
25
|
+
const curModel = cfg.model || agent.provider.model
|
|
26
|
+
const thinkInfo = cfg.thinking === null ? "off"
|
|
27
|
+
: cfg.thinking?.type === "disabled" ? "off"
|
|
28
|
+
: cfg.reasoningEffort ? `on (${cfg.reasoningEffort})`
|
|
29
|
+
: cfg.thinking ? `on (${cfg.thinking.type})` : "(main)"
|
|
30
|
+
return `Advisor: ${enabled ? "ON" : "OFF"} | Model: ${curModel} | Think: ${thinkInfo}`
|
|
47
31
|
}
|
|
48
|
-
}
|
|
49
32
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
const cfg = agent.config.advisor ??= {}
|
|
33
|
+
function headerLine() {
|
|
34
|
+
return ` ${advisorStatus()}`.replace(/\|/g, ansi.dim + "|" + ansi.reset)
|
|
35
|
+
}
|
|
54
36
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
37
|
+
// ── Model picker sub-loop ──
|
|
38
|
+
async function modelPicker() {
|
|
39
|
+
if (!modelCache) {
|
|
40
|
+
modelCache = await fetchAdvisorModels(agent)
|
|
41
|
+
}
|
|
42
|
+
let modelIdx = 0
|
|
43
|
+
for (;;) {
|
|
44
|
+
const entries = buildModelEntries(agent, cfg, modelCache)
|
|
45
|
+
const c = await showPicker("Advisor Model", entries, { defaultIndex: modelIdx })
|
|
46
|
+
if (!c) return
|
|
47
|
+
modelIdx = Math.max(0, entries.filter((e) => e.type === "item").indexOf(c))
|
|
48
|
+
|
|
49
|
+
if (c.action === "inherit") {
|
|
50
|
+
delete cfg.provider
|
|
51
|
+
delete cfg.model
|
|
52
|
+
await persist()
|
|
53
|
+
pushLabel("❯ Advisor", ansi.bold + C.tool)
|
|
54
|
+
pushLine("Model: using main model", C.tool)
|
|
55
|
+
} else if (c.action === "switch") {
|
|
56
|
+
cfg.provider = c.provider
|
|
57
|
+
cfg.model = c.model
|
|
58
|
+
await persist()
|
|
59
|
+
pushLabel("❯ Advisor", ansi.bold + C.tool)
|
|
60
|
+
pushLine(`Model: ${c.provider}/${c.model}`, C.tool)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
62
63
|
}
|
|
63
64
|
|
|
64
|
-
//
|
|
65
|
-
|
|
65
|
+
// ── Thinking picker sub-loop ──
|
|
66
|
+
async function thinkingPicker() {
|
|
67
|
+
let thinkIdx = 0
|
|
68
|
+
for (;;) {
|
|
69
|
+
const entries = buildThinkingEntries(agent, cfg)
|
|
70
|
+
const c = await showPicker("Advisor Thinking", entries, { defaultIndex: thinkIdx })
|
|
71
|
+
if (!c) return
|
|
72
|
+
thinkIdx = Math.max(0, entries.filter((e) => e.type === "item").indexOf(c))
|
|
73
|
+
|
|
74
|
+
if (c.action === "inherit") {
|
|
75
|
+
delete cfg.thinking
|
|
76
|
+
delete cfg.reasoningEffort
|
|
77
|
+
await persist()
|
|
78
|
+
pushLabel("❯ Advisor", ansi.bold + C.tool)
|
|
79
|
+
pushLine("Thinking: using main model settings", C.tool)
|
|
80
|
+
} else if (c.action === "think_on") {
|
|
81
|
+
const { specForModel } = await import("../config.mjs")
|
|
82
|
+
const spec = specForModel(getEffectiveModel(agent, cfg))
|
|
83
|
+
cfg.thinking = { type: spec.thinkEnabledValue ?? "enabled" }
|
|
84
|
+
if (spec.thinkApi === "effort") delete cfg.thinking
|
|
85
|
+
await persist()
|
|
86
|
+
pushLabel("❯ Advisor", ansi.bold + C.tool)
|
|
87
|
+
pushLine(`Thinking: ON`, C.tool)
|
|
88
|
+
} else if (c.action === "think_off") {
|
|
89
|
+
const { specForModel } = await import("../config.mjs")
|
|
90
|
+
const spec = specForModel(getEffectiveModel(agent, cfg))
|
|
91
|
+
const isCustomThink = (spec.thinkEnabledValue ?? "enabled") !== "enabled"
|
|
92
|
+
cfg.thinking = isCustomThink ? null : { type: "disabled" }
|
|
93
|
+
await persist()
|
|
94
|
+
pushLabel("❯ Advisor", ansi.bold + C.tool)
|
|
95
|
+
pushLine("Thinking: OFF", C.tool)
|
|
96
|
+
} else if (c.action.startsWith("effort_")) {
|
|
97
|
+
cfg.reasoningEffort = c.action.slice(7)
|
|
98
|
+
await persist()
|
|
99
|
+
pushLabel("❯ Advisor", ansi.bold + C.tool)
|
|
100
|
+
pushLine(`Reasoning effort: ${cfg.reasoningEffort}`, C.tool)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ── Main loop ──
|
|
106
|
+
let mainIdx = 0
|
|
107
|
+
for (;;) {
|
|
108
|
+
const enabled = cfg.enabled === true
|
|
109
|
+
const curProvider = cfg.provider || "(main)"
|
|
110
|
+
const curModel = cfg.model || agent.provider.model
|
|
111
|
+
const guardInfo = cfg.guard === true ? "on" : "off"
|
|
112
|
+
|
|
113
|
+
const entries = [
|
|
114
|
+
{ type: "header", text: headerLine() },
|
|
115
|
+
{ type: "item", text: `Advisor: ${enabled ? "ON" : "OFF"}`, action: "toggle" },
|
|
116
|
+
{ type: "item", text: `Model: ${curModel}`, action: "model", note: `Provider: ${curProvider}` },
|
|
117
|
+
{ type: "item", text: `Thinking: ${advisorStatus().split("|")[2]?.trim() || "(main)"}`, action: "thinking" },
|
|
118
|
+
{ type: "item", text: `Guard: ${guardInfo}`, action: "guard" },
|
|
119
|
+
{ type: "item", text: "View full config", action: "view" },
|
|
120
|
+
]
|
|
121
|
+
|
|
122
|
+
const choice = await showPicker("Advisor", entries, { defaultIndex: mainIdx })
|
|
123
|
+
if (!choice) return // Esc
|
|
124
|
+
mainIdx = Math.max(0, entries.filter((e) => e.type === "item").indexOf(choice))
|
|
125
|
+
|
|
126
|
+
if (choice.action === "view") {
|
|
127
|
+
pushLabel("❯ Advisor", ansi.bold + C.tool)
|
|
128
|
+
pushLine(`Status: ${enabled ? "ON" : "OFF"}`, C.dim)
|
|
129
|
+
pushLine(`Model: ${curModel} (provider: ${curProvider})`, C.dim)
|
|
130
|
+
pushLine(`Guard: ${guardInfo}`, C.dim)
|
|
131
|
+
pushLine(`Thinking: ${advisorStatus().split("|")[2]?.trim() || "(main)"}`, C.dim)
|
|
132
|
+
continue
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (choice.action === "toggle") {
|
|
136
|
+
cfg.enabled = !cfg.enabled
|
|
137
|
+
await persist().catch(err => pushLine(`[error] Advisor toggle: ${err.message}`, C.error))
|
|
138
|
+
pushLabel("❯ Advisor", ansi.bold + C.tool)
|
|
139
|
+
pushLine(`Advisor: ${cfg.enabled ? "ON" : "OFF"}`, C.tool)
|
|
140
|
+
continue
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (choice.action === "guard") {
|
|
144
|
+
cfg.guard = !(cfg.guard === true)
|
|
145
|
+
await persist().catch(err => pushLine(`[error] ${err.message}`, C.error))
|
|
146
|
+
pushLabel("❯ Advisor", ansi.bold + C.tool)
|
|
147
|
+
pushLine(`Guard: ${cfg.guard === true ? "on" : "off"}`, C.tool)
|
|
148
|
+
continue
|
|
149
|
+
}
|
|
66
150
|
|
|
67
|
-
|
|
68
|
-
|
|
151
|
+
if (choice.action === "model") {
|
|
152
|
+
await modelPicker()
|
|
153
|
+
continue
|
|
154
|
+
}
|
|
69
155
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
} else if (e.action === "switch") {
|
|
75
|
-
cfg.provider = e.provider
|
|
76
|
-
cfg.model = e.model
|
|
77
|
-
pushLine(`Advisor: ${e.provider}/${e.model}`, C.dim)
|
|
156
|
+
if (choice.action === "thinking") {
|
|
157
|
+
await thinkingPicker()
|
|
158
|
+
continue
|
|
159
|
+
}
|
|
78
160
|
}
|
|
79
|
-
await persist()
|
|
80
161
|
}
|
|
81
162
|
|
|
82
|
-
|
|
163
|
+
// ── Model helpers ──
|
|
164
|
+
|
|
165
|
+
function getEffectiveModel(agent, cfg) {
|
|
166
|
+
const providerForDefaults = cfg.provider
|
|
167
|
+
? agent.providers?.find(p => p.name === cfg.provider) || agent.provider
|
|
168
|
+
: agent.provider
|
|
169
|
+
return cfg.model || providerForDefaults.model
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function fetchAdvisorModels(agent) {
|
|
83
173
|
const { listModels } = await import("../provider/index.mjs")
|
|
84
|
-
|
|
174
|
+
const result = new Map()
|
|
175
|
+
await Promise.all((agent.providers || []).map(async (p) => {
|
|
85
176
|
try {
|
|
86
177
|
const envKey = { deepseek: "DEEPSEEK_API_KEY", openai: "OPENAI_API_KEY" }[p.name]
|
|
87
178
|
let apiKey = p.apiKey
|
|
88
179
|
if (!apiKey && envKey && process.env[envKey]) apiKey = process.env[envKey]
|
|
89
180
|
if (!apiKey) apiKey = process.env.THINCODER_API_KEY
|
|
90
181
|
const models = await listModels({ baseURL: p.baseURL, apiKey: apiKey ?? "" }, { signal: AbortSignal.timeout(10000) })
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
.filter((m) => m !== p.model)
|
|
95
|
-
.map((m) => ({ type: "item", text: ` ${m}`, action: "switch", provider: p.name, model: m })))
|
|
96
|
-
const header = entries[at]
|
|
97
|
-
header.note = `${p.baseURL}${p.apiKey ? "" : " (no key)"}${agent.activeProvider === p.name ? " ← active" : ""}`
|
|
98
|
-
} catch (err) {
|
|
99
|
-
const header = entries.find((e) => e.type === "header" && e.text === p.name)
|
|
100
|
-
if (header) header.note = `${p.baseURL}${p.apiKey ? "" : " (no key)"}${agent.activeProvider === p.name ? " ← active" : ""} (fetch failed: ${err.message.slice(0, 40)})`
|
|
182
|
+
result.set(p.name, { models, error: null })
|
|
183
|
+
} catch (err) {
|
|
184
|
+
result.set(p.name, { models: [], error: err.message.slice(0, 40) })
|
|
101
185
|
}
|
|
102
186
|
}))
|
|
187
|
+
return result
|
|
103
188
|
}
|
|
104
189
|
|
|
105
|
-
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
190
|
+
function buildModelEntries(agent, cfg, cache) {
|
|
191
|
+
const entries = []
|
|
192
|
+
entries.push({ type: "item", text: (!cfg.provider ? "● " : " ") + "Use main model", action: "inherit" })
|
|
193
|
+
|
|
194
|
+
for (const p of agent.providers || []) {
|
|
195
|
+
const cached = cache.get(p.name)
|
|
196
|
+
const hasKey = !!(p.apiKey
|
|
197
|
+
|| (p.name === "deepseek" && process.env.DEEPSEEK_API_KEY)
|
|
198
|
+
|| (p.name === "openai" && process.env.OPENAI_API_KEY)
|
|
199
|
+
|| process.env.THINCODER_API_KEY)
|
|
200
|
+
const noteParts = [p.baseURL]
|
|
201
|
+
if (!hasKey) noteParts.push("(no key)")
|
|
202
|
+
if (agent.activeProvider === p.name) noteParts.push("← active")
|
|
203
|
+
if (cached?.error) noteParts.push(`(fetch failed: ${cached.error})`)
|
|
204
|
+
entries.push({ type: "header", text: p.name, note: noteParts.join(" ") })
|
|
205
|
+
|
|
206
|
+
// Default model
|
|
207
|
+
const isDefault = cfg.provider === p.name && cfg.model === p.model
|
|
208
|
+
entries.push({ type: "item", text: `${isDefault ? "● " : " "}${p.model}`, action: "switch", provider: p.name, model: p.model })
|
|
209
|
+
|
|
210
|
+
// Additional models from API, excluding the default model
|
|
211
|
+
if (cached?.models) {
|
|
212
|
+
for (const m of cached.models) {
|
|
213
|
+
if (m === p.model) continue
|
|
214
|
+
const isSelected = cfg.provider === p.name && cfg.model === m
|
|
215
|
+
entries.push({ type: "item", text: `${isSelected ? "● " : " "}${m}`, action: "switch", provider: p.name, model: m })
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return entries
|
|
220
|
+
}
|
|
109
221
|
|
|
222
|
+
async function buildThinkingEntries(agent, cfg) {
|
|
223
|
+
const { specForModel } = await import("../config.mjs")
|
|
110
224
|
const providerForDefaults = cfg.provider
|
|
111
225
|
? agent.providers?.find(p => p.name === cfg.provider) || agent.provider
|
|
112
226
|
: agent.provider
|
|
@@ -127,31 +241,12 @@ async function openAdvisorThinkingPicker(ctx, persist) {
|
|
|
127
241
|
]
|
|
128
242
|
if (!isEffortOnly) {
|
|
129
243
|
entries.push({ type: "header", text: "Thinking mode" })
|
|
130
|
-
entries.push({ type: "item", text: `Enabled
|
|
131
|
-
entries.push({ type: "item", text: `Disabled ${curThinking?.type === "disabled" || curThinking === null ? "← current" : ""}`, action: "think_off" })
|
|
244
|
+
entries.push({ type: "item", text: `Enabled ${thinkingEnabled ? "← current" : ""}`, action: "think_on" })
|
|
245
|
+
entries.push({ type: "item", text: `Disabled ${(curThinking?.type === "disabled" || curThinking === null) ? "← current" : ""}`, action: "think_off" })
|
|
132
246
|
}
|
|
133
247
|
entries.push({ type: "header", text: "Reasoning effort" })
|
|
134
248
|
for (const level of effortLevels) {
|
|
135
249
|
entries.push({ type: "item", text: `${level} ${curEffort === level ? "← current" : ""}`, action: `effort_${level}` })
|
|
136
250
|
}
|
|
137
|
-
|
|
138
|
-
const e = await showPicker("Advisor Thinking", entries)
|
|
139
|
-
if (!e) return
|
|
140
|
-
|
|
141
|
-
if (e.action === "inherit") {
|
|
142
|
-
delete cfg.thinking
|
|
143
|
-
delete cfg.reasoningEffort
|
|
144
|
-
pushLine("Advisor: using main model thinking settings", C.dim)
|
|
145
|
-
} else if (e.action === "think_on") {
|
|
146
|
-
cfg.thinking = { type: thinkOnValue }
|
|
147
|
-
if (isEffortOnly) delete cfg.thinking
|
|
148
|
-
pushLine(`Advisor: thinking ON (${thinkOnValue})`, C.dim)
|
|
149
|
-
} else if (e.action === "think_off") {
|
|
150
|
-
cfg.thinking = isCustomThink ? null : { type: "disabled" }
|
|
151
|
-
pushLine("Advisor: thinking OFF", C.dim)
|
|
152
|
-
} else if (e.action.startsWith("effort_")) {
|
|
153
|
-
cfg.reasoningEffort = e.action.slice(7)
|
|
154
|
-
pushLine(`Advisor: reasoning effort = ${cfg.reasoningEffort}`, C.dim)
|
|
155
|
-
}
|
|
156
|
-
await persist()
|
|
251
|
+
return entries
|
|
157
252
|
}
|
package/src/tui/cmd-auto.mjs
CHANGED
|
@@ -1,12 +1,10 @@
|
|
|
1
1
|
/** /auto command: toggle auto-approve mode.
|
|
2
|
-
* ctx: { agent } */
|
|
2
|
+
* ctx: { agent, pushLine, pushLabel } */
|
|
3
|
+
import { ansi, C } from "./ansi.mjs"
|
|
4
|
+
|
|
3
5
|
export async function handleAutoCommand(ctx) {
|
|
4
|
-
const { agent } = ctx
|
|
6
|
+
const { agent, pushLine, pushLabel } = ctx
|
|
5
7
|
agent.autoApprove = !agent.autoApprove
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
agent._pendingReminders.push("[System reminder: AUTO mode is now ON. All tool calls are automatically approved — you may write, edit, and run commands without asking. Use this for long autonomous tasks. The user can still interrupt.]")
|
|
9
|
-
} else {
|
|
10
|
-
agent._pendingReminders.push("[System reminder: AUTO mode is now OFF. Destructive tool calls now require user approval again. Confirm before writing files, running commands, or spawning subagents.]")
|
|
11
|
-
}
|
|
8
|
+
pushLabel("❯ Auto", ansi.bold + C.tool)
|
|
9
|
+
pushLine(`Auto-approve: ${agent.autoApprove ? "ON" : "OFF"}`, C.tool)
|
|
12
10
|
}
|