remote-claude 0.2.14 → 0.2.15
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 +2 -0
- package/lark_client/card_builder.py +51 -1
- package/lark_client/card_service.py +66 -4
- package/lark_client/lark_handler.py +25 -7
- package/lark_client/main.py +14 -5
- package/lark_client/session_bridge.py +0 -1
- package/lark_client/shared_memory_poller.py +159 -2
- package/package.json +1 -1
- package/server/parsers/claude_parser.py +27 -6
- package/server/parsers/codex_parser.py +264 -115
package/README.md
CHANGED
|
@@ -123,6 +123,8 @@ remote-claude attach <会话名>
|
|
|
123
123
|
"im:message.p2p_msg:readonly",
|
|
124
124
|
"im:message.reactions:read",
|
|
125
125
|
"im:message.reactions:write_only",
|
|
126
|
+
"im:message.urgent",
|
|
127
|
+
"im:message.urgent.status:write",
|
|
126
128
|
"im:message:readonly",
|
|
127
129
|
"im:message:recall",
|
|
128
130
|
"im:message:send_as_bot",
|
|
@@ -1201,7 +1201,8 @@ def build_session_closed_card(session_name: str) -> Dict[str, Any]:
|
|
|
1201
1201
|
|
|
1202
1202
|
|
|
1203
1203
|
def build_menu_card(sessions: List[Dict], current_session: Optional[str] = None,
|
|
1204
|
-
session_groups: Optional[Dict[str, str]] = None, page: int = 0
|
|
1204
|
+
session_groups: Optional[Dict[str, str]] = None, page: int = 0,
|
|
1205
|
+
notify_enabled: bool = True, urgent_enabled: bool = False) -> Dict[str, Any]:
|
|
1205
1206
|
"""构建快捷操作菜单卡片(/menu 和 /list 共用):内嵌会话列表 + 快捷操作"""
|
|
1206
1207
|
elements = []
|
|
1207
1208
|
|
|
@@ -1251,6 +1252,55 @@ def build_menu_card(sessions: List[Dict], current_session: Optional[str] = None,
|
|
|
1251
1252
|
]
|
|
1252
1253
|
})
|
|
1253
1254
|
|
|
1255
|
+
notify_label = "🔔 完成通知: 开" if notify_enabled else "🔕 完成通知: 关"
|
|
1256
|
+
if not notify_enabled:
|
|
1257
|
+
urgent_label = "🔇 加急通知: 关"
|
|
1258
|
+
urgent_button: Dict[str, Any] = {
|
|
1259
|
+
"tag": "button",
|
|
1260
|
+
"text": {"tag": "plain_text", "content": urgent_label},
|
|
1261
|
+
"type": "default",
|
|
1262
|
+
"disabled": True,
|
|
1263
|
+
}
|
|
1264
|
+
elif urgent_enabled:
|
|
1265
|
+
urgent_label = "🔔 加急通知: 开"
|
|
1266
|
+
urgent_button = {
|
|
1267
|
+
"tag": "button",
|
|
1268
|
+
"text": {"tag": "plain_text", "content": urgent_label},
|
|
1269
|
+
"type": "default",
|
|
1270
|
+
"behaviors": [{"type": "callback", "value": {"action": "menu_toggle_urgent"}}]
|
|
1271
|
+
}
|
|
1272
|
+
else:
|
|
1273
|
+
urgent_label = "🔕 加急通知: 关"
|
|
1274
|
+
urgent_button = {
|
|
1275
|
+
"tag": "button",
|
|
1276
|
+
"text": {"tag": "plain_text", "content": urgent_label},
|
|
1277
|
+
"type": "default",
|
|
1278
|
+
"behaviors": [{"type": "callback", "value": {"action": "menu_toggle_urgent"}}]
|
|
1279
|
+
}
|
|
1280
|
+
elements.append({
|
|
1281
|
+
"tag": "column_set",
|
|
1282
|
+
"flex_mode": "none",
|
|
1283
|
+
"columns": [
|
|
1284
|
+
{
|
|
1285
|
+
"tag": "column",
|
|
1286
|
+
"width": "weighted",
|
|
1287
|
+
"weight": 1,
|
|
1288
|
+
"elements": [{
|
|
1289
|
+
"tag": "button",
|
|
1290
|
+
"text": {"tag": "plain_text", "content": notify_label},
|
|
1291
|
+
"type": "default",
|
|
1292
|
+
"behaviors": [{"type": "callback", "value": {"action": "menu_toggle_notify"}}]
|
|
1293
|
+
}]
|
|
1294
|
+
},
|
|
1295
|
+
{
|
|
1296
|
+
"tag": "column",
|
|
1297
|
+
"width": "weighted",
|
|
1298
|
+
"weight": 1,
|
|
1299
|
+
"elements": [urgent_button]
|
|
1300
|
+
},
|
|
1301
|
+
]
|
|
1302
|
+
})
|
|
1303
|
+
|
|
1254
1304
|
return {
|
|
1255
1305
|
"schema": "2.0",
|
|
1256
1306
|
"config": {"wide_screen_mode": True},
|
|
@@ -223,11 +223,69 @@ class CardService:
|
|
|
223
223
|
_track_stats('error', 'card_api', detail='update_card')
|
|
224
224
|
return False
|
|
225
225
|
|
|
226
|
-
async def
|
|
227
|
-
"""
|
|
226
|
+
async def send_urgent_app(self, message_id: str, user_ids: list) -> bool:
|
|
227
|
+
"""对已有消息发送应用内加急通知,避免发新消息顶高流式卡片"""
|
|
228
|
+
if not self.client:
|
|
229
|
+
return False
|
|
230
|
+
|
|
231
|
+
import asyncio
|
|
232
|
+
from lark_oapi.api.im.v1 import UrgentAppMessageRequest, UrgentReceivers
|
|
233
|
+
|
|
234
|
+
try:
|
|
235
|
+
request = UrgentAppMessageRequest.builder() \
|
|
236
|
+
.message_id(message_id) \
|
|
237
|
+
.user_id_type("open_id") \
|
|
238
|
+
.request_body(
|
|
239
|
+
UrgentReceivers.builder()
|
|
240
|
+
.user_id_list(user_ids)
|
|
241
|
+
.build()
|
|
242
|
+
) \
|
|
243
|
+
.build()
|
|
244
|
+
|
|
245
|
+
response = await asyncio.to_thread(self.client.im.v1.message.urgent_app, request)
|
|
246
|
+
if response.success():
|
|
247
|
+
logger.info(f"加急通知成功: message_id={message_id}, users={user_ids}")
|
|
248
|
+
return True
|
|
249
|
+
else:
|
|
250
|
+
logger.warning(f"加急通知失败: code={response.code} msg={response.msg}")
|
|
251
|
+
return False
|
|
252
|
+
except Exception as e:
|
|
253
|
+
logger.error(f"加急通知异常: {e}")
|
|
254
|
+
return False
|
|
255
|
+
|
|
256
|
+
async def cancel_urgent_app(self, message_id: str, user_ids: list) -> bool:
|
|
257
|
+
"""取消已有消息的应用内加急通知"""
|
|
258
|
+
if not self.client:
|
|
259
|
+
return False
|
|
260
|
+
|
|
261
|
+
import asyncio
|
|
262
|
+
from lark_oapi.core.model import BaseRequest
|
|
263
|
+
from lark_oapi.core.enum import HttpMethod, AccessTokenType
|
|
264
|
+
|
|
265
|
+
try:
|
|
266
|
+
request = BaseRequest()
|
|
267
|
+
request.http_method = HttpMethod.POST
|
|
268
|
+
request.uri = "/open-apis/im/v2/urgent/batch_cancel"
|
|
269
|
+
request.token_types = {AccessTokenType.TENANT}
|
|
270
|
+
request.queries = [("user_id_type", "open_id")]
|
|
271
|
+
request.body = {"message_id": message_id, "receiver_user_ids": user_ids}
|
|
272
|
+
|
|
273
|
+
response = await asyncio.to_thread(self.client.request, request)
|
|
274
|
+
if response.success():
|
|
275
|
+
logger.info(f"取消加急成功: message_id={message_id}, code={response.code}")
|
|
276
|
+
return True
|
|
277
|
+
else:
|
|
278
|
+
logger.warning(f"取消加急失败: code={response.code} msg={response.msg}")
|
|
279
|
+
return False
|
|
280
|
+
except Exception as e:
|
|
281
|
+
logger.error(f"取消加急异常: {e}")
|
|
282
|
+
return False
|
|
283
|
+
|
|
284
|
+
async def send_text(self, chat_id: str, text: str) -> Optional[str]:
|
|
285
|
+
"""发送纯文本消息,返回 message_id(失败返回 None)"""
|
|
228
286
|
if not self.client:
|
|
229
287
|
print(f"[Lark] 消息: {text}")
|
|
230
|
-
return
|
|
288
|
+
return None
|
|
231
289
|
|
|
232
290
|
try:
|
|
233
291
|
import asyncio
|
|
@@ -247,10 +305,14 @@ class CardService:
|
|
|
247
305
|
self.client.im.v1.message.create, request
|
|
248
306
|
)
|
|
249
307
|
|
|
250
|
-
if
|
|
308
|
+
if response.success():
|
|
309
|
+
return getattr(getattr(response, "data", None), "message_id", None)
|
|
310
|
+
else:
|
|
251
311
|
logger.warning(f"发送文本失败: code={response.code} msg={response.msg}")
|
|
312
|
+
return None
|
|
252
313
|
except Exception as e:
|
|
253
314
|
logger.error(f"发送文本异常: {e}")
|
|
315
|
+
return None
|
|
254
316
|
|
|
255
317
|
# 管理活跃卡片的方法
|
|
256
318
|
def get_active_card(self, chat_id: str) -> Optional[CardState]:
|
|
@@ -115,7 +115,8 @@ class LarkHandler:
|
|
|
115
115
|
|
|
116
116
|
# ── 统一 attach / detach / on_disconnect ────────────────────────────────
|
|
117
117
|
|
|
118
|
-
async def _attach(self, chat_id: str, session_name: str
|
|
118
|
+
async def _attach(self, chat_id: str, session_name: str,
|
|
119
|
+
user_id: Optional[str] = None) -> bool:
|
|
119
120
|
"""统一 attach 逻辑(私聊/群聊共用)"""
|
|
120
121
|
# 在断开旧连接之前,先更新旧流式卡片为已断开状态
|
|
121
122
|
old_session = self._chat_sessions.get(chat_id)
|
|
@@ -138,7 +139,8 @@ class LarkHandler:
|
|
|
138
139
|
if await bridge.connect():
|
|
139
140
|
self._bridges[chat_id] = bridge
|
|
140
141
|
self._chat_sessions[chat_id] = session_name
|
|
141
|
-
self._poller.start(chat_id, session_name)
|
|
142
|
+
self._poller.start(chat_id, session_name, is_group=(chat_id in self._group_chat_ids),
|
|
143
|
+
notify_user_id=user_id)
|
|
142
144
|
_track_stats('lark', 'attach', session_name=session_name,
|
|
143
145
|
chat_id=chat_id)
|
|
144
146
|
return True
|
|
@@ -244,7 +246,7 @@ class LarkHandler:
|
|
|
244
246
|
)
|
|
245
247
|
return
|
|
246
248
|
|
|
247
|
-
ok = await self._attach(chat_id, session_name)
|
|
249
|
+
ok = await self._attach(chat_id, session_name, user_id=user_id)
|
|
248
250
|
if ok:
|
|
249
251
|
self._chat_bindings[chat_id] = session_name
|
|
250
252
|
self._save_chat_bindings()
|
|
@@ -374,7 +376,7 @@ class LarkHandler:
|
|
|
374
376
|
await card_service.send_text(chat_id, f"错误: 会话启动超时 (12s)\n\n{log_content}")
|
|
375
377
|
return
|
|
376
378
|
|
|
377
|
-
ok = await self._attach(chat_id, session_name)
|
|
379
|
+
ok = await self._attach(chat_id, session_name, user_id=user_id)
|
|
378
380
|
if ok:
|
|
379
381
|
self._chat_bindings[chat_id] = session_name
|
|
380
382
|
self._save_chat_bindings()
|
|
@@ -603,9 +605,25 @@ class LarkHandler:
|
|
|
603
605
|
for cid in self._group_chat_ids
|
|
604
606
|
if cid in self._chat_bindings
|
|
605
607
|
}
|
|
606
|
-
card = build_menu_card(sessions, current_session=current, session_groups=session_groups, page=page
|
|
608
|
+
card = build_menu_card(sessions, current_session=current, session_groups=session_groups, page=page,
|
|
609
|
+
notify_enabled=self._poller.get_notify_enabled(),
|
|
610
|
+
urgent_enabled=self._poller.get_urgent_enabled())
|
|
607
611
|
await self._send_or_update_card(chat_id, card, message_id)
|
|
608
612
|
|
|
613
|
+
async def _cmd_toggle_notify(self, user_id: str, chat_id: str,
|
|
614
|
+
message_id: Optional[str] = None):
|
|
615
|
+
"""切换就绪通知开关并刷新菜单卡片"""
|
|
616
|
+
new_value = not self._poller.get_notify_enabled()
|
|
617
|
+
self._poller.set_notify_enabled(new_value)
|
|
618
|
+
await self._cmd_menu(user_id, chat_id, message_id=message_id)
|
|
619
|
+
|
|
620
|
+
async def _cmd_toggle_urgent(self, user_id: str, chat_id: str,
|
|
621
|
+
message_id: Optional[str] = None):
|
|
622
|
+
"""切换加急通知开关并刷新菜单卡片"""
|
|
623
|
+
new_value = not self._poller.get_urgent_enabled()
|
|
624
|
+
self._poller.set_urgent_enabled(new_value)
|
|
625
|
+
await self._cmd_menu(user_id, chat_id, message_id=message_id)
|
|
626
|
+
|
|
609
627
|
async def _cmd_ls(self, user_id: str, chat_id: str, args: str,
|
|
610
628
|
tree: bool = False, message_id: Optional[str] = None, page: int = 0):
|
|
611
629
|
"""查看目录文件结构"""
|
|
@@ -715,7 +733,7 @@ class LarkHandler:
|
|
|
715
733
|
self._group_chat_ids.add(group_chat_id)
|
|
716
734
|
self._save_group_chat_ids()
|
|
717
735
|
# 立即 attach,让新群即刻开始接收 Claude 输出
|
|
718
|
-
await self._attach(group_chat_id, session_name)
|
|
736
|
+
await self._attach(group_chat_id, session_name, user_id=user_id)
|
|
719
737
|
|
|
720
738
|
# 刷新会话列表卡片,使"创建群聊"按钮变为"进入群聊"
|
|
721
739
|
await self._cmd_list(user_id, chat_id, message_id=message_id)
|
|
@@ -804,7 +822,7 @@ class LarkHandler:
|
|
|
804
822
|
saved_session = self._chat_bindings.get(chat_id)
|
|
805
823
|
if saved_session:
|
|
806
824
|
logger.info(f"自动恢复绑定: chat_id={chat_id[:8]}..., session={saved_session}")
|
|
807
|
-
ok = await self._attach(chat_id, saved_session)
|
|
825
|
+
ok = await self._attach(chat_id, saved_session, user_id=user_id)
|
|
808
826
|
if not ok:
|
|
809
827
|
self._group_chat_ids.discard(chat_id)
|
|
810
828
|
self._save_group_chat_ids()
|
package/lark_client/main.py
CHANGED
|
@@ -54,11 +54,12 @@ def _setup_logging():
|
|
|
54
54
|
for _noisy in ('urllib3', 'websockets', 'asyncio'):
|
|
55
55
|
logging.getLogger(_noisy).setLevel(logging.INFO)
|
|
56
56
|
|
|
57
|
-
#
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
57
|
+
# 控制台输出(仅在终端交互模式下启用,守护进程模式下 stderr 已重定向到日志文件)
|
|
58
|
+
if sys.stderr.isatty():
|
|
59
|
+
console_handler = logging.StreamHandler()
|
|
60
|
+
console_handler.setLevel(logging.INFO)
|
|
61
|
+
console_handler.setFormatter(formatter)
|
|
62
|
+
root_logger.addHandler(console_handler)
|
|
62
63
|
|
|
63
64
|
|
|
64
65
|
# 在导入 lark SDK 之前配置日志
|
|
@@ -305,6 +306,14 @@ def handle_card_action(event: P2CardActionTrigger) -> P2CardActionTriggerRespons
|
|
|
305
306
|
asyncio.create_task(_multi_send())
|
|
306
307
|
return None
|
|
307
308
|
|
|
309
|
+
if action_type == "menu_toggle_notify":
|
|
310
|
+
asyncio.create_task(handler._cmd_toggle_notify(user_id, chat_id, message_id=message_id))
|
|
311
|
+
return None
|
|
312
|
+
|
|
313
|
+
if action_type == "menu_toggle_urgent":
|
|
314
|
+
asyncio.create_task(handler._cmd_toggle_urgent(user_id, chat_id, message_id=message_id))
|
|
315
|
+
return None
|
|
316
|
+
|
|
308
317
|
# 各卡片底部菜单按钮:辅助卡片就地→菜单,流式卡片降级新卡
|
|
309
318
|
if action_type == "menu_open":
|
|
310
319
|
asyncio.create_task(handler._cmd_menu(user_id, chat_id, message_id=message_id))
|
|
@@ -11,7 +11,6 @@ import sys
|
|
|
11
11
|
from pathlib import Path
|
|
12
12
|
from typing import Optional, Callable, Dict
|
|
13
13
|
|
|
14
|
-
logging.basicConfig(level=logging.INFO, format='[%(name)s] %(message)s')
|
|
15
14
|
logger = logging.getLogger('SessionBridge')
|
|
16
15
|
|
|
17
16
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
@@ -35,6 +35,8 @@ try:
|
|
|
35
35
|
except Exception:
|
|
36
36
|
def _track_stats(*args, **kwargs): pass
|
|
37
37
|
|
|
38
|
+
from utils.session import ensure_user_data_dir, USER_DATA_DIR
|
|
39
|
+
|
|
38
40
|
# ── 常量 ──────────────────────────────────────────────────────────────────────
|
|
39
41
|
INITIAL_WINDOW = 30 # 首次 attach 最多显示最近 30 个 blocks
|
|
40
42
|
from .config import MAX_CARD_BLOCKS # 单张卡片最多 N 个 blocks → 超限冻结(可通过 .env 配置)
|
|
@@ -62,6 +64,10 @@ class StreamTracker:
|
|
|
62
64
|
cards: List[CardSlice] = field(default_factory=list)
|
|
63
65
|
content_hash: str = ""
|
|
64
66
|
reader: Optional[Any] = None # SharedStateReader,延迟初始化
|
|
67
|
+
is_group: bool = False # 是否为群聊
|
|
68
|
+
prev_is_ready: bool = True # 上一帧是否就绪(初始 True 避免首次误触发)
|
|
69
|
+
notify_user_id: Optional[str] = None # 就绪通知 @ 的用户 open_id
|
|
70
|
+
last_notify_message_id: Optional[str] = None # 上一条就绪通知的 message_id(用于后续加急复用)
|
|
65
71
|
|
|
66
72
|
|
|
67
73
|
# ── 轮询器 ────────────────────────────────────────────────────────────────────
|
|
@@ -81,11 +87,13 @@ class SharedMemoryPoller:
|
|
|
81
87
|
self._kick_events: Dict[str, asyncio.Event] = {} # chat_id → Event(唤醒轮询)
|
|
82
88
|
self._rapid_until: Dict[str, float] = {} # chat_id → 快速模式截止时间
|
|
83
89
|
|
|
84
|
-
def start(self, chat_id: str, session_name: str
|
|
90
|
+
def start(self, chat_id: str, session_name: str, is_group: bool = False,
|
|
91
|
+
notify_user_id: Optional[str] = None) -> None:
|
|
85
92
|
"""attach 成功后调用:清空旧状态,启动轮询 Task"""
|
|
86
93
|
self.stop(chat_id)
|
|
87
94
|
|
|
88
|
-
tracker = StreamTracker(chat_id=chat_id, session_name=session_name
|
|
95
|
+
tracker = StreamTracker(chat_id=chat_id, session_name=session_name, is_group=is_group,
|
|
96
|
+
notify_user_id=notify_user_id)
|
|
89
97
|
self._trackers[chat_id] = tracker
|
|
90
98
|
self._kick_events[chat_id] = asyncio.Event()
|
|
91
99
|
|
|
@@ -212,6 +220,9 @@ class SharedMemoryPoller:
|
|
|
212
220
|
option_block = state.get("option_block")
|
|
213
221
|
cli_type = state.get("cli_type", "claude")
|
|
214
222
|
|
|
223
|
+
# 就绪状态转换检测
|
|
224
|
+
await self._check_ready_notification(tracker, blocks, status_line, option_block, cli_type)
|
|
225
|
+
|
|
215
226
|
# 获取活跃卡片(最后一张且未冻结)
|
|
216
227
|
active = None
|
|
217
228
|
if tracker.cards and not tracker.cards[-1].frozen:
|
|
@@ -415,6 +426,84 @@ class SharedMemoryPoller:
|
|
|
415
426
|
f"blocks={len(new_blocks)} card_id={new_card_id}"
|
|
416
427
|
)
|
|
417
428
|
|
|
429
|
+
async def _check_ready_notification(
|
|
430
|
+
self, tracker: StreamTracker,
|
|
431
|
+
blocks: list, status_line: Optional[dict], option_block: Optional[dict],
|
|
432
|
+
cli_type: str = "claude"
|
|
433
|
+
) -> None:
|
|
434
|
+
"""检测就绪状态转换(忙碌→就绪),群聊中发送 @所有人 提醒"""
|
|
435
|
+
current_ready = _is_ready(blocks, status_line, option_block)
|
|
436
|
+
prev_ready = tracker.prev_is_ready
|
|
437
|
+
tracker.prev_is_ready = current_ready
|
|
438
|
+
|
|
439
|
+
if current_ready and not prev_ready and tracker.is_group and _notify_enabled:
|
|
440
|
+
count = _increment_ready_count()
|
|
441
|
+
uid = tracker.notify_user_id or "all"
|
|
442
|
+
cli_name = "Claude" if cli_type == "claude" else "Codex"
|
|
443
|
+
logger.info(f"就绪提醒: chat_id={tracker.chat_id[:8]}..., count={count}, uid={uid}, "
|
|
444
|
+
f"last_msg={'有' if tracker.last_notify_message_id else '无'}")
|
|
445
|
+
|
|
446
|
+
if tracker.last_notify_message_id and uid != "all" and _urgent_enabled:
|
|
447
|
+
# 已有通知消息 + 加急开关开启 → 尝试加急
|
|
448
|
+
try:
|
|
449
|
+
ok = await self._card_service.send_urgent_app(
|
|
450
|
+
tracker.last_notify_message_id, [uid]
|
|
451
|
+
)
|
|
452
|
+
if ok:
|
|
453
|
+
# 加急成功 → 15 秒后自动取消
|
|
454
|
+
asyncio.create_task(self._cancel_urgent_later(
|
|
455
|
+
tracker.last_notify_message_id, [uid], delay=5
|
|
456
|
+
))
|
|
457
|
+
else:
|
|
458
|
+
# 加急失败(权限未开通等)→ 降级发新消息
|
|
459
|
+
label = ""
|
|
460
|
+
text = f'<at user_id="{uid}">{label}</at> {cli_name} 已就绪,等待您的输入...(这是第{count}次通知)'
|
|
461
|
+
msg_id = await self._card_service.send_text(tracker.chat_id, text)
|
|
462
|
+
if msg_id:
|
|
463
|
+
tracker.last_notify_message_id = msg_id
|
|
464
|
+
except Exception as e:
|
|
465
|
+
logger.warning(f"加急通知失败: {e}")
|
|
466
|
+
else:
|
|
467
|
+
# 首次通知(或无法加急时)→ 发新消息,记录 message_id
|
|
468
|
+
label = "所有人" if uid == "all" else ""
|
|
469
|
+
text = f'<at user_id="{uid}">{label}</at> {cli_name} 已就绪,等待您的输入...(这是第{count}次通知)'
|
|
470
|
+
try:
|
|
471
|
+
msg_id = await self._card_service.send_text(tracker.chat_id, text)
|
|
472
|
+
if msg_id:
|
|
473
|
+
tracker.last_notify_message_id = msg_id
|
|
474
|
+
except Exception as e:
|
|
475
|
+
logger.warning(f"就绪提醒发送失败: {e}")
|
|
476
|
+
|
|
477
|
+
async def _cancel_urgent_later(self, message_id: str, user_ids: list, delay: float = 15) -> None:
|
|
478
|
+
"""延迟取消加急通知"""
|
|
479
|
+
await asyncio.sleep(delay)
|
|
480
|
+
try:
|
|
481
|
+
await self._card_service.cancel_urgent_app(message_id, user_ids)
|
|
482
|
+
except Exception as e:
|
|
483
|
+
logger.warning(f"延迟取消加急失败: {e}")
|
|
484
|
+
|
|
485
|
+
def get_notify_enabled(self) -> bool:
|
|
486
|
+
"""获取就绪通知开关状态"""
|
|
487
|
+
return _notify_enabled
|
|
488
|
+
|
|
489
|
+
def set_notify_enabled(self, enabled: bool) -> None:
|
|
490
|
+
"""更新就绪通知开关状态并持久化"""
|
|
491
|
+
global _notify_enabled
|
|
492
|
+
_notify_enabled = enabled
|
|
493
|
+
_save_notify_enabled(enabled)
|
|
494
|
+
logger.info(f"就绪通知开关已{'开启' if enabled else '关闭'}")
|
|
495
|
+
|
|
496
|
+
def get_urgent_enabled(self) -> bool:
|
|
497
|
+
"""获取加急通知开关状态"""
|
|
498
|
+
return _urgent_enabled
|
|
499
|
+
|
|
500
|
+
def set_urgent_enabled(self, enabled: bool) -> None:
|
|
501
|
+
"""更新加急通知开关状态并持久化"""
|
|
502
|
+
global _urgent_enabled
|
|
503
|
+
_urgent_enabled = enabled
|
|
504
|
+
_save_urgent_enabled(enabled)
|
|
505
|
+
logger.info(f"加急通知开关已{'开启' if enabled else '关闭'}")
|
|
506
|
+
|
|
418
507
|
@staticmethod
|
|
419
508
|
def _compute_hash(
|
|
420
509
|
blocks: list, status_line: Optional[dict],
|
|
@@ -432,3 +521,71 @@ class SharedMemoryPoller:
|
|
|
432
521
|
return hashlib.md5(
|
|
433
522
|
json.dumps(data, ensure_ascii=False, sort_keys=True).encode()
|
|
434
523
|
).hexdigest()
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
# ── 模块级辅助函数 ────────────────────────────────────────────────────────────
|
|
527
|
+
|
|
528
|
+
def _is_ready(blocks: list, status_line: Optional[dict], option_block: Optional[dict]) -> bool:
|
|
529
|
+
"""数据层就绪判断:无 streaming block、无 status_line(option_block 不影响就绪)"""
|
|
530
|
+
has_streaming = any(b.get("is_streaming", False) for b in blocks)
|
|
531
|
+
return not has_streaming and status_line is None
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
_READY_COUNT_FILE = USER_DATA_DIR / "ready_notify_count"
|
|
535
|
+
_NOTIFY_ENABLED_FILE = USER_DATA_DIR / "ready_notify_enabled"
|
|
536
|
+
_URGENT_ENABLED_FILE = USER_DATA_DIR / "urgent_notify_enabled"
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def _load_notify_enabled() -> bool:
|
|
540
|
+
"""读取就绪通知开关状态,不存在或解析失败返回 True(默认开启)"""
|
|
541
|
+
try:
|
|
542
|
+
return _NOTIFY_ENABLED_FILE.read_text().strip() == "1"
|
|
543
|
+
except Exception:
|
|
544
|
+
return True
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def _save_notify_enabled(enabled: bool) -> None:
|
|
548
|
+
"""持久化就绪通知开关状态"""
|
|
549
|
+
try:
|
|
550
|
+
ensure_user_data_dir()
|
|
551
|
+
_NOTIFY_ENABLED_FILE.write_text("1" if enabled else "0")
|
|
552
|
+
except Exception as e:
|
|
553
|
+
logger.warning(f"_save_notify_enabled 失败: {e}")
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def _load_urgent_enabled() -> bool:
|
|
557
|
+
"""读取加急通知开关状态,不存在或解析失败返回 False(默认关闭)"""
|
|
558
|
+
try:
|
|
559
|
+
return _URGENT_ENABLED_FILE.read_text().strip() == "1"
|
|
560
|
+
except Exception:
|
|
561
|
+
return False
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def _save_urgent_enabled(enabled: bool) -> None:
|
|
565
|
+
"""持久化加急通知开关状态"""
|
|
566
|
+
try:
|
|
567
|
+
ensure_user_data_dir()
|
|
568
|
+
_URGENT_ENABLED_FILE.write_text("1" if enabled else "0")
|
|
569
|
+
except Exception as e:
|
|
570
|
+
logger.warning(f"_save_urgent_enabled 失败: {e}")
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
# 模块级开关状态:启动时加载一次
|
|
574
|
+
_notify_enabled: bool = _load_notify_enabled()
|
|
575
|
+
_urgent_enabled: bool = _load_urgent_enabled()
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def _increment_ready_count() -> int:
|
|
579
|
+
"""原子递增全局就绪提醒计数器,返回新值(持久化到文件)"""
|
|
580
|
+
try:
|
|
581
|
+
ensure_user_data_dir()
|
|
582
|
+
try:
|
|
583
|
+
count = int(_READY_COUNT_FILE.read_text().strip())
|
|
584
|
+
except Exception:
|
|
585
|
+
count = 0
|
|
586
|
+
count += 1
|
|
587
|
+
_READY_COUNT_FILE.write_text(str(count))
|
|
588
|
+
return count
|
|
589
|
+
except Exception as e:
|
|
590
|
+
logger.warning(f"_increment_ready_count 失败: {e}")
|
|
591
|
+
return 1
|
package/package.json
CHANGED
|
@@ -909,22 +909,43 @@ class ClaudeParser(BaseParser):
|
|
|
909
909
|
return None
|
|
910
910
|
|
|
911
911
|
def _extract_input_area_text(self, screen: pyte.Screen, input_rows: List[int]) -> str:
|
|
912
|
-
"""提取输入区 ❯
|
|
913
|
-
|
|
912
|
+
"""提取输入区 ❯ 提示符后的当前输入文本(空提示符返回空字符串)。
|
|
913
|
+
多行输入时,收集 ❯ 行之后首列为空的续行,合并为完整文本。
|
|
914
|
+
"""
|
|
915
|
+
for i, row in enumerate(input_rows):
|
|
914
916
|
if _get_col0(screen, row) == '❯':
|
|
915
917
|
text = _get_row_text(screen, row)[1:].strip()
|
|
916
918
|
# 排除纯分割线装饰行(如 ❯─────)
|
|
917
919
|
if text and not all(c in DIVIDER_CHARS for c in text):
|
|
918
|
-
|
|
920
|
+
# 收集续行(首列为空的非空文本行)
|
|
921
|
+
lines = [text]
|
|
922
|
+
for next_row in input_rows[i + 1:]:
|
|
923
|
+
col0 = _get_col0(screen, next_row)
|
|
924
|
+
if col0.strip(): # 首列有字符,遇到新 block 或新 ❯ 行,停止
|
|
925
|
+
break
|
|
926
|
+
next_text = _get_row_text(screen, next_row).strip()
|
|
927
|
+
if next_text:
|
|
928
|
+
lines.append(next_text)
|
|
929
|
+
return '\n'.join(lines)
|
|
919
930
|
return ''
|
|
920
931
|
|
|
921
932
|
def _extract_input_area_ansi_text(self, screen: pyte.Screen, input_rows: List[int]) -> str:
|
|
922
|
-
"""提取输入区 ❯ 提示符后的当前输入文本(ANSI
|
|
923
|
-
|
|
933
|
+
"""提取输入区 ❯ 提示符后的当前输入文本(ANSI 版本)。
|
|
934
|
+
多行输入时,收集 ❯ 行之后首列为空的续行,合并为完整文本。
|
|
935
|
+
"""
|
|
936
|
+
for i, row in enumerate(input_rows):
|
|
924
937
|
if _get_col0(screen, row) == '❯':
|
|
925
938
|
text = _get_row_text(screen, row)[1:].strip()
|
|
926
939
|
if text and not all(c in DIVIDER_CHARS for c in text):
|
|
927
|
-
|
|
940
|
+
lines = [_get_row_ansi_text(screen, row, start_col=1).strip()]
|
|
941
|
+
for next_row in input_rows[i + 1:]:
|
|
942
|
+
col0 = _get_col0(screen, next_row)
|
|
943
|
+
if col0.strip(): # 首列有字符,停止
|
|
944
|
+
break
|
|
945
|
+
next_text = _get_row_text(screen, next_row).strip()
|
|
946
|
+
if next_text:
|
|
947
|
+
lines.append(_get_row_ansi_text(screen, next_row).strip())
|
|
948
|
+
return '\n'.join(lines)
|
|
928
949
|
return ''
|
|
929
950
|
|
|
930
951
|
def _parse_permission_area(
|
|
@@ -1,14 +1,11 @@
|
|
|
1
1
|
"""Codex CLI 输出组件解析器
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
-
|
|
7
|
-
-
|
|
8
|
-
-
|
|
9
|
-
- 分割线识别规则
|
|
10
|
-
- 欢迎框检测(_trim_welcome 中的 'Claude Code' 字符串)
|
|
11
|
-
- 选项/权限交互格式
|
|
3
|
+
独立解析器,专门处理 Codex CLI 的终端输出格式。与 ClaudeParser 的关键差异:
|
|
4
|
+
- STAR_CHARS:不使用星星字符,StatusLine 改为 DOT_CHARS blink 检测
|
|
5
|
+
- 用户输入指示符:`›` (U+203A) / `>` (U+003E),与 ClaudeParser 的 `❯` 不同
|
|
6
|
+
- 区域分割:无 ─━═ 字符分割线,用背景色区域(连续 bg 行 + 首尾纯背景色边界)识别输入区域
|
|
7
|
+
- 欢迎框:`>_ OpenAI Codex` + `model:` + `directory:` 特征,无固定行号
|
|
8
|
+
- 选项交互:编号选项 + Enter/Esc 导航提示,通过提示符颜色和上方签名区分普通/选项模式
|
|
12
9
|
"""
|
|
13
10
|
|
|
14
11
|
import re
|
|
@@ -35,6 +32,7 @@ STAR_CHARS: Set[str] = set(
|
|
|
35
32
|
'⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
|
|
36
33
|
'△' # Codex 系统警告(不闪烁 → SystemBlock)
|
|
37
34
|
'⚠' # Codex 警告(不闪烁 → SystemBlock)
|
|
35
|
+
'■' # Codex 错误提示(不闪烁 → SystemBlock)
|
|
38
36
|
)
|
|
39
37
|
|
|
40
38
|
# 圆点字符集(OutputBlock 首列)
|
|
@@ -111,9 +109,9 @@ def _strip_inline_boxes_pair(content: str, ansi_content: str) -> tuple:
|
|
|
111
109
|
|
|
112
110
|
|
|
113
111
|
# 编号选项行正则(权限确认对话框特征:> 1. Yes / 2. No 等)
|
|
114
|
-
_NUMBERED_OPTION_RE = re.compile(r'^(
|
|
115
|
-
# 带 > 光标的编号选项行正则(锚点)
|
|
116
|
-
_CURSOR_OPTION_RE = re.compile(r'
|
|
112
|
+
_NUMBERED_OPTION_RE = re.compile(r'^(?:[>❯›]\s*)?\d+[.)]\s+.+')
|
|
113
|
+
# 带 > / ❯ / › 光标的编号选项行正则(锚点)
|
|
114
|
+
_CURSOR_OPTION_RE = re.compile(r'^[>❯›]\s*(\d+)[.)]\s+.+')
|
|
117
115
|
|
|
118
116
|
# Codex 状态行检测(首列 ● blink=True + 内容含 "esc to interrupt")
|
|
119
117
|
|
|
@@ -197,7 +195,7 @@ def _get_row_ansi_text(screen: pyte.Screen, row: int, start_col: int = 0) -> str
|
|
|
197
195
|
"""提取指定行带 ANSI 转义码的文本。
|
|
198
196
|
|
|
199
197
|
先确定有效列范围(与 _get_row_text 的 rstrip 等价),仅在有效范围内生成 ANSI 码。
|
|
200
|
-
start_col
|
|
198
|
+
start_col 用于跳过首列特殊字符(圆点/星星/›)。
|
|
201
199
|
"""
|
|
202
200
|
buf_row = screen.buffer[row]
|
|
203
201
|
|
|
@@ -293,36 +291,42 @@ def _is_bright_color(color: str) -> bool:
|
|
|
293
291
|
return True
|
|
294
292
|
|
|
295
293
|
|
|
296
|
-
def
|
|
297
|
-
"""
|
|
298
|
-
|
|
299
|
-
条件:
|
|
300
|
-
1. 行首是 ›(CODEX_PROMPT_CHARS)
|
|
301
|
-
2. 首字符前景色是亮色(非 default 且非暗色)
|
|
302
|
-
3. 上一行、当前行、下一行都有整行背景色(严格检查,缺一行则失败)
|
|
303
|
-
"""
|
|
304
|
-
# 检查行首是 ›
|
|
305
|
-
if _get_col0(screen, row) not in CODEX_PROMPT_CHARS:
|
|
294
|
+
def _is_white_color(color: str) -> bool:
|
|
295
|
+
"""判断颜色是否为白色/亮白色"""
|
|
296
|
+
if not color or color == 'default':
|
|
306
297
|
return False
|
|
298
|
+
key = color.lower().replace(' ', '').replace('-', '')
|
|
299
|
+
if key in ('white', 'brightwhite'):
|
|
300
|
+
return True
|
|
301
|
+
# hex 颜色:R/G/B 都 > 200
|
|
302
|
+
if len(color) == 6:
|
|
303
|
+
try:
|
|
304
|
+
r, g, b = int(color[0:2], 16), int(color[2:4], 16), int(color[4:6], 16)
|
|
305
|
+
return r > 200 and g > 200 and b > 200
|
|
306
|
+
except ValueError:
|
|
307
|
+
pass
|
|
308
|
+
return False
|
|
307
309
|
|
|
308
|
-
# 检查首字符前景色是亮色
|
|
309
|
-
try:
|
|
310
|
-
char = screen.buffer[row][0]
|
|
311
|
-
fg = getattr(char, 'fg', 'default') or 'default'
|
|
312
|
-
if not _is_bright_color(fg):
|
|
313
|
-
return False # 前景色不是亮色(可能是暗色或 default)
|
|
314
|
-
except (KeyError, IndexError):
|
|
315
|
-
return False
|
|
316
310
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
if row == 0 or not _has_full_row_bg(screen, row - 1):
|
|
321
|
-
return False
|
|
322
|
-
if row >= screen.lines - 1 or not _has_full_row_bg(screen, row + 1):
|
|
311
|
+
def _is_light_blue_color(color: str) -> bool:
|
|
312
|
+
"""判断颜色是否为浅蓝色/青色"""
|
|
313
|
+
if not color or color == 'default':
|
|
323
314
|
return False
|
|
324
|
-
|
|
325
|
-
|
|
315
|
+
key = color.lower().replace(' ', '').replace('-', '')
|
|
316
|
+
if key in ('cyan', 'brightcyan', 'brightblue'):
|
|
317
|
+
return True
|
|
318
|
+
# hex 颜色:偏蓝(B > R 且 B > G 且整体较亮)
|
|
319
|
+
if len(color) == 6:
|
|
320
|
+
try:
|
|
321
|
+
r, g, b = int(color[0:2], 16), int(color[2:4], 16), int(color[4:6], 16)
|
|
322
|
+
if b > r and b > g and (r + g + b) > 200:
|
|
323
|
+
return True
|
|
324
|
+
# 青色:G 和 B 都高
|
|
325
|
+
if g > 150 and b > 150 and r < 150:
|
|
326
|
+
return True
|
|
327
|
+
except ValueError:
|
|
328
|
+
pass
|
|
329
|
+
return False
|
|
326
330
|
|
|
327
331
|
|
|
328
332
|
def _get_row_text(screen: pyte.Screen, row: int) -> str:
|
|
@@ -351,26 +355,17 @@ def _get_col0_blink(screen: pyte.Screen, row: int) -> bool:
|
|
|
351
355
|
return False
|
|
352
356
|
|
|
353
357
|
|
|
354
|
-
def _is_divider_row(screen: pyte.Screen, row: int) -> bool:
|
|
355
|
-
"""判断整行是否为分割线:所有非空字符均为分割线字符,不限制行长度"""
|
|
356
|
-
found = False
|
|
357
|
-
for col in range(screen.columns):
|
|
358
|
-
c = screen.buffer[row][col].data
|
|
359
|
-
if not c.strip():
|
|
360
|
-
continue
|
|
361
|
-
if c not in DIVIDER_CHARS:
|
|
362
|
-
return False # 发现非分割线字符,立即短路
|
|
363
|
-
found = True
|
|
364
|
-
return found
|
|
365
358
|
|
|
359
|
+
def _is_pure_bg_row(screen: pyte.Screen, row: int) -> bool:
|
|
360
|
+
"""纯背景色行:整行有非默认背景色但无文字内容(作为背景色区域的边界标记)。
|
|
366
361
|
|
|
367
|
-
|
|
368
|
-
|
|
362
|
+
等同于 Claude Code 的 ─━═ 字符分割线,但以背景色实现。
|
|
363
|
+
与底部栏区别:底部栏有文字内容,纯背景色行只有 bg 色的空格。
|
|
369
364
|
|
|
370
|
-
|
|
371
|
-
|
|
365
|
+
注意:使用 _has_row_bg(检测所有列含空格)而非 _get_row_dominant_bg
|
|
366
|
+
(只统计非空格字符),确保纯空格背景行能被正确识别。
|
|
372
367
|
"""
|
|
373
|
-
if
|
|
368
|
+
if not _has_row_bg(screen, row):
|
|
374
369
|
return False
|
|
375
370
|
return _get_row_text(screen, row).strip() == ''
|
|
376
371
|
|
|
@@ -471,7 +466,7 @@ def _find_contiguous_options(lines, nav_re):
|
|
|
471
466
|
line = lines[i]
|
|
472
467
|
if not line or nav_re.search(line):
|
|
473
468
|
continue
|
|
474
|
-
m = re.match(r'^(
|
|
469
|
+
m = re.match(r'^(?:[>❯›]\s*)?(\d+)[.)]\s+', line)
|
|
475
470
|
if m:
|
|
476
471
|
if int(m.group(1)) == expected:
|
|
477
472
|
first_option_idx = i
|
|
@@ -487,7 +482,7 @@ def _find_contiguous_options(lines, nav_re):
|
|
|
487
482
|
line = lines[i]
|
|
488
483
|
if not line or nav_re.search(line):
|
|
489
484
|
continue
|
|
490
|
-
m = re.match(r'^(
|
|
485
|
+
m = re.match(r'^(?:[>❯›]\s*)?(\d+)[.)]\s+', line)
|
|
491
486
|
if m:
|
|
492
487
|
if int(m.group(1)) == expected:
|
|
493
488
|
last_option_idx = i
|
|
@@ -512,13 +507,15 @@ class CodexParser(BaseParser):
|
|
|
512
507
|
# 星号滑动窗口(1.5秒):记录每行最近 1.5 秒内出现的 (timestamp, char),
|
|
513
508
|
# 窗口内 ≥2 种不同字符 → spinner 旋转 → StatusLine;始终只有 1 种字符 → SystemBlock
|
|
514
509
|
self._star_row_history: Dict[int, deque] = {}
|
|
515
|
-
# 最近一次解析到的输入区
|
|
510
|
+
# 最近一次解析到的输入区 › 文本(用于 MessageQueue 追踪变更)
|
|
516
511
|
self.last_input_text: str = ''
|
|
517
512
|
self.last_input_ansi_text: str = ''
|
|
518
513
|
# 最近一次 parse 的内部耗时(供外部写日志用)
|
|
519
514
|
self.last_parse_timing: str = ''
|
|
520
515
|
# 布局模式:"normal" | "option" | "detail" | "agent_list" | "agent_detail"
|
|
521
516
|
self.last_layout_mode: str = 'normal'
|
|
517
|
+
# Pass 1 区域切分确定的布局模式(None 表示 Pass 1 未成功)
|
|
518
|
+
self._pass1_mode: Optional[str] = None
|
|
522
519
|
|
|
523
520
|
def parse(self, screen: pyte.Screen) -> List[Component]:
|
|
524
521
|
"""解析 pyte 屏幕,返回组件列表"""
|
|
@@ -529,11 +526,13 @@ class CodexParser(BaseParser):
|
|
|
529
526
|
output_rows, input_rows, bottom_rows = self._split_regions(screen)
|
|
530
527
|
_t1 = _time.perf_counter()
|
|
531
528
|
|
|
532
|
-
#
|
|
529
|
+
# 布局模式判定(优先使用 Pass 1 的结果)
|
|
533
530
|
prev_mode = self.last_layout_mode
|
|
534
|
-
if
|
|
531
|
+
if self._pass1_mode is not None:
|
|
532
|
+
self.last_layout_mode = self._pass1_mode
|
|
533
|
+
elif input_rows:
|
|
535
534
|
if _has_numbered_options(screen, input_rows):
|
|
536
|
-
self.last_layout_mode = 'option' #
|
|
535
|
+
self.last_layout_mode = 'option' # 回退路径:编号选项检测
|
|
537
536
|
else:
|
|
538
537
|
self.last_layout_mode = 'normal'
|
|
539
538
|
elif bottom_rows:
|
|
@@ -561,7 +560,7 @@ class CodexParser(BaseParser):
|
|
|
561
560
|
self._dot_attr_cache.clear()
|
|
562
561
|
self._star_row_history.clear()
|
|
563
562
|
|
|
564
|
-
# 提取输入区
|
|
563
|
+
# 提取输入区 › 文本(用于 MessageQueue 追踪变更)
|
|
565
564
|
self.last_input_text = self._extract_input_area_text(screen, input_rows)
|
|
566
565
|
self.last_input_ansi_text = self._extract_input_area_ansi_text(screen, input_rows)
|
|
567
566
|
|
|
@@ -611,7 +610,7 @@ class CodexParser(BaseParser):
|
|
|
611
610
|
if text:
|
|
612
611
|
bottom_parts.append(text)
|
|
613
612
|
ansi_bottom_parts.append(_get_row_ansi_text(screen, r).strip())
|
|
614
|
-
if bottom_parts and not
|
|
613
|
+
if bottom_parts and not agent_panel:
|
|
615
614
|
bar_text = '\n'.join(bottom_parts)
|
|
616
615
|
bar_ansi = '\n'.join(ansi_bottom_parts)
|
|
617
616
|
has_agents, agent_count, agent_summary = _parse_bottom_bar_agents(bar_text)
|
|
@@ -637,58 +636,47 @@ class CodexParser(BaseParser):
|
|
|
637
636
|
def _split_regions(
|
|
638
637
|
self, screen: pyte.Screen
|
|
639
638
|
) -> Tuple[List[int], List[int], List[int]]:
|
|
640
|
-
"""Codex 无 ─━═
|
|
639
|
+
"""Codex 无 ─━═ 分割线,用背景色区域(连续 bg 行 + 首尾纯背景色边界)定位输入区域。
|
|
641
640
|
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
[输出区] / [bg divider] / [› 输入行] / [bg divider] / [底部栏]
|
|
641
|
+
背景色区域:连续 3 行以上都有背景色,且第一行和最后一行是纯背景色(无文字),
|
|
642
|
+
整个区域即为输入区域。
|
|
645
643
|
|
|
646
644
|
优先级:
|
|
647
|
-
1.
|
|
648
|
-
2.
|
|
649
|
-
|
|
650
|
-
- 首字符前景色是亮色(非 default 且非暗色)
|
|
651
|
-
- 上一行、当前行、下一行都有整行背景色(严格检查)
|
|
652
|
-
3. 位置弱信号(回退):找最后一个其后无 block 字符的 › 行
|
|
645
|
+
1. 背景色区域(强):_find_bg_region 找连续 bg zone 内首尾纯背景色边界对
|
|
646
|
+
2. 宽松亮色 › 检测(回退):只检查行首字符和前景色
|
|
647
|
+
3. 位置弱信号:找最后一个其后无 block 字符的 › 行
|
|
653
648
|
4. 纯背景色兜底:无 › 时用 _find_chrome_boundary
|
|
654
649
|
"""
|
|
655
650
|
scan_limit = min(screen.cursor.y + 5, screen.lines - 1)
|
|
656
651
|
_BLOCK_CHARS = DOT_CHARS | CODEX_PROMPT_CHARS | STAR_CHARS
|
|
657
652
|
|
|
658
|
-
# Pass 1
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
output_rows = self._trim_welcome(screen, list(range(
|
|
668
|
-
input_rows =
|
|
669
|
-
bottom_rows = list(range(
|
|
653
|
+
# Pass 1:找"背景色区域"(连续 bg zone 内首尾纯背景色边界对,区域 >= 3 行)
|
|
654
|
+
self._pass1_mode = None
|
|
655
|
+
bg_region = self._find_bg_region(screen, scan_limit)
|
|
656
|
+
if bg_region is not None:
|
|
657
|
+
region_start, region_end = bg_region
|
|
658
|
+
content_rows = list(range(region_start + 1, region_end))
|
|
659
|
+
# 确定布局模式
|
|
660
|
+
mode = self._determine_input_mode(screen, region_start, content_rows)
|
|
661
|
+
self._pass1_mode = mode
|
|
662
|
+
output_rows = self._trim_welcome(screen, list(range(region_start)))
|
|
663
|
+
input_rows = content_rows
|
|
664
|
+
bottom_rows = list(range(region_end + 1, scan_limit + 1))
|
|
670
665
|
return output_rows, input_rows, bottom_rows
|
|
671
666
|
|
|
672
|
-
# Pass 2
|
|
667
|
+
# Pass 2:宽松检测亮起的 › 行(只检查行首字符和前景色,不检查背景色)
|
|
673
668
|
input_boundary = None
|
|
674
669
|
for row in range(scan_limit, -1, -1):
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
char = screen.buffer[row][0]
|
|
686
|
-
fg = getattr(char, 'fg', 'default') or 'default'
|
|
687
|
-
if _is_bright_color(fg):
|
|
688
|
-
input_boundary = row
|
|
689
|
-
break
|
|
690
|
-
except (KeyError, IndexError):
|
|
691
|
-
pass
|
|
670
|
+
col0 = _get_col0(screen, row)
|
|
671
|
+
if col0 in CODEX_PROMPT_CHARS:
|
|
672
|
+
try:
|
|
673
|
+
char = screen.buffer[row][0]
|
|
674
|
+
fg = getattr(char, 'fg', 'default') or 'default'
|
|
675
|
+
if _is_bright_color(fg):
|
|
676
|
+
input_boundary = row
|
|
677
|
+
break
|
|
678
|
+
except (KeyError, IndexError):
|
|
679
|
+
pass
|
|
692
680
|
|
|
693
681
|
# Pass 3:位置弱信号
|
|
694
682
|
if input_boundary is None:
|
|
@@ -719,6 +707,141 @@ class CodexParser(BaseParser):
|
|
|
719
707
|
output_rows = self._trim_welcome(screen, list(range(scan_limit + 1)))
|
|
720
708
|
return output_rows, [], []
|
|
721
709
|
|
|
710
|
+
def _find_bg_region(
|
|
711
|
+
self, screen: pyte.Screen, scan_limit: int
|
|
712
|
+
) -> Optional[Tuple[int, int]]:
|
|
713
|
+
"""在连续 bg zone 内找背景色区域(首尾纯背景色边界对,区域 >= 3 行)。
|
|
714
|
+
|
|
715
|
+
算法:
|
|
716
|
+
1. 从 scan_limit 往上扫描找连续 bg 行 zone(用 _has_row_bg)
|
|
717
|
+
2. zone 内找所有纯背景色行(用 _is_pure_bg_row)
|
|
718
|
+
3. 取首条和末条纯背景色行作为边界对
|
|
719
|
+
4. 验证 region_end - region_start >= 2(至少 3 行:边界 + 内容行)
|
|
720
|
+
|
|
721
|
+
Returns: (region_start, region_end) 含边界行,或 None
|
|
722
|
+
"""
|
|
723
|
+
zone_end: Optional[int] = None
|
|
724
|
+
zone_start: Optional[int] = None
|
|
725
|
+
for row in range(scan_limit, -1, -1):
|
|
726
|
+
if _has_row_bg(screen, row):
|
|
727
|
+
if zone_end is None:
|
|
728
|
+
zone_end = row
|
|
729
|
+
zone_start = row
|
|
730
|
+
elif zone_end is not None:
|
|
731
|
+
break # 遇到无 bg 行,zone 边界确定
|
|
732
|
+
|
|
733
|
+
if zone_start is None or zone_end is None:
|
|
734
|
+
return None
|
|
735
|
+
|
|
736
|
+
# 在 zone 内找所有纯背景色行(背景色区域边界候选)
|
|
737
|
+
pure_bg_rows = [r for r in range(zone_start, zone_end + 1)
|
|
738
|
+
if _is_pure_bg_row(screen, r)]
|
|
739
|
+
if len(pure_bg_rows) < 2:
|
|
740
|
+
return None
|
|
741
|
+
|
|
742
|
+
region_start, region_end = pure_bg_rows[0], pure_bg_rows[-1]
|
|
743
|
+
# 区域至少 3 行(边界对 + 至少 1 行内容)
|
|
744
|
+
if region_end - region_start < 2:
|
|
745
|
+
return None
|
|
746
|
+
|
|
747
|
+
return region_start, region_end
|
|
748
|
+
|
|
749
|
+
def _determine_input_mode(
|
|
750
|
+
self, screen: pyte.Screen, region_start: int, content_rows: List[int]
|
|
751
|
+
) -> str:
|
|
752
|
+
"""判断背景色区域的布局模式:'option' 或 'normal'。
|
|
753
|
+
|
|
754
|
+
判断优先级:
|
|
755
|
+
1. 条件 4b:首个内容行行首是 ›,白色/亮色 → 'normal'
|
|
756
|
+
2. 条件 4a:上方依次是空行+分割线+空行 → 'option'
|
|
757
|
+
3. 条件 4c:内容行中有浅蓝色 › 且整行同色 → 'option'
|
|
758
|
+
4. 兜底:_has_numbered_options → 'option',否则 'normal'
|
|
759
|
+
"""
|
|
760
|
+
if not content_rows:
|
|
761
|
+
return 'normal'
|
|
762
|
+
|
|
763
|
+
# 条件 4b:首个内容行行首是 › + 白色/亮色 → normal
|
|
764
|
+
first_content = content_rows[0]
|
|
765
|
+
col0 = _get_col0(screen, first_content)
|
|
766
|
+
if col0 in CODEX_PROMPT_CHARS:
|
|
767
|
+
try:
|
|
768
|
+
char = screen.buffer[first_content][0]
|
|
769
|
+
fg = getattr(char, 'fg', 'default') or 'default'
|
|
770
|
+
if _is_white_color(fg) or _is_bright_color(fg):
|
|
771
|
+
return 'normal'
|
|
772
|
+
except (KeyError, IndexError):
|
|
773
|
+
pass
|
|
774
|
+
|
|
775
|
+
# 条件 4a:上方有空行+分割线+空行 → option
|
|
776
|
+
if self._has_option_context_above(screen, region_start):
|
|
777
|
+
return 'option'
|
|
778
|
+
|
|
779
|
+
# 条件 4c:内容行中有浅蓝色 › 且整行同色 → option
|
|
780
|
+
for row in content_rows:
|
|
781
|
+
col0 = _get_col0(screen, row)
|
|
782
|
+
if col0 in CODEX_PROMPT_CHARS:
|
|
783
|
+
try:
|
|
784
|
+
char = screen.buffer[row][0]
|
|
785
|
+
fg = getattr(char, 'fg', 'default') or 'default'
|
|
786
|
+
if _is_light_blue_color(fg) and self._is_whole_row_same_fg(screen, row, fg):
|
|
787
|
+
return 'option'
|
|
788
|
+
except (KeyError, IndexError):
|
|
789
|
+
pass
|
|
790
|
+
|
|
791
|
+
# 兜底:编号选项检测
|
|
792
|
+
if _has_numbered_options(screen, content_rows):
|
|
793
|
+
return 'option'
|
|
794
|
+
|
|
795
|
+
return 'normal'
|
|
796
|
+
|
|
797
|
+
def _has_option_context_above(self, screen: pyte.Screen, region_start: int) -> bool:
|
|
798
|
+
"""检测条件 4a:背景色区域上方依次是
|
|
799
|
+
默认背景色空行 → 默认背景色普通分割线(─━═)→ 默认背景色空行
|
|
800
|
+
"""
|
|
801
|
+
if region_start < 3:
|
|
802
|
+
return False
|
|
803
|
+
|
|
804
|
+
empty_below = region_start - 1
|
|
805
|
+
divider_row = region_start - 2
|
|
806
|
+
empty_above = region_start - 3
|
|
807
|
+
|
|
808
|
+
# 检查 empty_below:默认 bg + 无文字
|
|
809
|
+
if _get_row_dominant_bg(screen, empty_below) != 'default':
|
|
810
|
+
return False
|
|
811
|
+
if _get_row_text(screen, empty_below).strip():
|
|
812
|
+
return False
|
|
813
|
+
|
|
814
|
+
# 检查 divider_row:默认 bg + 含 ─━═ 字符
|
|
815
|
+
if _get_row_dominant_bg(screen, divider_row) != 'default':
|
|
816
|
+
return False
|
|
817
|
+
divider_text = _get_row_text(screen, divider_row).strip()
|
|
818
|
+
if not divider_text or not any(c in DIVIDER_CHARS for c in divider_text):
|
|
819
|
+
return False
|
|
820
|
+
|
|
821
|
+
# 检查 empty_above:默认 bg + 无文字
|
|
822
|
+
if _get_row_dominant_bg(screen, empty_above) != 'default':
|
|
823
|
+
return False
|
|
824
|
+
if _get_row_text(screen, empty_above).strip():
|
|
825
|
+
return False
|
|
826
|
+
|
|
827
|
+
return True
|
|
828
|
+
|
|
829
|
+
def _is_whole_row_same_fg(self, screen: pyte.Screen, row: int, expected_fg: str) -> bool:
|
|
830
|
+
"""检查整行非空字符是否都有相同的前景色(条件 4c 的整行同色检测)"""
|
|
831
|
+
has_chars = False
|
|
832
|
+
for col in range(screen.columns):
|
|
833
|
+
try:
|
|
834
|
+
char = screen.buffer[row][col]
|
|
835
|
+
if not char.data.strip():
|
|
836
|
+
continue
|
|
837
|
+
has_chars = True
|
|
838
|
+
fg = getattr(char, 'fg', 'default') or 'default'
|
|
839
|
+
if fg != expected_fg:
|
|
840
|
+
return False
|
|
841
|
+
except (KeyError, IndexError):
|
|
842
|
+
continue
|
|
843
|
+
return has_chars
|
|
844
|
+
|
|
722
845
|
def _find_chrome_boundary(self, screen: pyte.Screen, scan_limit: int) -> Optional[int]:
|
|
723
846
|
"""背景色检测:从底部往上找连续的非默认背景行(UI chrome)。
|
|
724
847
|
|
|
@@ -917,7 +1040,7 @@ class CodexParser(BaseParser):
|
|
|
917
1040
|
# UserInput:› U+203A(Codex 实际使用字符)或 > U+003E(兼容)
|
|
918
1041
|
if col0 in CODEX_PROMPT_CHARS:
|
|
919
1042
|
first_text = lines[0][1:].strip()
|
|
920
|
-
# 内容全是分割线字符(如
|
|
1043
|
+
# 内容全是分割线字符(如 ›─────...─)→ 装饰性分隔符,忽略
|
|
921
1044
|
if not first_text or all(c in DIVIDER_CHARS for c in first_text):
|
|
922
1045
|
return None
|
|
923
1046
|
# 收集后续续行(多行输入 / 屏幕自动换行),过滤尾部空白行
|
|
@@ -1092,7 +1215,7 @@ class CodexParser(BaseParser):
|
|
|
1092
1215
|
if not input_rows:
|
|
1093
1216
|
return None
|
|
1094
1217
|
|
|
1095
|
-
# 入口检测:是否有编号选项行(需
|
|
1218
|
+
# 入口检测:是否有编号选项行(需 > 锚点)
|
|
1096
1219
|
if not _has_numbered_options(screen, input_rows):
|
|
1097
1220
|
return None
|
|
1098
1221
|
|
|
@@ -1142,7 +1265,7 @@ class CodexParser(BaseParser):
|
|
|
1142
1265
|
if NAV_RE.search(line):
|
|
1143
1266
|
continue
|
|
1144
1267
|
# 编号选项行
|
|
1145
|
-
m = re.match(r'^(
|
|
1268
|
+
m = re.match(r'^(?:[>❯›]\s*)?(\d+)[.)]\s*(.+)', line)
|
|
1146
1269
|
if m:
|
|
1147
1270
|
if current_opt is not None:
|
|
1148
1271
|
options.append(current_opt)
|
|
@@ -1168,22 +1291,48 @@ class CodexParser(BaseParser):
|
|
|
1168
1291
|
return None
|
|
1169
1292
|
|
|
1170
1293
|
def _extract_input_area_text(self, screen: pyte.Screen, input_rows: List[int]) -> str:
|
|
1171
|
-
"""
|
|
1172
|
-
|
|
1294
|
+
"""提取输入区提示符后的当前输入文本(空提示符返回空字符串)。
|
|
1295
|
+
选项交互模式下不提取输入文本(input_rows 是选项内容,非用户输入)。
|
|
1296
|
+
多行输入时,收集 › 行之后首列为空的续行,合并为完整文本。
|
|
1297
|
+
"""
|
|
1298
|
+
if self.last_layout_mode == 'option':
|
|
1299
|
+
return ''
|
|
1300
|
+
for i, row in enumerate(input_rows):
|
|
1173
1301
|
if _get_col0(screen, row) in CODEX_PROMPT_CHARS:
|
|
1174
1302
|
text = _get_row_text(screen, row)[1:].strip()
|
|
1175
1303
|
# 排除纯分割线装饰行(如 ›─────)
|
|
1176
1304
|
if text and not all(c in DIVIDER_CHARS for c in text):
|
|
1177
|
-
|
|
1305
|
+
# 收集续行(首列为空的非空文本行)
|
|
1306
|
+
lines = [text]
|
|
1307
|
+
for next_row in input_rows[i + 1:]:
|
|
1308
|
+
col0 = _get_col0(screen, next_row)
|
|
1309
|
+
if col0.strip(): # 首列有字符,遇到新 block 或新 › 行,停止
|
|
1310
|
+
break
|
|
1311
|
+
next_text = _get_row_text(screen, next_row).strip()
|
|
1312
|
+
if next_text:
|
|
1313
|
+
lines.append(next_text)
|
|
1314
|
+
return '\n'.join(lines)
|
|
1178
1315
|
return ''
|
|
1179
1316
|
|
|
1180
1317
|
def _extract_input_area_ansi_text(self, screen: pyte.Screen, input_rows: List[int]) -> str:
|
|
1181
|
-
"""提取输入区提示符后的当前输入文本(ANSI
|
|
1182
|
-
|
|
1318
|
+
"""提取输入区提示符后的当前输入文本(ANSI 版本)。
|
|
1319
|
+
多行输入时,收集 › 行之后首列为空的续行,合并为完整文本。
|
|
1320
|
+
"""
|
|
1321
|
+
if self.last_layout_mode == 'option':
|
|
1322
|
+
return ''
|
|
1323
|
+
for i, row in enumerate(input_rows):
|
|
1183
1324
|
if _get_col0(screen, row) in CODEX_PROMPT_CHARS:
|
|
1184
1325
|
text = _get_row_text(screen, row)[1:].strip()
|
|
1185
1326
|
if text and not all(c in DIVIDER_CHARS for c in text):
|
|
1186
|
-
|
|
1327
|
+
lines = [_get_row_ansi_text(screen, row, start_col=1).strip()]
|
|
1328
|
+
for next_row in input_rows[i + 1:]:
|
|
1329
|
+
col0 = _get_col0(screen, next_row)
|
|
1330
|
+
if col0.strip(): # 首列有字符,停止
|
|
1331
|
+
break
|
|
1332
|
+
next_text = _get_row_text(screen, next_row).strip()
|
|
1333
|
+
if next_text:
|
|
1334
|
+
lines.append(_get_row_ansi_text(screen, next_row).strip())
|
|
1335
|
+
return '\n'.join(lines)
|
|
1187
1336
|
return ''
|
|
1188
1337
|
|
|
1189
1338
|
def _parse_permission_area(
|
|
@@ -1193,8 +1342,8 @@ class CodexParser(BaseParser):
|
|
|
1193
1342
|
) -> Optional[OptionBlock]:
|
|
1194
1343
|
"""解析 1 条分割线布局下的权限确认区域,返回 OptionBlock(sub_type="permission")
|
|
1195
1344
|
|
|
1196
|
-
检测条件:bottom_rows 中含
|
|
1197
|
-
通过 _find_contiguous_options 以
|
|
1345
|
+
检测条件:bottom_rows 中含 > 锚点 + ≥2 个编号选项行。
|
|
1346
|
+
通过 _find_contiguous_options 以 > 为锚点双向扫描,只收集连续编号选项。
|
|
1198
1347
|
"""
|
|
1199
1348
|
if not bottom_rows:
|
|
1200
1349
|
return None
|
|
@@ -1261,7 +1410,7 @@ class CodexParser(BaseParser):
|
|
|
1261
1410
|
for i in range(first_opt_idx, last_opt_idx + 1):
|
|
1262
1411
|
line, cat = classified[i]
|
|
1263
1412
|
if cat == 'option':
|
|
1264
|
-
m = re.match(r'^(
|
|
1413
|
+
m = re.match(r'^(?:[>❯›]\s*)?(\d+)[.)]\s*(.+)', line)
|
|
1265
1414
|
if m:
|
|
1266
1415
|
options.append({
|
|
1267
1416
|
'label': m.group(2).strip(),
|