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/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
 
@@ -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)
@@ -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
- return guard ? `${guard.notice}\n\n${result}` : result
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
- regex = new RegExp(args.pattern)
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.slice(0, 500).map(async (e) => {
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
+ }
@@ -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
- return await new Promise((resolve) => execFile("powershell", ["-NoProfile", "-Command", "Get-Clipboard"], { timeout: 5000 }, (err, stdout) => resolve(err ? "" : stdout)))
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).
@@ -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: always available | Model: ${curModel} | Think: ${thinkInfo}`
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: `Guard: ${guardInfo}`, action: "guard" },
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(`Guard: ${guardInfo}`, C.dim)
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(`Guard: ${cfg.guard === true ? "on" : "off"}`, C.tool)
138
+ pushLine(`Advisor: ${cfg.guard === true ? "on" : "off"}`, C.tool)
140
139
  continue
141
140
  }
142
141
 
@@ -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 = { ...(raw.embedding ?? {}), apiKey: embKey } })
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
+ }