@antaif3ng/til-work 0.2.0 → 0.4.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.
@@ -5,7 +5,7 @@ import * as readline from "node:readline";
5
5
  import { readFileSync } from "node:fs";
6
6
  import chalk from "chalk";
7
7
  import { TilSession } from "../core/session.js";
8
- import { getConfigPath, loadConfig, saveConfig } from "../core/config.js";
8
+ import { getConfigPath, loadConfig, saveConfig, saveProjectMcpServers, loadProjectMcpServers } from "../core/config.js";
9
9
  import { VERSION } from "../version.js";
10
10
  import { createToolCallRequestHandler } from "../core/tool-permissions.js";
11
11
  import { formatTokenCount } from "../core/pricing.js";
@@ -16,8 +16,8 @@ const COMMANDS = {
16
16
  "/help": "显示帮助信息",
17
17
  "/clear": "清空对话历史",
18
18
  "/model": "查看/切换/管理模型",
19
- "/skills": "查看已加载的技能",
20
- "/mcp": "查看已连接的 MCP 服务和工具",
19
+ "/skills": "查看/管理技能 (reload/install/enable/disable/rm)",
20
+ "/mcp": "查看/管理 MCP 服务 (add/rm/reload)",
21
21
  "/extensions": "查看已加载的扩展和 MCP 服务",
22
22
  "/memory": "查看当前记忆内容",
23
23
  "/sessions": "查看历史会话列表",
@@ -25,6 +25,15 @@ const COMMANDS = {
25
25
  "/config": "查看当前配置",
26
26
  "/exit": "退出 TIL",
27
27
  };
28
+ const TIPS = [
29
+ "输入 / 查看所有可用命令,方向键选择",
30
+ "使用 /model 切换模型",
31
+ "按 Ctrl+C 或 Esc 中止正在执行的请求",
32
+ "使用 /memory 查看记忆内容",
33
+ "使用 /sessions 查看历史会话",
34
+ "输入 @ 引用文件内容(支持搜索和方向键选择)",
35
+ "换行: 行尾输入 \\ 再回车,或 Ctrl+J / Alt+Enter",
36
+ ];
28
37
  // ── UI helpers ──
29
38
  function getBorderWidth() {
30
39
  return Math.min(process.stdout.columns || 80, 80);
@@ -32,8 +41,16 @@ function getBorderWidth() {
32
41
  function printBorder(ch = "─") {
33
42
  console.log(chalk.dim(ch.repeat(getBorderWidth())));
34
43
  }
44
+ function shortenDir(cwd) {
45
+ return cwd.replace(process.env.HOME || "", "~");
46
+ }
35
47
  // ── Interactive Popup Menu ──
36
- const PROMPT_WIDTH = 3; // visible width of " › "
48
+ const PROMPT_WIDTH = 3; // visible width of " › " or " … "
49
+ function resetMenu(menu) {
50
+ menu.visible = false;
51
+ menu.items = [];
52
+ menu.selectedIndex = 0;
53
+ }
37
54
  function getCommandEntries(session) {
38
55
  const allCmds = Object.keys(COMMANDS);
39
56
  const skillCmds = session.getSkillNames().map((n) => `/skill:${n}`);
@@ -54,13 +71,11 @@ function filterEntries(entries, filter) {
54
71
  function getModelMenuEntries(filter, session) {
55
72
  const entries = [];
56
73
  const currentId = session.model.id;
57
- // Current model first
58
74
  entries.push({
59
75
  label: currentId,
60
76
  detail: "当前",
61
77
  value: `/model ${currentId}`,
62
78
  });
63
- // Load config synchronously — config.models is already migrated at startup
64
79
  const cfg = session.config;
65
80
  if (cfg.models) {
66
81
  for (const [id, m] of Object.entries(cfg.models)) {
@@ -88,44 +103,63 @@ function getFileMenuEntries(prefix, cwd) {
88
103
  value: s.value,
89
104
  }));
90
105
  }
106
+ const PLACEHOLDER = "Ctrl+J 或 \\+Enter 换行";
91
107
  /**
92
- * Render the popup menu below the input line using relative cursor movement.
93
- * Unlike \x1b[s / \x1b[u (save/restore absolute position), relative movement
94
- * via \x1b[nA works correctly even when the terminal scrolls during output.
108
+ * Render the area below the input cursor: popup menu (if any) +
109
+ * underline + status line. Only called at prompt start and on menu
110
+ * state changes NEVER on every keystroke so CJK IME is not disturbed.
95
111
  */
96
- function renderMenu(menu, cursorCol) {
97
- if (!menu.visible || menu.items.length === 0)
112
+ function renderBelowArea(menu, below, session, cursorCol) {
113
+ if (!below.active)
98
114
  return;
99
- const totalLinesToClear = Math.max(menu.items.length, menu.renderedLines);
100
- const padWidth = menu.type === "file" ? 24 : 16;
115
+ const menuItemCount = menu.visible && menu.items.length > 0 ? menu.items.length : 0;
116
+ const fixedLines = 2; // underline + status
117
+ const contentLines = menuItemCount + fixedLines;
118
+ const totalLinesToProcess = Math.max(contentLines, below.renderedLines);
119
+ // Pre-scroll: ensure enough vertical space below cursor so that
120
+ // writing \n never causes unexpected scrolling after rendering.
121
+ let preScroll = "";
122
+ for (let i = 0; i < totalLinesToProcess + 1; i++)
123
+ preScroll += "\n";
124
+ preScroll += `\x1b[${totalLinesToProcess + 1}A`;
101
125
  let output = "\n";
102
- for (let i = 0; i < menu.items.length; i++) {
103
- const { label, detail } = menu.items[i];
104
- const isSelected = i === menu.selectedIndex;
105
- if (isSelected) {
106
- output += ` ${chalk.bgCyan.black(` ${label.padEnd(padWidth)}`)} ${chalk.white(detail)}`;
107
- }
108
- else {
109
- output += ` ${chalk.cyan(` ${label.padEnd(padWidth)}`)} ${chalk.dim(detail)}`;
126
+ // Menu items
127
+ if (menuItemCount > 0) {
128
+ const padWidth = menu.type === "file" ? 24 : 16;
129
+ for (let i = 0; i < menu.items.length; i++) {
130
+ const { label, detail } = menu.items[i];
131
+ const isSelected = i === menu.selectedIndex;
132
+ if (isSelected) {
133
+ output += ` ${chalk.bgCyan.black(` ${label.padEnd(padWidth)}`)} ${chalk.white(detail)}`;
134
+ }
135
+ else {
136
+ output += ` ${chalk.cyan(` ${label.padEnd(padWidth)}`)} ${chalk.dim(detail)}`;
137
+ }
138
+ output += "\x1b[K\n";
110
139
  }
111
- output += "\x1b[K\n";
112
140
  }
113
- for (let i = menu.items.length; i < menu.renderedLines; i++) {
141
+ // Underline
142
+ output += chalk.dim("─".repeat(getBorderWidth())) + "\x1b[K\n";
143
+ // Status line
144
+ const dir = shortenDir(session.cwd);
145
+ output += chalk.dim(` ${dir} · Tip: ${below.tip}`) + "\x1b[K\n";
146
+ // Clear stale lines from previous render
147
+ for (let i = contentLines; i < below.renderedLines; i++) {
114
148
  output += "\x1b[K\n";
115
149
  }
116
- menu.renderedLines = Math.max(menu.renderedLines, menu.items.length);
117
- const linesToGoUp = 1 + totalLinesToClear;
150
+ below.renderedLines = contentLines;
151
+ const linesToGoUp = 1 + totalLinesToProcess;
118
152
  output += `\x1b[${linesToGoUp}A\r`;
119
153
  if (cursorCol > 0) {
120
154
  output += `\x1b[${cursorCol}C`;
121
155
  }
122
- process.stdout.write(output);
156
+ process.stdout.write(preScroll + output);
123
157
  }
124
- function clearMenu(menu, cursorCol = 0) {
125
- if (menu.renderedLines === 0)
158
+ function clearBelowArea(below, cursorCol) {
159
+ if (below.renderedLines === 0)
126
160
  return;
127
161
  let output = "";
128
- const totalLines = menu.renderedLines + 1;
162
+ const totalLines = below.renderedLines + 1;
129
163
  for (let i = 0; i < totalLines; i++) {
130
164
  output += "\n\x1b[K";
131
165
  }
@@ -134,10 +168,11 @@ function clearMenu(menu, cursorCol = 0) {
134
168
  output += `\x1b[${cursorCol}C`;
135
169
  }
136
170
  process.stdout.write(output);
137
- menu.visible = false;
138
- menu.items = [];
139
- menu.selectedIndex = 0;
140
- menu.renderedLines = 0;
171
+ below.renderedLines = 0;
172
+ }
173
+ /** Show grey placeholder text when input is empty, using cursor save/restore. */
174
+ function showPlaceholder() {
175
+ process.stdout.write(`\x1b[s${chalk.dim(PLACEHOLDER)}\x1b[u`);
141
176
  }
142
177
  // ── Spinner ──
143
178
  const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
@@ -185,9 +220,18 @@ async function printExitMessage(session) {
185
220
  console.log(chalk.dim(`继续最近会话: ${chalk.cyan("til --continue")}`));
186
221
  await session.shutdown();
187
222
  }
223
+ // ── Tips helper ──
224
+ function getRandomTip(session) {
225
+ const tips = [...TIPS];
226
+ if (session.skills.length > 0)
227
+ tips.push("使用 /skills 查看已加载的技能");
228
+ const extInfo = session.getExtensionInfo();
229
+ if (extInfo.length > 0)
230
+ tips.push("使用 /extensions 查看已加载的扩展");
231
+ return tips[Math.floor(Math.random() * tips.length)];
232
+ }
188
233
  // ── Main interactive mode ──
189
234
  export async function runInteractiveMode(options) {
190
- // Pending permission prompts queue — resolved by the readline prompt
191
235
  let pendingPermissionResolve = null;
192
236
  const permissionPrompt = (question) => {
193
237
  return new Promise((resolve) => {
@@ -210,6 +254,10 @@ export async function runInteractiveMode(options) {
210
254
  let firstTokenReceived = false;
211
255
  let insideThinkBlock = false;
212
256
  let thinkTagBuffer = "";
257
+ // Multi-line input state
258
+ const multiLineBuffer = [];
259
+ // Below-area state: menu + underline + status
260
+ const below = { renderedLines: 0, active: false, tip: "" };
213
261
  session.subscribe((event) => {
214
262
  switch (event.type) {
215
263
  case "agent_start":
@@ -365,13 +413,11 @@ export async function runInteractiveMode(options) {
365
413
  spinner = null;
366
414
  }
367
415
  isStreaming = false;
368
- // Check for errors
369
416
  const lastMsg = event.messages?.[event.messages.length - 1];
370
417
  if (lastMsg && "stopReason" in lastMsg && lastMsg.stopReason === "error") {
371
418
  const errText = lastMsg.errorMessage || "未知错误";
372
419
  process.stdout.write(chalk.red(`\n ✗ 错误: ${errText}\n`));
373
420
  }
374
- // Show per-turn usage summary
375
421
  const turnUsage = session.getLastTurnUsageSummary();
376
422
  const ctxInfo = session.getContextUsagePercent();
377
423
  if (turnUsage) {
@@ -382,7 +428,7 @@ export async function runInteractiveMode(options) {
382
428
  ctxColor = chalk.yellow;
383
429
  process.stdout.write(chalk.dim(`\n ${turnUsage} ctx:${ctxColor(ctxInfo.display)}`));
384
430
  }
385
- process.stdout.write("\n");
431
+ process.stdout.write("\n\n");
386
432
  break;
387
433
  }
388
434
  }
@@ -391,7 +437,6 @@ export async function runInteractiveMode(options) {
391
437
  visible: false,
392
438
  items: [],
393
439
  selectedIndex: 0,
394
- renderedLines: 0,
395
440
  type: "command",
396
441
  };
397
442
  const completer = (line) => {
@@ -406,7 +451,167 @@ export async function runInteractiveMode(options) {
406
451
  const getCursorCol = () => PROMPT_WIDTH + (rl.cursor ?? 0);
407
452
  const allEntries = getCommandEntries(session);
408
453
  const originalTtyWrite = rl._ttyWrite;
454
+ // Helper: update menu visibility based on current line content
455
+ function updateMenuForLine(line, session) {
456
+ if (line.startsWith("/model ")) {
457
+ const modelFilter = line.slice("/model ".length);
458
+ if (!["add ", "rm ", "default "].some((sub) => modelFilter.startsWith(sub))) {
459
+ const entries = getModelMenuEntries(modelFilter, session);
460
+ if (entries.length > 0) {
461
+ menu.visible = true;
462
+ menu.items = entries;
463
+ menu.type = "command";
464
+ menu.selectedIndex = 0;
465
+ }
466
+ else {
467
+ resetMenu(menu);
468
+ }
469
+ }
470
+ else {
471
+ resetMenu(menu);
472
+ }
473
+ }
474
+ else if (line.startsWith("/mcp ")) {
475
+ const mcpSub = line.slice("/mcp ".length);
476
+ if (!mcpSub.includes(" ")) {
477
+ const mcpSubcmds = [
478
+ { label: "add", detail: "添加 MCP 服务", value: "/mcp add " },
479
+ { label: "rm", detail: "删除 MCP 服务", value: "/mcp rm " },
480
+ { label: "reload", detail: "重载所有 MCP 服务", value: "/mcp reload" },
481
+ ];
482
+ const filtered = mcpSub ? mcpSubcmds.filter((e) => e.label.startsWith(mcpSub)) : mcpSubcmds;
483
+ if (filtered.length > 0) {
484
+ menu.visible = true;
485
+ menu.items = filtered;
486
+ menu.type = "command";
487
+ menu.selectedIndex = 0;
488
+ }
489
+ else {
490
+ resetMenu(menu);
491
+ }
492
+ }
493
+ else if (mcpSub.startsWith("rm ")) {
494
+ const nameFilter = mcpSub.slice("rm ".length);
495
+ const mcpExt = session.extensionRunner?.loadedExtensions.find((e) => e.name === "mcp");
496
+ const serverNames = new Set();
497
+ if (mcpExt) {
498
+ for (const key of mcpExt.tools.keys()) {
499
+ const parts = key.split("_");
500
+ if (parts.length >= 3 && parts[0] === "mcp")
501
+ serverNames.add(parts[1]);
502
+ }
503
+ }
504
+ if (session.config.mcpServers) {
505
+ for (const name of Object.keys(session.config.mcpServers))
506
+ serverNames.add(name);
507
+ }
508
+ const entries = [...serverNames]
509
+ .filter((n) => !nameFilter || n.startsWith(nameFilter))
510
+ .map((n) => ({ label: n, detail: "MCP 服务", value: `/mcp rm ${n}` }));
511
+ if (entries.length > 0) {
512
+ menu.visible = true;
513
+ menu.items = entries;
514
+ menu.type = "command";
515
+ menu.selectedIndex = 0;
516
+ }
517
+ else {
518
+ resetMenu(menu);
519
+ }
520
+ }
521
+ else {
522
+ resetMenu(menu);
523
+ }
524
+ }
525
+ else if (line.startsWith("/skills ")) {
526
+ const skillsSub = line.slice("/skills ".length);
527
+ if (!skillsSub.includes(" ")) {
528
+ const skillSubcmds = [
529
+ { label: "reload", detail: "重新扫描并加载技能", value: "/skills reload" },
530
+ { label: "install", detail: "安装技能", value: "/skills install " },
531
+ { label: "enable", detail: "启用技能", value: "/skills enable " },
532
+ { label: "disable", detail: "禁用技能", value: "/skills disable " },
533
+ { label: "rm", detail: "删除技能文件", value: "/skills rm " },
534
+ ];
535
+ const filtered = skillsSub ? skillSubcmds.filter((e) => e.label.startsWith(skillsSub)) : skillSubcmds;
536
+ if (filtered.length > 0) {
537
+ menu.visible = true;
538
+ menu.items = filtered;
539
+ menu.type = "command";
540
+ menu.selectedIndex = 0;
541
+ }
542
+ else {
543
+ resetMenu(menu);
544
+ }
545
+ }
546
+ else if (skillsSub.startsWith("enable ")) {
547
+ const nameFilter = skillsSub.slice("enable ".length);
548
+ const disabled = session.config.disabledSkills ?? [];
549
+ const entries = disabled
550
+ .filter((n) => !nameFilter || n.startsWith(nameFilter))
551
+ .map((n) => ({ label: n, detail: "已禁用", value: `/skills enable ${n}` }));
552
+ if (entries.length > 0) {
553
+ menu.visible = true;
554
+ menu.items = entries;
555
+ menu.type = "command";
556
+ menu.selectedIndex = 0;
557
+ }
558
+ else {
559
+ resetMenu(menu);
560
+ }
561
+ }
562
+ else if (skillsSub.startsWith("disable ") || skillsSub.startsWith("rm ")) {
563
+ const cmd = skillsSub.startsWith("disable ") ? "disable" : "rm";
564
+ const nameFilter = skillsSub.slice(cmd.length + 1);
565
+ const entries = session.skills
566
+ .filter((s) => !nameFilter || s.name.startsWith(nameFilter))
567
+ .map((s) => ({ label: s.name, detail: s.source, value: `/skills ${cmd} ${s.name}` }));
568
+ if (entries.length > 0) {
569
+ menu.visible = true;
570
+ menu.items = entries;
571
+ menu.type = "command";
572
+ menu.selectedIndex = 0;
573
+ }
574
+ else {
575
+ resetMenu(menu);
576
+ }
577
+ }
578
+ else {
579
+ resetMenu(menu);
580
+ }
581
+ }
582
+ else if (line.startsWith("/") && !line.includes(" ")) {
583
+ const filtered = filterEntries(allEntries, line);
584
+ if (filtered.length > 0) {
585
+ menu.visible = true;
586
+ menu.items = filtered;
587
+ menu.type = "command";
588
+ menu.selectedIndex = 0;
589
+ }
590
+ else {
591
+ resetMenu(menu);
592
+ }
593
+ }
594
+ else {
595
+ const atPrefix = extractAtPrefix(line);
596
+ if (atPrefix !== null) {
597
+ const entries = getFileMenuEntries(atPrefix, session.cwd);
598
+ if (entries.length > 0) {
599
+ menu.visible = true;
600
+ menu.items = entries;
601
+ menu.type = "file";
602
+ menu.selectedIndex = 0;
603
+ }
604
+ else {
605
+ resetMenu(menu);
606
+ }
607
+ }
608
+ else {
609
+ resetMenu(menu);
610
+ }
611
+ }
612
+ }
409
613
  rl._ttyWrite = function (s, key) {
614
+ // ── Permission prompt passthrough ──
410
615
  if (pendingPermissionResolve && s) {
411
616
  const resolve = pendingPermissionResolve;
412
617
  pendingPermissionResolve = null;
@@ -414,6 +619,7 @@ export async function runInteractiveMode(options) {
414
619
  resolve(s);
415
620
  return;
416
621
  }
622
+ // ── Streaming: only Esc to abort ──
417
623
  if (isStreaming) {
418
624
  if (key && key.name === "escape") {
419
625
  if (spinner) {
@@ -428,28 +634,55 @@ export async function runInteractiveMode(options) {
428
634
  return originalTtyWrite.call(this, s, key);
429
635
  }
430
636
  const cursorCol = getCursorCol();
637
+ const keyName = key?.name;
638
+ const isReturnKey = keyName === "return" || keyName === "enter";
639
+ // ── Multi-line newline insertion ──
640
+ // 1. Shift+Enter / Alt+Enter (terminals that report modifier flags)
641
+ // 2. Ctrl+J: raw char is always \n (0x0A), distinct from Enter's \r (0x0D)
642
+ // 3. Raw \x1b\r from Kitty shift+enter mapping or legacy Alt+Enter
643
+ const isNewlineInsert = (key && isReturnKey && (key.shift === true || key.meta === true)) ||
644
+ s === "\n" ||
645
+ s === "\x1b\r";
646
+ if (isNewlineInsert) {
647
+ clearBelowArea(below, cursorCol);
648
+ multiLineBuffer.push(rl.line ?? "");
649
+ process.stdout.write("\n");
650
+ rl.line = "";
651
+ rl.cursor = 0;
652
+ rl._prompt = chalk.cyan(" … ");
653
+ process.stdout.write(chalk.cyan(" … "));
654
+ resetMenu(menu);
655
+ renderBelowArea(menu, below, session, PROMPT_WIDTH);
656
+ return;
657
+ }
658
+ // ── Menu visible: handle navigation & selection ──
431
659
  if (menu.visible) {
432
- if (key && key.name === "up") {
433
- menu.selectedIndex = Math.max(0, menu.selectedIndex - 1);
434
- renderMenu(menu, cursorCol);
660
+ if (key && keyName === "up") {
661
+ menu.selectedIndex = menu.selectedIndex <= 0
662
+ ? menu.items.length - 1
663
+ : menu.selectedIndex - 1;
664
+ renderBelowArea(menu, below, session, cursorCol);
435
665
  return;
436
666
  }
437
- if (key && key.name === "down") {
438
- menu.selectedIndex = Math.min(menu.items.length - 1, menu.selectedIndex + 1);
439
- renderMenu(menu, cursorCol);
667
+ if (key && keyName === "down") {
668
+ menu.selectedIndex = menu.selectedIndex >= menu.items.length - 1
669
+ ? 0
670
+ : menu.selectedIndex + 1;
671
+ renderBelowArea(menu, below, session, cursorCol);
440
672
  return;
441
673
  }
442
- if (key && (key.name === "return" || key.name === "tab")) {
674
+ if (key && (isReturnKey || keyName === "tab")) {
443
675
  const selected = menu.items[menu.selectedIndex];
444
676
  const menuType = menu.type;
445
677
  if (selected) {
446
- clearMenu(menu, cursorCol);
678
+ resetMenu(menu);
447
679
  if (menuType === "command") {
448
680
  rl.line = "";
449
681
  rl.cursor = 0;
450
682
  process.stdout.write("\r\x1b[K");
451
683
  process.stdout.write(chalk.cyan(" › "));
452
- if (key.name === "return") {
684
+ if (isReturnKey) {
685
+ clearBelowArea(below, PROMPT_WIDTH);
453
686
  rl.line = selected.value;
454
687
  rl.cursor = selected.value.length;
455
688
  process.stdout.write(selected.value);
@@ -459,6 +692,7 @@ export async function runInteractiveMode(options) {
459
692
  rl.line = selected.value;
460
693
  rl.cursor = selected.value.length;
461
694
  process.stdout.write(selected.value);
695
+ renderBelowArea(menu, below, session, getCursorCol());
462
696
  return;
463
697
  }
464
698
  }
@@ -475,68 +709,87 @@ export async function runInteractiveMode(options) {
475
709
  process.stdout.write(chalk.cyan(" › "));
476
710
  process.stdout.write(newLine);
477
711
  }
712
+ renderBelowArea(menu, below, session, getCursorCol());
478
713
  return;
479
714
  }
480
715
  }
481
716
  }
482
- if (key && key.name === "escape") {
483
- clearMenu(menu, cursorCol);
717
+ if (key && keyName === "escape") {
718
+ resetMenu(menu);
719
+ renderBelowArea(menu, below, session, cursorCol);
484
720
  return;
485
721
  }
486
722
  }
487
- originalTtyWrite.call(this, s, key);
488
- const newLine = rl.line ?? "";
489
- const newCursorCol = getCursorCol();
490
- if (newLine.startsWith("/model ")) {
491
- const modelFilter = newLine.slice("/model ".length);
492
- if (!["add ", "rm ", "default "].some((sub) => modelFilter.startsWith(sub))) {
493
- const entries = getModelMenuEntries(modelFilter, session);
494
- if (entries.length > 0) {
495
- menu.visible = true;
496
- menu.items = entries;
497
- menu.type = "command";
498
- menu.selectedIndex = 0;
499
- renderMenu(menu, newCursorCol);
723
+ // ── Escape: cancel multi-line or ignore ──
724
+ if (key && keyName === "escape") {
725
+ if (multiLineBuffer.length > 0) {
726
+ clearBelowArea(below, cursorCol);
727
+ for (let i = 0; i < multiLineBuffer.length; i++) {
728
+ process.stdout.write("\x1b[1A\x1b[2K");
500
729
  }
501
- else if (menu.visible) {
502
- clearMenu(menu, newCursorCol);
503
- }
504
- }
505
- else if (menu.visible) {
506
- clearMenu(menu, newCursorCol);
730
+ process.stdout.write("\r\x1b[K");
731
+ multiLineBuffer.length = 0;
732
+ rl.line = "";
733
+ rl.cursor = 0;
734
+ rl._prompt = chalk.cyan(" › ");
735
+ process.stdout.write(chalk.cyan(" › "));
736
+ renderBelowArea(menu, below, session, PROMPT_WIDTH);
737
+ showPlaceholder();
738
+ return;
507
739
  }
740
+ return;
508
741
  }
509
- else if (newLine.startsWith("/") && !newLine.includes(" ")) {
510
- const filtered = filterEntries(allEntries, newLine);
511
- if (filtered.length > 0) {
512
- menu.visible = true;
513
- menu.items = filtered;
514
- menu.type = "command";
515
- menu.selectedIndex = 0;
516
- renderMenu(menu, newCursorCol);
742
+ // ── Enter: submit or backslash-newline ──
743
+ if (key && isReturnKey) {
744
+ const currentLine = rl.line ?? "";
745
+ const cursor = rl.cursor ?? 0;
746
+ // Backslash+Enter → newline (pi-mono style, works in ALL terminals)
747
+ if (cursor > 0 && currentLine[cursor - 1] === "\\") {
748
+ clearBelowArea(below, cursorCol);
749
+ const before = currentLine.slice(0, cursor - 1);
750
+ const after = currentLine.slice(cursor);
751
+ multiLineBuffer.push(before + after);
752
+ process.stdout.write("\n");
753
+ rl.line = "";
754
+ rl.cursor = 0;
755
+ rl._prompt = chalk.cyan(" … ");
756
+ process.stdout.write(chalk.cyan(" … "));
757
+ resetMenu(menu);
758
+ renderBelowArea(menu, below, session, PROMPT_WIDTH);
759
+ return;
517
760
  }
518
- else if (menu.visible) {
519
- clearMenu(menu, newCursorCol);
761
+ // Normal submit
762
+ clearBelowArea(below, cursorCol);
763
+ below.active = false;
764
+ if (multiLineBuffer.length > 0) {
765
+ const fullInput = [...multiLineBuffer, currentLine].join("\n");
766
+ multiLineBuffer.length = 0;
767
+ rl.line = fullInput;
768
+ rl.cursor = fullInput.length;
769
+ rl._prompt = chalk.cyan(" › ");
520
770
  }
771
+ originalTtyWrite.call(this, s, key);
772
+ return;
521
773
  }
522
- else {
523
- const atPrefix = extractAtPrefix(newLine);
524
- if (atPrefix !== null) {
525
- const entries = getFileMenuEntries(atPrefix, session.cwd);
526
- if (entries.length > 0) {
527
- menu.visible = true;
528
- menu.items = entries;
529
- menu.type = "file";
530
- menu.selectedIndex = 0;
531
- renderMenu(menu, newCursorCol);
532
- }
533
- else if (menu.visible) {
534
- clearMenu(menu, newCursorCol);
535
- }
536
- }
537
- else if (menu.visible) {
538
- clearMenu(menu, newCursorCol);
539
- }
774
+ // ── Normal key processing ──
775
+ const prevMenuVisible = menu.visible;
776
+ const prevMenuItemCount = menu.items.length;
777
+ const prevLine = rl.line ?? "";
778
+ originalTtyWrite.call(this, s, key);
779
+ const newLine = rl.line ?? "";
780
+ updateMenuForLine(newLine, session);
781
+ // Clear placeholder remnants when first character is typed
782
+ if (prevLine.length === 0 && newLine.length > 0) {
783
+ process.stdout.write("\x1b[K");
784
+ }
785
+ const menuChanged = menu.visible !== prevMenuVisible ||
786
+ (menu.visible && menu.items.length !== prevMenuItemCount);
787
+ if (menuChanged) {
788
+ renderBelowArea(menu, below, session, getCursorCol());
789
+ }
790
+ // Re-show placeholder when input is empty
791
+ if (newLine.length === 0 && multiLineBuffer.length === 0 && !menu.visible) {
792
+ showPlaceholder();
540
793
  }
541
794
  };
542
795
  if (process.stdin.isTTY) {
@@ -544,13 +797,22 @@ export async function runInteractiveMode(options) {
544
797
  }
545
798
  const promptUser = () => {
546
799
  return new Promise((resolve) => {
547
- clearMenu(menu);
800
+ clearBelowArea(below, 0);
801
+ resetMenu(menu);
802
+ multiLineBuffer.length = 0;
803
+ below.active = true;
804
+ below.tip = getRandomTip(session);
805
+ console.log();
548
806
  printBorder();
549
807
  rl.question(chalk.cyan(" › "), (answer) => {
550
- clearMenu(menu);
808
+ clearBelowArea(below, getCursorCol());
809
+ below.active = false;
551
810
  printBorder();
552
811
  resolve(answer);
553
812
  });
813
+ // Render placeholder + underline + status below input
814
+ showPlaceholder();
815
+ renderBelowArea(menu, below, session, PROMPT_WIDTH);
554
816
  });
555
817
  };
556
818
  process.on("SIGINT", () => {
@@ -563,11 +825,27 @@ export async function runInteractiveMode(options) {
563
825
  process.stdout.write(chalk.yellow("\n⚡ 已中止 (Ctrl+C)\n"));
564
826
  printSessionTip(session);
565
827
  }
828
+ else if (multiLineBuffer.length > 0) {
829
+ clearBelowArea(below, getCursorCol());
830
+ for (let i = 0; i < multiLineBuffer.length; i++) {
831
+ process.stdout.write("\x1b[1A\x1b[2K");
832
+ }
833
+ process.stdout.write("\r\x1b[K");
834
+ multiLineBuffer.length = 0;
835
+ rl.line = "";
836
+ rl.cursor = 0;
837
+ rl._prompt = chalk.cyan(" › ");
838
+ process.stdout.write(chalk.cyan(" › "));
839
+ showPlaceholder();
840
+ renderBelowArea(menu, below, session, PROMPT_WIDTH);
841
+ }
566
842
  else {
567
843
  console.log(chalk.dim("\n(输入 /exit 或 Ctrl+D 退出)"));
568
844
  }
569
845
  });
570
846
  rl.on("close", () => {
847
+ clearBelowArea(below, getCursorCol());
848
+ below.active = false;
571
849
  printExitMessage(session).finally(() => process.exit(0));
572
850
  });
573
851
  // Main REPL loop
@@ -585,9 +863,10 @@ export async function runInteractiveMode(options) {
585
863
  continue;
586
864
  }
587
865
  try {
588
- // Expand @path references into file contents
589
866
  const { expandedText, fileContent } = extractAtReferences(trimmed, session.cwd);
590
- const finalInput = fileContent ? fileContent + "\n" + expandedText : expandedText;
867
+ const finalInput = fileContent
868
+ ? `${expandedText}\n\n<referenced_files>\n${fileContent}</referenced_files>`
869
+ : expandedText;
591
870
  await session.prompt(finalInput);
592
871
  }
593
872
  catch (err) {
@@ -612,7 +891,6 @@ async function handleCommand(input, session) {
612
891
  break;
613
892
  case "/model": {
614
893
  const sub = args[0];
615
- // /model — show current + configured models
616
894
  if (!sub) {
617
895
  const cfg = await loadConfig();
618
896
  const configuredModels = cfg.models ?? {};
@@ -639,7 +917,6 @@ async function handleCommand(input, session) {
639
917
  console.log(chalk.dim(` /model rm <id> 删除模型\n`));
640
918
  break;
641
919
  }
642
- // /model default <id> — set persistent default
643
920
  if (sub === "default") {
644
921
  const modelId = args[1];
645
922
  if (!modelId) {
@@ -653,7 +930,6 @@ async function handleCommand(input, session) {
653
930
  console.log(chalk.green(`\n 默认模型已设为: ${modelId} (已同步切换)\n`));
654
931
  break;
655
932
  }
656
- // /model add <id> [baseUrl] [apiKey]
657
933
  if (sub === "add") {
658
934
  const modelId = args[1];
659
935
  if (!modelId) {
@@ -675,7 +951,6 @@ async function handleCommand(input, session) {
675
951
  console.log(chalk.dim(` 切换使用: /model ${modelId}\n`));
676
952
  break;
677
953
  }
678
- // /model rm <id>
679
954
  if (sub === "rm") {
680
955
  const modelId = args[1];
681
956
  if (!modelId) {
@@ -692,7 +967,6 @@ async function handleCommand(input, session) {
692
967
  console.log(chalk.green(`\n 已删除: ${modelId}\n`));
693
968
  break;
694
969
  }
695
- // /model <id> — switch model
696
970
  try {
697
971
  session.switchModel(sub);
698
972
  console.log(chalk.green(`\n 已切换到: ${sub}\n`));
@@ -797,61 +1071,334 @@ async function handleCommand(input, session) {
797
1071
  break;
798
1072
  }
799
1073
  case "/skills": {
800
- const skills = session.skills;
801
- if (skills.length === 0) {
802
- console.log(chalk.dim("\n 暂无技能。将 SKILL.md 文件放入 ~/.til/skills/ .til/skills/ 即可加载。\n"));
1074
+ const sub = args[0];
1075
+ if (!sub) {
1076
+ const skills = session.skills;
1077
+ const cfg = await loadConfig();
1078
+ const disabledSkills = cfg.disabledSkills ?? [];
1079
+ const totalCount = skills.length + disabledSkills.length;
1080
+ if (totalCount === 0) {
1081
+ console.log(chalk.dim("\n 暂无技能。将 SKILL.md 文件放入 ~/.til/skills/ 或 .til/skills/ 即可加载。\n"));
1082
+ }
1083
+ else {
1084
+ const sourceLabel = { builtin: "内置", user: "用户", project: "项目", path: "路径" };
1085
+ console.log(chalk.bold(`\n已加载技能 (${skills.length}):\n`));
1086
+ for (const s of skills) {
1087
+ const tag = chalk.dim(`[${sourceLabel[s.source] || s.source}]`);
1088
+ console.log(` ${chalk.green("●")} ${chalk.cyan(s.name.padEnd(24))} ${tag} ${chalk.dim(s.description.slice(0, 50))}`);
1089
+ }
1090
+ if (disabledSkills.length > 0) {
1091
+ console.log(chalk.bold(`\n已禁用技能 (${disabledSkills.length}):\n`));
1092
+ for (const name of disabledSkills) {
1093
+ console.log(` ${chalk.red("○")} ${chalk.dim(name)}`);
1094
+ }
1095
+ }
1096
+ }
1097
+ console.log(chalk.dim("\n /skill:<名称> 查看技能详情"));
1098
+ console.log(chalk.dim(" /skills reload 重新扫描并加载技能"));
1099
+ console.log(chalk.dim(" /skills install <source> 安装技能 (npx skills add)"));
1100
+ console.log(chalk.dim(" /skills enable <name> 启用已禁用的技能"));
1101
+ console.log(chalk.dim(" /skills disable <name> 禁用技能"));
1102
+ console.log(chalk.dim(" /skills rm <name> 删除技能文件\n"));
1103
+ break;
803
1104
  }
804
- else {
805
- const sourceLabel = { builtin: "内置", user: "用户", project: "项目", path: "路径" };
806
- console.log(chalk.bold(`\n已加载技能 (${skills.length}):\n`));
807
- for (const s of skills) {
808
- const tag = chalk.dim(`[${sourceLabel[s.source] || s.source}]`);
809
- console.log(` ${chalk.cyan(s.name.padEnd(24))} ${tag} ${chalk.dim(s.description.slice(0, 50))}`);
810
- }
811
- console.log(chalk.dim("\n 使用 /skill:<名称> 查看技能详情。"));
812
- console.log(chalk.dim(" 自定义: ~/.til/skills/ .til/skills/\n"));
1105
+ if (sub === "reload") {
1106
+ const reloadSpinner = new Spinner("正在重新加载技能...");
1107
+ reloadSpinner.start();
1108
+ try {
1109
+ await session.reloadSkills();
1110
+ reloadSpinner.stop(chalk.green(` ✓ 技能已重载 (${session.skills.length} )`));
1111
+ }
1112
+ catch (err) {
1113
+ reloadSpinner.stop(chalk.red(` 重载失败: ${err.message}`));
1114
+ }
1115
+ console.log();
1116
+ break;
813
1117
  }
1118
+ if (sub === "install") {
1119
+ const source = args[1];
1120
+ if (!source) {
1121
+ console.log(chalk.red("\n 用法: /skills install <source>"));
1122
+ console.log(chalk.dim(" 示例: /skills install ivangdavila/excel-xlsx\n"));
1123
+ break;
1124
+ }
1125
+ console.log(chalk.dim(`\n 正在安装技能: ${source}...`));
1126
+ const { execSync } = await import("node:child_process");
1127
+ try {
1128
+ const output = execSync(`npx skills add ${source} -g -y`, {
1129
+ encoding: "utf-8",
1130
+ timeout: 60000,
1131
+ cwd: session.cwd,
1132
+ });
1133
+ console.log(chalk.green(` ✓ 安装完成`));
1134
+ if (output.trim())
1135
+ console.log(chalk.dim(` ${output.trim()}`));
1136
+ await session.reloadSkills();
1137
+ console.log(chalk.dim(` 已重载 (${session.skills.length} 个技能)\n`));
1138
+ }
1139
+ catch (err) {
1140
+ console.log(chalk.red(` ✗ 安装失败: ${err.message}\n`));
1141
+ }
1142
+ break;
1143
+ }
1144
+ if (sub === "enable") {
1145
+ const name = args[1];
1146
+ if (!name) {
1147
+ console.log(chalk.red("\n 用法: /skills enable <name>\n"));
1148
+ break;
1149
+ }
1150
+ const cfg = await loadConfig();
1151
+ const disabled = cfg.disabledSkills ?? [];
1152
+ if (!disabled.includes(name)) {
1153
+ console.log(chalk.yellow(`\n 技能 "${name}" 未被禁用\n`));
1154
+ break;
1155
+ }
1156
+ cfg.disabledSkills = disabled.filter((n) => n !== name);
1157
+ await saveConfig(cfg);
1158
+ await session.reloadSkills();
1159
+ console.log(chalk.green(`\n 已启用技能: ${name} (当前 ${session.skills.length} 个)\n`));
1160
+ break;
1161
+ }
1162
+ if (sub === "disable") {
1163
+ const name = args[1];
1164
+ if (!name) {
1165
+ console.log(chalk.red("\n 用法: /skills disable <name>\n"));
1166
+ break;
1167
+ }
1168
+ const skill = session.skills.find((s) => s.name === name);
1169
+ if (!skill) {
1170
+ console.log(chalk.yellow(`\n 技能 "${name}" 未找到或已禁用\n`));
1171
+ break;
1172
+ }
1173
+ const cfg = await loadConfig();
1174
+ if (!cfg.disabledSkills)
1175
+ cfg.disabledSkills = [];
1176
+ if (!cfg.disabledSkills.includes(name)) {
1177
+ cfg.disabledSkills.push(name);
1178
+ }
1179
+ await saveConfig(cfg);
1180
+ await session.reloadSkills();
1181
+ console.log(chalk.green(`\n 已禁用技能: ${name} (当前 ${session.skills.length} 个)\n`));
1182
+ break;
1183
+ }
1184
+ if (sub === "rm") {
1185
+ const name = args[1];
1186
+ if (!name) {
1187
+ console.log(chalk.red("\n 用法: /skills rm <name>\n"));
1188
+ break;
1189
+ }
1190
+ const { loadSkills: loadAllSkills } = await import("../core/skills.js");
1191
+ const allResult = loadAllSkills({ cwd: session.cwd, includeDefaults: true });
1192
+ const target = allResult.skills.find((s) => s.name === name);
1193
+ if (!target) {
1194
+ console.log(chalk.yellow(`\n 技能 "${name}" 未找到\n`));
1195
+ break;
1196
+ }
1197
+ if (target.source === "builtin") {
1198
+ console.log(chalk.red(`\n 内置技能不能删除,使用 /skills disable ${name} 禁用\n`));
1199
+ break;
1200
+ }
1201
+ const { unlink } = await import("node:fs/promises");
1202
+ try {
1203
+ await unlink(target.filePath);
1204
+ console.log(chalk.green(`\n 已删除: ${target.filePath}`));
1205
+ // Also remove from disabled list if present
1206
+ const cfg = await loadConfig();
1207
+ if (cfg.disabledSkills?.includes(name)) {
1208
+ cfg.disabledSkills = cfg.disabledSkills.filter((n) => n !== name);
1209
+ await saveConfig(cfg);
1210
+ }
1211
+ await session.reloadSkills();
1212
+ console.log(chalk.dim(` 已重载 (${session.skills.length} 个技能)\n`));
1213
+ }
1214
+ catch (err) {
1215
+ console.log(chalk.red(`\n 删除失败: ${err.message}\n`));
1216
+ }
1217
+ break;
1218
+ }
1219
+ console.log(chalk.yellow(`\n 未知子命令: ${sub}。使用 /skills 查看帮助。\n`));
814
1220
  break;
815
1221
  }
816
1222
  case "/mcp": {
817
- const cfg = await loadConfig();
818
- const mcpServers = cfg.mcpServers ?? {};
819
- const serverNames = Object.keys(mcpServers);
820
- if (serverNames.length === 0) {
821
- console.log(chalk.dim("\n 暂无 MCP 服务。\n"));
822
- console.log(chalk.dim(" 在 ~/.til/config.json 中配置 mcpServers:"));
823
- console.log(chalk.dim(' { "mcpServers": { "name": { "transport": "streamable_http", "url": "..." } } }\n'));
1223
+ const sub = args[0];
1224
+ if (!sub) {
1225
+ // Display MCP status
1226
+ const cfg = await loadConfig();
1227
+ const globalMcp = cfg.mcpServers ?? {};
1228
+ const projectMcp = loadProjectMcpServers(session.cwd);
1229
+ const mergedMcp = { ...globalMcp, ...projectMcp };
1230
+ const serverNames = Object.keys(mergedMcp);
1231
+ if (serverNames.length === 0) {
1232
+ console.log(chalk.dim("\n 暂无 MCP 服务。\n"));
1233
+ console.log(chalk.dim(" 使用 /mcp add 添加服务,或在 ~/.til/config.json 中配置 mcpServers。\n"));
1234
+ }
1235
+ else {
1236
+ const mcpExt = session.extensionRunner?.loadedExtensions.find((e) => e.name === "mcp");
1237
+ const mcpTools = mcpExt ? [...mcpExt.tools.keys()] : [];
1238
+ console.log(chalk.bold(`\nMCP 服务 (${serverNames.length}):\n`));
1239
+ for (const name of serverNames) {
1240
+ const server = mergedMcp[name];
1241
+ const isProject = name in projectMcp;
1242
+ const rawTransport = server.transport ?? server.type;
1243
+ const transport = (rawTransport === "http" || rawTransport === "streamable_http" || rawTransport === "sse")
1244
+ ? "http"
1245
+ : rawTransport === "stdio" ? "stdio"
1246
+ : server.url ? "http" : "stdio";
1247
+ const serverTools = mcpTools.filter((t) => t.startsWith(`mcp_${name}_`));
1248
+ const status = serverTools.length > 0 ? chalk.green("✓ 已连接") : chalk.red("✗ 未连接");
1249
+ const scope = isProject ? chalk.yellow("[项目]") : chalk.dim("[全局]");
1250
+ console.log(` ${chalk.cyan(name.padEnd(20))} ${status} ${chalk.dim(transport)} ${scope}`);
1251
+ if (server.url)
1252
+ console.log(` ${chalk.dim("URL:")} ${server.url}`);
1253
+ if (server.command)
1254
+ console.log(` ${chalk.dim("CMD:")} ${server.command} ${(server.args ?? []).join(" ")}`);
1255
+ if (serverTools.length > 0) {
1256
+ console.log(` ${chalk.dim(`工具 (${serverTools.length}):`)}`);
1257
+ for (const toolKey of serverTools) {
1258
+ const shortName = toolKey.replace(`mcp_${name}_`, "");
1259
+ const toolDef = mcpExt?.tools.get(toolKey);
1260
+ const desc = toolDef ? chalk.dim(` — ${(toolDef.description ?? "").slice(0, 50)}`) : "";
1261
+ console.log(` ${chalk.white(shortName)}${desc}`);
1262
+ }
1263
+ }
1264
+ console.log();
1265
+ }
1266
+ }
1267
+ console.log(chalk.dim(` /mcp add <name> --stdio <cmd> [args...] 添加 stdio 服务`));
1268
+ console.log(chalk.dim(` /mcp add <name> --url <url> [-H key:value ...] 添加 HTTP 服务`));
1269
+ console.log(chalk.dim(` /mcp add <name> ... --project 添加到项目级配置`));
1270
+ console.log(chalk.dim(` /mcp rm <name> 删除服务`));
1271
+ console.log(chalk.dim(` /mcp reload 重载所有服务\n`));
1272
+ break;
824
1273
  }
825
- else {
826
- const mcpExt = session.extensionRunner?.loadedExtensions.find((e) => e.name === "mcp");
827
- const mcpTools = mcpExt ? [...mcpExt.tools.keys()] : [];
828
- console.log(chalk.bold(`\nMCP 服务 (${serverNames.length}):\n`));
829
- for (const name of serverNames) {
830
- const server = mcpServers[name];
831
- const rawTransport = server.transport ?? server.type;
832
- const transport = (rawTransport === "http" || rawTransport === "streamable_http" || rawTransport === "sse")
833
- ? "http"
834
- : rawTransport === "stdio" ? "stdio"
835
- : server.url ? "http" : "stdio";
836
- const serverTools = mcpTools.filter((t) => t.startsWith(`mcp_${name}_`));
837
- const status = serverTools.length > 0 ? chalk.green("✓ 已连接") : chalk.red("✗ 未连接");
838
- console.log(` ${chalk.cyan(name.padEnd(20))} ${status} ${chalk.dim(transport)}`);
839
- if (server.url)
840
- console.log(` ${chalk.dim("URL:")} ${server.url}`);
841
- if (server.command)
842
- console.log(` ${chalk.dim("CMD:")} ${server.command} ${(server.args ?? []).join(" ")}`);
843
- if (serverTools.length > 0) {
844
- console.log(` ${chalk.dim(`工具 (${serverTools.length}):`)}`);
845
- for (const toolKey of serverTools) {
846
- const shortName = toolKey.replace(`mcp_${name}_`, "");
847
- const toolDef = mcpExt?.tools.get(toolKey);
848
- const desc = toolDef ? chalk.dim(` — ${(toolDef.description ?? "").slice(0, 50)}`) : "";
849
- console.log(` ${chalk.white(shortName)}${desc}`);
1274
+ if (sub === "add") {
1275
+ const name = args[1];
1276
+ if (!name) {
1277
+ console.log(chalk.red("\n 用法:"));
1278
+ console.log(chalk.dim(" /mcp add <name> --stdio <command> [args...]"));
1279
+ console.log(chalk.dim(" /mcp add <name> --url <url> [-H key:value ...]"));
1280
+ console.log(chalk.dim(" 添加 --project 存入项目级配置\n"));
1281
+ break;
1282
+ }
1283
+ const restArgs = args.slice(2);
1284
+ const isProject = restArgs.includes("--project");
1285
+ const filteredArgs = restArgs.filter((a) => a !== "--project");
1286
+ let serverConfig = null;
1287
+ if (filteredArgs.includes("--stdio")) {
1288
+ const stdioIdx = filteredArgs.indexOf("--stdio");
1289
+ const command = filteredArgs[stdioIdx + 1];
1290
+ if (!command) {
1291
+ console.log(chalk.red("\n --stdio 后需要指定命令\n"));
1292
+ break;
1293
+ }
1294
+ const cmdArgs = filteredArgs.slice(stdioIdx + 2).filter((a) => !a.startsWith("-H"));
1295
+ const env = {};
1296
+ // Parse -E key=value for env vars
1297
+ for (let i = 0; i < filteredArgs.length; i++) {
1298
+ if (filteredArgs[i] === "-E" && filteredArgs[i + 1]) {
1299
+ const [k, ...vParts] = filteredArgs[i + 1].split("=");
1300
+ if (k)
1301
+ env[k] = vParts.join("=");
1302
+ }
1303
+ }
1304
+ serverConfig = {
1305
+ transport: "stdio",
1306
+ command,
1307
+ args: cmdArgs.length > 0 ? cmdArgs : undefined,
1308
+ env: Object.keys(env).length > 0 ? env : undefined,
1309
+ };
1310
+ }
1311
+ else if (filteredArgs.includes("--url")) {
1312
+ const urlIdx = filteredArgs.indexOf("--url");
1313
+ const url = filteredArgs[urlIdx + 1];
1314
+ if (!url) {
1315
+ console.log(chalk.red("\n --url 后需要指定 URL\n"));
1316
+ break;
1317
+ }
1318
+ const headers = {};
1319
+ for (let i = 0; i < filteredArgs.length; i++) {
1320
+ if (filteredArgs[i] === "-H" && filteredArgs[i + 1]) {
1321
+ const colonIdx = filteredArgs[i + 1].indexOf(":");
1322
+ if (colonIdx > 0) {
1323
+ const key = filteredArgs[i + 1].slice(0, colonIdx).trim();
1324
+ const value = filteredArgs[i + 1].slice(colonIdx + 1).trim();
1325
+ headers[key] = value;
1326
+ }
850
1327
  }
851
1328
  }
852
- console.log();
1329
+ serverConfig = {
1330
+ transport: "streamable_http",
1331
+ url,
1332
+ headers: Object.keys(headers).length > 0 ? headers : undefined,
1333
+ };
853
1334
  }
1335
+ else {
1336
+ console.log(chalk.red("\n 需要指定 --stdio 或 --url\n"));
1337
+ break;
1338
+ }
1339
+ if (serverConfig) {
1340
+ if (isProject) {
1341
+ const projectServers = loadProjectMcpServers(session.cwd);
1342
+ projectServers[name] = serverConfig;
1343
+ await saveProjectMcpServers(session.cwd, projectServers);
1344
+ console.log(chalk.green(`\n 已添加 MCP 服务: ${name} (项目级)`));
1345
+ }
1346
+ else {
1347
+ const cfg = await loadConfig();
1348
+ if (!cfg.mcpServers)
1349
+ cfg.mcpServers = {};
1350
+ cfg.mcpServers[name] = serverConfig;
1351
+ await saveConfig(cfg);
1352
+ console.log(chalk.green(`\n 已添加 MCP 服务: ${name} (全局)`));
1353
+ }
1354
+ console.log(chalk.dim(` 使用 /mcp reload 激活\n`));
1355
+ }
1356
+ break;
854
1357
  }
1358
+ if (sub === "rm") {
1359
+ const name = args[1];
1360
+ if (!name) {
1361
+ console.log(chalk.red("\n 用法: /mcp rm <name>\n"));
1362
+ break;
1363
+ }
1364
+ let removed = false;
1365
+ const cfg = await loadConfig();
1366
+ if (cfg.mcpServers?.[name]) {
1367
+ delete cfg.mcpServers[name];
1368
+ await saveConfig(cfg);
1369
+ removed = true;
1370
+ }
1371
+ const projectServers = loadProjectMcpServers(session.cwd);
1372
+ if (projectServers[name]) {
1373
+ delete projectServers[name];
1374
+ await saveProjectMcpServers(session.cwd, projectServers);
1375
+ removed = true;
1376
+ }
1377
+ if (removed) {
1378
+ console.log(chalk.green(`\n 已删除 MCP 服务: ${name}`));
1379
+ console.log(chalk.dim(` 使用 /mcp reload 使变更生效\n`));
1380
+ }
1381
+ else {
1382
+ console.log(chalk.red(`\n MCP 服务 "${name}" 不存在\n`));
1383
+ }
1384
+ break;
1385
+ }
1386
+ if (sub === "reload") {
1387
+ const reloadSpinner = new Spinner("正在重载 MCP 服务...");
1388
+ reloadSpinner.start();
1389
+ try {
1390
+ await session.reloadMcpServers();
1391
+ const mcpExt = session.extensionRunner?.loadedExtensions.find((e) => e.name === "mcp");
1392
+ const toolCount = mcpExt ? mcpExt.tools.size : 0;
1393
+ reloadSpinner.stop(chalk.green(` ✓ MCP 服务已重载 (${toolCount} 个工具)`));
1394
+ }
1395
+ catch (err) {
1396
+ reloadSpinner.stop(chalk.red(` ✗ 重载失败: ${err.message}`));
1397
+ }
1398
+ console.log();
1399
+ break;
1400
+ }
1401
+ console.log(chalk.yellow(`\n 未知子命令: ${sub}。使用 /mcp 查看帮助。\n`));
855
1402
  break;
856
1403
  }
857
1404
  case "/extensions": {
@@ -913,7 +1460,6 @@ async function handleCommand(input, session) {
913
1460
  }
914
1461
  break;
915
1462
  }
916
- // Try extension-registered commands
917
1463
  const extCmd = cmd.slice(1);
918
1464
  const extCommands = session.getExtensionCommands();
919
1465
  if (extCommands.has(extCmd)) {
@@ -1014,7 +1560,7 @@ function countVisualLines(text, termWidth) {
1014
1560
  }
1015
1561
  function printBanner(session) {
1016
1562
  const modelId = session.model.name || session.model.id;
1017
- const dir = session.cwd.replace(process.env.HOME || "", "~");
1563
+ const dir = shortenDir(session.cwd);
1018
1564
  const sm = session.sessionManager;
1019
1565
  console.log();
1020
1566
  console.log(chalk.bold.cyan(">_") + " " + chalk.bold("欢迎使用千岛湖 Til-Work") + " " + chalk.dim(`v${VERSION}`));
@@ -1036,21 +1582,7 @@ function printBanner(session) {
1036
1582
  const totalTools = extInfo.reduce((sum, e) => sum + e.toolCount, 0);
1037
1583
  console.log(` ${chalk.dim("extensions")} ${chalk.white(String(extInfo.length))} 个已加载 (${totalTools} 工具)`);
1038
1584
  }
1039
- const tips = [
1040
- "输入 / 查看所有可用命令,方向键选择",
1041
- "使用 /model 切换模型",
1042
- "按 Ctrl+C 或 Esc 中止正在执行的请求",
1043
- "使用 /memory 查看记忆内容",
1044
- "使用 /sessions 查看历史会话",
1045
- "输入 @ 引用文件内容(支持搜索和方向键选择)",
1046
- ];
1047
- if (session.skills.length > 0) {
1048
- tips.push("使用 /skills 查看已加载的技能");
1049
- }
1050
- if (extInfo.length > 0) {
1051
- tips.push("使用 /extensions 查看已加载的扩展");
1052
- }
1053
- const tip = tips[Math.floor(Math.random() * tips.length)];
1585
+ const tip = getRandomTip(session);
1054
1586
  console.log(`\n ${chalk.bold("Tip:")} ${chalk.italic(tip)}\n`);
1055
1587
  }
1056
1588
  //# sourceMappingURL=interactive.js.map