jinzd-ai-cli 0.1.29 → 0.1.31

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/CLAUDE.md CHANGED
@@ -343,6 +343,83 @@ const stdinLines = Array.isArray(rawStdin) ? rawStdin.map(String)
343
343
  - [x] **web_fetch DNS 解析时 SSRF 防护**(v0.1.25):新增 `resolveAndCheck()` 函数,用 `dns.promises.lookup()` 预解析域名,检查结果 IP 是否为私有地址。初始 URL 和 redirect 目标均校验。
344
344
  - [ ] **`persistentCwd` 全局状态**:bash 工具的当前工作目录是模块级全局变量,多 session 并发时可能串扰。现阶段单 session REPL 无影响,GUI 多会话扩展时需重构为 per-session 状态。
345
345
 
346
+ ## 本轮开发完成记录(2026-03-01,v0.1.26 → v0.1.30)
347
+
348
+ ### 安全修复续篇:低危 L2–L7
349
+
350
+ **L2**(`src/tools/builtin/web-fetch.ts`):`htmlToText()` 函数新增 200 KB HTML 大小上限(`HTML_REGEX_LIMIT = 200_000`),超出则截断后再做正则替换,防止恶意大 HTML 导致正则性能崩溃。
351
+
352
+ **L3**(`src/session/session-manager.ts`):新增 `safeDate(value: unknown): Date` 辅助函数,对无效日期字符串返回 `new Date(0)` 而非 `Invalid Date`,防止 `listSessions()` 和 `searchMessages()` 的日期比较静默失败。
353
+
354
+ **L4**(`src/tools/builtin/ask-user.ts` + `src/tools/builtin/google-search.ts`):为模块级全局上下文对象(`askUserContext` / `googleSearchContext`)添加架构说明注释,提示 GUI 多会话扩展时需重构为 per-session 状态。
355
+
356
+ **L5**(`src/config/schema.ts`):为 `timeouts` 字段补充三层优先级文档注释:
357
+ 1. `modelParams[modelId].timeout` — 最高
358
+ 2. `timeouts[providerId]` — provider 级默认
359
+ 3. 内置 provider 硬编码默认值 — 最低兜底
360
+
361
+ **L6/L7**:确认已安全(`call.arguments['path']` 已用 `String(... ?? '')` 兜底;主循环 `line` handler 已有 try/catch)。
362
+
363
+ ---
364
+
365
+ ### 新增功能 1:多模态图片输入(P0)
366
+
367
+ **背景**:用户希望通过 `@image.png 描述这张图` 语法将图片发送给各 AI Provider。
368
+
369
+ **类型层**(已有,无需改动):`src/core/types.ts` 的 `ImageContentPart { type: 'image_url', image_url: { url: 'data:mime;base64,...' } }` 与 OpenAI 格式完全兼容。
370
+
371
+ **Claude Provider**(`src/providers/claude.ts`):
372
+ - 新增 `contentToClaudeParts()` 私有方法:将内部 `image_url` 格式转换为 Anthropic SDK 期望的 `{ type: 'image', source: { type: 'base64', media_type, data } }` 格式
373
+ - 应用到 `chat()`、`chatStream()`、`chatWithTools()` 的消息构建
374
+
375
+ **Gemini Provider**(`src/providers/gemini.ts`):
376
+ - 移除错误的 `getContentText()` 调用(会丢弃图片)
377
+ - 新增 `contentToGeminiParts()` 私有方法:转换为 Gemini SDK 格式 `{ inlineData: { mimeType, data } }`
378
+ - 修复 `toGeminiHistory()`、`chat()`、`chatStream()`、`chatWithTools()` 的消息构建
379
+ - `chatWithTools()` 中变量 `lastMessage: string` 重命名为 `lastMsgParts: Part[]`,历史嵌入改为 `{ role: 'user', parts: lastMsgParts }`
380
+
381
+ **OpenAI 兼容 Provider**:已就绪,格式一致,无需修改。
382
+
383
+ **REPL 层**(`src/repl/repl.ts`):
384
+ - 新增常量 `MAX_IMAGE_BYTES = 10 * 1024 * 1024`(10 MB)
385
+ - `parseAtReferences()` 图片读取前用 `statSync` 校验大小,超限时加入 `refs` 类型为 `'toolarge'`,不内联
386
+ - `handleChat()` 新增 `toolarge` 分支:打印黄色警告 `⚠ Image too large (> 10 MB): <path>`
387
+ - 修复 `getVisionModelHint()`:Claude/Gemini 返回 `null`(原生支持,无需警告);DeepSeek 显示明确不支持提示;zhipu 推荐 `glm-4.6v`;kimi `moonshot-v1-*` 推荐对应 vision 版本
388
+
389
+ ---
390
+
391
+ ### 新增功能 2:Escape/Ctrl+C 中断 AI 流式输出(P0)
392
+
393
+ **背景**:流式生成期间无法中断,必须等待 AI 返回完整内容。
394
+
395
+ **架构设计**:两个流式生成入口——`handleChatSimple()` 和 `handleChatWithTools()` 的 tee streaming 分支(`save_last_response` 工具)——均支持中断。主路径(`chatWithTools()` → `renderResponse()`)为非流式,暂不支持。
396
+
397
+ **`src/core/types.ts`**:`ChatRequest` 新增 `signal?: AbortSignal`,透传给 Provider API 调用。
398
+
399
+ **Provider 层**:
400
+ - `claude.ts`:`messages.stream()` 第二参数传入 `{ signal: request.signal }`(Anthropic SDK 支持)
401
+ - `openai-compatible.ts`:流式 `create()` 第二参数传入 `{ signal: request.signal }`(OpenAI SDK 支持)
402
+ - `gemini.ts`:生成器循环开头检查 `if (request.signal?.aborted) break`(兼容性保险)
403
+
404
+ **`src/repl/renderer.ts`**:`renderStream()` 的 `options` 新增 `signal?: AbortSignal`:
405
+ - `for await` 循环开头检查 `signal.aborted` → 标记 `interrupted = true` 并 `break`
406
+ - 捕获 SDK 抛出的 `AbortError`(name 检测)→ 同样标记 `interrupted`
407
+ - 中断时 `flushBuf()` 输出残留缓冲,打印灰色 `[interrupted]` 提示
408
+ - 返回值不变(`{ content, usage, tokensShown }`),`content` 为已生成的部分内容
409
+
410
+ **`src/repl/repl.ts`**:
411
+ - 新增类属性 `streamAbortController: AbortController | null` + `_escHandler: ((d: Buffer) => void) | null`
412
+ - 新增 `setupStreamInterrupt()` 方法:创建 `AbortController`,`process.stdin.resume()`(绕过 `rl.pause()` 暂停),注册原始字节监听器检测纯 ESC(`0x1b`,单字节,区别于 ESC 序列)和 Ctrl+C(`0x03`)
413
+ - 新增 `teardownStreamInterrupt()` 方法:移除监听器,`process.stdin.pause()`,清空引用
414
+ - SIGINT 处理器最顶部新增分支:`streamAbortController` 非空时 `abort()` 并 return,不退出程序
415
+ - `handleChatSimple()` 流式分支和 tee streaming 分支均用 `setupStreamInterrupt()`/`teardownStreamInterrupt()` 包裹 + `signal` 传入
416
+
417
+ **版本与收尾**
418
+ - `src/core/constants.ts`:VERSION `0.1.26` → `0.1.30`(合并前几次 bump)
419
+ - `package.json`:version `0.1.29` → `0.1.30`
420
+
421
+ ---
422
+
346
423
  ## 本轮开发完成记录(2026-03-01,v0.1.25 → v0.1.26)
347
424
 
348
425
  ### 新增功能:Context 自动管理 + 测试报告 + 脚手架
@@ -871,8 +948,8 @@ const stdinLines = Array.isArray(rawStdin) ? rawStdin.map(String)
871
948
  ### ❌ 缺失功能路线图(新增)
872
949
 
873
950
  #### P0 — 核心竞争力缺口
874
- - [ ] **中断生成**:`Escape` 键立即停止 AI 流式输出(当前无法中断,必须等待完成)
875
- - [ ] **多模态输入(图片)**:支持粘贴/引用图片路径作为输入(当前纯文本)
951
+ - [x] **中断生成**(v0.1.30):`Escape` 键或 `Ctrl+C` 立即停止 AI 流式输出,不退出程序,显示 `[interrupted]` 后恢复提示符;已生成内容保留到 session
952
+ - [x] **多模态输入(图片)**(v0.1.30):`@image.png 描述这张图` 语法发送图片;Claude/Gemini/OpenAI 兼容格式自动转换;10MB 大小限制;`getVisionModelHint()` 正确识别 Claude/Gemini 原生支持视觉
876
953
  - [ ] **`/add-dir` 命令**:运行时动态添加目录到上下文
877
954
  - [ ] **并行工具调用**:AI 一次返回多个工具同时执行(当前为分组串行)
878
955
 
@@ -8,7 +8,7 @@ import { platform } from "os";
8
8
  import chalk from "chalk";
9
9
 
10
10
  // src/core/constants.ts
11
- var VERSION = "0.1.26";
11
+ var VERSION = "0.1.30";
12
12
  var APP_NAME = "ai-cli";
13
13
  var CONFIG_DIR_NAME = ".aicli";
14
14
  var CONFIG_FILE_NAME = "config.json";
package/dist/index.js CHANGED
@@ -27,7 +27,7 @@ import {
27
27
  SUBAGENT_MAX_ROUNDS_LIMIT,
28
28
  VERSION,
29
29
  runTestsTool
30
- } from "./chunk-3EJWGNHV.js";
30
+ } from "./chunk-IW6VVPO4.js";
31
31
 
32
32
  // src/index.ts
33
33
  import { program } from "commander";
@@ -409,7 +409,7 @@ var ClaudeProvider = class extends BaseProvider {
409
409
  messages,
410
410
  system: request.systemPrompt,
411
411
  max_tokens: request.maxTokens ?? 8192
412
- });
412
+ }, { signal: request.signal });
413
413
  for await (const event of stream) {
414
414
  if (event.type === "content_block_delta" && event.delta.type === "text_delta") {
415
415
  yield { delta: event.delta.text, done: false };
@@ -650,6 +650,7 @@ var GeminiProvider = class extends BaseProvider {
650
650
  const chat = genModel.startChat({ history });
651
651
  const result = await chat.sendMessageStream(lastMsgParts);
652
652
  for await (const chunk of result.stream) {
653
+ if (request.signal?.aborted) break;
653
654
  yield { delta: chunk.text(), done: false };
654
655
  }
655
656
  const finalResponse = await result.response;
@@ -857,7 +858,8 @@ var OpenAICompatibleProvider = class extends BaseProvider {
857
858
  stream_options: { include_usage: true },
858
859
  ...request.thinking ? { thinking: { type: "enabled" } } : {}
859
860
  }, {
860
- timeout: request.timeout ?? this.defaultTimeout
861
+ timeout: request.timeout ?? this.defaultTimeout,
862
+ signal: request.signal
861
863
  });
862
864
  for await (const chunk of stream) {
863
865
  const choice = chunk.choices[0];
@@ -1661,8 +1663,8 @@ var SessionManager = class {
1661
1663
 
1662
1664
  // src/repl/repl.ts
1663
1665
  import * as readline from "readline";
1664
- import { existsSync as existsSync18, readFileSync as readFileSync13, readdirSync as readdirSync9, statSync as statSync6 } from "fs";
1665
- import { join as join13, resolve as resolve4, extname as extname4, dirname as dirname5, basename as basename5 } from "path";
1666
+ import { existsSync as existsSync19, readFileSync as readFileSync13, readdirSync as readdirSync9, statSync as statSync7 } from "fs";
1667
+ import { join as join14, resolve as resolve4, extname as extname4, dirname as dirname5, basename as basename5 } from "path";
1666
1668
  import chalk10 from "chalk";
1667
1669
 
1668
1670
  // src/repl/renderer.ts
@@ -1850,19 +1852,36 @@ var Renderer = class {
1850
1852
  if (out) process.stdout.write(out);
1851
1853
  buf = "";
1852
1854
  };
1853
- for await (const chunk of stream) {
1854
- if (chunk.usage) {
1855
- usage = chunk.usage;
1855
+ let interrupted = false;
1856
+ try {
1857
+ for await (const chunk of stream) {
1858
+ if (options?.signal?.aborted) {
1859
+ interrupted = true;
1860
+ break;
1861
+ }
1862
+ if (chunk.usage) {
1863
+ usage = chunk.usage;
1864
+ }
1865
+ if (chunk.done) {
1866
+ flushBuf();
1867
+ break;
1868
+ }
1869
+ if (!chunk.delta) continue;
1870
+ fullContent += chunk.delta;
1871
+ buf += chunk.delta;
1872
+ if (fileStream) fileStream.write(chunk.delta);
1873
+ flushBuf();
1856
1874
  }
1857
- if (chunk.done) {
1875
+ } catch (err) {
1876
+ if (err?.name === "AbortError") {
1877
+ interrupted = true;
1858
1878
  flushBuf();
1859
- break;
1879
+ } else {
1880
+ throw err;
1860
1881
  }
1861
- if (!chunk.delta) continue;
1862
- fullContent += chunk.delta;
1863
- buf += chunk.delta;
1864
- if (fileStream) fileStream.write(chunk.delta);
1865
- flushBuf();
1882
+ }
1883
+ if (interrupted) {
1884
+ process.stdout.write(chalk.dim(" [interrupted]\n"));
1866
1885
  }
1867
1886
  let tokensShown = false;
1868
1887
  if (options?.showTokens) {
@@ -1963,10 +1982,10 @@ Error: ${message}
1963
1982
  };
1964
1983
 
1965
1984
  // src/repl/commands/index.ts
1966
- import { writeFileSync as writeFileSync4, mkdirSync as mkdirSync4, existsSync as existsSync5, readFileSync as readFileSync4, readdirSync as readdirSync2, statSync } from "fs";
1967
- import { execSync as execSync2 } from "child_process";
1985
+ import { writeFileSync as writeFileSync4, mkdirSync as mkdirSync4, existsSync as existsSync6, readFileSync as readFileSync4, readdirSync as readdirSync2, statSync as statSync2 } from "fs";
1986
+ import { execSync as execSync3 } from "child_process";
1968
1987
  import { platform } from "os";
1969
- import { resolve, dirname as dirname2, join as join4 } from "path";
1988
+ import { resolve, dirname as dirname2, join as join5, basename } from "path";
1970
1989
  import chalk2 from "chalk";
1971
1990
 
1972
1991
  // src/tools/git-context.ts
@@ -2129,6 +2148,93 @@ var UndoStack = class {
2129
2148
  };
2130
2149
  var undoStack = new UndoStack();
2131
2150
 
2151
+ // src/repl/clipboard.ts
2152
+ import { execSync as execSync2 } from "child_process";
2153
+ import { existsSync as existsSync5, statSync, unlinkSync as unlinkSync2 } from "fs";
2154
+ import { tmpdir } from "os";
2155
+ import { join as join4 } from "path";
2156
+ var CLIPBOARD_TIMEOUT = 5e3;
2157
+ function readClipboardImage() {
2158
+ const outPath = join4(tmpdir(), `aicli-paste-${Date.now()}.png`);
2159
+ try {
2160
+ if (process.platform === "win32") {
2161
+ _readWin32(outPath);
2162
+ } else if (process.platform === "darwin") {
2163
+ _readDarwin(outPath);
2164
+ } else {
2165
+ _readLinux(outPath);
2166
+ }
2167
+ } catch {
2168
+ _cleanup(outPath);
2169
+ return null;
2170
+ }
2171
+ if (!existsSync5(outPath) || statSync(outPath).size === 0) {
2172
+ _cleanup(outPath);
2173
+ return null;
2174
+ }
2175
+ return outPath;
2176
+ }
2177
+ function _readWin32(outPath) {
2178
+ const escapedPath = outPath.replace(/\\/g, "\\\\");
2179
+ const script = [
2180
+ "Add-Type -AssemblyName System.Windows.Forms",
2181
+ "Add-Type -AssemblyName System.Drawing",
2182
+ "$img = [System.Windows.Forms.Clipboard]::GetImage()",
2183
+ `if ($null -ne $img) { $img.Save('${escapedPath}', [System.Drawing.Imaging.ImageFormat]::Png); exit 0 } else { exit 1 }`
2184
+ ].join("; ");
2185
+ execSync2(`powershell -NoProfile -NonInteractive -Command "${script}"`, {
2186
+ stdio: "pipe",
2187
+ timeout: CLIPBOARD_TIMEOUT
2188
+ });
2189
+ }
2190
+ function _readDarwin(outPath) {
2191
+ try {
2192
+ execSync2(`pngpaste "${outPath}"`, { stdio: "pipe", timeout: CLIPBOARD_TIMEOUT });
2193
+ return;
2194
+ } catch {
2195
+ }
2196
+ const script = [
2197
+ "try",
2198
+ " set d to (the clipboard as \xABclass PNGf\xBB)",
2199
+ ` set fp to open for access POSIX file "${outPath}" with write permission`,
2200
+ " write d to fp",
2201
+ " close access fp",
2202
+ "end try"
2203
+ ].join("\n");
2204
+ execSync2(`osascript -e '${script}'`, { stdio: "pipe", timeout: CLIPBOARD_TIMEOUT });
2205
+ }
2206
+ function _readLinux(outPath) {
2207
+ try {
2208
+ execSync2(`xclip -selection clipboard -t image/png -o > "${outPath}"`, {
2209
+ shell: "/bin/sh",
2210
+ stdio: "pipe",
2211
+ timeout: CLIPBOARD_TIMEOUT
2212
+ });
2213
+ return;
2214
+ } catch {
2215
+ }
2216
+ execSync2(`wl-paste --type image/png > "${outPath}"`, {
2217
+ shell: "/bin/sh",
2218
+ stdio: "pipe",
2219
+ timeout: CLIPBOARD_TIMEOUT
2220
+ });
2221
+ }
2222
+ function _cleanup(path) {
2223
+ try {
2224
+ if (existsSync5(path)) unlinkSync2(path);
2225
+ } catch {
2226
+ }
2227
+ }
2228
+ function getClipboardHint() {
2229
+ if (process.platform === "darwin") {
2230
+ return "\u5982 pngpaste \u672A\u5B89\u88C5\uFF0C\u8BF7\u8FD0\u884C\uFF1Abrew install pngpaste";
2231
+ }
2232
+ if (process.platform === "linux") {
2233
+ return "\u8BF7\u5B89\u88C5 xclip\uFF08X11\uFF09\uFF1Asudo apt install xclip\n\u6216 wl-paste\uFF08Wayland\uFF09\uFF1Asudo apt install wl-clipboard";
2234
+ }
2235
+ return "";
2236
+ }
2237
+
2132
2238
  // src/repl/commands/index.ts
2133
2239
  function fmtCtx(tokens) {
2134
2240
  if (tokens >= 1e6) return `${Math.round(tokens / 1e5) / 10}M`;
@@ -2176,19 +2282,19 @@ function scanDirTree(dir, maxDepth = 2, maxEntries = 80) {
2176
2282
  }
2177
2283
  const filtered = entries.filter((e) => !e.startsWith(".") && !SCAN_SKIP_DIRS.has(e));
2178
2284
  const sorted = filtered.sort((a, b) => {
2179
- const aIsDir = statSync(join4(d, a)).isDirectory();
2180
- const bIsDir = statSync(join4(d, b)).isDirectory();
2285
+ const aIsDir = statSync2(join5(d, a)).isDirectory();
2286
+ const bIsDir = statSync2(join5(d, b)).isDirectory();
2181
2287
  if (aIsDir !== bIsDir) return aIsDir ? -1 : 1;
2182
2288
  return a.localeCompare(b);
2183
2289
  });
2184
2290
  for (let i = 0; i < sorted.length && count < maxEntries; i++) {
2185
2291
  const name = sorted[i];
2186
- const fullPath = join4(d, name);
2292
+ const fullPath = join5(d, name);
2187
2293
  const isLast = i === sorted.length - 1;
2188
2294
  const connector = isLast ? "\u2514\u2500\u2500 " : "\u251C\u2500\u2500 ";
2189
2295
  let isDir;
2190
2296
  try {
2191
- isDir = statSync(fullPath).isDirectory();
2297
+ isDir = statSync2(fullPath).isDirectory();
2192
2298
  } catch {
2193
2299
  continue;
2194
2300
  }
@@ -2210,7 +2316,7 @@ function scanProject(cwd) {
2210
2316
  configFiles: [],
2211
2317
  directoryStructure: ""
2212
2318
  };
2213
- const check = (file) => existsSync5(join4(cwd, file));
2319
+ const check = (file) => existsSync6(join5(cwd, file));
2214
2320
  const configCandidates = [
2215
2321
  "package.json",
2216
2322
  "tsconfig.json",
@@ -2237,7 +2343,7 @@ function scanProject(cwd) {
2237
2343
  info.type = "node";
2238
2344
  info.language = check("tsconfig.json") ? "TypeScript" : "JavaScript";
2239
2345
  try {
2240
- const pkg = JSON.parse(readFileSync4(join4(cwd, "package.json"), "utf-8"));
2346
+ const pkg = JSON.parse(readFileSync4(join5(cwd, "package.json"), "utf-8"));
2241
2347
  const scripts = pkg.scripts ?? {};
2242
2348
  info.buildCommand = scripts.build ? `npm run build` : void 0;
2243
2349
  info.testCommand = scripts.test ? `npm test` : void 0;
@@ -2318,14 +2424,14 @@ ${info.directoryStructure}
2318
2424
  function copyToClipboard(text) {
2319
2425
  const plat = platform();
2320
2426
  if (plat === "win32") {
2321
- execSync2("clip", { input: text, stdio: ["pipe", "ignore", "ignore"] });
2427
+ execSync3("clip", { input: text, stdio: ["pipe", "ignore", "ignore"] });
2322
2428
  } else if (plat === "darwin") {
2323
- execSync2("pbcopy", { input: text, stdio: ["pipe", "ignore", "ignore"] });
2429
+ execSync3("pbcopy", { input: text, stdio: ["pipe", "ignore", "ignore"] });
2324
2430
  } else {
2325
2431
  try {
2326
- execSync2("xclip -selection clipboard", { input: text, stdio: ["pipe", "ignore", "ignore"] });
2432
+ execSync3("xclip -selection clipboard", { input: text, stdio: ["pipe", "ignore", "ignore"] });
2327
2433
  } catch {
2328
- execSync2("xsel --clipboard --input", { input: text, stdio: ["pipe", "ignore", "ignore"] });
2434
+ execSync3("xsel --clipboard --input", { input: text, stdio: ["pipe", "ignore", "ignore"] });
2329
2435
  }
2330
2436
  }
2331
2437
  }
@@ -3026,9 +3132,9 @@ ${text}
3026
3132
  const cwd = process.cwd();
3027
3133
  const gitRoot = getGitRoot(cwd);
3028
3134
  const targetDir = gitRoot ?? cwd;
3029
- const targetPath = join4(targetDir, "AICLI.md");
3135
+ const targetPath = join5(targetDir, "AICLI.md");
3030
3136
  const force = args.includes("--force");
3031
- if (existsSync5(targetPath) && !force) {
3137
+ if (existsSync6(targetPath) && !force) {
3032
3138
  ctx.renderer.printInfo(`AICLI.md already exists at ${targetPath}`);
3033
3139
  ctx.renderer.printInfo("Use /init --force to overwrite, or edit it manually.");
3034
3140
  return;
@@ -3075,6 +3181,25 @@ ${text}
3075
3181
  }
3076
3182
  }
3077
3183
  },
3184
+ {
3185
+ name: "paste",
3186
+ description: "Read image from clipboard and send with optional description",
3187
+ usage: "/paste [description]",
3188
+ async execute(args, ctx) {
3189
+ const imgPath = readClipboardImage();
3190
+ if (!imgPath) {
3191
+ const hint = getClipboardHint();
3192
+ ctx.renderer.renderError(
3193
+ "\u526A\u8D34\u677F\u4E2D\u6CA1\u6709\u56FE\u7247\u3002\u8BF7\u5148\u5728\u5176\u4ED6\u7A0B\u5E8F\u4E2D\u590D\u5236\u4E00\u5F20\u56FE\u7247\uFF0C\u518D\u8FD0\u884C /paste\u3002" + (hint ? `
3194
+ ${hint}` : "")
3195
+ );
3196
+ return;
3197
+ }
3198
+ const description = args.join(" ").trim() || "\u8BF7\u63CF\u8FF0\u8FD9\u5F20\u56FE\u7247";
3199
+ ctx.renderer.printSuccess(`\u{1F4CB} \u56FE\u7247\u5DF2\u8BFB\u53D6\uFF1A${basename(imgPath)}`);
3200
+ await ctx.sendAsChat(`@${imgPath} ${description}`);
3201
+ }
3202
+ },
3078
3203
  {
3079
3204
  name: "cost",
3080
3205
  description: "Show session token usage summary",
@@ -3189,7 +3314,7 @@ ${text}
3189
3314
  let diff;
3190
3315
  try {
3191
3316
  const cmd = staged ? "git diff --staged" : "git diff";
3192
- diff = execSync2(cmd, { encoding: "utf-8", timeout: 1e4 }).trim();
3317
+ diff = execSync3(cmd, { encoding: "utf-8", timeout: 1e4 }).trim();
3193
3318
  } catch {
3194
3319
  ctx.renderer.renderError("Failed to run git diff.");
3195
3320
  return;
@@ -3256,7 +3381,7 @@ ${text}
3256
3381
  description: "Run project tests and show structured report",
3257
3382
  usage: "/test [command|filter]",
3258
3383
  async execute(args, _ctx) {
3259
- const { executeTests } = await import("./run-tests-MKMB3TXE.js");
3384
+ const { executeTests } = await import("./run-tests-VVR5SMST.js");
3260
3385
  const argStr = args.join(" ").trim();
3261
3386
  let testArgs = {};
3262
3387
  if (argStr) {
@@ -3479,8 +3604,8 @@ function selectFromList(prompt, items, initialIndex = 0) {
3479
3604
  }
3480
3605
 
3481
3606
  // src/tools/builtin/bash.ts
3482
- import { execSync as execSync3 } from "child_process";
3483
- import { existsSync as existsSync6 } from "fs";
3607
+ import { execSync as execSync4 } from "child_process";
3608
+ import { existsSync as existsSync7 } from "fs";
3484
3609
  import { platform as platform2 } from "os";
3485
3610
  import { resolve as resolve2 } from "path";
3486
3611
  var IS_WINDOWS = platform2() === "win32";
@@ -3529,7 +3654,7 @@ var bashTool = {
3529
3654
  let effectiveCwd = persistentCwd;
3530
3655
  if (cwdArg) {
3531
3656
  const resolved = resolve2(persistentCwd, cwdArg);
3532
- if (!existsSync6(resolved)) {
3657
+ if (!existsSync7(resolved)) {
3533
3658
  throw new Error(
3534
3659
  `cwd directory does not exist: "${resolved}". Create it first (e.g. mkdir -p "${resolved}") before specifying it as cwd.`
3535
3660
  );
@@ -3545,7 +3670,7 @@ var bashTool = {
3545
3670
  actualCommand = command;
3546
3671
  }
3547
3672
  try {
3548
- const output = execSync3(actualCommand, {
3673
+ const output = execSync4(actualCommand, {
3549
3674
  timeout,
3550
3675
  encoding: IS_WINDOWS ? "buffer" : "utf-8",
3551
3676
  stdio: ["pipe", "pipe", "pipe"],
@@ -3601,7 +3726,7 @@ function updateCwdFromCommand(command, baseCwd) {
3601
3726
  if (!target || target.startsWith("$") || target === "~") return;
3602
3727
  try {
3603
3728
  const newDir = resolve2(baseCwd, target);
3604
- if (existsSync6(newDir)) {
3729
+ if (existsSync7(newDir)) {
3605
3730
  persistentCwd = newDir;
3606
3731
  }
3607
3732
  } catch {
@@ -3609,7 +3734,7 @@ function updateCwdFromCommand(command, baseCwd) {
3609
3734
  }
3610
3735
 
3611
3736
  // src/tools/builtin/read-file.ts
3612
- import { readFileSync as readFileSync5, existsSync as existsSync7, statSync as statSync2 } from "fs";
3737
+ import { readFileSync as readFileSync5, existsSync as existsSync8, statSync as statSync3 } from "fs";
3613
3738
  import { extname, resolve as resolve3, basename as basename2, sep } from "path";
3614
3739
  import { homedir as homedir2 } from "os";
3615
3740
  var MAX_FILE_BYTES = 10 * 1024 * 1024;
@@ -3702,8 +3827,8 @@ var readFileTool = {
3702
3827
  const encoding = args["encoding"] ?? "utf-8";
3703
3828
  if (!filePath) throw new Error("path is required");
3704
3829
  const normalizedPath = resolve3(filePath);
3705
- if (!existsSync7(normalizedPath)) throw new Error(`File not found: ${filePath}`);
3706
- const { size } = statSync2(normalizedPath);
3830
+ if (!existsSync8(normalizedPath)) throw new Error(`File not found: ${filePath}`);
3831
+ const { size } = statSync3(normalizedPath);
3707
3832
  if (size > MAX_FILE_BYTES) {
3708
3833
  const mb = (size / 1024 / 1024).toFixed(1);
3709
3834
  return `[File too large: ${filePath} (${mb} MB)]
@@ -3797,7 +3922,7 @@ var writeFileTool = {
3797
3922
  };
3798
3923
 
3799
3924
  // src/tools/builtin/edit-file.ts
3800
- import { readFileSync as readFileSync6, writeFileSync as writeFileSync6, existsSync as existsSync8 } from "fs";
3925
+ import { readFileSync as readFileSync6, writeFileSync as writeFileSync6, existsSync as existsSync9 } from "fs";
3801
3926
  var editFileTool = {
3802
3927
  definition: {
3803
3928
  name: "edit_file",
@@ -3855,7 +3980,7 @@ var editFileTool = {
3855
3980
  const filePath = String(args["path"] ?? "");
3856
3981
  const encoding = args["encoding"] ?? "utf-8";
3857
3982
  if (!filePath) throw new Error("path is required");
3858
- if (!existsSync8(filePath)) throw new Error(`File not found: ${filePath}`);
3983
+ if (!existsSync9(filePath)) throw new Error(`File not found: ${filePath}`);
3859
3984
  const original = readFileSync6(filePath, encoding);
3860
3985
  if (args["old_str"] !== void 0) {
3861
3986
  const oldStr = String(args["old_str"]);
@@ -3921,8 +4046,8 @@ function truncatePreview(str, maxLen = 80) {
3921
4046
  }
3922
4047
 
3923
4048
  // src/tools/builtin/list-dir.ts
3924
- import { readdirSync as readdirSync3, statSync as statSync3, existsSync as existsSync9 } from "fs";
3925
- import { join as join5 } from "path";
4049
+ import { readdirSync as readdirSync3, statSync as statSync4, existsSync as existsSync10 } from "fs";
4050
+ import { join as join6 } from "path";
3926
4051
  var listDirTool = {
3927
4052
  definition: {
3928
4053
  name: "list_dir",
@@ -3944,7 +4069,7 @@ var listDirTool = {
3944
4069
  async execute(args) {
3945
4070
  const dirPath = String(args["path"] ?? process.cwd());
3946
4071
  const recursive = Boolean(args["recursive"] ?? false);
3947
- if (!existsSync9(dirPath)) throw new Error(`Directory not found: ${dirPath}`);
4072
+ if (!existsSync10(dirPath)) throw new Error(`Directory not found: ${dirPath}`);
3948
4073
  const lines = [`Directory: ${dirPath}
3949
4074
  `];
3950
4075
  listRecursive(dirPath, "", recursive, lines);
@@ -3973,11 +4098,11 @@ function listRecursive(basePath, indent, recursive, lines) {
3973
4098
  if (entry.isDirectory()) {
3974
4099
  lines.push(`${indent}\u{1F4C1} ${entry.name}/`);
3975
4100
  if (recursive) {
3976
- listRecursive(join5(basePath, entry.name), indent + " ", true, lines);
4101
+ listRecursive(join6(basePath, entry.name), indent + " ", true, lines);
3977
4102
  }
3978
4103
  } else {
3979
4104
  try {
3980
- const stat = statSync3(join5(basePath, entry.name));
4105
+ const stat = statSync4(join6(basePath, entry.name));
3981
4106
  const size = formatSize(stat.size);
3982
4107
  lines.push(`${indent}\u{1F4C4} ${entry.name} (${size})`);
3983
4108
  } catch {
@@ -3993,8 +4118,8 @@ function formatSize(bytes) {
3993
4118
  }
3994
4119
 
3995
4120
  // src/tools/builtin/grep-files.ts
3996
- import { readdirSync as readdirSync4, readFileSync as readFileSync7, statSync as statSync4, existsSync as existsSync10 } from "fs";
3997
- import { join as join6, relative } from "path";
4121
+ import { readdirSync as readdirSync4, readFileSync as readFileSync7, statSync as statSync5, existsSync as existsSync11 } from "fs";
4122
+ import { join as join7, relative } from "path";
3998
4123
  var grepFilesTool = {
3999
4124
  definition: {
4000
4125
  name: "grep_files",
@@ -4045,7 +4170,7 @@ var grepFilesTool = {
4045
4170
  const contextLines = Math.max(0, Number(args["context_lines"] ?? 0));
4046
4171
  const maxResults = Math.max(1, Number(args["max_results"] ?? 50));
4047
4172
  if (!pattern) throw new Error("pattern is required");
4048
- if (!existsSync10(rootPath)) throw new Error(`Path not found: ${rootPath}`);
4173
+ if (!existsSync11(rootPath)) throw new Error(`Path not found: ${rootPath}`);
4049
4174
  let regex;
4050
4175
  try {
4051
4176
  regex = new RegExp(pattern, ignoreCase ? "gi" : "g");
@@ -4054,7 +4179,7 @@ var grepFilesTool = {
4054
4179
  regex = new RegExp(escaped, ignoreCase ? "gi" : "g");
4055
4180
  }
4056
4181
  const results = [];
4057
- const stat = statSync4(rootPath);
4182
+ const stat = statSync5(rootPath);
4058
4183
  if (stat.isFile()) {
4059
4184
  searchInFile(rootPath, rootPath, regex, contextLines, maxResults, results);
4060
4185
  } else {
@@ -4118,11 +4243,11 @@ function collectFiles(dirPath, filePattern, results, regex, contextLines, maxRes
4118
4243
  if (results.length >= maxResults) return;
4119
4244
  if (entry.isDirectory()) {
4120
4245
  if (SKIP_DIRS.has(entry.name) || entry.name.startsWith(".")) continue;
4121
- collectFiles(join6(dirPath, entry.name), filePattern, results, regex, contextLines, maxResults, rootPath);
4246
+ collectFiles(join7(dirPath, entry.name), filePattern, results, regex, contextLines, maxResults, rootPath);
4122
4247
  } else if (entry.isFile()) {
4123
4248
  if (isBinary(entry.name)) continue;
4124
4249
  if (filePattern && !matchesFilePattern(entry.name, filePattern)) continue;
4125
- const fullPath = join6(dirPath, entry.name);
4250
+ const fullPath = join7(dirPath, entry.name);
4126
4251
  const relPath = relative(rootPath, fullPath);
4127
4252
  searchInFile(fullPath, relPath, regex, contextLines, maxResults, results);
4128
4253
  }
@@ -4163,8 +4288,8 @@ function searchInFile(fullPath, displayPath, regex, contextLines, maxResults, re
4163
4288
  }
4164
4289
 
4165
4290
  // src/tools/builtin/glob-files.ts
4166
- import { readdirSync as readdirSync5, statSync as statSync5, existsSync as existsSync11 } from "fs";
4167
- import { join as join7, relative as relative2, basename as basename3 } from "path";
4291
+ import { readdirSync as readdirSync5, statSync as statSync6, existsSync as existsSync12 } from "fs";
4292
+ import { join as join8, relative as relative2, basename as basename3 } from "path";
4168
4293
  var globFilesTool = {
4169
4294
  definition: {
4170
4295
  name: "glob_files",
@@ -4197,7 +4322,7 @@ var globFilesTool = {
4197
4322
  const rootPath = String(args["path"] ?? process.cwd());
4198
4323
  const maxResults = Math.max(1, Number(args["max_results"] ?? 100));
4199
4324
  if (!pattern) throw new Error("pattern is required");
4200
- if (!existsSync11(rootPath)) throw new Error(`Path not found: ${rootPath}`);
4325
+ if (!existsSync12(rootPath)) throw new Error(`Path not found: ${rootPath}`);
4201
4326
  const regex = globToRegex(pattern);
4202
4327
  const matches = [];
4203
4328
  collectMatchingFiles(rootPath, rootPath, regex, matches, maxResults);
@@ -4274,7 +4399,7 @@ function collectMatchingFiles(dirPath, rootPath, regex, results, maxResults) {
4274
4399
  }
4275
4400
  for (const entry of entries) {
4276
4401
  if (results.length >= maxResults) break;
4277
- const fullPath = join7(dirPath, entry.name);
4402
+ const fullPath = join8(dirPath, entry.name);
4278
4403
  if (entry.isDirectory()) {
4279
4404
  if (SKIP_DIRS2.has(entry.name) || entry.name.startsWith(".")) continue;
4280
4405
  collectMatchingFiles(fullPath, rootPath, regex, results, maxResults);
@@ -4282,7 +4407,7 @@ function collectMatchingFiles(dirPath, rootPath, regex, results, maxResults) {
4282
4407
  const relPath = relative2(rootPath, fullPath).replace(/\\/g, "/");
4283
4408
  if (regex.test(relPath) || regex.test(basename3(relPath))) {
4284
4409
  try {
4285
- const stat = statSync5(fullPath);
4410
+ const stat = statSync6(fullPath);
4286
4411
  results.push({ relPath, absPath: fullPath, mtime: stat.mtimeMs });
4287
4412
  } catch {
4288
4413
  results.push({ relPath, absPath: fullPath, mtime: 0 });
@@ -4666,11 +4791,11 @@ var saveLastResponseTool = {
4666
4791
  };
4667
4792
 
4668
4793
  // src/tools/builtin/save-memory.ts
4669
- import { existsSync as existsSync12, readFileSync as readFileSync8, appendFileSync as appendFileSync2, mkdirSync as mkdirSync7 } from "fs";
4670
- import { join as join8 } from "path";
4794
+ import { existsSync as existsSync13, readFileSync as readFileSync8, appendFileSync as appendFileSync2, mkdirSync as mkdirSync7 } from "fs";
4795
+ import { join as join9 } from "path";
4671
4796
  import { homedir as homedir3 } from "os";
4672
4797
  function getMemoryFilePath() {
4673
- return join8(homedir3(), CONFIG_DIR_NAME, MEMORY_FILE_NAME);
4798
+ return join9(homedir3(), CONFIG_DIR_NAME, MEMORY_FILE_NAME);
4674
4799
  }
4675
4800
  function formatTimestamp() {
4676
4801
  const now = /* @__PURE__ */ new Date();
@@ -4694,8 +4819,8 @@ var saveMemoryTool = {
4694
4819
  const content = String(args["content"] ?? "").trim();
4695
4820
  if (!content) throw new Error("content is required");
4696
4821
  const memoryPath = getMemoryFilePath();
4697
- const configDir = join8(homedir3(), CONFIG_DIR_NAME);
4698
- if (!existsSync12(configDir)) {
4822
+ const configDir = join9(homedir3(), CONFIG_DIR_NAME);
4823
+ if (!existsSync13(configDir)) {
4699
4824
  mkdirSync7(configDir, { recursive: true });
4700
4825
  }
4701
4826
  const timestamp = formatTimestamp();
@@ -5285,8 +5410,8 @@ var spawnAgentTool = {
5285
5410
 
5286
5411
  // src/tools/registry.ts
5287
5412
  import { pathToFileURL } from "url";
5288
- import { existsSync as existsSync13, mkdirSync as mkdirSync8, readdirSync as readdirSync6 } from "fs";
5289
- import { join as join9 } from "path";
5413
+ import { existsSync as existsSync14, mkdirSync as mkdirSync8, readdirSync as readdirSync6 } from "fs";
5414
+ import { join as join10 } from "path";
5290
5415
  var ToolRegistry = class {
5291
5416
  tools = /* @__PURE__ */ new Map();
5292
5417
  pluginToolNames = /* @__PURE__ */ new Set();
@@ -5359,7 +5484,7 @@ var ToolRegistry = class {
5359
5484
  * Returns the number of successfully loaded plugins.
5360
5485
  */
5361
5486
  async loadPlugins(pluginsDir, allowPlugins = false) {
5362
- if (!existsSync13(pluginsDir)) {
5487
+ if (!existsSync14(pluginsDir)) {
5363
5488
  try {
5364
5489
  mkdirSync8(pluginsDir, { recursive: true });
5365
5490
  } catch {
@@ -5385,12 +5510,12 @@ var ToolRegistry = class {
5385
5510
  process.stderr.write(
5386
5511
  `
5387
5512
  [plugins] \u26A0 Loading ${files.length} plugin(s) with FULL system privileges:
5388
- ` + files.map((f) => ` + ${join9(pluginsDir, f)}`).join("\n") + "\n\n"
5513
+ ` + files.map((f) => ` + ${join10(pluginsDir, f)}`).join("\n") + "\n\n"
5389
5514
  );
5390
5515
  let loaded = 0;
5391
5516
  for (const file of files) {
5392
5517
  try {
5393
- const fileUrl = pathToFileURL(join9(pluginsDir, file)).href;
5518
+ const fileUrl = pathToFileURL(join10(pluginsDir, file)).href;
5394
5519
  const mod = await import(fileUrl);
5395
5520
  const tool = mod.tool ?? mod.default?.tool ?? mod.default;
5396
5521
  if (!tool || typeof tool.execute !== "function" || !tool.definition?.name) {
@@ -5419,7 +5544,7 @@ var ToolRegistry = class {
5419
5544
 
5420
5545
  // src/tools/executor.ts
5421
5546
  import chalk8 from "chalk";
5422
- import { existsSync as existsSync14, readFileSync as readFileSync9 } from "fs";
5547
+ import { existsSync as existsSync15, readFileSync as readFileSync9 } from "fs";
5423
5548
 
5424
5549
  // src/tools/diff-utils.ts
5425
5550
  import chalk7 from "chalk";
@@ -5575,7 +5700,7 @@ function simpleDiff(oldLines, newLines) {
5575
5700
  }
5576
5701
 
5577
5702
  // src/tools/hooks.ts
5578
- import { execSync as execSync4 } from "child_process";
5703
+ import { execSync as execSync5 } from "child_process";
5579
5704
  function runHook(template, vars) {
5580
5705
  if (!template) return;
5581
5706
  let cmd = template;
@@ -5584,7 +5709,7 @@ function runHook(template, vars) {
5584
5709
  cmd = cmd.replace(/\{args\}/g, vars.args ?? "");
5585
5710
  cmd = cmd.replace(/\{status\}/g, vars.status ?? "");
5586
5711
  try {
5587
- execSync4(cmd, {
5712
+ execSync5(cmd, {
5588
5713
  timeout: 5e3,
5589
5714
  stdio: ["pipe", "pipe", "pipe"],
5590
5715
  encoding: "utf-8"
@@ -5909,7 +6034,7 @@ var ToolExecutor = class {
5909
6034
  const filePath = String(call.arguments["path"] ?? "");
5910
6035
  const newContent = String(call.arguments["content"] ?? "");
5911
6036
  if (!filePath) return;
5912
- if (existsSync14(filePath)) {
6037
+ if (existsSync15(filePath)) {
5913
6038
  let oldContent;
5914
6039
  try {
5915
6040
  oldContent = readFileSync9(filePath, "utf-8");
@@ -5935,7 +6060,7 @@ var ToolExecutor = class {
5935
6060
  }
5936
6061
  } else if (call.name === "edit_file") {
5937
6062
  const filePath = String(call.arguments["path"] ?? "");
5938
- if (!filePath || !existsSync14(filePath)) return;
6063
+ if (!filePath || !existsSync15(filePath)) return;
5939
6064
  const oldStr = call.arguments["old_str"];
5940
6065
  const newStr = call.arguments["new_str"];
5941
6066
  if (oldStr !== void 0) {
@@ -6275,9 +6400,9 @@ Managing ${displayName} API Key`);
6275
6400
  };
6276
6401
 
6277
6402
  // src/repl/custom-commands.ts
6278
- import { existsSync as existsSync15, readFileSync as readFileSync10, readdirSync as readdirSync7, mkdirSync as mkdirSync9 } from "fs";
6279
- import { join as join10, extname as extname3 } from "path";
6280
- import { execSync as execSync5 } from "child_process";
6403
+ import { existsSync as existsSync16, readFileSync as readFileSync10, readdirSync as readdirSync7, mkdirSync as mkdirSync9 } from "fs";
6404
+ import { join as join11, extname as extname3 } from "path";
6405
+ import { execSync as execSync6 } from "child_process";
6281
6406
  function parseSimpleYaml(text) {
6282
6407
  const result = {};
6283
6408
  for (const line of text.split("\n")) {
@@ -6322,7 +6447,7 @@ function expandTemplate(template, args) {
6322
6447
  });
6323
6448
  result = result.replace(/\{\{git-diff\}\}/g, () => {
6324
6449
  try {
6325
- return execSync5("git diff", { encoding: "utf-8", timeout: 1e4 }).trim();
6450
+ return execSync6("git diff", { encoding: "utf-8", timeout: 1e4 }).trim();
6326
6451
  } catch {
6327
6452
  return "[No git diff available]";
6328
6453
  }
@@ -6340,14 +6465,14 @@ var CustomCommandManager = class {
6340
6465
  commands = /* @__PURE__ */ new Map();
6341
6466
  loadCommands() {
6342
6467
  this.commands.clear();
6343
- if (!existsSync15(this.commandsDir)) {
6468
+ if (!existsSync16(this.commandsDir)) {
6344
6469
  mkdirSync9(this.commandsDir, { recursive: true });
6345
6470
  return 0;
6346
6471
  }
6347
6472
  let count = 0;
6348
6473
  for (const file of readdirSync7(this.commandsDir)) {
6349
6474
  if (extname3(file) !== ".md") continue;
6350
- const cmd = parseCommandFile(join10(this.commandsDir, file));
6475
+ const cmd = parseCommandFile(join11(this.commandsDir, file));
6351
6476
  if (cmd) {
6352
6477
  this.commands.set(cmd.meta.name, cmd);
6353
6478
  count++;
@@ -6364,8 +6489,8 @@ var CustomCommandManager = class {
6364
6489
  };
6365
6490
 
6366
6491
  // src/repl/dev-state.ts
6367
- import { existsSync as existsSync16, readFileSync as readFileSync11, writeFileSync as writeFileSync8, unlinkSync as unlinkSync2, mkdirSync as mkdirSync10 } from "fs";
6368
- import { join as join11 } from "path";
6492
+ import { existsSync as existsSync17, readFileSync as readFileSync11, writeFileSync as writeFileSync8, unlinkSync as unlinkSync3, mkdirSync as mkdirSync10 } from "fs";
6493
+ import { join as join12 } from "path";
6369
6494
  import { homedir as homedir4 } from "os";
6370
6495
  var DEV_STATE_MAX_CHARS = 4e3;
6371
6496
  var SNAPSHOT_PROMPT = `You are about to be replaced by a different AI model. Please generate a structured development state snapshot so the next model can continue seamlessly.
@@ -6404,11 +6529,11 @@ function sessionHasMeaningfulContent(messages) {
6404
6529
  return hasUser && hasAssistant;
6405
6530
  }
6406
6531
  function getDevStatePath() {
6407
- return join11(homedir4(), CONFIG_DIR_NAME, DEV_STATE_FILE_NAME);
6532
+ return join12(homedir4(), CONFIG_DIR_NAME, DEV_STATE_FILE_NAME);
6408
6533
  }
6409
6534
  function saveDevState(content) {
6410
- const configDir = join11(homedir4(), CONFIG_DIR_NAME);
6411
- if (!existsSync16(configDir)) {
6535
+ const configDir = join12(homedir4(), CONFIG_DIR_NAME);
6536
+ if (!existsSync17(configDir)) {
6412
6537
  mkdirSync10(configDir, { recursive: true });
6413
6538
  }
6414
6539
  let trimmed = content.trim();
@@ -6424,15 +6549,15 @@ function saveDevState(content) {
6424
6549
  }
6425
6550
  function loadDevState() {
6426
6551
  const path = getDevStatePath();
6427
- if (!existsSync16(path)) return null;
6552
+ if (!existsSync17(path)) return null;
6428
6553
  const content = readFileSync11(path, "utf-8").trim();
6429
6554
  return content || null;
6430
6555
  }
6431
6556
  function clearDevState() {
6432
6557
  const path = getDevStatePath();
6433
- if (existsSync16(path)) {
6558
+ if (existsSync17(path)) {
6434
6559
  try {
6435
- unlinkSync2(path);
6560
+ unlinkSync3(path);
6436
6561
  } catch {
6437
6562
  }
6438
6563
  }
@@ -6851,8 +6976,8 @@ var McpManager = class {
6851
6976
  };
6852
6977
 
6853
6978
  // src/skills/manager.ts
6854
- import { existsSync as existsSync17, readdirSync as readdirSync8, mkdirSync as mkdirSync11 } from "fs";
6855
- import { join as join12 } from "path";
6979
+ import { existsSync as existsSync18, readdirSync as readdirSync8, mkdirSync as mkdirSync11 } from "fs";
6980
+ import { join as join13 } from "path";
6856
6981
 
6857
6982
  // src/skills/types.ts
6858
6983
  import { readFileSync as readFileSync12 } from "fs";
@@ -6917,7 +7042,7 @@ var SkillManager = class {
6917
7042
  /** 发现并加载 skillsDir 下所有 .md 文件,返回加载数量 */
6918
7043
  loadSkills() {
6919
7044
  this.skills.clear();
6920
- if (!existsSync17(this.skillsDir)) {
7045
+ if (!existsSync18(this.skillsDir)) {
6921
7046
  try {
6922
7047
  mkdirSync11(this.skillsDir, { recursive: true });
6923
7048
  } catch {
@@ -6932,7 +7057,7 @@ var SkillManager = class {
6932
7057
  }
6933
7058
  for (const entry of entries) {
6934
7059
  if (!entry.endsWith(".md")) continue;
6935
- const filePath = join12(this.skillsDir, entry);
7060
+ const filePath = join13(this.skillsDir, entry);
6936
7061
  const skill = parseSkillFile(filePath);
6937
7062
  if (skill) {
6938
7063
  this.skills.set(skill.meta.name, skill);
@@ -7000,12 +7125,12 @@ function parseAtReferences(input2, cwd) {
7000
7125
  const absPath = resolve4(cwd, rawPath);
7001
7126
  const ext = extname4(rawPath).toLowerCase();
7002
7127
  const mime = IMAGE_MIME[ext];
7003
- if (!existsSync18(absPath)) {
7128
+ if (!existsSync19(absPath)) {
7004
7129
  refs.push({ path: rawPath, type: "notfound" });
7005
7130
  continue;
7006
7131
  }
7007
7132
  if (mime) {
7008
- const fileSize = statSync6(absPath).size;
7133
+ const fileSize = statSync7(absPath).size;
7009
7134
  if (fileSize > MAX_IMAGE_BYTES) {
7010
7135
  refs.push({ path: rawPath, type: "toolarge" });
7011
7136
  continue;
@@ -7102,6 +7227,10 @@ var Repl = class {
7102
7227
  /** 技能管理器 */
7103
7228
  skillManager = null;
7104
7229
  customCommandManager = null;
7230
+ /** 流式生成中断控制器(ESC/Ctrl+C 中断时使用) */
7231
+ streamAbortController = null;
7232
+ /** ESC 键监听器引用(用于 removeListener 时取消注册) */
7233
+ _escHandler = null;
7105
7234
  /**
7106
7235
  * 交互式列表选择器进行中标志。
7107
7236
  * 与 toolExecutor.confirming 类似:主循环 line handler 在此为 true 时忽略 line 事件,
@@ -7114,8 +7243,8 @@ var Repl = class {
7114
7243
  */
7115
7244
  findContextFile(dir, candidates = CONTEXT_FILE_CANDIDATES) {
7116
7245
  for (const candidate of candidates) {
7117
- const fullPath = join13(dir, candidate);
7118
- if (existsSync18(fullPath)) {
7246
+ const fullPath = join14(dir, candidate);
7247
+ if (existsSync19(fullPath)) {
7119
7248
  const content = readFileSync13(fullPath, "utf-8").trim();
7120
7249
  if (content) return { filePath: fullPath, content };
7121
7250
  }
@@ -7148,7 +7277,7 @@ var Repl = class {
7148
7277
  );
7149
7278
  return { layers: [], mergedContent: "" };
7150
7279
  }
7151
- if (existsSync18(fullPath)) {
7280
+ if (existsSync19(fullPath)) {
7152
7281
  const content = readFileSync13(fullPath, "utf-8").trim();
7153
7282
  if (content) {
7154
7283
  const layer = {
@@ -7206,8 +7335,8 @@ var Repl = class {
7206
7335
  * 超过 MEMORY_MAX_CHARS 时只取末尾最新部分。
7207
7336
  */
7208
7337
  loadMemoryContent() {
7209
- const memoryPath = join13(this.config.getConfigDir(), MEMORY_FILE_NAME);
7210
- if (!existsSync18(memoryPath)) return null;
7338
+ const memoryPath = join14(this.config.getConfigDir(), MEMORY_FILE_NAME);
7339
+ if (!existsSync19(memoryPath)) return null;
7211
7340
  let content = readFileSync13(memoryPath, "utf-8").trim();
7212
7341
  if (!content) return null;
7213
7342
  if (content.length > MEMORY_MAX_CHARS) {
@@ -7484,14 +7613,14 @@ ${response.content.trim()}
7484
7613
  process.stdout.write(chalk10.dim(` \u{1F50C} Plugins loaded: ${pluginCount} tool(s) from plugins/
7485
7614
  `));
7486
7615
  }
7487
- const skillsDir = join13(this.config.getConfigDir(), SKILLS_DIR_NAME);
7616
+ const skillsDir = join14(this.config.getConfigDir(), SKILLS_DIR_NAME);
7488
7617
  this.skillManager = new SkillManager(skillsDir);
7489
7618
  const skillCount = this.skillManager.loadSkills();
7490
7619
  if (skillCount > 0) {
7491
7620
  process.stdout.write(chalk10.dim(` \u{1F3AF} Skills: ${skillCount} available (use /skill to manage)
7492
7621
  `));
7493
7622
  }
7494
- const commandsDir = join13(this.config.getConfigDir(), CUSTOM_COMMANDS_DIR_NAME);
7623
+ const commandsDir = join14(this.config.getConfigDir(), CUSTOM_COMMANDS_DIR_NAME);
7495
7624
  this.customCommandManager = new CustomCommandManager(commandsDir);
7496
7625
  const customCmdCount = this.customCommandManager.loadCommands();
7497
7626
  if (customCmdCount > 0) {
@@ -7515,7 +7644,12 @@ ${response.content.trim()}
7515
7644
  );
7516
7645
  }
7517
7646
  }
7647
+ this.setupClipboardPaste();
7518
7648
  this.rl.on("SIGINT", () => {
7649
+ if (this.streamAbortController) {
7650
+ this.streamAbortController.abort();
7651
+ return;
7652
+ }
7519
7653
  if (this.toolExecutor.confirming) {
7520
7654
  this.toolExecutor.cancelConfirm();
7521
7655
  return;
@@ -7830,15 +7964,15 @@ ${response.content.trim()}
7830
7964
  const dir = normalized.includes("/") ? dirname5(normalized) : ".";
7831
7965
  const prefix = normalized.includes("/") ? basename5(normalized) : normalized;
7832
7966
  const absDir = resolve4(process.cwd(), dir);
7833
- if (!existsSync18(absDir)) return [];
7967
+ if (!existsSync19(absDir)) return [];
7834
7968
  const entries = readdirSync9(absDir);
7835
7969
  const results = [];
7836
7970
  for (const entry of entries) {
7837
7971
  if (entry.startsWith(".")) continue;
7838
7972
  if (!entry.toLowerCase().startsWith(prefix.toLowerCase())) continue;
7839
7973
  try {
7840
- const fullPath = join13(absDir, entry);
7841
- const stat = statSync6(fullPath);
7974
+ const fullPath = join14(absDir, entry);
7975
+ const stat = statSync7(fullPath);
7842
7976
  const rel = dir === "." ? entry : `${dir}/${entry}`;
7843
7977
  results.push(stat.isDirectory() ? `${rel}/` : rel);
7844
7978
  } catch {
@@ -7853,36 +7987,106 @@ ${response.content.trim()}
7853
7987
  shouldShowTokens() {
7854
7988
  return this.config.get("ui").showTokenCount;
7855
7989
  }
7990
+ /**
7991
+ * 流式生成开始前调用:创建 AbortController,监听 ESC 键(0x1b)和 Ctrl+C(0x03)原始字节。
7992
+ * rl.pause() 会暂停 stdin,这里临时恢复以接收原始键盘字节。
7993
+ */
7994
+ setupStreamInterrupt() {
7995
+ const ac = new AbortController();
7996
+ this.streamAbortController = ac;
7997
+ process.stdin.resume();
7998
+ const handler = (data) => {
7999
+ if (data[0] === 27 && data.length === 1 || data[0] === 3) {
8000
+ ac.abort();
8001
+ }
8002
+ };
8003
+ this._escHandler = handler;
8004
+ process.stdin.on("data", handler);
8005
+ return ac;
8006
+ }
8007
+ /**
8008
+ * 流式生成结束后调用(finally 块中):清理 ESC 监听器,还原 stdin 暂停状态。
8009
+ */
8010
+ teardownStreamInterrupt() {
8011
+ if (this._escHandler) {
8012
+ process.stdin.removeListener("data", this._escHandler);
8013
+ this._escHandler = null;
8014
+ }
8015
+ process.stdin.pause();
8016
+ this.streamAbortController = null;
8017
+ }
8018
+ /**
8019
+ * 注册 Ctrl+V 剪贴板图片粘贴快捷键。
8020
+ *
8021
+ * 使用 prependListener 先于 readline 的 quotedInsert 处理器运行:
8022
+ * 1. 我们的 handler 在 setImmediate 中调用 rl.write('@imgPath ')
8023
+ * 2. readline 的 quotedInsert 消费第一个字符('@'),字面插入 '@' — 结果相同
8024
+ * 3. 后续字符正常插入
8025
+ * 最终 readline 缓冲区包含完整的 @路径,用户可继续追加描述文字后回车发送。
8026
+ */
8027
+ setupClipboardPaste() {
8028
+ process.stdin.prependListener("keypress", (_ch, key) => {
8029
+ if (!key?.ctrl || key?.name !== "v") return;
8030
+ if (this.streamAbortController || this.toolExecutor.confirming || askUserContext.prompting || this.selecting) return;
8031
+ const imgPath = readClipboardImage();
8032
+ if (imgPath) {
8033
+ setImmediate(() => {
8034
+ this.rl.write(`@${imgPath} `);
8035
+ process.stdout.write(chalk10.dim(`
8036
+ \u{1F4CB} \u56FE\u7247\u5DF2\u7C98\u8D34\uFF0C\u8BF7\u6DFB\u52A0\u63CF\u8FF0\u540E\u56DE\u8F66\u53D1\u9001
8037
+ `));
8038
+ this.showPrompt();
8039
+ });
8040
+ } else {
8041
+ setImmediate(() => {
8042
+ const hint = getClipboardHint();
8043
+ process.stdout.write(
8044
+ chalk10.dim(`
8045
+ \u2139 \u526A\u8D34\u677F\u4E2D\u6CA1\u6709\u56FE\u7247\u3002\u53EF\u4F7F\u7528 @\u8DEF\u5F84 \u5F15\u7528\u672C\u5730\u56FE\u7247\u6587\u4EF6\u3002`) + (hint ? chalk10.dim(`
8046
+ ${hint}`) : "") + "\n"
8047
+ );
8048
+ this.showPrompt();
8049
+ });
8050
+ }
8051
+ });
8052
+ }
7856
8053
  async handleChatSimple(provider, messages) {
7857
8054
  const session = this.sessions.current;
7858
8055
  const useStreaming = this.config.get("ui").streaming;
7859
8056
  const modelParams = this.getModelParams();
7860
8057
  if (useStreaming) {
7861
- const stream = provider.chatStream({
7862
- messages,
7863
- model: this.currentModel,
7864
- systemPrompt: this.buildCurrentSystemPrompt(),
7865
- stream: true,
7866
- temperature: modelParams.temperature,
7867
- maxTokens: modelParams.maxTokens,
7868
- timeout: modelParams.timeout,
7869
- thinking: modelParams.thinking
7870
- });
7871
- const showTokens = this.shouldShowTokens();
7872
- const { content, usage, tokensShown } = await this.renderer.renderStream(stream, {
7873
- showTokens,
7874
- sessionTotal: showTokens ? { ...this.sessionTokenUsage } : void 0
7875
- });
7876
- lastResponseStore.content = content;
7877
- session.addMessage({ role: "assistant", content, timestamp: /* @__PURE__ */ new Date() });
7878
- this.events.emit("message.after", { content });
7879
- if (usage) {
7880
- this.sessionTokenUsage.inputTokens += usage.inputTokens;
7881
- this.sessionTokenUsage.outputTokens += usage.outputTokens;
7882
- session.addTokenUsage(usage);
7883
- if (showTokens && !tokensShown) {
7884
- this.renderer.renderUsage(usage, this.sessionTokenUsage);
8058
+ const ac = this.setupStreamInterrupt();
8059
+ try {
8060
+ const stream = provider.chatStream({
8061
+ messages,
8062
+ model: this.currentModel,
8063
+ systemPrompt: this.buildCurrentSystemPrompt(),
8064
+ stream: true,
8065
+ temperature: modelParams.temperature,
8066
+ maxTokens: modelParams.maxTokens,
8067
+ timeout: modelParams.timeout,
8068
+ thinking: modelParams.thinking,
8069
+ signal: ac.signal
8070
+ });
8071
+ const showTokens = this.shouldShowTokens();
8072
+ const { content, usage, tokensShown } = await this.renderer.renderStream(stream, {
8073
+ showTokens,
8074
+ sessionTotal: showTokens ? { ...this.sessionTokenUsage } : void 0,
8075
+ signal: ac.signal
8076
+ });
8077
+ lastResponseStore.content = content;
8078
+ session.addMessage({ role: "assistant", content, timestamp: /* @__PURE__ */ new Date() });
8079
+ this.events.emit("message.after", { content });
8080
+ if (usage) {
8081
+ this.sessionTokenUsage.inputTokens += usage.inputTokens;
8082
+ this.sessionTokenUsage.outputTokens += usage.outputTokens;
8083
+ session.addTokenUsage(usage);
8084
+ if (showTokens && !tokensShown) {
8085
+ this.renderer.renderUsage(usage, this.sessionTokenUsage);
8086
+ }
7885
8087
  }
8088
+ } finally {
8089
+ this.teardownStreamInterrupt();
7886
8090
  }
7887
8091
  } else {
7888
8092
  const spinner = this.renderer.showSpinner("Thinking...");
@@ -7984,46 +8188,52 @@ ${response.content.trim()}
7984
8188
  const saveToFile = String(saveLastResponseCall.arguments["path"] ?? "");
7985
8189
  if (!saveToFile) {
7986
8190
  } else {
7987
- const genStream = provider.chatStream({
7988
- messages: apiMessages,
7989
- model: this.currentModel,
7990
- systemPrompt,
7991
- stream: true,
7992
- temperature: modelParams.temperature,
7993
- maxTokens: modelParams.maxTokens,
7994
- timeout: modelParams.timeout,
7995
- thinking: modelParams.thinking,
7996
- ...extraMessages.length > 0 ? { _extraMessages: extraMessages } : {}
7997
- });
7998
- const teeShowTokens = this.shouldShowTokens();
7999
- const { content: genContent, usage: genUsage, tokensShown: teeTokShown } = await this.renderer.renderStream(
8000
- genStream,
8001
- { saveToFile, showTokens: teeShowTokens, sessionTotal: teeShowTokens ? { ...this.sessionTokenUsage } : void 0 }
8002
- );
8003
- lastResponseStore.content = genContent;
8004
- if (genUsage) {
8005
- roundUsage.inputTokens += genUsage.inputTokens;
8006
- roundUsage.outputTokens += genUsage.outputTokens;
8007
- }
8008
- session.addMessage({ role: "assistant", content: genContent, timestamp: /* @__PURE__ */ new Date() });
8009
- this.events.emit("message.after", { content: genContent });
8010
- const lines = genContent.split("\n").length;
8011
- const bytes = Buffer.byteLength(genContent, "utf-8");
8012
- const syntheticResults = result.toolCalls.map((tc) => ({
8013
- callId: tc.id,
8014
- content: tc.name === "save_last_response" ? `File saved: ${saveToFile} (${lines} lines, ${bytes} bytes)` : `[skipped: file already saved by tee streaming]`,
8015
- isError: false
8016
- }));
8017
- const reasoningContent2 = "reasoningContent" in result ? result.reasoningContent : void 0;
8018
- const newMsgs2 = provider.buildToolResultMessages(result.toolCalls, syntheticResults, reasoningContent2);
8019
- extraMessages.push(...newMsgs2);
8020
- if (roundUsage.inputTokens > 0 || roundUsage.outputTokens > 0) {
8021
- this.sessionTokenUsage.inputTokens += roundUsage.inputTokens;
8022
- this.sessionTokenUsage.outputTokens += roundUsage.outputTokens;
8023
- session.addTokenUsage(roundUsage);
8024
- if (teeShowTokens && !teeTokShown) {
8025
- this.renderer.renderUsage(roundUsage, this.sessionTokenUsage);
8191
+ const teeAc = this.setupStreamInterrupt();
8192
+ try {
8193
+ const genStream = provider.chatStream({
8194
+ messages: apiMessages,
8195
+ model: this.currentModel,
8196
+ systemPrompt,
8197
+ stream: true,
8198
+ temperature: modelParams.temperature,
8199
+ maxTokens: modelParams.maxTokens,
8200
+ timeout: modelParams.timeout,
8201
+ thinking: modelParams.thinking,
8202
+ signal: teeAc.signal,
8203
+ ...extraMessages.length > 0 ? { _extraMessages: extraMessages } : {}
8204
+ });
8205
+ const teeShowTokens = this.shouldShowTokens();
8206
+ const { content: genContent, usage: genUsage, tokensShown: teeTokShown } = await this.renderer.renderStream(
8207
+ genStream,
8208
+ { saveToFile, showTokens: teeShowTokens, sessionTotal: teeShowTokens ? { ...this.sessionTokenUsage } : void 0, signal: teeAc.signal }
8209
+ );
8210
+ lastResponseStore.content = genContent;
8211
+ if (genUsage) {
8212
+ roundUsage.inputTokens += genUsage.inputTokens;
8213
+ roundUsage.outputTokens += genUsage.outputTokens;
8214
+ }
8215
+ session.addMessage({ role: "assistant", content: genContent, timestamp: /* @__PURE__ */ new Date() });
8216
+ this.events.emit("message.after", { content: genContent });
8217
+ const lines = genContent.split("\n").length;
8218
+ const bytes = Buffer.byteLength(genContent, "utf-8");
8219
+ const syntheticResults = result.toolCalls.map((tc) => ({
8220
+ callId: tc.id,
8221
+ content: tc.name === "save_last_response" ? `File saved: ${saveToFile} (${lines} lines, ${bytes} bytes)` : `[skipped: file already saved by tee streaming]`,
8222
+ isError: false
8223
+ }));
8224
+ const reasoningContent2 = "reasoningContent" in result ? result.reasoningContent : void 0;
8225
+ const newMsgs2 = provider.buildToolResultMessages(result.toolCalls, syntheticResults, reasoningContent2);
8226
+ extraMessages.push(...newMsgs2);
8227
+ if (roundUsage.inputTokens > 0 || roundUsage.outputTokens > 0) {
8228
+ this.sessionTokenUsage.inputTokens += roundUsage.inputTokens;
8229
+ this.sessionTokenUsage.outputTokens += roundUsage.outputTokens;
8230
+ session.addTokenUsage(roundUsage);
8231
+ if (teeShowTokens && !teeTokShown) {
8232
+ this.renderer.renderUsage(roundUsage, this.sessionTokenUsage);
8233
+ }
8026
8234
  }
8235
+ } finally {
8236
+ this.teardownStreamInterrupt();
8027
8237
  }
8028
8238
  return;
8029
8239
  }
@@ -2,7 +2,7 @@
2
2
  import {
3
3
  executeTests,
4
4
  runTestsTool
5
- } from "./chunk-3EJWGNHV.js";
5
+ } from "./chunk-IW6VVPO4.js";
6
6
  export {
7
7
  executeTests,
8
8
  runTestsTool
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jinzd-ai-cli",
3
- "version": "0.1.29",
3
+ "version": "0.1.31",
4
4
  "description": "Cross-platform REPL-style AI CLI with multi-provider support",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",