thincoder 0.12.9 → 0.12.11
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 +17 -2
- package/package.json +1 -1
- package/src/advisor/history.mjs +2 -2
- package/src/advisor/messages.mjs +6 -6
- package/src/advisor/repos.mjs +2 -2
- package/src/advisor/run.mjs +15 -12
- package/src/advisor.mjs +13 -29
- package/src/agent-tools/advisor.mjs +3 -4
- package/src/agent-tools/plan.mjs +3 -3
- package/src/agent-tools/subagent.mjs +49 -4
- package/src/agent.mjs +1 -2
- package/src/config.mjs +3 -0
- package/src/context.mjs +1 -1
- package/src/prompts/advisor-round2.md +4 -4
- package/src/prompts/advisor-round3.md +4 -4
- package/src/tools/system.mjs +13 -5
- package/src/tui/cmd-shell.mjs +104 -0
- package/src/tui/cmd-submodel.mjs +151 -0
- package/src/tui/index.mjs +3 -2
- package/src/tui/pickers.mjs +32 -1
- package/src/tui/render-conversation.mjs +18 -3
- package/src/tui/render.mjs +24 -5
- package/src/tui/slash-commands.mjs +10 -0
package/README.md
CHANGED
|
@@ -20,7 +20,7 @@ Design philosophy (the entire meaning of the name): if the Node standard library
|
|
|
20
20
|
- **Memory system**: three layers (personal/project/team), FTS5 + vector RRF hybrid retrieval, git-friendly markdown format
|
|
21
21
|
- **Two-phase tool scheduling**: permission prompts serialized, read-only tools parallelized, side-effect tools serialized
|
|
22
22
|
- **Session persistence** ⭐0.5.0: up to 5 archive slots, `/session` to switch anytime, tool results visible after restore. Process-level isolation — multiple instances in the same directory each get their own session slot
|
|
23
|
-
- **Concurrent subagents**: three roles — `explore`/`plan`/`coder` — dispatched in parallel, streaming output visible, reports land in the conversation
|
|
23
|
+
- **Concurrent subagents**: three roles — `explore`/`plan`/`coder` — dispatched in parallel, streaming output visible, reports land in the conversation; per-subagent model override (`subagent` tool `model` arg or `agent.subagentModel` config — e.g. discuss with `glm-5.2`, let `deepseek-v4-flash` implement)
|
|
24
24
|
- **Plan Mode**: read-only exploration + design, implement after user approval
|
|
25
25
|
- **AUTO mode**: `/auto` full authorization, no confirmations on long tasks
|
|
26
26
|
- **Task tracking**: `task` tool breaks down multi-step work, status bar ✓n/m live progress, auto-filters completed items
|
|
@@ -85,7 +85,7 @@ thincoder upgrade
|
|
|
85
85
|
|
|
86
86
|
Running from source: replace `thincoder` above with `node bin/thincoder.mjs`.
|
|
87
87
|
|
|
88
|
-
Slash commands in the TUI: `/help`, `/model` (two-level picker: first select provider, then model; `/model <provider>:<name>` switches directly), `/provider` (add/remove providers, set keys, custom endpoints), `/think` (thinking mode toggle and reasoning effort), `/config` (view config, `/config embedkey` for the embedding key, `/config set` for parameters), `/session` (list/switch archived sessions), `/reindex` (rebuild the index), `/extract` (extract knowledge from the current session), `/restore` (restore checkpoint), `/clear`, `/exit`. High-frequency commands support abbreviations: `/h` `/x` `/m` `/p` `/t` `/c` `/n`. Typing `/` shows live matching hints in the status bar. Model picker supports search/filter — type to narrow down results.
|
|
88
|
+
Slash commands in the TUI: `/help`, `/model` (two-level picker: first select provider, then model; `/model <provider>:<name>` switches directly), `/submodel` (subagent models per type — picker over global + explore/plan/coder/eng-coder slots, or `/submodel <type> <provider:model>` directly), `/shell` (platform-aware picker of available shells — e.g. `/shell` → pick Git Bash/pwsh, or `/shell "C:\Program Files\Git\bin\bash.exe"`, `/shell reset`; fixes win11 cmd encoding/command issues), `/provider` (add/remove providers, set keys, custom endpoints), `/think` (thinking mode toggle and reasoning effort), `/config` (view config, `/config embedkey` for the embedding key, `/config set` for parameters), `/session` (list/switch archived sessions), `/reindex` (rebuild the index), `/extract` (extract knowledge from the current session), `/restore` (restore checkpoint), `/clear`, `/exit`. High-frequency commands support abbreviations: `/h` `/x` `/m` `/p` `/t` `/c` `/n`. Typing `/` shows live matching hints in the status bar. Model picker supports search/filter — type to narrow down results.
|
|
89
89
|
|
|
90
90
|
Environment variables: `THINCODER_API_KEY` (or `DEEPSEEK_API_KEY` / `OPENAI_API_KEY`), `THINCODER_BASE_URL`, `THINCODER_MODEL`, `SILICONFLOW_API_KEY`.
|
|
91
91
|
|
|
@@ -112,6 +112,7 @@ Environment variables: `THINCODER_API_KEY` (or `DEEPSEEK_API_KEY` / `OPENAI_API_
|
|
|
112
112
|
},
|
|
113
113
|
],
|
|
114
114
|
"activeProvider": "deepseek", // currently active provider name
|
|
115
|
+
"shell": null, // bash tool shell (win11: e.g. "C:\\Program Files\\Git\\bin\\bash.exe" or "pwsh"); null = system default — cmd on Windows (UTF-8 forced per command), /bin/sh elsewhere. TUI: /shell
|
|
115
116
|
"embedding": {
|
|
116
117
|
// optional: without it, retrieval is pure FTS
|
|
117
118
|
"baseURL": "https://api.siliconflow.cn/v1",
|
|
@@ -120,6 +121,8 @@ Environment variables: `THINCODER_API_KEY` (or `DEEPSEEK_API_KEY` / `OPENAI_API_
|
|
|
120
121
|
},
|
|
121
122
|
"agent": {
|
|
122
123
|
"maxTurns": 100, // tool-loop cap
|
|
124
|
+
"subagentModel": null, // default subagent provider/model override: "provider:model" | provider name | model name; null = inherit parent provider. Per-call: subagent tool `model` arg
|
|
125
|
+
"subagentModels": {}, // per-type override: { "explore": "...", "plan": "...", "coder": "...", "eng-coder": "..." }; priority: tool model arg > this > subagentModel > parent provider
|
|
123
126
|
"compactThreshold": 100000, // context compaction threshold (approx. tokens)
|
|
124
127
|
},
|
|
125
128
|
"memory": {
|
|
@@ -209,6 +212,18 @@ Code conventions: pure `.mjs`, no semicolons, no npm dependencies allowed (inclu
|
|
|
209
212
|
|
|
210
213
|
## Changelog
|
|
211
214
|
|
|
215
|
+
### 0.12.11 (2026-08)
|
|
216
|
+
- **Subagent model per type** — `subagent` tool `model` arg, `config.agent.subagentModels` (per explore/plan/coder/eng-coder) and `config.agent.subagentModel` (global fallback); priority: tool arg > type > global > parent provider. `/submodel` TUI command: picker over 5 slots (global + 4 roles) with provider→model selection, or direct args (`/submodel coder deepseek:deepseek-v4-flash`).
|
|
217
|
+
- **Configurable bash shell** — `config.shell` or `/shell` TUI command: platform-aware picker (Windows: pwsh/Git Bash/WSL bash; POSIX: bash/zsh/fish — availability-detected, custom path supported). Windows default cmd now forces UTF-8 per command (`chcp 65001`) — fixes garbled Chinese output on win11.
|
|
218
|
+
- **Markdown table alignment fix** — `stringWidth` strips ANSI (zero display width) and rendered table rows are padded back to the computed width; inline markers (`` `code` ``, `**bold**`) no longer shift the borders.
|
|
219
|
+
- **sliceByWidth keeps ANSI sequences whole** — never slices mid-escape-sequence.
|
|
220
|
+
|
|
221
|
+
### 0.12.10 (2026-08)
|
|
222
|
+
- **Code-quality pass (advisor subsystem):**
|
|
223
|
+
- **Drop 11 unused exports** — internal-use symbols no longer leak through the module API (advisor table headers/constants, plan reminders, token-UUID helper, shrinkOversized).
|
|
224
|
+
- **Advisor tool-timeout timer is now cleared** when the tool wins the race (no dangling timers); static import replaces a dynamic import on the hot path.
|
|
225
|
+
- **Advisor re-review no longer trusts git output** — the follow-up path previously injected a `git diff HEAD` snapshot: once fixes were committed the diff was empty and the model read "no changes" as "no fixes", misreporting fixed items as unfixed. The follow-up now injects **no git information at all** — verification is `read`-only, evidence must quote this round's read output (line numbers from the stale prior table are not evidence), and dead snapshot-dedup fields were removed.
|
|
226
|
+
|
|
212
227
|
### 0.12.9 (2026-08)
|
|
213
228
|
- **Prompt-system quality pass (both CLI and VS Code extension, byte-identical sync):**
|
|
214
229
|
- **Advisor-after-code rule moved from system.md to discipline.md** — engineering mode no longer receives the conflicting "call advisor after changing code" instruction (its review-timing rules say do not call unprompted). Standard mode behavior unchanged.
|
package/package.json
CHANGED
package/src/advisor/history.mjs
CHANGED
|
@@ -6,8 +6,8 @@ import { join } from "node:path"
|
|
|
6
6
|
|
|
7
7
|
export const ADVISOR_MD_PATH = ".thincoder/advisor.md"
|
|
8
8
|
export const ADVISOR_TABLE_HEADER = "| # | File | Severity | Issue | Suggestion |"
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
const CONVERGENCE_TABLE_HEADER = "| # | Orig# | File | Severity | Status | Notes |"
|
|
10
|
+
const AGENT_RESPONSE_HEADER = "| # | Action | Detail |"
|
|
11
11
|
export const LEGACY_ADVISOR_HEADER = "| # | 文件 | 严重程度 | 问题描述 | 建议修复 |"
|
|
12
12
|
|
|
13
13
|
const DEFAULT_CRITERIA = `Review the code changes, focusing on:
|
package/src/advisor/messages.mjs
CHANGED
|
@@ -57,7 +57,6 @@ export function buildAdvisorUserMessage(agent, _prior, reviewType, designToken =
|
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
// Pre-collected changes — the design doc diff.
|
|
60
|
-
// _advisorLastSnapshot is only consumed by code-review convergence — skip the write here.
|
|
61
60
|
const snapshots = collectRepoSnapshots(repos, agent.cwd)
|
|
62
61
|
if (snapshots.length > 0) {
|
|
63
62
|
parts.push("## Design Document (git diff)")
|
|
@@ -96,7 +95,7 @@ export function buildAdvisorUserMessage(agent, _prior, reviewType, designToken =
|
|
|
96
95
|
return parts.join("\n")
|
|
97
96
|
}
|
|
98
97
|
|
|
99
|
-
// Convergence data (round 2+)
|
|
98
|
+
// Convergence data (round 2+). Design reviews returned above — only code reviews reach here.
|
|
100
99
|
if (prior && (agent._advisorRound || 0) > 0) {
|
|
101
100
|
const response = extractAgentResponseTable(agent.history, prior.sinceIdx)
|
|
102
101
|
|| "(Agent did not provide a response table — re-evaluate each issue)"
|
|
@@ -164,10 +163,11 @@ export function buildAdvisorUserMessage(agent, _prior, reviewType, designToken =
|
|
|
164
163
|
parts.push("## Instructions")
|
|
165
164
|
parts.push("1. IMPORTANT: in the diff, `-` lines are REMOVED content (no longer in the file), `+` lines are ADDED. The prior issue table (if any) is HISTORY — always verify current file state with `read` before judging an item.")
|
|
166
165
|
if (isReReview) {
|
|
167
|
-
parts.push("2. STALE-CONTEXT WARNING: any diff embedded in earlier messages is a historical snapshot — treat it as expired. Only
|
|
168
|
-
parts.push("3.
|
|
169
|
-
parts.push("4. `read`
|
|
170
|
-
parts.push("5.
|
|
166
|
+
parts.push("2. STALE-CONTEXT WARNING: any diff or file content embedded in earlier messages is a historical snapshot — treat it as expired. Only fresh `read` results describe the current state. Never quote a `-` line from an earlier diff as if it were live code.")
|
|
167
|
+
parts.push("3. Verify the prior issue table against the CURRENT FILE STATE — use `read`, never `git diff` alone. Fixes may already be committed: an empty `git diff` does NOT mean nothing changed. `git log -3` shows recent commits.")
|
|
168
|
+
parts.push("4. `read` the files in the Review Scope in full — ALWAYS, regardless of what `git diff` shows. Batch reads/greps in a single reply.")
|
|
169
|
+
parts.push("5. Evidence rule: every 'Unfixed'/'New' finding MUST quote the exact line content from THIS round's `read` output (e.g. `run.mjs:180: timeoutId = setTimeout(...)`). Line numbers alone are NOT evidence — they may come from the stale prior table. Findings without a fresh quoted line are treated as unverified and will not be accepted.")
|
|
170
|
+
parts.push("6. Produce your verification table. Do not re-read content you already have.")
|
|
171
171
|
} else {
|
|
172
172
|
parts.push("2. Read `AGENTS.md` / design docs only if they exist (check once; do not re-probe with multiple patterns).")
|
|
173
173
|
parts.push("3. `read` changed files for full context beyond the diff. Batch independent reads/greps in a single reply instead of one call per round-trip.")
|
package/src/advisor/repos.mjs
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
import { execFileSync } from "node:child_process"
|
|
6
6
|
import { dirname, basename, resolve } from "node:path"
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
const GIT_TIMEOUT = 5_000
|
|
9
|
+
const MAX_EMBEDDED_DIFF = 50_000
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
12
|
* Find the git repository roots that contain the agent's touched files.
|
package/src/advisor/run.mjs
CHANGED
|
@@ -8,7 +8,7 @@ import { toOpenAISchema } from "../tools/index.mjs"
|
|
|
8
8
|
import { prepareAdvisorMessages } from "../advisor.mjs"
|
|
9
9
|
import { extractPriorIssueTable } from "../advisor/history.mjs"
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
const MAX_ADVISOR_TURNS = 100
|
|
12
12
|
// Mechanical convergence cap: the protocol assumes up to 5 rounds suffice
|
|
13
13
|
// (full review, verify+fix cycles, strict verification). A 6th call means the
|
|
14
14
|
// model is looping — refuse it instead of burning tokens on a review that cannot
|
|
@@ -172,18 +172,21 @@ async function runAdvisorToolLoop(provider, messages, onOutput, signal, agent, c
|
|
|
172
172
|
if (!tool) {
|
|
173
173
|
result = `Error: unknown tool "${tc.name}". Available: ${[...ADVISOR_TOOL_BY_NAME.keys()].join(", ")}`
|
|
174
174
|
} else {
|
|
175
|
-
// Execute with timeout
|
|
175
|
+
// Execute with timeout (clear the timer when the tool wins the race —
|
|
176
|
+
// otherwise up to MAX_ADVISOR_TURNS dangling timers accumulate)
|
|
176
177
|
try {
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
onOutput,
|
|
181
|
-
signal,
|
|
178
|
+
let timeoutId
|
|
179
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
180
|
+
timeoutId = setTimeout(() => reject(new Error(`tool timeout after ${TOOL_TIMEOUT_MS}ms`)), TOOL_TIMEOUT_MS)
|
|
182
181
|
})
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
182
|
+
try {
|
|
183
|
+
result = await Promise.race([
|
|
184
|
+
tool.execute(args, { cwd, agent, onOutput, signal }),
|
|
185
|
+
timeoutPromise,
|
|
186
|
+
])
|
|
187
|
+
} finally {
|
|
188
|
+
clearTimeout(timeoutId)
|
|
189
|
+
}
|
|
187
190
|
} catch (e) {
|
|
188
191
|
const errorType = e.message.includes("timeout") ? "timeout"
|
|
189
192
|
: e.message.includes("ENOENT") ? "file_not_found"
|
|
@@ -224,7 +227,7 @@ async function runAdvisorToolLoop(provider, messages, onOutput, signal, agent, c
|
|
|
224
227
|
}
|
|
225
228
|
|
|
226
229
|
/** Resolve the advisor's provider: cfg.provider/model when set, otherwise the main agent's provider */
|
|
227
|
-
|
|
230
|
+
function resolveAdvisorProvider(agent) {
|
|
228
231
|
const cfg = agent.config?.advisor
|
|
229
232
|
if (cfg?.provider) {
|
|
230
233
|
try {
|
package/src/advisor.mjs
CHANGED
|
@@ -2,11 +2,13 @@
|
|
|
2
2
|
* advisor.mjs — advisor system-prompt selection, follow-up building, session assembly.
|
|
3
3
|
* User-message building lives in advisor/messages.mjs; execution (tool loop, provider
|
|
4
4
|
* resolution, review entry) in advisor/run.mjs; git discovery/collection in
|
|
5
|
-
* advisor/repos.mjs
|
|
5
|
+
* advisor/repos.mjs (design-review diffs only — code-review follow-ups deliberately
|
|
6
|
+
* inject NO git information); history extraction in advisor/history.mjs.
|
|
6
7
|
*
|
|
7
8
|
* The advisor runs as a read-only exploration sub-agent with tools
|
|
8
|
-
* (read, glob, grep, ls, git, lsp, code_search).
|
|
9
|
-
* via git diff
|
|
9
|
+
* (read, glob, grep, ls, git, lsp, code_search). Round 1 discovers changes
|
|
10
|
+
* via git diff and reads files for context; convergence rounds (2+) verify
|
|
11
|
+
* fix status with `read` only — no git output is injected or trusted.
|
|
10
12
|
*
|
|
11
13
|
* Config:
|
|
12
14
|
* { advisor: { enabled: true, provider: "deepseek", model: "deepseek-chat" } }
|
|
@@ -37,8 +39,6 @@
|
|
|
37
39
|
import { readFileSync } from "node:fs"
|
|
38
40
|
import { join, dirname } from "node:path"
|
|
39
41
|
import { fileURLToPath } from "node:url"
|
|
40
|
-
import { createHash } from "node:crypto"
|
|
41
|
-
import { findReviewRepos, collectRepoSnapshots } from "./advisor/repos.mjs"
|
|
42
42
|
import { extractPriorIssueTable, extractAgentResponseTable } from "./advisor/history.mjs"
|
|
43
43
|
import { buildAdvisorUserMessage } from "./advisor/messages.mjs"
|
|
44
44
|
// Re-export for run.mjs and tests (keeps their imports from "../advisor.mjs" stable)
|
|
@@ -88,7 +88,10 @@ export function buildAdvisorSystemPrompt(agent, _prior, reviewType) {
|
|
|
88
88
|
|
|
89
89
|
/**
|
|
90
90
|
* Build a follow-up user message for round 2+ — the agent's response table +
|
|
91
|
-
*
|
|
91
|
+
* round-aware instructions, without re-sending the full round-1 context.
|
|
92
|
+
* Deliberately NO git information injected (no diff snapshot, no git context):
|
|
93
|
+
* git output misled re-reviews — committed fixes never show in `git diff HEAD`,
|
|
94
|
+
* so the model read "no changes" as "no fixes". Verification is `read`-only.
|
|
92
95
|
*/
|
|
93
96
|
export function buildAdvisorFollowUp(agent, _prior) {
|
|
94
97
|
const prior = _prior ?? extractPriorIssueTable(agent.history)
|
|
@@ -111,31 +114,15 @@ export function buildAdvisorFollowUp(agent, _prior) {
|
|
|
111
114
|
"## Instructions",
|
|
112
115
|
round === 2
|
|
113
116
|
? "Verify each item in the prior table. Flag any obvious NEW issues introduced by the fixes (crashes, data loss, logic errors — not style). Produce a verification table."
|
|
114
|
-
: "Strictly verify ONLY the items in the prior table against the
|
|
117
|
+
: "Strictly verify ONLY the items in the prior table against the CURRENT FILE STATE (use `read` — an empty diff does not mean the fixes are absent). Do NOT look for new issues.",
|
|
115
118
|
"",
|
|
116
|
-
"IMPORTANT:
|
|
119
|
+
"IMPORTANT: the prior issue table is HISTORY — always verify current file state with `read` before judging an item as fixed or unfixed.",
|
|
117
120
|
// Round-aware evidence rule: "New" entries only exist in round 2 (round 3+ forbids them).
|
|
118
|
-
`STALE-CONTEXT WARNING:
|
|
121
|
+
`STALE-CONTEXT WARNING: only fresh \`read\` results describe the current state — never judge from earlier snapshots or from \`git diff\` (committed fixes never show in \`git diff HEAD\`). Read the files to verify. Any "Unfixed" entry${round === 2 ? ' (and any "New" entry)' : ""} MUST quote the exact line content from THIS round's \`read\` output (e.g. \`run.mjs:180: timeoutId = setTimeout(...)\`); line numbers alone are NOT evidence (they may come from the stale prior table). Uncited findings are unverified and will be ignored.`,
|
|
119
122
|
"",
|
|
120
|
-
"Do NOT re-read AGENTS.md / design docs
|
|
123
|
+
"Do NOT re-read AGENTS.md / design docs. Verify fix status with `read` only — do not rely on git output: a clean working tree does not mean fixes are absent (they may be committed).",
|
|
121
124
|
"",
|
|
122
125
|
]
|
|
123
|
-
const snapshots = collectRepoSnapshots(findReviewRepos(agent), agent.cwd)
|
|
124
|
-
const snapshotText = snapshots.join("\n")
|
|
125
|
-
const snapshotHash = snapshotText ? createHash("sha1").update(snapshotText).digest("hex") : null
|
|
126
|
-
// Skip re-pushing an identical diff (e.g. advisor re-run without any file changes) —
|
|
127
|
-
// the previous snapshot is already in the conversation, duplicating it wastes tokens.
|
|
128
|
-
if (snapshots.length === 0) {
|
|
129
|
-
parts.push("## Current Changes", "(No git repository or no changes detected.)")
|
|
130
|
-
} else if (snapshotHash && snapshotHash === agent._advisorLastSnapshotHash) {
|
|
131
|
-
// Hash match → the snapshot from the previous round is still current.
|
|
132
|
-
// Using hash instead of full-text comparison avoids keeping the entire diff
|
|
133
|
-
// string in memory and handles edge cases (e.g. file changed then reverted).
|
|
134
|
-
parts.push("## Current Changes", "(No changes since your previous review — the diff snapshot is identical, so the one from your last round remains valid for this round.)")
|
|
135
|
-
} else {
|
|
136
|
-
parts.push("## Current Changes (git status + git diff HEAD, refreshed)", ...snapshots)
|
|
137
|
-
}
|
|
138
|
-
agent._advisorLastSnapshotHash = snapshotHash
|
|
139
126
|
return parts.join("\n")
|
|
140
127
|
}
|
|
141
128
|
|
|
@@ -165,8 +152,6 @@ export function prepareAdvisorMessages(agent, reviewType, designToken = null, do
|
|
|
165
152
|
// a follow-up "Verify Prior Table" would be meaningless; start a fresh full review
|
|
166
153
|
if (!prior) {
|
|
167
154
|
agent._advisorSession = null
|
|
168
|
-
agent._advisorLastSnapshotHash = null
|
|
169
|
-
session = null
|
|
170
155
|
// Only reset the round counter on a truly fresh start (no prior reviews at all).
|
|
171
156
|
// If _advisorRound > 0, there WAS a prior review — it just passed (all-clear).
|
|
172
157
|
if (!agent._advisorRound) agent._advisorRound = 0
|
|
@@ -188,7 +173,6 @@ export function prepareAdvisorMessages(agent, reviewType, designToken = null, do
|
|
|
188
173
|
// Fresh session. Only reset round if this is truly the first review.
|
|
189
174
|
// If _advisorRound > 0, there was a prior review that passed (all-clear).
|
|
190
175
|
if (!agent._advisorRound) agent._advisorRound = 0
|
|
191
|
-
agent._advisorLastSnapshot = null
|
|
192
176
|
if (!prior) {
|
|
193
177
|
// Tell the advisor why no prior issue table is present
|
|
194
178
|
session[1] = {
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { randomUUID, createHmac } from "node:crypto"
|
|
7
7
|
import { runAdvisorReview } from "../advisor/run.mjs"
|
|
8
|
+
import { isDocFile } from "../advisor/repos.mjs"
|
|
8
9
|
|
|
9
10
|
const TOKEN_EXPIRY_MS = 3600000 // 1 hour
|
|
10
11
|
const TOKEN_SECRET = process.env.THINCODER_TOKEN_SECRET || "thincoder-default-secret"
|
|
@@ -50,7 +51,7 @@ export function validateDesignToken(token) {
|
|
|
50
51
|
}
|
|
51
52
|
|
|
52
53
|
/** Extract UUID from signed token for regex matching */
|
|
53
|
-
|
|
54
|
+
function extractTokenUUID(token) {
|
|
54
55
|
const parts = token.split(":")
|
|
55
56
|
return parts.length >= 1 ? parts[0] : token
|
|
56
57
|
}
|
|
@@ -78,7 +79,7 @@ export const advisorTool = {
|
|
|
78
79
|
"Run an independent review on your work. " +
|
|
79
80
|
"Use type='design' to review design documents before implementation — pass documents=[...] with the explicit list of doc paths to review; use documents in code review too (the task's Docs involved list). " +
|
|
80
81
|
"Use type='code' (default) to review code changes after implementation — pass paths=[...] to specify which files or directories to review, or documents=[...] for acceptance criteria context. " +
|
|
81
|
-
"The advisor is an independent read-only sub-agent that explores the codebase,
|
|
82
|
+
"The advisor is an independent read-only sub-agent that explores the codebase, " +
|
|
82
83
|
"reads files, and traces callers via grep/lsp. " +
|
|
83
84
|
"For code review: round 1 does a full review, round 2 verifies the prior table, " +
|
|
84
85
|
"round 3+ strictly checks only the prior table — convergence, not divergence. " +
|
|
@@ -118,7 +119,6 @@ export const advisorTool = {
|
|
|
118
119
|
|
|
119
120
|
// Design review: validate that documents are in docs/ or are recognized doc files
|
|
120
121
|
if (reviewType === "design" && documents) {
|
|
121
|
-
const { isDocFile } = await import("../advisor/repos.mjs")
|
|
122
122
|
const invalidDocs = documents.filter((doc) => {
|
|
123
123
|
// Allow docs/ directory and recognized doc files (METHODOLOGY.md, README.md, etc.)
|
|
124
124
|
if (doc.startsWith("docs/") || doc.startsWith("docs\\")) return false
|
|
@@ -133,7 +133,6 @@ export const advisorTool = {
|
|
|
133
133
|
if (reviewType === "design") {
|
|
134
134
|
agent._advisorRound = 0
|
|
135
135
|
agent._advisorSession = null
|
|
136
|
-
agent._advisorLastSnapshotHash = null // stale diff dedup baseline must not leak into the next code review
|
|
137
136
|
}
|
|
138
137
|
|
|
139
138
|
// Generate the design token BEFORE the review and inject it into the advisor's prompt.
|
package/src/agent-tools/plan.mjs
CHANGED
|
@@ -8,17 +8,17 @@
|
|
|
8
8
|
* user sends a new message — so the constraint never fades from context.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
const PLAN_FULL_REMINDER =
|
|
12
12
|
"[System reminder: plan mode is ON. Workflow: (1) explore/read codebase with read-only tools, " +
|
|
13
13
|
"(2) design a solution considering trade-offs, (3) present your plan by calling plan with action='exit' " +
|
|
14
14
|
"so the user can approve it. Only read-only tools are allowed — do not write, edit, or run mutation commands. " +
|
|
15
15
|
"Your turn must end with either a clarifying question to the user or a call to plan with action='exit'.]"
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
const PLAN_SPARSE_REMINDER =
|
|
18
18
|
"[System reminder: plan mode still active — read-only tools only (the current plan file exempt). " +
|
|
19
19
|
"Design the solution, then call plan with action='exit' for user approval.]"
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
const PLAN_EXIT_REMINDER =
|
|
22
22
|
"[System reminder: plan mode is now OFF. Start implementing your plan — edit files, run commands. " +
|
|
23
23
|
"No need for a task list (plan already covered that) or further confirmation.]"
|
|
24
24
|
|
|
@@ -14,6 +14,48 @@ import { validateDesignToken } from "./advisor.mjs"
|
|
|
14
14
|
* - parallel subagent calls via the parallel channel (parallel: true)
|
|
15
15
|
* - non-recursive: child agents do not get the subagent tool (depth > 0 is not injected)
|
|
16
16
|
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Effective subagent model override for a role (CLI parity shared with VS Code):
|
|
20
|
+
* priority — subagent tool `model` arg > config.agent.subagentModels[role] > config.agent.subagentModel > null (inherit parent).
|
|
21
|
+
*/
|
|
22
|
+
export function effectiveSubagentModel(parent, role, modelArg) {
|
|
23
|
+
if (modelArg) return modelArg
|
|
24
|
+
const cfg = parent.config?.agent ?? {}
|
|
25
|
+
return cfg.subagentModels?.[role] ?? cfg.subagentModel ?? null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Resolve the sub-agent's provider from a model override string (shared with the
|
|
30
|
+
* VS Code port). Forms accepted:
|
|
31
|
+
* "provider:model" → the named provider with the named model
|
|
32
|
+
* "provider" → the named provider's configured model
|
|
33
|
+
* "model" → same provider as the parent, different model
|
|
34
|
+
* null → parent's provider unchanged.
|
|
35
|
+
* API keys follow the config fallback order (provider.apiKey → THINCODER_API_KEY → provider-specific env).
|
|
36
|
+
*/
|
|
37
|
+
export function resolveChildProvider(parent, modelArg) {
|
|
38
|
+
if (!modelArg) return { ...parent.provider }
|
|
39
|
+
const providers = parent.config?.providersList ?? []
|
|
40
|
+
const withKey = (p) => {
|
|
41
|
+
if (p.apiKey?.trim()) return { ...p, apiKey: p.apiKey.trim() }
|
|
42
|
+
if (process.env.THINCODER_API_KEY) return { ...p, apiKey: process.env.THINCODER_API_KEY }
|
|
43
|
+
const envMap = { deepseek: "DEEPSEEK_API_KEY", openai: "OPENAI_API_KEY" }
|
|
44
|
+
const keyVar = envMap[p.name]
|
|
45
|
+
if (keyVar && process.env[keyVar]) return { ...p, apiKey: process.env[keyVar] }
|
|
46
|
+
return { ...p }
|
|
47
|
+
}
|
|
48
|
+
if (modelArg.includes(":")) {
|
|
49
|
+
const [pname, mname] = modelArg.split(":")
|
|
50
|
+
const p = providers.find((x) => x.name === pname)
|
|
51
|
+
if (!p) throw new Error(`subagent model: unknown provider "${pname}" (available: ${providers.map((x) => x.name).join(", ") || "none"})`)
|
|
52
|
+
return { ...withKey(p), model: mname || p.model }
|
|
53
|
+
}
|
|
54
|
+
const byName = providers.find((x) => x.name === modelArg)
|
|
55
|
+
if (byName) return withKey(byName)
|
|
56
|
+
return { ...parent.provider, model: modelArg }
|
|
57
|
+
}
|
|
58
|
+
|
|
17
59
|
export const subagentTool = {
|
|
18
60
|
name: "subagent",
|
|
19
61
|
description:
|
|
@@ -30,6 +72,7 @@ export const subagentTool = {
|
|
|
30
72
|
task: { type: "string", description: "Self-contained task description for the sub-agent" },
|
|
31
73
|
context: { type: "string", description: "Optional background the sub-agent needs (it cannot see this conversation)" },
|
|
32
74
|
role: { type: "string", enum: ["explore", "plan", "coder", "eng-coder"], description: "Sub-agent role: 'explore' (read-only search/analysis), 'plan' (read-only implementation planning), 'coder' (full implementation), 'eng-coder' (engineering-mode coder — strict methodology, design-driven). ENUM IS OVERRIDDEN IN setup.mjs PER ENGINEERING MODE." },
|
|
75
|
+
model: { type: "string", description: "Provider/model override for this sub-agent: 'provider:model', a provider name from config, or a model name on the parent's provider. Defaults to the agent.subagentModel config, then the parent's provider. Useful for offloading heavy work to a cheaper model." },
|
|
33
76
|
designToken: { type: "string", description: "Required when role='eng-coder': the token returned by advisor(type='design') after the design review passed. Without a valid token, eng-coder cannot modify files." },
|
|
34
77
|
},
|
|
35
78
|
required: ["task"],
|
|
@@ -49,6 +92,9 @@ export const subagentTool = {
|
|
|
49
92
|
throw new Error("Engineering mode is not active — use role='coder' for implementation tasks.")
|
|
50
93
|
}
|
|
51
94
|
|
|
95
|
+
// Provider/model override: tool `model` arg > subagentModels[role] > subagentModel > parent provider
|
|
96
|
+
const childProvider = resolveChildProvider(parent, effectiveSubagentModel(parent, role, args.model))
|
|
97
|
+
|
|
52
98
|
// eng-coder token gate: the design review must have passed and the caller must
|
|
53
99
|
// present the exact token advisor issued — otherwise the child is not authorized to code.
|
|
54
100
|
if (role === "eng-coder") {
|
|
@@ -97,7 +143,7 @@ export const subagentTool = {
|
|
|
97
143
|
: parent.config
|
|
98
144
|
|
|
99
145
|
const child = createAgent({
|
|
100
|
-
provider:
|
|
146
|
+
provider: childProvider,
|
|
101
147
|
tools,
|
|
102
148
|
config: childConfig,
|
|
103
149
|
cwd: parent.cwd,
|
|
@@ -180,11 +226,10 @@ export function mergeChildMutations(parent, child) {
|
|
|
180
226
|
parent._verifiedThisRun = false
|
|
181
227
|
parent._verifyPassed = undefined
|
|
182
228
|
}
|
|
183
|
-
// Fresh code → fresh convergence budget + stale session
|
|
229
|
+
// Fresh code → fresh convergence budget + stale session cleanup.
|
|
184
230
|
// _advisorRound reset ensures new code gets a full round-1 review;
|
|
185
|
-
// _advisorSession
|
|
231
|
+
// _advisorSession prevents cross-contamination between reviews.
|
|
186
232
|
parent._advisorRound = 0
|
|
187
233
|
parent._advisorSession = null
|
|
188
|
-
parent._advisorLastSnapshotHash = null
|
|
189
234
|
return true
|
|
190
235
|
}
|
package/src/agent.mjs
CHANGED
|
@@ -100,7 +100,7 @@ export function createAgent({
|
|
|
100
100
|
_mutatedThisRun: false, _verifiedThisRun: false, _verifyPassed: undefined, _calledAdvisorThisRun: false,
|
|
101
101
|
_engDesignReviewed: false, // eng-coder: design review gate passed (hard gate in dispatch.mjs)
|
|
102
102
|
_engDesignToken: null, // issued by advisor(type="design"); required to spawn eng-coder
|
|
103
|
-
_touchedFiles: [], _verifyRetries: 0, _advisorRound: 0, _advisorSession: null,
|
|
103
|
+
_touchedFiles: [], _verifyRetries: 0, _advisorRound: 0, _advisorSession: null,
|
|
104
104
|
_lastEngState: false,
|
|
105
105
|
_pendingReminders: [],
|
|
106
106
|
_pendingTimers: [],
|
|
@@ -131,7 +131,6 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
|
|
|
131
131
|
agent._verifyRetries = 0
|
|
132
132
|
agent._advisorRound = 0
|
|
133
133
|
agent._advisorSession = null // advisor session is per-run: discard when the task ends, next task starts fresh
|
|
134
|
-
agent._advisorLastSnapshotHash = null // dedup baseline is per-run too — stale snapshot could wrongly suppress a diff refresh
|
|
135
134
|
agent._emptyRetries = 0 // empty-response retry budget is per-run: a fresh user turn restarts from zero
|
|
136
135
|
agent._compressFailures = 0 // compaction summary-failure counter is per-run: a fresh user turn restarts from zero
|
|
137
136
|
}
|
package/src/config.mjs
CHANGED
|
@@ -43,6 +43,8 @@ const DEFAULTS = {
|
|
|
43
43
|
agent: {
|
|
44
44
|
maxTurns: 100,
|
|
45
45
|
subagentTurns: 100,
|
|
46
|
+
subagentModel: null, // default subagent model: "provider:model" | provider name | model name (parent provider); null = inherit parent provider
|
|
47
|
+
subagentModels: {}, // per-type override: { explore, plan, coder, "eng-coder" } — priority: subagent tool model arg > this[role] > subagentModel > parent provider
|
|
46
48
|
goalTurns: 200,
|
|
47
49
|
compactThreshold: 100000,
|
|
48
50
|
verifyGuard: false, // push model back to verify when files were mutated but verify not run (opt-in)
|
|
@@ -56,6 +58,7 @@ const DEFAULTS = {
|
|
|
56
58
|
projectDir: ".thincoder/memory",
|
|
57
59
|
team: null,
|
|
58
60
|
},
|
|
61
|
+
shell: null, // bash tool shell executable (e.g. "C:\\Program Files\\Git\\bin\\bash.exe" or "pwsh"); null = system default (cmd on Windows, /bin/sh elsewhere)
|
|
59
62
|
embedding: {
|
|
60
63
|
baseURL: "https://api.siliconflow.cn/v1",
|
|
61
64
|
model: "BAAI/bge-m3",
|
package/src/context.mjs
CHANGED
|
@@ -237,7 +237,7 @@ const OVERSIZE_CONTENT_LIMIT = 8_000
|
|
|
237
237
|
* does not touch reasoning_content (DeepSeek/Kimi echo protocol) or tool_calls pairing structure — no protocol 400 risk.
|
|
238
238
|
* Only called after compressIfNeeded determines threshold is exceeded. Returns whether any message was truncated.
|
|
239
239
|
*/
|
|
240
|
-
|
|
240
|
+
function shrinkOversized(agent, limit = OVERSIZE_CONTENT_LIMIT) {
|
|
241
241
|
let shrunk = false
|
|
242
242
|
for (const m of agent.history) {
|
|
243
243
|
if ((m.role !== "user" && m.role !== "tool") || typeof m.content !== "string") continue
|
|
@@ -5,21 +5,21 @@ You have read-only tools to explore the codebase.
|
|
|
5
5
|
You have a budget of 30 tool rounds (chat turns). Hard mechanical cap: 100 rounds.
|
|
6
6
|
|
|
7
7
|
Review workflow:
|
|
8
|
-
1. The files
|
|
8
|
+
1. The affected files are named in the prior issue table — read them in full. The prior issue table is HISTORY from a previous review, not current state.
|
|
9
9
|
2. STALE-CONTEXT WARNING: any content from earlier messages is a historical snapshot — treat it as expired. Only fresh `read` results describe the current state.
|
|
10
10
|
3. Project conventions were established in round 1 — do NOT re-read AGENTS.md / design docs unless a fix appears to contradict the task itself.
|
|
11
|
-
4.
|
|
11
|
+
4. **ALWAYS verify current file content with `read` before judging a prior-table item as fixed or unfixed — never decide based on the prior table alone.** An empty `git diff` does NOT mean nothing changed: fixes may already be committed (`git log -3` shows recent commits) — `read` the files named in the prior table regardless of the diff. Batch independent tool calls in one reply.
|
|
12
12
|
5. Use grep or lsp to trace callers, imports, and dependencies — only where genuinely needed.
|
|
13
13
|
6. Produce your review table.
|
|
14
14
|
|
|
15
|
-
Budget: read only the files
|
|
15
|
+
Budget: read only the files named in the prior-table items. If at 15 rounds you have not yet verified all items, wrap up.
|
|
16
16
|
|
|
17
17
|
Rules:
|
|
18
18
|
- Respect the project's stated platform requirements — do not flag features as errors if they are valid under the project's target environment.
|
|
19
19
|
- Primarily check fix status of items in the prior issue table.
|
|
20
20
|
- For items marked "fixed": verify they were actually fixed.
|
|
21
21
|
- For items marked "not an issue": evaluate whether the reasoning is sound.
|
|
22
|
-
- Every "Unfixed" or "New" entry MUST
|
|
22
|
+
- Every "Unfixed" or "New" entry MUST quote the exact line content from THIS round's `read` output (e.g. `run.mjs:180: timeoutId = setTimeout(...)`). Line numbers alone are NOT evidence — they may come from the stale prior table. Findings without a fresh quoted line are treated as unverified and will not be accepted.
|
|
23
23
|
- You may flag obvious new problems — but only if clearly visible in the reviewed files and would cause crashes, data loss, or logic errors.
|
|
24
24
|
- Do NOT nitpick style or naming.
|
|
25
25
|
- Output a Markdown table listing all remaining problems (old or new):
|
|
@@ -5,21 +5,21 @@ You have read-only tools to explore the codebase.
|
|
|
5
5
|
You have a budget of 30 tool rounds (chat turns). Hard mechanical cap: 100 rounds.
|
|
6
6
|
|
|
7
7
|
Review workflow:
|
|
8
|
-
1. The files
|
|
8
|
+
1. The affected files are named in the prior issue table — read them in full. The prior issue table is HISTORY from a previous review, not current state.
|
|
9
9
|
2. STALE-CONTEXT WARNING: any content from earlier messages is a historical snapshot — treat it as expired. Only fresh `read` results describe the current state.
|
|
10
10
|
3. Project conventions were established in round 1 — do NOT re-read AGENTS.md / design docs.
|
|
11
|
-
4.
|
|
11
|
+
4. **ALWAYS verify current file content with `read` before judging a prior-table item as fixed or unfixed — never decide based on the prior table alone.** An empty `git diff` does NOT mean nothing changed: fixes may already be committed (`git log -3` shows recent commits) — `read` the files named in the prior table regardless of the diff. Batch independent tool calls in one reply.
|
|
12
12
|
5. Verify fix status of each item in the prior issue table.
|
|
13
13
|
6. Produce your review table.
|
|
14
14
|
|
|
15
|
-
Budget: read only the files
|
|
15
|
+
Budget: read only the files named in the prior-table items. If at 15 rounds you have not yet verified all items, wrap up.
|
|
16
16
|
|
|
17
17
|
Rules:
|
|
18
18
|
- Respect the project's stated platform requirements — do not flag features as errors if they are valid under the project's target environment.
|
|
19
19
|
- Only check fix status of items in the prior issue table.
|
|
20
20
|
- For items marked "fixed": verify they were actually fixed.
|
|
21
21
|
- For items marked "not an issue": evaluate whether the reasoning is sound.
|
|
22
|
-
- Every "Unfixed" entry MUST
|
|
22
|
+
- Every "Unfixed" entry MUST quote the exact line content from THIS round's `read` output (e.g. `run.mjs:180: timeoutId = setTimeout(...)`). Line numbers alone are NOT evidence — they may come from the stale prior table. Findings without a fresh quoted line are treated as unverified and will not be accepted.
|
|
23
23
|
- Output a Markdown table. Only list items that still have problems:
|
|
24
24
|
| # | Orig# | File | Severity | Status | Notes |
|
|
25
25
|
|---|-------|------|----------|--------|-------|
|
package/src/tools/system.mjs
CHANGED
|
@@ -48,7 +48,7 @@ function checkBashSafety(command, cwd) {
|
|
|
48
48
|
* Sets PYTHONIOENCODING on Windows to override GBK default for Python scripts.
|
|
49
49
|
*/
|
|
50
50
|
function buildBashEnv() {
|
|
51
|
-
const
|
|
51
|
+
const isWindows = process.platform === "win32"
|
|
52
52
|
return {
|
|
53
53
|
...process.env,
|
|
54
54
|
GIT_EDITOR: "true",
|
|
@@ -57,7 +57,7 @@ function buildBashEnv() {
|
|
|
57
57
|
GIT_PAGER: "cat",
|
|
58
58
|
PAGER: "cat",
|
|
59
59
|
TERM: "dumb",
|
|
60
|
-
...(
|
|
60
|
+
...(isWindows ? { PYTHONIOENCODING: "utf-8" } : {}),
|
|
61
61
|
}
|
|
62
62
|
}
|
|
63
63
|
|
|
@@ -118,11 +118,18 @@ async function gitGuardSnapshot(command, cwd) {
|
|
|
118
118
|
}
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
-
function runBash(command, cwd, { timeout, signal, onOutput }) {
|
|
121
|
+
function runBash(command, cwd, { timeout, signal, onOutput, shell }) {
|
|
122
122
|
return new Promise((resolve) => {
|
|
123
|
-
|
|
123
|
+
// Windows + default cmd: force UTF-8 code page for this child process (each spawn
|
|
124
|
+
// is an independent cmd, so chcp has no side effects on other shells) — otherwise
|
|
125
|
+
// cmd emits GBK bytes that the UTF-8 decoder turns into mojibake, and the model
|
|
126
|
+
// fights encoding errors instead of the actual command (reported UX on win11).
|
|
127
|
+
const effectiveCommand = process.platform === "win32" && !shell
|
|
128
|
+
? `chcp 65001 >nul && ${command}`
|
|
129
|
+
: command
|
|
130
|
+
const child = spawn(effectiveCommand, {
|
|
124
131
|
cwd,
|
|
125
|
-
shell: true,
|
|
132
|
+
shell: shell ?? true,
|
|
126
133
|
windowsHide: true,
|
|
127
134
|
detached: process.platform !== "win32",
|
|
128
135
|
stdio: ["ignore", "pipe", "pipe"],
|
|
@@ -239,6 +246,7 @@ export const bashTool = {
|
|
|
239
246
|
timeout: args.timeout ?? BASH_TIMEOUT_MS,
|
|
240
247
|
signal: ctx.signal,
|
|
241
248
|
onOutput: ctx.onOutput,
|
|
249
|
+
shell: ctx.agent?.config?.shell ?? null,
|
|
242
250
|
})
|
|
243
251
|
return guard ? `${guard.notice}\n\n${result}` : result
|
|
244
252
|
},
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process"
|
|
2
|
+
import { existsSync } from "node:fs"
|
|
3
|
+
import { C } from "./ansi.mjs"
|
|
4
|
+
|
|
5
|
+
/** Platform shell candidates: { name, value (config.shell payload), detect() }.
|
|
6
|
+
* detect() returns truthy when the shell is available (static check, never throws).
|
|
7
|
+
* The FULL filtered result (available shells) is cached at module level — shell
|
|
8
|
+
* availability does not change during a session, and re-running spawnSync on every
|
|
9
|
+
* /shell would freeze the TUI (up to 3 × timeout per open). */
|
|
10
|
+
let _shellCandidatesCache = null
|
|
11
|
+
function platformShellCandidates() {
|
|
12
|
+
if (_shellCandidatesCache) return _shellCandidatesCache
|
|
13
|
+
const win = process.platform === "win32"
|
|
14
|
+
const commandExists = (cmd) => {
|
|
15
|
+
try {
|
|
16
|
+
// 'command -v' is a POSIX shell builtin; sh -c runs it (Windows uses `where`)
|
|
17
|
+
const r = spawnSync(win ? "where" : "sh", win ? [cmd] : ["-c", `command -v ${cmd}`], { encoding: "utf8", timeout: 3000 })
|
|
18
|
+
return r.status === 0 && r.stdout.trim().length > 0
|
|
19
|
+
} catch { return false }
|
|
20
|
+
}
|
|
21
|
+
const GIT_BASH_PATHS = [
|
|
22
|
+
"C:\\Program Files\\Git\\bin\\bash.exe",
|
|
23
|
+
"C:\\Program Files (x86)\\Git\\bin\\bash.exe",
|
|
24
|
+
`${process.env.LOCALAPPDATA ?? ""}\\Programs\\Git\\bin\\bash.exe`,
|
|
25
|
+
]
|
|
26
|
+
const candidates = []
|
|
27
|
+
// System default always first
|
|
28
|
+
candidates.push({ name: "System default", value: null, detect: () => true })
|
|
29
|
+
if (win) {
|
|
30
|
+
candidates.push({ name: "PowerShell (pwsh)", value: "pwsh", detect: () => commandExists("pwsh") })
|
|
31
|
+
candidates.push({ name: "Windows PowerShell (powershell)", value: "powershell", detect: () => commandExists("powershell") })
|
|
32
|
+
const gb = GIT_BASH_PATHS.find((p) => existsSync(p))
|
|
33
|
+
if (gb) candidates.push({ name: `Git Bash (${gb})`, value: gb, detect: () => true })
|
|
34
|
+
candidates.push({ name: "WSL bash (wsl)", value: "wsl", detect: () => commandExists("wsl") })
|
|
35
|
+
} else {
|
|
36
|
+
for (const sh of ["bash", "zsh", "fish"]) {
|
|
37
|
+
candidates.push({ name: sh, value: sh, detect: () => commandExists(sh) })
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
// Known limitation: POSIX detection requires `sh` in PATH (extremely minimal
|
|
41
|
+
// containers may lack it — then only "System default" is offered).
|
|
42
|
+
_shellCandidatesCache = candidates.filter((c) => c.detect())
|
|
43
|
+
return _shellCandidatesCache
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** /shell command: bash tool shell — platform-aware picker (default) or direct args.
|
|
47
|
+
* ctx: { agent, pushLine, showPicker, askQuestion, persistRaw }
|
|
48
|
+
* /shell → picker: available shells for this platform + custom path
|
|
49
|
+
* /shell <path|name> → set directly (quotes stripped)
|
|
50
|
+
* /shell reset → system default (case-insensitive) */
|
|
51
|
+
export async function handleShellCommand(ctx, args = []) {
|
|
52
|
+
const { agent, pushLine, showPicker, askQuestion, persistRaw } = ctx
|
|
53
|
+
// Strip surrounding quotes (slash args are whitespace-split; /shell "C:\Program Files\Git\bin\bash.exe"
|
|
54
|
+
// would otherwise persist the literal quote characters and spawn would fail)
|
|
55
|
+
const input = args.join(" ").trim().replace(/^["'](.+)["']$/, "$1")
|
|
56
|
+
|
|
57
|
+
const persist = async (value) => {
|
|
58
|
+
agent.config.shell = value
|
|
59
|
+
await persistRaw((raw) => { raw.shell = value }).catch((e) => pushLine(`[error] ${e.message}`, C.error))
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ── Direct args ──
|
|
63
|
+
if (input) {
|
|
64
|
+
if (input.toLowerCase() === "reset") {
|
|
65
|
+
await persist(null)
|
|
66
|
+
pushLine("Shell reset to system default.", C.text)
|
|
67
|
+
return
|
|
68
|
+
}
|
|
69
|
+
await persist(input)
|
|
70
|
+
pushLine(`Shell set to \`${input}\` — bash tool commands will run through it.`, C.text)
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ── Platform-aware picker ──
|
|
75
|
+
const available = platformShellCandidates() // cached — detection runs once per session
|
|
76
|
+
const current = agent.config?.shell ?? null
|
|
77
|
+
const entries = [
|
|
78
|
+
{ type: "header", text: current ? `Current: ${current}` : "Current: (system default)" },
|
|
79
|
+
...available.map((c) => ({
|
|
80
|
+
type: "item",
|
|
81
|
+
text: `${c.name}${c.value ? ` → ${c.value}` : " (cmd + UTF-8 on Windows / /bin/sh elsewhere)"}`,
|
|
82
|
+
action: "pick",
|
|
83
|
+
value: c.value,
|
|
84
|
+
marker: (c.value ?? null) === current ? "●" : "",
|
|
85
|
+
})),
|
|
86
|
+
{ type: "header", text: "Other" },
|
|
87
|
+
{ type: "item", text: "Custom path… (type any shell path/command)", action: "custom" },
|
|
88
|
+
]
|
|
89
|
+
const defaultIndex = Math.max(0, available.findIndex((c) => (c.value ?? null) === current))
|
|
90
|
+
const picked = await showPicker("Shell", entries, { defaultIndex })
|
|
91
|
+
if (!picked) return // Esc
|
|
92
|
+
|
|
93
|
+
if (picked.action === "custom") {
|
|
94
|
+
const path = await askQuestion("Enter shell path or command (e.g. C:\\Program Files\\Git\\bin\\bash.exe, pwsh, cmd):")
|
|
95
|
+
if (!path) return
|
|
96
|
+
await persist(path.trim().replace(/^["'](.+)["']$/, "$1"))
|
|
97
|
+
pushLine(`Shell set to \`${path.trim()}\` — bash tool commands will run through it.`, C.text)
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
await persist(picked.value)
|
|
101
|
+
pushLine(picked.value === null
|
|
102
|
+
? "Shell reset to system default."
|
|
103
|
+
: `Shell set to \`${picked.value}\` — bash tool commands will run through it.`, C.text)
|
|
104
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { ansi, C } from "./ansi.mjs"
|
|
2
|
+
|
|
3
|
+
/** Subagent role slots — each has an independent model override slot */
|
|
4
|
+
export const SUBMODEL_SLOTS = ["explore", "plan", "coder", "eng-coder"]
|
|
5
|
+
|
|
6
|
+
/** Human-readable effective display with inheritance source. role=null → global slot.
|
|
7
|
+
* source semantics: "type" = role-specific override (subagentModels[role]),
|
|
8
|
+
* "global" = falls back to subagentModel, "parent" = no override, inherits the
|
|
9
|
+
* parent agent's provider. */
|
|
10
|
+
function slotDisplay(agent, role) {
|
|
11
|
+
const cfg = agent.config?.agent ?? {}
|
|
12
|
+
if (role === null) {
|
|
13
|
+
return cfg.subagentModel ? { value: cfg.subagentModel, source: "global" } : { value: null, source: "parent" }
|
|
14
|
+
}
|
|
15
|
+
const type = cfg.subagentModels?.[role]
|
|
16
|
+
const global = cfg.subagentModel
|
|
17
|
+
if (type) return { value: type, source: "type" }
|
|
18
|
+
if (global) return { value: global, source: "global" }
|
|
19
|
+
return { value: null, source: "parent" }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** /submodel command: subagent model config — picker menu (default) or direct args.
|
|
23
|
+
* ctx: { agent, pushLine, showPicker, askQuestion, persistRaw, pickModelForSlot }
|
|
24
|
+
* /submodel → picker: global + 4 role slots
|
|
25
|
+
* /submodel <value> → set global default
|
|
26
|
+
* /submodel <type> <value> → set a role slot
|
|
27
|
+
* /submodel <type> → show a slot
|
|
28
|
+
* /submodel reset [type] → clear global (or a slot)
|
|
29
|
+
* value forms: provider:model | provider name | model name (same as subagent tool model arg) */
|
|
30
|
+
export async function handleSubmodelCommand(ctx, args = []) {
|
|
31
|
+
const { agent, pushLine, showPicker, askQuestion, persistRaw, pickModelForSlot } = ctx
|
|
32
|
+
const input = args.join(" ").trim()
|
|
33
|
+
|
|
34
|
+
// Single write channel: mutate BOTH in-memory agent.config.agent and the disk raw
|
|
35
|
+
// with the same function (no divergent double-writes). Agent config is created
|
|
36
|
+
// lazily here — an Esc-cancelled picker never writes anything.
|
|
37
|
+
const persist = async (mutate) => {
|
|
38
|
+
agent.config.agent ??= {}
|
|
39
|
+
const a = agent.config.agent
|
|
40
|
+
mutate(a)
|
|
41
|
+
await persistRaw((raw) => {
|
|
42
|
+
raw.agent ??= {}
|
|
43
|
+
mutate(raw.agent)
|
|
44
|
+
}).catch((e) => pushLine(`[error] ${e.message}`, C.error))
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ── Direct args ──
|
|
48
|
+
if (input) {
|
|
49
|
+
const parts = input.split(/\s+/)
|
|
50
|
+
const first = parts[0].toLowerCase()
|
|
51
|
+
if (first === "reset") {
|
|
52
|
+
const type = parts[1]?.toLowerCase()
|
|
53
|
+
if (type) {
|
|
54
|
+
if (!SUBMODEL_SLOTS.includes(type)) {
|
|
55
|
+
pushLine(`Unknown subagent type: ${type} (available: ${SUBMODEL_SLOTS.join(", ")})`, C.error)
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
await persist((a) => { a.subagentModels ??= {}; delete a.subagentModels[type]; if (Object.keys(a.subagentModels).length === 0) delete a.subagentModels })
|
|
59
|
+
pushLine(`Subagent type ${type}: reset to inherit (${slotDisplay(agent, type).source === "global" ? `global: ${slotDisplay(agent, type).value}` : "parent provider"}).`, C.text)
|
|
60
|
+
} else {
|
|
61
|
+
await persist((a) => { a.subagentModel = null })
|
|
62
|
+
pushLine("Subagent global model: reset to inherit parent provider.", C.text)
|
|
63
|
+
}
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
if (SUBMODEL_SLOTS.includes(first)) {
|
|
67
|
+
const value = parts.slice(1).join(" ")
|
|
68
|
+
if (!value) {
|
|
69
|
+
const d = slotDisplay(agent, first)
|
|
70
|
+
pushLine(`Subagent ${first}: ${d.value ? `\`${d.value}\` (${d.source} config)` : "(inherit: parent provider)"}`, C.text)
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
await persist((a) => { a.subagentModels ??= {}; a.subagentModels[first] = value })
|
|
74
|
+
pushLine(`Subagent ${first} model set to \`${value}\`.`, C.text)
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
if (parts.length >= 2) {
|
|
78
|
+
// Two+ tokens and the first is not a known slot → user probably meant a type
|
|
79
|
+
pushLine(`Unknown subagent type: ${first} (available: ${SUBMODEL_SLOTS.join(", ")})`, C.error)
|
|
80
|
+
return
|
|
81
|
+
}
|
|
82
|
+
// Global value (provider:model | provider | model)
|
|
83
|
+
await persist((a) => { a.subagentModel = input })
|
|
84
|
+
pushLine(`Subagent global model set to \`${input}\`.`, C.text)
|
|
85
|
+
return
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ── Picker menu: global + 4 role slots ──
|
|
89
|
+
// for(;;) loop relies on showPicker's async/Promise suspension — every iteration
|
|
90
|
+
// awaits a user choice; Esc (null) exits. Mirrors openModelPicker's menu-loop pattern.
|
|
91
|
+
for (;;) {
|
|
92
|
+
const g = slotDisplay(agent, null)
|
|
93
|
+
const entries = [
|
|
94
|
+
{ type: "header", text: "Subagent models — pick a slot to edit (Esc exits)" },
|
|
95
|
+
{ type: "item", text: `${g.value ? `Global default: ${g.value}` : "Global default: (inherit parent)"}`, action: "slot", slot: "global", marker: g.value ? "●" : "○" },
|
|
96
|
+
...SUBMODEL_SLOTS.map((role) => {
|
|
97
|
+
const d = slotDisplay(agent, role)
|
|
98
|
+
const label = d.value ? `${role}: ${d.value}${d.source === "global" ? " (←global)" : ""}` : `${role}: (inherit: ${d.source === "global" ? "global" : "parent"})`
|
|
99
|
+
return { type: "item", text: label, action: "slot", slot: role, marker: d.source === "type" ? "●" : "○" }
|
|
100
|
+
}),
|
|
101
|
+
{ type: "header", text: "Actions" },
|
|
102
|
+
{ type: "item", text: "Reset all (inherit parent)", action: "resetall" },
|
|
103
|
+
]
|
|
104
|
+
const picked = await showPicker("Subagent Models", entries)
|
|
105
|
+
if (!picked) return // Esc
|
|
106
|
+
|
|
107
|
+
if (picked.action === "resetall") {
|
|
108
|
+
await persist((a) => { a.subagentModel = null; delete a.subagentModels })
|
|
109
|
+
pushLine("All subagent model overrides cleared — inherit parent provider.", C.text)
|
|
110
|
+
continue
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const role = picked.slot === "global" ? null : picked.slot
|
|
114
|
+
const cur = slotDisplay(agent, role)
|
|
115
|
+
const sub = await showPicker(`Subagent ${picked.slot}`, [
|
|
116
|
+
{ type: "header", text: `Current: ${cur.value ? `\`${cur.value}\` (${cur.source})` : "(inherit)"}` },
|
|
117
|
+
{ type: "item", text: "Set model… (provider → model picker)", action: "set" },
|
|
118
|
+
{ type: "item", text: "Set to parent provider model", action: "parent" },
|
|
119
|
+
{ type: "item", text: "Reset (inherit)", action: "reset" },
|
|
120
|
+
])
|
|
121
|
+
if (!sub) continue // Esc → back to slots
|
|
122
|
+
|
|
123
|
+
if (sub.action === "set") {
|
|
124
|
+
const sel = await pickModelForSlot()
|
|
125
|
+
if (!sel) continue
|
|
126
|
+
const value = `${sel.provider}:${sel.model}`
|
|
127
|
+
if (role) {
|
|
128
|
+
await persist((a) => { a.subagentModels ??= {}; a.subagentModels[role] = value })
|
|
129
|
+
pushLine(`Subagent ${role} model set to \`${value}\`.`, C.text)
|
|
130
|
+
} else {
|
|
131
|
+
await persist((a) => { a.subagentModel = value })
|
|
132
|
+
pushLine(`Subagent global model set to \`${value}\`.`, C.text)
|
|
133
|
+
}
|
|
134
|
+
} else if (sub.action === "parent") {
|
|
135
|
+
const value = `${agent.activeProvider}:${agent.activeModel ?? agent.provider?.model}`
|
|
136
|
+
if (role) {
|
|
137
|
+
await persist((a) => { a.subagentModels ??= {}; a.subagentModels[role] = value })
|
|
138
|
+
} else {
|
|
139
|
+
await persist((a) => { a.subagentModel = value })
|
|
140
|
+
}
|
|
141
|
+
pushLine(`Subagent ${picked.slot} set to parent model \`${value}\`.`, C.text)
|
|
142
|
+
} else if (sub.action === "reset") {
|
|
143
|
+
if (role) {
|
|
144
|
+
await persist((a) => { a.subagentModels ??= {}; delete a.subagentModels[role]; if (Object.keys(a.subagentModels).length === 0) delete a.subagentModels })
|
|
145
|
+
} else {
|
|
146
|
+
await persist((a) => { a.subagentModel = null })
|
|
147
|
+
}
|
|
148
|
+
pushLine(`Subagent ${picked.slot} reset to inherit.`, C.text)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
package/src/tui/index.mjs
CHANGED
|
@@ -291,7 +291,7 @@ export async function startTUI(agent, opts = {}) {
|
|
|
291
291
|
// local TUI/agent config, never the in-flight turn); the rest are queued
|
|
292
292
|
const cmd0 = text.split(/\s+/)[0].toLowerCase()
|
|
293
293
|
const resolved0 = SLASH_ALIASES[cmd0] ?? cmd0
|
|
294
|
-
const safeDuringProcessing = new Set(["/help", "/exit", "/model", "/think", "/config", "/skills", "/mcp", "/goal", "/session"])
|
|
294
|
+
const safeDuringProcessing = new Set(["/help", "/exit", "/model", "/submodel", "/shell", "/think", "/config", "/skills", "/mcp", "/goal", "/session"])
|
|
295
295
|
if (safeDuringProcessing.has(resolved0)) {
|
|
296
296
|
await handleSlash(text)
|
|
297
297
|
render()
|
|
@@ -341,7 +341,7 @@ export async function startTUI(agent, opts = {}) {
|
|
|
341
341
|
const { persistRaw, syncProviderField, maskKey } = createConfigHelpers(agent)
|
|
342
342
|
|
|
343
343
|
// Model picker + generic picker: implemented in pickers.mjs
|
|
344
|
-
const { closePicker, showPicker, popPicker, renderPickerLines, openModelPicker, selectModel, setProviderKey } = createPickers({
|
|
344
|
+
const { closePicker, showPicker, popPicker, renderPickerLines, openModelPicker, selectModel, setProviderKey, pickModelForSlot } = createPickers({
|
|
345
345
|
agent, state, render, ansi, C, pushLine, pushLabel, persistRaw, askQuestion, maskKey,
|
|
346
346
|
})
|
|
347
347
|
|
|
@@ -363,6 +363,7 @@ export async function startTUI(agent, opts = {}) {
|
|
|
363
363
|
openModelPicker: () => openModelPicker(),
|
|
364
364
|
selectModel,
|
|
365
365
|
setProviderKey,
|
|
366
|
+
pickModelForSlot,
|
|
366
367
|
runDistill,
|
|
367
368
|
exit: () => { cleanup(); setTimeout(() => process.exit(0), 100) },
|
|
368
369
|
})
|
package/src/tui/pickers.mjs
CHANGED
|
@@ -40,6 +40,8 @@ export function createPickers(ctx) {
|
|
|
40
40
|
closePicker()
|
|
41
41
|
return new Promise((resolve) => {
|
|
42
42
|
const itemCount = entries.filter((e) => e.type === "item").length
|
|
43
|
+
// No selectable items — resolve immediately instead of showing an empty picker
|
|
44
|
+
if (itemCount === 0) { resolve(null); return }
|
|
43
45
|
const index = Math.max(0, Math.min(defaultIndex, Math.max(0, itemCount - 1)))
|
|
44
46
|
state.picker = { title, entries, lines: [], index, scroll: 0, selectedLine: 0, filter: "", resolve }
|
|
45
47
|
state.pickerStack.push(state.picker)
|
|
@@ -389,5 +391,34 @@ export function createPickers(ctx) {
|
|
|
389
391
|
await persistRaw((raw) => { raw.providers = agent.providers })
|
|
390
392
|
}
|
|
391
393
|
|
|
392
|
-
|
|
394
|
+
|
|
395
|
+
/** Slot-bound model picker: two-level provider → model selection that RETURNS
|
|
396
|
+
* { provider, model } instead of writing main-session state — used by /submodel
|
|
397
|
+
* to write into a subagent slot (global or per-role). Esc from the model list
|
|
398
|
+
* returns to the provider list (openModelPicker parity); Esc from the provider
|
|
399
|
+
* list exits → null. */
|
|
400
|
+
async function pickModelForSlot() {
|
|
401
|
+
for (;;) {
|
|
402
|
+
const providers = agent.providers
|
|
403
|
+
if (!providers.length) return null
|
|
404
|
+
const e = await showPicker("Select provider", providers.map((p) => ({
|
|
405
|
+
type: "item",
|
|
406
|
+
text: `${p.name.padEnd(12)} ${p.model}`,
|
|
407
|
+
action: "open-models",
|
|
408
|
+
provider: p.name,
|
|
409
|
+
})))
|
|
410
|
+
if (!e?.provider) return null
|
|
411
|
+
const providerConfig = providers.find((p) => p.name === e.provider)
|
|
412
|
+
if (!providerConfig) return null
|
|
413
|
+
const entries = buildModelEntriesForProvider(e.provider, providerConfig)
|
|
414
|
+
fetchModelsForProvider(e.provider, entries).catch((err) => {
|
|
415
|
+
pushLine(`[model] fetch models failed: ${err.message}`, C.error)
|
|
416
|
+
})
|
|
417
|
+
const me = await showPicker(`${e.provider} models`, entries)
|
|
418
|
+
if (!me?.model) continue // Esc from model list → back to provider list
|
|
419
|
+
return { provider: e.provider, model: me.model }
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
return { showPicker, closePicker, popPicker, renderPickerLines, openModelPicker, selectModel, setProviderKey, pickModelForSlot }
|
|
393
424
|
}
|
|
@@ -3,11 +3,26 @@
|
|
|
3
3
|
* Extracted from render-frame.mjs.
|
|
4
4
|
*/
|
|
5
5
|
import { ansi, C } from "./ansi.mjs"
|
|
6
|
-
import { formatTables, sanitizeDisplay, wrapText } from "./render.mjs"
|
|
6
|
+
import { formatTables, sanitizeDisplay, stringWidth, wrapText } from "./render.mjs"
|
|
7
7
|
import { renderMarkdownInline, renderMarkdownHeading } from "./markdown.mjs"
|
|
8
8
|
|
|
9
9
|
let _convCache = { key: "", cols: 0, lines: [] }
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Render markdown markers to ANSI, then pad the line tail back to the pre-render
|
|
13
|
+
* display width. Markers (`` ` ``, `**`, `~~`) vanish on render — without the
|
|
14
|
+
* compensation, table rows containing them display shorter than the column widths
|
|
15
|
+
* computed by formatTables and the borders misalign (reported regression).
|
|
16
|
+
* @param {string} text — plain text line (no ANSI yet), already wrapped
|
|
17
|
+
* @returns {string} ANSI-rendered line whose display width equals stringWidth(text)
|
|
18
|
+
*/
|
|
19
|
+
function renderMarkdownPreservingWidth(text) {
|
|
20
|
+
const rendered = renderMarkdownInline(renderMarkdownHeading(text))
|
|
21
|
+
const diff = stringWidth(text) - stringWidth(rendered)
|
|
22
|
+
return diff > 0 ? rendered + " ".repeat(diff) : rendered
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
11
26
|
export function convCacheKey(state) {
|
|
12
27
|
const lastLine = state.lines.length > 0 ? state.lines[state.lines.length - 1] : null
|
|
13
28
|
// expandedBlocks participates: expanding/folding a block must invalidate the cache
|
|
@@ -87,7 +102,7 @@ function buildConvLines(state, cols) {
|
|
|
87
102
|
for (const wrapped of wrapText(line, cols - 1)) {
|
|
88
103
|
// Lightweight markdown display (IK5VW3): headings bold + inline markers styled.
|
|
89
104
|
// Runs AFTER wrapping so the ANSI it inserts never skews width math.
|
|
90
|
-
block.push({ text:
|
|
105
|
+
block.push({ text: renderMarkdownPreservingWidth(wrapped), color: l.color, _foldId: l._foldId, _src: i })
|
|
91
106
|
}
|
|
92
107
|
}
|
|
93
108
|
if (folded && block.length > LONG_FOLD_LINES) {
|
|
@@ -135,7 +150,7 @@ function buildConvLines(state, cols) {
|
|
|
135
150
|
if (state.streaming) {
|
|
136
151
|
for (const line of formatTables(sanitizeDisplay(state.streaming), cols - 1)) {
|
|
137
152
|
for (const wrapped of wrapText(line, cols - 1)) {
|
|
138
|
-
convLines.push({ text:
|
|
153
|
+
convLines.push({ text: renderMarkdownPreservingWidth(wrapped), color: C.text })
|
|
139
154
|
}
|
|
140
155
|
}
|
|
141
156
|
}
|
package/src/tui/render.mjs
CHANGED
|
@@ -31,20 +31,37 @@ export function charWidth(cp) {
|
|
|
31
31
|
|
|
32
32
|
/** Compute the display width of a string (CJK characters count as 2) */
|
|
33
33
|
export function stringWidth(text) {
|
|
34
|
+
// ANSI escape sequences occupy zero display width — strip them before counting.
|
|
35
|
+
// Without this, markdown-rendered text (ANSI inserted) was measured wider than it
|
|
36
|
+
// displays, and table lines with inline markers ended up shorter than the computed
|
|
37
|
+
// column widths (reported: table borders misaligned after `code`/`**bold**` cells).
|
|
34
38
|
let w = 0
|
|
35
|
-
for (const
|
|
39
|
+
for (const part of text.split(ANSI_SEQUENCE_RE)) {
|
|
40
|
+
for (const ch of part) w += charWidth(ch.codePointAt(0))
|
|
41
|
+
}
|
|
36
42
|
return w
|
|
37
43
|
}
|
|
38
44
|
|
|
39
|
-
/** Slice by display width */
|
|
45
|
+
/** Slice by display width — ANSI sequences count as zero width and are kept whole. */
|
|
40
46
|
export function sliceByWidth(text, maxWidth) {
|
|
41
47
|
let w = 0
|
|
42
48
|
let out = ""
|
|
43
|
-
|
|
44
|
-
|
|
49
|
+
let i = 0
|
|
50
|
+
while (i < text.length) {
|
|
51
|
+
// Copy any ANSI sequence verbatim (zero display width, never sliced mid-sequence)
|
|
52
|
+
const m = text.slice(i).match(ANSI_SEQUENCE)
|
|
53
|
+
if (m && m.index === 0) {
|
|
54
|
+
out += m[0]
|
|
55
|
+
i += m[0].length
|
|
56
|
+
continue
|
|
57
|
+
}
|
|
58
|
+
const cp = text.codePointAt(i)
|
|
59
|
+
const ch = String.fromCodePoint(cp)
|
|
60
|
+
const cw = charWidth(cp)
|
|
45
61
|
if (w + cw > maxWidth) break
|
|
46
62
|
w += cw
|
|
47
63
|
out += ch
|
|
64
|
+
i += ch.length
|
|
48
65
|
}
|
|
49
66
|
return out
|
|
50
67
|
}
|
|
@@ -186,7 +203,9 @@ export function layoutInput(chars, cursor, width) {
|
|
|
186
203
|
* Display-layer only — raw tool results the model sees are unchanged; dirty displays already in session
|
|
187
204
|
* are also cleaned during replay.
|
|
188
205
|
*/
|
|
189
|
-
const
|
|
206
|
+
const ANSI_SEQUENCE = /\x1b\[[0-9;?]*[a-zA-Z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[()][0-9A-B]|\x1b[=>#][0-9]?/
|
|
207
|
+
// Global variant for replace()/split(); the non-global one keeps match.index for slicing
|
|
208
|
+
const ANSI_SEQUENCE_RE = new RegExp(ANSI_SEQUENCE.source, "g")
|
|
190
209
|
export function sanitizeDisplay(s) {
|
|
191
210
|
return s
|
|
192
211
|
.replace(ANSI_SEQUENCE_RE, "")
|
|
@@ -25,6 +25,8 @@ import { handleAutoCommand } from "./cmd-auto.mjs"
|
|
|
25
25
|
import { handleAdvisorCommand } from "./cmd-advisor.mjs"
|
|
26
26
|
import { handleThinkCommand } from "./cmd-think.mjs"
|
|
27
27
|
import { handleModelCommand } from "./cmd-model.mjs"
|
|
28
|
+
import { handleSubmodelCommand } from "./cmd-submodel.mjs"
|
|
29
|
+
import { handleShellCommand } from "./cmd-shell.mjs"
|
|
28
30
|
import { handleConfigCommand } from "./cmd-config.mjs"
|
|
29
31
|
import { handleExtractCommand } from "./cmd-extract.mjs"
|
|
30
32
|
import { handleHelpCommand } from "./cmd-help.mjs"
|
|
@@ -39,6 +41,8 @@ export const SLASH_COMMANDS = [
|
|
|
39
41
|
{ name: "/eng", group: "Agent", desc: "toggle engineering mode — strict methodology enforcement" },
|
|
40
42
|
{ name: "/advisor", group: "Agent", desc: "advisor settings (toggle, model, thinking, guard)" },
|
|
41
43
|
{ name: "/model", group: "Agent", desc: "select model & manage providers" },
|
|
44
|
+
{ name: "/submodel", group: "Agent", desc: "subagent model per type (explore/plan/coder/eng-coder)" },
|
|
45
|
+
{ name: "/shell", group: "System", desc: "bash tool shell (git-bash/pwsh path; win11 cmd encoding fix)" },
|
|
42
46
|
{ name: "/goal", group: "Agent", desc: "set/view/cancel long-term goal" },
|
|
43
47
|
{ name: "/think", group: "Agent", desc: "thinking mode & reasoning effort" },
|
|
44
48
|
{ name: "/upgrade", group: "System", desc: "check for updates & upgrade" },
|
|
@@ -78,6 +82,8 @@ export const HANDLERS = {
|
|
|
78
82
|
"/advisor": handleAdvisorCommand,
|
|
79
83
|
"/think": handleThinkCommand,
|
|
80
84
|
"/model": handleModelCommand,
|
|
85
|
+
"/submodel": handleSubmodelCommand,
|
|
86
|
+
"/shell": handleShellCommand,
|
|
81
87
|
"/config": handleConfigCommand,
|
|
82
88
|
"/upgrade": handleUpgradeCommand,
|
|
83
89
|
"/fold": handleFoldCommand,
|
|
@@ -125,6 +131,10 @@ export function createSlashCommands(ctx) {
|
|
|
125
131
|
const argIndex = parts.length - 2 // which parameter is being typed (0-based)
|
|
126
132
|
const match = (cands) => cands.filter((c) => c.startsWith(last)).map((c) => `${head} ${c}`)
|
|
127
133
|
if (cmd === "/model" && argIndex === 0) return match(agent.providers.map((p) => p.name))
|
|
134
|
+
if (cmd === "/submodel") {
|
|
135
|
+
if (argIndex === 0) return match(["explore", "plan", "coder", "eng-coder", "reset"])
|
|
136
|
+
if (parts[1] && !["reset"].includes(parts[1]) && argIndex === 1) return match(agent.providers.map((p) => p.name))
|
|
137
|
+
}
|
|
128
138
|
if (cmd === "/think") {
|
|
129
139
|
if (argIndex === 0) return match(["on", "off", "effort"])
|
|
130
140
|
if (argIndex === 1 && parts[1].toLowerCase() === "effort") {
|