zapmyco 0.4.0 → 0.7.0

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.
@@ -1,25 +1,25 @@
1
1
  #!/usr/bin/env node
2
- import { D as buildSkillSnapshot, E as eventBus, M as __VERSION__, N as __require, O as loadSkills, _ as createLlmBasedAgent, a as SubAgentManager, h as ZapmycoErrorCode, j as APP_NAME, p as WebError, s as logger, t as loadConfig } from "../loader-BK1Z72gI.mjs";
2
+ import { B as APP_NAME, E as createLlmBasedAgent, F as eventBus, H as __VERSION__, I as buildSkillSnapshot, L as loadSkills, S as WebError, U as __require, V as SESSION_DIR_NAME, g as logger, h as configureLogger, i as AgentLlmFacade, n as loadConfig, p as SubAgentManager, t as HOME_CONFIG_PATH, w as ZapmycoErrorCode } from "../loader-B5_elj6d.mjs";
3
3
  import { createHash, randomBytes } from "node:crypto";
4
- import { mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
4
+ import { mkdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
5
5
  import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
6
6
  import * as os from "node:os";
7
- import { homedir } from "node:os";
7
+ import { homedir, tmpdir } from "node:os";
8
8
  import * as path from "node:path";
9
9
  import { dirname, isAbsolute, join, normalize, resolve } from "node:path";
10
10
  import { EventEmitter } from "node:events";
11
+ import { getModels, getProviders } from "@mariozechner/pi-ai";
11
12
  import chalk, { Chalk } from "chalk";
12
13
  import { Command } from "commander";
13
- import { getModel } from "@mariozechner/pi-ai";
14
- import { Container, Editor, Key, ProcessTerminal, TUI, matchesKey, truncateToWidth, wrapTextWithAnsi } from "@mariozechner/pi-tui";
15
- import { spawn } from "node:child_process";
14
+ import { spawn, spawnSync } from "node:child_process";
15
+ import { CombinedAutocompleteProvider, Container, Editor, Input, Key, ProcessTerminal, SelectList, TUI, getKeybindings, matchesKey, truncateToWidth, wrapTextWithAnsi } from "@mariozechner/pi-tui";
16
16
  import TurndownService from "turndown";
17
17
  import { lookup } from "node:dns/promises";
18
18
  import { Client } from "@modelcontextprotocol/sdk/client";
19
19
  import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
20
20
 
21
21
  //#region src/cli/repl/command-registry.ts
22
- const log$5 = logger.child("repl:command-registry");
22
+ const log$6 = logger.child("repl:command-registry");
23
23
  /**
24
24
  * 命令注册表
25
25
  */
@@ -34,7 +34,7 @@ var CommandRegistry = class {
34
34
  */
35
35
  register(cmd) {
36
36
  const canonicalName = cmd.name.toLowerCase();
37
- if (this.commands.has(canonicalName)) log$5.warn(`命令 "${canonicalName}" 已存在,将被覆盖`);
37
+ if (this.commands.has(canonicalName)) log$6.warn(`命令 "${canonicalName}" 已存在,将被覆盖`);
38
38
  this.commands.set(canonicalName, cmd);
39
39
  for (const alias of cmd.aliases) {
40
40
  const lowerAlias = alias.toLowerCase();
@@ -62,7 +62,7 @@ var CommandRegistry = class {
62
62
  */
63
63
  async dispatch(parsed) {
64
64
  if (parsed.kind !== "command") {
65
- log$5.warn("dispatch 收到了非 command 类型的输入");
65
+ log$6.warn("dispatch 收到了非 command 类型的输入");
66
66
  return;
67
67
  }
68
68
  const cmd = this.getCommand(parsed.name);
@@ -74,7 +74,7 @@ var CommandRegistry = class {
74
74
  await cmd.handler(parsed.args, this.session);
75
75
  } catch (error) {
76
76
  const message = error instanceof Error ? error.message : String(error);
77
- log$5.error(`命令 /${cmd.name} 执行出错`, {}, error);
77
+ log$6.error(`命令 /${cmd.name} 执行出错`, {}, error);
78
78
  console.log(`\n 命令执行出错: ${message}\n`);
79
79
  }
80
80
  }
@@ -140,14 +140,73 @@ function createClearCommand() {
140
140
  //#endregion
141
141
  //#region src/cli/repl/commands/config-cmd.ts
142
142
  /**
143
+ * /config 命令
144
+ *
145
+ * 查看和修改当前配置信息。
146
+ */
147
+ /** 判断 key 是否可能触发原型污染 */
148
+ function isPrototypePollutionKey(key) {
149
+ return key === "__proto__" || key === "constructor" || key === "prototype";
150
+ }
151
+ function getByDotPath(obj, path) {
152
+ const keys = path.split(".");
153
+ let current = obj;
154
+ for (const key of keys) {
155
+ if (current === null || current === void 0 || typeof current !== "object") return;
156
+ if (isPrototypePollutionKey(key)) return;
157
+ current = current[key];
158
+ }
159
+ return current;
160
+ }
161
+ /**
162
+ * 通过 dot-path 设置嵌套对象属性(自动创建中间对象)
163
+ */
164
+ function setByDotPath(obj, path, value) {
165
+ const keys = path.split(".");
166
+ let current = obj;
167
+ for (let i = 0; i < keys.length - 1; i++) {
168
+ const key = keys[i];
169
+ if (isPrototypePollutionKey(key)) return;
170
+ if (!(key in current) || typeof current[key] !== "object" || current[key] === null) current[key] = {};
171
+ current = current[key];
172
+ }
173
+ const lastKey = keys[keys.length - 1];
174
+ if (isPrototypePollutionKey(lastKey)) return;
175
+ current[lastKey] = value;
176
+ }
177
+ /**
178
+ * 更新 settings.json 中指定 dot-path 的值
179
+ */
180
+ function updateSettingsFile(path, value) {
181
+ try {
182
+ const raw = readFileSync(HOME_CONFIG_PATH, "utf-8");
183
+ const config = JSON.parse(raw);
184
+ let parsedValue = value;
185
+ try {
186
+ parsedValue = JSON.parse(value);
187
+ } catch {}
188
+ setByDotPath(config, path, parsedValue);
189
+ writeFileSync(HOME_CONFIG_PATH, JSON.stringify(config, null, 2) + "\n", "utf-8");
190
+ return {
191
+ success: true,
192
+ message: ""
193
+ };
194
+ } catch (err) {
195
+ return {
196
+ success: false,
197
+ message: err instanceof Error ? err.message : String(err)
198
+ };
199
+ }
200
+ }
201
+ /**
143
202
  * 创建 config 命令定义
144
203
  */
145
204
  function createConfigCommand() {
146
205
  return {
147
206
  name: "config",
148
207
  aliases: ["cfg"],
149
- description: "查看配置 [show | get <key>]",
150
- usage: "/config [show | get <key>]",
208
+ description: "查看或修改配置 [show | get <key> | set <key> <value>]",
209
+ usage: "/config [show | get <key> | set <key> <value>]",
151
210
  handler(args, session) {
152
211
  const config = session.config;
153
212
  const renderer = session.getRenderer();
@@ -161,9 +220,9 @@ function createConfigCommand() {
161
220
  if (value !== void 0) {
162
221
  const displayValue = args[1].toLowerCase().includes("apikey") || args[1].toLowerCase().includes("api_key") ? "***已配置***" : JSON.stringify(value, null, 2);
163
222
  session.appendOutput([
164
- ``,
223
+ "",
165
224
  ` ${args[1]}: ${displayValue}`,
166
- ``
225
+ ""
167
226
  ]);
168
227
  } else session.appendOutput([
169
228
  "",
@@ -173,28 +232,40 @@ function createConfigCommand() {
173
232
  ]);
174
233
  return;
175
234
  }
235
+ if (args[0] === "set" && args[1] && args[2] !== void 0) {
236
+ const key = args[1];
237
+ const value = args.slice(2).join(" ");
238
+ const result = updateSettingsFile(key, value);
239
+ if (result.success) {
240
+ let parsedValue = value;
241
+ try {
242
+ parsedValue = JSON.parse(value);
243
+ } catch {}
244
+ setByDotPath(session.config, key, parsedValue);
245
+ session.applyConfigUpdate(key);
246
+ const displayValue = key.toLowerCase().includes("apikey") || key.toLowerCase().includes("api_key") ? "***已配置***" : value;
247
+ session.appendOutput([
248
+ "",
249
+ ` ✅ 配置已更新: ${key} = ${displayValue}`,
250
+ ` 已持久化到 ${HOME_CONFIG_PATH}`,
251
+ ""
252
+ ]);
253
+ } else session.appendOutput([
254
+ "",
255
+ ` ❌ 配置更新失败: ${result.message}`,
256
+ ""
257
+ ]);
258
+ return;
259
+ }
176
260
  session.appendOutput([
177
261
  "",
178
- " 用法: /config [show | get <key>]",
262
+ " 用法: /config [show | get <key> | set <key> <value>]",
263
+ " 示例: /config set llm.providers.deepseek.apiKey sk-xxx",
179
264
  ""
180
265
  ]);
181
266
  }
182
267
  };
183
268
  }
184
- /**
185
- * 通过 dot-path 获取嵌套对象属性
186
- *
187
- * 例如: getByPath(config, 'llm.provider') → config.llm.provider
188
- */
189
- function getByDotPath(obj, path) {
190
- const keys = path.split(".");
191
- let current = obj;
192
- for (const key of keys) {
193
- if (current === null || current === void 0 || typeof current !== "object") return;
194
- current = current[key];
195
- }
196
- return current;
197
- }
198
269
 
199
270
  //#endregion
200
271
  //#region src/cli/repl/commands/help.ts
@@ -294,6 +365,840 @@ function createQuitCommand() {
294
365
  };
295
366
  }
296
367
 
368
+ //#endregion
369
+ //#region src/cli/repl/components/dialogs.ts
370
+ /**
371
+ * Shared dialog components for TUI overlays
372
+ *
373
+ * Extracted from settings-cmd.ts for reuse across the REPL.
374
+ * Provides SelectList and TextInput overlay dialogs.
375
+ */
376
+ /** Overlay layout options for menus */
377
+ const OVERLAY_OPTIONS = {
378
+ width: "100%",
379
+ anchor: "top-left",
380
+ margin: {
381
+ top: 1,
382
+ bottom: 1
383
+ }
384
+ };
385
+ const SELECT_THEME = {
386
+ selectedPrefix: (text) => chalk.green(`❯ ${text}`),
387
+ selectedText: (text) => chalk.green.bold(text),
388
+ description: (text) => chalk.gray(text),
389
+ scrollInfo: (text) => chalk.gray(text),
390
+ noMatch: (text) => chalk.red(text)
391
+ };
392
+ /**
393
+ * Wraps SelectList with a footer hint showing available keybindings.
394
+ * This lets users discover navigation keys without relying on terminal conventions.
395
+ */
396
+ var SelectListWithFooter = class {
397
+ selectList;
398
+ tui;
399
+ filterText = "";
400
+ isFiltering = false;
401
+ /** Callbacks stored at wrapper level, forwarded through inner SelectList */
402
+ onSelect;
403
+ onCancel;
404
+ /** Called when user presses q/esc — exits settings entirely */
405
+ onExit;
406
+ /** Called when user presses backspace/h — goes back one level */
407
+ onBack;
408
+ constructor(tui, items, maxVisible, theme) {
409
+ this.tui = tui;
410
+ this.selectList = new SelectList(items, maxVisible, theme);
411
+ this.selectList.onSelect = (item) => {
412
+ this.onSelect?.(item);
413
+ };
414
+ this.selectList.onCancel = () => {
415
+ this.onCancel?.();
416
+ };
417
+ }
418
+ handleInput(data) {
419
+ if (this.isFiltering) if (data.length === 1 && data >= " " && data <= "~") {
420
+ this.filterText += data;
421
+ this.applyFilter();
422
+ } else if (data === "backspace" || data === "" || data === "\b") if (this.filterText.length > 0) {
423
+ this.filterText = this.filterText.slice(0, -1);
424
+ this.applyFilter();
425
+ } else {
426
+ this.isFiltering = false;
427
+ this.resetFilteredItems();
428
+ this.selectList.invalidate();
429
+ }
430
+ else if (data === "escape") {
431
+ this.isFiltering = false;
432
+ this.filterText = "";
433
+ this.resetFilteredItems();
434
+ this.selectList.invalidate();
435
+ } else if (data === "enter") {
436
+ this.isFiltering = false;
437
+ this.selectList.handleInput("enter");
438
+ } else this.selectList.handleInput(data);
439
+ else if (data === "/") {
440
+ this.isFiltering = true;
441
+ this.filterText = "";
442
+ this.selectList.invalidate();
443
+ } else if (data === "escape" || data === "q") this.onExit?.();
444
+ else if (data === "backspace" || data === "" || data === "\b" || data === "h") this.onBack?.();
445
+ else this.selectList.handleInput(data);
446
+ }
447
+ /** Apply current filter text to the SelectList (uses includes, not startsWith) */
448
+ applyFilter() {
449
+ const sl = this.selectList;
450
+ if (!this.filterText) sl.filteredItems = [...sl.items];
451
+ else {
452
+ const lower = this.filterText.toLowerCase();
453
+ sl.filteredItems = sl.items.filter((item) => item.value.toLowerCase().includes(lower));
454
+ }
455
+ sl.selectedIndex = 0;
456
+ this.selectList.invalidate();
457
+ }
458
+ /** Restore SelectList to show all unfiltered items */
459
+ resetFilteredItems() {
460
+ const sl = this.selectList;
461
+ sl.filteredItems = [...sl.items];
462
+ sl.selectedIndex = 0;
463
+ }
464
+ invalidate() {
465
+ this.selectList.invalidate();
466
+ }
467
+ render(width) {
468
+ const lines = [];
469
+ if (this.isFiltering) {
470
+ lines.push("");
471
+ lines.push(chalk.cyan(` /${this.filterText}█`));
472
+ lines.push(chalk.gray(` ${"─".repeat(Math.max(0, width - 4))}`));
473
+ lines.push("");
474
+ }
475
+ const listLines = this.selectList.render(width);
476
+ lines.push(...listLines);
477
+ const termHeight = this.tui.terminal.rows;
478
+ const padding = Math.max(0, termHeight - 1 - lines.length - 3);
479
+ for (let i = 0; i < padding; i++) lines.push("");
480
+ if (width >= 50) {
481
+ lines.push(chalk.gray(` ${"─".repeat(Math.max(0, width - 4))}`));
482
+ if (this.isFiltering) lines.push(chalk.gray(" 输入文字搜索 · ↑↓ 导航 · Enter 确认 · Esc 取消"));
483
+ else lines.push(chalk.gray(" k/j ↑↓ 导航 · / 搜索 · Enter 选择 · Esc/q 退出 · BS/h 返回"));
484
+ } else if (this.isFiltering) lines.push(chalk.gray(" Enter 确认 · Esc 取消"));
485
+ else lines.push(chalk.gray(" ↑↓=k/j / Enter Esc/q BS/h"));
486
+ lines.push("");
487
+ return lines;
488
+ }
489
+ };
490
+ /**
491
+ * Show a SelectList overlay and wait for user selection
492
+ * @returns The selected item, or null if cancelled
493
+ */
494
+ function showSelectList(tui, items, options) {
495
+ return new Promise((resolve) => {
496
+ const list = new SelectListWithFooter(tui, items, options?.maxVisible ?? 10, SELECT_THEME);
497
+ let handle = null;
498
+ list.onSelect = (item) => {
499
+ handle?.hide();
500
+ resolve(item);
501
+ };
502
+ list.onCancel = () => {
503
+ handle?.hide();
504
+ resolve(null);
505
+ };
506
+ list.onExit = () => {
507
+ handle?.hide();
508
+ resolve(null);
509
+ options?.onExit?.();
510
+ };
511
+ list.onBack = () => {
512
+ handle?.hide();
513
+ resolve(null);
514
+ };
515
+ handle = tui.showOverlay(list, OVERLAY_OPTIONS);
516
+ });
517
+ }
518
+ var TextInputComponent = class {
519
+ input;
520
+ label;
521
+ constructor(label, initialValue, placeholder, onSubmit, onCancel) {
522
+ this.label = label;
523
+ this.input = new Input();
524
+ if (initialValue) this.input.setValue(initialValue);
525
+ this.input.onSubmit = (value) => {
526
+ onSubmit(value === placeholder ? initialValue : value);
527
+ };
528
+ this.input.onEscape = () => {
529
+ onCancel();
530
+ };
531
+ }
532
+ get focused() {
533
+ return this.input.focused;
534
+ }
535
+ set focused(v) {
536
+ this.input.focused = v;
537
+ }
538
+ handleInput(data) {
539
+ this.input.handleInput(data);
540
+ }
541
+ invalidate() {
542
+ this.input.invalidate();
543
+ }
544
+ render(width) {
545
+ const c = chalk;
546
+ return [
547
+ "",
548
+ c.bold(` ${this.label}`),
549
+ "",
550
+ c.gray(` ${"─".repeat(Math.min(width - 4, 50))}`),
551
+ ` ${this.input.render(width - 4)[0] ?? ""}`,
552
+ c.gray(` ${"─".repeat(Math.min(width - 4, 50))}`),
553
+ "",
554
+ c.gray(" Enter to confirm · Esc to cancel"),
555
+ ""
556
+ ];
557
+ }
558
+ };
559
+ /**
560
+ * Show a text input overlay
561
+ * @returns The entered text, or null if cancelled
562
+ */
563
+ function showTextInput(tui, label, initialValue, placeholder) {
564
+ return new Promise((resolve) => {
565
+ let handle = null;
566
+ const component = new TextInputComponent(label, initialValue, placeholder ?? "", (value) => {
567
+ handle?.hide();
568
+ resolve(value);
569
+ }, () => {
570
+ handle?.hide();
571
+ resolve(null);
572
+ });
573
+ handle = tui.showOverlay(component, {
574
+ width: "60%",
575
+ minWidth: 50,
576
+ maxHeight: 12,
577
+ anchor: "top-left"
578
+ });
579
+ });
580
+ }
581
+ /**
582
+ * A simple overlay component that displays configuration text
583
+ * and dismisses on q/Esc/Enter/BS/h.
584
+ */
585
+ var ConfigViewComponent = class {
586
+ lines;
587
+ onDismiss;
588
+ constructor(config, renderer) {
589
+ this.lines = renderer.renderConfig(config);
590
+ }
591
+ /** Set the dismiss callback from showConfigView */
592
+ setDismissCallback(cb) {
593
+ this.onDismiss = cb;
594
+ }
595
+ handleInput(data) {
596
+ if (data === "escape" || data === "q" || data === "enter" || data === "backspace" || data === "" || data === "\b" || data === "h") this.onDismiss?.();
597
+ }
598
+ invalidate() {}
599
+ render(width) {
600
+ if (width < 50) return [
601
+ ...this.lines,
602
+ "",
603
+ chalk.gray(" q/Esc 返回")
604
+ ];
605
+ return [
606
+ ...this.lines,
607
+ "",
608
+ chalk.gray(` ${"─".repeat(Math.max(0, width - 4))}`),
609
+ chalk.gray(" q/Esc 返回 · Enter/BS/h 返回菜单"),
610
+ ""
611
+ ];
612
+ }
613
+ };
614
+ /**
615
+ * Show the current configuration as a dismissible overlay.
616
+ * Returns when the user dismisses it.
617
+ */
618
+ function showConfigView(tui, config, renderer) {
619
+ return new Promise((resolve) => {
620
+ const component = new ConfigViewComponent(config, renderer);
621
+ const handle = tui.showOverlay(component, {
622
+ width: "100%",
623
+ anchor: "top-left",
624
+ margin: {
625
+ top: 1,
626
+ bottom: 1
627
+ }
628
+ });
629
+ component.setDismissCallback(() => {
630
+ handle.hide();
631
+ resolve();
632
+ });
633
+ });
634
+ }
635
+
636
+ //#endregion
637
+ //#region src/cli/repl/config-utils.ts
638
+ /**
639
+ * Configuration file utilities
640
+ *
641
+ * Shared read/write helpers for ~/.zapmyco/settings.json,
642
+ * used by settings-cmd.ts and session.ts.
643
+ */
644
+ /** Read settings.json and return a mutable object */
645
+ function readSettings() {
646
+ try {
647
+ return JSON.parse(readFileSync(HOME_CONFIG_PATH, "utf-8"));
648
+ } catch {
649
+ return {};
650
+ }
651
+ }
652
+ /** Write back to settings.json */
653
+ function writeSettings(settings) {
654
+ writeFileSync(HOME_CONFIG_PATH, JSON.stringify(settings, null, 2) + "\n", "utf-8");
655
+ }
656
+ /** Safely set a nested property (prototype-chain safe) */
657
+ function _setByDotPath(obj, path, value) {
658
+ const keys = path.split(".");
659
+ let current = obj;
660
+ for (let i = 0; i < keys.length - 1; i++) {
661
+ const key = keys[i];
662
+ if (key === "__proto__" || key === "constructor" || key === "prototype") return;
663
+ if (!(key in current) || typeof current[key] !== "object" || current[key] === null) current[key] = {};
664
+ current = current[key];
665
+ }
666
+ const lastKey = keys[keys.length - 1];
667
+ if (lastKey === "__proto__" || lastKey === "constructor" || lastKey === "prototype") return;
668
+ current[lastKey] = value;
669
+ }
670
+ /** Get a nested property value via dot-path */
671
+ function _getByDotPath(obj, path) {
672
+ const keys = path.split(".");
673
+ let current = obj;
674
+ for (const key of keys) {
675
+ if (current === null || current === void 0 || typeof current !== "object") return;
676
+ if (key === "__proto__" || key === "constructor" || key === "prototype") return void 0;
677
+ current = current[key];
678
+ }
679
+ return current;
680
+ }
681
+
682
+ //#endregion
683
+ //#region src/cli/repl/commands/settings-cmd.ts
684
+ /**
685
+ * /settings command — interactive configuration menu
686
+ *
687
+ * TUI overlay-based graphical configuration interface:
688
+ * - View and change the default model
689
+ * - Configure API keys, models, and base URLs for existing providers
690
+ * - Add new providers from a curated list
691
+ * - All changes sync to ~/.zapmyco/settings.json in real-time
692
+ */
693
+ /** Curated list of known providers (sorted by popularity) */
694
+ const KNOWN_PROVIDERS = [
695
+ {
696
+ id: "anthropic",
697
+ label: "Anthropic",
698
+ apiFormat: "anthropic"
699
+ },
700
+ {
701
+ id: "openai",
702
+ label: "OpenAI"
703
+ },
704
+ {
705
+ id: "deepseek",
706
+ label: "DeepSeek"
707
+ },
708
+ {
709
+ id: "google",
710
+ label: "Google (Gemini)"
711
+ },
712
+ {
713
+ id: "mistral",
714
+ label: "Mistral AI"
715
+ },
716
+ {
717
+ id: "xai",
718
+ label: "xAI (Grok)"
719
+ },
720
+ {
721
+ id: "groq",
722
+ label: "Groq"
723
+ },
724
+ {
725
+ id: "openrouter",
726
+ label: "OpenRouter"
727
+ },
728
+ {
729
+ id: "cerebras",
730
+ label: "Cerebras"
731
+ },
732
+ {
733
+ id: "fireworks",
734
+ label: "Fireworks AI"
735
+ },
736
+ {
737
+ id: "github-copilot",
738
+ label: "GitHub Copilot"
739
+ },
740
+ {
741
+ id: "huggingface",
742
+ label: "Hugging Face"
743
+ },
744
+ {
745
+ id: "moonshotai",
746
+ label: "Moonshot AI (Kimi)"
747
+ },
748
+ {
749
+ id: "cloudflare-workers-ai",
750
+ label: "Cloudflare Workers AI"
751
+ },
752
+ {
753
+ id: "amazon-bedrock",
754
+ label: "Amazon Bedrock"
755
+ },
756
+ {
757
+ id: "zai",
758
+ label: "ZAI"
759
+ },
760
+ {
761
+ id: "minimax",
762
+ label: "MiniMax"
763
+ },
764
+ {
765
+ id: "opencode",
766
+ label: "OpenCode"
767
+ }
768
+ ];
769
+ /** Set a dot-path value, persist to disk and hot-reload agent */
770
+ function setConfigValue(session, dotPath, value) {
771
+ const settings = readSettings();
772
+ _setByDotPath(settings, dotPath, value);
773
+ writeSettings(settings);
774
+ _setByDotPath(session.config, dotPath, value);
775
+ session.applyConfigUpdate(dotPath);
776
+ }
777
+ /** Check if a provider has an API key configured */
778
+ function hasApiKey(config, providerName) {
779
+ const key = _getByDotPath(config, `llm.providers.${providerName}.apiKey`);
780
+ if (!key) return false;
781
+ const keyStr = String(key);
782
+ return keyStr.length > 0 && keyStr !== "${}";
783
+ }
784
+ /** Get available model IDs for a provider (from config or pi-ai) */
785
+ function getProviderModels(config, providerName) {
786
+ const models = _getByDotPath(config, `llm.providers.${providerName}.models`);
787
+ if (models && typeof models === "object" && Object.keys(models).length > 0) return Object.keys(models);
788
+ try {
789
+ const piModels = getModels(providerName);
790
+ if (piModels && piModels.length > 0) return piModels.map((m) => m.id);
791
+ } catch {}
792
+ return [];
793
+ }
794
+ /**
795
+ * Create the /settings command definition
796
+ */
797
+ function createSettingsCommand() {
798
+ return {
799
+ name: "settings",
800
+ aliases: ["set"],
801
+ description: "Interactive configuration menu — manage model providers and API keys",
802
+ usage: "/settings [list-providers | list-models <provider>]",
803
+ async handler(args, session) {
804
+ const tui = session.getTui();
805
+ const config = readSettings();
806
+ if (args.length > 0) {
807
+ await handleCommandLine(args, session, tui, config);
808
+ return;
809
+ }
810
+ await handleInteractiveMode(tui, session, config);
811
+ }
812
+ };
813
+ }
814
+ /**
815
+ * CLI mode — quick operations via arguments
816
+ */
817
+ async function handleCommandLine(args, session, _tui, config) {
818
+ switch (args[0]) {
819
+ case "list-providers": {
820
+ const providers = _getByDotPath(config, "llm.providers");
821
+ const names = providers ? Object.keys(providers) : [];
822
+ const lines = ["", "Known providers:"];
823
+ for (const p of KNOWN_PROVIDERS) {
824
+ const configured = names.includes(p.id) ? " ✓" : " ";
825
+ lines.push(` ${configured} ${p.label} (${p.id})`);
826
+ }
827
+ session.appendOutput(lines);
828
+ return;
829
+ }
830
+ case "list-models": {
831
+ if (!args[1]) {
832
+ session.appendOutput([
833
+ "",
834
+ "Usage: /settings list-models <provider>",
835
+ ""
836
+ ]);
837
+ return;
838
+ }
839
+ const modelIds = getProviderModels(config, args[1]);
840
+ if (modelIds.length === 0) {
841
+ session.appendOutput([
842
+ "",
843
+ `Provider "${args[1]}" has no known models`,
844
+ "Hint: use /settings to configure this provider first",
845
+ ""
846
+ ]);
847
+ return;
848
+ }
849
+ session.appendOutput([
850
+ "",
851
+ `${args[1]} available models:`,
852
+ ...modelIds.map((id) => ` - ${id}`),
853
+ ""
854
+ ]);
855
+ return;
856
+ }
857
+ default: session.appendOutput([
858
+ "",
859
+ "Usage:",
860
+ " /settings — Open interactive configuration menu",
861
+ " /settings list-providers — List all known providers",
862
+ " /settings list-models <name> — List available models for a provider",
863
+ ""
864
+ ]);
865
+ }
866
+ }
867
+ /**
868
+ * Interactive menu mode — main flow
869
+ */
870
+ async function handleInteractiveMode(tui, session, config) {
871
+ const state = { current: config };
872
+ /**
873
+ * Handle API Key configuration
874
+ */
875
+ const handleApiKeyConfig = async (providerName, _currentKey) => {
876
+ const envVarName = `${providerName.toUpperCase().replace(/-/g, "_")}_API_KEY`;
877
+ const choice = await showSelectList(tui, [
878
+ {
879
+ value: "env",
880
+ label: `Use env var ${"${" + envVarName + "}"}`,
881
+ description: "Recommended — more secure"
882
+ },
883
+ {
884
+ value: "manual",
885
+ label: "Enter manually",
886
+ description: "Type the key directly (stored in plaintext in settings.json)"
887
+ },
888
+ {
889
+ value: "clear",
890
+ label: "Clear",
891
+ description: "Remove the configured key"
892
+ }
893
+ ], { onExit: exitAll });
894
+ if (!choice) return;
895
+ if (choice.value === "clear") {
896
+ setConfigValue(session, `llm.providers.${providerName}.apiKey`, "");
897
+ session.appendOutput([
898
+ "",
899
+ ` [ok] Cleared API key for ${providerName}`,
900
+ ""
901
+ ]);
902
+ } else if (choice.value === "env") {
903
+ setConfigValue(session, `llm.providers.${providerName}.apiKey`, `\${${envVarName}}`);
904
+ session.appendOutput([
905
+ "",
906
+ ` [ok] Set ${providerName} API key to env var \${${envVarName}}`,
907
+ ` Make sure ${envVarName} is set in your shell`,
908
+ ""
909
+ ]);
910
+ } else if (choice.value === "manual") {
911
+ const key = await showTextInput(tui, `Enter ${providerName} API Key`, "", "sk-...");
912
+ if (key && key.length > 0) {
913
+ setConfigValue(session, `llm.providers.${providerName}.apiKey`, key);
914
+ session.appendOutput([
915
+ "",
916
+ ` [ok] Configured API key for ${providerName}`,
917
+ ""
918
+ ]);
919
+ }
920
+ }
921
+ };
922
+ /**
923
+ * Handle Base URL configuration
924
+ */
925
+ const handleBaseUrlConfig = async (providerName, _currentUrl) => {
926
+ const url = await showTextInput(tui, `Enter ${providerName} Base URL`, "", "https://api.example.com");
927
+ if (url !== null) {
928
+ const configPath = `llm.providers.${providerName}.baseUrl`;
929
+ if (url.length === 0) {
930
+ const settings = readSettings();
931
+ _setByDotPath(settings, configPath, void 0);
932
+ const parent = _getByDotPath(settings, `llm.providers.${providerName}`);
933
+ if (parent) delete parent.baseUrl;
934
+ writeSettings(settings);
935
+ session.appendOutput([
936
+ "",
937
+ ` [ok] Reset ${providerName} Base URL to default`,
938
+ ""
939
+ ]);
940
+ } else {
941
+ setConfigValue(session, configPath, url);
942
+ session.appendOutput([
943
+ "",
944
+ ` [ok] Set ${providerName} Base URL: ${url}`,
945
+ ""
946
+ ]);
947
+ }
948
+ }
949
+ };
950
+ /**
951
+ * Handle model selection
952
+ */
953
+ const handleModelSelect = async (providerName) => {
954
+ const modelIds = getProviderModels(state.current, providerName);
955
+ if (modelIds.length === 0) {
956
+ session.appendOutput([
957
+ "",
958
+ ` ${providerName} has no available model list`,
959
+ " Configure models manually in settings.json or check the provider name",
960
+ ""
961
+ ]);
962
+ return;
963
+ }
964
+ const selected = await showSelectList(tui, modelIds.map((id) => ({
965
+ value: id,
966
+ label: id,
967
+ description: ""
968
+ })), { onExit: exitAll });
969
+ if (selected && selected.value) {
970
+ const modelId = selected.value;
971
+ const settings = readSettings();
972
+ _setByDotPath(settings, `llm.providers.${providerName}.models.${modelId}`, { id: modelId });
973
+ writeSettings(settings);
974
+ _setByDotPath(session.config, `llm.providers.${providerName}.models`, _getByDotPath(settings, `llm.providers.${providerName}.models`) ?? {});
975
+ session.appendOutput([
976
+ "",
977
+ ` [ok] Selected model: ${providerName}/${modelId}`,
978
+ ""
979
+ ]);
980
+ }
981
+ };
982
+ /**
983
+ * Handle setting a provider's model as the default
984
+ */
985
+ const handleSetDefault = async (providerName) => {
986
+ const modelIds = getProviderModels(state.current, providerName);
987
+ if (modelIds.length === 0) {
988
+ session.appendOutput([
989
+ "",
990
+ " Configure a model first before setting it as default",
991
+ ""
992
+ ]);
993
+ return;
994
+ }
995
+ const modelKey = `${providerName}/${modelIds[0]}`;
996
+ setConfigValue(session, "llm.defaultModel", modelKey);
997
+ session.appendOutput([
998
+ "",
999
+ ` [ok] Default model set to: ${modelKey}`,
1000
+ ""
1001
+ ]);
1002
+ };
1003
+ let running = true;
1004
+ const exitAll = () => {
1005
+ running = false;
1006
+ };
1007
+ while (running) {
1008
+ state.current = readSettings();
1009
+ const providers = _getByDotPath(state.current, "llm.providers") ?? {};
1010
+ const providerCount = Object.keys(providers).length;
1011
+ const choice = await showSelectList(tui, [
1012
+ {
1013
+ value: "default-model",
1014
+ label: "Default Model",
1015
+ description: String(_getByDotPath(state.current, "llm.defaultModel") ?? "not configured")
1016
+ },
1017
+ {
1018
+ value: "manage-providers",
1019
+ label: "Manage Providers",
1020
+ description: providerCount > 0 ? `${providerCount} configured` : "none configured"
1021
+ },
1022
+ {
1023
+ value: "view-config",
1024
+ label: "View Config",
1025
+ description: "Display full configuration details"
1026
+ }
1027
+ ], { onExit: exitAll });
1028
+ if (!choice) {
1029
+ running = false;
1030
+ break;
1031
+ }
1032
+ const value = choice.value;
1033
+ if (value === "default-model") {
1034
+ const configuredProviders = _getByDotPath(state.current, "llm.providers");
1035
+ const allProviders = getProviders();
1036
+ const enabledItems = [];
1037
+ const disabledItems = [];
1038
+ for (const providerName of allProviders) {
1039
+ const models = getProviderModels(state.current, providerName);
1040
+ if (models.length === 0) continue;
1041
+ const isEnabled = configuredProviders !== void 0 && providerName in configuredProviders && hasApiKey(state.current, providerName);
1042
+ for (const modelId of models) {
1043
+ const key = `${providerName}/${modelId}`;
1044
+ if (isEnabled) enabledItems.push({
1045
+ value: key,
1046
+ label: key,
1047
+ description: ""
1048
+ });
1049
+ else disabledItems.push({
1050
+ value: key,
1051
+ label: chalk.gray(key),
1052
+ description: chalk.gray("未配置 - Enter 设置 API Key")
1053
+ });
1054
+ }
1055
+ }
1056
+ const modelItems = [...enabledItems, ...disabledItems];
1057
+ if (modelItems.length === 0) {
1058
+ session.appendOutput([
1059
+ "",
1060
+ " No models available from pi-ai registry",
1061
+ ""
1062
+ ]);
1063
+ continue;
1064
+ }
1065
+ const selected = await showSelectList(tui, modelItems, { onExit: exitAll });
1066
+ if (!selected || !selected.value) continue;
1067
+ const selectedKey = selected.value;
1068
+ const slashIndex = selectedKey.indexOf("/");
1069
+ const providerName = selectedKey.slice(0, slashIndex);
1070
+ if (configuredProviders !== void 0 && providerName in configuredProviders && hasApiKey(state.current, providerName)) {
1071
+ setConfigValue(session, "llm.defaultModel", selectedKey);
1072
+ session.appendOutput([
1073
+ "",
1074
+ ` [ok] Default model set to: ${selectedKey}`,
1075
+ ""
1076
+ ]);
1077
+ } else {
1078
+ if (!configuredProviders || !(providerName in configuredProviders)) {
1079
+ const known = KNOWN_PROVIDERS.find((p) => p.id === providerName);
1080
+ const newProvider = { apiKey: "" };
1081
+ if (known?.apiFormat) newProvider.apiFormat = known.apiFormat;
1082
+ const settings = readSettings();
1083
+ _setByDotPath(settings, `llm.providers.${providerName}`, newProvider);
1084
+ writeSettings(settings);
1085
+ _setByDotPath(session.config, `llm.providers.${providerName}`, newProvider);
1086
+ state.current = readSettings();
1087
+ }
1088
+ await handleApiKeyConfig(providerName, "");
1089
+ state.current = readSettings();
1090
+ if (hasApiKey(state.current, providerName)) {
1091
+ setConfigValue(session, "llm.defaultModel", selectedKey);
1092
+ session.appendOutput([
1093
+ "",
1094
+ ` [ok] Default model set to: ${selectedKey}`,
1095
+ ""
1096
+ ]);
1097
+ }
1098
+ }
1099
+ } else if (value === "manage-providers") {
1100
+ const providerChoice = await showSelectList(tui, [...Object.entries(providers).map(([name]) => {
1101
+ const hasK = hasApiKey(state.current, name);
1102
+ return {
1103
+ value: `provider:${name}`,
1104
+ label: name,
1105
+ description: hasK ? "key configured" : "no key"
1106
+ };
1107
+ }), {
1108
+ value: "add-provider",
1109
+ label: "Add Provider",
1110
+ description: "Select from a list of known providers"
1111
+ }], { onExit: exitAll });
1112
+ if (!providerChoice) continue;
1113
+ const providerValue = providerChoice.value;
1114
+ if (providerValue.startsWith("provider:")) {
1115
+ const providerName = providerValue.slice(9);
1116
+ const action = await showSelectList(tui, [
1117
+ {
1118
+ value: "api-key",
1119
+ label: "Configure API Key",
1120
+ description: ""
1121
+ },
1122
+ {
1123
+ value: "model",
1124
+ label: "Select Model",
1125
+ description: ""
1126
+ },
1127
+ {
1128
+ value: "base-url",
1129
+ label: "Base URL",
1130
+ description: ""
1131
+ },
1132
+ {
1133
+ value: "set-default",
1134
+ label: "Set as Default",
1135
+ description: ""
1136
+ }
1137
+ ], { onExit: exitAll });
1138
+ if (!action) continue;
1139
+ switch (action.value) {
1140
+ case "api-key":
1141
+ await handleApiKeyConfig(providerName, String(_getByDotPath(state.current, `llm.providers.${providerName}.apiKey`) ?? ""));
1142
+ break;
1143
+ case "model":
1144
+ await handleModelSelect(providerName);
1145
+ break;
1146
+ case "base-url":
1147
+ await handleBaseUrlConfig(providerName, String(_getByDotPath(state.current, `llm.providers.${providerName}.baseUrl`) ?? ""));
1148
+ break;
1149
+ case "set-default":
1150
+ await handleSetDefault(providerName);
1151
+ break;
1152
+ }
1153
+ } else if (providerValue === "add-provider") {
1154
+ const selected = await showSelectList(tui, KNOWN_PROVIDERS.map((p) => ({
1155
+ value: p.id,
1156
+ label: `${p.label} (${p.id})`,
1157
+ description: p.apiFormat ? `API format: ${p.apiFormat}` : "OpenAI compatible"
1158
+ })), {
1159
+ maxVisible: 12,
1160
+ onExit: exitAll
1161
+ });
1162
+ if (!selected || !selected.value) continue;
1163
+ const providerName = selected.value;
1164
+ const existingProviders = _getByDotPath(state.current, "llm.providers");
1165
+ if (existingProviders && providerName in existingProviders) {
1166
+ session.appendOutput([
1167
+ "",
1168
+ ` ${providerName} already exists, use the provider entry to configure it`,
1169
+ ""
1170
+ ]);
1171
+ continue;
1172
+ }
1173
+ const known = KNOWN_PROVIDERS.find((p) => p.id === providerName);
1174
+ const newProvider = { apiKey: "" };
1175
+ if (known?.apiFormat) newProvider.apiFormat = known.apiFormat;
1176
+ const settings = readSettings();
1177
+ _setByDotPath(settings, `llm.providers.${providerName}`, newProvider);
1178
+ writeSettings(settings);
1179
+ _setByDotPath(session.config, `llm.providers.${providerName}`, newProvider);
1180
+ session.appendOutput([
1181
+ "",
1182
+ ` [ok] Added provider: ${providerName}`,
1183
+ ""
1184
+ ]);
1185
+ if ((await showSelectList(tui, [{
1186
+ value: "yes",
1187
+ label: "Yes, configure API Key now",
1188
+ description: "Go to API Key settings"
1189
+ }, {
1190
+ value: "no",
1191
+ label: "Later",
1192
+ description: "Return to main menu"
1193
+ }], { onExit: exitAll }))?.value === "yes") await handleApiKeyConfig(providerName, "");
1194
+ }
1195
+ } else if (value === "view-config") {
1196
+ const renderer = session.getRenderer();
1197
+ await showConfigView(tui, session.config, renderer);
1198
+ }
1199
+ }
1200
+ }
1201
+
297
1202
  //#endregion
298
1203
  //#region src/cli/repl/commands/status.ts
299
1204
  /**
@@ -321,6 +1226,7 @@ function createStatusCommand() {
321
1226
  * 继承自 pi-tui 的 Editor,添加 zapmyco 特有的快捷键处理:
322
1227
  * - Ctrl+C: 取消任务 / 二次退出
323
1228
  * - Ctrl+D: 退出
1229
+ * - Ctrl+O: 打开外部编辑器编辑输入
324
1230
  * - Escape: 取消当前输入
325
1231
  *
326
1232
  * 同时 override render() 以:
@@ -357,8 +1263,12 @@ var ZapmycoEditor = class extends Editor {
357
1263
  onCtrlC;
358
1264
  /** Ctrl+D 回调 */
359
1265
  onCtrlD;
1266
+ /** Ctrl+O 回调(打开外部编辑器) */
1267
+ onOpenEditor;
360
1268
  /** 是否正在执行(用于显示 loading) */
361
1269
  #executing = false;
1270
+ /** 是否显示 spinner(执行期间禁用输入但不一定显示 spinner) */
1271
+ #showSpinner = true;
362
1272
  /** loading 动画帧索引 */
363
1273
  #loadingFrame = 0;
364
1274
  /** loading 动画定时器 */
@@ -376,15 +1286,22 @@ var ZapmycoEditor = class extends Editor {
376
1286
  if (this.getText().length === 0 && this.onCtrlD) this.onCtrlD();
377
1287
  return;
378
1288
  }
1289
+ if (matchesKey(data, Key.ctrl("o")) && this.onOpenEditor) {
1290
+ this.onOpenEditor();
1291
+ return;
1292
+ }
379
1293
  super.handleInput(data);
380
1294
  }
381
1295
  /**
382
1296
  * 设置执行状态(控制 loading spinner 显示)
1297
+ * @param executing 是否正在执行
1298
+ * @param showSpinner 是否显示 spinner(默认 true)。设为 false 时仅禁用输入,不显示动画
383
1299
  */
384
- setExecuting(executing) {
385
- if (this.#executing === executing) return;
1300
+ setExecuting(executing, showSpinner = true) {
1301
+ if (this.#executing === executing && this.#showSpinner === showSpinner) return;
386
1302
  this.#executing = executing;
387
- if (executing) {
1303
+ this.#showSpinner = showSpinner;
1304
+ if (executing && showSpinner) {
388
1305
  this.#loadingFrame = 0;
389
1306
  this.#loadingTimer = setInterval(() => {
390
1307
  this.#loadingFrame = (this.#loadingFrame + 1) % LOADING_FRAMES.length;
@@ -417,7 +1334,7 @@ var ZapmycoEditor = class extends Editor {
417
1334
  for (let i = 0; i < contentLines.length; i++) {
418
1335
  const prefix = i === 0 ? PROMPT_PREFIX : " ".repeat(promptWidth);
419
1336
  let line;
420
- if (i === 0 && this.#executing) line = `${prefix}${LOADING_FRAMES[this.#loadingFrame]} ${contentLines[i]}`;
1337
+ if (i === 0 && this.#executing && this.#showSpinner) line = `${prefix}${LOADING_FRAMES[this.#loadingFrame]} ${contentLines[i]}`;
421
1338
  else line = prefix + contentLines[i];
422
1339
  contentLines[i] = truncateToWidth(line, width);
423
1340
  }
@@ -718,7 +1635,7 @@ const CRON_CONSTANTS = {
718
1635
  *
719
1636
  * @module cli/repl/cron/cron-scheduler
720
1637
  */
721
- const log$4 = logger.child("cron:scheduler");
1638
+ const log$5 = logger.child("cron:scheduler");
722
1639
  var CronScheduler = class extends EventEmitter {
723
1640
  store;
724
1641
  jobs = [];
@@ -739,7 +1656,7 @@ var CronScheduler = class extends EventEmitter {
739
1656
  if (this.running) return;
740
1657
  const loadedJobs = await this.store.load();
741
1658
  this.jobs = loadedJobs;
742
- log$4.info(`调度器启动,加载 ${loadedJobs.length} 个 durable 任务`);
1659
+ log$5.info(`调度器启动,加载 ${loadedJobs.length} 个 durable 任务`);
743
1660
  await this.handleMissedJobs();
744
1661
  this.checkAutoExpiry();
745
1662
  this.running = true;
@@ -755,7 +1672,7 @@ var CronScheduler = class extends EventEmitter {
755
1672
  clearInterval(this.timer);
756
1673
  this.timer = null;
757
1674
  }
758
- log$4.info("调度器已停止");
1675
+ log$5.info("调度器已停止");
759
1676
  }
760
1677
  /** 添加任务 */
761
1678
  async addJob(job) {
@@ -767,7 +1684,7 @@ var CronScheduler = class extends EventEmitter {
767
1684
  this.jobs.push(job);
768
1685
  await this.store.persist(this.jobs);
769
1686
  } else this.sessionJobs.push(job);
770
- log$4.info("任务已添加", {
1687
+ log$5.info("任务已添加", {
771
1688
  id: job.id,
772
1689
  cron: job.cron,
773
1690
  durable: job.durable
@@ -897,7 +1814,7 @@ var CronScheduler = class extends EventEmitter {
897
1814
  }, delay);
898
1815
  }
899
1816
  if (toDelete.length > 0) {
900
- log$4.info(`跳过 ${toDelete.length} 个错过的一次性任务(超出补发上限)`);
1817
+ log$5.info(`跳过 ${toDelete.length} 个错过的一次性任务(超出补发上限)`);
901
1818
  this.emit("missed-overflow", {
902
1819
  count: toDelete.length,
903
1820
  jobIds: toDelete.map((m) => m.id)
@@ -915,7 +1832,7 @@ var CronScheduler = class extends EventEmitter {
915
1832
  job.lastFiredAt = now;
916
1833
  job.fireCount++;
917
1834
  this.removeJob(job.id);
918
- log$4.info("任务已过期并触发最后一次", { id: job.id });
1835
+ log$5.info("任务已过期并触发最后一次", { id: job.id });
919
1836
  }
920
1837
  }
921
1838
  }
@@ -976,7 +1893,7 @@ function applyOneShotJitter(jobId, rawNext) {
976
1893
  *
977
1894
  * @module cli/repl/cron/cron-store
978
1895
  */
979
- const log$3 = logger.child("cron:store");
1896
+ const log$4 = logger.child("cron:store");
980
1897
  const STORE_FILE = join(join(homedir(), ".zapmyco", "cron"), "scheduled_tasks.json");
981
1898
  var CronStore = class {
982
1899
  filePath;
@@ -1000,13 +1917,13 @@ var CronStore = class {
1000
1917
  const raw = await readFile(this.filePath, "utf-8");
1001
1918
  const data = JSON.parse(raw);
1002
1919
  if (!Array.isArray(data)) {
1003
- log$3.warn("存储文件格式无效(非数组),将使用空列表");
1920
+ log$4.warn("存储文件格式无效(非数组),将使用空列表");
1004
1921
  return [];
1005
1922
  }
1006
1923
  return this.validateJobs(data);
1007
1924
  } catch (err) {
1008
1925
  if (err.code === "ENOENT") return [];
1009
- log$3.warn("加载定时任务文件失败,将使用空列表", { error: err instanceof Error ? err.message : String(err) });
1926
+ log$4.warn("加载定时任务文件失败,将使用空列表", { error: err instanceof Error ? err.message : String(err) });
1010
1927
  return [];
1011
1928
  }
1012
1929
  }
@@ -1053,7 +1970,7 @@ var CronStore = class {
1053
1970
  if (typeof obj.maxFires === "number") job.maxFires = obj.maxFires;
1054
1971
  valid.push(job);
1055
1972
  }
1056
- if (valid.length < raw.length) log$3.warn(`跳过 ${raw.length - valid.length} 个无效任务条目`);
1973
+ if (valid.length < raw.length) log$4.warn(`跳过 ${raw.length - valid.length} 个无效任务条目`);
1057
1974
  return valid;
1058
1975
  }
1059
1976
  };
@@ -1065,8 +1982,19 @@ function getCronStore() {
1065
1982
 
1066
1983
  //#endregion
1067
1984
  //#region src/cli/repl/history-store.ts
1985
+ /**
1986
+ * 会话历史存储
1987
+ *
1988
+ * 基于内存的环形缓冲区,记录 REPL 会话中的用户输入和执行结果。
1989
+ * 支持文件持久化到 ~/.zapmyco/history.json,跨会话恢复。
1990
+ */
1991
+ const log$3 = logger.child("history:store");
1068
1992
  /** 默认最大历史条数 */
1069
1993
  const DEFAULT_MAX_SIZE = 100;
1994
+ /** 历史文件存储路径 */
1995
+ function getHistoryFilePath() {
1996
+ return join(homedir(), SESSION_DIR_NAME, "history.json");
1997
+ }
1070
1998
  /**
1071
1999
  * 历史存储类
1072
2000
  */
@@ -1074,8 +2002,11 @@ var HistoryStore = class {
1074
2002
  entries = [];
1075
2003
  nextId = 1;
1076
2004
  maxSize;
2005
+ filePath;
1077
2006
  constructor(maxSize = DEFAULT_MAX_SIZE) {
1078
2007
  this.maxSize = maxSize;
2008
+ this.filePath = getHistoryFilePath();
2009
+ this.load();
1079
2010
  }
1080
2011
  /** 添加条目 */
1081
2012
  push(entry) {
@@ -1085,6 +2016,7 @@ var HistoryStore = class {
1085
2016
  };
1086
2017
  this.entries.push(newEntry);
1087
2018
  if (this.entries.length > this.maxSize) this.entries.shift();
2019
+ this.save();
1088
2020
  return newEntry;
1089
2021
  }
1090
2022
  /** 获取所有条目 */
@@ -1096,16 +2028,52 @@ var HistoryStore = class {
1096
2028
  const count = Math.min(n, this.entries.length);
1097
2029
  return this.entries.slice(-count);
1098
2030
  }
1099
- /** 清空所有条目 */
2031
+ /** 清空所有条目(同时清除持久化文件) */
1100
2032
  clear() {
1101
2033
  this.entries = [];
2034
+ this.save();
1102
2035
  }
1103
2036
  /** 搜索条目(按输入内容模糊匹配) */
1104
2037
  search(query) {
1105
2038
  const lowerQuery = query.toLowerCase();
1106
2039
  return this.entries.filter((entry) => entry.input.toLowerCase().includes(lowerQuery));
1107
2040
  }
2041
+ /** 从文件加载历史记录 */
2042
+ load() {
2043
+ try {
2044
+ ensureDir(dirname(this.filePath));
2045
+ const raw = readFileSync(this.filePath, "utf-8");
2046
+ const data = JSON.parse(raw);
2047
+ if (Array.isArray(data.entries)) {
2048
+ this.entries = data.entries.slice(-this.maxSize);
2049
+ this.nextId = typeof data.nextId === "number" ? data.nextId : 1;
2050
+ log$3.debug("历史记录已加载", {
2051
+ count: this.entries.length,
2052
+ nextId: this.nextId
2053
+ });
2054
+ }
2055
+ } catch {
2056
+ log$3.debug("无历史文件或加载失败,使用空历史");
2057
+ }
2058
+ }
2059
+ /** 持久化历史记录到文件 */
2060
+ save() {
2061
+ try {
2062
+ ensureDir(dirname(this.filePath));
2063
+ const data = JSON.stringify({
2064
+ entries: this.entries,
2065
+ nextId: this.nextId
2066
+ }, null, 2);
2067
+ writeFileSync(this.filePath, data, "utf-8");
2068
+ } catch (err) {
2069
+ log$3.warn("历史记录保存失败", { error: err instanceof Error ? err.message : String(err) });
2070
+ }
2071
+ }
1108
2072
  };
2073
+ /** 确保目录存在 */
2074
+ function ensureDir(dir) {
2075
+ mkdirSync(dir, { recursive: true });
2076
+ }
1109
2077
 
1110
2078
  //#endregion
1111
2079
  //#region src/cli/repl/input-parser.ts
@@ -1344,13 +2312,17 @@ var OutputFormatter = class {
1344
2312
  c.bold(" LLM:")
1345
2313
  ];
1346
2314
  lines.push(` 默认模型: ${config.llm.defaultModel}`);
1347
- const defaultModelConfig = config.llm.models[config.llm.defaultModel];
1348
- if (defaultModelConfig) {
1349
- lines.push(` 提供商: ${defaultModelConfig.provider}`);
1350
- lines.push(` 模型 ID: ${defaultModelConfig.modelId}`);
1351
- }
1352
- const auth = config.llm.providers[defaultModelConfig?.provider ?? "anthropic"];
1353
- lines.push(` API Key: ${auth?.apiKey ? c.gray("***已配置***") : c.red("(未配置)")}`);
2315
+ const defaultModelKey = config.llm.defaultModel;
2316
+ const slashIdx = defaultModelKey.indexOf("/");
2317
+ const defaultProvider = slashIdx > 0 ? defaultModelKey.slice(0, slashIdx) : "anthropic";
2318
+ const defaultModelName = slashIdx > 0 ? defaultModelKey.slice(slashIdx + 1) : defaultModelKey;
2319
+ const providerConfig = config.llm.providers[defaultProvider];
2320
+ const modelConfig = providerConfig?.models?.[defaultModelName];
2321
+ lines.push(` 提供商: ${defaultProvider}`);
2322
+ lines.push(` 模型 ID: ${modelConfig?.id ?? defaultModelName}`);
2323
+ if (modelConfig?.input && modelConfig.input.length > 0) lines.push(` 输入类型: ${modelConfig.input.join(", ")}`);
2324
+ lines.push(` API Key: ${providerConfig?.apiKey ? c.gray("***已配置***") : c.red("(未配置)")}`);
2325
+ if (providerConfig?.apiFormat) lines.push(` API 格式: ${providerConfig.apiFormat}`);
1354
2326
  lines.push(c.bold(" 调度器:"));
1355
2327
  lines.push(` 最大并行: ${config.scheduler.maxConcurrency}`);
1356
2328
  lines.push(` 单 Agent 最大并发: ${config.scheduler.maxPerAgent}`);
@@ -6557,26 +7529,32 @@ var TaskStore = class {
6557
7529
  };
6558
7530
 
6559
7531
  //#endregion
6560
- //#region src/llm/pi-ai-provider.ts
7532
+ //#region src/cli/repl/session.ts
6561
7533
  /**
6562
- * 解析模型标识符
7534
+ * REPL 会话核心(pi-tui 版)
6563
7535
  *
6564
- * 支持格式:provider/modelId(如 anthropic/claude-sonnet-4-20250514)
6565
- * 导出供其他模块复用(如 REPL Session 为 Agent 解析 Model 对象)
7536
+ * 使用 @mariozechner/pi-tui 框架替代 readline,
7537
+ * 实现完整的 TUI 交互式 REPL:
7538
+ * - Editor 组件自带上下边框
7539
+ * - 差量渲染,无闪烁
7540
+ * - 组件化布局,可扩展
6566
7541
  */
6567
- function parseModelKey(key) {
6568
- const slashIndex = key.indexOf("/");
6569
- if (slashIndex <= 0 || slashIndex >= key.length - 1) return null;
6570
- return {
6571
- provider: key.slice(0, slashIndex),
6572
- modelId: key.slice(slashIndex + 1)
6573
- };
6574
- }
6575
-
6576
- //#endregion
6577
- //#region src/cli/repl/session.ts
6578
7542
  const log = logger.child("repl:session");
6579
7543
  /**
7544
+ * 检查错误消息是否匹配 "No API key for provider",返回解决指引行
7545
+ */
7546
+ function getApiKeyErrorHelp(errorMessage) {
7547
+ const match = errorMessage.match(/No API key for provider: (\w+)/);
7548
+ if (!match) return [];
7549
+ const providerName = match[1];
7550
+ const envVarName = `${providerName.toUpperCase().replace(/-/g, "_")}_API_KEY`;
7551
+ return [
7552
+ "",
7553
+ chalk.yellow(` 请设置环境变量: export ${envVarName}=<your-api-key>`),
7554
+ chalk.yellow(` 或在 REPL 中使用: /config set llm.providers.${providerName}.apiKey <your-key>`)
7555
+ ];
7556
+ }
7557
+ /**
6580
7558
  * 输出区域组件
6581
7559
  *
6582
7560
  * 管理所有输出内容的行缓冲,实现 pi-tui 的 render 接口。
@@ -6599,6 +7577,12 @@ var OutputArea = class extends Container {
6599
7577
  else this.lines[this.lines.length - 1] += text;
6600
7578
  this.invalidate();
6601
7579
  }
7580
+ /** 替换最后一行的完整内容(用于 spinner 动画和首 chunk 替换) */
7581
+ replaceLastLine(text) {
7582
+ if (this.lines.length > 0) this.lines[this.lines.length - 1] = text;
7583
+ else this.lines.push(text);
7584
+ this.invalidate();
7585
+ }
6602
7586
  /** 清空所有内容 */
6603
7587
  clear() {
6604
7588
  this.lines = [];
@@ -6655,6 +7639,12 @@ var ReplSession = class {
6655
7639
  const theme = createTheme(this.options.color);
6656
7640
  const terminal = new ProcessTerminal();
6657
7641
  this.tui = new TUI(terminal);
7642
+ getKeybindings().setUserBindings({
7643
+ "tui.select.up": ["up", "k"],
7644
+ "tui.select.down": ["down", "j"],
7645
+ "tui.select.cancel": ["escape", "h"],
7646
+ "tui.select.confirm": ["enter", "l"]
7647
+ });
6658
7648
  this.outputArea = new OutputArea();
6659
7649
  this.editor = new ZapmycoEditor(this.tui, theme.editorTheme);
6660
7650
  const root = new Container();
@@ -6666,6 +7656,7 @@ var ReplSession = class {
6666
7656
  this.registry = new CommandRegistry(this);
6667
7657
  this.renderer = new Renderer(this.options);
6668
7658
  this.history = new HistoryStore(this.options.maxHistorySize);
7659
+ for (const entry of this.history.getAll()) this.editor.addToHistory(entry.input);
6669
7660
  this.agent = this.createReplAgent();
6670
7661
  this.taskStore = new TaskStore();
6671
7662
  this.taskStore.load();
@@ -6720,6 +7711,10 @@ var ReplSession = class {
6720
7711
  getRenderer() {
6721
7712
  return this.renderer;
6722
7713
  }
7714
+ /** 获取 TUI 实例(用于显示 overlay 菜单) */
7715
+ getTui() {
7716
+ return this.tui;
7717
+ }
6723
7718
  /** 获取历史存储引用 */
6724
7719
  getHistoryStore() {
6725
7720
  return this.history;
@@ -6749,10 +7744,19 @@ var ReplSession = class {
6749
7744
  const startTime = Date.now();
6750
7745
  let historyEntry;
6751
7746
  const taskId = `task-${Date.now()}`;
7747
+ const ZAPMYCO_PREFIX = "ZapMyco: ";
7748
+ const THINKING_PREFIX = " 💭 ";
7749
+ const colorEnabled = this.options.color;
7750
+ const userStyle = (s) => colorEnabled ? chalk.bold.cyan(s) : s;
7751
+ const responseStyle = (s) => s;
7752
+ const toolStyle = (s) => colorEnabled ? chalk.yellow(s) : s;
7753
+ const thinkingStyle = (s) => colorEnabled ? chalk.gray(s) : s;
7754
+ let spinnerActive = true;
7755
+ let spinnerInterval;
6752
7756
  try {
6753
7757
  this._state = "executing";
6754
7758
  this.updateStatsState();
6755
- this.editor.setExecuting(true);
7759
+ this.editor.setExecuting(true, false);
6756
7760
  this.currentTaskAbort = new AbortController();
6757
7761
  historyEntry = this.history.push({
6758
7762
  timestamp: Date.now(),
@@ -6762,24 +7766,64 @@ var ReplSession = class {
6762
7766
  goalId: `goal-${startTime}`,
6763
7767
  rawInput
6764
7768
  });
6765
- const goalLines = [`Me: ${rawInput}`, "ZapMyco: "];
6766
- this.outputArea.append(goalLines);
7769
+ this.outputArea.append([userStyle(`Me: ${rawInput}`), responseStyle(ZAPMYCO_PREFIX + LOADING_FRAMES[0])]);
7770
+ let spinnerFrame = 0;
7771
+ spinnerActive = true;
7772
+ spinnerInterval = setInterval(() => {
7773
+ if (!spinnerActive) return;
7774
+ spinnerFrame = (spinnerFrame + 1) % LOADING_FRAMES.length;
7775
+ this.outputArea.replaceLastLine(responseStyle(ZAPMYCO_PREFIX + LOADING_FRAMES[spinnerFrame]));
7776
+ this.tui.requestRender();
7777
+ }, 100);
7778
+ let firstOutputReceived = false;
7779
+ let outputAccumulator = "";
7780
+ let thinkingAccumulator = "";
7781
+ let streamMode = "response";
6767
7782
  const outputHandler = (event) => {
6768
- if (event.taskId === taskId) {
6769
- this.outputArea.appendText(event.text);
6770
- this.tui.requestRender();
7783
+ if (event.taskId !== taskId || !event.text) return;
7784
+ if (!firstOutputReceived) {
7785
+ firstOutputReceived = true;
7786
+ spinnerActive = false;
7787
+ clearInterval(spinnerInterval);
7788
+ streamMode = "response";
7789
+ thinkingAccumulator = "";
7790
+ outputAccumulator = event.text;
7791
+ this.outputArea.replaceLastLine(responseStyle(ZAPMYCO_PREFIX + outputAccumulator));
7792
+ } else if (streamMode !== "response") {
7793
+ streamMode = "response";
7794
+ thinkingAccumulator = "";
7795
+ outputAccumulator = event.text;
7796
+ this.outputArea.append([responseStyle(ZAPMYCO_PREFIX + outputAccumulator)]);
7797
+ } else {
7798
+ outputAccumulator += event.text;
7799
+ this.outputArea.replaceLastLine(responseStyle(ZAPMYCO_PREFIX + outputAccumulator));
6771
7800
  }
7801
+ this.tui.requestRender();
7802
+ };
7803
+ const thinkingHandler = (event) => {
7804
+ if (event.taskId !== taskId || !event.text) return;
7805
+ if (streamMode !== "thinking") {
7806
+ streamMode = "thinking";
7807
+ outputAccumulator = "";
7808
+ thinkingAccumulator = event.text;
7809
+ this.outputArea.append([thinkingStyle(THINKING_PREFIX + thinkingAccumulator)]);
7810
+ } else {
7811
+ thinkingAccumulator += event.text;
7812
+ this.outputArea.replaceLastLine(thinkingStyle(THINKING_PREFIX + thinkingAccumulator));
7813
+ }
7814
+ this.tui.requestRender();
6772
7815
  };
6773
7816
  const errorHandler = (event) => {
6774
7817
  if (event.taskId === taskId) log.error("Agent 执行中收到 error 事件", { error: event.error.message });
6775
7818
  };
6776
7819
  const progressHandler = (event) => {
6777
7820
  if (event.taskId === taskId && event.percent === 0) {
6778
- this.outputArea.append([` → ${event.message}`]);
7821
+ this.outputArea.append([toolStyle(` → ${event.message}`)]);
6779
7822
  this.tui.requestRender();
6780
7823
  }
6781
7824
  };
6782
7825
  this.agent.on(this.agent.EVENT_OUTPUT, outputHandler);
7826
+ this.agent.on(this.agent.EVENT_THINKING, thinkingHandler);
6783
7827
  this.agent.on(this.agent.EVENT_ERROR, errorHandler);
6784
7828
  this.agent.on(this.agent.EVENT_PROGRESS, progressHandler);
6785
7829
  this.currentTaskId = taskId;
@@ -6797,6 +7841,7 @@ var ReplSession = class {
6797
7841
  }
6798
7842
  });
6799
7843
  this.agent.off(this.agent.EVENT_OUTPUT, outputHandler);
7844
+ this.agent.off(this.agent.EVENT_THINKING, thinkingHandler);
6800
7845
  this.agent.off(this.agent.EVENT_ERROR, errorHandler);
6801
7846
  this.agent.off(this.agent.EVENT_PROGRESS, progressHandler);
6802
7847
  log.debug("Agent 执行完成", {
@@ -6806,9 +7851,53 @@ var ReplSession = class {
6806
7851
  duration: Date.now() - startTime
6807
7852
  });
6808
7853
  const outputText = typeof taskResult.output === "string" ? taskResult.output : taskResult.output != null ? JSON.stringify(taskResult.output) : null;
7854
+ if (spinnerActive) {
7855
+ spinnerActive = false;
7856
+ clearInterval(spinnerInterval);
7857
+ if (outputText) this.outputArea.replaceLastLine(responseStyle(ZAPMYCO_PREFIX + outputText));
7858
+ else if (taskResult.status !== "success") {
7859
+ const errorMsg = taskResult.error?.message ?? "Agent 执行失败(无详细错误信息)";
7860
+ this.outputArea.replaceLastLine(chalk.red(`ZapMyco: [错误] ${errorMsg}`));
7861
+ const helpLines = getApiKeyErrorHelp(errorMsg);
7862
+ if (helpLines.length > 0) {
7863
+ this.outputArea.append(helpLines);
7864
+ const providerMatch = errorMsg.match(/No API key for provider: (\w+)/);
7865
+ if (providerMatch) {
7866
+ const providerName = providerMatch[1];
7867
+ this.outputArea.append([""]);
7868
+ if ((await showSelectList(this.tui, [{
7869
+ value: "yes",
7870
+ label: "好的,我来输入 API Key",
7871
+ description: `直接输入 ${providerName} 的 API Key,立即配置并重试`
7872
+ }, {
7873
+ value: "no",
7874
+ label: "稍后再说",
7875
+ description: "回到对话"
7876
+ }], { title: `需要配置 ${providerName} 的 API Key` }))?.value === "yes") {
7877
+ const apiKey = await showTextInput(this.tui, `请输入 ${providerName} 的 API Key:`, "", "sk-...");
7878
+ if (apiKey && apiKey.length > 0) {
7879
+ const dotPath = `llm.providers.${providerName}.apiKey`;
7880
+ const settings = readSettings();
7881
+ _setByDotPath(settings, dotPath, apiKey);
7882
+ writeSettings(settings);
7883
+ _setByDotPath(this.config, dotPath, apiKey);
7884
+ this.applyConfigUpdate(dotPath);
7885
+ this.outputArea.append([
7886
+ "",
7887
+ chalk.green(`已配置 ${providerName} 的 API Key,正在重试...`),
7888
+ ""
7889
+ ]);
7890
+ return await this.executeGoal(rawInput);
7891
+ }
7892
+ }
7893
+ this.outputArea.append([""]);
7894
+ }
7895
+ }
7896
+ } else this.outputArea.replaceLastLine(chalk.red("ZapMyco: [错误] 模型未返回任何内容,请检查 API Key 配置"));
7897
+ }
6809
7898
  if (taskResult.status !== "success") {
6810
7899
  const errorMsg = taskResult.error?.message ?? "Agent 执行失败(无详细错误信息)";
6811
- this.outputArea.appendText(`[错误] ${errorMsg}`);
7900
+ if (!spinnerActive || outputText) this.outputArea.appendText(`[错误] ${errorMsg}`);
6812
7901
  log.error("Agent 执行返回 failure", {
6813
7902
  taskId,
6814
7903
  error: taskResult.error,
@@ -6854,14 +7943,52 @@ var ReplSession = class {
6854
7943
  } catch (error) {
6855
7944
  const err = error instanceof Error ? error : new Error(String(error));
6856
7945
  log.error("目标执行失败", { input: rawInput }, err);
7946
+ spinnerActive = false;
7947
+ clearInterval(spinnerInterval);
6857
7948
  this.stats.totalRequests++;
6858
7949
  this.stats.failureCount++;
6859
7950
  eventBus.emit("goal:failed", {
6860
7951
  goalId: `goal-${startTime}`,
6861
7952
  error: err
6862
7953
  });
6863
- const errorLines = this.renderer.renderError(err);
6864
- this.outputArea.append(errorLines);
7954
+ this.outputArea.replaceLastLine(responseStyle(`${ZAPMYCO_PREFIX}[错误] ${err.message}`));
7955
+ const helpLines = getApiKeyErrorHelp(err.message);
7956
+ if (helpLines.length > 0) {
7957
+ this.outputArea.append(helpLines);
7958
+ const providerMatch = err.message.match(/No API key for provider: (\w+)/);
7959
+ if (providerMatch) {
7960
+ const providerName = providerMatch[1];
7961
+ this.outputArea.append([""]);
7962
+ if ((await showSelectList(this.tui, [{
7963
+ value: "yes",
7964
+ label: "好的,我来输入 API Key",
7965
+ description: `直接输入 ${providerName} 的 API Key,立即配置并重试`
7966
+ }, {
7967
+ value: "no",
7968
+ label: "稍后再说",
7969
+ description: "回到对话"
7970
+ }], { title: `需要配置 ${providerName} 的 API Key` }))?.value === "yes") {
7971
+ const apiKey = await showTextInput(this.tui, `请输入 ${providerName} 的 API Key:`, "", "sk-...");
7972
+ if (apiKey && apiKey.length > 0) {
7973
+ const dotPath = `llm.providers.${providerName}.apiKey`;
7974
+ const settings = readSettings();
7975
+ _setByDotPath(settings, dotPath, apiKey);
7976
+ writeSettings(settings);
7977
+ _setByDotPath(this.config, dotPath, apiKey);
7978
+ this.applyConfigUpdate(dotPath);
7979
+ this.outputArea.append([
7980
+ "",
7981
+ chalk.green(`已配置 ${providerName} 的 API Key,正在重试...`),
7982
+ ""
7983
+ ]);
7984
+ return await this.executeGoal(rawInput);
7985
+ }
7986
+ }
7987
+ this.outputArea.append([""]);
7988
+ }
7989
+ }
7990
+ const errorLines = this.renderer.renderError(err).slice(1);
7991
+ if (errorLines.length > 0) this.outputArea.append(errorLines);
6865
7992
  const duration = Date.now() - startTime;
6866
7993
  return {
6867
7994
  goalId: `goal-${startTime}`,
@@ -6878,6 +8005,8 @@ var ReplSession = class {
6878
8005
  }
6879
8006
  };
6880
8007
  } finally {
8008
+ spinnerActive = false;
8009
+ if (spinnerInterval) clearInterval(spinnerInterval);
6881
8010
  this._state = "idle";
6882
8011
  this.updateStatsState();
6883
8012
  this.editor.setExecuting(false);
@@ -6906,9 +8035,11 @@ var ReplSession = class {
6906
8035
  case "incomplete": break;
6907
8036
  case "command":
6908
8037
  await this.registry.dispatch(parsed);
8038
+ this.editor.addToHistory(line);
6909
8039
  break;
6910
8040
  case "goal":
6911
8041
  await this.executeGoal(parsed.rawInput);
8042
+ this.editor.addToHistory(line);
6912
8043
  break;
6913
8044
  }
6914
8045
  }
@@ -6921,6 +8052,27 @@ var ReplSession = class {
6921
8052
  this.registry.register(createConfigCommand());
6922
8053
  this.registry.register(createAgentsCommand());
6923
8054
  this.registry.register(createStatusCommand());
8055
+ this.registry.register(createSettingsCommand());
8056
+ this.buildAutocompleteProvider();
8057
+ }
8058
+ /** 构建并设置 autocomplete provider,将命令注册表中的命令接入 pi-tui 补全系统 */
8059
+ buildAutocompleteProvider() {
8060
+ const slashCommands = [];
8061
+ for (const cmd of this.registry.listCommands()) {
8062
+ const base = {
8063
+ name: cmd.name,
8064
+ description: cmd.description
8065
+ };
8066
+ if (cmd.usage !== `/${cmd.name}`) base.argumentHint = cmd.usage;
8067
+ slashCommands.push(base);
8068
+ for (const alias of cmd.aliases) slashCommands.push({
8069
+ name: alias,
8070
+ description: `${cmd.description}(别名: /${cmd.name})`
8071
+ });
8072
+ }
8073
+ const provider = new CombinedAutocompleteProvider(slashCommands, process.cwd(), null);
8074
+ this.editor.setAutocompleteProvider(provider);
8075
+ this.editor.setAutocompleteMaxVisible(12);
6924
8076
  }
6925
8077
  /**
6926
8078
  * 创建 REPL 专用的 Agent 实例
@@ -6940,39 +8092,25 @@ var ReplSession = class {
6940
8092
  }],
6941
8093
  runtimeConfig: this.config.agentRuntime ?? {}
6942
8094
  });
6943
- agent.innerAgent.state.model = this.resolveModelForAgent();
6944
- agent.innerAgent.getApiKey = (_provider) => {
6945
- const modelKey = this.config.llm.defaultModel;
6946
- const providerName = this.config.llm.models[modelKey]?.provider;
6947
- if (providerName) return this.config.llm.providers[providerName]?.apiKey;
6948
- };
6949
- return agent;
6950
- }
6951
- /**
6952
- * 为 Agent 解析 pi-ai Model 对象
6953
- *
6954
- * 复用 PiAiProvider 的模型解析逻辑(parseModelKey + getModel),
6955
- * 但不依赖 chat/chatStream 方法。
6956
- */
6957
- resolveModelForAgent() {
6958
- const modelKey = this.config.llm.defaultModel;
6959
- const parsed = parseModelKey(modelKey);
6960
- if (!parsed) throw new Error(`无效的模型标识符: ${modelKey}`);
6961
- const modelConfig = this.config.llm.models[modelKey];
6962
- const provider = modelConfig?.provider ?? parsed.provider;
6963
- const modelId = modelConfig?.modelId ?? parsed.modelId;
6964
- const baseModelId = provider === "anthropic" ? "claude-sonnet-4-20250514" : modelId;
6965
- let model;
6966
- try {
6967
- model = getModel(provider, baseModelId);
6968
- } catch {
6969
- model = getModel("anthropic", "claude-sonnet-4-20250514");
8095
+ const facade = new AgentLlmFacade(this.config.llm);
8096
+ agent.innerAgent.state.model = facade.resolvePiModel();
8097
+ agent.innerAgent.getApiKey = facade.createGetApiKeyFn();
8098
+ agent.llmFacade = facade;
8099
+ const defaultModelInfo = facade.getModelInfo();
8100
+ if (defaultModelInfo) {
8101
+ if (!facade.getApiKey(defaultModelInfo.provider)) {
8102
+ const providerName = defaultModelInfo.provider;
8103
+ const envVar = providerName.toUpperCase() + "_API_KEY";
8104
+ this.outputArea.append([
8105
+ chalk.red(`[!] 提供商 "${providerName}" 没有配置 API Key`),
8106
+ chalk.yellow(` 请设置环境变量: export ${envVar}=<your-key>`),
8107
+ chalk.yellow(` 或在 REPL 中使用: /config set llm.providers.${providerName}.apiKey <your-key>`),
8108
+ ""
8109
+ ]);
8110
+ log.warn("默认提供商缺少 API Key", { provider: providerName });
8111
+ }
6970
8112
  }
6971
- if (!model) throw new Error(`无法初始化模型 ${modelKey}:pi-ai 返回了无效的模型对象`);
6972
- model.name = modelKey;
6973
- model.id = modelId;
6974
- if (modelConfig?.baseUrl) model.baseUrl = modelConfig.baseUrl;
6975
- return model;
8113
+ return agent;
6976
8114
  }
6977
8115
  /**
6978
8116
  * 注册 REPL 场景下的基础工具
@@ -7010,6 +8148,7 @@ var ReplSession = class {
7010
8148
  const snapshot = buildSkillSnapshot(entries, skillConfig.maxSkillsInPrompt);
7011
8149
  this.agent.skillPrompt = snapshot.prompt;
7012
8150
  this._registerSkillCommands(entries);
8151
+ this.buildAutocompleteProvider();
7013
8152
  log.info("Skill 系统初始化完成", {
7014
8153
  count: snapshot.count,
7015
8154
  names: snapshot.names
@@ -7077,6 +8216,7 @@ var ReplSession = class {
7077
8216
  this.editor.onCtrlD = () => {
7078
8217
  this.shutdown("收到 EOF (Ctrl+D)");
7079
8218
  };
8219
+ this.editor.onOpenEditor = () => this.openInEditor();
7080
8220
  }
7081
8221
  /** 设置信号处理 */
7082
8222
  setupSignalHandlers() {
@@ -7099,6 +8239,71 @@ var ReplSession = class {
7099
8239
  this.currentTaskAbort = null;
7100
8240
  }
7101
8241
  }
8242
+ /**
8243
+ * 打开外部编辑器(vim / $EDITOR)编辑当前输入内容
8244
+ *
8245
+ * 流程:
8246
+ * 1. 将编辑器当前文本写入临时文件
8247
+ * 2. 暂停 TUI(恢复终端 cooked 模式)
8248
+ * 3. 启动外部编辑器,用户编辑并保存退出
8249
+ * 4. 读取编辑后的内容并更新编辑器
8250
+ * 5. 恢复 TUI 并重绘
8251
+ */
8252
+ openInEditor() {
8253
+ const tmpFile = join(tmpdir(), "zapmyco-editor-input.txt");
8254
+ let tuiStopped = false;
8255
+ try {
8256
+ const currentText = this.editor.getExpandedText();
8257
+ writeFileSync(tmpFile, currentText, "utf-8");
8258
+ this.tui.stop();
8259
+ tuiStopped = true;
8260
+ const editorCmd = process.env.VISUAL || process.env.EDITOR || "vim";
8261
+ const result = spawnSync(editorCmd, [tmpFile], { stdio: "inherit" });
8262
+ const newText = readFileSync(tmpFile, "utf-8");
8263
+ if (newText !== currentText) this.editor.setText(newText);
8264
+ if (result.error) {
8265
+ const err = result.error;
8266
+ if (err.code === "ENOENT") this.outputArea.append([
8267
+ "",
8268
+ `未找到编辑器: ${editorCmd},请设置 $EDITOR 环境变量`,
8269
+ ""
8270
+ ]);
8271
+ else this.outputArea.append([
8272
+ "",
8273
+ `编辑器启动失败: ${err.message}`,
8274
+ ""
8275
+ ]);
8276
+ }
8277
+ } catch (err) {
8278
+ const message = err instanceof Error ? err.message : String(err);
8279
+ this.outputArea.append([
8280
+ "",
8281
+ `打开编辑器失败: ${message}`,
8282
+ ""
8283
+ ]);
8284
+ } finally {
8285
+ if (tuiStopped) {
8286
+ this.tui.start();
8287
+ this.tui.requestRender(true);
8288
+ }
8289
+ try {
8290
+ unlinkSync(tmpFile);
8291
+ } catch {}
8292
+ }
8293
+ }
8294
+ /**
8295
+ * 应用配置更新到运行中的 Agent(无需重启)
8296
+ *
8297
+ * 当前处理以 "llm." 开头的配置变更,重新创建 AgentLlmFacade
8298
+ * 并注入到运行中的 Agent 实例,使新 Key/模型立即生效。
8299
+ */
8300
+ applyConfigUpdate(key) {
8301
+ if (!key.startsWith("llm.")) return;
8302
+ const newFacade = new AgentLlmFacade(this.config.llm);
8303
+ this.agent.innerAgent.state.model = newFacade.resolvePiModel();
8304
+ this.agent.innerAgent.getApiKey = newFacade.createGetApiKeyFn();
8305
+ this.agent.llmFacade = newFacade;
8306
+ }
7102
8307
  /** 更新统计中的状态字段 */
7103
8308
  updateStatsState() {
7104
8309
  this.stats.state = this._state;
@@ -7118,7 +8323,14 @@ var ReplSession = class {
7118
8323
  * 加载配置 → 创建会话 → 进入输入循环
7119
8324
  */
7120
8325
  async function startRepl() {
7121
- await new ReplSession(await loadConfig()).start();
8326
+ configureLogger({
8327
+ logFilePath: join(homedir(), ".zapmyco", "logs", "zapmyco.log"),
8328
+ quiet: true
8329
+ });
8330
+ const config = await loadConfig();
8331
+ if (config.logging?.level) configureLogger({ level: config.logging.level });
8332
+ if (config.logging?.file) configureLogger({ logFilePath: config.logging.file });
8333
+ await new ReplSession(config).start();
7122
8334
  }
7123
8335
 
7124
8336
  //#endregion