remote-claude 0.2.13 → 0.2.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/init.sh +5 -0
- package/package.json +1 -1
- package/server/parsers/base_parser.py +4 -1
- package/server/rich_text_renderer.py +69 -4
- package/server/server.py +195 -16
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@ Claude Code 只能在启动它的那个终端窗口里操作。一旦离开电
|
|
|
11
11
|
- **飞书里直接操作** — 手机/平板打开飞书,就能看到 Claude 的实时输出,发消息、选选项、批准权限,和终端里一模一样。
|
|
12
12
|
- **用手机无缝延续电脑上做的工作** — 电脑上打开的Claude进程,也可以用飞书共享操作,开会、午休、通勤、上厕所时,都可以用手机延续之前在电脑上的工作。
|
|
13
13
|
- **在电脑上也可以无缝延续手机上的工作** - 在lark端也可以打开新的Claude进程启动新的工作,回到电脑前还可以`attach`共享操作同一个Claude进程,延续手机端的工作。
|
|
14
|
-
- **多端共享操作** — 多个终端 + 飞书可以共享操作同一个claude进程,回到家里ssh登录到服务器上也可以通过`attach`继续操作在公司ssh登录到服务器上打开的claude进程操作。
|
|
14
|
+
- **多端共享操作** — 多个终端 + 飞书可以共享操作同一个claude/codex进程,回到家里ssh登录到服务器上也可以通过`attach`继续操作在公司ssh登录到服务器上打开的claude/codex进程操作。
|
|
15
15
|
- **机制安全** - 完全不侵入 Claude 进程,remote 功能完全通过终端交互来实现,不必担心 Claude 进程意外崩溃导致工作进展丢失。
|
|
16
16
|
|
|
17
17
|
## 飞书端体验
|
|
@@ -68,7 +68,7 @@ remote-claude attach <会话名>
|
|
|
68
68
|
|
|
69
69
|
1. 登录[飞书开放平台](https://open.feishu.cn/),创建企业自建应用
|
|
70
70
|
2. 获取 **App ID** 和 **App Secret**
|
|
71
|
-
3. 用`cla`或`cl`启动一次claude, 按照交互提示填入**App ID** 和 **App Secret**
|
|
71
|
+
3. 用`cla`或`cl`启动一次claude(或用cx或cdx启动一次codex), 按照交互提示填入**App ID** 和 **App Secret**
|
|
72
72
|
4. [飞书开放平台]的企业自建应用页面`添加应用能力`(机器人能力)
|
|
73
73
|
5. 企业自建应用页面配置事件回调(如果第3步没启动成功这里配置不了):
|
|
74
74
|
- `事件与回调` -> `事件配置` -> `订阅方式`右边的笔图标 -> `选择:使用长连接接收事件` -> `点击保存` -> `下面添加事件: 接收消息 v2.0 (im.message.receive_v1)`
|
|
@@ -198,9 +198,9 @@ remote-claude attach <会话名>
|
|
|
198
198
|
7. 企业自建应用页面: `创建版本` -> `发布到线上`
|
|
199
199
|
8. 至此,完成飞书机器人配置
|
|
200
200
|
|
|
201
|
-
#### 4.2 通过飞书机器人操作claude
|
|
201
|
+
#### 4.2 通过飞书机器人操作claude/codex
|
|
202
202
|
|
|
203
|
-
1.
|
|
203
|
+
1. 从飞书搜索刚刚创建的飞书机器人(第一次搜比较慢,如果搜不到可能是忘记发布了)
|
|
204
204
|
2. 飞书中与机器人对话,可用命令:
|
|
205
205
|
- `/menu` 展示菜单卡片,后续操作都操作这个卡片上的按钮即可
|
|
206
206
|
|
package/init.sh
CHANGED
|
@@ -40,6 +40,11 @@ setup_path() {
|
|
|
40
40
|
# 不存在则创建
|
|
41
41
|
[[ -f "$PROFILE" ]] || touch "$PROFILE"
|
|
42
42
|
|
|
43
|
+
# 未写入 source .bashrc 则追加
|
|
44
|
+
if ! grep -qF '.bashrc' "$PROFILE" 2>/dev/null; then
|
|
45
|
+
echo '[ -f "$HOME/.bashrc" ] && . "$HOME/.bashrc"' >> "$PROFILE"
|
|
46
|
+
fi
|
|
47
|
+
|
|
43
48
|
# 未写入则追加
|
|
44
49
|
if ! grep -qF '$HOME/.local/bin' "$PROFILE" 2>/dev/null; then
|
|
45
50
|
echo "" >> "$PROFILE"
|
package/package.json
CHANGED
|
@@ -5,7 +5,7 @@ ClaudeParser 和 CodexParser 均继承此类。
|
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
from abc import ABC, abstractmethod
|
|
8
|
-
from typing import List
|
|
8
|
+
from typing import Any, Dict, List
|
|
9
9
|
|
|
10
10
|
import pyte
|
|
11
11
|
|
|
@@ -29,6 +29,9 @@ class BaseParser(ABC):
|
|
|
29
29
|
last_input_ansi_text: str = ''
|
|
30
30
|
last_parse_timing: str = ''
|
|
31
31
|
last_layout_mode: str = 'normal'
|
|
32
|
+
# 诊断用:记录最近一次 parse 的区域统计与组件统计
|
|
33
|
+
last_region_stats: Dict[str, Any] = None
|
|
34
|
+
last_component_stats: Dict[str, Any] = None
|
|
32
35
|
|
|
33
36
|
@abstractmethod
|
|
34
37
|
def parse(self, screen: pyte.Screen) -> List:
|
|
@@ -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
|
|
59
|
-
"""pyte
|
|
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
|
-
|
|
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:
|
package/server/server.py
CHANGED
|
@@ -68,6 +68,63 @@ except Exception:
|
|
|
68
68
|
HISTORY_BUFFER_SIZE = 100 * 1024 # 100KB
|
|
69
69
|
|
|
70
70
|
|
|
71
|
+
# ── VirtualScreen:将 HistoryScreen.history.top + buffer 合并为统一视图 ──────
|
|
72
|
+
|
|
73
|
+
class _VirtualBuffer:
|
|
74
|
+
"""统一访问接口:history rows [0..offset) + screen.buffer [offset..offset+lines)"""
|
|
75
|
+
__slots__ = ('_history', '_buffer', '_offset')
|
|
76
|
+
|
|
77
|
+
def __init__(self, history, buffer, offset):
|
|
78
|
+
self._history = history # list[dict],来自 list(screen.history.top)
|
|
79
|
+
self._buffer = buffer # pyte screen.buffer
|
|
80
|
+
self._offset = offset # len(history)
|
|
81
|
+
|
|
82
|
+
def __getitem__(self, row):
|
|
83
|
+
if row < self._offset:
|
|
84
|
+
return self._history[row]
|
|
85
|
+
return self._buffer[row - self._offset]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class _VirtualCursor:
|
|
89
|
+
"""模拟 pyte cursor,y 偏移 history 行数"""
|
|
90
|
+
__slots__ = ('y',)
|
|
91
|
+
|
|
92
|
+
def __init__(self, y: int):
|
|
93
|
+
self.y = y
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class VirtualScreen:
|
|
97
|
+
"""HistoryScreen wrapper:将 history.top + buffer 合并为 parser 可直接读取的统一屏幕视图。
|
|
98
|
+
|
|
99
|
+
parser 无需任何修改:
|
|
100
|
+
- _split_regions 从 cursor.y+5 向上扫描找分割线,先找到当前屏幕中的分割线就停了
|
|
101
|
+
- output_rows 自然包含 history 中的行号(0 到 offset-1)
|
|
102
|
+
- _get_row_text/buffer[row] 通过 VirtualBuffer 透明返回对应行
|
|
103
|
+
"""
|
|
104
|
+
__slots__ = ('_screen', '_history', '_offset')
|
|
105
|
+
|
|
106
|
+
def __init__(self, screen):
|
|
107
|
+
self._screen = screen
|
|
108
|
+
self._history = list(screen.history.top) if hasattr(screen, 'history') else []
|
|
109
|
+
self._offset = len(self._history)
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def columns(self):
|
|
113
|
+
return self._screen.columns
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def lines(self):
|
|
117
|
+
return self._offset + self._screen.lines
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def cursor(self):
|
|
121
|
+
return _VirtualCursor(self._offset + self._screen.cursor.y)
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def buffer(self):
|
|
125
|
+
return _VirtualBuffer(self._history, self._screen.buffer, self._offset)
|
|
126
|
+
|
|
127
|
+
|
|
71
128
|
# ── 全量快照架构 ─────────────────────────────────────────────────────────────
|
|
72
129
|
|
|
73
130
|
@dataclass
|
|
@@ -129,9 +186,17 @@ class OutputWatcher:
|
|
|
129
186
|
self._debug_verbose = debug_verbose # --debug-verbose 开启后输出 indicator/repr 等诊断信息
|
|
130
187
|
safe_name = _safe_filename(session_name)
|
|
131
188
|
self._debug_file = f"/tmp/remote-claude/{safe_name}_messages.log"
|
|
189
|
+
# PTY 原始字节流日志(仅 --debug-screen 开启时使用)
|
|
190
|
+
self._raw_log_fd = None
|
|
191
|
+
if debug_screen:
|
|
192
|
+
raw_log_path = f"/tmp/remote-claude/{safe_name}_pty_raw.log"
|
|
193
|
+
try:
|
|
194
|
+
self._raw_log_fd = open(raw_log_path, "a", encoding="ascii", buffering=1)
|
|
195
|
+
except Exception:
|
|
196
|
+
pass
|
|
132
197
|
# 持久化 pyte 渲染器:PTY 数据直接实时喂入,flush 时直接读 screen
|
|
133
198
|
from rich_text_renderer import RichTextRenderer
|
|
134
|
-
self._renderer = RichTextRenderer(columns=cols, lines=rows)
|
|
199
|
+
self._renderer = RichTextRenderer(columns=cols, lines=rows, debug_stream=debug_screen)
|
|
135
200
|
# 持久化解析器(跨帧保留 dot_row_cache);由调用方注入(可插拔架构)
|
|
136
201
|
import logging as _logging
|
|
137
202
|
_logging.getLogger('ComponentParser').setLevel(_logging.DEBUG)
|
|
@@ -175,6 +240,15 @@ class OutputWatcher:
|
|
|
175
240
|
# 诊断日志:记录 PTY 数据到达
|
|
176
241
|
if data:
|
|
177
242
|
logger.debug(f"[diag-feed] len={len(data)} data={data[:50]!r}")
|
|
243
|
+
# 改动3:--debug-screen 开启时追加原始字节到 _pty_raw.log(base64 编码)
|
|
244
|
+
if self._raw_log_fd is not None and data:
|
|
245
|
+
try:
|
|
246
|
+
import base64 as _b64
|
|
247
|
+
ts = time.strftime('%H:%M:%S') + f'.{int(time.time() * 1000) % 1000:03d}'
|
|
248
|
+
encoded = _b64.b64encode(data).decode('ascii')
|
|
249
|
+
self._raw_log_fd.write(f"{ts} len={len(data)} {encoded}\n")
|
|
250
|
+
except Exception:
|
|
251
|
+
pass
|
|
178
252
|
try:
|
|
179
253
|
loop = asyncio.get_running_loop()
|
|
180
254
|
except RuntimeError:
|
|
@@ -217,8 +291,10 @@ class OutputWatcher:
|
|
|
217
291
|
self._write_screen_debug(self._renderer.screen)
|
|
218
292
|
t1 = time.time()
|
|
219
293
|
|
|
220
|
-
# 1.
|
|
221
|
-
|
|
294
|
+
# 1. 构建 VirtualScreen(history.top + buffer)并解析
|
|
295
|
+
# _write_screen_debug 仍用原始 screen(只写当前帧快照,不含 history)
|
|
296
|
+
vscreen = VirtualScreen(self._renderer.screen)
|
|
297
|
+
components = self._parser.parse(vscreen)
|
|
222
298
|
input_text = self._parser.last_input_text
|
|
223
299
|
input_ansi_text = self._parser.last_input_ansi_text
|
|
224
300
|
|
|
@@ -256,10 +332,10 @@ class OutputWatcher:
|
|
|
256
332
|
last_ob_blink = b.is_streaming
|
|
257
333
|
last_ob_content = b.content[:40]
|
|
258
334
|
last_ob_start_row = b.start_row
|
|
259
|
-
#
|
|
335
|
+
# 读 vscreen.buffer 获取原始字符属性(支持 history 行)
|
|
260
336
|
if b.start_row >= 0:
|
|
261
337
|
try:
|
|
262
|
-
char =
|
|
338
|
+
char = vscreen.buffer[b.start_row][0]
|
|
263
339
|
last_ob_indicator_char = str(getattr(char, 'data', ''))
|
|
264
340
|
last_ob_indicator_fg = str(getattr(char, 'fg', ''))
|
|
265
341
|
last_ob_indicator_bold = bool(getattr(char, 'bold', False))
|
|
@@ -329,7 +405,7 @@ class OutputWatcher:
|
|
|
329
405
|
b.is_streaming = True
|
|
330
406
|
break
|
|
331
407
|
|
|
332
|
-
# 5. 直接使用 visible_blocks(
|
|
408
|
+
# 5. 直接使用 visible_blocks(VirtualScreen 已包含 history.top + 当前屏幕)
|
|
333
409
|
all_blocks = visible_blocks
|
|
334
410
|
|
|
335
411
|
# 5b. 后台 agent 摘要:BottomBar 有 agent 信息但面板未展开时,
|
|
@@ -513,8 +589,80 @@ class OutputWatcher:
|
|
|
513
589
|
except Exception:
|
|
514
590
|
pass
|
|
515
591
|
|
|
592
|
+
@staticmethod
|
|
593
|
+
def _char_to_ansi(char) -> str:
|
|
594
|
+
"""将 pyte Char 的颜色/样式属性转为 ANSI SGR 前缀序列。
|
|
595
|
+
|
|
596
|
+
返回带 ANSI 转义的字符字符串,调用方需在行尾追加 \\033[0m 重置。
|
|
597
|
+
仅在属性非默认时才输出对应 SGR,降低输出体积。
|
|
598
|
+
"""
|
|
599
|
+
# 标准颜色名到 ANSI 码
|
|
600
|
+
_FG_NAMES = {
|
|
601
|
+
'black': 30, 'red': 31, 'green': 32, 'yellow': 33,
|
|
602
|
+
'blue': 34, 'magenta': 35, 'cyan': 36, 'white': 37,
|
|
603
|
+
'brown': 33, # pyte 把 ANSI 33 解析为 brown
|
|
604
|
+
}
|
|
605
|
+
_BG_NAMES = {
|
|
606
|
+
'black': 40, 'red': 41, 'green': 42, 'yellow': 43,
|
|
607
|
+
'blue': 44, 'magenta': 45, 'cyan': 46, 'white': 47,
|
|
608
|
+
'brown': 43,
|
|
609
|
+
}
|
|
610
|
+
_BRIGHT_FG = {
|
|
611
|
+
'brightblack': 90, 'brightred': 91, 'brightgreen': 92, 'brightyellow': 93,
|
|
612
|
+
'brightblue': 94, 'brightmagenta': 95, 'brightcyan': 96, 'brightwhite': 97,
|
|
613
|
+
}
|
|
614
|
+
_BRIGHT_BG = {
|
|
615
|
+
'brightblack': 100, 'brightred': 101, 'brightgreen': 102, 'brightyellow': 103,
|
|
616
|
+
'brightblue': 104, 'brightmagenta': 105, 'brightcyan': 106, 'brightwhite': 107,
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
sgr = []
|
|
620
|
+
|
|
621
|
+
# bold / blink
|
|
622
|
+
if getattr(char, 'bold', False):
|
|
623
|
+
sgr.append('1')
|
|
624
|
+
if getattr(char, 'blink', False):
|
|
625
|
+
sgr.append('5')
|
|
626
|
+
|
|
627
|
+
# 前景色
|
|
628
|
+
fg = getattr(char, 'fg', 'default')
|
|
629
|
+
if fg and fg != 'default':
|
|
630
|
+
fg_low = str(fg).lower()
|
|
631
|
+
if fg_low in _FG_NAMES:
|
|
632
|
+
sgr.append(str(_FG_NAMES[fg_low]))
|
|
633
|
+
elif fg_low in _BRIGHT_FG:
|
|
634
|
+
sgr.append(str(_BRIGHT_FG[fg_low]))
|
|
635
|
+
elif len(fg_low) == 6 and all(c in '0123456789abcdef' for c in fg_low):
|
|
636
|
+
# 24-bit true color
|
|
637
|
+
r = int(fg_low[0:2], 16)
|
|
638
|
+
g = int(fg_low[2:4], 16)
|
|
639
|
+
b = int(fg_low[4:6], 16)
|
|
640
|
+
sgr.append(f'38;2;{r};{g};{b}')
|
|
641
|
+
|
|
642
|
+
# 背景色
|
|
643
|
+
bg = getattr(char, 'bg', 'default')
|
|
644
|
+
if bg and bg != 'default':
|
|
645
|
+
bg_low = str(bg).lower()
|
|
646
|
+
if bg_low in _BG_NAMES:
|
|
647
|
+
sgr.append(str(_BG_NAMES[bg_low]))
|
|
648
|
+
elif bg_low in _BRIGHT_BG:
|
|
649
|
+
sgr.append(str(_BRIGHT_BG[bg_low]))
|
|
650
|
+
elif len(bg_low) == 6 and all(c in '0123456789abcdef' for c in bg_low):
|
|
651
|
+
r = int(bg_low[0:2], 16)
|
|
652
|
+
g = int(bg_low[2:4], 16)
|
|
653
|
+
b = int(bg_low[4:6], 16)
|
|
654
|
+
sgr.append(f'48;2;{r};{g};{b}')
|
|
655
|
+
|
|
656
|
+
if sgr:
|
|
657
|
+
return f'\033[{";".join(sgr)}m{char.data}\033[0m'
|
|
658
|
+
return char.data
|
|
659
|
+
|
|
516
660
|
def _write_screen_debug(self, screen):
|
|
517
|
-
"""将 pyte 屏幕内容写入调试文件(_screen.log)
|
|
661
|
+
"""将 pyte 屏幕内容写入调试文件(_screen.log)
|
|
662
|
+
|
|
663
|
+
每个字符的 fg/bg 颜色通过 ANSI SGR 序列直接嵌入,
|
|
664
|
+
cat _screen.log 即可在终端看到与 pyte 渲染一致的着色效果。
|
|
665
|
+
"""
|
|
518
666
|
base = f"/tmp/remote-claude/{_safe_filename(self._session_name)}"
|
|
519
667
|
try:
|
|
520
668
|
# pyte 屏幕快照(覆盖写,只保留最新一帧)
|
|
@@ -526,20 +674,51 @@ class OutputWatcher:
|
|
|
526
674
|
"",
|
|
527
675
|
]
|
|
528
676
|
for row in range(scan_limit + 1):
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
677
|
+
# 构建带颜色的行内容(用于终端直接 cat 显示)
|
|
678
|
+
colored_buf = []
|
|
679
|
+
plain_buf = [' '] * screen.columns
|
|
680
|
+
for col in range(screen.columns):
|
|
681
|
+
char = screen.buffer[row].get(col)
|
|
682
|
+
if char is None:
|
|
683
|
+
colored_buf.append(' ')
|
|
684
|
+
continue
|
|
685
|
+
plain_buf[col] = char.data
|
|
686
|
+
colored_buf.append(self._char_to_ansi(char))
|
|
687
|
+
|
|
688
|
+
rstripped_plain = ''.join(plain_buf).rstrip()
|
|
689
|
+
if not rstripped_plain:
|
|
690
|
+
# 检查是否有背景色(对 Codex 背景色分割线很重要)
|
|
691
|
+
row_bgs = set()
|
|
692
|
+
for col in range(screen.columns):
|
|
693
|
+
char = screen.buffer[row].get(col)
|
|
694
|
+
if char is not None:
|
|
695
|
+
bg = getattr(char, 'bg', 'default')
|
|
696
|
+
if bg and bg != 'default':
|
|
697
|
+
row_bgs.add(str(bg))
|
|
698
|
+
if row_bgs:
|
|
699
|
+
bg_str = ','.join(sorted(row_bgs))
|
|
700
|
+
lines.append(f"{row:3d} |[bg:{bg_str} ···]")
|
|
701
|
+
else:
|
|
702
|
+
lines.append(f"{row:3d} |")
|
|
535
703
|
continue
|
|
704
|
+
|
|
536
705
|
try:
|
|
537
706
|
c0 = screen.buffer[row][0]
|
|
538
707
|
col0_blink = getattr(c0, "blink", False)
|
|
539
708
|
except (KeyError, IndexError):
|
|
540
709
|
col0_blink = False
|
|
710
|
+
|
|
541
711
|
blink_mark = "B" if col0_blink else " "
|
|
542
|
-
|
|
712
|
+
# rstrip 着色内容:找最后一个有内容的列号(正确处理 CJK 占 2 列的情况)
|
|
713
|
+
# plain_buf 按列号索引,len(rstripped_plain) 会因 CJK 字符导致列数偏小
|
|
714
|
+
last_col = 0
|
|
715
|
+
for col in range(screen.columns - 1, -1, -1):
|
|
716
|
+
if plain_buf[col] != ' ':
|
|
717
|
+
last_col = col + 1
|
|
718
|
+
break
|
|
719
|
+
colored_line = ''.join(colored_buf[:last_col])
|
|
720
|
+
lines.append(f"{row:3d}{blink_mark}|{colored_line}")
|
|
721
|
+
|
|
543
722
|
with open(screen_path, "w", encoding="utf-8") as f:
|
|
544
723
|
f.write("\n".join(lines))
|
|
545
724
|
except Exception:
|
|
@@ -707,7 +886,7 @@ class ProxyServer:
|
|
|
707
886
|
|
|
708
887
|
# PTY 终端尺寸:与 lark_client 的 pyte 渲染器保持一致
|
|
709
888
|
PTY_COLS = 220
|
|
710
|
-
PTY_ROWS =
|
|
889
|
+
PTY_ROWS = 100 # 行数缩减至 100,历史内容通过 HistoryScreen.history.top 保存(5000 行容量)
|
|
711
890
|
|
|
712
891
|
def _get_parser(self):
|
|
713
892
|
"""根据 cli_type 返回对应的解析器实例"""
|
|
@@ -965,7 +1144,7 @@ class ProxyServer:
|
|
|
965
1144
|
"""处理终端大小变化:同步更新 PTY 和 pyte 渲染尺寸,清空 raw buffer。
|
|
966
1145
|
Claude 收到 SIGWINCH 后会全屏重绘,buffer 清空后自然恢复为新尺寸的完整屏幕数据。"""
|
|
967
1146
|
try:
|
|
968
|
-
# output_watcher 的 rows 固定为 PTY_ROWS(
|
|
1147
|
+
# output_watcher 的 rows 固定为 PTY_ROWS(100),不跟随客户端终端尺寸变化
|
|
969
1148
|
# terminal client 直接渲染 PTY 原始输出,不依赖 output_watcher,无需同步 rows
|
|
970
1149
|
self.output_watcher.resize(msg.cols, self.PTY_ROWS)
|
|
971
1150
|
winsize = struct.pack('HHHH', msg.rows, msg.cols, 0, 0)
|