remote-claude 0.2.13 → 0.2.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,14 +1,11 @@
1
1
  """Codex CLI 输出组件解析器
2
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
- - 选项/权限交互格式
3
+ 独立解析器,专门处理 Codex CLI 的终端输出格式。与 ClaudeParser 的关键差异:
4
+ - STAR_CHARS:不使用星星字符,StatusLine 改为 DOT_CHARS blink 检测
5
+ - 用户输入指示符:`›` (U+203A) / `>` (U+003E),与 ClaudeParser `❯` 不同
6
+ - 区域分割:无 ─━═ 字符分割线,用背景色区域(连续 bg 行 + 首尾纯背景色边界)识别输入区域
7
+ - 欢迎框:`>_ OpenAI Codex` + `model:` + `directory:` 特征,无固定行号
8
+ - 选项交互:编号选项 + Enter/Esc 导航提示,通过提示符颜色和上方签名区分普通/选项模式
12
9
  """
13
10
 
14
11
  import re
@@ -35,6 +32,7 @@ STAR_CHARS: Set[str] = set(
35
32
  '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
36
33
  '△' # Codex 系统警告(不闪烁 → SystemBlock)
37
34
  '⚠' # Codex 警告(不闪烁 → SystemBlock)
35
+ '■' # Codex 错误提示(不闪烁 → SystemBlock)
38
36
  )
39
37
 
40
38
  # 圆点字符集(OutputBlock 首列)
@@ -111,9 +109,9 @@ def _strip_inline_boxes_pair(content: str, ansi_content: str) -> tuple:
111
109
 
112
110
 
113
111
  # 编号选项行正则(权限确认对话框特征:> 1. Yes / 2. No 等)
114
- _NUMBERED_OPTION_RE = re.compile(r'^(?:>\s*)?\d+[.)]\s+.+')
115
- # 带 > 光标的编号选项行正则(锚点)
116
- _CURSOR_OPTION_RE = re.compile(r'^>\s*(\d+)[.)]\s+.+')
112
+ _NUMBERED_OPTION_RE = re.compile(r'^(?:[>❯›]\s*)?\d+[.)]\s+.+')
113
+ # 带 > / ❯ / › 光标的编号选项行正则(锚点)
114
+ _CURSOR_OPTION_RE = re.compile(r'^[>❯›]\s*(\d+)[.)]\s+.+')
117
115
 
118
116
  # Codex 状态行检测(首列 ● blink=True + 内容含 "esc to interrupt")
119
117
 
@@ -197,7 +195,7 @@ def _get_row_ansi_text(screen: pyte.Screen, row: int, start_col: int = 0) -> str
197
195
  """提取指定行带 ANSI 转义码的文本。
198
196
 
199
197
  先确定有效列范围(与 _get_row_text 的 rstrip 等价),仅在有效范围内生成 ANSI 码。
200
- start_col 用于跳过首列特殊字符(圆点/星星/❯)。
198
+ start_col 用于跳过首列特殊字符(圆点/星星/›)。
201
199
  """
202
200
  buf_row = screen.buffer[row]
203
201
 
@@ -293,36 +291,42 @@ def _is_bright_color(color: str) -> bool:
293
291
  return True
294
292
 
295
293
 
296
- def _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:
294
+ def _is_white_color(color: str) -> bool:
295
+ """判断颜色是否为白色/亮白色"""
296
+ if not color or color == 'default':
306
297
  return False
298
+ key = color.lower().replace(' ', '').replace('-', '')
299
+ if key in ('white', 'brightwhite'):
300
+ return True
301
+ # hex 颜色:R/G/B 都 > 200
302
+ if len(color) == 6:
303
+ try:
304
+ r, g, b = int(color[0:2], 16), int(color[2:4], 16), int(color[4:6], 16)
305
+ return r > 200 and g > 200 and b > 200
306
+ except ValueError:
307
+ pass
308
+ return False
307
309
 
308
- # 检查首字符前景色是亮色
309
- try:
310
- char = screen.buffer[row][0]
311
- fg = getattr(char, 'fg', 'default') or 'default'
312
- if not _is_bright_color(fg):
313
- return False # 前景色不是亮色(可能是暗色或 default)
314
- except (KeyError, IndexError):
315
- return False
316
310
 
317
- # 严格检查:上一行、当前行、下一行都必须有整行背景色
318
- 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):
311
+ def _is_light_blue_color(color: str) -> bool:
312
+ """判断颜色是否为浅蓝色/青色"""
313
+ if not color or color == 'default':
323
314
  return False
324
-
325
- return True
315
+ key = color.lower().replace(' ', '').replace('-', '')
316
+ if key in ('cyan', 'brightcyan', 'brightblue'):
317
+ return True
318
+ # hex 颜色:偏蓝(B > R 且 B > G 且整体较亮)
319
+ if len(color) == 6:
320
+ try:
321
+ r, g, b = int(color[0:2], 16), int(color[2:4], 16), int(color[4:6], 16)
322
+ if b > r and b > g and (r + g + b) > 200:
323
+ return True
324
+ # 青色:G 和 B 都高
325
+ if g > 150 and b > 150 and r < 150:
326
+ return True
327
+ except ValueError:
328
+ pass
329
+ return False
326
330
 
327
331
 
328
332
  def _get_row_text(screen: pyte.Screen, row: int) -> str:
@@ -351,26 +355,17 @@ def _get_col0_blink(screen: pyte.Screen, row: int) -> bool:
351
355
  return False
352
356
 
353
357
 
354
- def _is_divider_row(screen: pyte.Screen, row: int) -> bool:
355
- """判断整行是否为分割线:所有非空字符均为分割线字符,不限制行长度"""
356
- found = False
357
- for col in range(screen.columns):
358
- c = screen.buffer[row][col].data
359
- if not c.strip():
360
- continue
361
- if c not in DIVIDER_CHARS:
362
- return False # 发现非分割线字符,立即短路
363
- found = True
364
- return found
365
358
 
359
+ def _is_pure_bg_row(screen: pyte.Screen, row: int) -> bool:
360
+ """纯背景色行:整行有非默认背景色但无文字内容(作为背景色区域的边界标记)。
366
361
 
367
- def _is_bg_divider_row(screen: pyte.Screen, row: int) -> bool:
368
- """判断是否为背景色分割线:整行有非默认背景色但无文字内容。
362
+ 等同于 Claude Code ─━═ 字符分割线,但以背景色实现。
363
+ 与底部栏区别:底部栏有文字内容,纯背景色行只有 bg 色的空格。
369
364
 
370
- Codex 用这类行代替 ─━═ 字符分割线,UserInput 和输入区上下各有一条。
371
- 与底部栏区别:底部栏有文字内容,背景色分割线只有 bg 色的空格。
365
+ 注意:使用 _has_row_bg(检测所有列含空格)而非 _get_row_dominant_bg
366
+ (只统计非空格字符),确保纯空格背景行能被正确识别。
372
367
  """
373
- if _get_row_dominant_bg(screen, row) == 'default':
368
+ if not _has_row_bg(screen, row):
374
369
  return False
375
370
  return _get_row_text(screen, row).strip() == ''
376
371
 
@@ -471,7 +466,7 @@ def _find_contiguous_options(lines, nav_re):
471
466
  line = lines[i]
472
467
  if not line or nav_re.search(line):
473
468
  continue
474
- m = re.match(r'^(?:>\s*)?(\d+)[.)]\s+', line)
469
+ m = re.match(r'^(?:[>❯›]\s*)?(\d+)[.)]\s+', line)
475
470
  if m:
476
471
  if int(m.group(1)) == expected:
477
472
  first_option_idx = i
@@ -487,7 +482,7 @@ def _find_contiguous_options(lines, nav_re):
487
482
  line = lines[i]
488
483
  if not line or nav_re.search(line):
489
484
  continue
490
- m = re.match(r'^(?:>\s*)?(\d+)[.)]\s+', line)
485
+ m = re.match(r'^(?:[>❯›]\s*)?(\d+)[.)]\s+', line)
491
486
  if m:
492
487
  if int(m.group(1)) == expected:
493
488
  last_option_idx = i
@@ -512,13 +507,15 @@ class CodexParser(BaseParser):
512
507
  # 星号滑动窗口(1.5秒):记录每行最近 1.5 秒内出现的 (timestamp, char),
513
508
  # 窗口内 ≥2 种不同字符 → spinner 旋转 → StatusLine;始终只有 1 种字符 → SystemBlock
514
509
  self._star_row_history: Dict[int, deque] = {}
515
- # 最近一次解析到的输入区 文本(用于 MessageQueue 追踪变更)
510
+ # 最近一次解析到的输入区 文本(用于 MessageQueue 追踪变更)
516
511
  self.last_input_text: str = ''
517
512
  self.last_input_ansi_text: str = ''
518
513
  # 最近一次 parse 的内部耗时(供外部写日志用)
519
514
  self.last_parse_timing: str = ''
520
515
  # 布局模式:"normal" | "option" | "detail" | "agent_list" | "agent_detail"
521
516
  self.last_layout_mode: str = 'normal'
517
+ # Pass 1 区域切分确定的布局模式(None 表示 Pass 1 未成功)
518
+ self._pass1_mode: Optional[str] = None
522
519
 
523
520
  def parse(self, screen: pyte.Screen) -> List[Component]:
524
521
  """解析 pyte 屏幕,返回组件列表"""
@@ -529,11 +526,13 @@ class CodexParser(BaseParser):
529
526
  output_rows, input_rows, bottom_rows = self._split_regions(screen)
530
527
  _t1 = _time.perf_counter()
531
528
 
532
- # 布局模式判定
529
+ # 布局模式判定(优先使用 Pass 1 的结果)
533
530
  prev_mode = self.last_layout_mode
534
- if input_rows:
531
+ if self._pass1_mode is not None:
532
+ self.last_layout_mode = self._pass1_mode
533
+ elif input_rows:
535
534
  if _has_numbered_options(screen, input_rows):
536
- self.last_layout_mode = 'option' # 2 分割线 + 编号选项
535
+ self.last_layout_mode = 'option' # 回退路径:编号选项检测
537
536
  else:
538
537
  self.last_layout_mode = 'normal'
539
538
  elif bottom_rows:
@@ -561,7 +560,7 @@ class CodexParser(BaseParser):
561
560
  self._dot_attr_cache.clear()
562
561
  self._star_row_history.clear()
563
562
 
564
- # 提取输入区 文本(用于 MessageQueue 追踪变更)
563
+ # 提取输入区 文本(用于 MessageQueue 追踪变更)
565
564
  self.last_input_text = self._extract_input_area_text(screen, input_rows)
566
565
  self.last_input_ansi_text = self._extract_input_area_ansi_text(screen, input_rows)
567
566
 
@@ -611,7 +610,7 @@ class CodexParser(BaseParser):
611
610
  if text:
612
611
  bottom_parts.append(text)
613
612
  ansi_bottom_parts.append(_get_row_ansi_text(screen, r).strip())
614
- if bottom_parts and not option and not agent_panel:
613
+ if bottom_parts and not agent_panel:
615
614
  bar_text = '\n'.join(bottom_parts)
616
615
  bar_ansi = '\n'.join(ansi_bottom_parts)
617
616
  has_agents, agent_count, agent_summary = _parse_bottom_bar_agents(bar_text)
@@ -637,58 +636,47 @@ class CodexParser(BaseParser):
637
636
  def _split_regions(
638
637
  self, screen: pyte.Screen
639
638
  ) -> Tuple[List[int], List[int], List[int]]:
640
- """Codex 无 ─━═ 分割线,用背景色分割线(整行 bg 色且无文字)定位区域。
639
+ """Codex 无 ─━═ 分割线,用背景色区域(连续 bg 行 + 首尾纯背景色边界)定位输入区域。
641
640
 
642
- Codex UserInput 和输入区上下各有一条背景色分割线,效果等同 ─━═。
643
- 从底部向上找最后两条背景色分割线,按以下结构拆分:
644
- [输出区] / [bg divider] / [› 输入行] / [bg divider] / [底部栏]
641
+ 背景色区域:连续 3 行以上都有背景色,且第一行和最后一行是纯背景色(无文字),
642
+ 整个区域即为输入区域。
645
643
 
646
644
  优先级:
647
- 1. 背景色分割线(强):从下往上找最后两条无文字 bg
648
- 2. 精确检测输入区域首行(亮起的+ 连续三行背景色):
649
- - 行首是 ›(CODEX_PROMPT_CHARS)
650
- - 首字符前景色是亮色(非 default 且非暗色)
651
- - 上一行、当前行、下一行都有整行背景色(严格检查)
652
- 3. 位置弱信号(回退):找最后一个其后无 block 字符的 › 行
645
+ 1. 背景色区域(强):_find_bg_region 找连续 bg zone 内首尾纯背景色边界对
646
+ 2. 宽松亮色检测(回退):只检查行首字符和前景色
647
+ 3. 位置弱信号:找最后一个其后无 block 字符的 › 行
653
648
  4. 纯背景色兜底:无 › 时用 _find_chrome_boundary
654
649
  """
655
650
  scan_limit = min(screen.cursor.y + 5, screen.lines - 1)
656
651
  _BLOCK_CHARS = DOT_CHARS | CODEX_PROMPT_CHARS | STAR_CHARS
657
652
 
658
- # Pass 1:背景色分割线(最强信号,等同 ─━═)
659
- 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))
653
+ # Pass 1:找"背景色区域"(连续 bg zone 内首尾纯背景色边界对,区域 >= 3 行)
654
+ self._pass1_mode = None
655
+ bg_region = self._find_bg_region(screen, scan_limit)
656
+ if bg_region is not None:
657
+ region_start, region_end = bg_region
658
+ content_rows = list(range(region_start + 1, region_end))
659
+ # 确定布局模式
660
+ mode = self._determine_input_mode(screen, region_start, content_rows)
661
+ self._pass1_mode = mode
662
+ output_rows = self._trim_welcome(screen, list(range(region_start)))
663
+ input_rows = content_rows
664
+ bottom_rows = list(range(region_end + 1, scan_limit + 1))
670
665
  return output_rows, input_rows, bottom_rows
671
666
 
672
- # Pass 2:精确检测输入区域首行(亮起的+ 连续三行背景色)
667
+ # Pass 2:宽松检测亮起的行(只检查行首字符和前景色,不检查背景色)
673
668
  input_boundary = None
674
669
  for row in range(scan_limit, -1, -1):
675
- 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
670
+ col0 = _get_col0(screen, row)
671
+ if col0 in CODEX_PROMPT_CHARS:
672
+ try:
673
+ char = screen.buffer[row][0]
674
+ fg = getattr(char, 'fg', 'default') or 'default'
675
+ if _is_bright_color(fg):
676
+ input_boundary = row
677
+ break
678
+ except (KeyError, IndexError):
679
+ pass
692
680
 
693
681
  # Pass 3:位置弱信号
694
682
  if input_boundary is None:
@@ -719,6 +707,141 @@ class CodexParser(BaseParser):
719
707
  output_rows = self._trim_welcome(screen, list(range(scan_limit + 1)))
720
708
  return output_rows, [], []
721
709
 
710
+ def _find_bg_region(
711
+ self, screen: pyte.Screen, scan_limit: int
712
+ ) -> Optional[Tuple[int, int]]:
713
+ """在连续 bg zone 内找背景色区域(首尾纯背景色边界对,区域 >= 3 行)。
714
+
715
+ 算法:
716
+ 1. 从 scan_limit 往上扫描找连续 bg 行 zone(用 _has_row_bg)
717
+ 2. zone 内找所有纯背景色行(用 _is_pure_bg_row)
718
+ 3. 取首条和末条纯背景色行作为边界对
719
+ 4. 验证 region_end - region_start >= 2(至少 3 行:边界 + 内容行)
720
+
721
+ Returns: (region_start, region_end) 含边界行,或 None
722
+ """
723
+ zone_end: Optional[int] = None
724
+ zone_start: Optional[int] = None
725
+ for row in range(scan_limit, -1, -1):
726
+ if _has_row_bg(screen, row):
727
+ if zone_end is None:
728
+ zone_end = row
729
+ zone_start = row
730
+ elif zone_end is not None:
731
+ break # 遇到无 bg 行,zone 边界确定
732
+
733
+ if zone_start is None or zone_end is None:
734
+ return None
735
+
736
+ # 在 zone 内找所有纯背景色行(背景色区域边界候选)
737
+ pure_bg_rows = [r for r in range(zone_start, zone_end + 1)
738
+ if _is_pure_bg_row(screen, r)]
739
+ if len(pure_bg_rows) < 2:
740
+ return None
741
+
742
+ region_start, region_end = pure_bg_rows[0], pure_bg_rows[-1]
743
+ # 区域至少 3 行(边界对 + 至少 1 行内容)
744
+ if region_end - region_start < 2:
745
+ return None
746
+
747
+ return region_start, region_end
748
+
749
+ def _determine_input_mode(
750
+ self, screen: pyte.Screen, region_start: int, content_rows: List[int]
751
+ ) -> str:
752
+ """判断背景色区域的布局模式:'option' 或 'normal'。
753
+
754
+ 判断优先级:
755
+ 1. 条件 4b:首个内容行行首是 ›,白色/亮色 → 'normal'
756
+ 2. 条件 4a:上方依次是空行+分割线+空行 → 'option'
757
+ 3. 条件 4c:内容行中有浅蓝色 › 且整行同色 → 'option'
758
+ 4. 兜底:_has_numbered_options → 'option',否则 'normal'
759
+ """
760
+ if not content_rows:
761
+ return 'normal'
762
+
763
+ # 条件 4b:首个内容行行首是 › + 白色/亮色 → normal
764
+ first_content = content_rows[0]
765
+ col0 = _get_col0(screen, first_content)
766
+ if col0 in CODEX_PROMPT_CHARS:
767
+ try:
768
+ char = screen.buffer[first_content][0]
769
+ fg = getattr(char, 'fg', 'default') or 'default'
770
+ if _is_white_color(fg) or _is_bright_color(fg):
771
+ return 'normal'
772
+ except (KeyError, IndexError):
773
+ pass
774
+
775
+ # 条件 4a:上方有空行+分割线+空行 → option
776
+ if self._has_option_context_above(screen, region_start):
777
+ return 'option'
778
+
779
+ # 条件 4c:内容行中有浅蓝色 › 且整行同色 → option
780
+ for row in content_rows:
781
+ col0 = _get_col0(screen, row)
782
+ if col0 in CODEX_PROMPT_CHARS:
783
+ try:
784
+ char = screen.buffer[row][0]
785
+ fg = getattr(char, 'fg', 'default') or 'default'
786
+ if _is_light_blue_color(fg) and self._is_whole_row_same_fg(screen, row, fg):
787
+ return 'option'
788
+ except (KeyError, IndexError):
789
+ pass
790
+
791
+ # 兜底:编号选项检测
792
+ if _has_numbered_options(screen, content_rows):
793
+ return 'option'
794
+
795
+ return 'normal'
796
+
797
+ def _has_option_context_above(self, screen: pyte.Screen, region_start: int) -> bool:
798
+ """检测条件 4a:背景色区域上方依次是
799
+ 默认背景色空行 → 默认背景色普通分割线(─━═)→ 默认背景色空行
800
+ """
801
+ if region_start < 3:
802
+ return False
803
+
804
+ empty_below = region_start - 1
805
+ divider_row = region_start - 2
806
+ empty_above = region_start - 3
807
+
808
+ # 检查 empty_below:默认 bg + 无文字
809
+ if _get_row_dominant_bg(screen, empty_below) != 'default':
810
+ return False
811
+ if _get_row_text(screen, empty_below).strip():
812
+ return False
813
+
814
+ # 检查 divider_row:默认 bg + 含 ─━═ 字符
815
+ if _get_row_dominant_bg(screen, divider_row) != 'default':
816
+ return False
817
+ divider_text = _get_row_text(screen, divider_row).strip()
818
+ if not divider_text or not any(c in DIVIDER_CHARS for c in divider_text):
819
+ return False
820
+
821
+ # 检查 empty_above:默认 bg + 无文字
822
+ if _get_row_dominant_bg(screen, empty_above) != 'default':
823
+ return False
824
+ if _get_row_text(screen, empty_above).strip():
825
+ return False
826
+
827
+ return True
828
+
829
+ def _is_whole_row_same_fg(self, screen: pyte.Screen, row: int, expected_fg: str) -> bool:
830
+ """检查整行非空字符是否都有相同的前景色(条件 4c 的整行同色检测)"""
831
+ has_chars = False
832
+ for col in range(screen.columns):
833
+ try:
834
+ char = screen.buffer[row][col]
835
+ if not char.data.strip():
836
+ continue
837
+ has_chars = True
838
+ fg = getattr(char, 'fg', 'default') or 'default'
839
+ if fg != expected_fg:
840
+ return False
841
+ except (KeyError, IndexError):
842
+ continue
843
+ return has_chars
844
+
722
845
  def _find_chrome_boundary(self, screen: pyte.Screen, scan_limit: int) -> Optional[int]:
723
846
  """背景色检测:从底部往上找连续的非默认背景行(UI chrome)。
724
847
 
@@ -917,7 +1040,7 @@ class CodexParser(BaseParser):
917
1040
  # UserInput:› U+203A(Codex 实际使用字符)或 > U+003E(兼容)
918
1041
  if col0 in CODEX_PROMPT_CHARS:
919
1042
  first_text = lines[0][1:].strip()
920
- # 内容全是分割线字符(如 ❯─────...─)→ 装饰性分隔符,忽略
1043
+ # 内容全是分割线字符(如 ›─────...─)→ 装饰性分隔符,忽略
921
1044
  if not first_text or all(c in DIVIDER_CHARS for c in first_text):
922
1045
  return None
923
1046
  # 收集后续续行(多行输入 / 屏幕自动换行),过滤尾部空白行
@@ -1092,7 +1215,7 @@ class CodexParser(BaseParser):
1092
1215
  if not input_rows:
1093
1216
  return None
1094
1217
 
1095
- # 入口检测:是否有编号选项行(需 锚点)
1218
+ # 入口检测:是否有编号选项行(需 > 锚点)
1096
1219
  if not _has_numbered_options(screen, input_rows):
1097
1220
  return None
1098
1221
 
@@ -1142,7 +1265,7 @@ class CodexParser(BaseParser):
1142
1265
  if NAV_RE.search(line):
1143
1266
  continue
1144
1267
  # 编号选项行
1145
- m = re.match(r'^(?:>\s*)?(\d+)[.)]\s*(.+)', line)
1268
+ m = re.match(r'^(?:[>❯›]\s*)?(\d+)[.)]\s*(.+)', line)
1146
1269
  if m:
1147
1270
  if current_opt is not None:
1148
1271
  options.append(current_opt)
@@ -1168,22 +1291,48 @@ class CodexParser(BaseParser):
1168
1291
  return None
1169
1292
 
1170
1293
  def _extract_input_area_text(self, screen: pyte.Screen, input_rows: List[int]) -> str:
1171
- """提取输入区提示符后的当前输入文本(空提示符返回空字符串)"""
1172
- for row in input_rows:
1294
+ """提取输入区提示符后的当前输入文本(空提示符返回空字符串)。
1295
+ 选项交互模式下不提取输入文本(input_rows 是选项内容,非用户输入)。
1296
+ 多行输入时,收集 › 行之后首列为空的续行,合并为完整文本。
1297
+ """
1298
+ if self.last_layout_mode == 'option':
1299
+ return ''
1300
+ for i, row in enumerate(input_rows):
1173
1301
  if _get_col0(screen, row) in CODEX_PROMPT_CHARS:
1174
1302
  text = _get_row_text(screen, row)[1:].strip()
1175
1303
  # 排除纯分割线装饰行(如 ›─────)
1176
1304
  if text and not all(c in DIVIDER_CHARS for c in text):
1177
- return text
1305
+ # 收集续行(首列为空的非空文本行)
1306
+ lines = [text]
1307
+ for next_row in input_rows[i + 1:]:
1308
+ col0 = _get_col0(screen, next_row)
1309
+ if col0.strip(): # 首列有字符,遇到新 block 或新 › 行,停止
1310
+ break
1311
+ next_text = _get_row_text(screen, next_row).strip()
1312
+ if next_text:
1313
+ lines.append(next_text)
1314
+ return '\n'.join(lines)
1178
1315
  return ''
1179
1316
 
1180
1317
  def _extract_input_area_ansi_text(self, screen: pyte.Screen, input_rows: List[int]) -> str:
1181
- """提取输入区提示符后的当前输入文本(ANSI 版本)"""
1182
- for row in input_rows:
1318
+ """提取输入区提示符后的当前输入文本(ANSI 版本)。
1319
+ 多行输入时,收集 行之后首列为空的续行,合并为完整文本。
1320
+ """
1321
+ if self.last_layout_mode == 'option':
1322
+ return ''
1323
+ for i, row in enumerate(input_rows):
1183
1324
  if _get_col0(screen, row) in CODEX_PROMPT_CHARS:
1184
1325
  text = _get_row_text(screen, row)[1:].strip()
1185
1326
  if text and not all(c in DIVIDER_CHARS for c in text):
1186
- return _get_row_ansi_text(screen, row, start_col=1).strip()
1327
+ lines = [_get_row_ansi_text(screen, row, start_col=1).strip()]
1328
+ for next_row in input_rows[i + 1:]:
1329
+ col0 = _get_col0(screen, next_row)
1330
+ if col0.strip(): # 首列有字符,停止
1331
+ break
1332
+ next_text = _get_row_text(screen, next_row).strip()
1333
+ if next_text:
1334
+ lines.append(_get_row_ansi_text(screen, next_row).strip())
1335
+ return '\n'.join(lines)
1187
1336
  return ''
1188
1337
 
1189
1338
  def _parse_permission_area(
@@ -1193,8 +1342,8 @@ class CodexParser(BaseParser):
1193
1342
  ) -> Optional[OptionBlock]:
1194
1343
  """解析 1 条分割线布局下的权限确认区域,返回 OptionBlock(sub_type="permission")
1195
1344
 
1196
- 检测条件:bottom_rows 中含 锚点 + ≥2 个编号选项行。
1197
- 通过 _find_contiguous_options 以 为锚点双向扫描,只收集连续编号选项。
1345
+ 检测条件:bottom_rows 中含 > 锚点 + ≥2 个编号选项行。
1346
+ 通过 _find_contiguous_options 以 > 为锚点双向扫描,只收集连续编号选项。
1198
1347
  """
1199
1348
  if not bottom_rows:
1200
1349
  return None
@@ -1261,7 +1410,7 @@ class CodexParser(BaseParser):
1261
1410
  for i in range(first_opt_idx, last_opt_idx + 1):
1262
1411
  line, cat = classified[i]
1263
1412
  if cat == 'option':
1264
- m = re.match(r'^(?:>\s*)?(\d+)[.)]\s*(.+)', line)
1413
+ m = re.match(r'^(?:[>❯›]\s*)?(\d+)[.)]\s*(.+)', line)
1265
1414
  if m:
1266
1415
  options.append({
1267
1416
  'label': m.group(2).strip(),
@@ -11,10 +11,14 @@
11
11
  """
12
12
 
13
13
  import codecs
14
+ import logging
14
15
  import pyte
16
+ from pyte.screens import Margins
15
17
  from typing import List, Tuple, Optional
16
18
  from dataclasses import dataclass
17
19
 
20
+ logger = logging.getLogger(__name__)
21
+
18
22
 
19
23
  # ANSI 颜色名称到飞书颜色的映射
20
24
  # 飞书 Markdown 只支持: green, red, grey 三种颜色
@@ -55,11 +59,38 @@ class StyledSpan:
55
59
  strikethrough: bool = False
56
60
 
57
61
 
58
- class _DimAwareScreen(pyte.Screen):
59
- """pyte 不支持 SGR 2 (dim/faint),子类化后将 dim 映射为灰色前景"""
62
+ class _ExtendedStream(pyte.Stream):
63
+ """pyte.Stream 子类:补充 SU(ESC[nS)和 SD(ESC[nT)支持。
64
+
65
+ pyte 原生 CSI dispatch 不包含 'S' 和 'T',Codex 的 ESC[2S 会被完全忽略。
66
+ """
67
+ csi = {**pyte.Stream.csi, 'S': 'scroll_up', 'T': 'scroll_down'}
68
+
69
+
70
+ class _DebugStream(_ExtendedStream):
71
+ """_ExtendedStream 子类:记录被 pyte 丢弃的未识别转义序列到 DEBUG 日志。
72
+
73
+ 仅在 --debug-screen 开启时使用,替换默认 _ExtendedStream。
74
+ """
75
+ _undef_logger = logging.getLogger('pyte.Stream.undefined')
76
+
77
+ def _undefined(self, *args, **kwargs):
78
+ self._undef_logger.debug(f"pyte 未识别序列: args={args!r} kwargs={kwargs!r}")
79
+
80
+
81
+ class _DimAwareScreen(pyte.HistoryScreen):
82
+ """pyte.HistoryScreen 子类:
83
+ 1. SGR 2 (dim/faint) 映射为灰色前景
84
+ 2. 补充 SU/SD(ESC[nS/ESC[nT)滚动支持,滚出行保存到 history.top
85
+ """
60
86
 
61
87
  # dim 状态下使用的灰色(与终端 dim 效果近似)
62
88
  _DIM_FG = '808080'
89
+ _DEFAULT_HISTORY = 5000
90
+
91
+ def __init__(self, columns, lines, **kwargs):
92
+ kwargs.setdefault('history', self._DEFAULT_HISTORY)
93
+ super().__init__(columns, lines, **kwargs)
63
94
 
64
95
  def select_graphic_rendition(self, *attrs):
65
96
  # 检查是否包含 SGR 2 (dim)
@@ -70,13 +101,35 @@ class _DimAwareScreen(pyte.Screen):
70
101
  if has_dim and self.cursor.attrs.fg == 'default':
71
102
  self.cursor.attrs = self.cursor.attrs._replace(fg=self._DIM_FG)
72
103
 
104
+ def scroll_up(self, count=1):
105
+ """ESC[nS — SU:上滚 n 行,滚出行保存到 history.top"""
106
+ top, bottom = self.margins or Margins(0, self.lines - 1)
107
+ saved_y = self.cursor.y
108
+ self.cursor.y = bottom
109
+ for _ in range(count):
110
+ self.index()
111
+ self.cursor.y = saved_y
112
+
113
+ def scroll_down(self, count=1):
114
+ """ESC[nT — SD:下滚 n 行"""
115
+ top, bottom = self.margins or Margins(0, self.lines - 1)
116
+ saved_y = self.cursor.y
117
+ self.cursor.y = top
118
+ for _ in range(count):
119
+ self.reverse_index()
120
+ self.cursor.y = saved_y
121
+
73
122
 
74
123
  class RichTextRenderer:
75
124
  """将 pyte 屏幕内容转换为飞书富文本"""
76
125
 
77
- def __init__(self, columns: int = 200, lines: int = 500):
126
+ def __init__(self, columns: int = 200, lines: int = 500, debug_stream: bool = False):
78
127
  self.screen = _DimAwareScreen(columns, lines)
79
- self.stream = pyte.Stream(self.screen)
128
+ # debug_stream=True 时使用 _DebugStream,记录 pyte 未识别序列
129
+ if debug_stream:
130
+ self.stream = _DebugStream(self.screen)
131
+ else:
132
+ self.stream = _ExtendedStream(self.screen)
80
133
  self.stream.use_utf8 = True
81
134
  # 增量 UTF-8 解码器:保持跨 chunk 的解码状态,防止多字节序列被截断
82
135
  self._utf8_decoder = codecs.getincrementaldecoder('utf-8')(errors='replace')
@@ -84,6 +137,18 @@ class RichTextRenderer:
84
137
  def feed(self, data: bytes) -> None:
85
138
  """喂入原始终端数据"""
86
139
  text = self._utf8_decoder.decode(data)
140
+ # 改动4:检测 UTF-8 解码产生的替换字符(U+FFFD),说明原始字节流有非法序列
141
+ if '\ufffd' in text:
142
+ # 定位替换字符位置(最多报告前 5 处)
143
+ positions = [i for i, c in enumerate(text) if c == '\ufffd'][:5]
144
+ contexts = []
145
+ for pos in positions:
146
+ ctx_start = max(0, pos - 4)
147
+ ctx_end = min(len(text), pos + 5)
148
+ ctx = repr(text[ctx_start:ctx_end])
149
+ contexts.append(f"pos={pos} ctx={ctx}")
150
+ logger.warning(f"UTF-8 解码替换字符(\\ufffd): count={text.count(chr(0xfffd))} "
151
+ f"positions={contexts}")
87
152
  self.stream.feed(text)
88
153
 
89
154
  def clear(self) -> None: