remote-claude 1.0.1 → 1.0.3

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/.env.example CHANGED
@@ -30,3 +30,7 @@ ALLOWED_USERS=ou_xxxxx,ou_yyyyy
30
30
  # 支持: DEBUG / INFO / WARNING / ERROR
31
31
  # SERVER_LOG_LEVEL=INFO
32
32
 
33
+ # SOCKS 代理兼容(可选)
34
+ # 系统有 SOCKS 代理但飞书可直连时,设为 1 绕过代理
35
+ # LARK_NO_PROXY=1
36
+
@@ -427,6 +427,7 @@ def _build_buttons_v2(options: List[Dict[str, str]]) -> List[Dict[str, Any]]:
427
427
  "value": {
428
428
  "action": "select_option",
429
429
  "value": opt["value"],
430
+ "needs_input": opt.get("needs_input", False),
430
431
  "total": str(total),
431
432
  }
432
433
  }
@@ -1022,16 +1023,22 @@ def build_dir_card(target, entries: List[Dict], sessions: List[Dict], tree: bool
1022
1023
 
1023
1024
  if is_dir and depth == 0:
1024
1025
  auto_session = _dir_session_name(full_path)
1026
+ # 前缀匹配:找 session_groups 中精确匹配或以 auto_session + "_" 开头的条目(取最后一个,即最新的)
1027
+ matched_group_cid = None
1028
+ if session_groups:
1029
+ for sn, cid in session_groups.items():
1030
+ if sn == auto_session or sn.startswith(auto_session + "_"):
1031
+ matched_group_cid = cid
1025
1032
  group_btn = {
1026
1033
  "tag": "button",
1027
- "text": {"tag": "plain_text", "content": "进入群聊" if (session_groups and auto_session in session_groups) else "创建群聊"},
1034
+ "text": {"tag": "plain_text", "content": "进入群聊" if matched_group_cid else "创建群聊"},
1028
1035
  "type": "default",
1029
1036
  "behaviors": [{"type": "open_url",
1030
- "default_url": f"https://applink.feishu.cn/client/chat/open?openChatId={session_groups[auto_session]}",
1031
- "android_url": f"https://applink.feishu.cn/client/chat/open?openChatId={session_groups[auto_session]}",
1032
- "ios_url": f"https://applink.feishu.cn/client/chat/open?openChatId={session_groups[auto_session]}",
1033
- "pc_url": f"https://applink.feishu.cn/client/chat/open?openChatId={session_groups[auto_session]}"}]
1034
- if (session_groups and auto_session in session_groups) else
1037
+ "default_url": f"https://applink.feishu.cn/client/chat/open?openChatId={matched_group_cid}",
1038
+ "android_url": f"https://applink.feishu.cn/client/chat/open?openChatId={matched_group_cid}",
1039
+ "ios_url": f"https://applink.feishu.cn/client/chat/open?openChatId={matched_group_cid}",
1040
+ "pc_url": f"https://applink.feishu.cn/client/chat/open?openChatId={matched_group_cid}"}]
1041
+ if matched_group_cid else
1035
1042
  [{"type": "callback", "value": {
1036
1043
  "action": "dir_new_group",
1037
1044
  "path": full_path,
@@ -1202,7 +1209,8 @@ def build_session_closed_card(session_name: str) -> Dict[str, Any]:
1202
1209
 
1203
1210
  def build_menu_card(sessions: List[Dict], current_session: Optional[str] = None,
1204
1211
  session_groups: Optional[Dict[str, str]] = None, page: int = 0,
1205
- notify_enabled: bool = True, urgent_enabled: bool = False) -> Dict[str, Any]:
1212
+ notify_enabled: bool = True, urgent_enabled: bool = False,
1213
+ bypass_enabled: bool = False) -> Dict[str, Any]:
1206
1214
  """构建快捷操作菜单卡片(/menu 和 /list 共用):内嵌会话列表 + 快捷操作"""
1207
1215
  elements = []
1208
1216
 
@@ -1301,6 +1309,14 @@ def build_menu_card(sessions: List[Dict], current_session: Optional[str] = None,
1301
1309
  ]
1302
1310
  })
1303
1311
 
1312
+ bypass_label = "🔓 新会话bypass: 开" if bypass_enabled else "🔒 新会话bypass: 关"
1313
+ elements.append({
1314
+ "tag": "button",
1315
+ "text": {"tag": "plain_text", "content": bypass_label},
1316
+ "type": "default",
1317
+ "behaviors": [{"type": "callback", "value": {"action": "menu_toggle_bypass"}}]
1318
+ })
1319
+
1304
1320
  return {
1305
1321
  "schema": "2.0",
1306
1322
  "config": {"wide_screen_mode": True},
@@ -48,3 +48,7 @@ LARK_LOG_LEVEL = {
48
48
  "WARNING": 30,
49
49
  "ERROR": 40,
50
50
  }.get(_LARK_LOG_LEVEL, 20) # 默认 INFO
51
+
52
+ # SOCKS 代理兼容(可选,默认 False)
53
+ # 系统有 SOCKS 代理但飞书可直连时,设为 1 绕过代理
54
+ LARK_NO_PROXY = os.getenv("LARK_NO_PROXY", "").strip() in ("1", "true", "yes")
@@ -11,8 +11,11 @@
11
11
  import asyncio
12
12
  import json
13
13
  import logging
14
+ import os as _os
14
15
  import subprocess
15
16
  import sys
17
+ import time
18
+ from datetime import datetime as _datetime
16
19
  from pathlib import Path
17
20
  from typing import Optional, Dict, Any, List
18
21
 
@@ -30,7 +33,23 @@ from .card_builder import (
30
33
  from .shared_memory_poller import SharedMemoryPoller, CardSlice
31
34
 
32
35
  sys.path.insert(0, str(Path(__file__).parent.parent))
33
- from utils.session import list_active_sessions, get_socket_path, get_chat_bindings_file, ensure_user_data_dir
36
+ from utils.session import list_active_sessions, get_socket_path, get_chat_bindings_file, ensure_user_data_dir, USER_DATA_DIR
37
+
38
+
39
+ def _read_log_since(since: '_datetime', log_path: 'Path') -> str:
40
+ """读取 startup.log 中 since 时间点之后的日志行"""
41
+ if not log_path.exists():
42
+ return ""
43
+ lines = []
44
+ for line in log_path.read_text(encoding="utf-8", errors="replace").splitlines():
45
+ try:
46
+ ts = _datetime.strptime(line[:23], "%Y-%m-%d %H:%M:%S.%f")
47
+ if ts >= since:
48
+ lines.append(line)
49
+ except ValueError:
50
+ if lines:
51
+ lines.append(line)
52
+ return "\n".join(lines)
34
53
 
35
54
  try:
36
55
  from stats import track as _track_stats
@@ -66,6 +85,8 @@ class LarkHandler:
66
85
  self._group_chat_ids: set = self._load_group_chat_ids()
67
86
  # chat_id → CardSlice(用户主动断开后保留,供重连时冻结旧卡片)
68
87
  self._detached_slices: Dict[str, CardSlice] = {}
88
+ # 正在启动中的会话名集合(防止并发点击触发竞态)
89
+ self._starting_sessions: set = set()
69
90
 
70
91
  # ── 持久化绑定 ──────────────────────────────────────────────────────────
71
92
 
@@ -168,6 +189,9 @@ class LarkHandler:
168
189
  if active_slice:
169
190
  await self._update_card_disconnected(chat_id, session_name, active_slice)
170
191
 
192
+ # 会话退出时自动解散绑定到该会话的所有专属群聊
193
+ await self._disband_groups_for_session(session_name, source="disconnect")
194
+
171
195
  # ── 消息入口 ────────────────────────────────────────────────────────────
172
196
 
173
197
  async def handle_message(self, user_id: str, chat_id: str, text: str,
@@ -316,45 +340,36 @@ class LarkHandler:
316
340
  )
317
341
  return
318
342
 
343
+ if session_name in self._starting_sessions:
344
+ await card_service.send_text(chat_id, f"会话 '{session_name}' 正在启动中,请稍候")
345
+ return
346
+ self._starting_sessions.add(session_name)
347
+
319
348
  script_dir = Path(__file__).parent.parent.absolute()
320
349
  server_script = script_dir / "server" / "server.py"
321
- cmd = [sys.executable, str(server_script), session_name]
350
+ cmd = ["uv", "run", "--project", str(script_dir), "python3", str(server_script), session_name]
351
+ if self._poller.get_bypass_enabled():
352
+ cmd += ["--", "--dangerously-skip-permissions", "--permission-mode=dontAsk"]
322
353
 
323
354
  logger.info(f"启动会话: {session_name}, 工作目录: {work_dir}, 命令: {' '.join(cmd)}")
324
355
  _track_stats('lark', 'cmd_start', session_name=session_name, chat_id=chat_id)
325
356
 
326
357
  try:
327
- import os as _os
328
- from datetime import datetime as _datetime
329
358
  env = _os.environ.copy()
330
359
  env.pop("CLAUDECODE", None)
331
360
 
332
- from utils.session import USER_DATA_DIR
333
361
  log_path = USER_DATA_DIR / "startup.log"
334
362
  start_time = _datetime.now()
335
363
 
336
- proc = subprocess.Popen(
337
- cmd,
338
- stdout=subprocess.DEVNULL,
339
- stderr=subprocess.DEVNULL,
340
- start_new_session=True,
341
- cwd=work_dir,
342
- env=env,
343
- )
344
-
345
- def _read_log_since(since):
346
- if not log_path.exists():
347
- return ""
348
- lines = []
349
- for line in log_path.read_text(encoding="utf-8").splitlines():
350
- try:
351
- ts = _datetime.strptime(line[:23], "%Y-%m-%d %H:%M:%S.%f")
352
- if ts >= since:
353
- lines.append(line)
354
- except ValueError:
355
- if lines:
356
- lines.append(line)
357
- return "\n".join(lines)
364
+ with open(log_path, 'a') as stderr_fd:
365
+ proc = subprocess.Popen(
366
+ cmd,
367
+ stdout=subprocess.DEVNULL,
368
+ stderr=stderr_fd,
369
+ start_new_session=True,
370
+ cwd=work_dir,
371
+ env=env,
372
+ )
358
373
 
359
374
  socket_path = get_socket_path(session_name)
360
375
  for i in range(120):
@@ -365,13 +380,13 @@ class LarkHandler:
365
380
  elapsed = (i + 1) // 10
366
381
  rc = proc.poll()
367
382
  if rc is not None:
368
- log_content = _read_log_since(start_time)
383
+ log_content = _read_log_since(start_time, log_path)
369
384
  logger.warning(f"会话启动失败: server 进程已退出 (exitcode={rc}, elapsed={elapsed}s)\n{log_content}")
370
385
  await card_service.send_text(chat_id, f"错误: Server 进程意外退出 (code={rc})\n\n{log_content}")
371
386
  return
372
387
  logger.info(f"等待 server socket... ({elapsed}s)")
373
388
  else:
374
- log_content = _read_log_since(start_time)
389
+ log_content = _read_log_since(start_time, log_path)
375
390
  logger.error(f"会话启动超时 (12s), session={session_name}\n{log_content}")
376
391
  await card_service.send_text(chat_id, f"错误: 会话启动超时 (12s)\n\n{log_content}")
377
392
  return
@@ -389,42 +404,61 @@ class LarkHandler:
389
404
  except Exception as e:
390
405
  logger.error(f"启动会话失败: {e}")
391
406
  await card_service.send_text(chat_id, f"错误: 启动失败 - {e}")
407
+ finally:
408
+ self._starting_sessions.discard(session_name)
392
409
 
393
410
  async def _cmd_start_and_new_group(self, user_id: str, chat_id: str,
394
411
  session_name: str, path: str):
395
412
  """在指定目录启动会话并创建专属群聊"""
396
- sessions = list_active_sessions()
397
- if any(s["name"] == session_name for s in sessions):
398
- # 会话已存在,直接创建群聊
399
- await self._cmd_new_group(user_id, chat_id, session_name)
400
- return
401
-
402
413
  work_path = Path(path).expanduser()
403
414
  if not work_path.is_dir():
404
415
  await card_service.send_text(chat_id, f"错误: 路径无效: {path}")
405
416
  return
406
417
 
418
+ sessions = list_active_sessions()
419
+ active_names = {s["name"] for s in sessions}
420
+ if session_name in active_names or session_name in self._starting_sessions:
421
+ session_name = f"{session_name}_{_datetime.now().strftime('%m%d_%H%M%S')}"
422
+
423
+ self._starting_sessions.add(session_name)
424
+
407
425
  work_dir = str(work_path.absolute())
408
426
  script_dir = Path(__file__).parent.parent.absolute()
409
427
  server_script = script_dir / "server" / "server.py"
410
- cmd = [sys.executable, str(server_script), session_name]
428
+ cmd = ["uv", "run", "--project", str(script_dir), "python3", str(server_script), session_name]
429
+ if self._poller.get_bypass_enabled():
430
+ cmd += ["--", "--dangerously-skip-permissions", "--permission-mode=dontAsk"]
411
431
 
412
432
  try:
413
- import os as _os
414
433
  env = _os.environ.copy()
415
434
  env.pop("CLAUDECODE", None)
416
- subprocess.Popen(
417
- cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
418
- start_new_session=True, cwd=work_dir, env=env,
419
- )
435
+
436
+ log_path = USER_DATA_DIR / "startup.log"
437
+ start_time = _datetime.now()
438
+
439
+ with open(log_path, 'a') as stderr_fd:
440
+ proc = subprocess.Popen(
441
+ cmd, stdout=subprocess.DEVNULL, stderr=stderr_fd,
442
+ start_new_session=True, cwd=work_dir, env=env,
443
+ )
420
444
 
421
445
  socket_path = get_socket_path(session_name)
422
- for _ in range(120):
446
+ for i in range(120):
423
447
  await asyncio.sleep(0.1)
424
448
  if socket_path.exists():
425
449
  break
450
+ if (i + 1) % 10 == 0:
451
+ elapsed = (i + 1) // 10
452
+ rc = proc.poll()
453
+ if rc is not None:
454
+ log_content = _read_log_since(start_time, log_path)
455
+ logger.warning(f"启动并创建群聊失败: server 进程已退出 (exitcode={rc}, elapsed={elapsed}s)\n{log_content}")
456
+ await card_service.send_text(chat_id, f"错误: Server 进程意外退出 (code={rc})\n\n{log_content}")
457
+ return
426
458
  else:
427
- await card_service.send_text(chat_id, "错误: 会话启动超时")
459
+ log_content = _read_log_since(start_time, log_path)
460
+ logger.error(f"启动并创建群聊超时 (12s), session={session_name}\n{log_content}")
461
+ await card_service.send_text(chat_id, f"错误: 会话启动超时 (12s)\n\n{log_content}")
428
462
  return
429
463
 
430
464
  await self._cmd_new_group(user_id, chat_id, session_name)
@@ -432,6 +466,8 @@ class LarkHandler:
432
466
  except Exception as e:
433
467
  logger.error(f"启动并创建群聊失败: {e}")
434
468
  await card_service.send_text(chat_id, f"操作失败:{e}")
469
+ finally:
470
+ self._starting_sessions.discard(session_name)
435
471
 
436
472
  async def _cmd_kill(self, user_id: str, chat_id: str, args: str,
437
473
  message_id: Optional[str] = None):
@@ -607,7 +643,8 @@ class LarkHandler:
607
643
  }
608
644
  card = build_menu_card(sessions, current_session=current, session_groups=session_groups, page=page,
609
645
  notify_enabled=self._poller.get_notify_enabled(),
610
- urgent_enabled=self._poller.get_urgent_enabled())
646
+ urgent_enabled=self._poller.get_urgent_enabled(),
647
+ bypass_enabled=self._poller.get_bypass_enabled())
611
648
  await self._send_or_update_card(chat_id, card, message_id)
612
649
 
613
650
  async def _cmd_toggle_notify(self, user_id: str, chat_id: str,
@@ -624,6 +661,13 @@ class LarkHandler:
624
661
  self._poller.set_urgent_enabled(new_value)
625
662
  await self._cmd_menu(user_id, chat_id, message_id=message_id)
626
663
 
664
+ async def _cmd_toggle_bypass(self, user_id: str, chat_id: str,
665
+ message_id: Optional[str] = None):
666
+ """切换新会话 bypass 开关并刷新菜单卡片"""
667
+ new_value = not self._poller.get_bypass_enabled()
668
+ self._poller.set_bypass_enabled(new_value)
669
+ await self._cmd_menu(user_id, chat_id, message_id=message_id)
670
+
627
671
  async def _cmd_ls(self, user_id: str, chat_id: str, args: str,
628
672
  tree: bool = False, message_id: Optional[str] = None, page: int = 0):
629
673
  """查看目录文件结构"""
@@ -779,6 +823,32 @@ class LarkHandler:
779
823
  except Exception as e:
780
824
  return False, str(e)
781
825
 
826
+ async def _disband_groups_for_session(self, session_name: str, source: str = ""):
827
+ """解散绑定到指定会话的所有专属群聊"""
828
+ disbanded = []
829
+ for cid in list(self._group_chat_ids):
830
+ if self._chat_bindings.get(cid) == session_name:
831
+ log_prefix = f"[{source}] " if source else ""
832
+ logger.info(f"{log_prefix}自动解散群聊: chat_id={cid[:8]}..., session={session_name}")
833
+ # 先清理本地状态(防止并发协程重入时重复处理)
834
+ self._group_chat_ids.discard(cid)
835
+ self._chat_bindings.pop(cid, None)
836
+ disbanded.append(cid)
837
+ # 停止轮询 + 断开 bridge
838
+ self._poller.stop(cid)
839
+ bridge = self._bridges.pop(cid, None)
840
+ if bridge:
841
+ await bridge.disconnect()
842
+ self._chat_sessions.pop(cid, None)
843
+ self._detached_slices.pop(cid, None)
844
+ # 调用飞书 API 解散
845
+ ok, err = await self._disband_group_via_api(cid)
846
+ if not ok:
847
+ logger.warning(f"{log_prefix}解散群 {cid[:8]}... API 失败: {err}")
848
+ if disbanded:
849
+ self._save_chat_bindings()
850
+ self._save_group_chat_ids()
851
+
782
852
  async def _cmd_disband_group(self, user_id: str, chat_id: str, session_name: str,
783
853
  message_id: Optional[str] = None):
784
854
  """解散与指定会话绑定的专属群聊"""
@@ -830,6 +900,8 @@ class LarkHandler:
830
900
  await card_service.send_text(
831
901
  chat_id, f"会话 '{saved_session}' 已不存在,请重新 /attach"
832
902
  )
903
+ # 会话已不存在,解散绑定到该会话的所有专属群聊
904
+ await self._disband_groups_for_session(saved_session, source="lazy")
833
905
  return
834
906
  bridge = self._bridges.get(chat_id)
835
907
  else:
@@ -849,12 +921,11 @@ class LarkHandler:
849
921
 
850
922
  # ── 选项处理 ─────────────────────────────────────────────────────────────
851
923
 
852
- async def handle_option_select(self, user_id: str, chat_id: str, option_value: str, option_total: int = 0):
853
- """处理用户选择的选项(按钮点击)
924
+ async def handle_option_select(self, user_id: str, chat_id: str, option_value: str, option_total: int = 0, *, needs_input: bool = False):
925
+ """闭环选项选择:箭头键导航 + 共享内存验证
854
926
 
855
- 最后一个选项特殊处理:Claude CLI 的光标选择模式中,最后一个选项
856
- 直接发数字键无效。改为先发倒数第二项的数字跳转,再发 移到最后一项,
857
- 最后发 Enter 确认。
927
+ 发箭头键导航到目标选项,每步从共享内存读取 selected_value 确认是否到位,
928
+ 到位后发 Enter 确认。避免数字键在溢出选项上无效的问题。
858
929
  """
859
930
  logger.info(f"处理选项选择: user={user_id[:8]}..., option={option_value}, total={option_total}")
860
931
  _track_stats('lark', 'option_select',
@@ -866,37 +937,99 @@ class LarkHandler:
866
937
  await card_service.send_text(chat_id, "未连接到任何会话,请先使用 /attach <会话名> 连接")
867
938
  return
868
939
 
869
- key_mapping = {
870
- "yes": "y",
871
- "no": "n",
872
- "allow_once": "y",
873
- "allow_always": "a",
874
- "deny": "n",
875
- }
876
- key_to_send = key_mapping.get(option_value.lower())
877
-
878
- if key_to_send:
879
- # 固定映射的选项(permission 类型)
880
- logger.info(f"发送按键到 Claude: {key_to_send}")
881
- success = await bridge.send_key(key_to_send)
882
- elif option_total > 1 and option_value == str(option_total):
883
- # 最后一个选项:发 (N-1) 次 ↓ → Enter
884
- import asyncio
885
- steps = option_total - 1
886
- logger.info(f"最后一个选项,发送: {steps}次↓ → Enter")
887
- for _ in range(steps):
888
- await bridge.send_raw(b"\x1b[B") # ↓ 箭头
889
- await asyncio.sleep(0.05)
890
- success = await bridge.send_raw(b"\r")
891
- else:
892
- # 普通数字选项
893
- logger.info(f"发送按键到 Claude: {option_value}")
894
- success = await bridge.send_key(option_value)
940
+ target = option_value # 目标选项 value(如 "2")
941
+ max_steps = max(option_total, 10) if option_total > 0 else 10
895
942
 
896
- if success:
897
- self._poller.kick(chat_id)
898
- else:
899
- await card_service.send_text(chat_id, "发送选择失败")
943
+ # 记录初始 option_block 的 block_id,防止跨选项交互误操作
944
+ initial_snapshot = self._poller.read_snapshot(chat_id)
945
+ if not initial_snapshot:
946
+ return
947
+ initial_ob = initial_snapshot.get('option_block')
948
+ if not initial_ob:
949
+ return
950
+ initial_block_id = initial_ob.get('block_id', '')
951
+
952
+ for step in range(max_steps):
953
+ # 1. 读取当前选中项
954
+ snapshot = self._poller.read_snapshot(chat_id)
955
+ if not snapshot:
956
+ break
957
+ ob = snapshot.get('option_block')
958
+ if not ob:
959
+ break # option_block 已消失(CLI 已进入下一状态)
960
+
961
+ # 检查 block_id 一致性
962
+ if initial_block_id and ob.get('block_id', '') != initial_block_id:
963
+ logger.warning(f"option_block 已切换,中止选项选择")
964
+ break
965
+
966
+ current = ob.get('selected_value', '')
967
+
968
+ # 闪烁帧重试:❯ 光标字符会时隐时现,selected_value 为空时短暂重试
969
+ if not current:
970
+ for _retry in range(5): # 最多重试 5 次,共 500ms
971
+ await asyncio.sleep(0.1)
972
+ snap = self._poller.read_snapshot(chat_id)
973
+ if not snap:
974
+ break
975
+ retry_ob = snap.get('option_block')
976
+ if not retry_ob:
977
+ break
978
+ current = retry_ob.get('selected_value', '')
979
+ if current:
980
+ break
981
+
982
+ # 2. 已到位 → 发 Enter(自由输入选项只导航不发 Enter)
983
+ if current == target:
984
+ if needs_input:
985
+ logger.info(f"自由输入选项已到位: target={target},不发送 Enter")
986
+ self._poller.kick(chat_id)
987
+ return
988
+ logger.info(f"选项已到位: current={current} == target={target},发送 Enter")
989
+ success = await bridge.send_raw(b"\r")
990
+ if success:
991
+ self._poller.kick(chat_id)
992
+ else:
993
+ await card_service.send_text(chat_id, "发送选择失败")
994
+ return
995
+
996
+ # 3. 未到位 → 发箭头键
997
+ if current and target:
998
+ try:
999
+ if int(current) < int(target):
1000
+ logger.info(f"步骤{step}: current={current} < target={target},发送 ↓")
1001
+ await bridge.send_raw(b"\x1b[B") # ↓
1002
+ else:
1003
+ logger.info(f"步骤{step}: current={current} > target={target},发送 ↑")
1004
+ await bridge.send_raw(b"\x1b[A") # ↑
1005
+ except ValueError:
1006
+ logger.warning(f"步骤{step}: 无法比较 current={current!r} 和 target={target!r},发送 ↓")
1007
+ await bridge.send_raw(b"\x1b[B")
1008
+ else:
1009
+ # selected_value 重试后仍为空(真正的初始状态),默认向下
1010
+ logger.info(f"步骤{step}: selected_value 重试后仍为空,发送 ↓")
1011
+ await bridge.send_raw(b"\x1b[B")
1012
+
1013
+ # 4. 等待共享内存更新(轮询直到 selected_value 变为另一个非空值或超时)
1014
+ old_selected = current
1015
+ deadline = time.time() + 2.0 # 单步超时 2s
1016
+ while time.time() < deadline:
1017
+ await asyncio.sleep(0.1) # 100ms 轮询
1018
+ snap = self._poller.read_snapshot(chat_id)
1019
+ if not snap:
1020
+ break
1021
+ new_ob = snap.get('option_block')
1022
+ if not new_ob:
1023
+ break # option_block 消失,退出
1024
+ if initial_block_id and new_ob.get('block_id', '') != initial_block_id:
1025
+ break # block_id 变了,外层会处理
1026
+ new_selected = new_ob.get('selected_value', '')
1027
+ # 忽略闪烁帧:只有变为另一个非空值才视为真正变化
1028
+ if new_selected and new_selected != old_selected:
1029
+ break
1030
+
1031
+ # 超过 max_steps 仍未到位,记录警告
1032
+ logger.warning(f"选项选择超步数: target={target}, steps={max_steps}")
900
1033
 
901
1034
  # ── 快捷键发送 ─────────────────────────────────────────────────────────────
902
1035
 
@@ -8,8 +8,10 @@ Remote Claude 飞书客户端
8
8
  import asyncio
9
9
  import json
10
10
  import logging
11
+ import os
11
12
  import signal
12
13
  import sys
14
+ import urllib.request
13
15
  from pathlib import Path
14
16
 
15
17
 
@@ -183,8 +185,9 @@ def handle_card_action(event: P2CardActionTrigger) -> P2CardActionTriggerRespons
183
185
  if action_type == "select_option":
184
186
  option_value = action_value.get("value", "")
185
187
  option_total = int(action_value.get("total", "0"))
186
- print(f"[Lark] 用户选择了选项: {option_value} (total={option_total})")
187
- asyncio.create_task(handler.handle_option_select(user_id, chat_id, option_value, option_total))
188
+ needs_input = action_value.get("needs_input", False)
189
+ print(f"[Lark] 用户选择了选项: {option_value} (total={option_total}, needs_input={needs_input})")
190
+ asyncio.create_task(handler.handle_option_select(user_id, chat_id, option_value, option_total, needs_input=needs_input))
188
191
  return None
189
192
 
190
193
  # 列表卡片:进入会话
@@ -314,6 +317,10 @@ def handle_card_action(event: P2CardActionTrigger) -> P2CardActionTriggerRespons
314
317
  asyncio.create_task(handler._cmd_toggle_urgent(user_id, chat_id, message_id=message_id))
315
318
  return None
316
319
 
320
+ if action_type == "menu_toggle_bypass":
321
+ asyncio.create_task(handler._cmd_toggle_bypass(user_id, chat_id, message_id=message_id))
322
+ return None
323
+
317
324
  # 各卡片底部菜单按钮:辅助卡片就地→菜单,流式卡片降级新卡
318
325
  if action_type == "menu_open":
319
326
  asyncio.create_task(handler._cmd_menu(user_id, chat_id, message_id=message_id))
@@ -371,6 +378,21 @@ class LarkBot:
371
378
  log_level=lark.LogLevel.INFO,
372
379
  )
373
380
 
381
+ # 代理兼容:检测 SOCKS 代理,按配置决定是否绕过
382
+ proxy_info = urllib.request.getproxies()
383
+ socks_proxy = (proxy_info.get('socks') or proxy_info.get('all')
384
+ or proxy_info.get('https') or proxy_info.get('http'))
385
+ if socks_proxy and 'socks' in socks_proxy.lower():
386
+ if config.LARK_NO_PROXY:
387
+ # 用户选择绕过代理 → 清除代理环境变量
388
+ for var in ('ALL_PROXY', 'all_proxy', 'HTTPS_PROXY', 'https_proxy',
389
+ 'HTTP_PROXY', 'http_proxy', 'SOCKS_PROXY', 'socks_proxy'):
390
+ os.environ.pop(var, None)
391
+ print(f"检测到 SOCKS 代理 ({socks_proxy}),已按 LARK_NO_PROXY=1 绕过")
392
+ else:
393
+ print(f"检测到 SOCKS 代理 ({socks_proxy}),将通过代理连接")
394
+ print(" 如连接失败,可在 .env 中设置 LARK_NO_PROXY=1 绕过代理")
395
+
374
396
  self.running = True
375
397
  print("\n机器人已启动,等待消息...")
376
398
  print("在飞书中发送 /help 查看使用说明\n")
@@ -119,22 +119,16 @@ class SessionBridge:
119
119
  if not self.writer or not self.running:
120
120
  return False
121
121
  try:
122
+ # 分两步发送:先发文本,等 Ink 框架处理完毕,再发 Enter
123
+ # 不能合并为 text+\r 一次写入(Ink 同一 tick 处理时 \r 会被提前消费,导致需要两次 Enter)
124
+ # 不能在文本前/后加 ESC(ESC+Enter 被终端解释为 Alt+Enter,导致换行而非提交)
122
125
  msg = InputMessage(text.encode('utf-8'), self.client_id)
123
126
  self.writer.write(encode_message(msg))
124
127
  await self.writer.drain()
125
-
126
- # 发送 Escape 退出多行模式
127
- await asyncio.sleep(0.1)
128
- msg = InputMessage(b"\x1b", self.client_id)
129
- self.writer.write(encode_message(msg))
130
- await self.writer.drain()
131
-
132
- # 发送 Enter 提交
133
- await asyncio.sleep(0.1)
128
+ await asyncio.sleep(0.05)
134
129
  msg = InputMessage(b"\r", self.client_id)
135
130
  self.writer.write(encode_message(msg))
136
131
  await self.writer.drain()
137
-
138
132
  return True
139
133
  except Exception as e:
140
134
  logger.error(f"发送失败: {e}")
@@ -40,6 +40,7 @@ from utils.session import ensure_user_data_dir, USER_DATA_DIR
40
40
  # ── 常量 ──────────────────────────────────────────────────────────────────────
41
41
  INITIAL_WINDOW = 30 # 首次 attach 最多显示最近 30 个 blocks
42
42
  from .config import MAX_CARD_BLOCKS # 单张卡片最多 N 个 blocks → 超限冻结(可通过 .env 配置)
43
+ CARD_SIZE_LIMIT = 25 * 1024 # 25KB,飞书限制 30KB,留 5KB 余量
43
44
  POLL_INTERVAL = 1.0 # 轮询间隔(秒)
44
45
  RAPID_INTERVAL = 0.2 # 快速轮询间隔(秒)
45
46
  RAPID_DURATION = 2.0 # 快速轮询持续时间(秒)
@@ -153,6 +154,16 @@ class SharedMemoryPoller:
153
154
  if exc:
154
155
  logger.error(f"轮询 Task 异常: chat_id={chat_id[:8]}..., {exc}", exc_info=exc)
155
156
 
157
+ def read_snapshot(self, chat_id: str) -> Optional[dict]:
158
+ """直接读取指定 chat_id 的当前共享内存快照(供 handle_option_select 等即时查询使用)"""
159
+ tracker = self._trackers.get(chat_id)
160
+ if tracker and tracker.reader:
161
+ try:
162
+ return tracker.reader.read()
163
+ except Exception as e:
164
+ logger.warning(f"read_snapshot 失败: {e}")
165
+ return None
166
+
156
167
  def kick(self, chat_id: str) -> None:
157
168
  """触发立即轮询并进入快速轮询模式"""
158
169
  self._rapid_until[chat_id] = time.time() + RAPID_DURATION
@@ -262,6 +273,16 @@ class SharedMemoryPoller:
262
273
  from .card_builder import build_stream_card
263
274
  card_dict = build_stream_card(blocks_slice, status_line, bottom_bar, agent_panel=agent_panel, option_block=option_block, session_name=tracker.session_name, cli_type=cli_type)
264
275
 
276
+ # 大小超限检查(与 blocks 数量超限同一套逻辑)
277
+ card_size = len(json.dumps(card_dict, ensure_ascii=False).encode('utf-8'))
278
+ if card_size > CARD_SIZE_LIMIT:
279
+ freeze_count = self._find_freeze_count(blocks_slice, tracker.session_name)
280
+ await self._freeze_and_split(
281
+ tracker, blocks, status_line, bottom_bar, agent_panel, option_block,
282
+ cli_type=cli_type, freeze_count=freeze_count,
283
+ )
284
+ return
285
+
265
286
  active.sequence += 1
266
287
  success = await self._card_service.update_card(
267
288
  card_id=active.card_id,
@@ -326,6 +347,15 @@ class SharedMemoryPoller:
326
347
 
327
348
  from .card_builder import build_stream_card
328
349
  card_dict = build_stream_card(blocks_slice, status_line, bottom_bar, agent_panel=agent_panel, option_block=option_block, session_name=tracker.session_name, cli_type=cli_type)
350
+
351
+ # 新卡大小检查:超限则从头部裁剪
352
+ card_size = len(json.dumps(card_dict, ensure_ascii=False).encode('utf-8'))
353
+ while card_size > CARD_SIZE_LIMIT and len(blocks_slice) > 1:
354
+ blocks_slice = blocks_slice[1:]
355
+ start_idx += 1
356
+ card_dict = build_stream_card(blocks_slice, status_line, bottom_bar, agent_panel=agent_panel, option_block=option_block, session_name=tracker.session_name, cli_type=cli_type)
357
+ card_size = len(json.dumps(card_dict, ensure_ascii=False).encode('utf-8'))
358
+
329
359
  card_id = await self._card_service.create_card(card_dict)
330
360
 
331
361
  if card_id:
@@ -384,6 +414,24 @@ class SharedMemoryPoller:
384
414
  f"[ELEMENT_LIMIT_SPLIT] session={tracker.session_name} "
385
415
  f"new_start={new_start} blocks={len(new_blocks)} card_id={new_card_id}"
386
416
  )
417
+ tracker.last_notify_message_id = None
418
+
419
+ def _find_freeze_count(self, blocks_slice: List[dict], session_name: str) -> int:
420
+ """二分查找冻结卡片能容纳的最大 blocks 数(保证卡片 JSON 大小 ≤ CARD_SIZE_LIMIT)"""
421
+ from .card_builder import build_stream_card
422
+ lo, hi = 1, len(blocks_slice)
423
+ result = 1
424
+ while lo <= hi:
425
+ mid = (lo + hi) // 2
426
+ card = build_stream_card(blocks_slice[:mid], None, None,
427
+ is_frozen=True, session_name=session_name)
428
+ size = len(json.dumps(card, ensure_ascii=False).encode('utf-8'))
429
+ if size <= CARD_SIZE_LIMIT:
430
+ result = mid
431
+ lo = mid + 1
432
+ else:
433
+ hi = mid - 1
434
+ return result
387
435
 
388
436
  async def _freeze_and_split(
389
437
  self, tracker: StreamTracker, blocks: List[dict],
@@ -391,12 +439,15 @@ class SharedMemoryPoller:
391
439
  agent_panel: Optional[dict] = None,
392
440
  option_block: Optional[dict] = None,
393
441
  cli_type: str = "claude",
442
+ freeze_count: Optional[int] = None,
394
443
  ) -> None:
395
444
  """冻结当前卡片 + 开新卡"""
396
445
  active = tracker.cards[-1]
446
+ count = freeze_count if freeze_count is not None else MAX_CARD_BLOCKS
447
+ reason = 'size' if freeze_count is not None else 'count'
397
448
 
398
- # 冻结当前卡片(只保留前 MAX_CARD_BLOCKS 个 blocks,移除状态区和按钮)
399
- frozen_blocks = blocks[active.start_idx:active.start_idx + MAX_CARD_BLOCKS]
449
+ # 冻结当前卡片(只保留前 count 个 blocks,移除状态区和按钮)
450
+ frozen_blocks = blocks[active.start_idx:active.start_idx + count]
400
451
  from .card_builder import build_stream_card
401
452
  frozen_card = build_stream_card(frozen_blocks, None, None, is_frozen=True)
402
453
  active.sequence += 1
@@ -406,16 +457,25 @@ class SharedMemoryPoller:
406
457
  chat_id=tracker.chat_id)
407
458
  logger.info(
408
459
  f"[FREEZE] session={tracker.session_name} card_id={active.card_id} "
409
- f"blocks=[{active.start_idx}:{active.start_idx + MAX_CARD_BLOCKS}]"
460
+ f"blocks=[{active.start_idx}:{active.start_idx + count}] reason={reason}"
410
461
  )
411
462
 
412
463
  # 创建新卡片
413
- new_start = active.start_idx + MAX_CARD_BLOCKS
464
+ new_start = active.start_idx + count
414
465
  new_blocks = blocks[new_start:]
415
466
  if not new_blocks:
416
467
  return
417
468
 
418
469
  new_card_dict = build_stream_card(new_blocks, status_line, bottom_bar, agent_panel=agent_panel, option_block=option_block, session_name=tracker.session_name, cli_type=cli_type)
470
+
471
+ # 新卡大小检查:超限则从头部裁剪
472
+ new_card_size = len(json.dumps(new_card_dict, ensure_ascii=False).encode('utf-8'))
473
+ while new_card_size > CARD_SIZE_LIMIT and len(new_blocks) > 1:
474
+ new_blocks = new_blocks[1:]
475
+ new_start += 1
476
+ new_card_dict = build_stream_card(new_blocks, status_line, bottom_bar, agent_panel=agent_panel, option_block=option_block, session_name=tracker.session_name, cli_type=cli_type)
477
+ new_card_size = len(json.dumps(new_card_dict, ensure_ascii=False).encode('utf-8'))
478
+
419
479
  new_card_id = await self._card_service.create_card(new_card_dict)
420
480
  if new_card_id:
421
481
  await self._card_service.send_card(tracker.chat_id, new_card_id)
@@ -425,6 +485,7 @@ class SharedMemoryPoller:
425
485
  f"[NEW after FREEZE] session={tracker.session_name} start_idx={new_start} "
426
486
  f"blocks={len(new_blocks)} card_id={new_card_id}"
427
487
  )
488
+ tracker.last_notify_message_id = None
428
489
 
429
490
  async def _check_ready_notification(
430
491
  self, tracker: StreamTracker,
@@ -504,6 +565,17 @@ class SharedMemoryPoller:
504
565
  _save_urgent_enabled(enabled)
505
566
  logger.info(f"加急通知开关已{'开启' if enabled else '关闭'}")
506
567
 
568
+ def get_bypass_enabled(self) -> bool:
569
+ """获取新会话 bypass 开关状态"""
570
+ return _bypass_enabled
571
+
572
+ def set_bypass_enabled(self, enabled: bool) -> None:
573
+ """更新新会话 bypass 开关状态并持久化"""
574
+ global _bypass_enabled
575
+ _bypass_enabled = enabled
576
+ _save_bypass_enabled(enabled)
577
+ logger.info(f"新会话 bypass 开关已{'开启' if enabled else '关闭'}")
578
+
507
579
  @staticmethod
508
580
  def _compute_hash(
509
581
  blocks: list, status_line: Optional[dict],
@@ -534,6 +606,7 @@ def _is_ready(blocks: list, status_line: Optional[dict], option_block: Optional[
534
606
  _READY_COUNT_FILE = USER_DATA_DIR / "ready_notify_count"
535
607
  _NOTIFY_ENABLED_FILE = USER_DATA_DIR / "ready_notify_enabled"
536
608
  _URGENT_ENABLED_FILE = USER_DATA_DIR / "urgent_notify_enabled"
609
+ _BYPASS_ENABLED_FILE = USER_DATA_DIR / "bypass_enabled"
537
610
 
538
611
 
539
612
  def _load_notify_enabled() -> bool:
@@ -570,9 +643,27 @@ def _save_urgent_enabled(enabled: bool) -> None:
570
643
  logger.warning(f"_save_urgent_enabled 失败: {e}")
571
644
 
572
645
 
646
+ def _load_bypass_enabled() -> bool:
647
+ """读取新会话 bypass 开关状态,不存在或解析失败返回 False(默认关闭)"""
648
+ try:
649
+ return _BYPASS_ENABLED_FILE.read_text().strip() == "1"
650
+ except Exception:
651
+ return False
652
+
653
+
654
+ def _save_bypass_enabled(enabled: bool) -> None:
655
+ """持久化新会话 bypass 开关状态"""
656
+ try:
657
+ ensure_user_data_dir()
658
+ _BYPASS_ENABLED_FILE.write_text("1" if enabled else "0")
659
+ except Exception as e:
660
+ logger.warning(f"_save_bypass_enabled 失败: {e}")
661
+
662
+
573
663
  # 模块级开关状态:启动时加载一次
574
664
  _notify_enabled: bool = _load_notify_enabled()
575
665
  _urgent_enabled: bool = _load_urgent_enabled()
666
+ _bypass_enabled: bool = _load_bypass_enabled()
576
667
 
577
668
 
578
669
  def _increment_ready_count() -> int:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "remote-claude",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "description": "双端共享 Claude CLI 工具",
5
5
  "bin": {
6
6
  "remote-claude": "bin/remote-claude",
@@ -8,6 +8,7 @@
8
8
  "cl": "bin/cl"
9
9
  },
10
10
  "scripts": {
11
+ "preinstall": "bash scripts/preinstall.sh",
11
12
  "postinstall": "bash scripts/postinstall.sh"
12
13
  },
13
14
  "files": [
package/pyproject.toml CHANGED
@@ -8,6 +8,7 @@ dependencies = [
8
8
  "python-dotenv>=1.0.0",
9
9
  "pyte>=0.8.0",
10
10
  "mixpanel>=5.0.0",
11
+ "python-socks[asyncio]>=2.0.0",
11
12
  ]
12
13
 
13
14
  [project.scripts]
@@ -0,0 +1,4 @@
1
+ #!/bin/bash
2
+ # 升级前尝试停止旧的 lark 客户端(失败不报错)
3
+ remote-claude lark stop 2>/dev/null || true
4
+ exit 0
@@ -258,6 +258,40 @@ def _get_row_text(screen: pyte.Screen, row: int) -> str:
258
258
  return ''.join(buf).rstrip()
259
259
 
260
260
 
261
+ def _is_dim_fg(fg_color: str) -> bool:
262
+ """判断前景色是否为灰色/暗色(placeholder 风格)。"""
263
+ if not fg_color or fg_color == 'default':
264
+ return False
265
+ name = fg_color.lower().replace(' ', '').replace('-', '')
266
+ if name in ('brightblack', 'black'): # bright_black = gray
267
+ return True
268
+ if len(fg_color) == 6:
269
+ try:
270
+ r, g, b = int(fg_color[0:2], 16), int(fg_color[2:4], 16), int(fg_color[4:6], 16)
271
+ L = 0.2126 * r + 0.7152 * g + 0.0722 * b
272
+ # 灰色调(R≈G≈B)且非纯白 → inactive/placeholder 颜色(如 999999 亮度 153 > 128)
273
+ if max(r, g, b) - min(r, g, b) <= 30 and L < 230:
274
+ return True
275
+ # 非灰色但较暗
276
+ return L < 128
277
+ except ValueError:
278
+ pass
279
+ return False
280
+
281
+
282
+ def _option_label_is_dim(screen: pyte.Screen, row: int) -> bool:
283
+ """检查选项行 label 部分前景色是否为暗色(自由输入 placeholder 风格)。"""
284
+ text = _get_row_text(screen, row)
285
+ m = re.match(r'^[\s❯]*\d+[.)]\s+', text)
286
+ if not m:
287
+ return False
288
+ col = m.end()
289
+ buf_row = screen.buffer[row]
290
+ if col not in buf_row or not buf_row[col].data.strip():
291
+ return False
292
+ return _is_dim_fg(buf_row[col].fg)
293
+
294
+
261
295
  def _get_col0(screen: pyte.Screen, row: int) -> str:
262
296
  """获取指定行第一列字符(col=0)"""
263
297
  try:
@@ -874,6 +908,7 @@ class ClaudeParser(BaseParser):
874
908
 
875
909
  options: List[dict] = []
876
910
  current_opt: Optional[dict] = None
911
+ selected_value = ""
877
912
  ansi_raw_lines = [_get_row_ansi_text(screen, r) for r in input_rows + overflow]
878
913
 
879
914
  for row in all_option_rows:
@@ -891,7 +926,10 @@ class ClaudeParser(BaseParser):
891
926
  'label': m.group(2).strip(),
892
927
  'value': m.group(1),
893
928
  'description': '',
929
+ 'needs_input': _option_label_is_dim(screen, row), # 自由输入选项检测
894
930
  }
931
+ if line.startswith('❯'):
932
+ selected_value = m.group(1)
895
933
  elif current_opt is not None and line:
896
934
  # 描述行
897
935
  current_opt['description'] = (
@@ -905,6 +943,7 @@ class ClaudeParser(BaseParser):
905
943
  return OptionBlock(
906
944
  sub_type='option', tag=tag, question=question, options=options,
907
945
  ansi_raw='\n'.join(ansi_raw_lines).rstrip(),
946
+ selected_value=selected_value,
908
947
  )
909
948
  return None
910
949
 
@@ -1020,6 +1059,7 @@ class ClaudeParser(BaseParser):
1020
1059
  content_lines = pre_option_contents[1:-1]
1021
1060
 
1022
1061
  # 只收集范围内的 options
1062
+ selected_value = ""
1023
1063
  for i in range(first_opt_idx, last_opt_idx + 1):
1024
1064
  line, cat = classified[i]
1025
1065
  if cat == 'option':
@@ -1029,6 +1069,8 @@ class ClaudeParser(BaseParser):
1029
1069
  'label': m.group(2).strip(),
1030
1070
  'value': m.group(1),
1031
1071
  })
1072
+ if line.startswith('❯'):
1073
+ selected_value = m.group(1)
1032
1074
 
1033
1075
  return OptionBlock(
1034
1076
  sub_type='permission',
@@ -1037,6 +1079,7 @@ class ClaudeParser(BaseParser):
1037
1079
  question=question,
1038
1080
  options=options,
1039
1081
  ansi_raw='\n'.join(ansi_lines).rstrip(),
1082
+ selected_value=selected_value,
1040
1083
  )
1041
1084
 
1042
1085
  def _parse_agent_list_panel(
@@ -1256,6 +1256,7 @@ class CodexParser(BaseParser):
1256
1256
 
1257
1257
  options: List[dict] = []
1258
1258
  current_opt: Optional[dict] = None
1259
+ selected_value = ""
1259
1260
  ansi_raw_lines = [_get_row_ansi_text(screen, r) for r in input_rows + overflow]
1260
1261
 
1261
1262
  for row in all_option_rows:
@@ -1274,6 +1275,8 @@ class CodexParser(BaseParser):
1274
1275
  'value': m.group(1),
1275
1276
  'description': '',
1276
1277
  }
1278
+ if line[0:1] in CODEX_PROMPT_CHARS or line.startswith('❯'):
1279
+ selected_value = m.group(1)
1277
1280
  elif current_opt is not None and line:
1278
1281
  # 描述行
1279
1282
  current_opt['description'] = (
@@ -1287,6 +1290,7 @@ class CodexParser(BaseParser):
1287
1290
  return OptionBlock(
1288
1291
  sub_type='option', tag=tag, question=question, options=options,
1289
1292
  ansi_raw='\n'.join(ansi_raw_lines).rstrip(),
1293
+ selected_value=selected_value,
1290
1294
  )
1291
1295
  return None
1292
1296
 
@@ -1407,6 +1411,7 @@ class CodexParser(BaseParser):
1407
1411
  content_lines = pre_option_contents[1:-1]
1408
1412
 
1409
1413
  # 只收集范围内的 options
1414
+ selected_value = ""
1410
1415
  for i in range(first_opt_idx, last_opt_idx + 1):
1411
1416
  line, cat = classified[i]
1412
1417
  if cat == 'option':
@@ -1416,6 +1421,8 @@ class CodexParser(BaseParser):
1416
1421
  'label': m.group(2).strip(),
1417
1422
  'value': m.group(1),
1418
1423
  })
1424
+ if line[0:1] in CODEX_PROMPT_CHARS or line.startswith('❯'):
1425
+ selected_value = m.group(1)
1419
1426
 
1420
1427
  return OptionBlock(
1421
1428
  sub_type='permission',
@@ -1424,6 +1431,7 @@ class CodexParser(BaseParser):
1424
1431
  question=question,
1425
1432
  options=options,
1426
1433
  ansi_raw='\n'.join(ansi_lines).rstrip(),
1434
+ selected_value=selected_value,
1427
1435
  )
1428
1436
 
1429
1437
  def _parse_agent_list_panel(
@@ -79,6 +79,7 @@ class OptionBlock:
79
79
  ansi_raw: str = "" # 整个选项区域的 ANSI 原始文本
80
80
  indicator: str = "" # 首列字符原文
81
81
  ansi_indicator: str = "" # 带 ANSI 颜色的首列字符
82
+ selected_value: str = "" # 当前 ❯ 光标所在选项的 value(数字字符串)
82
83
 
83
84
 
84
85
  # 向后兼容别名