zapmyco 0.6.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,18 +1,18 @@
1
1
  #!/usr/bin/env node
2
- import { D as eventBus, F as __require, M as APP_NAME, N as SESSION_DIR_NAME, O as buildSkillSnapshot, P as __VERSION__, a as SubAgentManager, c as logger, g as ZapmycoErrorCode, k as loadSkills, m as WebError, s as configureLogger, t as loadConfig, v as createLlmBasedAgent } from "../loader-CyGlMdl7.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 { CombinedAutocompleteProvider, 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";
@@ -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,6 +1263,8 @@ 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;
362
1270
  /** 是否显示 spinner(执行期间禁用输入但不一定显示 spinner) */
@@ -378,6 +1286,10 @@ var ZapmycoEditor = class extends Editor {
378
1286
  if (this.getText().length === 0 && this.onCtrlD) this.onCtrlD();
379
1287
  return;
380
1288
  }
1289
+ if (matchesKey(data, Key.ctrl("o")) && this.onOpenEditor) {
1290
+ this.onOpenEditor();
1291
+ return;
1292
+ }
381
1293
  super.handleInput(data);
382
1294
  }
383
1295
  /**
@@ -1400,13 +2312,17 @@ var OutputFormatter = class {
1400
2312
  c.bold(" LLM:")
1401
2313
  ];
1402
2314
  lines.push(` 默认模型: ${config.llm.defaultModel}`);
1403
- const defaultModelConfig = config.llm.models[config.llm.defaultModel];
1404
- if (defaultModelConfig) {
1405
- lines.push(` 提供商: ${defaultModelConfig.provider}`);
1406
- lines.push(` 模型 ID: ${defaultModelConfig.modelId}`);
1407
- }
1408
- const auth = config.llm.providers[defaultModelConfig?.provider ?? "anthropic"];
1409
- 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}`);
1410
2326
  lines.push(c.bold(" 调度器:"));
1411
2327
  lines.push(` 最大并行: ${config.scheduler.maxConcurrency}`);
1412
2328
  lines.push(` 单 Agent 最大并发: ${config.scheduler.maxPerAgent}`);
@@ -6613,26 +7529,32 @@ var TaskStore = class {
6613
7529
  };
6614
7530
 
6615
7531
  //#endregion
6616
- //#region src/llm/pi-ai-provider.ts
7532
+ //#region src/cli/repl/session.ts
6617
7533
  /**
6618
- * 解析模型标识符
7534
+ * REPL 会话核心(pi-tui 版)
6619
7535
  *
6620
- * 支持格式:provider/modelId(如 anthropic/claude-sonnet-4-20250514)
6621
- * 导出供其他模块复用(如 REPL Session 为 Agent 解析 Model 对象)
7536
+ * 使用 @mariozechner/pi-tui 框架替代 readline,
7537
+ * 实现完整的 TUI 交互式 REPL:
7538
+ * - Editor 组件自带上下边框
7539
+ * - 差量渲染,无闪烁
7540
+ * - 组件化布局,可扩展
6622
7541
  */
6623
- function parseModelKey(key) {
6624
- const slashIndex = key.indexOf("/");
6625
- if (slashIndex <= 0 || slashIndex >= key.length - 1) return null;
6626
- return {
6627
- provider: key.slice(0, slashIndex),
6628
- modelId: key.slice(slashIndex + 1)
6629
- };
6630
- }
6631
-
6632
- //#endregion
6633
- //#region src/cli/repl/session.ts
6634
7542
  const log = logger.child("repl:session");
6635
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
+ /**
6636
7558
  * 输出区域组件
6637
7559
  *
6638
7560
  * 管理所有输出内容的行缓冲,实现 pi-tui 的 render 接口。
@@ -6717,6 +7639,12 @@ var ReplSession = class {
6717
7639
  const theme = createTheme(this.options.color);
6718
7640
  const terminal = new ProcessTerminal();
6719
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
+ });
6720
7648
  this.outputArea = new OutputArea();
6721
7649
  this.editor = new ZapmycoEditor(this.tui, theme.editorTheme);
6722
7650
  const root = new Container();
@@ -6783,6 +7711,10 @@ var ReplSession = class {
6783
7711
  getRenderer() {
6784
7712
  return this.renderer;
6785
7713
  }
7714
+ /** 获取 TUI 实例(用于显示 overlay 菜单) */
7715
+ getTui() {
7716
+ return this.tui;
7717
+ }
6786
7718
  /** 获取历史存储引用 */
6787
7719
  getHistoryStore() {
6788
7720
  return this.history;
@@ -6813,6 +7745,12 @@ var ReplSession = class {
6813
7745
  let historyEntry;
6814
7746
  const taskId = `task-${Date.now()}`;
6815
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;
6816
7754
  let spinnerActive = true;
6817
7755
  let spinnerInterval;
6818
7756
  try {
@@ -6828,37 +7766,64 @@ var ReplSession = class {
6828
7766
  goalId: `goal-${startTime}`,
6829
7767
  rawInput
6830
7768
  });
6831
- this.outputArea.append([`Me: ${rawInput}`, ZAPMYCO_PREFIX + LOADING_FRAMES[0]]);
7769
+ this.outputArea.append([userStyle(`Me: ${rawInput}`), responseStyle(ZAPMYCO_PREFIX + LOADING_FRAMES[0])]);
6832
7770
  let spinnerFrame = 0;
6833
7771
  spinnerActive = true;
6834
7772
  spinnerInterval = setInterval(() => {
6835
7773
  if (!spinnerActive) return;
6836
7774
  spinnerFrame = (spinnerFrame + 1) % LOADING_FRAMES.length;
6837
- this.outputArea.replaceLastLine(ZAPMYCO_PREFIX + LOADING_FRAMES[spinnerFrame]);
7775
+ this.outputArea.replaceLastLine(responseStyle(ZAPMYCO_PREFIX + LOADING_FRAMES[spinnerFrame]));
6838
7776
  this.tui.requestRender();
6839
7777
  }, 100);
6840
7778
  let firstOutputReceived = false;
7779
+ let outputAccumulator = "";
7780
+ let thinkingAccumulator = "";
7781
+ let streamMode = "response";
6841
7782
  const outputHandler = (event) => {
6842
- if (event.taskId === taskId) {
6843
- if (!firstOutputReceived) {
6844
- firstOutputReceived = true;
6845
- spinnerActive = false;
6846
- clearInterval(spinnerInterval);
6847
- this.outputArea.replaceLastLine(ZAPMYCO_PREFIX + event.text);
6848
- } else this.outputArea.appendText(event.text);
6849
- 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));
6850
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();
6851
7815
  };
6852
7816
  const errorHandler = (event) => {
6853
7817
  if (event.taskId === taskId) log.error("Agent 执行中收到 error 事件", { error: event.error.message });
6854
7818
  };
6855
7819
  const progressHandler = (event) => {
6856
7820
  if (event.taskId === taskId && event.percent === 0) {
6857
- this.outputArea.append([` → ${event.message}`]);
7821
+ this.outputArea.append([toolStyle(` → ${event.message}`)]);
6858
7822
  this.tui.requestRender();
6859
7823
  }
6860
7824
  };
6861
7825
  this.agent.on(this.agent.EVENT_OUTPUT, outputHandler);
7826
+ this.agent.on(this.agent.EVENT_THINKING, thinkingHandler);
6862
7827
  this.agent.on(this.agent.EVENT_ERROR, errorHandler);
6863
7828
  this.agent.on(this.agent.EVENT_PROGRESS, progressHandler);
6864
7829
  this.currentTaskId = taskId;
@@ -6876,6 +7841,7 @@ var ReplSession = class {
6876
7841
  }
6877
7842
  });
6878
7843
  this.agent.off(this.agent.EVENT_OUTPUT, outputHandler);
7844
+ this.agent.off(this.agent.EVENT_THINKING, thinkingHandler);
6879
7845
  this.agent.off(this.agent.EVENT_ERROR, errorHandler);
6880
7846
  this.agent.off(this.agent.EVENT_PROGRESS, progressHandler);
6881
7847
  log.debug("Agent 执行完成", {
@@ -6888,11 +7854,50 @@ var ReplSession = class {
6888
7854
  if (spinnerActive) {
6889
7855
  spinnerActive = false;
6890
7856
  clearInterval(spinnerInterval);
6891
- if (outputText) this.outputArea.replaceLastLine(ZAPMYCO_PREFIX + outputText);
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 配置"));
6892
7897
  }
6893
7898
  if (taskResult.status !== "success") {
6894
7899
  const errorMsg = taskResult.error?.message ?? "Agent 执行失败(无详细错误信息)";
6895
- this.outputArea.appendText(`[错误] ${errorMsg}`);
7900
+ if (!spinnerActive || outputText) this.outputArea.appendText(`[错误] ${errorMsg}`);
6896
7901
  log.error("Agent 执行返回 failure", {
6897
7902
  taskId,
6898
7903
  error: taskResult.error,
@@ -6946,7 +7951,42 @@ var ReplSession = class {
6946
7951
  goalId: `goal-${startTime}`,
6947
7952
  error: err
6948
7953
  });
6949
- this.outputArea.replaceLastLine(`ZapMyco: [错误] ${err.message}`);
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
+ }
6950
7990
  const errorLines = this.renderer.renderError(err).slice(1);
6951
7991
  if (errorLines.length > 0) this.outputArea.append(errorLines);
6952
7992
  const duration = Date.now() - startTime;
@@ -7012,6 +8052,7 @@ var ReplSession = class {
7012
8052
  this.registry.register(createConfigCommand());
7013
8053
  this.registry.register(createAgentsCommand());
7014
8054
  this.registry.register(createStatusCommand());
8055
+ this.registry.register(createSettingsCommand());
7015
8056
  this.buildAutocompleteProvider();
7016
8057
  }
7017
8058
  /** 构建并设置 autocomplete provider,将命令注册表中的命令接入 pi-tui 补全系统 */
@@ -7051,39 +8092,25 @@ var ReplSession = class {
7051
8092
  }],
7052
8093
  runtimeConfig: this.config.agentRuntime ?? {}
7053
8094
  });
7054
- agent.innerAgent.state.model = this.resolveModelForAgent();
7055
- agent.innerAgent.getApiKey = (_provider) => {
7056
- const modelKey = this.config.llm.defaultModel;
7057
- const providerName = this.config.llm.models[modelKey]?.provider;
7058
- if (providerName) return this.config.llm.providers[providerName]?.apiKey;
7059
- };
7060
- return agent;
7061
- }
7062
- /**
7063
- * 为 Agent 解析 pi-ai Model 对象
7064
- *
7065
- * 复用 PiAiProvider 的模型解析逻辑(parseModelKey + getModel),
7066
- * 但不依赖 chat/chatStream 方法。
7067
- */
7068
- resolveModelForAgent() {
7069
- const modelKey = this.config.llm.defaultModel;
7070
- const parsed = parseModelKey(modelKey);
7071
- if (!parsed) throw new Error(`无效的模型标识符: ${modelKey}`);
7072
- const modelConfig = this.config.llm.models[modelKey];
7073
- const provider = modelConfig?.provider ?? parsed.provider;
7074
- const modelId = modelConfig?.modelId ?? parsed.modelId;
7075
- const baseModelId = provider === "anthropic" ? "claude-sonnet-4-20250514" : modelId;
7076
- let model;
7077
- try {
7078
- model = getModel(provider, baseModelId);
7079
- } catch {
7080
- 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
+ }
7081
8112
  }
7082
- if (!model) throw new Error(`无法初始化模型 ${modelKey}:pi-ai 返回了无效的模型对象`);
7083
- model.name = modelKey;
7084
- model.id = modelId;
7085
- if (modelConfig?.baseUrl) model.baseUrl = modelConfig.baseUrl;
7086
- return model;
8113
+ return agent;
7087
8114
  }
7088
8115
  /**
7089
8116
  * 注册 REPL 场景下的基础工具
@@ -7189,6 +8216,7 @@ var ReplSession = class {
7189
8216
  this.editor.onCtrlD = () => {
7190
8217
  this.shutdown("收到 EOF (Ctrl+D)");
7191
8218
  };
8219
+ this.editor.onOpenEditor = () => this.openInEditor();
7192
8220
  }
7193
8221
  /** 设置信号处理 */
7194
8222
  setupSignalHandlers() {
@@ -7211,6 +8239,71 @@ var ReplSession = class {
7211
8239
  this.currentTaskAbort = null;
7212
8240
  }
7213
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
+ }
7214
8307
  /** 更新统计中的状态字段 */
7215
8308
  updateStatsState() {
7216
8309
  this.stats.state = this._state;