remote-claude 0.1.0

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,301 @@
1
+ """
2
+ 富文本渲染器 - 将终端 ANSI 样式转换为飞书卡片富文本
3
+
4
+ 飞书卡片 Markdown 支持:
5
+ - **粗体**
6
+ - *斜体*
7
+ - ~~删除线~~
8
+ - `代码`
9
+ - <font color="red">颜色文本</font>
10
+ - 链接 [text](url)
11
+ """
12
+
13
+ import codecs
14
+ import pyte
15
+ from typing import List, Tuple, Optional
16
+ from dataclasses import dataclass
17
+
18
+
19
+ # ANSI 颜色名称到飞书颜色的映射
20
+ # 飞书 Markdown 只支持: green, red, grey 三种颜色
21
+ ANSI_TO_LARK_COLOR = {
22
+ 'black': 'grey',
23
+ 'red': 'red',
24
+ 'green': 'green',
25
+ 'yellow': None, # 飞书不支持黄色,用默认色
26
+ 'brown': None, # pyte 把 ANSI 33 解析为 brown
27
+ 'blue': None, # 飞书不支持蓝色
28
+ 'magenta': 'red', # 用红色替代
29
+ 'cyan': 'green', # 用绿色替代
30
+ 'white': None, # 默认颜色
31
+ 'default': None,
32
+ # 亮色系
33
+ 'brightblack': 'grey',
34
+ 'brightred': 'red',
35
+ 'brightgreen': 'green',
36
+ 'brightyellow': None,
37
+ 'brightblue': None,
38
+ 'brightmagenta': 'red',
39
+ 'brightcyan': 'green',
40
+ 'brightwhite': None,
41
+ # 灰色系
42
+ 'grey': 'grey',
43
+ 'gray': 'grey',
44
+ }
45
+
46
+
47
+ @dataclass
48
+ class StyledSpan:
49
+ """带样式的文本片段"""
50
+ text: str
51
+ fg_color: Optional[str] = None
52
+ bold: bool = False
53
+ italic: bool = False
54
+ underline: bool = False
55
+ strikethrough: bool = False
56
+
57
+
58
+ class _DimAwareScreen(pyte.Screen):
59
+ """pyte 不支持 SGR 2 (dim/faint),子类化后将 dim 映射为灰色前景"""
60
+
61
+ # dim 状态下使用的灰色(与终端 dim 效果近似)
62
+ _DIM_FG = '808080'
63
+
64
+ def select_graphic_rendition(self, *attrs):
65
+ # 检查是否包含 SGR 2 (dim)
66
+ has_dim = 2 in attrs
67
+ # 过滤掉 2,让父类处理其余属性(父类不认识 2 会忽略)
68
+ super().select_graphic_rendition(*attrs)
69
+ # dim 且前景仍为 default → 映射为灰色
70
+ if has_dim and self.cursor.attrs.fg == 'default':
71
+ self.cursor.attrs = self.cursor.attrs._replace(fg=self._DIM_FG)
72
+
73
+
74
+ class RichTextRenderer:
75
+ """将 pyte 屏幕内容转换为飞书富文本"""
76
+
77
+ def __init__(self, columns: int = 200, lines: int = 500):
78
+ self.screen = _DimAwareScreen(columns, lines)
79
+ self.stream = pyte.Stream(self.screen)
80
+ self.stream.use_utf8 = True
81
+ # 增量 UTF-8 解码器:保持跨 chunk 的解码状态,防止多字节序列被截断
82
+ self._utf8_decoder = codecs.getincrementaldecoder('utf-8')(errors='replace')
83
+
84
+ def feed(self, data: bytes) -> None:
85
+ """喂入原始终端数据"""
86
+ text = self._utf8_decoder.decode(data)
87
+ self.stream.feed(text)
88
+
89
+ def clear(self) -> None:
90
+ """清空终端"""
91
+ self.screen.reset()
92
+ # 同时重置增量解码器状态
93
+ self._utf8_decoder.reset()
94
+
95
+ def get_plain_display(self) -> str:
96
+ """获取纯文本显示(不含样式)"""
97
+ lines = []
98
+ for line in self.screen.display:
99
+ stripped = line.rstrip()
100
+ if stripped:
101
+ lines.append(stripped)
102
+ return '\n'.join(lines)
103
+
104
+ def get_rich_text(self) -> str:
105
+ """获取富文本格式的内容(飞书 Markdown)"""
106
+ result_lines = []
107
+
108
+ for row_idx in range(self.screen.lines):
109
+ line_spans = self._get_line_spans(row_idx)
110
+ if not line_spans:
111
+ continue
112
+
113
+ # 检查是否整行都是空白
114
+ line_text = ''.join(span.text for span in line_spans)
115
+ if not line_text.strip():
116
+ continue
117
+
118
+ # 计算缩进(前导空格数量)
119
+ indent = 0
120
+ for span in line_spans:
121
+ if span.text.strip():
122
+ # 找到第一个非空白 span,计算其前导空格
123
+ indent += len(span.text) - len(span.text.lstrip())
124
+ break
125
+ else:
126
+ # 纯空白 span,累加长度
127
+ indent += len(span.text)
128
+
129
+ # 清理前导空白 span
130
+ while line_spans and not line_spans[0].text.strip():
131
+ line_spans.pop(0)
132
+
133
+ # 去掉第一个 span 的前导空格(稍后统一添加缩进)
134
+ if line_spans and line_spans[0].text:
135
+ line_spans[0].text = line_spans[0].text.lstrip()
136
+
137
+ # 转换为飞书 Markdown,并添加缩进
138
+ md_line = self._spans_to_markdown(line_spans)
139
+ if indent > 0:
140
+ # 使用全角空格保持缩进(飞书会压缩半角空格)
141
+ md_line = '\u3000' * (indent // 2) + ' ' * (indent % 2) + md_line
142
+ if md_line.strip():
143
+ # 行尾加两个空格,让 Markdown 强制换行
144
+ result_lines.append(md_line + ' ')
145
+
146
+ return '\n'.join(result_lines)
147
+
148
+ def _get_line_spans(self, row_idx: int) -> List[StyledSpan]:
149
+ """获取一行的样式片段"""
150
+ row = self.screen.buffer[row_idx]
151
+ spans = []
152
+ current_span = None
153
+
154
+ for col in range(self.screen.columns):
155
+ char = row[col]
156
+ char_data = char.data if hasattr(char, 'data') else ' '
157
+
158
+ # 获取样式
159
+ fg = getattr(char, 'fg', 'default')
160
+ bold = getattr(char, 'bold', False)
161
+ italic = getattr(char, 'italics', False)
162
+ underline = getattr(char, 'underscore', False)
163
+ strikethrough = getattr(char, 'strikethrough', False)
164
+
165
+ # 转换颜色
166
+ lark_color = self._convert_color(fg)
167
+
168
+ # 检查是否需要新的 span
169
+ if current_span is None:
170
+ current_span = StyledSpan(
171
+ text=char_data,
172
+ fg_color=lark_color,
173
+ bold=bold,
174
+ italic=italic,
175
+ underline=underline,
176
+ strikethrough=strikethrough
177
+ )
178
+ elif (current_span.fg_color == lark_color and
179
+ current_span.bold == bold and
180
+ current_span.italic == italic and
181
+ current_span.underline == underline and
182
+ current_span.strikethrough == strikethrough):
183
+ # 样式相同,追加文本
184
+ current_span.text += char_data
185
+ else:
186
+ # 样式变化,保存当前 span 并开始新的
187
+ if current_span.text:
188
+ spans.append(current_span)
189
+ current_span = StyledSpan(
190
+ text=char_data,
191
+ fg_color=lark_color,
192
+ bold=bold,
193
+ italic=italic,
194
+ underline=underline,
195
+ strikethrough=strikethrough
196
+ )
197
+
198
+ # 添加最后一个 span
199
+ if current_span and current_span.text:
200
+ spans.append(current_span)
201
+
202
+ # 清理尾部空白
203
+ while spans and not spans[-1].text.rstrip():
204
+ spans.pop()
205
+
206
+ if spans:
207
+ spans[-1].text = spans[-1].text.rstrip()
208
+
209
+ return spans
210
+
211
+ def _convert_color(self, ansi_color: str) -> Optional[str]:
212
+ """将 ANSI 颜色转换为飞书颜色"""
213
+ if not ansi_color:
214
+ return None
215
+
216
+ color = str(ansi_color).lower().replace('-', '')
217
+ return ANSI_TO_LARK_COLOR.get(color)
218
+
219
+ def _escape_markdown(self, text: str) -> str:
220
+ """转义 markdown 特殊字符"""
221
+ # 飞书 markdown 中需要转义的字符
222
+ for char in ['*', '_', '~', '`']:
223
+ text = text.replace(char, '\\' + char)
224
+ return text
225
+
226
+ def _spans_to_markdown(self, spans: List[StyledSpan]) -> str:
227
+ """将样式片段转换为飞书 Markdown"""
228
+ result = []
229
+
230
+ for span in spans:
231
+ text = span.text
232
+ if not text:
233
+ continue
234
+
235
+ # 应用样式(从内到外)
236
+ styled_text = text
237
+
238
+ # 粗体
239
+ if span.bold:
240
+ styled_text = f'**{styled_text}**'
241
+
242
+ # 斜体
243
+ if span.italic:
244
+ styled_text = f'*{styled_text}*'
245
+
246
+ # 删除线
247
+ if span.strikethrough:
248
+ styled_text = f'~~{styled_text}~~'
249
+
250
+ # 颜色:需要转义 markdown 特殊字符
251
+ if span.fg_color:
252
+ escaped_text = self._escape_markdown(styled_text)
253
+ styled_text = f'<font color="{span.fg_color}">{escaped_text}</font>'
254
+
255
+ result.append(styled_text)
256
+
257
+ return ''.join(result)
258
+
259
+ def get_display_for_lark(self, user_input: str = "") -> Tuple[str, str]:
260
+ """
261
+ 获取适合飞书显示的内容
262
+
263
+ 返回: (plain_text, rich_markdown)
264
+ """
265
+ plain = self.get_plain_display()
266
+ rich = self.get_rich_text()
267
+ return plain, rich
268
+
269
+
270
+ def test_renderer():
271
+ """测试渲染器"""
272
+ renderer = RichTextRenderer(80, 24)
273
+
274
+ # 模拟 Claude 的输出
275
+ test_data = (
276
+ # 红色错误
277
+ b'\x1b[31mError: Something went wrong\x1b[0m\n'
278
+ # 绿色成功
279
+ b'\x1b[32mSuccess!\x1b[0m\n'
280
+ # 粗体黄色警告
281
+ b'\x1b[1;33mWarning: Be careful\x1b[0m\n'
282
+ # 蓝色信息
283
+ b'\x1b[34mInfo: Normal message\x1b[0m\n'
284
+ # 代码块提示
285
+ b'```python\n'
286
+ b'def hello():\n'
287
+ b' print("Hello, World!")\n'
288
+ b'```\n'
289
+ )
290
+
291
+ renderer.feed(test_data)
292
+
293
+ print("=== 纯文本 ===")
294
+ print(renderer.get_plain_display())
295
+ print()
296
+ print("=== 富文本 Markdown ===")
297
+ print(renderer.get_rich_text())
298
+
299
+
300
+ if __name__ == "__main__":
301
+ test_renderer()