remote-claude 0.2.11 → 0.2.13
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 +4 -0
- package/README.md +1 -0
- package/client/client.py +41 -2
- package/init.sh +18 -0
- package/lark_client/session_bridge.py +40 -1
- package/package.json +2 -1
- package/server/parsers/__init__.py +13 -0
- package/server/parsers/base_parser.py +44 -0
- package/server/parsers/claude_parser.py +1263 -0
- package/server/parsers/codex_parser.py +1496 -0
- package/server/server.py +50 -27
- package/utils/session.py +9 -1
|
@@ -0,0 +1,1263 @@
|
|
|
1
|
+
"""Claude CLI 输出组件解析器
|
|
2
|
+
|
|
3
|
+
基于 CLAUDE.md 中定义的 4 步解析规则:
|
|
4
|
+
1. 区域切分:从底部向上找最后 2 条分割线,切出欢迎区/输出区/用户输入区/底部栏
|
|
5
|
+
2. 输出区切 Block:首列(col=0)有非空字符的行是 Block 首行
|
|
6
|
+
3. Block 分类:星星字符→StatusLine,圆点字符→OutputBlock,❯→UserInput
|
|
7
|
+
4. 执行状态判断:首列字符 blink 属性 + dot_row_cache 帧间持久化
|
|
8
|
+
|
|
9
|
+
OptionBlock(AskUserQuestion 选项交互块)出现在用户输入区,不在输出区。
|
|
10
|
+
|
|
11
|
+
此文件为 ClaudeParser 的正式实现位置(从 component_parser.py 迁移而来)。
|
|
12
|
+
component_parser.py 保留为向后兼容 shim。
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
import logging
|
|
17
|
+
import time
|
|
18
|
+
from collections import deque
|
|
19
|
+
from typing import List, Optional, Dict, Tuple, Set
|
|
20
|
+
|
|
21
|
+
import pyte
|
|
22
|
+
|
|
23
|
+
from utils.components import (
|
|
24
|
+
Component, OutputBlock, UserInput, OptionBlock, StatusLine, BottomBar, AgentPanelBlock, PlanBlock, SystemBlock,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
from .base_parser import BaseParser
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger('ComponentParser')
|
|
30
|
+
|
|
31
|
+
# 星星字符集(状态行首列)
|
|
32
|
+
STAR_CHARS: Set[str] = set(
|
|
33
|
+
'✱✶✷✸✹✺✻✼✽✾✿✰✲✳✴✵'
|
|
34
|
+
'❂❃❄❅❆❇'
|
|
35
|
+
'✢✣✤✥✦✧'
|
|
36
|
+
'⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
# 圆点字符集(OutputBlock 首列)
|
|
40
|
+
DOT_CHARS: Set[str] = {'●', '⏺', '⚫', '•', '◉', '◦', '⏹'}
|
|
41
|
+
|
|
42
|
+
# 分割线字符集
|
|
43
|
+
DIVIDER_CHARS: Set[str] = set('─━═')
|
|
44
|
+
|
|
45
|
+
# Box-drawing 字符集(Plan Mode 使用的框线字符)
|
|
46
|
+
BOX_CORNER_TOP: Set[str] = {'╭', '┌'}
|
|
47
|
+
BOX_CORNER_BOTTOM: Set[str] = {'╰', '└'}
|
|
48
|
+
BOX_VERTICAL: Set[str] = {'│', '┃', '║'}
|
|
49
|
+
|
|
50
|
+
# OutputBlock 内嵌框线清理(纯文本版,用于 content 检测)
|
|
51
|
+
_INLINE_BOX_TOP_RE = re.compile(r'^\s*[╭┌][─━═╌]+[╮┐]\s*$')
|
|
52
|
+
_INLINE_BOX_BOTTOM_RE = re.compile(r'^\s*[╰└][─━═╌]+[╯┘]\s*$')
|
|
53
|
+
_INLINE_BOX_LEFT_RE = re.compile(r'^(\s*)[│┃║] ?')
|
|
54
|
+
_INLINE_BOX_RIGHT_RE = re.compile(r'\s*[│┃║]\s*$')
|
|
55
|
+
|
|
56
|
+
# ANSI 版(用于 ansi_content 清理)
|
|
57
|
+
_A = r'(?:\x1b\[[\d;]*m)*'
|
|
58
|
+
_ANSI_BOX_LEFT_RE = re.compile(rf'^({_A}\s*){_A}[│┃║]{_A} ?')
|
|
59
|
+
_ANSI_BOX_RIGHT_RE = re.compile(rf'\s*{_A}[│┃║]{_A}\s*$')
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _strip_inline_boxes_pair(content: str, ansi_content: str) -> tuple:
|
|
63
|
+
"""去除 OutputBlock 内嵌的 box-drawing 框线,同步清理 content 和 ansi_content。
|
|
64
|
+
|
|
65
|
+
仅在检测到完整 box(顶边框 + 底边框配对)时才去除,防止误伤普通 │ 内容。
|
|
66
|
+
返回 (cleaned_content, cleaned_ansi_content)。
|
|
67
|
+
"""
|
|
68
|
+
lines = content.split('\n')
|
|
69
|
+
top_stack: list = []
|
|
70
|
+
box_ranges: list = []
|
|
71
|
+
for i, line in enumerate(lines):
|
|
72
|
+
if _INLINE_BOX_TOP_RE.match(line):
|
|
73
|
+
top_stack.append(i)
|
|
74
|
+
elif _INLINE_BOX_BOTTOM_RE.match(line) and top_stack:
|
|
75
|
+
top_idx = top_stack.pop()
|
|
76
|
+
box_ranges.append((top_idx, i))
|
|
77
|
+
|
|
78
|
+
if not box_ranges:
|
|
79
|
+
return content, ansi_content
|
|
80
|
+
|
|
81
|
+
ansi_lines = ansi_content.split('\n')
|
|
82
|
+
if len(ansi_lines) != len(lines):
|
|
83
|
+
return content, ansi_content
|
|
84
|
+
|
|
85
|
+
remove_lines: set = set()
|
|
86
|
+
side_lines: set = set()
|
|
87
|
+
for top_idx, bottom_idx in box_ranges:
|
|
88
|
+
remove_lines.add(top_idx)
|
|
89
|
+
remove_lines.add(bottom_idx)
|
|
90
|
+
for j in range(top_idx + 1, bottom_idx):
|
|
91
|
+
side_lines.add(j)
|
|
92
|
+
|
|
93
|
+
result_content = []
|
|
94
|
+
result_ansi = []
|
|
95
|
+
for i, (line, aline) in enumerate(zip(lines, ansi_lines)):
|
|
96
|
+
if i in remove_lines:
|
|
97
|
+
continue
|
|
98
|
+
if i in side_lines:
|
|
99
|
+
line = _INLINE_BOX_LEFT_RE.sub(r'\1', line)
|
|
100
|
+
line = _INLINE_BOX_RIGHT_RE.sub('', line)
|
|
101
|
+
aline = _ANSI_BOX_LEFT_RE.sub(r'\1', aline)
|
|
102
|
+
aline = _ANSI_BOX_RIGHT_RE.sub('', aline)
|
|
103
|
+
result_content.append(line)
|
|
104
|
+
result_ansi.append(aline)
|
|
105
|
+
|
|
106
|
+
return '\n'.join(result_content), '\n'.join(result_ansi)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# 编号选项行正则(权限确认对话框特征:❯ 1. Yes / 2. No 等)
|
|
110
|
+
_NUMBERED_OPTION_RE = re.compile(r'^(?:❯\s*)?\d+[.)]\s+.+')
|
|
111
|
+
# 带 ❯ 光标的编号选项行正则(锚点)
|
|
112
|
+
_CURSOR_OPTION_RE = re.compile(r'^❯\s*(\d+)[.)]\s+.+')
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# ─── ANSI 颜色映射 ─────────────────────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
# pyte 颜色名 → ANSI SGR 前景色代码
|
|
118
|
+
_FG_NAME_TO_SGR: Dict[str, int] = {
|
|
119
|
+
'black': 30, 'red': 31, 'green': 32, 'brown': 33, 'yellow': 33,
|
|
120
|
+
'blue': 34, 'magenta': 35, 'cyan': 36, 'white': 37,
|
|
121
|
+
'brightblack': 90, 'brightred': 91, 'brightgreen': 92, 'brightyellow': 93,
|
|
122
|
+
'brightblue': 94, 'brightmagenta': 95, 'brightcyan': 96, 'brightwhite': 97,
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
# pyte 颜色名 → ANSI SGR 背景色代码
|
|
126
|
+
_BG_NAME_TO_SGR: Dict[str, int] = {
|
|
127
|
+
'black': 40, 'red': 41, 'green': 42, 'brown': 43, 'yellow': 43,
|
|
128
|
+
'blue': 44, 'magenta': 45, 'cyan': 46, 'white': 47,
|
|
129
|
+
'brightblack': 100, 'brightred': 101, 'brightgreen': 102, 'brightyellow': 103,
|
|
130
|
+
'brightblue': 104, 'brightmagenta': 105, 'brightcyan': 106, 'brightwhite': 107,
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _fg_sgr(color: str) -> Optional[str]:
|
|
135
|
+
"""pyte 前景色 → ANSI SGR 序列片段(不含 \\x1b[...m 外壳)"""
|
|
136
|
+
if not color or color == 'default':
|
|
137
|
+
return None
|
|
138
|
+
# 颜色名
|
|
139
|
+
key = color.lower().replace(' ', '').replace('-', '')
|
|
140
|
+
if key in _FG_NAME_TO_SGR:
|
|
141
|
+
return str(_FG_NAME_TO_SGR[key])
|
|
142
|
+
# 6 位 hex(256 色或真彩色)
|
|
143
|
+
if len(color) == 6:
|
|
144
|
+
try:
|
|
145
|
+
r, g, b = int(color[0:2], 16), int(color[2:4], 16), int(color[4:6], 16)
|
|
146
|
+
return f'38;2;{r};{g};{b}'
|
|
147
|
+
except ValueError:
|
|
148
|
+
pass
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _bg_sgr(color: str) -> Optional[str]:
|
|
153
|
+
"""pyte 背景色 → ANSI SGR 序列片段"""
|
|
154
|
+
if not color or color == 'default':
|
|
155
|
+
return None
|
|
156
|
+
key = color.lower().replace(' ', '').replace('-', '')
|
|
157
|
+
if key in _BG_NAME_TO_SGR:
|
|
158
|
+
return str(_BG_NAME_TO_SGR[key])
|
|
159
|
+
if len(color) == 6:
|
|
160
|
+
try:
|
|
161
|
+
r, g, b = int(color[0:2], 16), int(color[2:4], 16), int(color[4:6], 16)
|
|
162
|
+
return f'48;2;{r};{g};{b}'
|
|
163
|
+
except ValueError:
|
|
164
|
+
pass
|
|
165
|
+
return None
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _char_style_parts(char) -> List[str]:
|
|
169
|
+
"""提取 pyte Char 的所有样式 SGR 参数(不含 \\x1b[...m 外壳)"""
|
|
170
|
+
parts: List[str] = []
|
|
171
|
+
fg = _fg_sgr(char.fg)
|
|
172
|
+
if fg:
|
|
173
|
+
parts.append(fg)
|
|
174
|
+
bg = _bg_sgr(char.bg)
|
|
175
|
+
if bg:
|
|
176
|
+
parts.append(bg)
|
|
177
|
+
if getattr(char, 'bold', False):
|
|
178
|
+
parts.append('1')
|
|
179
|
+
if getattr(char, 'italics', False):
|
|
180
|
+
parts.append('3')
|
|
181
|
+
if getattr(char, 'underscore', False):
|
|
182
|
+
parts.append('4')
|
|
183
|
+
if getattr(char, 'strikethrough', False):
|
|
184
|
+
parts.append('9')
|
|
185
|
+
if getattr(char, 'reverse', False):
|
|
186
|
+
parts.append('7')
|
|
187
|
+
return parts
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _get_row_ansi_text(screen: pyte.Screen, row: int, start_col: int = 0) -> str:
|
|
191
|
+
"""提取指定行带 ANSI 转义码的文本。
|
|
192
|
+
|
|
193
|
+
先确定有效列范围(与 _get_row_text 的 rstrip 等价),仅在有效范围内生成 ANSI 码。
|
|
194
|
+
start_col 用于跳过首列特殊字符(圆点/星星/❯)。
|
|
195
|
+
"""
|
|
196
|
+
buf_row = screen.buffer[row]
|
|
197
|
+
|
|
198
|
+
# 确定最右有效列(rstrip 等价)
|
|
199
|
+
max_col = -1
|
|
200
|
+
for col in buf_row:
|
|
201
|
+
if col >= start_col and buf_row[col].data.rstrip():
|
|
202
|
+
max_col = max(max_col, col)
|
|
203
|
+
if max_col < start_col:
|
|
204
|
+
return ''
|
|
205
|
+
|
|
206
|
+
# 预填充空格
|
|
207
|
+
result_chars = [' '] * (max_col - start_col + 1)
|
|
208
|
+
prev_parts: List[str] = []
|
|
209
|
+
has_style = False
|
|
210
|
+
|
|
211
|
+
for col, char in sorted(buf_row.items()):
|
|
212
|
+
if col < start_col or col > max_col:
|
|
213
|
+
continue
|
|
214
|
+
cur_parts = _char_style_parts(char)
|
|
215
|
+
if cur_parts != prev_parts:
|
|
216
|
+
if prev_parts:
|
|
217
|
+
# reset 后重设新样式
|
|
218
|
+
prefix = '\x1b[0;' + ';'.join(cur_parts) + 'm' if cur_parts else '\x1b[0m'
|
|
219
|
+
elif cur_parts:
|
|
220
|
+
prefix = '\x1b[' + ';'.join(cur_parts) + 'm'
|
|
221
|
+
else:
|
|
222
|
+
prefix = ''
|
|
223
|
+
result_chars[col - start_col] = prefix + char.data
|
|
224
|
+
prev_parts = cur_parts
|
|
225
|
+
has_style = has_style or bool(cur_parts)
|
|
226
|
+
else:
|
|
227
|
+
result_chars[col - start_col] = char.data
|
|
228
|
+
|
|
229
|
+
text = ''.join(result_chars)
|
|
230
|
+
# 行尾 reset
|
|
231
|
+
if has_style and prev_parts:
|
|
232
|
+
text += '\x1b[0m'
|
|
233
|
+
return text
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _get_col0_ansi(screen: pyte.Screen, row: int) -> str:
|
|
237
|
+
"""提取首列单个字符的 ANSI 表示(字符 + 颜色转义码)"""
|
|
238
|
+
try:
|
|
239
|
+
char = screen.buffer[row][0]
|
|
240
|
+
except (KeyError, IndexError):
|
|
241
|
+
return ''
|
|
242
|
+
if not char.data.strip():
|
|
243
|
+
return ''
|
|
244
|
+
parts = _char_style_parts(char)
|
|
245
|
+
if not parts:
|
|
246
|
+
return char.data
|
|
247
|
+
return '\x1b[' + ';'.join(parts) + 'm' + char.data + '\x1b[0m'
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
# ─── 屏幕行工具函数 ────────────────────────────────────────────────────────────
|
|
251
|
+
|
|
252
|
+
def _get_row_text(screen: pyte.Screen, row: int) -> str:
|
|
253
|
+
"""提取指定行完整文本(rstrip 去尾部空格)。
|
|
254
|
+
预分配空格列表后按 dict 实际内容覆写,避免逐列触发 defaultdict。"""
|
|
255
|
+
buf = [' '] * screen.columns
|
|
256
|
+
for col, char in screen.buffer[row].items():
|
|
257
|
+
buf[col] = char.data
|
|
258
|
+
return ''.join(buf).rstrip()
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _get_col0(screen: pyte.Screen, row: int) -> str:
|
|
262
|
+
"""获取指定行第一列字符(col=0)"""
|
|
263
|
+
try:
|
|
264
|
+
return screen.buffer[row][0].data
|
|
265
|
+
except (KeyError, IndexError):
|
|
266
|
+
return ''
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _get_col0_blink(screen: pyte.Screen, row: int) -> bool:
|
|
270
|
+
"""获取指定行第一列 blink 属性"""
|
|
271
|
+
try:
|
|
272
|
+
char = screen.buffer[row][0]
|
|
273
|
+
return bool(getattr(char, 'blink', False))
|
|
274
|
+
except (KeyError, IndexError):
|
|
275
|
+
return False
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _is_divider_row(screen: pyte.Screen, row: int) -> bool:
|
|
279
|
+
"""判断整行是否为分割线:所有非空字符均为分割线字符,不限制行长度"""
|
|
280
|
+
found = False
|
|
281
|
+
for col in range(screen.columns):
|
|
282
|
+
c = screen.buffer[row][col].data
|
|
283
|
+
if not c.strip():
|
|
284
|
+
continue
|
|
285
|
+
if c not in DIVIDER_CHARS:
|
|
286
|
+
return False # 发现非分割线字符,立即短路
|
|
287
|
+
found = True
|
|
288
|
+
return found
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _has_numbered_options(screen: pyte.Screen, rows: List[int]) -> bool:
|
|
292
|
+
"""检测 rows 是否含编号选项行(需 ❯ 锚点 + ≥2 编号行)
|
|
293
|
+
|
|
294
|
+
必须有至少一行匹配 _CURSOR_OPTION_RE(❯ 锚点),且总编号行 ≥2。
|
|
295
|
+
"""
|
|
296
|
+
has_cursor = False
|
|
297
|
+
option_count = 0
|
|
298
|
+
for r in rows:
|
|
299
|
+
text = _get_row_text(screen, r).strip()
|
|
300
|
+
if _CURSOR_OPTION_RE.match(text):
|
|
301
|
+
has_cursor = True
|
|
302
|
+
option_count += 1
|
|
303
|
+
elif _NUMBERED_OPTION_RE.match(text):
|
|
304
|
+
option_count += 1
|
|
305
|
+
return has_cursor and option_count >= 2
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _find_contiguous_options(lines, nav_re):
|
|
309
|
+
"""以 ❯ 锚点行出发,在 lines 中找出连续编号选项的范围。
|
|
310
|
+
|
|
311
|
+
Returns: (cursor_idx, first_option_idx, last_option_idx)
|
|
312
|
+
若未找到 ❯ 锚点,返回 (-1, -1, -1)
|
|
313
|
+
"""
|
|
314
|
+
# 1. 找 ❯ 锚点
|
|
315
|
+
cursor_idx = -1
|
|
316
|
+
cursor_num = 0
|
|
317
|
+
for i, line in enumerate(lines):
|
|
318
|
+
m = _CURSOR_OPTION_RE.match(line)
|
|
319
|
+
if m:
|
|
320
|
+
cursor_idx = i
|
|
321
|
+
cursor_num = int(m.group(1))
|
|
322
|
+
break
|
|
323
|
+
if cursor_idx < 0:
|
|
324
|
+
return (-1, -1, -1)
|
|
325
|
+
|
|
326
|
+
# 2. 向前扫描(N-1, N-2, ...)
|
|
327
|
+
first_option_idx = cursor_idx
|
|
328
|
+
expected = cursor_num - 1
|
|
329
|
+
for i in range(cursor_idx - 1, -1, -1):
|
|
330
|
+
line = lines[i]
|
|
331
|
+
if not line or nav_re.search(line):
|
|
332
|
+
continue
|
|
333
|
+
m = re.match(r'^(?:❯\s*)?(\d+)[.)]\s+', line)
|
|
334
|
+
if m:
|
|
335
|
+
if int(m.group(1)) == expected:
|
|
336
|
+
first_option_idx = i
|
|
337
|
+
expected -= 1
|
|
338
|
+
else:
|
|
339
|
+
break # 编号不连续,停止
|
|
340
|
+
# 非编号行(描述行)不打断扫描
|
|
341
|
+
|
|
342
|
+
# 3. 向后扫描(N+1, N+2, ...)
|
|
343
|
+
last_option_idx = cursor_idx
|
|
344
|
+
expected = cursor_num + 1
|
|
345
|
+
for i in range(cursor_idx + 1, len(lines)):
|
|
346
|
+
line = lines[i]
|
|
347
|
+
if not line or nav_re.search(line):
|
|
348
|
+
continue
|
|
349
|
+
m = re.match(r'^(?:❯\s*)?(\d+)[.)]\s+', line)
|
|
350
|
+
if m:
|
|
351
|
+
if int(m.group(1)) == expected:
|
|
352
|
+
last_option_idx = i
|
|
353
|
+
expected += 1
|
|
354
|
+
else:
|
|
355
|
+
break # 编号不连续,停止
|
|
356
|
+
# 非编号行(描述行)不打断扫描
|
|
357
|
+
|
|
358
|
+
return (cursor_idx, first_option_idx, last_option_idx)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
# ─── ScreenParser ─────────────────────────────────────────────────────────────
|
|
362
|
+
|
|
363
|
+
class ClaudeParser(BaseParser):
|
|
364
|
+
"""Claude CLI 终端屏幕解析器(会话级别持久化,跨帧保留 dot_row_cache)"""
|
|
365
|
+
|
|
366
|
+
def __init__(self):
|
|
367
|
+
# 帧间圆点缓存:布局模式切换时清空,防止残留行号产生幽灵 block
|
|
368
|
+
self._dot_row_cache: Dict[int, Tuple[str, str, str, str, bool]] = {}
|
|
369
|
+
# 星号滑动窗口(1秒):记录每行最近 1 秒内出现的 (timestamp, char),
|
|
370
|
+
# 窗口内 ≥2 种不同字符 → spinner 旋转 → StatusLine;始终只有 1 种字符 → SystemBlock
|
|
371
|
+
self._star_row_history: Dict[int, deque] = {}
|
|
372
|
+
# 最近一次解析到的输入区 ❯ 文本(用于 MessageQueue 追踪变更)
|
|
373
|
+
self.last_input_text: str = ''
|
|
374
|
+
self.last_input_ansi_text: str = ''
|
|
375
|
+
# 最近一次 parse 的内部耗时(供外部写日志用)
|
|
376
|
+
self.last_parse_timing: str = ''
|
|
377
|
+
# 布局模式:"normal" | "option" | "detail" | "agent_list" | "agent_detail"
|
|
378
|
+
self.last_layout_mode: str = 'normal'
|
|
379
|
+
|
|
380
|
+
def parse(self, screen: pyte.Screen) -> List[Component]:
|
|
381
|
+
"""解析 pyte 屏幕,返回组件列表"""
|
|
382
|
+
import time as _time
|
|
383
|
+
_t0 = _time.perf_counter()
|
|
384
|
+
|
|
385
|
+
# Step 1:区域切分
|
|
386
|
+
output_rows, input_rows, bottom_rows = self._split_regions(screen)
|
|
387
|
+
_t1 = _time.perf_counter()
|
|
388
|
+
|
|
389
|
+
# 布局模式判定
|
|
390
|
+
prev_mode = self.last_layout_mode
|
|
391
|
+
if input_rows:
|
|
392
|
+
if _has_numbered_options(screen, input_rows):
|
|
393
|
+
self.last_layout_mode = 'option' # 2 分割线 + 编号选项
|
|
394
|
+
else:
|
|
395
|
+
self.last_layout_mode = 'normal'
|
|
396
|
+
elif bottom_rows:
|
|
397
|
+
bottom_text = ' '.join(
|
|
398
|
+
_get_row_text(screen, r).strip() for r in bottom_rows
|
|
399
|
+
if _get_row_text(screen, r).strip()
|
|
400
|
+
).lower()
|
|
401
|
+
if _has_numbered_options(screen, bottom_rows):
|
|
402
|
+
self.last_layout_mode = 'option' # 1 分割线 + 编号选项(原 permission)
|
|
403
|
+
elif 'ctrl+o to toggle' in bottom_text:
|
|
404
|
+
self.last_layout_mode = 'detail'
|
|
405
|
+
elif ('background tasks' in bottom_text
|
|
406
|
+
or ('to select' in bottom_text and 'esc to close' in bottom_text)):
|
|
407
|
+
self.last_layout_mode = 'agent_list'
|
|
408
|
+
elif '← to go back' in bottom_text and 'to close' in bottom_text:
|
|
409
|
+
self.last_layout_mode = 'agent_detail'
|
|
410
|
+
else:
|
|
411
|
+
self.last_layout_mode = 'normal'
|
|
412
|
+
else:
|
|
413
|
+
self.last_layout_mode = 'normal'
|
|
414
|
+
|
|
415
|
+
# 模式切换时清空 dot_row_cache / star_row_history,防止残留行号产生幽灵 block
|
|
416
|
+
if self.last_layout_mode != prev_mode:
|
|
417
|
+
self._dot_row_cache.clear()
|
|
418
|
+
self._star_row_history.clear()
|
|
419
|
+
|
|
420
|
+
# 提取输入区 ❯ 文本(用于 MessageQueue 追踪变更)
|
|
421
|
+
self.last_input_text = self._extract_input_area_text(screen, input_rows)
|
|
422
|
+
self.last_input_ansi_text = self._extract_input_area_ansi_text(screen, input_rows)
|
|
423
|
+
|
|
424
|
+
# 清理已失效的 dot_row_cache 条目
|
|
425
|
+
self._cleanup_cache(screen, set(output_rows))
|
|
426
|
+
_t2 = _time.perf_counter()
|
|
427
|
+
|
|
428
|
+
# Step 2+3+4:解析输出区
|
|
429
|
+
components: List[Component] = self._parse_output_area(screen, output_rows)
|
|
430
|
+
_t3 = _time.perf_counter()
|
|
431
|
+
|
|
432
|
+
# 解析选项交互块(OptionBlock,状态型组件,不进 components)
|
|
433
|
+
overflow_rows: List[int] = []
|
|
434
|
+
option: Optional[OptionBlock] = None
|
|
435
|
+
agent_panel = None
|
|
436
|
+
|
|
437
|
+
if input_rows:
|
|
438
|
+
# 2 分割线 + 编号选项 → OptionBlock(sub_type="option")
|
|
439
|
+
option = self._parse_input_area(screen, input_rows, bottom_rows, overflow_rows)
|
|
440
|
+
elif bottom_rows and self.last_layout_mode == 'option':
|
|
441
|
+
# 1 分割线 + 编号选项 → OptionBlock(sub_type="permission")
|
|
442
|
+
option = self._parse_permission_area(screen, bottom_rows)
|
|
443
|
+
|
|
444
|
+
# Agent 面板检测(1 条分割线布局)
|
|
445
|
+
if not option:
|
|
446
|
+
if self.last_layout_mode == 'agent_list':
|
|
447
|
+
agent_panel = self._parse_agent_list_panel(screen, bottom_rows)
|
|
448
|
+
if agent_panel:
|
|
449
|
+
components.append(agent_panel)
|
|
450
|
+
elif self.last_layout_mode == 'agent_detail':
|
|
451
|
+
agent_panel = self._parse_agent_detail_panel(screen, bottom_rows)
|
|
452
|
+
if agent_panel:
|
|
453
|
+
components.append(agent_panel)
|
|
454
|
+
|
|
455
|
+
# OptionBlock 作为状态型组件单独存储(不进 components)
|
|
456
|
+
if option:
|
|
457
|
+
components.append(option)
|
|
458
|
+
|
|
459
|
+
# 底部栏(排除被 OptionBlock 溢出占用的行;有 option/agent_panel 时跳过)
|
|
460
|
+
overflow_set = set(overflow_rows)
|
|
461
|
+
bottom_parts = []
|
|
462
|
+
ansi_bottom_parts = []
|
|
463
|
+
for r in bottom_rows:
|
|
464
|
+
if r in overflow_set:
|
|
465
|
+
continue
|
|
466
|
+
text = _get_row_text(screen, r).strip()
|
|
467
|
+
if text:
|
|
468
|
+
bottom_parts.append(text)
|
|
469
|
+
ansi_bottom_parts.append(_get_row_ansi_text(screen, r).strip())
|
|
470
|
+
if bottom_parts and not option and not agent_panel:
|
|
471
|
+
bar_text = '\n'.join(bottom_parts)
|
|
472
|
+
bar_ansi = '\n'.join(ansi_bottom_parts)
|
|
473
|
+
has_agents, agent_count, agent_summary = _parse_bottom_bar_agents(bar_text)
|
|
474
|
+
components.append(BottomBar(
|
|
475
|
+
text=bar_text,
|
|
476
|
+
ansi_text=bar_ansi,
|
|
477
|
+
has_background_agents=has_agents,
|
|
478
|
+
agent_count=agent_count,
|
|
479
|
+
agent_summary=agent_summary,
|
|
480
|
+
))
|
|
481
|
+
_t4 = _time.perf_counter()
|
|
482
|
+
|
|
483
|
+
self.last_parse_timing = (
|
|
484
|
+
f"split={1000*(_t1-_t0):.1f}ms cleanup={1000*(_t2-_t1):.1f}ms "
|
|
485
|
+
f"output_area={1000*(_t3-_t2):.1f}ms rest={1000*(_t4-_t3):.1f}ms "
|
|
486
|
+
f"output_rows={len(output_rows)} cursor_y={screen.cursor.y}"
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
return components
|
|
490
|
+
|
|
491
|
+
# ─── Step 1:区域切分 ──────────────────────────────────────────────────
|
|
492
|
+
|
|
493
|
+
def _split_regions(
|
|
494
|
+
self, screen: pyte.Screen
|
|
495
|
+
) -> Tuple[List[int], List[int], List[int]]:
|
|
496
|
+
"""从底部向上找最后 2 条分割线,返回 (output_rows, input_rows, bottom_rows)"""
|
|
497
|
+
# cursor_y 是当前光标所在行,内容不会超过此行。
|
|
498
|
+
# 加 5 行余量应对光标尚未移动到最末行的情况,避免扫描 2000 行空行。
|
|
499
|
+
scan_limit = min(screen.cursor.y + 5, screen.lines - 1)
|
|
500
|
+
|
|
501
|
+
dividers: List[int] = []
|
|
502
|
+
for row in range(scan_limit, -1, -1):
|
|
503
|
+
if _is_divider_row(screen, row):
|
|
504
|
+
dividers.append(row)
|
|
505
|
+
if len(dividers) == 2:
|
|
506
|
+
break
|
|
507
|
+
dividers.sort()
|
|
508
|
+
|
|
509
|
+
if len(dividers) >= 2:
|
|
510
|
+
div1, div2 = dividers[-2], dividers[-1]
|
|
511
|
+
output_rows = self._trim_welcome(screen, list(range(div1)))
|
|
512
|
+
input_rows = list(range(div1 + 1, div2))
|
|
513
|
+
bottom_rows = list(range(div2 + 1, scan_limit + 1))
|
|
514
|
+
elif len(dividers) == 1:
|
|
515
|
+
div1 = dividers[0]
|
|
516
|
+
output_rows = self._trim_welcome(screen, list(range(div1)))
|
|
517
|
+
input_rows = []
|
|
518
|
+
bottom_rows = list(range(div1 + 1, scan_limit + 1))
|
|
519
|
+
else:
|
|
520
|
+
output_rows = self._trim_welcome(screen, list(range(scan_limit + 1)))
|
|
521
|
+
input_rows = []
|
|
522
|
+
bottom_rows = []
|
|
523
|
+
|
|
524
|
+
return output_rows, input_rows, bottom_rows
|
|
525
|
+
|
|
526
|
+
def _trim_welcome(self, screen: pyte.Screen, rows: List[int]) -> List[int]:
|
|
527
|
+
"""去掉欢迎区域:跳过首列为空的前缀行和欢迎框(Claude Code box)"""
|
|
528
|
+
i = 0
|
|
529
|
+
while i < len(rows):
|
|
530
|
+
col0 = _get_col0(screen, rows[i])
|
|
531
|
+
if not col0.strip():
|
|
532
|
+
i += 1
|
|
533
|
+
continue
|
|
534
|
+
# 首个非空 col0 是 box 顶角 → 检查是否为欢迎框
|
|
535
|
+
if col0 in BOX_CORNER_TOP:
|
|
536
|
+
first_line = _get_row_text(screen, rows[i])
|
|
537
|
+
if 'Claude Code' in first_line:
|
|
538
|
+
# 跳过整个欢迎框(到 ╰/└ 行为止)
|
|
539
|
+
i += 1
|
|
540
|
+
while i < len(rows):
|
|
541
|
+
if _get_col0(screen, rows[i]) in BOX_CORNER_BOTTOM:
|
|
542
|
+
i += 1
|
|
543
|
+
break
|
|
544
|
+
i += 1
|
|
545
|
+
continue # 继续跳过后续空行
|
|
546
|
+
# 非欢迎框,返回剩余行
|
|
547
|
+
return rows[i:]
|
|
548
|
+
return []
|
|
549
|
+
|
|
550
|
+
# ─── Step 2+3+4:输出区解析 ────────────────────────────────────────────
|
|
551
|
+
|
|
552
|
+
def _cleanup_cache(self, screen: pyte.Screen, output_row_set: Set[int]):
|
|
553
|
+
"""清理 dot_row_cache / star_row_history 中不再有效的条目"""
|
|
554
|
+
for row in list(self._dot_row_cache.keys()):
|
|
555
|
+
if row not in output_row_set:
|
|
556
|
+
# 行已不在输出区(屏幕滚动等)
|
|
557
|
+
del self._dot_row_cache[row]
|
|
558
|
+
continue
|
|
559
|
+
col0 = _get_col0(screen, row)
|
|
560
|
+
if col0.strip() and col0 not in DOT_CHARS:
|
|
561
|
+
# 首列变为其他非圆点内容,缓存失效
|
|
562
|
+
del self._dot_row_cache[row]
|
|
563
|
+
# 清理 star_row_history:只清理已滚出输出区的行,不根据 col0 内容变化删除
|
|
564
|
+
# 原因:星星字符闪烁时会暂时变成其他字符(如·)或空白,不应因此清空历史
|
|
565
|
+
# 历史记录本身有 1.5 秒过期机制,会自动清理过期数据
|
|
566
|
+
deleted_rows = []
|
|
567
|
+
for row in list(self._star_row_history.keys()):
|
|
568
|
+
if row not in output_row_set:
|
|
569
|
+
deleted_rows.append((row, "not_in_output"))
|
|
570
|
+
del self._star_row_history[row]
|
|
571
|
+
# 诊断日志:记录删除行为
|
|
572
|
+
if deleted_rows:
|
|
573
|
+
logger.debug(f"[diag-cleanup] deleted={deleted_rows} remaining={list(self._star_row_history.keys())}")
|
|
574
|
+
|
|
575
|
+
def _parse_output_area(
|
|
576
|
+
self, screen: pyte.Screen, rows: List[int]
|
|
577
|
+
) -> List[Component]:
|
|
578
|
+
"""切 Block 并分类"""
|
|
579
|
+
if not rows:
|
|
580
|
+
return []
|
|
581
|
+
|
|
582
|
+
# 切 Block:首列有非空字符 OR 在 dot_row_cache 中(圆点闪烁隐去帧)→ Block 首行
|
|
583
|
+
# box 区域(╭...╰)整体合并为一个 block
|
|
584
|
+
blocks: List[Tuple[int, List[int]]] = []
|
|
585
|
+
current_first: Optional[int] = None
|
|
586
|
+
current_rows: Optional[List[int]] = None
|
|
587
|
+
in_box = False
|
|
588
|
+
|
|
589
|
+
for row in rows:
|
|
590
|
+
col0 = _get_col0(screen, row)
|
|
591
|
+
|
|
592
|
+
# Box 区域合并:╭ 开始 → │ 继续 → ╰ 结束,整个区域作为一个 block
|
|
593
|
+
if in_box:
|
|
594
|
+
current_rows.append(row)
|
|
595
|
+
if col0 in BOX_CORNER_BOTTOM:
|
|
596
|
+
blocks.append((current_first, current_rows))
|
|
597
|
+
current_first = None
|
|
598
|
+
current_rows = None
|
|
599
|
+
in_box = False
|
|
600
|
+
continue
|
|
601
|
+
|
|
602
|
+
if col0 in BOX_CORNER_TOP:
|
|
603
|
+
# 先保存当前正在构建的 block
|
|
604
|
+
if current_rows is not None:
|
|
605
|
+
blocks.append((current_first, current_rows))
|
|
606
|
+
current_first = row
|
|
607
|
+
current_rows = [row]
|
|
608
|
+
in_box = True
|
|
609
|
+
continue
|
|
610
|
+
|
|
611
|
+
is_header = bool(col0.strip()) or (row in self._dot_row_cache)
|
|
612
|
+
|
|
613
|
+
if is_header:
|
|
614
|
+
if current_rows is not None:
|
|
615
|
+
blocks.append((current_first, current_rows))
|
|
616
|
+
current_first = row
|
|
617
|
+
current_rows = [row]
|
|
618
|
+
else:
|
|
619
|
+
if current_rows is not None:
|
|
620
|
+
current_rows.append(row)
|
|
621
|
+
# 欢迎区之前的行(_trim_welcome 已过滤,理论不会到这里)
|
|
622
|
+
|
|
623
|
+
if current_rows is not None:
|
|
624
|
+
blocks.append((current_first, current_rows))
|
|
625
|
+
|
|
626
|
+
return [
|
|
627
|
+
c for c in (
|
|
628
|
+
self._classify_block(screen, fr, br) for fr, br in blocks
|
|
629
|
+
)
|
|
630
|
+
if c is not None
|
|
631
|
+
]
|
|
632
|
+
|
|
633
|
+
def _classify_block(
|
|
634
|
+
self, screen: pyte.Screen, first_row: int, block_rows: List[int]
|
|
635
|
+
) -> Optional[Component]:
|
|
636
|
+
"""根据首行首列字符对 Block 分类"""
|
|
637
|
+
col0 = _get_col0(screen, first_row)
|
|
638
|
+
is_blink = _get_col0_blink(screen, first_row)
|
|
639
|
+
|
|
640
|
+
# 圆点闪烁隐去帧:col0 为空但 dot_row_cache 有记录
|
|
641
|
+
if not col0.strip() and first_row in self._dot_row_cache:
|
|
642
|
+
cached_first_line, cached_ansi_first, cached_ind, cached_ansi_ind, cached_blink = self._dot_row_cache[first_row]
|
|
643
|
+
logger.debug(
|
|
644
|
+
f"[cache-hit] row={first_row} cached={cached_first_line[:40]!r} blink={cached_blink}"
|
|
645
|
+
)
|
|
646
|
+
# 继承上次记录的 blink 状态:若上次 dot 出现时已是 False(block 已完成),
|
|
647
|
+
# 则本次 dot 消失(如 ctrl+o 重绘)不应误判为 streaming
|
|
648
|
+
first_content = cached_first_line[1:].strip() if cached_first_line else ''
|
|
649
|
+
body_lines = [_get_row_text(screen, r) for r in block_rows[1:]]
|
|
650
|
+
content = '\n'.join([first_content] + body_lines).rstrip()
|
|
651
|
+
ansi_body_lines = [_get_row_ansi_text(screen, r) for r in block_rows[1:]]
|
|
652
|
+
ansi_content = '\n'.join([cached_ansi_first] + ansi_body_lines).rstrip()
|
|
653
|
+
content, ansi_content = _strip_inline_boxes_pair(content, ansi_content)
|
|
654
|
+
return OutputBlock(
|
|
655
|
+
content=content, is_streaming=cached_blink, start_row=first_row,
|
|
656
|
+
ansi_content=ansi_content, indicator=cached_ind, ansi_indicator=cached_ansi_ind,
|
|
657
|
+
)
|
|
658
|
+
|
|
659
|
+
if not col0.strip():
|
|
660
|
+
return None
|
|
661
|
+
|
|
662
|
+
# PlanBlock:box-drawing 顶角字符(╭ 或 ┌)
|
|
663
|
+
if col0 in BOX_CORNER_TOP:
|
|
664
|
+
return self._parse_plan_block(screen, first_row, block_rows)
|
|
665
|
+
|
|
666
|
+
lines = [_get_row_text(screen, r) for r in block_rows]
|
|
667
|
+
|
|
668
|
+
# 星号字符:blink → StatusLine(状态行),非 blink → SystemBlock(系统提示)
|
|
669
|
+
# 兜底:1秒滑动窗口检测(窗口内 ≥2 种不同字符 → spinner 旋转 → StatusLine)
|
|
670
|
+
if col0 in STAR_CHARS:
|
|
671
|
+
now = time.time()
|
|
672
|
+
history = self._star_row_history.setdefault(first_row, deque())
|
|
673
|
+
# 清理超过 1.5 秒的旧帧(从 1.0 延长至 1.5,覆盖更多旋转周期,减少 StatusLine 误判)
|
|
674
|
+
while history and history[0][0] < now - 1.5:
|
|
675
|
+
history.popleft()
|
|
676
|
+
history.append((now, col0))
|
|
677
|
+
# 诊断日志:记录 history 状态和判定结果
|
|
678
|
+
unique_chars = {c for _, c in history}
|
|
679
|
+
logger.debug(f"[diag-star] row={first_row} col0={col0!r} is_blink={is_blink} history_len={len(history)} unique_chars={len(unique_chars)} chars={sorted(unique_chars)!r}")
|
|
680
|
+
# 窗口内 ≥2 种不同字符 → spinner 旋转 → StatusLine
|
|
681
|
+
inferred_blink = is_blink or len(unique_chars) > 1
|
|
682
|
+
if inferred_blink:
|
|
683
|
+
logger.debug(f"[diag-star] -> StatusLine")
|
|
684
|
+
return self._parse_status_block(
|
|
685
|
+
lines[0],
|
|
686
|
+
ansi_first_line=_get_row_ansi_text(screen, first_row),
|
|
687
|
+
indicator=col0,
|
|
688
|
+
ansi_indicator=_get_col0_ansi(screen, first_row),
|
|
689
|
+
)
|
|
690
|
+
else:
|
|
691
|
+
logger.debug(f"[diag-star] -> SystemBlock")
|
|
692
|
+
return self._parse_system_block(screen, first_row, block_rows, lines, col0)
|
|
693
|
+
|
|
694
|
+
# UserInput:❯
|
|
695
|
+
if col0 == '❯':
|
|
696
|
+
first_text = lines[0][1:].strip()
|
|
697
|
+
# 内容全是分割线字符(如 ❯─────...─)→ 装饰性分隔符,忽略
|
|
698
|
+
if not first_text or all(c in DIVIDER_CHARS for c in first_text):
|
|
699
|
+
return None
|
|
700
|
+
# 收集后续续行(多行输入 / 屏幕自动换行),过滤尾部空白行
|
|
701
|
+
body_lines = [l for l in lines[1:] if l.strip()]
|
|
702
|
+
text = '\n'.join([first_text] + body_lines)
|
|
703
|
+
ind = col0
|
|
704
|
+
ansi_ind = _get_col0_ansi(screen, first_row)
|
|
705
|
+
ansi_first = _get_row_ansi_text(screen, first_row, start_col=1).strip()
|
|
706
|
+
ansi_body = [_get_row_ansi_text(screen, r) for r in block_rows[1:]
|
|
707
|
+
if _get_row_text(screen, r).strip()]
|
|
708
|
+
ansi_text = '\n'.join([ansi_first] + ansi_body)
|
|
709
|
+
return UserInput(text=text, ansi_text=ansi_text, indicator=ind, ansi_indicator=ansi_ind)
|
|
710
|
+
|
|
711
|
+
# OutputBlock:圆点字符
|
|
712
|
+
if col0 in DOT_CHARS:
|
|
713
|
+
ind = col0
|
|
714
|
+
ansi_ind = _get_col0_ansi(screen, first_row)
|
|
715
|
+
ansi_first = _get_row_ansi_text(screen, first_row, start_col=1).strip()
|
|
716
|
+
ansi_body = [_get_row_ansi_text(screen, r) for r in block_rows[1:]]
|
|
717
|
+
# 更新帧间缓存(同时记录 blink 状态,供 dot 消失帧继承)
|
|
718
|
+
self._dot_row_cache[first_row] = (lines[0], ansi_first, ind, ansi_ind, is_blink)
|
|
719
|
+
if is_blink:
|
|
720
|
+
logger.debug(
|
|
721
|
+
f"[blink] row={first_row} content={lines[0][:40]!r}"
|
|
722
|
+
)
|
|
723
|
+
first_content = lines[0][1:].strip()
|
|
724
|
+
body_lines = lines[1:]
|
|
725
|
+
content = '\n'.join([first_content] + body_lines).rstrip()
|
|
726
|
+
ansi_content = '\n'.join([ansi_first] + ansi_body).rstrip()
|
|
727
|
+
content, ansi_content = _strip_inline_boxes_pair(content, ansi_content)
|
|
728
|
+
return OutputBlock(
|
|
729
|
+
content=content, is_streaming=is_blink, start_row=first_row,
|
|
730
|
+
ansi_content=ansi_content, indicator=ind, ansi_indicator=ansi_ind,
|
|
731
|
+
)
|
|
732
|
+
|
|
733
|
+
# 其他首列字符(装饰残留、欢迎区片段等),忽略
|
|
734
|
+
return None
|
|
735
|
+
|
|
736
|
+
def _parse_plan_block(
|
|
737
|
+
self, screen: pyte.Screen, first_row: int, block_rows: List[int]
|
|
738
|
+
) -> PlanBlock:
|
|
739
|
+
"""解析 box-drawing 框线包裹的计划内容(Plan Mode)"""
|
|
740
|
+
content_lines = []
|
|
741
|
+
ansi_lines = []
|
|
742
|
+
for row in block_rows:
|
|
743
|
+
col0 = _get_col0(screen, row)
|
|
744
|
+
if col0 in BOX_CORNER_TOP or col0 in BOX_CORNER_BOTTOM:
|
|
745
|
+
continue # 跳过顶/底边框行
|
|
746
|
+
if col0 in BOX_VERTICAL:
|
|
747
|
+
line = _get_row_text(screen, row)
|
|
748
|
+
inner = line[1:] # 去掉左侧 │
|
|
749
|
+
# 去掉右侧 │(如有)
|
|
750
|
+
stripped = inner.rstrip()
|
|
751
|
+
if stripped and stripped[-1] in BOX_VERTICAL:
|
|
752
|
+
inner = stripped[:-1]
|
|
753
|
+
content_lines.append(inner.rstrip())
|
|
754
|
+
ansi_line = _get_row_ansi_text(screen, row, start_col=1)
|
|
755
|
+
# 去掉右侧 │(可能包裹 ANSI 码,如 │\x1b[0m 或 \x1b[...m│\x1b[0m)
|
|
756
|
+
ansi_line = re.sub(r'(\x1b\[[0-9;]*m)*[│┃║](\x1b\[0m)?\s*$', '', ansi_line)
|
|
757
|
+
ansi_lines.append(ansi_line.rstrip())
|
|
758
|
+
|
|
759
|
+
content = '\n'.join(content_lines).strip()
|
|
760
|
+
ansi_content = '\n'.join(ansi_lines).strip()
|
|
761
|
+
|
|
762
|
+
title = ''
|
|
763
|
+
for line in content_lines:
|
|
764
|
+
if line.strip():
|
|
765
|
+
title = line.strip()
|
|
766
|
+
break
|
|
767
|
+
|
|
768
|
+
return PlanBlock(
|
|
769
|
+
title=title,
|
|
770
|
+
content=content,
|
|
771
|
+
is_streaming=False,
|
|
772
|
+
start_row=first_row,
|
|
773
|
+
ansi_content=ansi_content,
|
|
774
|
+
)
|
|
775
|
+
|
|
776
|
+
def _parse_status_block(
|
|
777
|
+
self, first_line: str,
|
|
778
|
+
ansi_first_line: str = '', indicator: str = '', ansi_indicator: str = '',
|
|
779
|
+
) -> Optional[StatusLine]:
|
|
780
|
+
"""解析状态行:✱ Action... (Xm Ys · ↓ Nk tokens)"""
|
|
781
|
+
rest = first_line[1:].strip() # 去掉首列星星字符
|
|
782
|
+
paren_match = re.search(r'\(([^)]+)\)\s*$', rest)
|
|
783
|
+
if not paren_match:
|
|
784
|
+
# 无统计括号(thinking 动画等),仍返回 StatusLine 保证 is_busy 可靠
|
|
785
|
+
return StatusLine(
|
|
786
|
+
action=rest, elapsed='', tokens='', raw=first_line,
|
|
787
|
+
ansi_raw=ansi_first_line, indicator=indicator, ansi_indicator=ansi_indicator,
|
|
788
|
+
)
|
|
789
|
+
stats_text = paren_match.group(1)
|
|
790
|
+
action = rest[:paren_match.start()].strip()
|
|
791
|
+
elapsed = tokens = ''
|
|
792
|
+
for part in [p.strip() for p in stats_text.split('·')]:
|
|
793
|
+
if re.search(r'\d+[mhs]', part):
|
|
794
|
+
elapsed = part
|
|
795
|
+
elif '↓' in part or 'token' in part.lower():
|
|
796
|
+
tokens = part
|
|
797
|
+
return StatusLine(
|
|
798
|
+
action=action, elapsed=elapsed, tokens=tokens, raw=first_line,
|
|
799
|
+
ansi_raw=ansi_first_line, indicator=indicator, ansi_indicator=ansi_indicator,
|
|
800
|
+
)
|
|
801
|
+
|
|
802
|
+
def _parse_system_block(
|
|
803
|
+
self, screen: pyte.Screen, first_row: int,
|
|
804
|
+
block_rows: List[int], lines: List[str], col0: str,
|
|
805
|
+
) -> SystemBlock:
|
|
806
|
+
"""解析系统提示块:首列星号字符不闪烁(blink=False)的 block"""
|
|
807
|
+
ind = col0
|
|
808
|
+
ansi_ind = _get_col0_ansi(screen, first_row)
|
|
809
|
+
first_content = lines[0][1:].strip()
|
|
810
|
+
body_lines = lines[1:]
|
|
811
|
+
content = '\n'.join([first_content] + body_lines).rstrip()
|
|
812
|
+
ansi_first = _get_row_ansi_text(screen, first_row, start_col=1).strip()
|
|
813
|
+
ansi_body = [_get_row_ansi_text(screen, r) for r in block_rows[1:]]
|
|
814
|
+
ansi_content = '\n'.join([ansi_first] + ansi_body).rstrip()
|
|
815
|
+
return SystemBlock(
|
|
816
|
+
content=content,
|
|
817
|
+
start_row=first_row,
|
|
818
|
+
ansi_content=ansi_content,
|
|
819
|
+
indicator=ind,
|
|
820
|
+
ansi_indicator=ansi_ind,
|
|
821
|
+
)
|
|
822
|
+
|
|
823
|
+
# ─── 用户输入区解析 ────────────────────────────────────────────────────
|
|
824
|
+
|
|
825
|
+
def _parse_input_area(
|
|
826
|
+
self,
|
|
827
|
+
screen: pyte.Screen,
|
|
828
|
+
input_rows: List[int],
|
|
829
|
+
bottom_rows: List[int],
|
|
830
|
+
overflow_out: List[int],
|
|
831
|
+
) -> Optional[OptionBlock]:
|
|
832
|
+
"""解析用户输入区的 OptionBlock(编号选项检测),并检测溢出到底部栏的尾部选项"""
|
|
833
|
+
if not input_rows:
|
|
834
|
+
return None
|
|
835
|
+
|
|
836
|
+
# 入口检测:是否有编号选项行(需 ❯ 锚点)
|
|
837
|
+
if not _has_numbered_options(screen, input_rows):
|
|
838
|
+
return None
|
|
839
|
+
|
|
840
|
+
# 收集行文本,用 _find_contiguous_options 定位连续选项范围
|
|
841
|
+
NAV_RE = re.compile(r'(Enter to select|↑/↓|Esc to cancel|to navigate)')
|
|
842
|
+
row_texts = [_get_row_text(screen, r).strip() for r in input_rows]
|
|
843
|
+
cursor_idx, first_opt_idx, last_opt_idx = _find_contiguous_options(row_texts, NAV_RE)
|
|
844
|
+
if cursor_idx < 0:
|
|
845
|
+
return None
|
|
846
|
+
|
|
847
|
+
# 向前收集 tag/question 行(first_opt_idx 之前)
|
|
848
|
+
tag = ''
|
|
849
|
+
question = ''
|
|
850
|
+
pre_contents: List[str] = []
|
|
851
|
+
for i in range(first_opt_idx):
|
|
852
|
+
text = row_texts[i]
|
|
853
|
+
if not text:
|
|
854
|
+
continue
|
|
855
|
+
if NAV_RE.search(text):
|
|
856
|
+
continue
|
|
857
|
+
pre_contents.append(text)
|
|
858
|
+
if pre_contents:
|
|
859
|
+
first = pre_contents[0]
|
|
860
|
+
if len(pre_contents) >= 2:
|
|
861
|
+
tag = first
|
|
862
|
+
question = pre_contents[-1]
|
|
863
|
+
else:
|
|
864
|
+
if '?' in first or '?' in first:
|
|
865
|
+
question = first
|
|
866
|
+
else:
|
|
867
|
+
tag = first
|
|
868
|
+
|
|
869
|
+
# 选项范围行 + 溢出检测
|
|
870
|
+
option_input_rows = input_rows[first_opt_idx:last_opt_idx + 1]
|
|
871
|
+
overflow = self._detect_option_overflow(screen, option_input_rows, bottom_rows)
|
|
872
|
+
overflow_out.extend(overflow)
|
|
873
|
+
all_option_rows = option_input_rows + overflow
|
|
874
|
+
|
|
875
|
+
options: List[dict] = []
|
|
876
|
+
current_opt: Optional[dict] = None
|
|
877
|
+
ansi_raw_lines = [_get_row_ansi_text(screen, r) for r in input_rows + overflow]
|
|
878
|
+
|
|
879
|
+
for row in all_option_rows:
|
|
880
|
+
line = _get_row_text(screen, row).strip()
|
|
881
|
+
if not line:
|
|
882
|
+
continue
|
|
883
|
+
if NAV_RE.search(line):
|
|
884
|
+
continue
|
|
885
|
+
# 编号选项行
|
|
886
|
+
m = re.match(r'^(?:❯\s*)?(\d+)[.)]\s*(.+)', line)
|
|
887
|
+
if m:
|
|
888
|
+
if current_opt is not None:
|
|
889
|
+
options.append(current_opt)
|
|
890
|
+
current_opt = {
|
|
891
|
+
'label': m.group(2).strip(),
|
|
892
|
+
'value': m.group(1),
|
|
893
|
+
'description': '',
|
|
894
|
+
}
|
|
895
|
+
elif current_opt is not None and line:
|
|
896
|
+
# 描述行
|
|
897
|
+
current_opt['description'] = (
|
|
898
|
+
current_opt['description'] + ' ' + line
|
|
899
|
+
).strip()
|
|
900
|
+
|
|
901
|
+
if current_opt is not None:
|
|
902
|
+
options.append(current_opt)
|
|
903
|
+
|
|
904
|
+
if question or options:
|
|
905
|
+
return OptionBlock(
|
|
906
|
+
sub_type='option', tag=tag, question=question, options=options,
|
|
907
|
+
ansi_raw='\n'.join(ansi_raw_lines).rstrip(),
|
|
908
|
+
)
|
|
909
|
+
return None
|
|
910
|
+
|
|
911
|
+
def _extract_input_area_text(self, screen: pyte.Screen, input_rows: List[int]) -> str:
|
|
912
|
+
"""提取输入区 ❯ 提示符后的当前输入文本(空提示符返回空字符串)"""
|
|
913
|
+
for row in input_rows:
|
|
914
|
+
if _get_col0(screen, row) == '❯':
|
|
915
|
+
text = _get_row_text(screen, row)[1:].strip()
|
|
916
|
+
# 排除纯分割线装饰行(如 ❯─────)
|
|
917
|
+
if text and not all(c in DIVIDER_CHARS for c in text):
|
|
918
|
+
return text
|
|
919
|
+
return ''
|
|
920
|
+
|
|
921
|
+
def _extract_input_area_ansi_text(self, screen: pyte.Screen, input_rows: List[int]) -> str:
|
|
922
|
+
"""提取输入区 ❯ 提示符后的当前输入文本(ANSI 版本)"""
|
|
923
|
+
for row in input_rows:
|
|
924
|
+
if _get_col0(screen, row) == '❯':
|
|
925
|
+
text = _get_row_text(screen, row)[1:].strip()
|
|
926
|
+
if text and not all(c in DIVIDER_CHARS for c in text):
|
|
927
|
+
return _get_row_ansi_text(screen, row, start_col=1).strip()
|
|
928
|
+
return ''
|
|
929
|
+
|
|
930
|
+
def _parse_permission_area(
|
|
931
|
+
self,
|
|
932
|
+
screen: pyte.Screen,
|
|
933
|
+
bottom_rows: List[int],
|
|
934
|
+
) -> Optional[OptionBlock]:
|
|
935
|
+
"""解析 1 条分割线布局下的权限确认区域,返回 OptionBlock(sub_type="permission")
|
|
936
|
+
|
|
937
|
+
检测条件:bottom_rows 中含 ❯ 锚点 + ≥2 个编号选项行。
|
|
938
|
+
通过 _find_contiguous_options 以 ❯ 为锚点双向扫描,只收集连续编号选项。
|
|
939
|
+
"""
|
|
940
|
+
if not bottom_rows:
|
|
941
|
+
return None
|
|
942
|
+
|
|
943
|
+
# 收集所有非空行文本和 ANSI 文本
|
|
944
|
+
lines: List[str] = []
|
|
945
|
+
ansi_lines: List[str] = []
|
|
946
|
+
for r in bottom_rows:
|
|
947
|
+
text = _get_row_text(screen, r).strip()
|
|
948
|
+
if text:
|
|
949
|
+
lines.append(text)
|
|
950
|
+
ansi_lines.append(_get_row_ansi_text(screen, r).rstrip())
|
|
951
|
+
|
|
952
|
+
if not lines:
|
|
953
|
+
return None
|
|
954
|
+
|
|
955
|
+
# 用锚点 + 连续性定位选项范围
|
|
956
|
+
NAV_RE = re.compile(r'(Esc to cancel|Tab to amend|to navigate|Enter to select|↑/↓)')
|
|
957
|
+
cursor_idx, first_opt_idx, last_opt_idx = _find_contiguous_options(lines, NAV_RE)
|
|
958
|
+
if cursor_idx < 0:
|
|
959
|
+
return None
|
|
960
|
+
|
|
961
|
+
# 至少 2 个编号行
|
|
962
|
+
opt_count = 0
|
|
963
|
+
for i in range(first_opt_idx, last_opt_idx + 1):
|
|
964
|
+
if _NUMBERED_OPTION_RE.match(lines[i]):
|
|
965
|
+
opt_count += 1
|
|
966
|
+
if opt_count < 2:
|
|
967
|
+
return None
|
|
968
|
+
|
|
969
|
+
# 分类每行(仅范围内的编号行标记为 option)
|
|
970
|
+
option_idx_set = set(range(first_opt_idx, last_opt_idx + 1))
|
|
971
|
+
classified: List[tuple] = []
|
|
972
|
+
for i, line in enumerate(lines):
|
|
973
|
+
if i in option_idx_set and _NUMBERED_OPTION_RE.match(line):
|
|
974
|
+
classified.append((line, 'option'))
|
|
975
|
+
elif NAV_RE.search(line):
|
|
976
|
+
classified.append((line, 'nav'))
|
|
977
|
+
else:
|
|
978
|
+
classified.append((line, 'content'))
|
|
979
|
+
|
|
980
|
+
# title / content / question 提取逻辑不变
|
|
981
|
+
title = ''
|
|
982
|
+
question = ''
|
|
983
|
+
options: List[dict] = []
|
|
984
|
+
content_lines: List[str] = []
|
|
985
|
+
|
|
986
|
+
# 收集第一个 option 之前的所有 content 行
|
|
987
|
+
pre_option_contents: List[str] = []
|
|
988
|
+
for i in range(first_opt_idx):
|
|
989
|
+
line, cat = classified[i]
|
|
990
|
+
if cat == 'content':
|
|
991
|
+
pre_option_contents.append(line)
|
|
992
|
+
|
|
993
|
+
if pre_option_contents:
|
|
994
|
+
if len(pre_option_contents) == 1:
|
|
995
|
+
question = pre_option_contents[0]
|
|
996
|
+
else:
|
|
997
|
+
title = pre_option_contents[0]
|
|
998
|
+
question = pre_option_contents[-1]
|
|
999
|
+
content_lines = pre_option_contents[1:-1]
|
|
1000
|
+
|
|
1001
|
+
# 只收集范围内的 options
|
|
1002
|
+
for i in range(first_opt_idx, last_opt_idx + 1):
|
|
1003
|
+
line, cat = classified[i]
|
|
1004
|
+
if cat == 'option':
|
|
1005
|
+
m = re.match(r'^(?:❯\s*)?(\d+)[.)]\s*(.+)', line)
|
|
1006
|
+
if m:
|
|
1007
|
+
options.append({
|
|
1008
|
+
'label': m.group(2).strip(),
|
|
1009
|
+
'value': m.group(1),
|
|
1010
|
+
})
|
|
1011
|
+
|
|
1012
|
+
return OptionBlock(
|
|
1013
|
+
sub_type='permission',
|
|
1014
|
+
title=title,
|
|
1015
|
+
content='\n'.join(content_lines),
|
|
1016
|
+
question=question,
|
|
1017
|
+
options=options,
|
|
1018
|
+
ansi_raw='\n'.join(ansi_lines).rstrip(),
|
|
1019
|
+
)
|
|
1020
|
+
|
|
1021
|
+
def _parse_agent_list_panel(
|
|
1022
|
+
self,
|
|
1023
|
+
screen: pyte.Screen,
|
|
1024
|
+
bottom_rows: List[int],
|
|
1025
|
+
) -> Optional[AgentPanelBlock]:
|
|
1026
|
+
"""解析 agent 列表面板(用户按 ↓ 展开)
|
|
1027
|
+
|
|
1028
|
+
特征:1 条分割线,分割线下方含 "Background tasks"、agent 列表、导航提示
|
|
1029
|
+
"""
|
|
1030
|
+
if not bottom_rows:
|
|
1031
|
+
return None
|
|
1032
|
+
|
|
1033
|
+
lines: List[str] = []
|
|
1034
|
+
ansi_lines: List[str] = []
|
|
1035
|
+
for r in bottom_rows:
|
|
1036
|
+
text = _get_row_text(screen, r).strip()
|
|
1037
|
+
if text:
|
|
1038
|
+
lines.append(text)
|
|
1039
|
+
ansi_lines.append(_get_row_ansi_text(screen, r).strip())
|
|
1040
|
+
|
|
1041
|
+
if not lines:
|
|
1042
|
+
return None
|
|
1043
|
+
|
|
1044
|
+
agents: List[dict] = []
|
|
1045
|
+
agent_count = 0
|
|
1046
|
+
|
|
1047
|
+
for line in lines:
|
|
1048
|
+
# 跳过导航提示行
|
|
1049
|
+
if re.search(r'(↑/↓\s+to select|esc to close|to navigate)', line, re.IGNORECASE):
|
|
1050
|
+
continue
|
|
1051
|
+
|
|
1052
|
+
# 匹配 "N active agents" 标题行
|
|
1053
|
+
m = re.match(r'(\d+)\s+(?:active\s+)?(?:background\s+)?(?:tasks?|agents?)', line, re.IGNORECASE)
|
|
1054
|
+
if m:
|
|
1055
|
+
agent_count = int(m.group(1))
|
|
1056
|
+
continue
|
|
1057
|
+
|
|
1058
|
+
# 匹配 "Background tasks" 标题行
|
|
1059
|
+
if re.match(r'background\s+tasks?', line, re.IGNORECASE):
|
|
1060
|
+
continue
|
|
1061
|
+
|
|
1062
|
+
# 匹配 agent 行:可选的 ❯ 前缀 + 名称 + (status)
|
|
1063
|
+
m = re.match(r'^(❯\s*)?(.+?)\s*\((running|completed|failed|killed)\)', line, re.IGNORECASE)
|
|
1064
|
+
if m:
|
|
1065
|
+
is_selected = bool(m.group(1))
|
|
1066
|
+
name = m.group(2).strip()
|
|
1067
|
+
status = m.group(3).lower()
|
|
1068
|
+
agents.append({
|
|
1069
|
+
"name": name,
|
|
1070
|
+
"status": status,
|
|
1071
|
+
"is_selected": is_selected,
|
|
1072
|
+
})
|
|
1073
|
+
|
|
1074
|
+
# 如果未从标题行获取 count,使用 agents 列表长度
|
|
1075
|
+
if not agent_count:
|
|
1076
|
+
agent_count = len(agents)
|
|
1077
|
+
|
|
1078
|
+
return AgentPanelBlock(
|
|
1079
|
+
panel_type="list",
|
|
1080
|
+
agent_count=agent_count,
|
|
1081
|
+
agents=agents,
|
|
1082
|
+
raw_text='\n'.join(lines),
|
|
1083
|
+
ansi_raw='\n'.join(ansi_lines),
|
|
1084
|
+
)
|
|
1085
|
+
|
|
1086
|
+
def _parse_agent_detail_panel(
|
|
1087
|
+
self,
|
|
1088
|
+
screen: pyte.Screen,
|
|
1089
|
+
bottom_rows: List[int],
|
|
1090
|
+
) -> Optional[AgentPanelBlock]:
|
|
1091
|
+
"""解析 agent 详情面板(列表中按 Enter)
|
|
1092
|
+
|
|
1093
|
+
特征:1 条分割线,分割线下方含 agent 类型+名称、统计、Progress、Prompt
|
|
1094
|
+
"""
|
|
1095
|
+
if not bottom_rows:
|
|
1096
|
+
return None
|
|
1097
|
+
|
|
1098
|
+
lines: List[str] = []
|
|
1099
|
+
ansi_lines: List[str] = []
|
|
1100
|
+
for r in bottom_rows:
|
|
1101
|
+
text = _get_row_text(screen, r).strip()
|
|
1102
|
+
if text:
|
|
1103
|
+
lines.append(text)
|
|
1104
|
+
ansi_lines.append(_get_row_ansi_text(screen, r).strip())
|
|
1105
|
+
|
|
1106
|
+
if not lines:
|
|
1107
|
+
return None
|
|
1108
|
+
|
|
1109
|
+
agent_type = ''
|
|
1110
|
+
agent_name = ''
|
|
1111
|
+
stats = ''
|
|
1112
|
+
progress_lines: List[str] = []
|
|
1113
|
+
prompt_lines: List[str] = []
|
|
1114
|
+
current_section = None # 'progress' | 'prompt' | None
|
|
1115
|
+
|
|
1116
|
+
for line in lines:
|
|
1117
|
+
# 跳过导航提示行
|
|
1118
|
+
if re.search(r'(← to go back|esc to close)', line, re.IGNORECASE):
|
|
1119
|
+
continue
|
|
1120
|
+
|
|
1121
|
+
# 匹配首行 "type › name" 格式
|
|
1122
|
+
if not agent_type:
|
|
1123
|
+
m = re.match(r'^(.+?)\s*›\s*(.+)', line)
|
|
1124
|
+
if m:
|
|
1125
|
+
agent_type = m.group(1).strip()
|
|
1126
|
+
agent_name = m.group(2).strip()
|
|
1127
|
+
continue
|
|
1128
|
+
|
|
1129
|
+
# 统计行:包含时间和 token 信息
|
|
1130
|
+
if re.search(r'\d+[ms]\s*·.*token', line, re.IGNORECASE):
|
|
1131
|
+
stats = line
|
|
1132
|
+
continue
|
|
1133
|
+
|
|
1134
|
+
# Section 标题
|
|
1135
|
+
if re.match(r'^progress$', line, re.IGNORECASE):
|
|
1136
|
+
current_section = 'progress'
|
|
1137
|
+
continue
|
|
1138
|
+
if re.match(r'^prompt$', line, re.IGNORECASE):
|
|
1139
|
+
current_section = 'prompt'
|
|
1140
|
+
continue
|
|
1141
|
+
|
|
1142
|
+
# Section 内容
|
|
1143
|
+
if current_section == 'progress':
|
|
1144
|
+
progress_lines.append(line)
|
|
1145
|
+
elif current_section == 'prompt':
|
|
1146
|
+
prompt_lines.append(line)
|
|
1147
|
+
|
|
1148
|
+
return AgentPanelBlock(
|
|
1149
|
+
panel_type="detail",
|
|
1150
|
+
agent_name=agent_name,
|
|
1151
|
+
agent_type=agent_type,
|
|
1152
|
+
stats=stats,
|
|
1153
|
+
progress='\n'.join(progress_lines).strip(),
|
|
1154
|
+
prompt='\n'.join(prompt_lines).strip(),
|
|
1155
|
+
raw_text='\n'.join(lines),
|
|
1156
|
+
ansi_raw='\n'.join(ansi_lines),
|
|
1157
|
+
)
|
|
1158
|
+
|
|
1159
|
+
def _detect_option_overflow(
|
|
1160
|
+
self,
|
|
1161
|
+
screen: pyte.Screen,
|
|
1162
|
+
option_rows: List[int],
|
|
1163
|
+
bottom_rows: List[int],
|
|
1164
|
+
) -> List[int]:
|
|
1165
|
+
"""检测溢出到底部栏区域的选项行(最多 2 行)
|
|
1166
|
+
|
|
1167
|
+
识别依据:缩进一致性 + 数字编号连续性
|
|
1168
|
+
"""
|
|
1169
|
+
if not bottom_rows or not option_rows:
|
|
1170
|
+
return []
|
|
1171
|
+
|
|
1172
|
+
# 找输入区中最后一个数字编号
|
|
1173
|
+
last_num = 0
|
|
1174
|
+
for row in reversed(option_rows):
|
|
1175
|
+
line = _get_row_text(screen, row).strip()
|
|
1176
|
+
m = re.match(r'^(?:❯\s*)?(\d+)[.)]\s+', line)
|
|
1177
|
+
if m:
|
|
1178
|
+
last_num = int(m.group(1))
|
|
1179
|
+
break
|
|
1180
|
+
|
|
1181
|
+
if last_num == 0:
|
|
1182
|
+
return []
|
|
1183
|
+
|
|
1184
|
+
overflow: List[int] = []
|
|
1185
|
+
expected = last_num + 1
|
|
1186
|
+
for row in bottom_rows[:2]:
|
|
1187
|
+
line = _get_row_text(screen, row).strip()
|
|
1188
|
+
m = re.match(r'^(?:❯\s*)?(\d+)[.)]\s+', line)
|
|
1189
|
+
if m and int(m.group(1)) == expected:
|
|
1190
|
+
overflow.append(row)
|
|
1191
|
+
expected += 1
|
|
1192
|
+
else:
|
|
1193
|
+
break
|
|
1194
|
+
|
|
1195
|
+
return overflow
|
|
1196
|
+
|
|
1197
|
+
|
|
1198
|
+
|
|
1199
|
+
# ─── 底部栏 Agent 信息解析 ─────────────────────────────────────────────────────
|
|
1200
|
+
|
|
1201
|
+
def _parse_bottom_bar_agents(text: str) -> tuple:
|
|
1202
|
+
"""解析底部栏中的后台 agent 信息
|
|
1203
|
+
|
|
1204
|
+
返回 (has_background_agents, agent_count, agent_summary)
|
|
1205
|
+
"""
|
|
1206
|
+
lower = text.lower()
|
|
1207
|
+
# 检测 "↓ to manage" 关键词(agent 模式的标志)
|
|
1208
|
+
if '↓ to manage' not in lower:
|
|
1209
|
+
return (False, 0, '')
|
|
1210
|
+
|
|
1211
|
+
# 匹配 "N local agents" 或 "N agents"
|
|
1212
|
+
m = re.search(r'(\d+)\s+(local\s+)?agents?', lower)
|
|
1213
|
+
if m:
|
|
1214
|
+
count = int(m.group(1))
|
|
1215
|
+
# 提取原始摘要文本(保留原始大小写)
|
|
1216
|
+
summary_match = re.search(r'(\d+\s+(?:local\s+)?agents?)', text, re.IGNORECASE)
|
|
1217
|
+
summary = summary_match.group(1) if summary_match else f"{count} agents"
|
|
1218
|
+
return (True, count, summary)
|
|
1219
|
+
|
|
1220
|
+
# 匹配 "(running)" 关键词(单个 agent 运行时的格式)
|
|
1221
|
+
if '(running)' in lower:
|
|
1222
|
+
# 提取 agent 名称:分割线前的部分
|
|
1223
|
+
parts = text.split('·')
|
|
1224
|
+
summary = parts[0].strip() if parts else text.strip()
|
|
1225
|
+
return (True, 1, summary)
|
|
1226
|
+
|
|
1227
|
+
return (False, 0, '')
|
|
1228
|
+
|
|
1229
|
+
|
|
1230
|
+
# ─── 公共接口 ──────────────────────────────────────────────────────────────────
|
|
1231
|
+
|
|
1232
|
+
def components_content_key(components: List[Component]) -> str:
|
|
1233
|
+
"""生成组件列表内容指纹,用于去重"""
|
|
1234
|
+
parts = []
|
|
1235
|
+
for c in components:
|
|
1236
|
+
if isinstance(c, OutputBlock):
|
|
1237
|
+
first = c.content.split('\n')[0][:100]
|
|
1238
|
+
parts.append(f"OB:{first}:{c.is_streaming}")
|
|
1239
|
+
elif isinstance(c, UserInput):
|
|
1240
|
+
parts.append(f"U:{c.text}")
|
|
1241
|
+
elif isinstance(c, OptionBlock):
|
|
1242
|
+
if c.sub_type == 'permission':
|
|
1243
|
+
parts.append(f"Perm:{c.question[:50]}:{len(c.options)}")
|
|
1244
|
+
else:
|
|
1245
|
+
parts.append(f"Opt:{c.question[:50]}:{len(c.options)}")
|
|
1246
|
+
elif isinstance(c, AgentPanelBlock):
|
|
1247
|
+
if c.panel_type == 'detail':
|
|
1248
|
+
parts.append(f"AP:{c.agent_name[:50]}")
|
|
1249
|
+
elif c.panel_type == 'summary':
|
|
1250
|
+
parts.append(f"AP:summary:{c.agent_count}")
|
|
1251
|
+
else:
|
|
1252
|
+
parts.append(f"AP:list:{c.agent_count}")
|
|
1253
|
+
elif isinstance(c, PlanBlock):
|
|
1254
|
+
parts.append(f"PL:{c.title[:50]}")
|
|
1255
|
+
elif isinstance(c, StatusLine):
|
|
1256
|
+
parts.append(f"S:{c.action}:{c.elapsed}")
|
|
1257
|
+
elif isinstance(c, BottomBar):
|
|
1258
|
+
parts.append(f"BB:{c.text[:100]}")
|
|
1259
|
+
return '|'.join(parts)
|
|
1260
|
+
|
|
1261
|
+
|
|
1262
|
+
# 向后兼容别名(供 component_parser.py shim 使用)
|
|
1263
|
+
ScreenParser = ClaudeParser
|