thincoder 0.12.36 → 0.12.38
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/package.json +1 -1
- package/src/advisor.mjs +4 -22
- package/src/agent-tools/consult.mjs +48 -9
- package/src/agent-tools/verify.mjs +13 -8
- package/src/config.mjs +17 -5
- package/src/context.mjs +1 -1
- package/src/escape.mjs +49 -0
- package/src/generate-title.mjs +12 -2
- package/src/prompts/discipline.md +8 -1
- package/src/prompts/engineering.md +13 -0
- package/src/prompts/system.md +2 -1
- package/src/provider/core.mjs +5 -0
- package/src/provider/sse.mjs +56 -14
- package/src/session-migrate.mjs +42 -0
- package/src/session.mjs +1 -38
- package/src/tools/bash.md +1 -0
- package/src/tools/codemode.mjs +125 -134
- package/src/tools/exec-prelude.mjs +84 -0
- package/src/tools/execute.md +14 -2
- package/src/tools/file_ops.md +16 -0
- package/src/tools/get_current_time.md +6 -0
- package/src/tools/git.md +10 -4
- package/src/tools/git.mjs +60 -8
- package/src/tools/grep.md +3 -1
- package/src/tools/index.mjs +7 -1
- package/src/tools/ls.md +1 -0
- package/src/tools/ops.mjs +142 -0
- package/src/tools/process.md +10 -0
- package/src/tools/sleep.md +5 -0
- package/src/tools/system.mjs +28 -6
- package/src/tools/tree.md +13 -0
- package/src/tools/tree.mjs +66 -0
- package/src/tui/clipboard.mjs +53 -1
- package/src/tui/cmd-advisor.mjs +4 -5
- package/src/tui/cmd-config.mjs +14 -2
- package/src/tui/cmd-copy.mjs +29 -0
- package/src/tui/math.mjs +309 -0
- package/src/tui/render-conversation.mjs +11 -3
- package/src/tui/render-frame.mjs +3 -3
- package/src/tui/slash-commands.mjs +4 -1
package/src/tools/grep.md
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
Search file contents with a regex. Returns matching lines as path:line: content.
|
|
2
2
|
|
|
3
3
|
Parameters:
|
|
4
|
-
- pattern (required): JavaScript regular expression
|
|
4
|
+
- pattern (required): JavaScript regular expression, or a literal string when literal=true
|
|
5
5
|
- path: Directory or file to search (default cwd)
|
|
6
6
|
- glob: Only search files matching this glob (e.g. '*.mjs')
|
|
7
|
+
- ignoreCase: Case-insensitive match (default false)
|
|
8
|
+
- literal: Literal string match — no regex interpretation (default false; use for strings with `. \` etc.)
|
|
7
9
|
- before: Lines of context to show before each match (grep -B). Default 0
|
|
8
10
|
- after: Lines of context to show after each match (grep -A). Default 0
|
|
9
11
|
|
package/src/tools/index.mjs
CHANGED
|
@@ -10,6 +10,8 @@ import { checklistTool } from "./checklist.mjs";
|
|
|
10
10
|
import { lintTool } from "./linter.mjs";
|
|
11
11
|
import { lspTool } from "./lsp.mjs";
|
|
12
12
|
import { codeModeTool } from "./codemode.mjs";
|
|
13
|
+
import { fileOpsTool, processTool, getCurrentTimeTool, sleepTool } from "./ops.mjs";
|
|
14
|
+
import { treeTool } from "./tree.mjs";
|
|
13
15
|
|
|
14
16
|
export const builtinTools = [
|
|
15
17
|
readTool, writeTool, editTool, insertAfterTool, hashlineEditTool, applyPatchTool,
|
|
@@ -17,6 +19,8 @@ export const builtinTools = [
|
|
|
17
19
|
websearchTool, lsTool, fetchTool, deleteTool,
|
|
18
20
|
gitTool, questionTool,
|
|
19
21
|
checklistTool, lintTool, lspTool, codeModeTool,
|
|
22
|
+
fileOpsTool, processTool, getCurrentTimeTool, sleepTool,
|
|
23
|
+
treeTool,
|
|
20
24
|
];
|
|
21
25
|
|
|
22
26
|
export {
|
|
@@ -25,4 +29,6 @@ export {
|
|
|
25
29
|
websearchTool, lsTool, fetchTool, deleteTool,
|
|
26
30
|
gitTool, questionTool,
|
|
27
31
|
checklistTool, lintTool, lspTool, codeModeTool,
|
|
28
|
-
|
|
32
|
+
fileOpsTool, processTool, getCurrentTimeTool, sleepTool,
|
|
33
|
+
treeTool,
|
|
34
|
+
};
|
package/src/tools/ls.md
CHANGED
|
@@ -2,6 +2,7 @@ List directory contents with type, size, and modification time. Directories list
|
|
|
2
2
|
|
|
3
3
|
Parameters:
|
|
4
4
|
- path: Directory path (default cwd)
|
|
5
|
+
- filter: Only list entries matching this glob (e.g. '*.mjs', '*test*') — a wildcard filter, not a full listing
|
|
5
6
|
|
|
6
7
|
Notes:
|
|
7
8
|
- Shows first 500 entries
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ops.mjs — operational tools: file_ops (move/copy/rename), process (list),
|
|
3
|
+
* get_current_time, sleep. Each exists so the model reaches for a dedicated tool
|
|
4
|
+
* instead of shelling out to `bash` for the same operation (parity with thinworker).
|
|
5
|
+
*/
|
|
6
|
+
import { DESC, resolveInCwd, truncate } from "./shared.mjs"
|
|
7
|
+
import { cp, rename, rm } from "node:fs/promises"
|
|
8
|
+
import { execFileSync } from "node:child_process"
|
|
9
|
+
|
|
10
|
+
// ─── file_ops ──────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
export const fileOpsTool = {
|
|
13
|
+
name: "file_ops",
|
|
14
|
+
description: DESC("file_ops"),
|
|
15
|
+
parameters: {
|
|
16
|
+
type: "object",
|
|
17
|
+
properties: {
|
|
18
|
+
action: { type: "string", enum: ["move", "copy", "rename"], description: "move | copy | rename" },
|
|
19
|
+
source: { type: "string", description: "Source path, relative to cwd or absolute" },
|
|
20
|
+
dest: { type: "string", description: "Destination path" },
|
|
21
|
+
},
|
|
22
|
+
required: ["action", "source", "dest"],
|
|
23
|
+
},
|
|
24
|
+
readonly: false,
|
|
25
|
+
async execute({ action, source, dest }, ctx) {
|
|
26
|
+
if (typeof source !== "string" || !source) return "Error: source is required"
|
|
27
|
+
if (typeof dest !== "string" || !dest) return "Error: dest is required"
|
|
28
|
+
if (!["move", "copy", "rename"].includes(action)) return `Error: action must be move | copy | rename (got "${action}")`
|
|
29
|
+
const src = resolveInCwd(ctx, source)
|
|
30
|
+
const dst = resolveInCwd(ctx, dest)
|
|
31
|
+
if (src === dst) return "Error: source and dest resolve to the same path"
|
|
32
|
+
|
|
33
|
+
if (action === "copy") {
|
|
34
|
+
await cp(src, dst, { recursive: true, force: true })
|
|
35
|
+
return `Copied ${source} → ${dest}`
|
|
36
|
+
}
|
|
37
|
+
// move & rename share the rename syscall; cross-device move falls back to copy+rm.
|
|
38
|
+
try {
|
|
39
|
+
await rename(src, dst)
|
|
40
|
+
} catch (e) {
|
|
41
|
+
if (e?.code !== "EXDEV") throw e
|
|
42
|
+
await cp(src, dst, { recursive: true, force: true })
|
|
43
|
+
await rm(src, { recursive: true, force: true })
|
|
44
|
+
}
|
|
45
|
+
return `${action === "rename" ? "Renamed" : "Moved"} ${source} → ${dest}`
|
|
46
|
+
},
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ─── process ───────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
export const processTool = {
|
|
52
|
+
name: "process",
|
|
53
|
+
description: DESC("process"),
|
|
54
|
+
parameters: {
|
|
55
|
+
type: "object",
|
|
56
|
+
properties: {
|
|
57
|
+
name: { type: "string", description: "Optional name substring filter (case-insensitive)" },
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
readonly: true,
|
|
61
|
+
async execute({ name }, ctx) {
|
|
62
|
+
const filter = typeof name === "string" && name.trim() ? name.trim().toLowerCase() : null
|
|
63
|
+
let rows
|
|
64
|
+
try {
|
|
65
|
+
rows = process.platform === "win32" ? listWindows() : listPosix()
|
|
66
|
+
} catch (e) {
|
|
67
|
+
return `process listing failed: ${e?.message ?? String(e)}`
|
|
68
|
+
}
|
|
69
|
+
if (filter) rows = rows.filter((r) => r.name.toLowerCase().includes(filter))
|
|
70
|
+
if (rows.length === 0) return filter ? `No running processes match "${name}"` : "(no processes)"
|
|
71
|
+
return truncate(rows.map((r) => `${r.name}\tPID ${r.pid}${r.mem ? `\t${r.mem}` : ""}`).join("\n"))
|
|
72
|
+
},
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function listWindows() {
|
|
76
|
+
// tasklist /FO CSV /NH → lines: "name.exe","1234","Console","1","12,345 K"
|
|
77
|
+
const out = execFileSync("tasklist", ["/FO", "CSV", "/NH"], { encoding: "utf8", timeout: 10000 })
|
|
78
|
+
const rows = []
|
|
79
|
+
for (const line of out.split("\n")) {
|
|
80
|
+
const parts = line.split('","')
|
|
81
|
+
if (parts.length < 2) continue
|
|
82
|
+
const name = parts[0].replace(/^"/, "").trim()
|
|
83
|
+
const pid = parts[1].replace(/"/, "").trim()
|
|
84
|
+
const mem = parts[4] ? parts[4].replace(/"/, "").trim() : ""
|
|
85
|
+
if (!name || !pid) continue
|
|
86
|
+
rows.push({ name, pid, mem })
|
|
87
|
+
}
|
|
88
|
+
return rows
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function listPosix() {
|
|
92
|
+
const out = execFileSync("ps", ["-eo", "pid=,comm="], { encoding: "utf8", timeout: 10000 })
|
|
93
|
+
const rows = []
|
|
94
|
+
for (const line of out.split("\n")) {
|
|
95
|
+
const m = line.trim().match(/^(\d+)\s+(.+)$/)
|
|
96
|
+
if (m) rows.push({ name: m[2], pid: m[1], mem: "" })
|
|
97
|
+
}
|
|
98
|
+
return rows
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ─── get_current_time ──────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
export const getCurrentTimeTool = {
|
|
104
|
+
name: "get_current_time",
|
|
105
|
+
description: DESC("get_current_time"),
|
|
106
|
+
parameters: { type: "object", properties: {} },
|
|
107
|
+
readonly: true,
|
|
108
|
+
async execute() {
|
|
109
|
+
const now = new Date()
|
|
110
|
+
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone ?? "unknown"
|
|
111
|
+
const days = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]
|
|
112
|
+
return `Date: ${now.toISOString()} (UTC)\nTimezone: ${tz}\nWeekday: ${days[now.getDay()]}\nLocal: ${now.toLocaleString()}`
|
|
113
|
+
},
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ─── sleep ─────────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
export const sleepTool = {
|
|
119
|
+
name: "sleep",
|
|
120
|
+
description: DESC("sleep"),
|
|
121
|
+
parameters: {
|
|
122
|
+
type: "object",
|
|
123
|
+
properties: {
|
|
124
|
+
seconds: { type: "number", description: "Seconds to wait (1-300)" },
|
|
125
|
+
reason: { type: "string", description: "Why wait (shown to the user)" },
|
|
126
|
+
},
|
|
127
|
+
required: ["seconds"],
|
|
128
|
+
},
|
|
129
|
+
readonly: true,
|
|
130
|
+
async execute({ seconds, reason }, ctx) {
|
|
131
|
+
const raw = Number(seconds)
|
|
132
|
+
const n = Number.isFinite(raw) ? Math.min(Math.max(Math.round(raw), 1), 300) : 1
|
|
133
|
+
await new Promise((resolve, reject) => {
|
|
134
|
+
const t = setTimeout(resolve, n * 1000)
|
|
135
|
+
if (ctx?.signal) {
|
|
136
|
+
if (ctx.signal.aborted) { clearTimeout(t); reject(new Error("aborted")) }
|
|
137
|
+
else ctx.signal.addEventListener("abort", () => { clearTimeout(t); reject(new Error("aborted")) }, { once: true })
|
|
138
|
+
}
|
|
139
|
+
})
|
|
140
|
+
return `Waited ${n}s${reason ? ` (${reason})` : ""}`
|
|
141
|
+
},
|
|
142
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
List running processes, optionally filtered by name. Returns process name / PID / memory.
|
|
2
|
+
|
|
3
|
+
**Route to process instead of bash:**
|
|
4
|
+
- `tasklist` (Windows) / `ps aux` (POSIX) → process
|
|
5
|
+
|
|
6
|
+
Parameters:
|
|
7
|
+
- name (optional): substring filter (case-insensitive), e.g. "node", "python"
|
|
8
|
+
|
|
9
|
+
Notes:
|
|
10
|
+
- List-only. To kill a process, use `bash taskkill /PID <pid> /F` (Windows) or `bash kill <pid>` — and confirm with the user first.
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
Wait a number of seconds before continuing. Use to wait for a web page to load, an async task to finish, or to respect a rate limit — cheaper than repeatedly polling.
|
|
2
|
+
|
|
3
|
+
Parameters:
|
|
4
|
+
- seconds (required): how many seconds to wait (1-300)
|
|
5
|
+
- reason (optional): why you are waiting (shown to the user)
|
package/src/tools/system.mjs
CHANGED
|
@@ -17,6 +17,20 @@ import { join } from "node:path";
|
|
|
17
17
|
/** Maximum buffer size per stream (stdout / stderr) before truncation */
|
|
18
18
|
const MAX_STREAM_BUF = 2_000_000
|
|
19
19
|
|
|
20
|
+
/** Escape a string for literal regex matching (grep literal=true). */
|
|
21
|
+
function escapeRegExp(s) {
|
|
22
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Keep only output lines matching a regex (bash filter, case-insensitive). */
|
|
26
|
+
function applyLineFilter(output, filter) {
|
|
27
|
+
let re
|
|
28
|
+
try { re = new RegExp(filter, "i") } catch (e) { return `Error: filter regex invalid: ${e.message}` }
|
|
29
|
+
const lines = output.split("\n").filter((l) => re.test(l))
|
|
30
|
+
if (lines.length === 0) return `(no output lines matched filter "${filter}")`
|
|
31
|
+
return truncate(lines.join("\n"))
|
|
32
|
+
}
|
|
33
|
+
|
|
20
34
|
// ====================================================================
|
|
21
35
|
// bash — command execution with safety gates
|
|
22
36
|
// ====================================================================
|
|
@@ -223,6 +237,7 @@ export const bashTool = {
|
|
|
223
237
|
properties: {
|
|
224
238
|
command: { type: "string", description: "Shell command to execute" },
|
|
225
239
|
timeout: { type: "number", description: `Timeout in ms (default ${BASH_TIMEOUT_MS})` },
|
|
240
|
+
filter: { type: "string", description: "Optional: only return output lines matching this regex (case-insensitive)" },
|
|
226
241
|
},
|
|
227
242
|
required: ["command"],
|
|
228
243
|
},
|
|
@@ -241,7 +256,8 @@ export const bashTool = {
|
|
|
241
256
|
onOutput: ctx.onOutput,
|
|
242
257
|
shell: ctx.agent?.config?.shell ?? null,
|
|
243
258
|
})
|
|
244
|
-
|
|
259
|
+
const filtered = args.filter ? applyLineFilter(result, args.filter) : result
|
|
260
|
+
return guard ? `${guard.notice}\n\n${filtered}` : filtered
|
|
245
261
|
},
|
|
246
262
|
}
|
|
247
263
|
|
|
@@ -305,9 +321,11 @@ export const grepTool = {
|
|
|
305
321
|
parameters: {
|
|
306
322
|
type: "object",
|
|
307
323
|
properties: {
|
|
308
|
-
pattern: { type: "string", description: "Regular expression" },
|
|
324
|
+
pattern: { type: "string", description: "Regular expression, or a literal string when literal=true" },
|
|
309
325
|
path: { type: "string", description: "Directory or file to search (default cwd)" },
|
|
310
326
|
glob: { type: "string", description: "Only search files matching this glob (e.g. '*.mjs')" },
|
|
327
|
+
ignoreCase: { type: "boolean", description: "Case-insensitive match (default false)" },
|
|
328
|
+
literal: { type: "boolean", description: "Literal string match — no regex interpretation (default false)" },
|
|
311
329
|
before: { type: "integer", description: "Lines of context to show before each match (grep -B). Default 0" },
|
|
312
330
|
after: { type: "integer", description: "Lines of context to show after each match (grep -A). Default 0" },
|
|
313
331
|
},
|
|
@@ -318,7 +336,8 @@ export const grepTool = {
|
|
|
318
336
|
const base = resolveInCwd(ctx, args.path ?? ".")
|
|
319
337
|
let regex
|
|
320
338
|
try {
|
|
321
|
-
|
|
339
|
+
const pat = args.literal ? escapeRegExp(String(args.pattern)) : args.pattern
|
|
340
|
+
regex = new RegExp(pat, args.ignoreCase ? "i" : "")
|
|
322
341
|
} catch (e) {
|
|
323
342
|
throw new Error(`grep pattern /${args.pattern}/ is not a valid regex: ${e.message}`)
|
|
324
343
|
}
|
|
@@ -409,11 +428,13 @@ export const lsTool = {
|
|
|
409
428
|
type: "object",
|
|
410
429
|
properties: {
|
|
411
430
|
path: { type: "string", description: "Directory path (default cwd)" },
|
|
431
|
+
filter: { type: "string", description: "Only list entries matching this glob (e.g. '*.mjs', '*test*')" },
|
|
412
432
|
},
|
|
413
433
|
},
|
|
414
434
|
readonly: true,
|
|
415
435
|
async execute(args, ctx) {
|
|
416
436
|
const abs = resolveInCwd(ctx, args.path ?? ".")
|
|
437
|
+
const filterRe = args.filter ? globToRegex(args.filter) : null
|
|
417
438
|
let entries
|
|
418
439
|
try {
|
|
419
440
|
entries = await readdir(abs, { withFileTypes: true })
|
|
@@ -422,7 +443,10 @@ export const lsTool = {
|
|
|
422
443
|
throw e
|
|
423
444
|
}
|
|
424
445
|
const rows = await Promise.all(
|
|
425
|
-
entries
|
|
446
|
+
entries
|
|
447
|
+
.filter((e) => !filterRe || filterRe.test(e.name))
|
|
448
|
+
.slice(0, 500)
|
|
449
|
+
.map(async (e) => {
|
|
426
450
|
const s = await stat(join(abs, e.name)).catch(() => null)
|
|
427
451
|
const isDir = e.isDirectory()
|
|
428
452
|
return {
|
|
@@ -439,5 +463,3 @@ export const lsTool = {
|
|
|
439
463
|
return truncate(out.join("\n"))
|
|
440
464
|
},
|
|
441
465
|
}
|
|
442
|
-
|
|
443
|
-
// ---------------------------------------------------------------- fetch
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Generate a directory tree of the codebase (default depth 3). Skips dotfiles, .git/node_modules/dist/build/bin/obj and other build/vendor dirs, and binary files. Use to quickly see which modules exist and where files live.
|
|
2
|
+
|
|
3
|
+
**Route to tree instead of bash:**
|
|
4
|
+
- `tree` / `find .` / `dir /s` → tree
|
|
5
|
+
|
|
6
|
+
Parameters:
|
|
7
|
+
- path: Root directory (default cwd)
|
|
8
|
+
- depth: Tree depth (default 3, max 6). Directories are listed before files, both sorted.
|
|
9
|
+
|
|
10
|
+
Notes:
|
|
11
|
+
- Capped at 200 entries.
|
|
12
|
+
- Directories end with `/`; tree-drawing uses `├──`/`└──`/`│`.
|
|
13
|
+
- Use depth for a shallow overview; use `ls` for one directory, `glob` for a specific file pattern.
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tree.mjs — directory tree tool (parity with thinworker `repomap`).
|
|
3
|
+
* Renders a repo's directory tree (default depth 3), skipping dotfiles,
|
|
4
|
+
* build/vendor dirs and binary files, so the model can see which modules
|
|
5
|
+
* exist without shelling out to `tree`/`find`.
|
|
6
|
+
*/
|
|
7
|
+
import { DESC, truncate, resolveInCwd } from "./shared.mjs"
|
|
8
|
+
import { readdir, stat } from "node:fs/promises"
|
|
9
|
+
import { join, basename, extname } from "node:path"
|
|
10
|
+
|
|
11
|
+
const MAX_ENTRIES = 200
|
|
12
|
+
const DEFAULT_DEPTH = 3
|
|
13
|
+
const SKIP_DIRS = new Set(["node_modules", "bin", "obj", "dist", "build", "coverage", "turbo", ".git", ".thincoder", ".vs", ".venv", "__pycache__", ".idea"])
|
|
14
|
+
const BINARY_EXTS = new Set([".exe", ".dll", ".png", ".jpg", ".jpeg", ".gif", ".pdf", ".docx", ".xlsx", ".pptx", ".zip", ".7z", ".mp3", ".mp4", ".woff", ".woff2", ".ico"])
|
|
15
|
+
|
|
16
|
+
export const treeTool = {
|
|
17
|
+
name: "tree",
|
|
18
|
+
description: DESC("tree"),
|
|
19
|
+
parameters: {
|
|
20
|
+
type: "object",
|
|
21
|
+
properties: {
|
|
22
|
+
path: { type: "string", description: "Root directory (default cwd)" },
|
|
23
|
+
depth: { type: "integer", description: `Tree depth (default ${DEFAULT_DEPTH}, max 6)` },
|
|
24
|
+
},
|
|
25
|
+
required: [],
|
|
26
|
+
},
|
|
27
|
+
readonly: true,
|
|
28
|
+
async execute(args, ctx) {
|
|
29
|
+
const root = resolveInCwd(ctx, args.path ?? ".")
|
|
30
|
+
let st
|
|
31
|
+
try { st = await stat(root) } catch { return `Error: directory not found: ${args.path ?? "."}` }
|
|
32
|
+
if (!st.isDirectory()) return `Error: not a directory: ${args.path ?? "."}`
|
|
33
|
+
const d = Number(args.depth)
|
|
34
|
+
const maxDepth = Number.isInteger(d) && d > 0 ? Math.min(d, 6) : DEFAULT_DEPTH
|
|
35
|
+
const lines = [basename(root) + "/"]
|
|
36
|
+
const state = { count: 1, capped: false }
|
|
37
|
+
await walk(root, 0, maxDepth, "", lines, state)
|
|
38
|
+
return truncate(lines.join("\n"))
|
|
39
|
+
},
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function walk(dir, depth, maxDepth, prefix, lines, state) {
|
|
43
|
+
if (state.capped) return
|
|
44
|
+
let entries
|
|
45
|
+
try { entries = await readdir(dir, { withFileTypes: true }) } catch { return }
|
|
46
|
+
const items = []
|
|
47
|
+
for (const e of entries) {
|
|
48
|
+
if (e.name.startsWith(".")) continue // dotfiles + dotdirs
|
|
49
|
+
const isDir = e.isDirectory()
|
|
50
|
+
if (isDir) { if (SKIP_DIRS.has(e.name)) continue; items.push({ name: e.name, isDir: true }) }
|
|
51
|
+
else if (!BINARY_EXTS.has(extname(e.name).toLowerCase())) items.push({ name: e.name, isDir: false })
|
|
52
|
+
}
|
|
53
|
+
items.sort((a, b) => (a.isDir === b.isDir ? a.name.localeCompare(b.name) : a.isDir ? -1 : 1))
|
|
54
|
+
|
|
55
|
+
for (let i = 0; i < items.length; i++) {
|
|
56
|
+
if (state.capped) return
|
|
57
|
+
if (state.count >= MAX_ENTRIES) { state.capped = true; lines.push(prefix + "…(更多项已省略)"); return }
|
|
58
|
+
const { name, isDir } = items[i]
|
|
59
|
+
const isLast = i === items.length - 1
|
|
60
|
+
lines.push(prefix + (isLast ? "└── " : "├── ") + (isDir ? name + "/" : name))
|
|
61
|
+
state.count++
|
|
62
|
+
if (isDir && depth + 1 < maxDepth) {
|
|
63
|
+
await walk(join(dir, name), depth + 1, maxDepth, prefix + (isLast ? " " : "│ "), lines, state)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
package/src/tui/clipboard.mjs
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import { C } from "./ansi.mjs"
|
|
2
2
|
|
|
3
|
+
/** Windows clipboard-read command: force UTF-8 console output so Get-Clipboard's bytes
|
|
4
|
+
* are decoded by Node's default UTF-8 (not the OEM codepage / GBK) — IK9UWM. Exported
|
|
5
|
+
* for unit tests (TUI.md §9.2D). */
|
|
6
|
+
export function buildWindowsClipboardCommand() {
|
|
7
|
+
return ["-NoProfile", "-Command", "[Console]::OutputEncoding=[Text.Encoding]::UTF8; Get-Clipboard"]
|
|
8
|
+
}
|
|
9
|
+
|
|
3
10
|
/** Read text from system clipboard. Returns empty string on failure. */
|
|
4
11
|
export async function readClipboardText() {
|
|
5
12
|
try {
|
|
@@ -7,7 +14,9 @@ export async function readClipboardText() {
|
|
|
7
14
|
const isWin = process.platform === "win32"
|
|
8
15
|
const isMac = process.platform === "darwin"
|
|
9
16
|
if (isWin) {
|
|
10
|
-
|
|
17
|
+
// Strip a leading \uFEFF — PowerShell may prepend a UTF-8 BOM once OutputEncoding
|
|
18
|
+
// flips to UTF-8 (TUI.md §9.2D BOM defense).
|
|
19
|
+
return await new Promise((resolve) => execFile("powershell", buildWindowsClipboardCommand(), { timeout: 5000 }, (err, stdout) => resolve(err ? "" : String(stdout).replace(/^\uFEFF/, ""))))
|
|
11
20
|
} else if (isMac) {
|
|
12
21
|
return await new Promise((resolve) => execFile("pbpaste", [], { timeout: 5000 }, (err, stdout) => resolve(err ? "" : stdout)))
|
|
13
22
|
} else {
|
|
@@ -18,6 +27,49 @@ export async function readClipboardText() {
|
|
|
18
27
|
}
|
|
19
28
|
}
|
|
20
29
|
|
|
30
|
+
/** Write text to the system clipboard. Returns true on success, false on failure. */
|
|
31
|
+
export async function writeClipboardText(text) {
|
|
32
|
+
if (typeof text !== "string" || text.length === 0) return false
|
|
33
|
+
try {
|
|
34
|
+
const { spawn } = await import("node:child_process")
|
|
35
|
+
const isWin = process.platform === "win32"
|
|
36
|
+
const isMac = process.platform === "darwin"
|
|
37
|
+
|
|
38
|
+
if (isWin) {
|
|
39
|
+
// -EncodedCommand is base64 UTF-16LE: PowerShell decodes the command (embedded text
|
|
40
|
+
// included) directly from UTF-16, so no console codepage (e.g. GBK) can garble
|
|
41
|
+
// non-ASCII characters — the same class of bug as the read path (IK9UWM).
|
|
42
|
+
const psCmd = `Set-Clipboard -Value '${text.replace(/'/g, "''")}'`
|
|
43
|
+
const encoded = Buffer.from(psCmd, "utf16le").toString("base64")
|
|
44
|
+
await spawnWait(spawn, "powershell", ["-NoProfile", "-EncodedCommand", encoded], null)
|
|
45
|
+
return true
|
|
46
|
+
}
|
|
47
|
+
if (isMac) {
|
|
48
|
+
await spawnWait(spawn, "pbcopy", [], text)
|
|
49
|
+
return true
|
|
50
|
+
}
|
|
51
|
+
// Linux: prefer wl-copy (Wayland), fall back to xclip (X11).
|
|
52
|
+
await spawnWait(spawn, "sh", ["-c", "command -v wl-copy >/dev/null 2>&1 && wl-copy || xclip -selection clipboard"], text)
|
|
53
|
+
return true
|
|
54
|
+
} catch {
|
|
55
|
+
return false
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Spawn a process and wait for clean exit. When stdinText is provided it is piped to
|
|
60
|
+
* the child as UTF-8 (used by pbcopy / xclip / wl-copy); otherwise stdio is ignored. */
|
|
61
|
+
function spawnWait(spawn, cmd, args, stdinText) {
|
|
62
|
+
return new Promise((resolve, reject) => {
|
|
63
|
+
const child = spawn(cmd, args, { stdio: stdinText == null ? "ignore" : ["pipe", "ignore", "ignore"] })
|
|
64
|
+
child.once("error", reject)
|
|
65
|
+
child.once("close", (code) => (code === 0 ? resolve() : reject(new Error(`${cmd} exited ${code}`))))
|
|
66
|
+
if (stdinText != null) {
|
|
67
|
+
child.stdin.on("error", () => {}) // swallow EPIPE when the tool exits without draining
|
|
68
|
+
child.stdin.end(stdinText, "utf8")
|
|
69
|
+
}
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
|
|
21
73
|
/** Insert pasted text into the active text target.
|
|
22
74
|
* Free-text question active → append to its answer (single-line field: newlines stripped).
|
|
23
75
|
* Options question active → ignore (no text field; must not leak into the input box).
|
package/src/tui/cmd-advisor.mjs
CHANGED
|
@@ -29,7 +29,7 @@ export async function handleAdvisorCommand(ctx) {
|
|
|
29
29
|
: cfg.thinking?.type === "disabled" ? "off"
|
|
30
30
|
: cfg.reasoningEffort ? `on (${cfg.reasoningEffort})`
|
|
31
31
|
: cfg.thinking ? `on (${cfg.thinking.type})` : "(main)"
|
|
32
|
-
return `Advisor
|
|
32
|
+
return `Advisor | Model: ${curModel} | Think: ${thinkInfo}`
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
function headerLine() {
|
|
@@ -115,7 +115,7 @@ export async function handleAdvisorCommand(ctx) {
|
|
|
115
115
|
{ type: "header", text: headerLine() },
|
|
116
116
|
{ type: "item", text: `Model: ${curModel}`, action: "model", note: `Provider: ${curProvider}` },
|
|
117
117
|
{ type: "item", text: `Thinking: ${advisorStatus().split("|")[2]?.trim() || "(main)"}`, action: "thinking" },
|
|
118
|
-
{ type: "item", text: `
|
|
118
|
+
{ type: "item", text: `Advisor: ${guardInfo}`, action: "guard" },
|
|
119
119
|
{ type: "item", text: "View full config", action: "view" },
|
|
120
120
|
]
|
|
121
121
|
|
|
@@ -125,9 +125,8 @@ export async function handleAdvisorCommand(ctx) {
|
|
|
125
125
|
|
|
126
126
|
if (choice.action === "view") {
|
|
127
127
|
pushLabel("❯ Advisor", ansi.bold + C.tool)
|
|
128
|
-
pushLine(`Status: always available`, C.dim)
|
|
129
128
|
pushLine(`Model: ${curModel} (provider: ${curProvider})`, C.dim)
|
|
130
|
-
pushLine(`
|
|
129
|
+
pushLine(`Advisor: ${guardInfo}`, C.dim)
|
|
131
130
|
pushLine(`Thinking: ${advisorStatus().split("|")[2]?.trim() || "(main)"}`, C.dim)
|
|
132
131
|
continue
|
|
133
132
|
}
|
|
@@ -136,7 +135,7 @@ export async function handleAdvisorCommand(ctx) {
|
|
|
136
135
|
cfg.guard = !(cfg.guard === true)
|
|
137
136
|
await persist().catch(err => pushLine(`[error] ${err.message}`, C.error))
|
|
138
137
|
pushLabel("❯ Advisor", ansi.bold + C.tool)
|
|
139
|
-
pushLine(`
|
|
138
|
+
pushLine(`Advisor: ${cfg.guard === true ? "on" : "off"}`, C.tool)
|
|
140
139
|
continue
|
|
141
140
|
}
|
|
142
141
|
|
package/src/tui/cmd-config.mjs
CHANGED
|
@@ -1,10 +1,22 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs"
|
|
2
2
|
import { ansi, C } from "./ansi.mjs"
|
|
3
|
+
/** Merge an embedding-key save into the raw config, backfilling baseURL/model from defaults.
|
|
4
|
+
* Keeps existing custom values (Ollama/local embedding); defaults are the single source
|
|
5
|
+
* (TUI.md §9.3D — NF1). Exported for unit tests. */
|
|
6
|
+
export function embeddingPatch(raw, embKey, defaults) {
|
|
7
|
+
const prev = raw?.embedding ?? {}
|
|
8
|
+
return {
|
|
9
|
+
...prev,
|
|
10
|
+
apiKey: embKey,
|
|
11
|
+
baseURL: prev.baseURL ?? defaults.baseURL,
|
|
12
|
+
model: prev.model ?? defaults.model,
|
|
13
|
+
}
|
|
14
|
+
}
|
|
3
15
|
|
|
4
16
|
/** /config command: view and set agent/embedding/proxy config. */
|
|
5
17
|
export async function handleConfigCommand(ctx, args = []) {
|
|
6
18
|
const { agent, pushLine, pushLabel, showPicker, askQuestion, persistRaw, maskKey, pickModelForSlot } = ctx
|
|
7
|
-
const { configPath } = await import("../config.mjs")
|
|
19
|
+
const { configPath, DEFAULTS } = await import("../config.mjs")
|
|
8
20
|
const ac = agent.config?.agent ?? {}
|
|
9
21
|
const ec = agent.config?.embedding ?? {}
|
|
10
22
|
|
|
@@ -20,7 +32,7 @@ export async function handleConfigCommand(ctx, args = []) {
|
|
|
20
32
|
if (!embKey) return false
|
|
21
33
|
agent.config.embedding ??= {}
|
|
22
34
|
agent.config.embedding.apiKey = embKey
|
|
23
|
-
await persistRaw((raw) => { raw.embedding =
|
|
35
|
+
await persistRaw((raw) => { raw.embedding = embeddingPatch(raw, embKey, DEFAULTS.embedding) })
|
|
24
36
|
if (agent.memory) {
|
|
25
37
|
const { createEmbedder } = await import("../embedding.mjs")
|
|
26
38
|
agent.memory.embedder = createEmbedder(agent.config.embedding)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cmd-copy.mjs — /copy command: copy the last assistant response to the clipboard.
|
|
3
|
+
* Copies the RAW markdown reply (agent.history content), not the ANSI-styled display.
|
|
4
|
+
*/
|
|
5
|
+
import { C } from "./ansi.mjs"
|
|
6
|
+
import { writeClipboardText } from "./clipboard.mjs"
|
|
7
|
+
|
|
8
|
+
/** Most recent assistant reply with non-empty text content (skips tool-calls-only / transient). */
|
|
9
|
+
export function lastAssistantContent(history) {
|
|
10
|
+
if (!Array.isArray(history)) return null
|
|
11
|
+
for (let i = history.length - 1; i >= 0; i--) {
|
|
12
|
+
const m = history[i]
|
|
13
|
+
if (m?.role === "assistant" && typeof m.content === "string" && m.content.trim().length > 0) {
|
|
14
|
+
return m.content
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return null
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function handleCopyCommand(ctx) {
|
|
21
|
+
const { agent, pushLine } = ctx
|
|
22
|
+
const text = lastAssistantContent(agent?.history)
|
|
23
|
+
if (!text) {
|
|
24
|
+
pushLine("No assistant response to copy yet", C.warn)
|
|
25
|
+
return
|
|
26
|
+
}
|
|
27
|
+
const ok = await writeClipboardText(text)
|
|
28
|
+
pushLine(ok ? `Copied last response (${text.length} chars) to clipboard` : "Clipboard write failed", ok ? C.tool : C.error)
|
|
29
|
+
}
|