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