agent-loft 0.2.39 → 0.2.40

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
@@ -102,6 +102,7 @@ agent-loft service install # 注册为开机自启服务(systemd / laun
102
102
  - 菜单栏图标可以换成额度条:Claude、Codex 各是一只像素小宠物,看样子就知道是谁,身子是 5 小时额度,下面一条是周额度。桌面端设置 → 额度显示 →「菜单栏显示额度」,选所有服务器合起来(取最高),或者按服务器分别选 Claude / Codex(图标上不写服务器名);都不选就只显示图标。「额度条显示」选实心表示剩余额度(默认,像电池)还是已用额度。只有 macOS 有,Windows / Linux 的托盘图标太小放不下。
103
103
  - 支持前面挡着 Cloudflare Access 的服务器:验证页直接在窗口里完成,过期会提醒;也可以配 Service Token 免验证。
104
104
  - 首次打开可以选「这台 Mac 也作为服务端」:一键在终端里安装 agent-loft(没有 Node.js 会先用 Homebrew 装)并开机自启,装好自动连上。Linux 上一样(需要先装好 Node.js 22+,开机自启用 systemd 用户服务);Windows 上服务端还没有开机自启,可以在 WSL 里装,桌面端会自动检测到 `127.0.0.1` 上的它。防休眠:本机有会话在跑时不休眠;另有永不休眠、定时休眠(设休眠时段,时段外不休眠,时段内本机没有会话在跑、屏幕锁上后让 Mac 睡下去)和合盖不休眠(macOS,要管理员密码)。
105
+ - 顶栏「更多」→「总量汇总」:把所有已添加服务器的用量加在一起看,按日 / 月 / 年统计 token 和金额,和网页设置 → 用量一样有柱状图和按 agent、模型、用户的分项,另外多一栏按服务器。不在线、没登录或服务端太旧的服务器不计入,页面上会注明。
105
106
  - 设置(`⌘,`)的服务器列表里展开某台服务器,能看它的 Agent 环境和版本,有新版本时点「升级服务端」;桌面端自己的更新在同一页的「更新」里点「更新并重启」。
106
107
 
107
108
  桌面端只是个壳(代码在 `desktop/`),服务端照常用 npm 安装和升级。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-loft",
3
- "version": "0.2.39",
3
+ "version": "0.2.40",
4
4
  "type": "module",
5
5
  "description": "多个 agent 的工作台:把本机 Claude Code / Codex 放到一处统一管理,用飞书和 Web 页面随时驱动——会话、审批、模型切换都共用",
6
6
  "keywords": [
@@ -49,35 +49,6 @@
49
49
  display: none;
50
50
  }
51
51
 
52
- /* 「/」「@」补全:键盘弹出后屏幕只剩一半,菜单改成一行一条、最多露出五条多一点,
53
- 露出半条好让人知道还能往下滑 */
54
- .slash-menu {
55
- bottom: calc(100% + 4px);
56
- max-height: min(196px, 30vh);
57
- padding: 4px;
58
- }
59
-
60
- .slash-item {
61
- display: flex;
62
- align-items: baseline;
63
- gap: 8px;
64
- padding: 7px 8px;
65
- }
66
-
67
- .slash-head {
68
- flex: none;
69
- max-width: 60%;
70
- }
71
-
72
- .slash-args {
73
- display: none;
74
- }
75
-
76
- .slash-desc {
77
- flex: 1;
78
- min-width: 0;
79
- }
80
-
81
52
  .ctx-bar {
82
53
  margin: 0 10px -22px;
83
54
  font-size: 13px;
@@ -334,6 +334,7 @@
334
334
  .remote-body.desk-full {
335
335
  position: fixed;
336
336
  inset: 0;
337
+ overflow: hidden;
337
338
  z-index: 35;
338
339
  background: #111;
339
340
  padding: env(safe-area-inset-top) 0 var(--kb-inset, 0px);
@@ -445,11 +446,12 @@
445
446
  resize: vertical;
446
447
  }
447
448
 
448
- /* 手机上收软键盘输入用:看不见,但要能拿到焦点 */
449
+ /* 手机上收软键盘输入用:看不见,但要能拿到焦点。
450
+ 放在顶上:放在底下会被键盘盖住,iOS 为了露出它把整页往上推(横屏时画面整个被推出屏幕) */
449
451
  .desk-keyinput {
450
452
  position: absolute;
451
453
  left: 0;
452
- bottom: 0;
454
+ top: 0;
453
455
  width: 1px;
454
456
  height: 1px;
455
457
  opacity: 0;
@@ -14,6 +14,23 @@ import { openSettings } from "./layout.js";
14
14
 
15
15
  // ---------- 输入框 ----------
16
16
 
17
+ /**
18
+ * 手机上「/」「@」菜单刚弹出来时收起键盘:不然键盘加菜单把屏幕占满了。
19
+ * 菜单留着,点一条就选;想接着打字筛选就点一下输入框,点别处菜单关掉
20
+ */
21
+ let keepMenus = false;
22
+ export function hideKeyboardForMenu() {
23
+ if (!isTouch || document.activeElement !== ui.input) return;
24
+ keepMenus = true;
25
+ ui.input.blur();
26
+ }
27
+ document.addEventListener("pointerdown", (e) => {
28
+ if (!keepMenus || [ui.input, ui.slashMenu, ui.mentionMenu].some((el) => el?.contains(e.target))) return;
29
+ keepMenus = false;
30
+ closeSlashMenu();
31
+ ui.mention?.close();
32
+ });
33
+
17
34
  export function renderComposer() {
18
35
  cancelVoice();
19
36
  ui.input = h("textarea", {
@@ -26,8 +43,11 @@ export function renderComposer() {
26
43
  void updateSlashMenu();
27
44
  void ui.mention.update();
28
45
  },
46
+ onfocus: () => (keepMenus = false),
29
47
  onblur: () =>
30
48
  setTimeout(() => {
49
+ // 为了让出地方收起的键盘(见 hideKeyboardForMenu):菜单留着;点回输入框也不关
50
+ if (keepMenus || document.activeElement === ui.input) return;
31
51
  closeSlashMenu();
32
52
  ui.mention.close();
33
53
  }, 150),
@@ -72,6 +92,7 @@ export function renderComposer() {
72
92
  ui.mentionMenu = h("div", { class: "slash-menu", hidden: true });
73
93
  ui.mention = attachMention(ui.input, ui.mentionMenu, {
74
94
  onEmpty: () => openSettings("技能"),
95
+ onShown: hideKeyboardForMenu,
75
96
  });
76
97
  ui.composer = h(
77
98
  "div",
@@ -40,14 +40,21 @@ function match(list, q) {
40
40
 
41
41
  /**
42
42
  * 给一个输入框挂上 @ 补全。menu 是放菜单的元素(调用方决定放哪、怎么定位,样式用 .slash-menu)。
43
- * onEmpty:一个技能都没有时,菜单里给个入口(去设置里的技能页)。返回 { update, keydown, close }:
43
+ * onEmpty:一个技能都没有时,菜单里给个入口(去设置里的技能页)。onShown:菜单关着时弹出来、有了内容那一下。返回 { update, keydown, close }:
44
44
  * 输入框的 input / keydown / blur 里分别调用。
45
45
  */
46
- export function attachMention(input, menu, { onEmpty, onPicked } = {}) {
47
- const st = { open: false, items: [], index: 0, token: 0, at: null };
46
+ export function attachMention(input, menu, { onEmpty, onPicked, onShown } = {}) {
47
+ const st = { open: false, shown: false, items: [], index: 0, token: 0, at: null };
48
+
49
+ const shown = () => {
50
+ if (st.shown) return;
51
+ st.shown = true;
52
+ onShown?.();
53
+ };
48
54
 
49
55
  const close = () => {
50
56
  st.open = false;
57
+ st.shown = false;
51
58
  st.items = [];
52
59
  st.index = 0;
53
60
  st.token++;
@@ -149,6 +156,7 @@ export function attachMention(input, menu, { onEmpty, onPicked } = {}) {
149
156
  h("div", { class: "slash-desc" }, "去「设置 → 技能」新建、上传"),
150
157
  ),
151
158
  );
159
+ shown();
152
160
  return;
153
161
  }
154
162
  const items = match(list, now.query);
@@ -156,6 +164,7 @@ export function attachMention(input, menu, { onEmpty, onPicked } = {}) {
156
164
  st.items = items;
157
165
  st.index = Math.min(st.index, items.length - 1);
158
166
  render(items);
167
+ shown();
159
168
  };
160
169
 
161
170
  /** 菜单开着时接管方向键 / Tab / 回车 / Esc;返回 true 表示已处理 */
@@ -110,9 +110,19 @@ export function leaveRemote() {
110
110
  // 只在触屏设备上算:电脑上双指缩放网页、出了滚动条时 visualViewport 也会变小,不是键盘
111
111
  const vv = window.visualViewport;
112
112
  if (vv && isTouch) {
113
+ let last = 0;
113
114
  const inset = () => {
114
115
  const covered = Math.max(0, Math.round(window.innerHeight - vv.height - vv.offsetTop));
115
116
  document.documentElement.style.setProperty("--kb-inset", `${covered}px`);
117
+ // 键盘弹出时 iOS 会把页面(和溢出隐藏的容器)往上推,收起后不一定推回来:
118
+ // 横屏全屏时整页只剩顶上露一截按键。键盘收起时统统滚回原位
119
+ if (last > 0 && covered === 0) {
120
+ window.scrollTo(0, 0);
121
+ for (const el of [document.scrollingElement, ...document.querySelectorAll("#app, .layout, .main, .remote-pane, .remote-body")]) {
122
+ if (el && el.scrollTop) el.scrollTop = 0;
123
+ }
124
+ }
125
+ last = covered;
116
126
  };
117
127
  vv.addEventListener("resize", inset);
118
128
  vv.addEventListener("scroll", inset);
@@ -3,6 +3,7 @@ import { api } from "../lib/api.js";
3
3
  import { desktop, remoteTabs, S, ui } from "../state.js";
4
4
  import { baseName } from "./topbar.js";
5
5
  import { newDraft, openConversation } from "./messages.js";
6
+ import { leaveRemote } from "./remote.js";
6
7
 
7
8
  // ---------- 侧栏标题跑马灯:名字被省略时,hover 来回滚动显示全名 ----------
8
9
 
@@ -420,13 +421,15 @@ export function renderSidebar() {
420
421
  const open = (key) => {
421
422
  closeSide();
422
423
  if (key !== S.current) void openConversation(key);
424
+ // 在远程控制页上点进来之前那条对话:对话没动过,退出远程控制就露出来了
425
+ else if (S.view === "remote") leaveRemote();
423
426
  };
424
427
  // 一行会话。orphan:是分叉出来的、但父对话不在这一组里(删了 / 在别的目录),没有连线可画,用图标标一下
425
428
  const convRow = (c, cls, detail, orphan) =>
426
429
  h(
427
430
  "button",
428
431
  {
429
- class: `conv-item${cls}${c.key === S.current ? " active" : ""}`,
432
+ class: `conv-item${cls}${c.key === S.current && !S.view ? " active" : ""}`,
430
433
  "data-key": c.key,
431
434
  title: `${convTitle(c)}${forkNote(c)}\n${detail}\n双击重命名,右键更多操作`,
432
435
  onclick: () => open(c.key),
@@ -455,7 +458,7 @@ export function renderSidebar() {
455
458
  el: h(
456
459
  "button",
457
460
  {
458
- class: `conv-item${cur?.channel === "feishu" && cur.channelId === bot.id ? " active" : ""}`,
461
+ class: `conv-item${!S.view && cur?.channel === "feishu" && cur.channelId === bot.id ? " active" : ""}`,
459
462
  "data-bot": bot.id,
460
463
  title: latest ? `飞书 · ${bot.name}\n${agentLabel(latest.agent)} · ${latest.cwd}` : `飞书 · ${bot.name}\n还没有会话`,
461
464
  onclick: () => (latest ? open(latest.key) : toast("这个机器人还没有会话,先在飞书里给它发条消息")),
@@ -3,14 +3,14 @@ import { api } from "../lib/api.js";
3
3
  import { S, ui } from "../state.js";
4
4
  import { agentLabel } from "./sidebar.js";
5
5
  import { usableAgents } from "./topbar.js";
6
- import { autosize, sendMessage, updateSendBtn } from "./composer.js";
6
+ import { autosize, hideKeyboardForMenu, sendMessage, updateSendBtn } from "./composer.js";
7
7
 
8
8
  // ---------- 斜杠命令补全 ----------
9
9
  //
10
10
  // 输入框里只有「/xxx」(还没打空格)时弹出命令表,顺序就是设置 → 命令里排好的。
11
11
  // 列表按 agent + 会话目录缓存一分钟:Claude 的原生命令要起一次进程才问得到。
12
12
 
13
- export const slash = { open: false, items: [], index: 0, cache: new Map(), token: 0 };
13
+ export const slash = { open: false, shown: false, items: [], index: 0, cache: new Map(), token: 0 };
14
14
 
15
15
  export function commandUsage(c) {
16
16
  return `/${c.name}${c.args ? ` ${c.args}` : ""}`;
@@ -75,6 +75,10 @@ export async function updateSlashMenu() {
75
75
  slash.items = items;
76
76
  slash.index = Math.min(slash.index, items.length - 1);
77
77
  renderSlashMenu(items);
78
+ if (!slash.shown) {
79
+ slash.shown = true;
80
+ hideKeyboardForMenu();
81
+ }
78
82
  }
79
83
 
80
84
  function renderSlashMenu(items, error) {
@@ -126,6 +130,7 @@ function highlightSlash() {
126
130
 
127
131
  export function closeSlashMenu() {
128
132
  slash.open = false;
133
+ slash.shown = false;
129
134
  slash.items = [];
130
135
  slash.index = 0;
131
136
  slash.token++;