thincoder 0.8.11 → 0.8.12
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 +11 -0
- package/bin/thincoder.mjs +115 -0
- package/package.json +1 -1
- package/src/advisor.mjs +105 -0
- package/src/agent/dispatch.mjs +35 -0
- package/src/agent/setup.mjs +9 -7
- package/src/agent-tools/subagent.mjs +1 -1
- package/src/agent-tools/timer.mjs +41 -0
- package/src/agent-tools/verify.mjs +165 -56
- package/src/agent-tools.mjs +1 -0
- package/src/agent.mjs +128 -20
- package/src/auto-think.mjs +83 -0
- package/src/cli/make-agent.mjs +9 -0
- package/src/config.mjs +18 -18
- package/src/git/checkpoint.mjs +2 -1
- package/src/git/gitmem.mjs +8 -2
- package/src/markdown.mjs +1 -1
- package/src/mcp/transport-http.mjs +8 -2
- package/src/memory/code-index.mjs +2 -2
- package/src/memory/code-sync.mjs +92 -35
- package/src/memory/core.mjs +10 -1
- package/src/memory/docs.mjs +24 -26
- package/src/memory/schema.mjs +16 -3
- package/src/prompts/coder.md +7 -4
- package/src/prompts/discipline.md +47 -15
- package/src/prompts/main.md +15 -11
- package/src/prompts/system.md +33 -7
- package/src/provider/core.mjs +134 -14
- package/src/provider/index.mjs +1 -1
- package/src/rules.mjs +53 -0
- package/src/session.mjs +1 -1
- package/src/tools/file.mjs +82 -1
- package/src/tools/hashline_edit.md +12 -0
- package/src/tools/index.mjs +6 -4
- package/src/tools/linter.md +13 -0
- package/src/tools/linter.mjs +146 -0
- package/src/tools/read.md +3 -2
- package/src/tools/repomap.mjs +14 -9
- package/src/tui/agent-turn.mjs +9 -2
- package/src/tui/ansi.mjs +1 -0
- package/src/tui/cmd-advisor.mjs +68 -0
- package/src/tui/cmd-think.mjs +36 -10
- package/src/tui/index.mjs +2 -1
- package/src/tui/key-handler.mjs +36 -1
- package/src/tui/layout.mjs +3 -1
- package/src/tui/pickers.mjs +15 -15
- package/src/tui/render-frame.mjs +17 -8
- package/src/tui/slash-commands.mjs +3 -0
- package/src/tools/repomap-parse.mjs +0 -168
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
Edit a file using content-hash addressing instead of string matching. More reliable than edit when whitespace or encoding varies — hashes are computed from exact line bytes on disk.
|
|
2
|
+
|
|
3
|
+
Parameters:
|
|
4
|
+
- path (required): File path
|
|
5
|
+
- old_hashes (required): Array of SHA256 hashes (12-char hex) identifying lines to replace. Read the file with hashes=true first to obtain these hashes. For a single line, pass [hash]; for a contiguous block, pass [hash1, hash2, ...] in order.
|
|
6
|
+
- new_content (required): Replacement text (multi-line ok, \n separated)
|
|
7
|
+
|
|
8
|
+
Notes:
|
|
9
|
+
- The hash of each line is computed as SHA256(line_content).slice(0, 12) — the same algorithm used by read(hashes=true)
|
|
10
|
+
- Hashes are position-independent: they identify lines by content, not by line number (which changes after edits)
|
|
11
|
+
- If the hash sequence isn't found, the error will include the current file's hashes so you can retry with corrected values
|
|
12
|
+
- Prefer this over edit when: 1) the file may have mixed whitespace/encoding, 2) you want to edit a block of lines with a single call
|
package/src/tools/index.mjs
CHANGED
|
@@ -1,24 +1,26 @@
|
|
|
1
1
|
// tools/index.mjs — backend-compatible re-export
|
|
2
2
|
export { toOpenAISchema } from "./shared.mjs";
|
|
3
3
|
|
|
4
|
-
import { readTool, writeTool, editTool, insertAfterTool, readImageTool } from "./file.mjs";
|
|
4
|
+
import { readTool, writeTool, editTool, insertAfterTool, readImageTool, hashlineEditTool } from "./file.mjs";
|
|
5
5
|
import { applyPatchTool, syntaxCheckTool, deleteTool } from "./patch.mjs";
|
|
6
6
|
import { bashTool, globTool, grepTool, lsTool } from "./system.mjs";
|
|
7
7
|
import { websearchTool, fetchTool } from "./web.mjs";
|
|
8
8
|
import { gitDiffTool, gitStatusTool, gitLogTool, questionTool, checkpointTool } from "./git.mjs";
|
|
9
9
|
import { checklistTool } from "./checklist.mjs";
|
|
10
|
+
import { linterTool } from "./linter.mjs";
|
|
10
11
|
|
|
11
12
|
export const builtinTools = [
|
|
12
|
-
readTool, writeTool, editTool, insertAfterTool, applyPatchTool,
|
|
13
|
+
readTool, writeTool, editTool, insertAfterTool, hashlineEditTool, applyPatchTool,
|
|
13
14
|
syntaxCheckTool, readImageTool, bashTool, globTool, grepTool,
|
|
14
15
|
websearchTool, lsTool, fetchTool, deleteTool,
|
|
15
16
|
gitDiffTool, gitStatusTool, gitLogTool, questionTool, checkpointTool,
|
|
16
|
-
checklistTool,
|
|
17
|
+
checklistTool, linterTool,
|
|
17
18
|
];
|
|
18
19
|
|
|
19
20
|
export {
|
|
20
|
-
readTool, writeTool, editTool, insertAfterTool, applyPatchTool,
|
|
21
|
+
readTool, writeTool, editTool, insertAfterTool, hashlineEditTool, applyPatchTool,
|
|
21
22
|
syntaxCheckTool, readImageTool, bashTool, globTool, grepTool,
|
|
22
23
|
websearchTool, lsTool, fetchTool, deleteTool,
|
|
23
24
|
gitDiffTool, gitStatusTool, gitLogTool, questionTool, checkpointTool,
|
|
25
|
+
checklistTool, linterTool,
|
|
24
26
|
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Run the appropriate linter/checker for a file. Auto-detects based on file extension and project config.
|
|
2
|
+
|
|
3
|
+
Parameters:
|
|
4
|
+
- path (optional): File to check (default: most recently modified file)
|
|
5
|
+
|
|
6
|
+
Supported languages:
|
|
7
|
+
- .js/.mjs/.cjs/.jsx: eslint (if config present) → node --check (built-in, always available)
|
|
8
|
+
- .ts/.tsx/.mts/.cts: eslint → tsc --noEmit (if tsconfig.json present) → node --check
|
|
9
|
+
- .py: ruff (if installed)
|
|
10
|
+
- .rs: cargo check (if Cargo.toml present)
|
|
11
|
+
- .go: go vet
|
|
12
|
+
|
|
13
|
+
Does NOT install anything — only uses tools already available in the project.
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { DESC, resolveInCwd } from "./shared.mjs"
|
|
2
|
+
|
|
3
|
+
export const linterTool = {
|
|
4
|
+
name: "linter",
|
|
5
|
+
description: DESC("linter"),
|
|
6
|
+
parameters: {
|
|
7
|
+
type: "object",
|
|
8
|
+
properties: {
|
|
9
|
+
path: { type: "string", description: "File path (default: most recently modified file)" },
|
|
10
|
+
},
|
|
11
|
+
},
|
|
12
|
+
readonly: true,
|
|
13
|
+
async execute(args, ctx) {
|
|
14
|
+
const { execFileSync } = await import("node:child_process")
|
|
15
|
+
const { existsSync } = await import("node:fs")
|
|
16
|
+
const { join, relative } = await import("node:path")
|
|
17
|
+
const abs = args.path ? resolveInCwd(ctx, args.path) : (ctx.agent?._touchedFiles?.at(-1) || null)
|
|
18
|
+
if (!abs) return "linter: no file specified and no recently modified file to check"
|
|
19
|
+
|
|
20
|
+
const ext = abs.split(".").pop()?.toLowerCase()
|
|
21
|
+
const checkers = LANG_CHECKERS[ext]
|
|
22
|
+
if (!checkers) return `linter: no linter configured for .${ext} files. Supported: ${Object.keys(LANG_CHECKERS).map(e => `.${e}`).join(", ")}`
|
|
23
|
+
|
|
24
|
+
for (const checker of checkers) {
|
|
25
|
+
const result = await checker(abs, { cwd: ctx.cwd, existsSync, execFileSync, join, relative })
|
|
26
|
+
if (result !== null) return result
|
|
27
|
+
}
|
|
28
|
+
return `linter: no linter available for ${args.path || abs}. Install one?`
|
|
29
|
+
},
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ─── Checker definitions ──────────────────────
|
|
33
|
+
|
|
34
|
+
async function eslintCheck(file, { cwd, existsSync, execFileSync, join, relative }) {
|
|
35
|
+
// Walk up to find eslint config
|
|
36
|
+
let dir = file.split(/[\\/]/).slice(0, -1).join("/") || "."
|
|
37
|
+
while (true) {
|
|
38
|
+
for (const cfg of [".eslintrc.js", ".eslintrc.cjs", ".eslintrc.json", ".eslintrc.yaml", ".eslintrc.yml", "eslint.config.js", "eslint.config.mjs"]) {
|
|
39
|
+
if (existsSync(join(cwd, dir, cfg))) {
|
|
40
|
+
try {
|
|
41
|
+
const cfgDir = join(cwd, dir)
|
|
42
|
+
const relPath = relative(cfgDir, file)
|
|
43
|
+
execFileSync("npx", ["eslint", "--no-color", "--format", "compact", relPath], {
|
|
44
|
+
cwd: cfgDir, encoding: "utf8", timeout: 30000, stdio: ["ignore", "pipe", "pipe"],
|
|
45
|
+
})
|
|
46
|
+
return "✓ eslint: no issues"
|
|
47
|
+
} catch (e) {
|
|
48
|
+
const stdout = (e.stdout || "").trim()
|
|
49
|
+
if (stdout) return stdout
|
|
50
|
+
return `✗ eslint: ${(e.stderr || e.message).slice(0, 500)}`
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
const parent = dir.split("/").slice(0, -1).join("/")
|
|
55
|
+
if (!parent || parent === dir) break
|
|
56
|
+
dir = parent
|
|
57
|
+
}
|
|
58
|
+
return null
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function tscCheck(file, { cwd, existsSync, execFileSync, join }) {
|
|
62
|
+
if (!existsSync(join(cwd, "tsconfig.json"))) return null
|
|
63
|
+
if (!/\.(ts|tsx|mts|cts)$/.test(file)) return null
|
|
64
|
+
try {
|
|
65
|
+
execFileSync("npx", ["tsc", "--noEmit", "--pretty", "false"], {
|
|
66
|
+
cwd, encoding: "utf8", timeout: 60000, stdio: ["ignore", "pipe", "pipe"],
|
|
67
|
+
})
|
|
68
|
+
return "✓ tsc: no type errors"
|
|
69
|
+
} catch (e) {
|
|
70
|
+
const stdout = (e.stdout || "").trim()
|
|
71
|
+
if (stdout) return stdout
|
|
72
|
+
return `✗ tsc: ${(e.stderr || e.message).slice(0, 500)}`
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function nodeCheck(file, { execFileSync, cwd }) {
|
|
77
|
+
if (!/\.(m?js|cjs)$/.test(file)) return null
|
|
78
|
+
try {
|
|
79
|
+
execFileSync(process.execPath, ["--check", file], {
|
|
80
|
+
cwd, encoding: "utf8", timeout: 10000, stdio: ["ignore", "pipe", "pipe"],
|
|
81
|
+
})
|
|
82
|
+
return "✓ node --check: Syntax OK"
|
|
83
|
+
} catch (e) {
|
|
84
|
+
return `✗ node --check: ${(e.stderr || e.message).slice(0, 500)}`
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function ruffCheck(file, { cwd, execFileSync }) {
|
|
89
|
+
if (!/\.py$/.test(file)) return null
|
|
90
|
+
try {
|
|
91
|
+
execFileSync("ruff", ["check", "--output-format", "concise", file], {
|
|
92
|
+
cwd, encoding: "utf8", timeout: 30000, stdio: ["ignore", "pipe", "pipe"],
|
|
93
|
+
})
|
|
94
|
+
return "✓ ruff: no issues"
|
|
95
|
+
} catch (e) {
|
|
96
|
+
if (e.code === "ENOENT") return "linter: ruff not installed. Run: pip install ruff"
|
|
97
|
+
const stdout = (e.stdout || "").trim()
|
|
98
|
+
if (stdout) return stdout
|
|
99
|
+
return `✗ ruff: ${(e.stderr || e.message).slice(0, 500)}`
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function cargoCheck(file, { cwd, existsSync, execFileSync, join }) {
|
|
104
|
+
if (!/\.rs$/.test(file)) return null
|
|
105
|
+
if (!existsSync(join(cwd, "Cargo.toml"))) return null
|
|
106
|
+
const fname = file.split(/[\\/]/).pop()
|
|
107
|
+
try {
|
|
108
|
+
const out = execFileSync("cargo", ["check", "--message-format", "short"], {
|
|
109
|
+
cwd, encoding: "utf8", timeout: 120000, stdio: ["ignore", "pipe", "pipe"],
|
|
110
|
+
})
|
|
111
|
+
const errors = out.split("\n").filter(l => l.includes(fname))
|
|
112
|
+
return errors.length > 0 ? errors.join("\n") : "✓ cargo check: no errors"
|
|
113
|
+
} catch (e) {
|
|
114
|
+
const combined = ((e.stdout || "") + "\n" + (e.stderr || "")).trim()
|
|
115
|
+
const errors = combined.split("\n").filter(l => l.includes(fname) || l.startsWith("error"))
|
|
116
|
+
return errors.length > 0 ? errors.join("\n") : `✗ cargo check failed:\n${combined.slice(0, 1000)}`
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function goVet(file, { cwd, execFileSync }) {
|
|
121
|
+
if (!/\.go$/.test(file)) return null
|
|
122
|
+
try {
|
|
123
|
+
execFileSync("go", ["vet", file], {
|
|
124
|
+
cwd, encoding: "utf8", timeout: 60000, stdio: ["ignore", "pipe", "pipe"],
|
|
125
|
+
})
|
|
126
|
+
return "✓ go vet: no issues"
|
|
127
|
+
} catch (e) {
|
|
128
|
+
return `✗ go vet: ${(e.stderr || e.message).slice(0, 500)}`
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ─── Language → checkers (first available wins) ──
|
|
133
|
+
|
|
134
|
+
const LANG_CHECKERS = {
|
|
135
|
+
js: [eslintCheck, nodeCheck],
|
|
136
|
+
mjs: [eslintCheck, nodeCheck],
|
|
137
|
+
cjs: [eslintCheck, nodeCheck],
|
|
138
|
+
jsx: [eslintCheck, nodeCheck],
|
|
139
|
+
ts: [eslintCheck, tscCheck],
|
|
140
|
+
tsx: [eslintCheck, tscCheck],
|
|
141
|
+
mts: [eslintCheck, tscCheck],
|
|
142
|
+
cts: [eslintCheck, tscCheck],
|
|
143
|
+
py: [ruffCheck],
|
|
144
|
+
rs: [cargoCheck],
|
|
145
|
+
go: [goVet],
|
|
146
|
+
}
|
package/src/tools/read.md
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
Read a text file. Returns numbered lines. Use offset/limit to page large files.
|
|
2
|
-
|
|
3
2
|
Parameters:
|
|
4
|
-
- path (required): File path, relative to cwd or absolute
|
|
3
|
+
- path (required): File path, relative to cwd or absolute (alias: filePath)
|
|
5
4
|
- offset: 1-based line number to start reading from
|
|
6
5
|
- limit: Max lines to return (default 2000)
|
|
6
|
+
- hashes: Include SHA256 content hashes per line (for hashline_edit). Set true before using hashline_edit.
|
|
7
7
|
|
|
8
8
|
Notes:
|
|
9
9
|
- Always prefer this over `cat` or shell-based reading — it caps output and avoids large dumps
|
|
10
10
|
- Use offset for pagination when the file is large
|
|
11
|
+
- When you plan to edit the file, set hashes=true to get line hashes for hashline_edit — hash-based editing avoids whitespace/encoding matching failures
|
package/src/tools/repomap.mjs
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
* Real-time import/export parsing, generates compact text for LLMs to understand code structure.
|
|
4
4
|
* No index stored — reads and parses files on each call, ~50ms.
|
|
5
5
|
*/
|
|
6
|
-
import {
|
|
6
|
+
import { existsSync } from "node:fs"
|
|
7
|
+
import { readFile } from "node:fs/promises"
|
|
7
8
|
import { join } from "node:path"
|
|
8
9
|
|
|
9
10
|
/** Extract JS/TS file import paths (normalize by stripping .ts/.js/.mjs suffixes) */
|
|
@@ -113,17 +114,19 @@ function normalizeExt(p) {
|
|
|
113
114
|
* Internal: scan all files, build forward dependency graph + reverse reference graph.
|
|
114
115
|
* Returns { deps, importers, fileCount } shared by buildOutline / buildSummary.
|
|
115
116
|
*/
|
|
116
|
-
function _buildDepGraph(db, cwd) {
|
|
117
|
+
async function _buildDepGraph(db, cwd) {
|
|
117
118
|
const allFiles = db.prepare(`SELECT DISTINCT path FROM code_chunks ORDER BY path`).all().map((r) => r.path)
|
|
118
119
|
if (allFiles.length === 0) return null
|
|
119
120
|
|
|
120
121
|
const deps = new Map() // path → { imports: Set, exports: Set, size: number, dir: string }
|
|
121
122
|
const importers = new Map() // importee → Set<importer>
|
|
122
123
|
|
|
123
|
-
for (
|
|
124
|
+
for (let i = 0; i < allFiles.length; i++) {
|
|
125
|
+
const rel = allFiles[i]
|
|
124
126
|
const abs = join(cwd, ...rel.split("/"))
|
|
125
127
|
if (!existsSync(abs)) continue
|
|
126
|
-
|
|
128
|
+
let text
|
|
129
|
+
try { text = await readFile(abs, "utf8") } catch { continue }
|
|
127
130
|
const lines = text.split("\n")
|
|
128
131
|
const ext = rel.slice(rel.lastIndexOf(".")).toLowerCase()
|
|
129
132
|
|
|
@@ -160,6 +163,8 @@ function _buildDepGraph(db, cwd) {
|
|
|
160
163
|
if (!importers.has(r)) importers.set(r, new Set())
|
|
161
164
|
importers.get(r).add(rel)
|
|
162
165
|
}
|
|
166
|
+
// Yield the event loop every 20 files to prevent TUI freeze
|
|
167
|
+
if (i % 20 === 19) await new Promise(r => setImmediate(r))
|
|
163
168
|
}
|
|
164
169
|
|
|
165
170
|
return { deps, importers, fileCount: allFiles.length }
|
|
@@ -173,8 +178,8 @@ function _buildDepGraph(db, cwd) {
|
|
|
173
178
|
* 3. Entry points (files with no importers — startup/top-level entry points)
|
|
174
179
|
* Output is naturally bounded (~1000-2000 chars), no more OUTLINE_INJECT_MAX hard truncation.
|
|
175
180
|
*/
|
|
176
|
-
export function buildSummary(db, cwd) {
|
|
177
|
-
const graph = _buildDepGraph(db, cwd)
|
|
181
|
+
export async function buildSummary(db, cwd) {
|
|
182
|
+
const graph = await _buildDepGraph(db, cwd)
|
|
178
183
|
if (!graph) return "(no indexed source files; run codeSync or /reindex first)"
|
|
179
184
|
const { deps, importers, fileCount } = graph
|
|
180
185
|
|
|
@@ -248,8 +253,8 @@ export function buildSummary(db, cwd) {
|
|
|
248
253
|
}
|
|
249
254
|
|
|
250
255
|
/** Get known file list from code_chunks (reuse index), parse by path to generate outline text */
|
|
251
|
-
export function buildOutline(db, cwd, focusPath) {
|
|
252
|
-
const graph = _buildDepGraph(db, cwd)
|
|
256
|
+
export async function buildOutline(db, cwd, focusPath) {
|
|
257
|
+
const graph = await _buildDepGraph(db, cwd)
|
|
253
258
|
if (!graph) return "(no indexed source files; run codeSync or /reindex first)"
|
|
254
259
|
const { deps, importers } = graph
|
|
255
260
|
|
|
@@ -298,7 +303,7 @@ export function repoOutlineTool(db, cwd) {
|
|
|
298
303
|
},
|
|
299
304
|
readonly: true,
|
|
300
305
|
async execute(args) {
|
|
301
|
-
const outline = buildOutline(db, cwd, args.path ?? null)
|
|
306
|
+
const outline = await buildOutline(db, cwd, args.path ?? null)
|
|
302
307
|
return outline
|
|
303
308
|
},
|
|
304
309
|
}
|
package/src/tui/agent-turn.mjs
CHANGED
|
@@ -30,6 +30,7 @@ export async function runAgentTurn(ctx, text) {
|
|
|
30
30
|
state.currentTool = null
|
|
31
31
|
state.processingStarted = Date.now()
|
|
32
32
|
state.controller = new AbortController()
|
|
33
|
+
state.interruptPrompt = null
|
|
33
34
|
// Refresh status bar every second during processing (elapsed timer)
|
|
34
35
|
const ticker = setInterval(() => {
|
|
35
36
|
if (state.processing) render()
|
|
@@ -179,10 +180,13 @@ export async function runAgentTurn(ctx, text) {
|
|
|
179
180
|
state.tokens.completion += usage.completion_tokens ?? 0
|
|
180
181
|
state.tokens.cacheHit += usage.prompt_cache_hit_tokens ?? 0
|
|
181
182
|
state.tokens.cacheMiss += usage.prompt_cache_miss_tokens ?? 0
|
|
183
|
+
state.tokens.reasoningTokens += usage.completion_tokens_details?.reasoning_tokens ?? 0
|
|
182
184
|
},
|
|
183
185
|
// Throttle wait (active gate / 429 backoff): show in status bar so user knows it's not frozen
|
|
184
186
|
onWait: ({ phase, seconds }) => {
|
|
185
|
-
|
|
187
|
+
if (phase === "gate") state.status = `TPM throttle wait ~${seconds}s`
|
|
188
|
+
else if (phase === "overloaded") state.status = `Server overloaded, retrying in ${seconds}s`
|
|
189
|
+
else state.status = `Rate-limited 429, retry in ${seconds}s`
|
|
186
190
|
render()
|
|
187
191
|
},
|
|
188
192
|
onTaskUpdate: (items) => {
|
|
@@ -193,12 +197,15 @@ export async function runAgentTurn(ctx, text) {
|
|
|
193
197
|
pushLine(` [task] ${done}/${items.length}${current ? ` ▶ ${current.title}` : ""}`, C.dim)
|
|
194
198
|
render()
|
|
195
199
|
},
|
|
200
|
+
onAdvisor: (note) => {
|
|
201
|
+
pushLine(` [advisor] ${note.replace(/\n/g, "\n ")}`, C.advisor)
|
|
202
|
+
},
|
|
196
203
|
// Incremental save: flush to disk every 5 tool turns — mid-crash loss window shrinks from an entire round to a few turns
|
|
197
204
|
onTurnEnd: (() => {
|
|
198
205
|
let n = 0
|
|
199
206
|
return () => {
|
|
200
207
|
if (++n % 5 !== 0) return
|
|
201
|
-
try { saveSession(agent, state.lines) } catch {}
|
|
208
|
+
try { saveSession(agent, state.lines) } catch (e) { console.error(`[session] incremental save failed: ${e.message}`) }
|
|
202
209
|
}
|
|
203
210
|
})(),
|
|
204
211
|
}
|
package/src/tui/ansi.mjs
CHANGED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/** /advisor command: toggle advisor on/off, select model.
|
|
2
|
+
* ctx: { agent, openPicker, pushLine } */
|
|
3
|
+
import { C } from "./ansi.mjs"
|
|
4
|
+
|
|
5
|
+
export async function handleAdvisorCommand(ctx) {
|
|
6
|
+
const { agent, openPicker, pushLine } = ctx
|
|
7
|
+
const cfg = agent.config.advisor ??= {}
|
|
8
|
+
const enabled = cfg.enabled === true
|
|
9
|
+
const curProvider = cfg.provider || agent.activeProvider
|
|
10
|
+
const curModel = cfg.model || agent.provider.model
|
|
11
|
+
|
|
12
|
+
const entries = [
|
|
13
|
+
{ type: "item", text: `Advisor: ${enabled ? "ON" : "OFF"}`, action: "toggle" },
|
|
14
|
+
{ type: "item", text: `Model: ${curProvider}/${curModel}`, action: "model" },
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
openPicker({
|
|
18
|
+
title: "Advisor",
|
|
19
|
+
entries,
|
|
20
|
+
onSelect: async (e) => {
|
|
21
|
+
if (e.action === "toggle") {
|
|
22
|
+
cfg.enabled = !cfg.enabled
|
|
23
|
+
agent._pendingReminders = agent._pendingReminders ?? []
|
|
24
|
+
if (cfg.enabled) {
|
|
25
|
+
agent._pendingReminders.push("[系统提醒: Advisor 审查已开启。每轮操作后,你的输出将被审查,观察结果可能作为系统提醒注入。请批判性参考——这是观察,不是命令。]")
|
|
26
|
+
} else {
|
|
27
|
+
agent._pendingReminders.push("[系统提醒: Advisor 审查已关闭。后续轮次不再自动审查。]")
|
|
28
|
+
}
|
|
29
|
+
} else if (e.action === "model") {
|
|
30
|
+
await openAdvisorModelPicker(ctx).catch(err => pushLine(`[error] ${err.message}`, C.error))
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function openAdvisorModelPicker(ctx) {
|
|
37
|
+
const { agent, openPicker, pushLine } = ctx
|
|
38
|
+
const providers = agent.providers || []
|
|
39
|
+
|
|
40
|
+
// Build flat list: each provider's name + a "use current model" entry
|
|
41
|
+
const entries = []
|
|
42
|
+
let idx = 0
|
|
43
|
+
for (const p of providers) {
|
|
44
|
+
const mark = p.name === agent.activeProvider ? "* " : " "
|
|
45
|
+
entries.push({ type: "item", text: `${mark}${p.name} — ${p.baseURL}`, action: "set_provider", provider: p.name, model: p.model })
|
|
46
|
+
idx++
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
openPicker({
|
|
50
|
+
title: "Advisor Model",
|
|
51
|
+
entries,
|
|
52
|
+
onSelect: async (e) => {
|
|
53
|
+
if (e.action === "set_provider") {
|
|
54
|
+
const cfg = agent.config.advisor ??= {}
|
|
55
|
+
if (e.provider === agent.activeProvider && e.model === agent.provider.model) {
|
|
56
|
+
// Same as main — clear override (use main pool)
|
|
57
|
+
delete cfg.provider
|
|
58
|
+
delete cfg.model
|
|
59
|
+
pushLine(`Advisor: 使用主模型 (${agent.activeProvider}/${agent.provider.model})`)
|
|
60
|
+
} else {
|
|
61
|
+
cfg.provider = e.provider
|
|
62
|
+
cfg.model = e.model
|
|
63
|
+
pushLine(`Advisor: ${e.provider}/${e.model}`)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
})
|
|
68
|
+
}
|
package/src/tui/cmd-think.mjs
CHANGED
|
@@ -4,20 +4,26 @@
|
|
|
4
4
|
export async function handleThinkCommand(ctx) {
|
|
5
5
|
const { agent, openPicker, syncProviderField } = ctx
|
|
6
6
|
const cur = agent.provider
|
|
7
|
-
const thinkingEnabled = cur.thinking?.type === "enabled" || cur.thinking?.type === undefined
|
|
8
7
|
const { specForModel } = await import("../config.mjs")
|
|
9
8
|
const spec = specForModel(cur.model)
|
|
10
9
|
const isEffortOnly = spec.thinkApi === "effort"
|
|
10
|
+
const thinkOnValue = spec.thinkOnValue ?? "enabled"
|
|
11
|
+
const isCustomThink = thinkOnValue !== "enabled"
|
|
12
|
+
// "enabled" when thinking.type matches the model's enabled value, or when thinking is absent and the model is NOT a custom-think model (defaults to on for standard models)
|
|
13
|
+
const thinkingEnabled = cur.thinking?.type === thinkOnValue || (cur.thinking?.type === undefined && !isCustomThink)
|
|
11
14
|
const entries = []
|
|
15
|
+
// Auto-think: classify difficulty per-prompt and auto-set reasoning effort
|
|
16
|
+
const autoThinkEnabled = agent.config?.agent?.autoThink === true
|
|
17
|
+
entries.push({ type: "item", text: `Auto: ${autoThinkEnabled ? "ON" : "OFF"}`, action: "auto" })
|
|
12
18
|
if (!isEffortOnly) {
|
|
13
|
-
entries.push({ type: "item", text: `Thinking: ${thinkingEnabled ? "ON" : "OFF"}`, action: thinkingEnabled ? "off" : "on" })
|
|
19
|
+
if (!autoThinkEnabled) entries.push({ type: "item", text: `Thinking: ${thinkingEnabled ? "ON" : "OFF"}`, action: thinkingEnabled ? "off" : "on" })
|
|
14
20
|
}
|
|
15
|
-
if (spec.reasoningEffortEnum) {
|
|
21
|
+
if (spec.reasoningEffortEnum && !autoThinkEnabled) {
|
|
16
22
|
for (const level of spec.reasoningEffortEnum) {
|
|
17
23
|
const mark = cur.reasoningEffort === level ? "▸ " : " "
|
|
18
24
|
entries.push({ type: "item", text: `${mark}effort: ${level}`, action: "effort", level })
|
|
19
25
|
}
|
|
20
|
-
} else {
|
|
26
|
+
} else if (!autoThinkEnabled) {
|
|
21
27
|
entries.push({ type: "item", text: "effort: high", action: "effort", level: "high" })
|
|
22
28
|
entries.push({ type: "item", text: "effort: max", action: "effort", level: "max" })
|
|
23
29
|
}
|
|
@@ -25,7 +31,19 @@ export async function handleThinkCommand(ctx) {
|
|
|
25
31
|
title: "Think",
|
|
26
32
|
entries,
|
|
27
33
|
onSelect: async (e) => {
|
|
28
|
-
if (e.action === "
|
|
34
|
+
if (e.action === "auto") {
|
|
35
|
+
const cfg = agent.config.agent ??= {}
|
|
36
|
+
cfg.autoThink = !cfg.autoThink
|
|
37
|
+
agent._pendingReminders = agent._pendingReminders ?? []
|
|
38
|
+
if (cfg.autoThink) {
|
|
39
|
+
// Turn off manual effort — auto will set it per-turn
|
|
40
|
+
delete cur.reasoningEffort
|
|
41
|
+
await syncProviderField("reasoningEffort", undefined)
|
|
42
|
+
agent._pendingReminders.push("[System reminder: Auto-think is now ON. Reasoning effort will be automatically set per-task based on difficulty classification.]")
|
|
43
|
+
} else {
|
|
44
|
+
agent._pendingReminders.push("[System reminder: Auto-think is now OFF. Reasoning effort will remain at its current manual setting.]")
|
|
45
|
+
}
|
|
46
|
+
} else if (e.action === "effort") {
|
|
29
47
|
cur.reasoningEffort = e.level
|
|
30
48
|
await syncProviderField("reasoningEffort", e.level)
|
|
31
49
|
} else {
|
|
@@ -36,12 +54,20 @@ export async function handleThinkCommand(ctx) {
|
|
|
36
54
|
if (!enable) await syncProviderField("reasoningEffort", undefined)
|
|
37
55
|
else await syncProviderField("reasoningEffort", cur.reasoningEffort)
|
|
38
56
|
} else {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
57
|
+
if (enable) {
|
|
58
|
+
cur.thinking = { type: thinkOnValue }
|
|
59
|
+
if (!cur.reasoningEffort) cur.reasoningEffort = "high"
|
|
60
|
+
} else {
|
|
61
|
+
// Custom-think models (MiniMax "adaptive") don't support "disabled" — remove the field instead
|
|
62
|
+
cur.thinking = isCustomThink ? undefined : { type: "disabled" }
|
|
63
|
+
delete cur.reasoningEffort
|
|
64
|
+
}
|
|
42
65
|
await syncProviderField("thinking", cur.thinking)
|
|
43
|
-
if (
|
|
44
|
-
|
|
66
|
+
if (enable) {
|
|
67
|
+
await syncProviderField("reasoningEffort", cur.reasoningEffort)
|
|
68
|
+
} else {
|
|
69
|
+
await syncProviderField("reasoningEffort", undefined)
|
|
70
|
+
}
|
|
45
71
|
}
|
|
46
72
|
}
|
|
47
73
|
},
|
package/src/tui/index.mjs
CHANGED
|
@@ -63,7 +63,7 @@ export async function startTUI(agent, opts = {}) {
|
|
|
63
63
|
picker: null, // model picker { entries, lines, index, scroll, selectedLine }
|
|
64
64
|
wizard: null, // first-launch config wizard { step, index, scroll, selectedLine, fields, error, lines }
|
|
65
65
|
tasks: agent.tasks ?? [], // task list from task tool (progress shown in status bar); carried over on session restore, auto-collapsed when all done
|
|
66
|
-
tokens: { prompt: 0, completion: 0, cacheHit: 0, cacheMiss: 0 }, // cumulative token usage (shown in status bar)
|
|
66
|
+
tokens: { prompt: 0, completion: 0, cacheHit: 0, cacheMiss: 0, reasoningTokens: 0 }, // cumulative token usage (shown in status bar)
|
|
67
67
|
ctxCache: { len: -1, tokens: 0 }, // context utilization estimate cache (estimateTokens is O(n), only recompute when history grows)
|
|
68
68
|
reasoning: "", // thinking stream buffer (dimmed display)
|
|
69
69
|
completion: null, // Tab completion state { candidates, index }
|
|
@@ -74,6 +74,7 @@ export async function startTUI(agent, opts = {}) {
|
|
|
74
74
|
processingStarted: 0, // current turn start time (status bar timer)
|
|
75
75
|
status: "Ready",
|
|
76
76
|
queue: [], // queued messages while processing: [{ text }], auto-dequeued when current turn finishes
|
|
77
|
+
interruptPrompt: null, // Ctrl+I interrupt message input: { text: "" } or null
|
|
77
78
|
}
|
|
78
79
|
|
|
79
80
|
// On session restore, if all tasks are completed, auto-collapse the todo panel (match runtime behavior)
|
package/src/tui/key-handler.mjs
CHANGED
|
@@ -91,7 +91,10 @@ export function createKeyHandler(ctx) {
|
|
|
91
91
|
insertPastedText(state, text)
|
|
92
92
|
render()
|
|
93
93
|
}
|
|
94
|
-
}).catch(() => {
|
|
94
|
+
}).catch((e) => {
|
|
95
|
+
q._pasting = false
|
|
96
|
+
console.error(`[tui] clipboard paste failed: ${e.message}`)
|
|
97
|
+
})
|
|
95
98
|
} else if (str && !key.ctrl && !key.meta) {
|
|
96
99
|
q.answer = (q.answer ?? "") + str
|
|
97
100
|
render()
|
|
@@ -111,6 +114,38 @@ export function createKeyHandler(ctx) {
|
|
|
111
114
|
setTimeout(() => process.exit(0), 100)
|
|
112
115
|
}
|
|
113
116
|
|
|
117
|
+
// Ctrl+I: interrupt current generation and inject a message (time-travel inject)
|
|
118
|
+
if (key.ctrl && !key.alt && key.name === "i") {
|
|
119
|
+
if (state.processing && state.controller && !state.interruptPrompt) {
|
|
120
|
+
state.interruptPrompt = { text: "" }
|
|
121
|
+
render()
|
|
122
|
+
}
|
|
123
|
+
return
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Interrupt prompt mode: type message, Enter to inject, Esc to cancel
|
|
127
|
+
if (state.interruptPrompt) {
|
|
128
|
+
if (key.name === "escape") {
|
|
129
|
+
state.interruptPrompt = null
|
|
130
|
+
render()
|
|
131
|
+
} else if (key.name === "return") {
|
|
132
|
+
const msg = (state.interruptPrompt.text ?? "").trim()
|
|
133
|
+
state.interruptPrompt = null
|
|
134
|
+
if (msg) {
|
|
135
|
+
pushLine(` [inject] ${msg}`, C.warn)
|
|
136
|
+
state.controller.abort({ interrupt: true, message: msg })
|
|
137
|
+
render()
|
|
138
|
+
}
|
|
139
|
+
} else if (key.name === "backspace") {
|
|
140
|
+
state.interruptPrompt.text = state.interruptPrompt.text.slice(0, -1)
|
|
141
|
+
render()
|
|
142
|
+
} else if (str && !key.ctrl && !key.meta) {
|
|
143
|
+
state.interruptPrompt.text += str.replace(/[\r\n]+/g, "")
|
|
144
|
+
render()
|
|
145
|
+
}
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
|
|
114
149
|
// generic list picker: ↑↓ move, Enter confirm, Esc cancel
|
|
115
150
|
if (state.picker) {
|
|
116
151
|
const items = state.picker?.entries.filter((e) => e.type === "item") ?? []
|
package/src/tui/layout.mjs
CHANGED
|
@@ -22,7 +22,9 @@ export function computeLayout(state, { cols, rows }) {
|
|
|
22
22
|
const W = Math.max(20, cols - 1)
|
|
23
23
|
|
|
24
24
|
// --- input box ---
|
|
25
|
-
const
|
|
25
|
+
const inputBuf = state.interruptPrompt ? [...state.interruptPrompt.text] : state.input
|
|
26
|
+
const inputCursor = state.interruptPrompt ? inputBuf.length : state.cursor
|
|
27
|
+
const inputLayout = layoutInput(inputBuf, inputCursor, W - 4)
|
|
26
28
|
let inputOffset = 0
|
|
27
29
|
if (inputLayout.lines.length > MAX_INPUT_LINES) {
|
|
28
30
|
inputOffset = Math.min(inputLayout.cursorLine, inputLayout.lines.length - MAX_INPUT_LINES)
|
package/src/tui/pickers.mjs
CHANGED
|
@@ -185,27 +185,27 @@ export function createPickers(ctx) {
|
|
|
185
185
|
openPicker({
|
|
186
186
|
title: "Add Provider",
|
|
187
187
|
entries: presetEntries,
|
|
188
|
-
onCancel: () => openModelPicker().catch(() => {}),
|
|
188
|
+
onCancel: () => openModelPicker().catch((e) => pushLine(`[error] ${e.message}`, C.error)),
|
|
189
189
|
onSelect: async (se) => {
|
|
190
190
|
if (se.kind === "custom") {
|
|
191
191
|
const name = await askQuestion("Enter provider name:")
|
|
192
|
-
if (!name) { openModelPicker().catch(() => {}); return }
|
|
193
|
-
if (agent.providers.some((p) => p.name === name)) { openModelPicker().catch(() => {}); return }
|
|
192
|
+
if (!name) { openModelPicker().catch((e) => pushLine(`[error] ${e.message}`, C.error)); return }
|
|
193
|
+
if (agent.providers.some((p) => p.name === name)) { openModelPicker().catch((e) => pushLine(`[error] ${e.message}`, C.error)); return }
|
|
194
194
|
const baseURLRaw = await askQuestion("Enter baseURL (e.g. https://api.example.com/v1):")
|
|
195
|
-
if (!baseURLRaw) { openModelPicker().catch(() => {}); return }
|
|
195
|
+
if (!baseURLRaw) { openModelPicker().catch((e) => pushLine(`[error] ${e.message}`, C.error)); return }
|
|
196
196
|
const baseURL = baseURLRaw.replace(/\/+$/, "")
|
|
197
|
-
if (!/^https?:\/\//.test(baseURL)) { openModelPicker().catch(() => {}); return }
|
|
197
|
+
if (!/^https?:\/\//.test(baseURL)) { openModelPicker().catch((e) => pushLine(`[error] ${e.message}`, C.error)); return }
|
|
198
198
|
const model = await askQuestion("Enter model name:")
|
|
199
|
-
if (!model) { openModelPicker().catch(() => {}); return }
|
|
199
|
+
if (!model) { openModelPicker().catch((e) => pushLine(`[error] ${e.message}`, C.error)); return }
|
|
200
200
|
agent.providers.push({ name, baseURL, model })
|
|
201
201
|
await persistRaw((raw) => { raw.providers = agent.providers })
|
|
202
202
|
const key = await askQuestion(`Enter API key for ${name} (leave empty to skip):`)
|
|
203
203
|
if (key) { await setProviderKey(name, key) }
|
|
204
|
-
openModelPicker().catch(() => {})
|
|
204
|
+
openModelPicker().catch((e) => pushLine(`[error] ${e.message}`, C.error))
|
|
205
205
|
return
|
|
206
206
|
}
|
|
207
207
|
// preset
|
|
208
|
-
if (agent.providers.some((p) => p.name === se.name)) { openModelPicker().catch(() => {}); return }
|
|
208
|
+
if (agent.providers.some((p) => p.name === se.name)) { openModelPicker().catch((e) => pushLine(`[error] ${e.message}`, C.error)); return }
|
|
209
209
|
const preset = PRESETS[se.name]
|
|
210
210
|
const providerCfg = { name: se.name, baseURL: preset.baseURL, model: preset.model }
|
|
211
211
|
if (preset.thinking) providerCfg.thinking = preset.thinking
|
|
@@ -217,7 +217,7 @@ export function createPickers(ctx) {
|
|
|
217
217
|
await persistRaw((raw) => { raw.providers = agent.providers })
|
|
218
218
|
const presetKey = await askQuestion(`Enter API key for ${se.name} (leave empty to skip):`)
|
|
219
219
|
if (presetKey) await setProviderKey(se.name, presetKey)
|
|
220
|
-
openModelPicker().catch(() => {})
|
|
220
|
+
openModelPicker().catch((e) => pushLine(`[error] ${e.message}`, C.error))
|
|
221
221
|
},
|
|
222
222
|
})
|
|
223
223
|
}
|
|
@@ -225,7 +225,7 @@ export function createPickers(ctx) {
|
|
|
225
225
|
/** Remove provider (cannot remove the currently active one) */
|
|
226
226
|
async function removeProviderFlow() {
|
|
227
227
|
const candidates = agent.providers.filter((p) => p.name !== agent.activeProvider)
|
|
228
|
-
if (candidates.length === 0) { openModelPicker().catch(() => {}); return }
|
|
228
|
+
if (candidates.length === 0) { openModelPicker().catch((e) => pushLine(`[error] ${e.message}`, C.error)); return }
|
|
229
229
|
const removeEntries = [
|
|
230
230
|
{ type: "header", text: "Select provider to remove (current one cannot be removed)" },
|
|
231
231
|
...candidates.map((p) => ({ type: "item", text: `${p.name} (${p.model})`, name: p.name })),
|
|
@@ -233,12 +233,12 @@ export function createPickers(ctx) {
|
|
|
233
233
|
openPicker({
|
|
234
234
|
title: "Remove Provider",
|
|
235
235
|
entries: removeEntries,
|
|
236
|
-
onCancel: () => openModelPicker().catch(() => {}),
|
|
236
|
+
onCancel: () => openModelPicker().catch((e) => pushLine(`[error] ${e.message}`, C.error)),
|
|
237
237
|
onSelect: async (se) => {
|
|
238
238
|
const at = agent.providers.findIndex((p) => p.name === se.name)
|
|
239
239
|
agent.providers.splice(at, 1)
|
|
240
240
|
await persistRaw((raw) => { raw.providers = agent.providers })
|
|
241
|
-
openModelPicker().catch(() => {})
|
|
241
|
+
openModelPicker().catch((e) => pushLine(`[error] ${e.message}`, C.error))
|
|
242
242
|
},
|
|
243
243
|
})
|
|
244
244
|
}
|
|
@@ -256,12 +256,12 @@ export function createPickers(ctx) {
|
|
|
256
256
|
openPicker({
|
|
257
257
|
title: "Configure API Key",
|
|
258
258
|
entries: keyEntries,
|
|
259
|
-
onCancel: () => openModelPicker().catch(() => {}),
|
|
259
|
+
onCancel: () => openModelPicker().catch((e) => pushLine(`[error] ${e.message}`, C.error)),
|
|
260
260
|
onSelect: async (se) => {
|
|
261
261
|
const key = await askQuestion(`Enter API key for ${se.name}:`)
|
|
262
|
-
if (!key) { openModelPicker().catch(() => {}); return }
|
|
262
|
+
if (!key) { openModelPicker().catch((e) => pushLine(`[error] ${e.message}`, C.error)); return }
|
|
263
263
|
await setProviderKey(se.name, key)
|
|
264
|
-
openModelPicker().catch(() => {})
|
|
264
|
+
openModelPicker().catch((e) => pushLine(`[error] ${e.message}`, C.error))
|
|
265
265
|
},
|
|
266
266
|
})
|
|
267
267
|
}
|