remote-claude 0.1.7 → 0.2.1

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.
@@ -11,10 +11,27 @@
11
11
 
12
12
  import logging
13
13
  import re as _re
14
+ import pathlib as _pl
15
+ import json as _json
14
16
  from typing import Dict, Any, List, Optional
15
17
 
16
18
  _cb_logger = logging.getLogger('CardBuilder')
17
19
 
20
+ # 版本号:从 package.json 读取,import 时只读一次
21
+ try:
22
+ _pkg = _pl.Path(__file__).parent.parent / "package.json"
23
+ _VERSION = "v" + _json.loads(_pkg.read_text())["version"]
24
+ except Exception:
25
+ _VERSION = ""
26
+
27
+
28
+ def _build_header(title: str, template: str) -> dict:
29
+ """构建卡片 header,自动附加版本号副标题"""
30
+ h: dict = {"title": {"tag": "plain_text", "content": title}, "template": template}
31
+ if _VERSION:
32
+ h["subtitle"] = {"tag": "plain_text", "content": _VERSION}
33
+ return h
34
+
18
35
  # ANSI SGR 前景色码 → 飞书颜色
19
36
  # 飞书支持: blue, wathet, turquoise, green, yellow, orange, red, carmine, violet, purple, indigo, grey
20
37
  _SGR_FG_TO_LARK = {
@@ -261,6 +278,7 @@ def _build_menu_button_row(session_name: Optional[str] = None, disconnected: boo
261
278
  "width": "auto",
262
279
  "elements": [{
263
280
  "tag": "button",
281
+ "name": "enter_submit",
264
282
  "text": {"tag": "plain_text", "content": "Enter ↵"},
265
283
  "type": "primary",
266
284
  "action_type": "form_submit",
@@ -290,6 +308,7 @@ def _build_menu_button_row(session_name: Optional[str] = None, disconnected: boo
290
308
  "width": "auto",
291
309
  "elements": [{
292
310
  "tag": "button",
311
+ "name": "enter_submit",
293
312
  "text": {"tag": "plain_text", "content": "Enter ↵"},
294
313
  "type": "primary",
295
314
  "action_type": "form_submit",
@@ -312,6 +331,7 @@ def _build_menu_button_row(session_name: Optional[str] = None, disconnected: boo
312
331
  ("Ctrl+O", {"action": "send_key", "key": "ctrl_o"}),
313
332
  ("Shift+Tab", {"action": "send_key", "key": "shift_tab"}),
314
333
  ("ESC", {"action": "send_key", "key": "esc"}),
334
+ ("(↹)×3", {"action": "send_key", "key": "shift_tab", "times": 3}),
315
335
  ]
316
336
 
317
337
  def _make_key_column(label, value):
@@ -722,10 +742,7 @@ def build_stream_card(
722
742
  return {
723
743
  "schema": "2.0",
724
744
  "config": {"wide_screen_mode": True, "enable_forward": True},
725
- "header": {
726
- "title": {"tag": "plain_text", "content": title},
727
- "template": template,
728
- },
745
+ "header": _build_header(title, template),
729
746
  "body": {"elements": elements},
730
747
  }
731
748
 
@@ -851,10 +868,7 @@ def build_status_card(connected: bool, session_name: Optional[str] = None) -> Di
851
868
  return {
852
869
  "schema": "2.0",
853
870
  "config": {"wide_screen_mode": True},
854
- "header": {
855
- "title": {"tag": "plain_text", "content": title},
856
- "template": template,
857
- },
871
+ "header": _build_header(title, template),
858
872
  "body": {"elements": [
859
873
  {"tag": "markdown", "content": content},
860
874
  {"tag": "hr"},
@@ -871,7 +885,7 @@ def _dir_session_name(path: str) -> str:
871
885
  return name or "session"
872
886
 
873
887
 
874
- def build_dir_card(target, entries: List[Dict], sessions: List[Dict], tree: bool = False, session_groups: Optional[Dict[str, str]] = None) -> Dict[str, Any]:
888
+ def build_dir_card(target, entries: List[Dict], sessions: List[Dict], tree: bool = False, session_groups: Optional[Dict[str, str]] = None, page: int = 0) -> Dict[str, Any]:
875
889
  """构建目录浏览卡片
876
890
 
877
891
  顶层目录(depth==0)带两个操作按钮:
@@ -906,9 +920,17 @@ def build_dir_card(target, entries: List[Dict], sessions: List[Dict], tree: bool
906
920
  })
907
921
  elements.append({"tag": "hr"})
908
922
 
909
- cap = 20
923
+ PER_PAGE = 12
910
924
  total = len(entries)
911
- shown = entries[:cap]
925
+ if tree:
926
+ # tree 模式不分页,直接展示全部(已有 max_items 上限)
927
+ shown = entries
928
+ page = 0
929
+ total_pages = 1
930
+ else:
931
+ total_pages = max(1, (total + PER_PAGE - 1) // PER_PAGE)
932
+ page = max(0, min(page, total_pages - 1))
933
+ shown = entries[page * PER_PAGE : (page + 1) * PER_PAGE]
912
934
 
913
935
  for entry in shown:
914
936
  name = entry["name"]
@@ -920,6 +942,22 @@ def build_dir_card(target, entries: List[Dict], sessions: List[Dict], tree: bool
920
942
 
921
943
  if is_dir and depth == 0:
922
944
  auto_session = _dir_session_name(full_path)
945
+ group_btn = {
946
+ "tag": "button",
947
+ "text": {"tag": "plain_text", "content": "进入群聊" if (session_groups and auto_session in session_groups) else "创建群聊"},
948
+ "type": "default",
949
+ "behaviors": [{"type": "open_url",
950
+ "default_url": f"https://applink.feishu.cn/client/chat/open?openChatId={session_groups[auto_session]}",
951
+ "android_url": f"https://applink.feishu.cn/client/chat/open?openChatId={session_groups[auto_session]}",
952
+ "ios_url": f"https://applink.feishu.cn/client/chat/open?openChatId={session_groups[auto_session]}",
953
+ "pc_url": f"https://applink.feishu.cn/client/chat/open?openChatId={session_groups[auto_session]}"}]
954
+ if (session_groups and auto_session in session_groups) else
955
+ [{"type": "callback", "value": {
956
+ "action": "dir_new_group",
957
+ "path": full_path,
958
+ "session_name": auto_session
959
+ }}]
960
+ }
923
961
  elements.append({
924
962
  "tag": "column_set",
925
963
  "flex_mode": "none",
@@ -927,65 +965,77 @@ def build_dir_card(target, entries: List[Dict], sessions: List[Dict], tree: bool
927
965
  {
928
966
  "tag": "column",
929
967
  "width": "weighted",
930
- "weight": 3,
931
- "elements": [{"tag": "markdown", "content": f"{icon} **{name}**"}]
932
- },
933
- {
934
- "tag": "column",
935
- "width": "weighted",
936
- "weight": 2,
968
+ "weight": 4,
937
969
  "elements": [{
938
- "tag": "button",
939
- "text": {"tag": "plain_text", "content": "📂 进入"},
940
- "type": "default",
970
+ "tag": "interactive_container",
971
+ "width": "fill",
972
+ "height": "auto",
941
973
  "behaviors": [{"type": "callback", "value": {
942
974
  "action": "dir_browse", "path": full_path
943
- }}]
975
+ }}],
976
+ "elements": [{"tag": "markdown", "content": f"📁 **{name}**"}]
944
977
  }]
945
978
  },
946
979
  {
947
980
  "tag": "column",
948
981
  "width": "weighted",
949
982
  "weight": 2,
950
- "elements": [{
951
- "tag": "button",
952
- "text": {"tag": "plain_text", "content": "🚀 在此启动"},
953
- "type": "primary",
954
- "behaviors": [{"type": "callback", "value": {
955
- "action": "dir_start",
956
- "path": full_path,
957
- "session_name": auto_session
958
- }}]
959
- }]
960
- },
961
- {
962
- "tag": "column",
963
- "width": "weighted",
964
- "weight": 2,
965
- "elements": [{
966
- "tag": "button",
967
- "text": {"tag": "plain_text", "content": "进入群聊" if (session_groups and auto_session in session_groups) else "创建群聊"},
968
- "type": "default",
969
- "behaviors": [{"type": "open_url",
970
- "default_url": f"https://applink.feishu.cn/client/chat/open?openChatId={session_groups[auto_session]}",
971
- "android_url": f"https://applink.feishu.cn/client/chat/open?openChatId={session_groups[auto_session]}",
972
- "ios_url": f"https://applink.feishu.cn/client/chat/open?openChatId={session_groups[auto_session]}",
973
- "pc_url": f"https://applink.feishu.cn/client/chat/open?openChatId={session_groups[auto_session]}"}]
974
- if (session_groups and auto_session in session_groups) else
975
- [{"type": "callback", "value": {
976
- "action": "dir_new_group",
977
- "path": full_path,
978
- "session_name": auto_session
979
- }}]
980
- }]
983
+ "elements": [
984
+ {
985
+ "tag": "button",
986
+ "text": {"tag": "plain_text", "content": "Claude"},
987
+ "type": "primary",
988
+ "behaviors": [{"type": "callback", "value": {
989
+ "action": "dir_start",
990
+ "path": full_path,
991
+ "session_name": auto_session
992
+ }}]
993
+ },
994
+ group_btn
995
+ ]
981
996
  }
982
997
  ]
983
998
  })
999
+ elements.append({"tag": "hr"})
984
1000
  else:
985
1001
  elements.append({"tag": "markdown", "content": f"{indent}{icon} {name}"})
986
1002
 
987
- if total > cap:
988
- elements.append({"tag": "markdown", "content": f"*...(共 {total} 项,仅显示前 {cap} 项)*"})
1003
+ if not tree and total > PER_PAGE:
1004
+ page_cols = []
1005
+ if page > 0:
1006
+ page_cols.append({
1007
+ "tag": "column",
1008
+ "width": "auto",
1009
+ "elements": [{
1010
+ "tag": "button",
1011
+ "text": {"tag": "plain_text", "content": "⬅️ 上一页"},
1012
+ "type": "default",
1013
+ "behaviors": [{"type": "callback", "value": {
1014
+ "action": "dir_page", "path": target_str, "page": page - 1
1015
+ }}]
1016
+ }]
1017
+ })
1018
+ page_cols.append({
1019
+ "tag": "column",
1020
+ "width": "weighted",
1021
+ "weight": 2,
1022
+ "vertical_align": "center",
1023
+ "elements": [{"tag": "markdown", "content": f"第 {page + 1}/{total_pages} 页"}]
1024
+ })
1025
+ if page < total_pages - 1:
1026
+ page_cols.append({
1027
+ "tag": "column",
1028
+ "width": "auto",
1029
+ "elements": [{
1030
+ "tag": "button",
1031
+ "text": {"tag": "plain_text", "content": "下一页 ➡️"},
1032
+ "type": "default",
1033
+ "behaviors": [{"type": "callback", "value": {
1034
+ "action": "dir_page", "path": target_str, "page": page + 1
1035
+ }}]
1036
+ }]
1037
+ })
1038
+ elements.append({"tag": "column_set", "flex_mode": "none", "columns": page_cols})
989
1039
 
990
1040
  elements.append({"tag": "hr"})
991
1041
  elements.append(_build_menu_button_only())
@@ -993,7 +1043,7 @@ def build_dir_card(target, entries: List[Dict], sessions: List[Dict], tree: bool
993
1043
  return {
994
1044
  "schema": "2.0",
995
1045
  "config": {"wide_screen_mode": True},
996
- "header": {"title": {"tag": "plain_text", "content": title}, "template": "blue"},
1046
+ "header": _build_header(title, "blue"),
997
1047
  "body": {"elements": elements}
998
1048
  }
999
1049
 
@@ -1025,10 +1075,7 @@ def build_help_card() -> Dict[str, Any]:
1025
1075
  return {
1026
1076
  "schema": "2.0",
1027
1077
  "config": {"wide_screen_mode": True},
1028
- "header": {
1029
- "title": {"tag": "plain_text", "content": "📖 Remote Claude 帮助"},
1030
- "template": "blue",
1031
- },
1078
+ "header": _build_header("📖 Remote Claude 帮助", "blue"),
1032
1079
  "body": {"elements": [
1033
1080
  {"tag": "markdown", "content": help_content},
1034
1081
  {"tag": "hr"},
@@ -1042,10 +1089,7 @@ def build_session_closed_card(session_name: str) -> Dict[str, Any]:
1042
1089
  return {
1043
1090
  "schema": "2.0",
1044
1091
  "config": {"wide_screen_mode": True},
1045
- "header": {
1046
- "title": {"tag": "plain_text", "content": "🔴 会话已关闭"},
1047
- "template": "red",
1048
- },
1092
+ "header": _build_header("🔴 会话已关闭", "red"),
1049
1093
  "body": {"elements": [
1050
1094
  {"tag": "markdown", "content": f"会话 **{session_name}** 已关闭,连接已自动断开。\n\n如需继续,请重新启动会话或连接到其他会话。"},
1051
1095
  {"tag": "hr"},
@@ -1093,17 +1137,6 @@ def build_menu_card(sessions: List[Dict], current_session: Optional[str] = None,
1093
1137
  "tag": "column_set",
1094
1138
  "flex_mode": "none",
1095
1139
  "columns": [
1096
- {
1097
- "tag": "column",
1098
- "width": "weighted",
1099
- "weight": 1,
1100
- "elements": [{
1101
- "tag": "button",
1102
- "text": {"tag": "plain_text", "content": "📖 帮助"},
1103
- "type": "default",
1104
- "behaviors": [{"type": "callback", "value": {"action": "menu_help"}}]
1105
- }]
1106
- },
1107
1140
  {
1108
1141
  "tag": "column",
1109
1142
  "width": "weighted",
@@ -1132,9 +1165,6 @@ def build_menu_card(sessions: List[Dict], current_session: Optional[str] = None,
1132
1165
  return {
1133
1166
  "schema": "2.0",
1134
1167
  "config": {"wide_screen_mode": True},
1135
- "header": {
1136
- "title": {"tag": "plain_text", "content": "⚡ 快捷操作"},
1137
- "template": "turquoise",
1138
- },
1168
+ "header": _build_header("⚡ 快捷操作", "turquoise"),
1139
1169
  "body": {"elements": elements}
1140
1170
  }
@@ -22,6 +22,23 @@ from lark_oapi.api.cardkit.v1 import (
22
22
 
23
23
  from . import config
24
24
 
25
+
26
+ def _is_element_limit_error(msg: str) -> bool:
27
+ """判断飞书 API 返回的错误是否为元素超限"""
28
+ if not msg:
29
+ return False
30
+ lower = msg.lower()
31
+ return "element exceeds" in lower or "超限" in lower
32
+
33
+
34
+ class _ElementLimitResult:
35
+ """元素超限的哨兵返回值,__bool__ 为 False 兼容现有 if not success 逻辑"""
36
+ is_element_limit = True
37
+
38
+ def __bool__(self):
39
+ return False
40
+
41
+
25
42
  import sys as _sys
26
43
  _sys.path.insert(0, str(__import__('pathlib').Path(__file__).parent.parent))
27
44
  try:
@@ -193,6 +210,10 @@ class CardService:
193
210
  return True
194
211
  else:
195
212
  logger.warning(f"更新卡片失败(attempt={attempt+1}): card_id={card_id} seq={sequence} code={response.code} msg={response.msg}")
213
+ if _is_element_limit_error(response.msg):
214
+ # 元素超限是内容问题,重试无意义,直接返回哨兵值
215
+ logger.warning(f"检测到元素超限错误,跳过重试: card_id={card_id}")
216
+ return _ElementLimitResult()
196
217
  except Exception as e:
197
218
  logger.error(f"更新卡片异常(attempt={attempt+1}): card_id={card_id} seq={sequence} error={e}")
198
219
 
@@ -531,7 +531,7 @@ class LarkHandler:
531
531
  await self._send_or_update_card(chat_id, card, message_id)
532
532
 
533
533
  async def _cmd_ls(self, user_id: str, chat_id: str, args: str,
534
- tree: bool = False, message_id: Optional[str] = None):
534
+ tree: bool = False, message_id: Optional[str] = None, page: int = 0):
535
535
  """查看目录文件结构"""
536
536
  all_sessions = list_active_sessions()
537
537
  sessions_info = []
@@ -571,7 +571,7 @@ class LarkHandler:
571
571
  return
572
572
 
573
573
  session_groups = {sname: cid for cid, sname in self._chat_bindings.items() if cid.startswith("oc_")}
574
- card = build_dir_card(target, entries, sessions_info, tree=tree, session_groups=session_groups)
574
+ card = build_dir_card(target, entries, sessions_info, tree=tree, session_groups=session_groups, page=page)
575
575
  await self._send_or_update_card(chat_id, card, message_id)
576
576
 
577
577
  async def _cmd_new_group(self, user_id: str, chat_id: str, args: str,
@@ -854,7 +854,7 @@ class LarkHandler:
854
854
  })
855
855
  except PermissionError:
856
856
  pass
857
- return entries[:30]
857
+ return entries
858
858
 
859
859
  @staticmethod
860
860
  def _collect_tree_entries(target, max_depth: int = 2, max_items: int = 60) -> list:
@@ -176,6 +176,14 @@ def handle_card_action(event: P2CardActionTrigger) -> P2CardActionTriggerRespons
176
176
  asyncio.create_task(handler._cmd_ls(user_id, chat_id, path, message_id=message_id))
177
177
  return None
178
178
 
179
+ # 目录卡片:翻页
180
+ if action_type == "dir_page":
181
+ path = action_value.get("path", "")
182
+ page = int(action_value.get("page", 0))
183
+ print(f"[Lark] dir_page: path={path}, page={page}")
184
+ asyncio.create_task(handler._cmd_ls(user_id, chat_id, path, message_id=message_id, page=page))
185
+ return None
186
+
179
187
  # 目录卡片:在该目录创建新 Claude 会话
180
188
  if action_type == "dir_start":
181
189
  path = action_value.get("path", "")
@@ -230,8 +238,13 @@ def handle_card_action(event: P2CardActionTrigger) -> P2CardActionTriggerRespons
230
238
  # 快捷键按钮(callback 模式)
231
239
  if action_type == "send_key":
232
240
  key_name = action_value.get("key", "")
233
- print(f"[Lark] send_key: key={key_name}")
234
- asyncio.create_task(handler.send_raw_key(user_id, chat_id, key_name))
241
+ times = action_value.get("times", 1)
242
+ print(f"[Lark] send_key: key={key_name}" + (f" ×{times}" if times > 1 else ""))
243
+ async def _multi_send(k=key_name, t=times):
244
+ for _ in range(t):
245
+ await handler.send_raw_key(user_id, chat_id, k)
246
+ await asyncio.sleep(0.15)
247
+ asyncio.create_task(_multi_send())
235
248
  return None
236
249
 
237
250
  # 各卡片底部菜单按钮:辅助卡片就地→菜单,流式卡片降级新卡
@@ -247,7 +247,13 @@ class SharedMemoryPoller:
247
247
  card_content=card_dict,
248
248
  )
249
249
 
250
- if not success:
250
+ if getattr(success, 'is_element_limit', False):
251
+ # 元素超限:冻结旧卡 + 推新流式卡
252
+ await self._handle_element_limit(
253
+ tracker, blocks, status_line, bottom_bar, agent_panel, option_block
254
+ )
255
+ return
256
+ elif not success:
251
257
  # 降级:创建新卡片替代
252
258
  logger.warning(
253
259
  f"update_card 失败 card_id={active.card_id} seq={active.sequence},降级为新卡片"
@@ -305,6 +311,48 @@ class SharedMemoryPoller:
305
311
  else:
306
312
  logger.warning(f"create_card 失败 session={tracker.session_name}")
307
313
 
314
+ async def _handle_element_limit(
315
+ self, tracker: StreamTracker, blocks: List[dict],
316
+ status_line: Optional[dict], bottom_bar: Optional[dict],
317
+ agent_panel: Optional[dict] = None,
318
+ option_block: Optional[dict] = None,
319
+ ) -> None:
320
+ """元素超限:冻结旧卡片 + 推送新流式卡片"""
321
+ active = tracker.cards[-1]
322
+ logger.warning(f"元素超限,冻结卡片 {active.card_id} 并推新卡")
323
+
324
+ # 1. 冻结旧卡片(灰色 header,无状态区和按钮)
325
+ from .card_builder import build_stream_card
326
+ blocks_slice = blocks[active.start_idx:]
327
+ frozen_card = build_stream_card(blocks_slice, None, None, is_frozen=True)
328
+ active.sequence += 1
329
+ await self._card_service.update_card(active.card_id, active.sequence, frozen_card)
330
+ active.frozen = True
331
+ _track_stats('card', 'freeze', session_name=tracker.session_name,
332
+ chat_id=tracker.chat_id)
333
+
334
+ # 2. 创建新流式卡片,从最近 INITIAL_WINDOW 个 blocks 开始(重置窗口)
335
+ new_start = max(0, len(blocks) - INITIAL_WINDOW)
336
+ new_blocks = blocks[new_start:]
337
+ if not new_blocks and not status_line and not bottom_bar:
338
+ return
339
+ new_card_dict = build_stream_card(
340
+ new_blocks, status_line, bottom_bar,
341
+ agent_panel=agent_panel, option_block=option_block,
342
+ session_name=tracker.session_name,
343
+ )
344
+ new_card_id = await self._card_service.create_card(new_card_dict)
345
+ if new_card_id:
346
+ await self._card_service.send_card(tracker.chat_id, new_card_id)
347
+ tracker.cards.append(CardSlice(card_id=new_card_id, start_idx=new_start))
348
+ tracker.content_hash = self._compute_hash(new_blocks, status_line, bottom_bar, agent_panel, option_block)
349
+ _track_stats('card', 'create', session_name=tracker.session_name,
350
+ chat_id=tracker.chat_id)
351
+ logger.info(
352
+ f"[ELEMENT_LIMIT_SPLIT] session={tracker.session_name} "
353
+ f"new_start={new_start} blocks={len(new_blocks)} card_id={new_card_id}"
354
+ )
355
+
308
356
  async def _freeze_and_split(
309
357
  self, tracker: StreamTracker, blocks: List[dict],
310
358
  status_line: Optional[dict], bottom_bar: Optional[dict],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "remote-claude",
3
- "version": "0.1.7",
3
+ "version": "0.2.1",
4
4
  "description": "双端共享 Claude CLI 工具",
5
5
  "bin": {
6
6
  "remote-claude": "bin/remote-claude",
@@ -26,13 +26,22 @@
26
26
  "uv.lock",
27
27
  ".env.example"
28
28
  ],
29
- "os": ["darwin", "linux"],
29
+ "os": [
30
+ "darwin",
31
+ "linux"
32
+ ],
30
33
  "license": "MIT",
31
34
  "repository": {
32
35
  "type": "git",
33
36
  "url": "git+https://github.com/yuyangzi/remote_claude.git"
34
37
  },
35
- "keywords": ["claude", "cli", "terminal", "pty", "lark"],
38
+ "keywords": [
39
+ "claude",
40
+ "cli",
41
+ "terminal",
42
+ "pty",
43
+ "lark"
44
+ ],
36
45
  "engines": {
37
46
  "node": ">=16"
38
47
  },