agent-loft 0.2.11 → 0.2.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -73,6 +73,8 @@ agent-loft service install
73
73
 
74
74
  聊天里的命令(飞书和 Web 通用;Web 输入框打 `/` 会弹出命令补全,飞书里打 `/` 会弹出机器人的指令面板(agent-loft 自动注册,约 5~8 分钟生效;只发一个 `/` 也会回一张可点击的命令卡片),顺序和显示项在 设置 → 命令 里调整):`/new` `/fork` `/sessions [all]` `/resume <id>` `/model [名称]` `/effort <程度>` `/agent [名称]`(或者直接发 `/claude`、`/codex`)`/stop` `/cd <目录>` `/pwd` `/status` `/approve <id> allow-once|allow-always|allow-session|deny` `/help`
75
75
 
76
+ 自定义命令:设置 → 命令 → 「添加命令」,填命令名、说明和一段提示词,之后发 `/命令名 参数` 就会把这段提示词发给 agent(提示词里的 `$ARGUMENTS` 换成参数,没写就把参数接在后面)。按 agent 分开存,可以编辑、删除,也会同步到飞书指令面板。
77
+
76
78
  ## 安全
77
79
 
78
80
  这个服务能让 agent 在你的机器上执行命令,等于一个远程 shell。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-loft",
3
- "version": "0.2.11",
3
+ "version": "0.2.12",
4
4
  "type": "module",
5
5
  "description": "多个 agent 的工作台:把本机 Claude Code / Codex 放到一处统一管理,用飞书和 Web 页面随时驱动——会话、审批、模型切换都共用",
6
6
  "keywords": [
@@ -136,12 +136,24 @@ export class ClaudeDriver implements AgentDriver {
136
136
  // 运行时带着 timestamp(ISO 字符串),类型声明里没有
137
137
  const ts = Date.parse((msg as { timestamp?: string }).timestamp ?? "");
138
138
  if (msg.type === "user") {
139
+ // Claude Code 自己塞进记录的「用户消息」不是人发的:后台命令跑完的 <task-notification> 等,
140
+ // 记录里 origin.kind 不是 human。老记录可能没有 origin,再按正文开头兜底
141
+ const origin = (msg as { origin?: { kind?: string } }).origin;
142
+ // 它触发的那轮回复仍单独成一条(和实时看到的一样),不并进上一条回复
143
+ if (origin?.kind && origin.kind !== "human") {
144
+ flush();
145
+ continue;
146
+ }
139
147
  const texts = blocks.filter((b) => b.type === "text" && b.text).map((b) => b.text!);
140
148
  if (texts.length === 0) continue; // 纯 tool_result
141
149
  // 我们自己拼进去的附件清单:从正文里拆出来,单独作为附件给前端展示
142
150
  const { text, attachments } = splitAttachmentNote(texts.join("\n"));
143
151
  if (!text && attachments.length === 0) continue;
144
152
  if (text.startsWith("<command-") || text.startsWith("<local-command")) continue;
153
+ if (text.startsWith("<task-notification>")) {
154
+ flush();
155
+ continue;
156
+ }
145
157
  flush();
146
158
  out.push({
147
159
  role: "user",
@@ -255,7 +255,12 @@ export function commandListCard(params: {
255
255
  { tag: "hr" },
256
256
  ];
257
257
  for (const c of shown) {
258
- const tag = c.source === "agent" ? ` <font color='grey'>· ${params.agentLabel}</font>` : "";
258
+ const tag =
259
+ c.source === "agent"
260
+ ? ` <font color='grey'>· ${params.agentLabel}</font>`
261
+ : c.source === "custom"
262
+ ? " <font color='grey'>· 自定义</font>"
263
+ : "";
259
264
  const text = `**${usageOf(c)}**${tag}\n<font color='grey'>${oneLine(c.description) || "(无说明)"}</font>`;
260
265
  elements.push({
261
266
  tag: "column_set",
@@ -4,6 +4,7 @@ import fs from "node:fs";
4
4
  import path from "node:path";
5
5
  import type { IncomingMessage, ServerResponse } from "node:http";
6
6
  import { AGENT_IDS, config, listWorkspaceDirs, resolveWorkspacePath, saveConfig, type AppConfig } from "../../core/config.js";
7
+ import { cleanCustomCommands } from "../../core/command-catalog.js";
7
8
  import { hub } from "../../core/hub.js";
8
9
  import { interactions } from "../../core/interactions.js";
9
10
  import { approve, listApproved, listPending, revoke } from "../../core/pairing.js";
@@ -183,11 +184,20 @@ function applyConfig(input: Partial<AppConfig>): void {
183
184
  const names = (v: unknown) =>
184
185
  Array.isArray(v) ? [...new Set(v.filter((s): s is string => typeof s === "string" && s.length > 0 && s.length < 128))] : null;
185
186
  for (const id of AGENT_IDS) {
186
- const prefs = (input.commands as Record<string, { order?: unknown; hidden?: unknown } | undefined>)[id];
187
+ const prefs = (input.commands as Record<string, { order?: unknown; hidden?: unknown; custom?: unknown } | undefined>)[id];
187
188
  if (!prefs) continue;
189
+ let custom = next.commands[id].custom;
190
+ if (prefs.custom !== undefined) {
191
+ try {
192
+ custom = cleanCustomCommands(prefs.custom, true);
193
+ } catch (err) {
194
+ throw new HttpError(400, err instanceof Error ? err.message : String(err));
195
+ }
196
+ }
188
197
  next.commands[id] = {
189
198
  order: names(prefs.order) ?? next.commands[id].order,
190
199
  hidden: names(prefs.hidden) ?? next.commands[id].hidden,
200
+ custom,
191
201
  };
192
202
  }
193
203
  }
@@ -1,7 +1,8 @@
1
1
  // 命令目录:输入「/」时给用户看的那张表。
2
2
  //
3
- // 两种来源:
3
+ // 三种来源:
4
4
  // - agent-loft 自己的命令(/new /status …),由 hub 拦下来执行,所有 agent 通用;
5
+ // - 用户自定义命令(设置 → 命令里添加):一段预设的提示词,发送时展开成这段话交给 agent;
5
6
  // - agent 原生命令(Claude 的 skills、/compact 之类),agent-loft 不认识,原样转给 agent。
6
7
  // 顺序和隐藏按 agent 分开存在配置里(设置 → 命令),用户自己排。
7
8
 
@@ -30,17 +31,88 @@ export const BUILTIN_COMMANDS: BuiltinSpec[] = [
30
31
  /** agent 自己报上来的命令 */
31
32
  export type AgentCommand = { name: string; description: string; args?: string };
32
33
 
34
+ /**
35
+ * 用户自定义命令。prompt 里的 $ARGUMENTS 换成命令后面跟的参数;
36
+ * 没写 $ARGUMENTS 又带了参数,参数接在提示词后面另起一段。
37
+ */
38
+ export type CustomCommand = { name: string; description: string; prompt: string };
39
+
40
+ /** 自定义命令名:和飞书指令同样的规则(小写字母、数字、- _,最长 32),这样能原样同步到飞书 */
41
+ export const CUSTOM_NAME = /^[a-z0-9][a-z0-9_-]{0,31}$/;
42
+
33
43
  export type CommandEntry = {
34
44
  name: string;
35
45
  args?: string;
36
46
  description: string;
37
- source: "agent-loft" | "agent";
47
+ source: "agent-loft" | "custom" | "agent";
48
+ /** 自定义命令的提示词(设置页编辑用) */
49
+ prompt?: string;
38
50
  /** 不带参数就能直接执行(飞书卡片上给按钮、Web 里回车直接发) */
39
51
  runnable: boolean;
40
52
  hidden: boolean;
41
53
  };
42
54
 
43
- export type CommandPrefs = { order: string[]; hidden: string[] };
55
+ export type CommandPrefs = { order: string[]; hidden: string[]; custom?: CustomCommand[] };
56
+
57
+ /**
58
+ * 整理自定义命令列表。strict(Web 保存时)遇到不合规的直接报错,提示具体哪条;
59
+ * 否则(读配置文件时,可能是手改的)悄悄丢掉不合规的。
60
+ */
61
+ export function cleanCustomCommands(list: unknown, strict = false): CustomCommand[] {
62
+ const fail = (msg: string) => {
63
+ if (strict) throw new Error(msg);
64
+ };
65
+ if (!Array.isArray(list)) {
66
+ fail("自定义命令格式不对");
67
+ return [];
68
+ }
69
+ const builtin = new Set(BUILTIN_COMMANDS.map((c) => c.name));
70
+ const out: CustomCommand[] = [];
71
+ for (const raw of list as Partial<CustomCommand>[]) {
72
+ const name = typeof raw?.name === "string" ? raw.name.trim().replace(/^[//]/, "").toLowerCase() : "";
73
+ const prompt = typeof raw?.prompt === "string" ? raw.prompt.trim() : "";
74
+ const description = typeof raw?.description === "string" ? raw.description.trim().slice(0, 200) : "";
75
+ if (!CUSTOM_NAME.test(name)) {
76
+ fail(`命令名「${name || "(空)"}」不合规:只能用小写字母、数字、- 和 _,以字母或数字开头,最长 32 个字符`);
77
+ continue;
78
+ }
79
+ if (builtin.has(name)) {
80
+ fail(`/${name} 是 agent-loft 内置命令,换个名字`);
81
+ continue;
82
+ }
83
+ if (out.some((c) => c.name === name)) {
84
+ fail(`/${name} 重复了`);
85
+ continue;
86
+ }
87
+ if (!prompt) {
88
+ fail(`/${name} 的提示词不能为空`);
89
+ continue;
90
+ }
91
+ if (prompt.length > 20000) {
92
+ fail(`/${name} 的提示词太长(最多 20000 字)`);
93
+ continue;
94
+ }
95
+ out.push({ name, description, prompt });
96
+ }
97
+ return out;
98
+ }
99
+
100
+ /**
101
+ * 整条消息是一条自定义命令时,展开成它的提示词;不是就返回 null(照原样处理)。
102
+ * 和内置命令一样,只认「整条消息就是命令」:多行的、不认识的名字都不展开。
103
+ */
104
+ export function expandCustomCommand(raw: string, custom: CustomCommand[] | undefined): string | null {
105
+ if (!custom?.length) return null;
106
+ const text = raw.trim().replace(/^//, "/");
107
+ if (!text.startsWith("/") || text.includes("\n")) return null;
108
+ const m = /^\/(\S+)(?:\s+([\s\S]*))?$/.exec(text);
109
+ if (!m) return null;
110
+ const cmd = custom.find((c) => c.name === m[1]!.toLowerCase());
111
+ if (!cmd) return null;
112
+ const args = (m[2] ?? "").trim();
113
+ if (cmd.prompt.includes("$ARGUMENTS")) return cmd.prompt.split("$ARGUMENTS").join(args).trim();
114
+ return args ? `${cmd.prompt.trim()}\n\n${args}` : cmd.prompt.trim();
115
+ }
44
116
 
45
117
  /** 参数里有必填项(尖括号)就不能一键执行 */
46
118
  function needsArgs(args: string | undefined): boolean {
@@ -73,6 +145,21 @@ export function buildCatalog(params: {
73
145
  hidden: false,
74
146
  }));
75
147
  const taken = new Set(BUILTIN_COMMANDS.map((c) => c.name));
148
+ // 自定义命令:和内置重名的不收(保存时已经拦过,这里兜底手改配置的情况);原生命令和它重名时让给它
149
+ const custom: CommandEntry[] = [];
150
+ for (const c of params.prefs?.custom ?? []) {
151
+ if (!CUSTOM_NAME.test(c.name) || taken.has(c.name)) continue;
152
+ taken.add(c.name);
153
+ custom.push({
154
+ name: c.name,
155
+ ...(c.prompt.includes("$ARGUMENTS") ? { args: "[参数]" } : {}),
156
+ description: c.description,
157
+ source: "custom",
158
+ prompt: c.prompt,
159
+ runnable: true,
160
+ hidden: false,
161
+ });
162
+ }
76
163
  const seen = new Set<string>();
77
164
  const native: CommandEntry[] = [];
78
165
  for (const c of params.native) {
@@ -89,7 +176,7 @@ export function buildCatalog(params: {
89
176
  });
90
177
  }
91
178
 
92
- const all = [...builtin, ...native];
179
+ const all = [...builtin, ...custom, ...native];
93
180
  const order = params.prefs?.order ?? [];
94
181
  const hidden = new Set(params.prefs?.hidden ?? []);
95
182
  const rank = new Map(order.map((name, i) => [name, i]));
@@ -6,7 +6,7 @@ import fs from "node:fs";
6
6
  import path from "node:path";
7
7
  import crypto from "node:crypto";
8
8
  import { configPath, ensureDirs, readJson, writeJsonAtomic } from "../home.js";
9
- import type { CommandPrefs } from "./command-catalog.js";
9
+ import { cleanCustomCommands, type CommandPrefs } from "./command-catalog.js";
10
10
 
11
11
 
12
12
  export type SandboxMode = "read-only" | "workspace-write" | "danger-full-access";
@@ -97,7 +97,7 @@ export function defaultConfig(): AppConfig {
97
97
  },
98
98
  },
99
99
  web: { enabled: true, host: "0.0.0.0", port: 8800, passwordHash: "", trustedProxies: ["127.0.0.1", "::1"], quotaRefreshMin: 10 },
100
- commands: { claude: { order: [], hidden: [] }, codex: { order: [], hidden: [] } },
100
+ commands: { claude: { order: [], hidden: [], custom: [] }, codex: { order: [], hidden: [], custom: [] } },
101
101
  feishu: { watchdogMs: 10 * 60 * 1000, bots: [] },
102
102
  };
103
103
  }
@@ -132,6 +132,11 @@ function normalizeBot(raw: Partial<FeishuBotConfig>): FeishuBotConfig {
132
132
  };
133
133
  }
134
134
 
135
+ /** 自定义命令可能是手改进配置文件的:不合规的丢掉,别让一条坏数据把命令列表搞崩 */
136
+ function normalizeCommands(cfg: AppConfig): void {
137
+ for (const id of AGENT_IDS) cfg.commands[id].custom = cleanCustomCommands(cfg.commands[id].custom);
138
+ }
139
+
135
140
  let current: AppConfig | null = null;
136
141
  const listeners = new Set<(next: AppConfig, prev: AppConfig) => void>();
137
142
 
@@ -142,6 +147,7 @@ export function loadConfig(): AppConfig {
142
147
  const merged = mergeDefaults(defaultConfig(), raw);
143
148
  merged.workspaceRoot = path.resolve(merged.workspaceRoot);
144
149
  merged.feishu.bots = merged.feishu.bots.map(normalizeBot);
150
+ normalizeCommands(merged);
145
151
  current = merged;
146
152
  if (!raw) writeJsonAtomic(configPath, merged);
147
153
  watchExternalEdits();
@@ -167,6 +173,7 @@ function watchExternalEdits(): void {
167
173
  const next = mergeDefaults(defaultConfig(), raw);
168
174
  next.workspaceRoot = path.resolve(next.workspaceRoot);
169
175
  next.feishu.bots = next.feishu.bots.map(normalizeBot);
176
+ normalizeCommands(next);
170
177
  if (JSON.stringify(next) === JSON.stringify(current)) return;
171
178
  const prev = current;
172
179
  current = next;
package/src/core/hub.ts CHANGED
@@ -38,7 +38,7 @@ import type {
38
38
  StepKind,
39
39
  UserInput,
40
40
  } from "./types.js";
41
- import { buildCatalog, type AgentCommand, type CommandEntry, type CommandPrefs } from "./command-catalog.js";
41
+ import { buildCatalog, expandCustomCommand, type AgentCommand, type CommandEntry, type CommandPrefs } from "./command-catalog.js";
42
42
  import { helpText, type ParsedCommand } from "./commands.js";
43
43
 
44
44
  // ---------- 事件 ----------
@@ -655,6 +655,10 @@ class Hub extends EventEmitter<{ event: [EventEnvelope] }> {
655
655
  send(key: string, input: UserInput, origin: Origin, gen?: InputGen): void {
656
656
  const conv = getConversation(key);
657
657
  if (!conv) throw new Error(`会话不存在:${key}`);
658
+ // 用户自定义命令(设置 → 命令):展开成预设的提示词再发,界面上和 agent 收到的都是展开后的内容
659
+ const custom = (config().commands as Record<string, CommandPrefs | undefined>)[conv.agent]?.custom;
660
+ const expanded = expandCustomCommand(input.text, custom);
661
+ if (expanded !== null) input = { ...input, text: expanded };
658
662
  const g = gen ?? this.generation(key);
659
663
  const cancelled = () => {
660
664
  if (this.stopGeneration(key) !== g.stop) {
package/web/app.js CHANGED
@@ -45,6 +45,7 @@ const ICON = {
45
45
  copy: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>',
46
46
  x: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg>',
47
47
  plus: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>',
48
+ trash: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4h8v2"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v6M14 11v6"/></svg>',
48
49
  folder: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7.5A2.5 2.5 0 0 1 5.5 5h3.6l2 2.2h7.4A2.5 2.5 0 0 1 21 9.7v7.8a2.5 2.5 0 0 1-2.5 2.5h-13A2.5 2.5 0 0 1 3 17.5Z"/><path d="M3 10h18"/></svg>',
49
50
  folderOpen: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M3 17.5V7.5A2.5 2.5 0 0 1 5.5 5h3.6l2 2.2h6.4A2.5 2.5 0 0 1 20 9.7V11"/><path d="M3 17.5 5.6 12a2 2 0 0 1 1.8-1.1h13a1 1 0 0 1 .9 1.4l-2.4 6.2a2.2 2.2 0 0 1-2 1.5H5.5A2.5 2.5 0 0 1 3 17.5Z"/></svg>',
50
51
  laptop: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="5" width="16" height="11" rx="1.5"/><path d="M2 19h20"/></svg>',
@@ -68,6 +69,27 @@ ICON.claude = (() => {
68
69
  })();
69
70
  ICON.codex = ICON.logo;
70
71
 
72
+ /**
73
+ * 星星加载动画(回复末尾、打开会话时用)。开屏那个带台面的完整 logo 动画直接写在 index.html 里。
74
+ * 每个实例的渐变 id 唯一,避免页面里有隐藏的同 id 渐变时画不出来。
75
+ * 回复流式输出时整条消息会反复重建,动画按全局时钟设负的起始延迟,重建后接着播、不会一抖一抖地重来。
76
+ */
77
+ let loaderSeq = 0;
78
+
79
+ /** 只有 logo 中间那颗大星,一边旋转一边放大缩小 */
80
+ const STARS_MS = 1800;
81
+ function starsLoader(cls = "") {
82
+ const id = `loft-g${++loaderSeq}`;
83
+ const el = h("span", {
84
+ class: `loft-stars${cls ? ` ${cls}` : ""}`,
85
+ role: "img",
86
+ "aria-label": "回复中",
87
+ html: `<svg viewBox="180 82 152 152" aria-hidden="true"><defs><linearGradient id="${id}" gradientUnits="userSpaceOnUse" x1="180" y1="82" x2="332" y2="234"><stop offset="0" stop-color="#3b82f6"/><stop offset="1" stop-color="#06b6d4"/></linearGradient></defs><path d="M256 82C268.92 145.08 268.92 145.08 332 158C268.92 170.92 268.92 170.92 256 234C243.08 170.92 243.08 170.92 180 158C243.08 145.08 243.08 145.08 256 82Z" fill="url(#${id})"/></svg>`,
88
+ });
89
+ el.style.setProperty("--t", `${-(performance.now() % STARS_MS).toFixed(0)}ms`);
90
+ return el;
91
+ }
92
+
71
93
  function agentIcon(id, cls = "agent-icon") {
72
94
  return h("span", { class: cls, html: ICON[id] ?? ICON.laptop });
73
95
  }
@@ -120,6 +142,28 @@ function formatDuration(ms) {
120
142
  return `${m}分${s ? `${s}秒` : ""}`;
121
143
  }
122
144
 
145
+ /** 回复进行中的实时用时:整秒跳动,12秒 / 3分05秒 / 1小时05分 */
146
+ function liveDuration(ms) {
147
+ const total = Math.max(0, Math.floor(ms / 1000));
148
+ const hr = Math.floor(total / 3600);
149
+ const m = Math.floor((total % 3600) / 60);
150
+ const s = total % 60;
151
+ if (hr > 0) return `${hr}小时${String(m).padStart(2, "0")}分`;
152
+ if (m > 0) return `${m}分${String(s).padStart(2, "0")}秒`;
153
+ return `${s}秒`;
154
+ }
155
+
156
+ /** 服务端时间 - 本机时间。事件里的 ts 是服务端时钟,算用时要换算过来,免得两边时钟不准 */
157
+ let clockSkew = 0;
158
+ const serverNow = () => Date.now() + clockSkew;
159
+
160
+ /** 所有进行中的用时一秒刷一次,只改文字,不重建消息 */
161
+ setInterval(() => {
162
+ for (const el of document.querySelectorAll(".ai-elapsed[data-start]")) {
163
+ el.textContent = liveDuration(serverNow() - Number(el.dataset.start));
164
+ }
165
+ }, 1000);
166
+
123
167
  /** 老版本记下的脚注是「用时 1234.5s」,显示时也换成时 / 分 / 秒 */
124
168
  function tidyFooter(footer) {
125
169
  return footer?.replace(/用时 ([\d.]+)s/, (_, s) => `用时 ${formatDuration(Number(s) * 1000)}`) ?? "";
@@ -478,7 +522,10 @@ async function boot() {
478
522
  WS.connect();
479
523
  void loadQuota();
480
524
  const fromHash = decodeURIComponent(location.hash.slice(1));
525
+ // 地址里带了会话就打开它;否则打开「最近」里最近活跃的那个对话,一个都没有才进新对话页
526
+ const latest = [...S.convs.values()].filter((c) => !c.hidden).sort((a, b) => b.activeAt - a.activeAt)[0];
481
527
  if (fromHash && S.convs.has(fromHash)) await openConversation(fromHash);
528
+ else if (latest) await openConversation(latest.key);
482
529
  else newDraft();
483
530
  if ("serviceWorker" in navigator) {
484
531
  navigator.serviceWorker.register("/sw.js").catch(() => {});
@@ -1348,6 +1395,7 @@ function newDraft() {
1348
1395
  S.current = null;
1349
1396
  S.items = [];
1350
1397
  S.turnAi = null;
1398
+ turnStartAt = null;
1351
1399
  S.uploads = [];
1352
1400
  setBusy(false);
1353
1401
  history.replaceState(null, "", location.pathname);
@@ -1387,13 +1435,14 @@ async function openConversation(key) {
1387
1435
  S.current = key;
1388
1436
  S.items = [];
1389
1437
  S.turnAi = null;
1438
+ turnStartAt = null;
1390
1439
  S.uploads = [];
1391
1440
  renderChips();
1392
1441
  history.replaceState(null, "", `#${encodeURIComponent(key)}`);
1393
1442
  WS.presence();
1394
1443
  renderTopbar();
1395
1444
  renderSidebar();
1396
- fill(ui.msgInner, h("div", { class: "empty" }, h("span", { class: "spinner" })));
1445
+ fill(ui.msgInner, h("div", { class: "empty" }, starsLoader("lg")));
1397
1446
  let data;
1398
1447
  for (let attempt = 0; ; attempt++) {
1399
1448
  try {
@@ -1414,6 +1463,7 @@ async function openConversation(key) {
1414
1463
  if (token !== S.loadToken) return; // 期间又切到别的会话了
1415
1464
  S.convs.set(key, data.info);
1416
1465
  S.allowedAgents = data.allowedAgents;
1466
+ if (Number.isFinite(data.serverTime)) clockSkew = data.serverTime - Date.now();
1417
1467
  fill(ui.msgInner, );
1418
1468
  for (const m of data.messages) {
1419
1469
  if (m.role === "user") addItem({ type: "user", text: m.text, files: m.attachments ?? [], origin: null });
@@ -1629,7 +1679,19 @@ function renderAi(item) {
1629
1679
  "div",
1630
1680
  { class: "msg-ai" },
1631
1681
  steps,
1632
- body ? renderMarkdown(body) : working && item.steps.length === 0 ? h("div", { class: "thinking" }, h("span", { class: "shimmer" }, "思考中…")) : null,
1682
+ body ? renderMarkdown(body) : null,
1683
+ // 还在回复:末尾挂上星星动画和实时用时;什么都还没出来时旁边带一句「思考中…」
1684
+ working
1685
+ ? h(
1686
+ "div",
1687
+ { class: "thinking" },
1688
+ starsLoader(),
1689
+ !body && item.steps.length === 0 ? h("span", { class: "shimmer" }, "思考中…") : null,
1690
+ item.startAt
1691
+ ? h("span", { class: "ai-elapsed", "data-start": item.startAt }, liveDuration(serverNow() - item.startAt))
1692
+ : null,
1693
+ )
1694
+ : null,
1633
1695
  item.error ? h("div", { class: "error-box" }, item.error) : null,
1634
1696
  item.done && !item.segment && (body || item.footer)
1635
1697
  ? h(
@@ -1647,9 +1709,17 @@ function renderAi(item) {
1647
1709
  );
1648
1710
  }
1649
1711
 
1650
- function currentAi() {
1712
+ /**
1713
+ * 这一轮的开始时间(服务端时钟):从用户发出那条消息算起。中途插了授权 / 提问卡片、
1714
+ * 回复拆成好几段时,后面的段接着这个时间算,显示的是整轮的总用时。
1715
+ * 没有用户消息就开始的一轮(比如后台任务跑完 agent 自己接着干),从第一个事件算起。
1716
+ */
1717
+ let turnStartAt = null;
1718
+
1719
+ function currentAi(ts) {
1651
1720
  if (!S.turnAi || S.turnAi.done) {
1652
- S.turnAi = addItem({ type: "ai", steps: [], committed: "", partial: "", done: false });
1721
+ turnStartAt ??= ts ?? serverNow();
1722
+ S.turnAi = addItem({ type: "ai", steps: [], committed: "", partial: "", done: false, startAt: turnStartAt });
1653
1723
  }
1654
1724
  return S.turnAi;
1655
1725
  }
@@ -1668,31 +1738,33 @@ function applyEvent(env) {
1668
1738
  S.items = S.items.filter((i) => i !== S.turnAi);
1669
1739
  S.turnAi = null;
1670
1740
  }
1741
+ // 上一轮还没结束时发的消息会排队,不改正在进行那一轮的开始时间
1742
+ if (!S.turnAi || S.turnAi.done) turnStartAt = env.ts;
1671
1743
  addItem({ type: "user", text: ev.text, files: ev.files ?? ev.attachments, origin: ev.origin });
1672
- currentAi();
1744
+ currentAi(env.ts);
1673
1745
  return;
1674
1746
  }
1675
1747
  case "step": {
1676
- const ai = currentAi();
1748
+ const ai = currentAi(env.ts);
1677
1749
  ai.steps.push(ev.text);
1678
1750
  updateItem(ai);
1679
1751
  return;
1680
1752
  }
1681
1753
  case "delta": {
1682
- const ai = currentAi();
1754
+ const ai = currentAi(env.ts);
1683
1755
  ai.partial += ev.text;
1684
1756
  updateItem(ai);
1685
1757
  return;
1686
1758
  }
1687
1759
  case "text": {
1688
- const ai = currentAi();
1760
+ const ai = currentAi(env.ts);
1689
1761
  ai.committed += (ai.committed ? "\n\n" : "") + ev.text;
1690
1762
  ai.partial = "";
1691
1763
  updateItem(ai);
1692
1764
  return;
1693
1765
  }
1694
1766
  case "result": {
1695
- const ai = currentAi();
1767
+ const ai = currentAi(env.ts);
1696
1768
  if (!ai.committed && ev.text) ai.committed = ev.text;
1697
1769
  ai.partial = "";
1698
1770
  ai.done = true;
@@ -1701,6 +1773,7 @@ function applyEvent(env) {
1701
1773
  ai.anchor = ev.anchor;
1702
1774
  updateItem(ai);
1703
1775
  S.turnAi = null;
1776
+ turnStartAt = null;
1704
1777
  return;
1705
1778
  }
1706
1779
  case "error": {
@@ -1710,6 +1783,7 @@ function applyEvent(env) {
1710
1783
  updateItem(S.turnAi);
1711
1784
  S.turnAi = null;
1712
1785
  } else addItem({ type: "error", text: ev.message });
1786
+ turnStartAt = null;
1713
1787
  return;
1714
1788
  }
1715
1789
  case "notice":
@@ -1722,6 +1796,7 @@ function applyEvent(env) {
1722
1796
  updateItem(S.turnAi);
1723
1797
  S.turnAi = null;
1724
1798
  }
1799
+ if (!ev.busy) turnStartAt = null;
1725
1800
  return;
1726
1801
  case "meta":
1727
1802
  return;
@@ -2364,6 +2439,7 @@ function renderSlashMenu(items, error) {
2364
2439
  h("span", { class: "slash-name" }, `/${c.name}`),
2365
2440
  c.args ? h("span", { class: "slash-args" }, c.args) : null,
2366
2441
  c.source === "agent" ? h("span", { class: "badge" }, agentLabel(currentAgentId())) : null,
2442
+ c.source === "custom" ? h("span", { class: "badge" }, "自定义") : null,
2367
2443
  ),
2368
2444
  h("div", { class: "slash-desc" }, oneLine(c.description, 120) || "(无说明)"),
2369
2445
  ),
@@ -3110,8 +3186,10 @@ async function saveConfig(cfg, patch) {
3110
3186
  renderTopbar();
3111
3187
  renderSidebar();
3112
3188
  renderContext();
3189
+ return true;
3113
3190
  } catch (err) {
3114
3191
  toast(err.message);
3192
+ return false;
3115
3193
  }
3116
3194
  }
3117
3195
 
@@ -3224,9 +3302,60 @@ function modelPicker(agent, model, effort, defaultLabel) {
3224
3302
  return { modelEl: modelSlot, effortEl: effortSlot, model: () => getModel(), effort: () => getEffort() };
3225
3303
  }
3226
3304
 
3305
+ /** agent-loft 内置命令名(和服务端 BUILTIN_COMMANDS 一致),自定义命令不能用 */
3306
+ const BUILTIN_NAMES = ["new", "status", "stop", "model", "effort", "sessions", "resume", "fork", "agent", "claude", "codex", "cd", "pwd", "autoedit", "approve", "help"];
3307
+
3308
+ /**
3309
+ * 添加 / 编辑一条自定义命令。onSave 返回 true 才关窗;失败(比如服务端校验没过)留着让用户改。
3310
+ * 规则和服务端一致:名字只能小写字母、数字、- _(飞书指令同款规则),不能和其它命令重名。
3311
+ */
3312
+ function customDialog({ agent, entry, taken }, onSave) {
3313
+ const name = h("input", { value: entry?.name ?? "", placeholder: "比如 review", autocomplete: "off", spellcheck: "false" });
3314
+ const desc = h("input", { value: entry?.description ?? "", placeholder: "在命令列表里显示的一句话说明(可选)", maxlength: 200 });
3315
+ const prompt = h("textarea", { rows: 8, placeholder: "发送这条命令时,实际发给 agent 的内容" });
3316
+ prompt.value = entry?.prompt ?? "";
3317
+ const err = h("div", { class: "error-text" });
3318
+ const ok = h("button", { class: "btn primary" }, entry ? "保存" : "添加");
3319
+ const builtin = new Set(BUILTIN_NAMES);
3320
+ const submit = async () => {
3321
+ const n = name.value.trim().replace(/^[//]/, "").toLowerCase();
3322
+ const p = prompt.value.trim();
3323
+ let problem = "";
3324
+ if (!/^[a-z0-9][a-z0-9_-]{0,31}$/.test(n)) problem = "命令名只能用小写字母、数字、- 和 _,以字母或数字开头,最长 32 个字符";
3325
+ else if (builtin.has(n)) problem = `/${n} 是 agent-loft 内置命令,换个名字`;
3326
+ else if (taken.includes(n)) problem = `已经有 /${n} 这条命令了,换个名字`;
3327
+ else if (!p) problem = "提示词不能为空";
3328
+ err.textContent = problem;
3329
+ if (problem) return;
3330
+ ok.disabled = true;
3331
+ const saved = await onSave({ name: n, description: desc.value.trim(), prompt: p });
3332
+ ok.disabled = false;
3333
+ if (saved) close();
3334
+ };
3335
+ ok.onclick = () => void submit();
3336
+ const close = modal({
3337
+ title: entry ? `编辑 /${entry.name}` : `添加命令 · ${agentLabel(agent)}`,
3338
+ body: h(
3339
+ "div",
3340
+ { class: "modal-body" },
3341
+ field("命令名", h("div", { class: "cmd-name-input" }, h("span", {}, "/"), name), "小写字母、数字、- 和 _,最长 32 个字符(和飞书指令规则一致,才能同步到飞书)"),
3342
+ field("说明", desc),
3343
+ field(
3344
+ "提示词",
3345
+ prompt,
3346
+ "发送「/命令名 参数」时,把这段话发给 agent。提示词里写 $ARGUMENTS 会换成参数;不写的话,参数接在提示词后面。",
3347
+ ),
3348
+ err,
3349
+ ),
3350
+ foot: h("div", { class: "modal-foot" }, ok),
3351
+ });
3352
+ setTimeout(() => (entry ? prompt : name).focus(), 0);
3353
+ }
3354
+
3227
3355
  /**
3228
3356
  * 设置 → 命令:每个 agent 一张表,拖动或用箭头排序,取消勾选就不在「/」列表里出现。
3229
3357
  * 保存时把整张表的顺序存下来;以后新装的 skill 不在表里,会自动接在最后。
3358
+ * 用户可以自己添加命令(一段预设的提示词),自己加的可以编辑、删除,增删改后立即保存。
3230
3359
  */
3231
3360
  function commandsTab(cfg, panel) {
3232
3361
  const agentIds = Object.keys(cfg.agents);
@@ -3313,9 +3442,16 @@ function commandsTab(cfg, panel) {
3313
3442
  { class: "t" },
3314
3443
  h("code", {}, commandUsage(c)),
3315
3444
  c.source === "agent" ? h("span", { class: "badge" }, agentLabel(agent)) : null,
3445
+ c.source === "custom" ? h("span", { class: "badge custom" }, "自定义") : null,
3316
3446
  ),
3317
- h("div", { class: "s" }, oneLine(c.description, 100) || "(无说明)"),
3447
+ h("div", { class: "s" }, oneLine(c.description || c.prompt || "", 100) || "(无说明)"),
3318
3448
  ),
3449
+ c.source === "custom"
3450
+ ? [
3451
+ h("button", { class: "icon-btn", title: "编辑", onclick: () => editCustom(c) }, icon("edit")),
3452
+ h("button", { class: "icon-btn danger", title: "删除", onclick: () => deleteCustom(c) }, icon("trash")),
3453
+ ]
3454
+ : null,
3319
3455
  h("button", { class: "icon-btn", title: "置顶", onclick: () => move(i, 0) }, "⤒"),
3320
3456
  h("button", { class: "icon-btn", title: "上移", onclick: () => move(i, i - 1) }, "↑"),
3321
3457
  h("button", { class: "icon-btn", title: "下移", onclick: () => move(i, i + 1) }, "↓"),
@@ -3326,10 +3462,46 @@ function commandsTab(cfg, panel) {
3326
3462
  };
3327
3463
 
3328
3464
  const save = async (prefs) => {
3329
- await saveConfig(cfg, { commands: { [agent]: prefs } });
3465
+ const ok = await saveConfig(cfg, { commands: { [agent]: prefs } });
3330
3466
  slash.cache.clear();
3331
3467
  // 服务端 3 秒防抖后才开始同步,稍等再看状态
3332
- setTimeout(() => void refreshFeishu(), 3500);
3468
+ if (ok) setTimeout(() => void refreshFeishu(), 3500);
3469
+ return ok;
3470
+ };
3471
+ const prefsOf = (rows) => ({
3472
+ order: rows.map((c) => c.name),
3473
+ hidden: rows.filter((c) => c.hidden).map((c) => c.name),
3474
+ custom: rows.filter((c) => c.source === "custom").map(({ name, description, prompt }) => ({ name, description, prompt })),
3475
+ });
3476
+
3477
+ // ---- 自定义命令:增删改都立即保存(连同当前的排序和显示),保存失败就不动列表 ----
3478
+ const commit = async (rows) => {
3479
+ if (!(await save(prefsOf(rows)))) return false;
3480
+ list = rows;
3481
+ renderList();
3482
+ return true;
3483
+ };
3484
+ const addCustom = () =>
3485
+ customDialog({ agent, taken: list.map((c) => c.name) }, async (entry) => {
3486
+ // 新命令放在已有的自定义命令后面;还没有的话放在 agent-loft 内置命令后面
3487
+ const rows = [...list];
3488
+ const lastCustom = rows.findLastIndex((c) => c.source === "custom");
3489
+ const at = lastCustom >= 0 ? lastCustom + 1 : rows.findLastIndex((c) => c.source === "agent-loft") + 1;
3490
+ rows.splice(at, 0, { ...entry, source: "custom", runnable: true, hidden: false });
3491
+ return commit(rows);
3492
+ });
3493
+ const editCustom = (c) =>
3494
+ customDialog({ agent, entry: c, taken: list.filter((x) => x !== c).map((x) => x.name) }, (entry) =>
3495
+ commit(list.map((x) => (x === c ? { ...x, ...entry } : x))),
3496
+ );
3497
+ const deleteCustom = async (c) => {
3498
+ const sure = await confirmDialog({
3499
+ title: `删除 /${c.name}?`,
3500
+ message: "删除后在「/」列表和飞书指令面板里都不再出现。",
3501
+ confirmText: "删除",
3502
+ danger: true,
3503
+ });
3504
+ if (sure) await commit(list.filter((x) => x !== c));
3333
3505
  };
3334
3506
 
3335
3507
  // ---- 飞书指令面板同步状态 ----
@@ -3397,7 +3569,12 @@ function commandsTab(cfg, panel) {
3397
3569
  "在输入框里打「/」时弹出的命令,按这里的顺序排列;保存后会自动同步到飞书机器人的指令面板。取消勾选的不会出现在列表里,但照样可以手动输入。带 agent 名标记的是 agent 自己的命令(skills 等),会原样转给 agent。",
3398
3570
  ),
3399
3571
  tabs,
3400
- search,
3572
+ h(
3573
+ "div",
3574
+ { class: "row cmd-tools" },
3575
+ search,
3576
+ h("button", { class: "btn", onclick: () => addCustom() }, icon("plus"), "添加命令"),
3577
+ ),
3401
3578
  listBox,
3402
3579
  h(
3403
3580
  "div",
@@ -3406,7 +3583,7 @@ function commandsTab(cfg, panel) {
3406
3583
  "button",
3407
3584
  {
3408
3585
  class: "btn primary",
3409
- onclick: () => save({ order: list.map((c) => c.name), hidden: list.filter((c) => c.hidden).map((c) => c.name) }),
3586
+ onclick: () => save(prefsOf(list)),
3410
3587
  },
3411
3588
  "保存",
3412
3589
  ),
@@ -3415,7 +3592,7 @@ function commandsTab(cfg, panel) {
3415
3592
  {
3416
3593
  class: "btn",
3417
3594
  onclick: async () => {
3418
- if (!(await confirmDialog({ title: "恢复默认?", message: `把 ${agentLabel(agent)} 的命令顺序和显示恢复成默认。`, confirmText: "恢复默认" }))) return;
3595
+ if (!(await confirmDialog({ title: "恢复默认?", message: `把 ${agentLabel(agent)} 的命令顺序和显示恢复成默认(自定义命令保留)。`, confirmText: "恢复默认" }))) return;
3419
3596
  await save({ order: [], hidden: [] });
3420
3597
  await load(agent);
3421
3598
  },
package/web/index.html CHANGED
@@ -17,7 +17,7 @@
17
17
  <link rel="stylesheet" href="/style.css" />
18
18
  </head>
19
19
  <body>
20
- <div id="app"><div class="boot">加载中…</div></div>
20
+ <div id="app"><div class="boot"><span class="loft-loader xl" role="img" aria-label="加载中"><svg viewBox="96 56 320 388" aria-hidden="true"><defs><linearGradient id="boot-g" gradientUnits="userSpaceOnUse" x1="104" y1="104" x2="408" y2="408"><stop offset="0" stop-color="#3b82f6"/><stop offset="1" stop-color="#06b6d4"/></linearGradient></defs><path class="ll-shadow" d="M256 296L404 364L256 432L108 364Z" fill="url(#boot-g)"/><path class="ll-plate" d="M256 252L404 320L256 388L108 320Z" fill="url(#boot-g)"/><path class="ll-star ll-l" d="M152 186C158.46 217.54 158.46 217.54 190 224C158.46 230.46 158.46 230.46 152 262C145.54 230.46 145.54 230.46 114 224C145.54 217.54 145.54 217.54 152 186Z" fill="url(#boot-g)"/><path class="ll-star ll-c" d="M256 82C268.92 145.08 268.92 145.08 332 158C268.92 170.92 268.92 170.92 256 234C243.08 170.92 243.08 170.92 180 158C243.08 145.08 243.08 145.08 256 82Z" fill="url(#boot-g)"/><path class="ll-star ll-r" d="M360 186C366.46 217.54 366.46 217.54 398 224C366.46 230.46 366.46 230.46 360 262C353.54 230.46 353.54 230.46 322 224C353.54 217.54 353.54 217.54 360 186Z" fill="url(#boot-g)"/></svg></span></div></div>
21
21
  <script type="module" src="/app.js"></script>
22
22
  </body>
23
23
  </html>
package/web/style.css CHANGED
@@ -1246,6 +1246,182 @@ a.att-file:hover {
1246
1246
  font-size: 14px;
1247
1247
  }
1248
1248
 
1249
+ /* 回复进行中的实时用时,跟在星星动画后面 */
1250
+ .ai-elapsed {
1251
+ font-size: 13px;
1252
+ color: var(--fg-faint);
1253
+ font-variant-numeric: tabular-nums;
1254
+ }
1255
+
1256
+ /* ---------- logo 加载动画:三颗星依次跳起,台面跟着呼吸 ---------- */
1257
+ .loft-loader {
1258
+ --size: 26px;
1259
+ display: inline-block;
1260
+ flex-shrink: 0;
1261
+ width: var(--size);
1262
+ height: var(--size);
1263
+ line-height: 0;
1264
+ }
1265
+
1266
+ .loft-loader.xl {
1267
+ --size: 72px;
1268
+ }
1269
+
1270
+ .loft-loader svg {
1271
+ display: block;
1272
+ width: 100%;
1273
+ height: 100%;
1274
+ overflow: visible;
1275
+ }
1276
+
1277
+ .loft-loader path {
1278
+ transform-box: fill-box;
1279
+ transform-origin: 50% 50%;
1280
+ animation-duration: 1.6s;
1281
+ animation-iteration-count: infinite;
1282
+ animation-timing-function: cubic-bezier(0.45, 0, 0.25, 1);
1283
+ animation-fill-mode: both;
1284
+ animation-delay: calc(var(--t, 0ms) + var(--d, 0ms));
1285
+ }
1286
+
1287
+ .loft-loader .ll-star {
1288
+ animation-name: loft-star;
1289
+ }
1290
+
1291
+ .loft-loader .ll-l {
1292
+ --d: 0ms;
1293
+ }
1294
+
1295
+ .loft-loader .ll-c {
1296
+ --d: 180ms;
1297
+ }
1298
+
1299
+ .loft-loader .ll-r {
1300
+ --d: 360ms;
1301
+ }
1302
+
1303
+ .loft-loader .ll-plate {
1304
+ animation-name: loft-plate;
1305
+ --d: 260ms;
1306
+ }
1307
+
1308
+ .loft-loader .ll-shadow {
1309
+ animation-name: loft-shadow;
1310
+ --d: 320ms;
1311
+ }
1312
+
1313
+ @keyframes loft-star {
1314
+ 0%,
1315
+ 70%,
1316
+ 100% {
1317
+ transform: translateY(0) scale(0.72);
1318
+ opacity: 0.4;
1319
+ }
1320
+ 28% {
1321
+ transform: translateY(-22px) scale(1.06);
1322
+ opacity: 1;
1323
+ }
1324
+ 48% {
1325
+ transform: translateY(0) scale(0.92);
1326
+ opacity: 0.85;
1327
+ }
1328
+ }
1329
+
1330
+ @keyframes loft-plate {
1331
+ 0%,
1332
+ 100% {
1333
+ opacity: 0.75;
1334
+ }
1335
+ 45% {
1336
+ opacity: 1;
1337
+ }
1338
+ }
1339
+
1340
+ @keyframes loft-shadow {
1341
+ 0%,
1342
+ 100% {
1343
+ transform: translateY(-6px);
1344
+ opacity: 0.32;
1345
+ }
1346
+ 50% {
1347
+ transform: translateY(6px);
1348
+ opacity: 0.12;
1349
+ }
1350
+ }
1351
+
1352
+ @media (prefers-reduced-motion: reduce) {
1353
+ .loft-loader .ll-star {
1354
+ animation-name: loft-star-calm;
1355
+ animation-duration: 2.4s;
1356
+ }
1357
+ .loft-loader .ll-shadow {
1358
+ animation: none;
1359
+ opacity: 0.3;
1360
+ }
1361
+ }
1362
+
1363
+ /* 回复末尾的精简版:只有一颗星,旋转 + 放大缩小 */
1364
+ .loft-stars {
1365
+ display: inline-block;
1366
+ flex-shrink: 0;
1367
+ width: 18px;
1368
+ height: 18px;
1369
+ line-height: 0;
1370
+ }
1371
+
1372
+ /* 打开会话、读历史时用的大一号 */
1373
+ .loft-stars.lg {
1374
+ width: 32px;
1375
+ height: 32px;
1376
+ }
1377
+
1378
+ .loft-stars svg {
1379
+ display: block;
1380
+ width: 100%;
1381
+ height: 100%;
1382
+ overflow: visible;
1383
+ }
1384
+
1385
+ .loft-stars path {
1386
+ transform-box: fill-box;
1387
+ transform-origin: 50% 50%;
1388
+ animation: loft-twirl 1.8s cubic-bezier(0.45, 0, 0.25, 1) infinite both;
1389
+ animation-delay: var(--t, 0ms);
1390
+ }
1391
+
1392
+ /* 四角星转 90° 正好回到原样,所以每轮转 180° 能无缝接上 */
1393
+ @keyframes loft-twirl {
1394
+ 0% {
1395
+ transform: rotate(0deg) scale(0.6);
1396
+ opacity: 0.55;
1397
+ }
1398
+ 45% {
1399
+ transform: rotate(110deg) scale(1.12);
1400
+ opacity: 1;
1401
+ }
1402
+ 100% {
1403
+ transform: rotate(180deg) scale(0.6);
1404
+ opacity: 0.55;
1405
+ }
1406
+ }
1407
+
1408
+ @media (prefers-reduced-motion: reduce) {
1409
+ .loft-stars path {
1410
+ animation-name: loft-star-calm;
1411
+ animation-duration: 2.4s;
1412
+ }
1413
+ }
1414
+
1415
+ @keyframes loft-star-calm {
1416
+ 0%,
1417
+ 100% {
1418
+ opacity: 0.4;
1419
+ }
1420
+ 40% {
1421
+ opacity: 1;
1422
+ }
1423
+ }
1424
+
1249
1425
  .footer-note {
1250
1426
  font-size: 12px;
1251
1427
  color: var(--fg-faint);
@@ -2736,6 +2912,59 @@ button.list-item:hover {
2736
2912
  width: auto;
2737
2913
  }
2738
2914
 
2915
+ .cmd-row .icon-btn svg {
2916
+ width: 16px;
2917
+ height: 16px;
2918
+ }
2919
+
2920
+ .cmd-row .icon-btn.danger:hover {
2921
+ color: var(--danger);
2922
+ }
2923
+
2924
+ .cmd-row .badge.custom {
2925
+ background: color-mix(in srgb, var(--accent) 12%, transparent);
2926
+ color: var(--accent);
2927
+ }
2928
+
2929
+ /* 筛选框 + 「添加命令」按钮一行 */
2930
+ .cmd-tools {
2931
+ flex-wrap: nowrap;
2932
+ }
2933
+
2934
+ .cmd-tools .input {
2935
+ flex: 1;
2936
+ min-width: 0;
2937
+ }
2938
+
2939
+ .cmd-tools .btn {
2940
+ flex-shrink: 0;
2941
+ display: inline-flex;
2942
+ align-items: center;
2943
+ gap: 4px;
2944
+ }
2945
+
2946
+ .cmd-tools .btn svg {
2947
+ width: 16px;
2948
+ height: 16px;
2949
+ }
2950
+
2951
+ /* 添加 / 编辑命令:命令名前面固定一个「/」 */
2952
+ .cmd-name-input {
2953
+ display: flex;
2954
+ align-items: center;
2955
+ gap: 6px;
2956
+ }
2957
+
2958
+ .cmd-name-input > span {
2959
+ color: var(--fg-faint);
2960
+ font-family: var(--mono);
2961
+ }
2962
+
2963
+ .field .cmd-name-input input {
2964
+ flex: 1;
2965
+ font-family: var(--mono);
2966
+ }
2967
+
2739
2968
  .brand-icon {
2740
2969
  display: inline-grid;
2741
2970
  width: 18px;