ot-cli 0.2.1__py3-none-any.whl

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.
Files changed (91) hide show
  1. openterminal/__init__.py +3 -0
  2. openterminal/agent/__init__.py +39 -0
  3. openterminal/agent/backend.py +180 -0
  4. openterminal/agent/build.py +115 -0
  5. openterminal/agent/middleware.py +111 -0
  6. openterminal/agent/model.py +41 -0
  7. openterminal/agent/prompt.py +63 -0
  8. openterminal/agent/runner.py +340 -0
  9. openterminal/agent/sysprobe.py +164 -0
  10. openterminal/agent/token_est.py +49 -0
  11. openterminal/app.py +52 -0
  12. openterminal/approval.py +7 -0
  13. openterminal/backend.py +3 -0
  14. openterminal/banner.py +7 -0
  15. openterminal/cli.py +426 -0
  16. openterminal/cmdset.py +7 -0
  17. openterminal/config.py +322 -0
  18. openterminal/connections/__init__.py +20 -0
  19. openterminal/connections/db.py +240 -0
  20. openterminal/connections/models.py +289 -0
  21. openterminal/connections/secrets.py +68 -0
  22. openterminal/connections_db.py +6 -0
  23. openterminal/core/__init__.py +41 -0
  24. openterminal/core/ai_flow.py +781 -0
  25. openterminal/core/auth.py +499 -0
  26. openterminal/core/cmdset_runner.py +352 -0
  27. openterminal/core/exec_events.py +650 -0
  28. openterminal/core/history.py +79 -0
  29. openterminal/core/hook_gate.py +415 -0
  30. openterminal/core/messages.py +193 -0
  31. openterminal/core/nested_ssh.py +178 -0
  32. openterminal/core/pipeline.py +1187 -0
  33. openterminal/core/pump.py +190 -0
  34. openterminal/core/transcript.py +26 -0
  35. openterminal/frontend/__init__.py +21 -0
  36. openterminal/frontend/approval.py +40 -0
  37. openterminal/frontend/banner.py +40 -0
  38. openterminal/frontend/headless.py +290 -0
  39. openterminal/frontend/render.py +41 -0
  40. openterminal/frontend/taskview.py +184 -0
  41. openterminal/frontend/term.py +1417 -0
  42. openterminal/headless.py +7 -0
  43. openterminal/history_db.py +7 -0
  44. openterminal/local_pty.py +3 -0
  45. openterminal/local_win.py +3 -0
  46. openterminal/policy.py +362 -0
  47. openterminal/render.py +7 -0
  48. openterminal/secrets_store.py +5 -0
  49. openterminal/session/__init__.py +17 -0
  50. openterminal/session/base.py +328 -0
  51. openterminal/session/local_posix.py +184 -0
  52. openterminal/session/local_win.py +173 -0
  53. openterminal/session/ssh.py +321 -0
  54. openterminal/shell/__init__.py +17 -0
  55. openterminal/shell/cmdset.py +49 -0
  56. openterminal/shell/integration.py +1298 -0
  57. openterminal/shell_integration.py +7 -0
  58. openterminal/shell_session.py +8 -0
  59. openterminal/skills/__init__.py +45 -0
  60. openterminal/skills/markdown-tables/SKILL.md +247 -0
  61. openterminal/ssh_pty.py +6 -0
  62. openterminal/sysprobe.py +7 -0
  63. openterminal/taskview.py +7 -0
  64. openterminal/term_frontend.py +7 -0
  65. openterminal/token_est.py +3 -0
  66. openterminal/transcript.py +7 -0
  67. openterminal/web/__init__.py +1 -0
  68. openterminal/web/files_api.py +789 -0
  69. openterminal/web/frontend/index.html +37 -0
  70. openterminal/web/frontend/static/app.js +3632 -0
  71. openterminal/web/frontend/static/block_stream.js +274 -0
  72. openterminal/web/frontend/static/block_view.js +383 -0
  73. openterminal/web/frontend/static/files_panel.js +782 -0
  74. openterminal/web/frontend/static/md_lite.js +127 -0
  75. openterminal/web/frontend/static/modal.js +122 -0
  76. openterminal/web/frontend/static/pad_ledger.js +46 -0
  77. openterminal/web/frontend/static/style.css +915 -0
  78. openterminal/web/frontend/static/ui/ui.css +1 -0
  79. openterminal/web/frontend/static/ui/ui.js +6292 -0
  80. openterminal/web/frontend/vendor/addon-fit.js +2 -0
  81. openterminal/web/frontend/vendor/xterm.css +8 -0
  82. openterminal/web/frontend/vendor/xterm.js +8 -0
  83. openterminal/web/protocol.py +41 -0
  84. openterminal/web/server.py +322 -0
  85. openterminal/web/worker.py +62 -0
  86. ot_cli-0.2.1.dist-info/METADATA +439 -0
  87. ot_cli-0.2.1.dist-info/RECORD +91 -0
  88. ot_cli-0.2.1.dist-info/WHEEL +5 -0
  89. ot_cli-0.2.1.dist-info/entry_points.txt +3 -0
  90. ot_cli-0.2.1.dist-info/licenses/LICENSE +202 -0
  91. ot_cli-0.2.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1417 @@
1
+ """CLI 终端前端:核心 ↔ stdin/stdout 适配。
2
+
3
+ 单管线核心(openterminal.core.PipelineCore)的零卡片终端前端:
4
+ - CliCore:emit_msg/emit_bytes/_emit_nowait 覆写为进前端串行渲染 outbox
5
+ (核心自身 outbox/sender_loop 对 None sink 空转);
6
+ - TermFrontend:raw 输入泵(键盘字节 → core.feed_input)、串行渲染
7
+ outbox(("bytes", d) → writer 直写;("msg", m) → _on_msg 钩子)、
8
+ 初始 resize、SIGWINCH、closed 停止;finally 还原 termios + 移除
9
+ 信号处理(KeyboardInterrupt/渲染异常/EOF/正常 closed 四条退出路径
10
+ 都必须走完,否则用户 shell 留在 raw 态);
11
+ - _CaptureLayer:本地截获层(审批/rescue/认证/Ctrl+C,spec §3.1/§3.3)。
12
+ 待决态消费的键与 password 累积字符绝不进 PTY;on_keys 返回
13
+ ("pass", 剩余直通字节) | ("edit", 余量字节);决策经 frontend.core.feed_msg
14
+ 发出后退出截获态;
15
+ - _run_edit:审批编辑流(e 键)。termios 交接顺序(Review Focus #5):
16
+ 暂停泵 → _restore_input → prompt_toolkit 多行输入 → _set_raw_input →
17
+ 恢复泵 → feed_msg;结果过 approval.reclassify_edited。
18
+
19
+ raw 原语四件(_set_raw_input/_restore_input/_read_one_key/_term_size):
20
+ 本文件私有实现(原复制自 rawmode.py——旧双管线已于 Task 7 删除)。
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import asyncio
25
+ import os
26
+ import re
27
+ import signal
28
+ import sys
29
+ import time
30
+
31
+ from rich.cells import cell_len, chop_cells
32
+ from rich.console import Console, Group
33
+ from rich.panel import Panel
34
+ from rich.syntax import Syntax
35
+ from rich.text import Text
36
+
37
+ from .approval import edit_decision, reclassify_edited
38
+ from ..config import Config
39
+ from ..core import ClientMsg, PipelineCore, ServerMsg
40
+
41
+
42
+ class CliCore(PipelineCore):
43
+ """PipelineCore 的 CLI 适配:全部下行出口进前端串行渲染 outbox。
44
+
45
+ 核心自身的 _outbox/_sender_loop 在 CLI 下不承载业务流量(attach(None)
46
+ 后 sender 空转);消息与 PTY 字节统一进 frontend._outbox,由前端
47
+ _render_loop 串行消费——渲染动作与 PTY 字节不互相撕裂(spec §3.2)。
48
+ """
49
+
50
+ def __init__(self, cfg: Config, target_name: str,
51
+ frontend: "TermFrontend | None") -> None:
52
+ # frontend 允许暂 None(两段构造:先建 core 再建 TermFrontend 后
53
+ # 回填),但 attach/emit 发生前必须就位
54
+ # 先设 _frontend 再 super().__init__:core __init__ 不碰 frontend,
55
+ # 顺序防未来踩坑(基类构造期若出现 emit 也能找到出口)
56
+ self._frontend = frontend
57
+ super().__init__(cfg, target_name)
58
+
59
+ async def emit_msg(self, msg: ServerMsg) -> None:
60
+ await self._frontend._outbox.put(("msg", msg))
61
+
62
+ async def emit_bytes(self, data: bytes) -> None:
63
+ await self._frontend._outbox.put(("bytes", data))
64
+
65
+ def _emit_nowait(self, msg: ServerMsg) -> None:
66
+ # 同步缝(AI token 流走它):同步回调里无法 await
67
+ self._frontend._outbox.put_nowait(("msg", msg))
68
+
69
+ async def close(self) -> None:
70
+ await super().close()
71
+ # CLI 下核心 outbox 永不收货(全部下行走前端 _outbox),基类
72
+ # _sender_loop 会永远阻塞在 outbox.get()、够不到「closed 已发 +
73
+ # backlog 空」的退出门闩——投一个哨兵唤醒它按门闩自然退出
74
+ # (sink=None 不发送,无副作用;attach 未发生时无 sender 可醒)。
75
+ # web 侧由 closed 消息本身唤醒 sender,CliCore 需要自己补这一脚。
76
+ if self._sender is not None and not self._sender.done():
77
+ self._outbox.put_nowait(("close-sentinel", b""))
78
+
79
+
80
+ class _CaptureLayer:
81
+ """本地截获层(spec §3.1/§3.3,与 web approvalKeys/rescue/auth 同规则)。
82
+
83
+ 待决态(state = "approval"|"rescue"|"password"|"host_key")下消费的
84
+ 决策键与 password 累积字符**绝不进 PTY**(Review Focus #2):
85
+ on_keys(data) -> ("pass", 剩余待直通字节) | ("edit", 余量字节)——决策键
86
+ 本地消费掉,pass payload 只含剩余字节;"edit" 表示进审批编辑流(由
87
+ _input_pump await frontend._run_edit())。
88
+
89
+ 决策全部经 frontend.core.feed_msg 发出(approve/reject payload 与 web
90
+ 完全一致,源自 approval.py;edit 用 approval.edit_decision),发送后
91
+ 退出对应截获态。enter_approval/enter_rescue/enter_auth 由渲染器在
92
+ 进入对应待决态时调用(调用形状被 test_term_render.py 钉死);
93
+ exit_* 对应清除。
94
+
95
+ 按键规则:
96
+ - approval(risk=="high" 二段确认):\\r 进 confirming(红行提示)→
97
+ 再 \\r 才 approve;\\x7f/\\x08 reject;e/E → ("edit", 余量)(编辑完成后泵继续路由余量);
98
+ confirming 态 \\x7f 仅退出 confirming;其余键吞掉不透传。
99
+ - rescue:y/Y → accept=True,n/N → accept=False,其余键透传。
100
+ - password:可打印字符累积(不回显,渲染器已打提示行)、\\x7f/\\x08
101
+ 退格、\\r 提交(remember 恒 False);期间字节不透传。
102
+ - host_key:y → text="true"、n → text="false",其余透传。
103
+ - 待决期 \\x03(钉死规则):approval → reject、rescue → accept=False,
104
+ 均不透传;password/host_key 态吞掉不透传(隐藏输入不外泄)。
105
+ - 无截获态 \\x03:核心任务活跃(core._ai_task 非 None)→ interrupt
106
+ 且不透传;空闲 → 原样透传(shell 原生 ^C)。
107
+ - \\x04(Ctrl+D)永远透传(shell exit → 核心 closed)。
108
+ """
109
+
110
+ def __init__(self, frontend: "TermFrontend") -> None:
111
+ self._frontend = frontend
112
+ self.state: str | None = None
113
+ self._risk = "normal" # approval 风险级(二段确认判定)
114
+ self._confirming = False # high 风险二段确认态
115
+ self._buf = b"" # password 累积(不回显)
116
+ self._editing = False # 编辑流进行中(泵已停读)
117
+ # 提交 auth 时回传的真实 auth_kind:渲染器从 ask_password 原样传
118
+ # 入(cmdset 仍归 "password"——core 侧 password/cmdset 同队列),
119
+ # nested_password(嵌套 ssh/sudo 密码模态)原样回传
120
+ self._auth_kind = "password"
121
+
122
+ # --- 待决态进入/退出(渲染器调用 enter_*;exit_* 对应清除)---
123
+
124
+ def enter_approval(self, risk: str) -> None:
125
+ self.state = "approval"
126
+ self._risk = "high" if risk == "high" else "normal"
127
+ self._confirming = False
128
+ self._editing = False
129
+
130
+ def enter_rescue(self) -> None:
131
+ self.state = "rescue"
132
+
133
+ def enter_auth(self, kind: str) -> None:
134
+ # 渲染器传 ask_password 的 auth_kind 原样("password"|"cmdset" 归
135
+ # "password",core 侧同入 _pws 队列;"nested_password" 原样回传);
136
+ # host_key 走独立 y/n 键盘态
137
+ self.state = "host_key" if kind == "host_key" else "password"
138
+ self._auth_kind = kind if kind in ("nested_password",) else "password"
139
+ self._buf = b""
140
+
141
+ def exit_approval(self) -> None:
142
+ if self.state == "approval":
143
+ self._exit_state()
144
+
145
+ def exit_rescue(self) -> None:
146
+ if self.state == "rescue":
147
+ self._exit_state()
148
+
149
+ def exit_auth(self) -> None:
150
+ if self.state in ("password", "host_key"):
151
+ self._exit_state()
152
+
153
+ def _exit_state(self) -> None:
154
+ self.state = None
155
+ self._confirming = False
156
+ self._buf = b""
157
+ self._editing = False
158
+
159
+ # --- 待决审批原命令(编辑流预填)---
160
+
161
+ @property
162
+ def pending_command(self) -> str:
163
+ """待决审批的原命令。enter_approval(risk) 形状被测试钉死不能加参,
164
+ 渲染器在调用前把命令写进 frontend._pending_approval_command。"""
165
+ return getattr(self._frontend, "_pending_approval_command", "") or ""
166
+
167
+ # --- 键盘路由 ---
168
+
169
+ def on_keys(self, data: bytes) -> tuple[str, object]:
170
+ if self.state is None:
171
+ return self._idle_keys(data)
172
+ if self.state == "approval":
173
+ return self._approval_keys(data)
174
+ if self.state == "rescue":
175
+ return self._rescue_keys(data)
176
+ return self._auth_keys(data)
177
+
178
+ def _task_active(self) -> bool:
179
+ """核心任务活跃 = core._ai_task 非 None(core.py 真实属性名)。"""
180
+ core = self._frontend.core
181
+ return getattr(core, "_ai_task", None) is not None
182
+
183
+ def _idle_keys(self, data: bytes) -> tuple[str, object]:
184
+ """无截获态:\\x03 任务活跃 → interrupt(消费掉不透传),空闲透传;
185
+ 其余字节(含 \\x04)全量直通。"""
186
+ if b"\x03" not in data:
187
+ return ("pass", data)
188
+ out = bytearray()
189
+ for i, byte in enumerate(data):
190
+ if byte == 3 and self._task_active():
191
+ self._send(ClientMsg(type="interrupt"))
192
+ else:
193
+ out.append(byte)
194
+ return ("pass", bytes(out))
195
+
196
+ def _approval_keys(self, data: bytes) -> tuple[str, object]:
197
+ out = bytearray() # approval 其余键吞掉;\\x04 透传为唯一例外
198
+ for i, byte in enumerate(data):
199
+ if byte == 4: # Ctrl+D 永远透传
200
+ out.append(byte)
201
+ continue
202
+ if byte == 3: # 待决期 Ctrl+C = reject(钉死)
203
+ self._send_decision("reject")
204
+ rest = bytes(out) + data[i + 1:]
205
+ return self.on_keys(rest) if rest else ("pass", b"")
206
+ if self._confirming:
207
+ if byte == 13: # confirming \\r → approve
208
+ self._send_decision("approve")
209
+ rest = bytes(out) + data[i + 1:]
210
+ return self.on_keys(rest) if rest else ("pass", b"")
211
+ if byte in (127, 8): # 仅退出 confirming 回待决
212
+ self._confirming = False
213
+ continue # confirming 其余键吞掉
214
+ if byte == 13: # \\r
215
+ if self._risk == "high":
216
+ self._confirming = True # 二段确认(红行提示)
217
+ self._frontend._capture_notice(
218
+ "确认执行?Enter 放行 / Backspace 取消")
219
+ else:
220
+ self._send_decision("approve")
221
+ rest = bytes(out) + data[i + 1:]
222
+ return self.on_keys(rest) if rest else ("pass", b"")
223
+ elif byte in (127, 8): # \\x7f/\\x08 → reject
224
+ self._send_decision("reject")
225
+ rest = bytes(out) + data[i + 1:]
226
+ return self.on_keys(rest) if rest else ("pass", b"")
227
+ elif bytes([byte]) in (b"e", b"E"):
228
+ self._editing = True # 进编辑流(泵 await _run_edit)
229
+ # 余量随 edit 返回:_run_edit 完成后泵继续路由(不丢字节);
230
+ # 前缀里已透传的字节(如 \x04)也要一并带回,不能静默吞掉
231
+ return ("edit", bytes(out) + data[i + 1:])
232
+ # 其余键消费不透传(与 web approvalKeys 同规则)
233
+ return ("pass", bytes(out))
234
+
235
+ def _rescue_keys(self, data: bytes) -> tuple[str, object]:
236
+ out = bytearray()
237
+ for i, byte in enumerate(data):
238
+ if byte == 3: # 待决期 Ctrl+C = 忽略救援
239
+ self._send_rescue(False)
240
+ rest = bytes(out) + data[i + 1:]
241
+ return self.on_keys(rest) if rest else ("pass", b"")
242
+ if byte in (121, 89): # y/Y → 交给 AI
243
+ self._send_rescue(True)
244
+ rest = bytes(out) + data[i + 1:] # 前缀透传字节不丢(minor 1)
245
+ return self.on_keys(rest) if rest else ("pass", b"")
246
+ if byte in (110, 78): # n/N → 忽略
247
+ self._send_rescue(False)
248
+ rest = bytes(out) + data[i + 1:]
249
+ return self.on_keys(rest) if rest else ("pass", b"")
250
+ out.append(byte) # 其余键透传
251
+ return ("pass", bytes(out))
252
+
253
+ def _auth_keys(self, data: bytes) -> tuple[str, object]:
254
+ if self.state == "host_key":
255
+ return self._host_key_keys(data)
256
+ return self._password_keys(data)
257
+
258
+ def _password_keys(self, data: bytes) -> tuple[str, object]:
259
+ """隐藏输入:可打印累积(不回显)、\\x7f 退格、\\r 提交;期间
260
+ 字节一律不透传(\\x04 例外:原样透传,计划钉死「永远透传」)。"""
261
+ out = bytearray() # 仅累积 \x04(隐藏输入期间唯一透传字节)
262
+ for i, byte in enumerate(data):
263
+ if byte == 4: # Ctrl+D 永远透传
264
+ out.append(byte)
265
+ continue
266
+ if byte == 3: # 隐藏输入不外泄:吞掉
267
+ continue
268
+ if byte == 13: # \\r 提交
269
+ self._send(ClientMsg(type="auth", auth_kind=self._auth_kind,
270
+ text=self._buf.decode("utf-8",
271
+ "replace")))
272
+ self._exit_state()
273
+ rest = bytes(out) + data[i + 1:]
274
+ return self.on_keys(rest) if rest else ("pass", b"")
275
+ if byte in (127, 8): # 退格(整字符删)
276
+ self._pop_password_char()
277
+ continue
278
+ if byte >= 32 or byte < 0: # 可打印/非 ASCII 累积
279
+ self._buf += bytes([byte])
280
+ # 其余控制键吞掉
281
+ return ("pass", bytes(out))
282
+
283
+ def _pop_password_char(self) -> None:
284
+ """密码缓冲退格:整字符删除——多字节 UTF-8 密码(IME/粘贴)按字节
285
+ 删会留悬空半字符,提交时 decode 成 U+FFFD 静默损坏。"""
286
+ if not self._buf:
287
+ return
288
+ if self._buf[-1] & 0xC0 == 0x80:
289
+ # 尾字节是续字节:剥掉整段续字节,再删该字符的首字节
290
+ self._buf = self._buf[:-1]
291
+ while self._buf and self._buf[-1] & 0xC0 == 0x80:
292
+ self._buf = self._buf[:-1]
293
+ if self._buf and self._buf[-1] & 0xC0 == 0xC0:
294
+ self._buf = self._buf[:-1]
295
+ else:
296
+ self._buf = self._buf[:-1] # ASCII/单字节:只删这一个字节
297
+
298
+ def _host_key_keys(self, data: bytes) -> tuple[str, object]:
299
+ out = bytearray()
300
+ for i, byte in enumerate(data):
301
+ if byte == 4: # Ctrl+D 永远透传
302
+ out.append(byte)
303
+ continue
304
+ if byte == 3: # 吞掉不泄漏
305
+ continue
306
+ if byte in (121, 89): # y → 信任
307
+ self._send(ClientMsg(type="auth", auth_kind="host_key",
308
+ text="true"))
309
+ self._exit_state()
310
+ rest = bytes(out) + data[i + 1:]
311
+ return self.on_keys(rest) if rest else ("pass", b"")
312
+ if byte in (110, 78): # n → 拒绝
313
+ self._send(ClientMsg(type="auth", auth_kind="host_key",
314
+ text="false"))
315
+ self._exit_state()
316
+ rest = bytes(out) + data[i + 1:]
317
+ return self.on_keys(rest) if rest else ("pass", b"")
318
+ out.append(byte) # 其余透传
319
+ return ("pass", bytes(out))
320
+
321
+ # --- 决策上行(payload 与 web 完全一致,源自 approval.py)---
322
+
323
+ def _send(self, msg: ClientMsg) -> None:
324
+ """on_keys 同步路由里的 async 上行:调度为任务立即发出。"""
325
+ asyncio.ensure_future(self._frontend.core.feed_msg(msg))
326
+
327
+ def _send_decision(self, kind: str) -> None:
328
+ if kind == "approve":
329
+ decision: dict = {"type": "approve"}
330
+ else:
331
+ decision = {"type": "reject", "message": "用户拒绝了该命令"}
332
+ self._send(ClientMsg(type="decision", decision=decision))
333
+ self._exit_state()
334
+ # 回执定格行本地合成(web store decide 事件的 CLI 等价物;core 不发
335
+ # decide 事件,呈现与防御性承接共用 renderer 同一份代码,spec §3.2)
336
+ self._frontend._receipt_decide(decision)
337
+
338
+ def _send_rescue(self, accept: bool) -> None:
339
+ self._send(ClientMsg(type="rescue", accept=accept))
340
+ self._exit_state()
341
+ self._frontend._receipt_rescue_decide(accept)
342
+
343
+ # --- 编辑流决策(_run_edit 收尾调用)---
344
+
345
+ def send_edit(self, new_command: str) -> None:
346
+ self._send(ClientMsg(type="decision", decision=edit_decision(new_command)))
347
+ self._exit_state()
348
+
349
+ def send_approve(self) -> None:
350
+ self._send_decision("approve")
351
+
352
+ def send_reject(self, message: str) -> None:
353
+ self._send(ClientMsg(type="decision",
354
+ decision={"type": "reject", "message": message}))
355
+ self._exit_state()
356
+
357
+ def cancel_edit(self) -> None:
358
+ """编辑取消/EOF:回待决态(不发决策)。"""
359
+ self._editing = False
360
+ self._confirming = False
361
+
362
+
363
+ def _fmt_tokens(n: int) -> str:
364
+ """token 数渲染:>=1000 缩写为 k(1200 → "1.2k"),.0 去尾。"""
365
+ if n < 1000:
366
+ return str(n)
367
+ if n < 1_000_000:
368
+ s = f"{n / 1000:.1f}"
369
+ else:
370
+ s = f"{n / 1_000_000:.1f}M"
371
+ if s.endswith(".0"):
372
+ s = s[:-2]
373
+ return s + ("k" if n < 1_000_000 else "")
374
+
375
+
376
+ class _WriterFile:
377
+ """rich Console → frontend.writer 的 file-like 适配器(纪律 #1)。
378
+
379
+ Console 的每笔输出编码成 UTF-8 字节后经 frontend._writer 发出,与
380
+ PTY 字节在同一串行 outbox 出队(Review Focus #3 不撕裂)。持有
381
+ frontend 而非 writer 本身:测试替换 frontend._writer 时取的是当次值。
382
+ """
383
+
384
+ def __init__(self, frontend: "TermFrontend") -> None:
385
+ self._fe = frontend
386
+
387
+ def write(self, s: str) -> int:
388
+ # raw 态终端 OPOST/ONLCR 已关:rich 输出的 \n 须翻译成 \r\n 防阶梯
389
+ self._fe._writer(
390
+ s.replace("\r\n", "\n").replace("\n", "\r\n").encode("utf-8"))
391
+ return len(s)
392
+
393
+ def flush(self) -> None:
394
+ pass # writer 侧直写 stdout.buffer 已带 flush
395
+
396
+
397
+ def _default_console(frontend: "TermFrontend"):
398
+ """生产 console:经 _WriterFile 走 frontend.writer(UTF-8 字节串行出队)。
399
+
400
+ force_terminal=True:CLI 输出恒为终端(面板边框/风险色 ANSI 直出),
401
+ 测试注入 StringIO console 时不受影响(适配器同样成立,渲染输出与
402
+ 字节流在测试里可分开断言)。宽度取当前终端列数。"""
403
+ return Console(file=_WriterFile(frontend), force_terminal=True,
404
+ width=_term_size()[1])
405
+
406
+
407
+ def _clip_display(s: str, max_cols: int) -> str:
408
+ """按显示列宽截断(rich cells 口径),保证状态行单行不折行。"""
409
+ if cell_len(s) <= max_cols:
410
+ return s
411
+ out = ""
412
+ for ch in s:
413
+ if cell_len(out + ch) > max_cols:
414
+ break
415
+ out += ch
416
+ return out
417
+
418
+
419
+ # 模型文本可能夹带的字面 ANSI 转义(工具输出复读等):进思考框前剥除,
420
+ # 否则会击穿盒内 dim/蓝样式(\x1b[0m 把 pad 与右边框重置成默认色),
421
+ # 且逐字符计宽会把 ESC 序列算进显示宽 → 边框漂移
422
+ _ANSI_RE = re.compile(
423
+ r"\x1b\[[0-9;:?]*[ -/]*[@-~]" # CSI(SGR 颜色等)
424
+ r"|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)" # OSC(BEL / ST 收尾)
425
+ r"|\x1b[@-Z\\-_]" # 其余双字符转义
426
+ )
427
+
428
+
429
+ def _display_width(s: str) -> int:
430
+ """显示列宽(rich cells 口径:CJK 全角计 2 列,组合符/ZWJ 计 0,
431
+ ambiguous 计 1 列——与 rich 面板及主流终端一致;旧逐字符
432
+ east_asian_width 口径会把组合符算 1 列、emoji 序列算错)。"""
433
+ return cell_len(s)
434
+
435
+
436
+ def _wrap_display(s: str, max_cols: int) -> list[str]:
437
+ """按显示列宽软折行(rich cells 口径),思考框行内容用。
438
+
439
+ max_cols ≤ 0 时原样单行返回(窄终端护栏由调用方兜底);空串返回
440
+ 单元素 [""],与 chop_cells(空串给 [])对齐,保证调用方总有行可画。"""
441
+ if max_cols <= 0:
442
+ return [s]
443
+ if not s:
444
+ return [""]
445
+ return chop_cells(s, max_cols)
446
+
447
+
448
+ class _StatusLine:
449
+ """任务期单行状态(转轮 + 计时 + token 计数)。
450
+
451
+ 绘制协议(Review Focus #3):写任何内容前先 ``\\r\\x1b[K`` 擦本行,
452
+ 写完重画;erase/redraw 只对 active 态生效。文本形如
453
+ ``⠋ 任务进行中 12s · in 1.2k / out 340``(estimated 时 ≈1.2k)。
454
+ 0.25s tick 由 TermFrontend._tick_loop 经 _outbox 入队 ("tick", None)
455
+ (串行不撕裂),_render_loop 的 tick 分支调 redraw()。
456
+
457
+ 光标状态两比特(防覆盖流式文本的关键):
458
+ - _drawn:状态行当前在屏上,光标停在其行尾(可原位 ``\\r`` 刷新);
459
+ - _fresh:光标停在新空行(可安全落状态行)。
460
+ 流式 chunk 直写后两者皆否(光标在流式文本行中),redraw 跳过,
461
+ 状态行隐没到下一个收束点(面板/换行结尾的写入)再重现。
462
+ """
463
+
464
+ _FRAMES = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏" # 转轮帧,每次 redraw 步进一格
465
+
466
+ def __init__(self, frontend: "TermFrontend") -> None:
467
+ self._frontend = frontend
468
+ self.active = False
469
+ self._label = "任务进行中"
470
+ self._start = time.monotonic()
471
+ self._frame = 0
472
+ self._t_in = 0
473
+ self._t_out = 0
474
+ self._estimated = False
475
+ self._drawn = False
476
+ self._fresh = True
477
+
478
+ def _writer(self, data: bytes) -> None:
479
+ self._frontend._writer(data)
480
+
481
+ def _text(self) -> str:
482
+ el = max(0, int(time.monotonic() - self._start))
483
+ parts = [f"{self._FRAMES[self._frame]} {self._label} {el}s"]
484
+ if self._t_in or self._t_out:
485
+ sign = "≈" if self._estimated else ""
486
+ parts.append(f"in {sign}{_fmt_tokens(self._t_in)}"
487
+ f" / out {sign}{_fmt_tokens(self._t_out)}")
488
+ return " · ".join(parts)
489
+
490
+ def activate(self, label: str) -> None:
491
+ self._label = label
492
+ self._start = time.monotonic()
493
+ self._frame = 0
494
+ self._t_in = self._t_out = 0
495
+ self._estimated = False
496
+ self.active = True
497
+ self.redraw()
498
+
499
+ def update(self, tokens_in: int, tokens_out: int, estimated: bool) -> None:
500
+ self._t_in = tokens_in
501
+ self._t_out = tokens_out
502
+ self._estimated = estimated
503
+ self.redraw()
504
+
505
+ def deactivate(self) -> None:
506
+ # 先擦再置 False:擦除只对在屏状态行生效,顺序反了会把状态行留在屏上
507
+ self.erase()
508
+ self.active = False
509
+
510
+ def reactivate(self) -> None:
511
+ """编辑流等临时接管屏幕后复活:保留计时/计数(activate 会归零)。"""
512
+ self.active = True
513
+ self.redraw()
514
+
515
+ def erase(self) -> None:
516
+ """擦除在屏状态行(\\r\\x1b[K);光标回到该行行首(视为新空行)。"""
517
+ if not self._drawn:
518
+ return
519
+ self._drawn = False
520
+ self._fresh = True
521
+ self._writer(b"\r\x1b[K")
522
+
523
+ def redraw(self) -> None:
524
+ """重画:光标在新空行(落新行)或状态行在屏(原位刷新)时有效;
525
+ 流式行中(两者皆否)跳过,防止覆盖流式文本。"""
526
+ if not self.active or not (self._fresh or self._drawn):
527
+ return
528
+ text = self._text()
529
+ # \x1b[K 只清一行:状态行必须单行,超终端宽度时截断(窄终端防折行)
530
+ text = _clip_display(text, max(1, _term_size()[1] - 1))
531
+ self._frame = (self._frame + 1) % len(self._FRAMES)
532
+ self._writer(f"\r{text}".encode("utf-8"))
533
+ self._drawn = True
534
+ self._fresh = False
535
+
536
+ def note_write(self, data: bytes | str) -> None:
537
+ """流式直写后同步光标状态:换行结尾 → 新空行;否则行中。
538
+
539
+ 裸 \\r 不算新空行:光标虽回行首,但该行仍有内容(进度条 \\r 帧),
540
+ 重画状态行会覆盖它——只有 \\n(新行)/erase(清行)产生 fresh。"""
541
+ if isinstance(data, bytes):
542
+ ended = data.endswith(b"\n")
543
+ else:
544
+ ended = data.endswith("\n")
545
+ if ended:
546
+ self._fresh = True
547
+ else:
548
+ self._fresh = False
549
+ self._drawn = False
550
+
551
+ @property
552
+ def at_fresh_line(self) -> bool:
553
+ """光标是否停在新空行(_print 据此决定是否先补换行收束)。"""
554
+ return self._fresh
555
+
556
+
557
+ class CliRenderer:
558
+ """内联渲染器:ServerMsg → 终端呈现(spec §3.2 映射表 + §3.4 零占位协议)。
559
+
560
+ - 面板/单行文本经注入的 rich console 输出;_print 会先擦状态行再写、
561
+ 写完重画(若状态行还活着),防止 rich 输出与状态行互相覆盖。
562
+ - 流式(ai_token/ai_think)与状态行不经 console:增量文本直接经
563
+ frontend 的 writer 追加(think 行缓冲后走蓝色 ROUNDED 边框逐行
564
+ 落笔——rich Panel 同款视觉;窄终端退回暗灰裸流)。
565
+ - ai_boundary → 立即 ``feed_msg(boundary_settled)``(零占位协议),
566
+ padded/new_session 忽略;decide/task_fail/rescue_decide 是
567
+ web 前端本地合成的事件词汇(core 不发),渲染器按 spec 词汇表
568
+ 防御性承接。
569
+ - approval/rescue/ask_password/ask_host_key 渲染后进入本地截获态
570
+ (frontend.capture.enter_*,按键语义由 _CaptureLayer 承接)。
571
+ """
572
+
573
+ _THINK_BORDER = "\x1b[34m" # 思考框边框色(蓝,对齐现有色板)
574
+
575
+ def __init__(self, core, console, frontend=None):
576
+ # frontend 允许缺省:取 core._frontend(CliCore 两段构造回填);
577
+ # TermFrontend 构造时显式传 self,形状更稳(Task 5 会再碰这里)
578
+ self._core = core
579
+ self._console = console
580
+ self._frontend = frontend if frontend is not None \
581
+ else getattr(core, "_frontend", None)
582
+ self.status = _StatusLine(self._frontend) if self._frontend is not None \
583
+ else None
584
+ self._think_open = False # 暗灰思考流开着(未收束)
585
+ self._think_text = "" # 当前思考段累计文本(定格行数的事实源)
586
+ self._think_buf = "" # 行缓冲残余半行(框内逐行落笔用)
587
+ self._think_box = False # 本思考段走边框(窄终端退回裸暗灰流)
588
+ self._token_open = False # 常规流式行开着(写过未换行)
589
+ self._tool_open = False # 工具调用行开着(写过未换行,end 补状态尾巴)
590
+ self._last_summary = "" # final 与 ai_card 同文去重(对齐 web store)
591
+
592
+ def _writer(self) -> object:
593
+ if self._frontend is not None:
594
+ return self._frontend._writer
595
+ return _default_writer
596
+
597
+ async def render(self, msg: ServerMsg) -> None:
598
+ t = msg.type
599
+ if t == "ready":
600
+ self._on_ready(msg)
601
+ elif t == "stage":
602
+ self._on_stage(msg)
603
+ elif t == "status":
604
+ self._print(Text(msg.text))
605
+ elif t == "usage":
606
+ if self.status is not None:
607
+ self.status.update(msg.tokens_in, msg.tokens_out,
608
+ msg.estimated)
609
+ elif t == "cmdset":
610
+ self._on_cmdset(msg)
611
+ elif t == "approval":
612
+ self._on_approval(msg)
613
+ elif t == "ask_password":
614
+ self._on_ask_password(msg)
615
+ elif t == "ask_host_key":
616
+ self._on_ask_host_key(msg)
617
+ elif t == "closed":
618
+ self._on_closed()
619
+ elif t == "reconnected":
620
+ self._print(Text(msg.text))
621
+ elif t == "event" and isinstance(msg.event, dict):
622
+ await self._on_event(msg.event)
623
+ # 未知 type 静默忽略(对齐 web handleEvent 的 default 分支)
624
+
625
+ # --- ServerMsg type 分支 ---
626
+
627
+ def _on_ready(self, msg: ServerMsg) -> None:
628
+ """一行连接信息(spec §3.2 映射表原文):interactive 字段存在且为
629
+ 1 → 「AI 就绪」;否则 → 「AI 不可用:shell 不支持集成」。"""
630
+ body = Text.assemble(
631
+ ("已连接 ", None),
632
+ (msg.host or "?", "bold"),
633
+ (f" {msg.user}" if msg.user else "", None),
634
+ (f"({msg.distro})" if msg.distro else "", None),
635
+ (" [", None),
636
+ ("AI 就绪", "green") if msg.interactive == 1
637
+ else ("AI 不可用:shell 不支持集成", "red"),
638
+ ("]", None),
639
+ )
640
+ self._print(body)
641
+
642
+ def _on_stage(self, msg: ServerMsg) -> None:
643
+ if msg.text == "agent_init":
644
+ self._print(Text("正在初始化 Agent…", style="dim cyan"))
645
+ elif msg.text:
646
+ self._print(Text(f"连接阶段:{msg.text}", style="dim cyan"))
647
+
648
+ def _on_cmdset(self, msg: ServerMsg) -> None:
649
+ if msg.state == "running":
650
+ self._print(Text(f"命令集 {msg.index}/{msg.total}",
651
+ style="cyan"))
652
+ elif msg.state == "done":
653
+ self._print(Text(f"命令集完成({msg.total}/{msg.total})",
654
+ style="cyan"))
655
+ # paused:CLI 无「继续」入口,计划映射表未定义 → 静默
656
+
657
+ def _on_approval(self, msg: ServerMsg) -> None:
658
+ """审批框:risk=="high" 红色边框、"normal" 蓝色;同时进入本地
659
+ 截获态(_CaptureLayer 消费决策键)。空 risk 按 high 兜底——
660
+ 面板颜色与截获层二段确认同一口径(此前面板画蓝、按键却走红行
661
+ 二段确认,视觉与交互矛盾)。"""
662
+ risk = msg.risk or "high"
663
+ border = "red" if risk == "high" else "blue"
664
+ title = "高危命令待审批" if risk == "high" else "命令待审批"
665
+ body = Text.assemble(
666
+ ("理由:", "dim"), (msg.reasons or "—", None),
667
+ (" · 主机:", "dim"), (msg.host or "—", None),
668
+ ("\n", None),
669
+ ("Enter 执行 · Backspace 拒绝 · e 编辑", "bold yellow"),
670
+ )
671
+ self._print(Panel(
672
+ Group(Syntax(msg.command, "bash", word_wrap=True,
673
+ theme="ansi_dark"), body),
674
+ title=f"[bold {border}]{title}[/] · {msg.host}",
675
+ title_align="left", border_style=border))
676
+ if self._frontend is not None:
677
+ # 编辑流预填源:enter_approval(risk) 调用形状被测试钉死不能
678
+ # 加参,原命令经 frontend 属性传递(截获层 e 键时读取)
679
+ self._frontend._pending_approval_command = msg.command
680
+ self._frontend.capture.enter_approval(risk)
681
+
682
+ def _on_ask_password(self, msg: ServerMsg) -> None:
683
+ label = msg.label or "密码"
684
+ self._print(Text.assemble((label, "bold"), ("(输入不回显,Enter 提交)",
685
+ "dim")))
686
+ if self._frontend is not None:
687
+ self._frontend.capture.enter_auth(msg.auth_kind or "password")
688
+
689
+ def _on_ask_host_key(self, msg: ServerMsg) -> None:
690
+ self._print(Text(msg.message or "未知主机指纹,是否信任?"))
691
+ self._print(Text("按 y 信任并继续 / n 拒绝连接", style="bold yellow"))
692
+ if self._frontend is not None:
693
+ self._frontend.capture.enter_auth("host_key")
694
+
695
+ def _on_closed(self) -> None:
696
+ self._flush_streams()
697
+ self._print(Text("会话已结束", style="dim"))
698
+ self._status_off() # Task 3 已有 stop 逻辑,这里只收状态行
699
+
700
+ # --- 事件分支 ---
701
+
702
+ async def _on_event(self, ev: dict) -> None:
703
+ kind = ev.get("kind")
704
+ if kind == "task_start":
705
+ self._flush_streams()
706
+ self._think_text = ""
707
+ self._last_summary = ""
708
+ if self.status is not None:
709
+ self.status.activate("任务进行中") # 状态行点亮(计时开始)
710
+ elif kind == "ai_token":
711
+ self._on_ai_token(ev.get("text", ""))
712
+ elif kind == "ai_think":
713
+ self._on_ai_think(ev.get("text", ""))
714
+ elif kind == "ai_collapse":
715
+ self._on_ai_collapse(ev.get("command", ""))
716
+ elif kind == "ai_tool":
717
+ self._on_ai_tool(ev)
718
+ elif kind in ("final", "ai_card"):
719
+ text = ev.get("text") or ev.get("markdown") or ""
720
+ self._on_final_card(text)
721
+ elif kind == "denied":
722
+ self._fail_line(ev.get("text", ""), "✗", "bold red")
723
+ elif kind == "limit":
724
+ self._fail_line(ev.get("text", ""), "⚠", "bold yellow")
725
+ elif kind == "interrupt":
726
+ # 用户中止(Ctrl+C/⏹):黄行收尾(对齐 web 状态栏「已停止」)
727
+ self._fail_line(ev.get("text", "已停止"), "⏹", "bold yellow")
728
+ elif kind in ("error", "task_fail"):
729
+ self._fail_line(ev.get("text") or ev.get("reason", ""),
730
+ "✗", "bold red")
731
+ elif kind == "rescue":
732
+ self._on_rescue(ev)
733
+ elif kind == "rescue_decide":
734
+ self._on_rescue_decide(ev)
735
+ elif kind == "decide":
736
+ self._on_decide(ev)
737
+ elif kind == "session_cleared":
738
+ self._flush_streams()
739
+ self._think_text = ""
740
+ self._last_summary = ""
741
+ self._print(Text("已开启新任务"))
742
+ elif kind == "ai_boundary":
743
+ # 零占位协议(§3.4):无卡可结账,立即 ack 让命令注入零等待
744
+ if self._core is not None:
745
+ await self._core.feed_msg(ClientMsg(type="boundary_settled"))
746
+ # padded / new_session / 未知事件 → 忽略(零占位协议)
747
+
748
+ def _on_ai_token(self, text: str) -> None:
749
+ """常规流式:增量文本直接经 writer 追加(不经 console)。"""
750
+ if not text or self._frontend is None:
751
+ return
752
+ if self._think_open:
753
+ # 思考流开着被 token 打断:关框收底(摘要由 ai_collapse 定格)
754
+ if self._think_box:
755
+ self._close_think_box()
756
+ else:
757
+ self._stream_write("\r\n")
758
+ self._think_open = False
759
+ self._think_text = ""
760
+ elif self.status is not None and self.status.active:
761
+ self.status.erase() # 状态行让位:token 从其行首起写
762
+ self._token_open = True
763
+ self._stream_write(text)
764
+
765
+ def _on_ai_think(self, text: str) -> None:
766
+ """思考流式:蓝色 ROUNDED 边框逐行呈现(窄终端退回裸暗灰流)。
767
+
768
+ 行缓冲后整行落笔(对齐 rich Panel 视觉):凑齐 \\n 才出框内行,
769
+ 因此每行都能 pad 到内宽再封右边框;残余半行由 _close_think_box 冲。"""
770
+ if not text or self._frontend is None:
771
+ return
772
+ # 模型文本可能夹带字面 ANSI/制表符:先剥除/展开再进盒,否则
773
+ # 击穿盒内样式且计宽错算(_ANSI_RE 处有完整说明)
774
+ text = _ANSI_RE.sub("", text).replace("\t", " ")
775
+ if not text:
776
+ return
777
+ if self._token_open:
778
+ self._stream_write("\r\n")
779
+ self._token_open = False
780
+ if not self._think_open:
781
+ self._think_open = True
782
+ self._think_text = ""
783
+ self._think_buf = ""
784
+ self._think_box = self._console.width >= 24 # 窄终端护栏
785
+ if self._think_box:
786
+ self._emit_think_line(self._think_top())
787
+ self._think_text += text
788
+ if self._think_box:
789
+ self._think_buf += text.replace("\r\n", "\n")
790
+ while "\n" in self._think_buf:
791
+ line, self._think_buf = self._think_buf.split("\n", 1)
792
+ self._emit_think_content(line)
793
+ else:
794
+ if self.status is not None and self.status.active:
795
+ self.status.erase() # 状态行让位:思考流从其行首起写
796
+ self._stream_write(f"\x1b[2m{text}\x1b[22m")
797
+
798
+ def _think_top(self) -> str:
799
+ """框顶 `╭─ 💭 思考 ────╮`(蓝色,总宽 = console.width)。"""
800
+ w = self._console.width
801
+ title = "💭 思考"
802
+ # ╭─ + title + 空格 + dashes + ╮ = w
803
+ dashes = max(1, w - _display_width(title) - 5)
804
+ return f"{self._THINK_BORDER}╭─ {title} " + "─" * dashes \
805
+ + f"╮\x1b[0m"
806
+
807
+ def _emit_think_line(self, raw: str) -> None:
808
+ """框线出笔:状态行让位(防重画嵌进框内行间)后整行落笔。"""
809
+ if self.status is not None and self.status.active:
810
+ self.status.erase()
811
+ self._stream_write(raw + "\r\n")
812
+
813
+ def _emit_think_content(self, line: str) -> None:
814
+ """框内一行:│ + 暗灰内容(软折行、pad 到内宽)+ │。"""
815
+ inner = max(1, self._console.width - 4)
816
+ for seg in _wrap_display(line, inner):
817
+ pad = " " * max(0, inner - _display_width(seg))
818
+ self._emit_think_line(
819
+ f"{self._THINK_BORDER}│\x1b[0m \x1b[2m{seg}{pad}\x1b[22m"
820
+ f" {self._THINK_BORDER}│\x1b[0m")
821
+
822
+ def _close_think_box(self) -> None:
823
+ """思考框收底:冲残余半行 + 底边(`… 思考 N 行` 摘要归调用方)。"""
824
+ if self._think_buf:
825
+ buf, self._think_buf = self._think_buf, ""
826
+ self._emit_think_content(buf)
827
+ self._emit_think_line(
828
+ f"{self._THINK_BORDER}╰" + "─" * max(1, self._console.width - 2)
829
+ + f"╯\x1b[0m")
830
+
831
+ def _end_think(self) -> None:
832
+ """思考段收束:定格一行摘要 `… 思考 N 行`(spec ai_collapse 映射)。"""
833
+ if self._frontend is None:
834
+ self._think_open = False
835
+ self._think_text = ""
836
+ self._think_buf = ""
837
+ return
838
+ if self._think_open:
839
+ if self._think_box:
840
+ self._close_think_box()
841
+ else:
842
+ self._stream_write("\r\n")
843
+ self._think_open = False
844
+ n = len([l for l in self._think_text.split("\n") if l.strip()])
845
+ if n:
846
+ self._stream_write(f"… 思考 {n} 行\r\n")
847
+ self._think_text = ""
848
+ self._think_buf = ""
849
+
850
+ def _on_ai_collapse(self, command: str) -> None:
851
+ """段落定格:先收思考段,再呈现 AI 发起的命令面板(原 web 卡片头)。"""
852
+ self._end_think()
853
+ if not command:
854
+ return
855
+ body = Text.assemble(
856
+ ("$ ", "dim"), (command, None), ("\n", None),
857
+ ("(AI 发起执行,输出见上方终端)", "dim"),
858
+ )
859
+ self._print(Panel(body, title="▶ 执行", title_align="left",
860
+ border_style="cyan"))
861
+
862
+ def _on_ai_tool(self, ev: dict) -> None:
863
+ """工具调用一行式(web 工具卡的 CLI 映射):青色 `🔧 read_file(path) …`,
864
+ end 原行补 ✓/✗ 尾巴。start 时若思考框/流式行开着先收底(对齐 token
865
+ 打断思考的行为);任务结束前未收的工具行由 _flush_streams 收尾。"""
866
+ if self._frontend is None:
867
+ return
868
+ if ev.get("name") == "execute":
869
+ return # CLI 的执行面板走 ai_collapse/输出流原生路径,不叠工具行
870
+ if ev.get("phase") == "start":
871
+ self._end_think()
872
+ if self._token_open:
873
+ self._stream_write("\r\n")
874
+ self._token_open = False
875
+ if self.status is not None and self.status.active:
876
+ self.status.erase() # 状态行让位:工具行从其行首起写
877
+ name = ev.get("name", "")
878
+ args = ev.get("args", "")
879
+ self._tool_open = True
880
+ self._stream_write(
881
+ f"\x1b[36m🔧 {name}\x1b[0m"
882
+ + (f"({args})" if args else "") + " …")
883
+ else:
884
+ if not self._tool_open:
885
+ return
886
+ mark = "\x1b[31m✗\x1b[0m" if ev.get("failed") else "\x1b[32m✓\x1b[0m"
887
+ self._stream_write(f" {mark}\r\n")
888
+ self._tool_open = False
889
+
890
+ def _flush_streams(self) -> None:
891
+ """final/error/denied/limit/task_fail/closed 前收束进行中的流式行。"""
892
+ self._end_think()
893
+ if self._token_open:
894
+ if self._frontend is not None:
895
+ self._stream_write("\r\n")
896
+ self._token_open = False
897
+ if self._tool_open:
898
+ if self._frontend is not None:
899
+ self._stream_write("\r\n")
900
+ self._tool_open = False
901
+
902
+ def _on_final_card(self, text: str) -> None:
903
+ self._flush_streams()
904
+ if not text or text == self._last_summary:
905
+ return
906
+ self._last_summary = text
907
+ self._summary_panel(text)
908
+ self._status_off()
909
+
910
+ def _summary_panel(self, text: str) -> None:
911
+ """总结框(render.print_summary 等价,但经本前端 console——不改
912
+ render.py)。rich Markdown 渲染(markdown 包缺失时 Text 兜底);
913
+ final 与 ai_card 连发同一份 markdown,同文去重(_on_final_card)。"""
914
+ try:
915
+ from rich.markdown import Markdown
916
+ content = Markdown(text)
917
+ except ImportError: # rich.markdown 缺依赖(markdown 包)→ 纯文本
918
+ content = Text(text)
919
+ self._print(Panel(content, title="[bold green]📝 总结[/]",
920
+ title_align="left", border_style="green"))
921
+
922
+ def _fail_line(self, text: str, symbol: str, style: str) -> None:
923
+ """denied/limit/error/task_fail:红/黄单行 + 状态行定格。"""
924
+ self._flush_streams()
925
+ body = Text.assemble((f"{symbol} ", style), (text, None))
926
+ self._print(body)
927
+ self._status_off()
928
+
929
+ def _status_off(self) -> None:
930
+ if self.status is not None:
931
+ self.status.deactivate()
932
+
933
+ def _on_decide(self, ev: dict) -> None:
934
+ """决策回执:审批框原位收回执行(✓ 已执行 / ✗ 已拒绝,spec §3.2)。
935
+ core 不发 decide 事件——由截获层发决策时经 _receipt_decide 本地
936
+ 合成(web store decide 同语义);此处也是防御性承接入口。"""
937
+ self._flush_streams()
938
+ d = ev.get("decision") or {}
939
+ typ = d.get("type") if isinstance(d, dict) else d
940
+ if typ == "reject":
941
+ self._print(Text("✗ 已拒绝", style="bold red"))
942
+ else:
943
+ self._print(Text("✓ 已执行", style="bold green"))
944
+
945
+ def _on_rescue(self, ev: dict) -> None:
946
+ """救援面板:失败行 + 退出码 + 输出尾部;同时进入本地截获态。"""
947
+ self._flush_streams()
948
+ out = (ev.get("output") or "").strip()
949
+ out_tail = "\n".join(out.splitlines()[-12:]) # 只展示输出尾部
950
+ body = Text.assemble(
951
+ ("失败命令:", "dim"), (ev.get("line", ""), None), ("\n", None),
952
+ ("退出码:", "dim"), (str(ev.get("ec", "?")), "bold"), ("\n", None),
953
+ (out_tail, "dim") if out_tail else ("(无输出)", "dim"),
954
+ )
955
+ self._print(Panel(body, title="[bold red]命令失败救援[/]",
956
+ title_align="left", border_style="red"))
957
+ if self._frontend is not None:
958
+ self._frontend.capture.enter_rescue()
959
+
960
+ def _on_rescue_decide(self, ev: dict) -> None:
961
+ """救援面板收回单行定格(web store rescue_decide 语义)。"""
962
+ self._flush_streams()
963
+ if ev.get("accept"):
964
+ self._print(Text("✓ 已交给 AI", style="bold green"))
965
+ else:
966
+ self._print(Text("已忽略"))
967
+
968
+ def _stream_write(self, data: str) -> None:
969
+ """流式直写(经 writer,不经 console);同步状态行光标状态。
970
+
971
+ raw 态终端 OPOST/ONLCR 已关(tty.setraw):模型文本里的 \n 须翻译
972
+ 成 \r\n,否则阶梯错位(与 _WriterFile 同法——先归一再翻译,
973
+ 对既有 "\r\n" 字面量幂等)。PTY 字节不走这里(内层 PTY 行纪律
974
+ 已做 ONLCR,不能再翻)。"""
975
+ b = data.replace("\r\n", "\n").replace("\n", "\r\n").encode("utf-8")
976
+ self._writer()(b)
977
+ if self.status is not None:
978
+ self.status.note_write(b)
979
+
980
+ def _print(self, renderable) -> None:
981
+ """console 输出统一入口:先收束进行中的流式段(思考盒/token 流),
982
+ 再擦状态行 → 写 → 重画(状态行 active 时)。
983
+
984
+ 收束前置是硬纪律:审批面板、密码提示、主机指纹询问都可能在思考流
985
+ 半开(残余半行未落)时到达——不先关盒,面板会嵌进框线中间、盒底和
986
+ `… 思考 N 行` 摘要落到面板之后。_flush_streams 幂等(已收束时零动作),
987
+ 各事件分支里既有的显式调用保留不受影响。状态行协议保证 rich 面板与
988
+ 状态行/PTY 字节不互相覆盖(Review Focus #3)。"""
989
+ self._flush_streams()
990
+ if self.status is not None:
991
+ self.status.erase()
992
+ if not self.status.at_fresh_line:
993
+ self._stream_write("\r\n") # 行中收起新行,防面板接半行
994
+ self._console.print(renderable)
995
+ if self.status is not None:
996
+ self.status.redraw()
997
+
998
+
999
+ def _default_writer(data: bytes) -> None:
1000
+ try:
1001
+ sys.stdout.buffer.write(data)
1002
+ sys.stdout.buffer.flush()
1003
+ except (BrokenPipeError, ValueError):
1004
+ pass # stdout 已关闭(管道消费端退出等):丢弃
1005
+
1006
+
1007
+ class TermFrontend:
1008
+ """raw stdin 泵 + 串行渲染 outbox + 生命周期。
1009
+
1010
+ reader 契约:``async () -> bytes``,返回 b"" = EOF 停泵;默认 POSIX
1011
+ 用 loop.add_reader(sys.stdin) + os.read,win32 用
1012
+ run_in_executor(_read_one_key)。
1013
+ writer 契约:``(bytes) -> None`` 同步写;默认 stdout.buffer 直写。
1014
+ """
1015
+
1016
+ def __init__(self, core: PipelineCore, *, reader=None, writer=None,
1017
+ console=None) -> None:
1018
+ self.core = core
1019
+ self._outbox: asyncio.Queue = asyncio.Queue()
1020
+ self._stop = asyncio.Event()
1021
+ self._capture = _CaptureLayer(self)
1022
+ self._writer = writer if writer is not None else _default_writer
1023
+ self._console = console if console is not None \
1024
+ else _default_console(self) # 生产 rich Console(经 writer 适配器)
1025
+ self._renderer = CliRenderer(core, self._console, frontend=self)
1026
+ self.status = self._renderer.status # _StatusLine(bytes 分支擦除协议)
1027
+ # stdin reader 账目必须在 _make_default_reader 之前清零:_posix_reader
1028
+ # 构造期就注册 add_reader 并写入 _stdin_fd/_stdin_cb(审查 B2:曾因
1029
+ # 后置初始化把账目抹掉,_remove_stdin_reader 永远 no-op)
1030
+ self._stdin_fd: int | None = None
1031
+ self._stdin_cb = None # add_reader 回调(编辑流摘挂/重挂用)
1032
+ self._reader = reader if reader is not None \
1033
+ else self._make_default_reader()
1034
+ self._winch_installed = False
1035
+ self._raw_state = None # termios 状态(Task 5 编辑流交接复用)
1036
+ self._pump_paused = False # 编辑流期间泵停读标志(交接顺序可观测)
1037
+ self._pending_approval_command = "" # 待决审批原命令(编辑预填)
1038
+ self._status_was_active = False # 编辑流前状态行是否在屏(复活用)
1039
+
1040
+ @property
1041
+ def capture(self) -> _CaptureLayer:
1042
+ """本地截获层:渲染器经它进入审批/rescue/认证待决态;输入泵经它
1043
+ 路由键盘字节(决策键消费、剩余字节直通)。"""
1044
+ return self._capture
1045
+
1046
+ # --- 默认 IO 原语 ---
1047
+
1048
+ def _make_default_reader(self):
1049
+ if sys.platform == "win32":
1050
+ return self._win_reader
1051
+ return self._posix_reader()
1052
+
1053
+ def _posix_reader(self):
1054
+ """POSIX 默认 reader:loop.add_reader(stdin) + os.read。
1055
+
1056
+ 构造期注册(TermFrontend 须在事件循环内构造——cli._run_pipeline
1057
+ 即 async 上下文);fd 就绪回调把字节塞队列,reader 协程按序取。
1058
+ EOF(os.read 返回 b"":管道关闭 / 重定向 stdin 结束)原样传 b""
1059
+ 给泵停摆。回调/队列挂 self:编辑流 _run_edit 需要摘挂同一注册。"""
1060
+ loop = asyncio.get_running_loop()
1061
+ queue: asyncio.Queue = asyncio.Queue()
1062
+ fileno = sys.stdin.fileno()
1063
+
1064
+ def _on_readable() -> None:
1065
+ try:
1066
+ data = os.read(fileno, 4096)
1067
+ except BlockingIOError:
1068
+ return
1069
+ except OSError:
1070
+ data = b""
1071
+ queue.put_nowait(data)
1072
+
1073
+ loop.add_reader(fileno, _on_readable)
1074
+ self._stdin_fd = fileno
1075
+ self._stdin_cb = _on_readable
1076
+
1077
+ async def _reader() -> bytes:
1078
+ return await queue.get()
1079
+
1080
+ return _reader
1081
+
1082
+ async def _win_reader(self) -> bytes:
1083
+ loop = asyncio.get_running_loop()
1084
+ ch = await loop.run_in_executor(None, _read_one_key)
1085
+ if not ch or ch == b"\x1a": # None / Ctrl+Z(win32 EOF)→ 停泵
1086
+ return b""
1087
+ if ch in (b"\x00", b"\xe0"):
1088
+ # 特殊键(方向键/功能键)前缀字节:续读第二字节原样透传,
1089
+ # 绝不能当 EOF(msvcrt 的 EOF 是 \x1a,前缀是 \x00/\xe0)
1090
+ ch2 = await loop.run_in_executor(None, _read_one_key)
1091
+ return ch + (ch2 or b"")
1092
+ return ch
1093
+
1094
+ # --- 生命周期 ---
1095
+
1096
+ async def run(self) -> None:
1097
+ # 非 tty(pytest / 管道喂入)跳过 termios——置 raw 会失败且无意义
1098
+ state = _set_raw_input() if sys.stdin.isatty() else None
1099
+ self._raw_state = state
1100
+ tasks: list[asyncio.Task] = []
1101
+ try:
1102
+ rows, cols = _term_size()
1103
+ # 初始 resize 先行入队:feed_msg 只入核心 _inbox,attach 前排队、
1104
+ # session 未就绪时 _dispatch 安全跳过(Review Focus #6)
1105
+ await self.core.feed_msg(
1106
+ ClientMsg(type="resize", rows=rows, cols=cols))
1107
+ self._install_winch()
1108
+ # CliCore 的 emit 不进核心 outbox;sender_loop 对 None sink 空转
1109
+ self.core.attach(None)
1110
+ render = asyncio.create_task(self._render_loop())
1111
+ pump = asyncio.create_task(self._input_pump())
1112
+ ticker = asyncio.create_task(self._tick_loop())
1113
+ tasks = [render, pump, ticker]
1114
+ waiter = asyncio.ensure_future(self._stop.wait())
1115
+ try:
1116
+ while not self._stop.is_set():
1117
+ done, _pending = await asyncio.wait(
1118
+ {waiter, render, pump},
1119
+ return_when=asyncio.FIRST_COMPLETED)
1120
+ if self._stop.is_set():
1121
+ break
1122
+ # 泵/渲染死亡而 _stop 未置 → 向外传播(finally 先还原
1123
+ # 终端再抛,Review Focus #1 异常路径)
1124
+ for t in done:
1125
+ if t is waiter or t.cancelled():
1126
+ continue
1127
+ exc = t.exception()
1128
+ if exc is not None:
1129
+ raise exc
1130
+ finally:
1131
+ waiter.cancel()
1132
+ finally:
1133
+ self._stop.set()
1134
+ for t in tasks:
1135
+ t.cancel()
1136
+ if tasks:
1137
+ await asyncio.gather(*tasks, return_exceptions=True)
1138
+ self._remove_winch()
1139
+ self._remove_stdin_reader()
1140
+ try:
1141
+ _restore_input(state)
1142
+ finally:
1143
+ # 收尾换行必须执行:即使 tcsetattr 抛(fd 失效等)也不能
1144
+ # 跳过,且不掩盖主异常
1145
+ try:
1146
+ self._writer(b"\r\n")
1147
+ except Exception: # noqa: BLE001 - 收尾换行失败不掩盖主异常
1148
+ pass
1149
+
1150
+ def _remove_stdin_reader(self) -> None:
1151
+ """注销默认 POSIX reader 的 add_reader(同一 loop 内多次连接不残留
1152
+ 旧 fd 监听;注入假 reader 时 _stdin_fd 为 None,no-op)。"""
1153
+ if self._stdin_fd is None:
1154
+ return
1155
+ fd, self._stdin_fd = self._stdin_fd, None
1156
+ self._stdin_cb = None
1157
+ try:
1158
+ asyncio.get_running_loop().remove_reader(fd)
1159
+ except RuntimeError:
1160
+ pass # loop 已关闭
1161
+
1162
+ def _suspend_stdin_reader(self) -> None:
1163
+ """编辑流前摘掉 stdin 的 loop 监听(不注销账目,_resume 可重挂)。
1164
+
1165
+ 不摘的后果(审查 B1 实证):① prompt_toolkit 在 executor 里读同一
1166
+ fd,add_reader 的 os.read 与之抢键——抢到的字节进 reader 队列,
1167
+ 编辑器拿不到,编辑结束后又被泵喂进 PTY;② 编辑期 termios 是
1168
+ cooked(_restore_input 有意交还),canonical 模式下 os.read 会阻塞
1169
+ 到整行——用户敲半行没回车,整个事件循环冻结在 read 系统调用里。"""
1170
+ if self._stdin_fd is None:
1171
+ return
1172
+ try:
1173
+ asyncio.get_running_loop().remove_reader(self._stdin_fd)
1174
+ except RuntimeError:
1175
+ pass # loop 已关闭
1176
+
1177
+ def _resume_stdin_reader(self) -> None:
1178
+ """编辑流后重挂 stdin 监听(与 _posix_reader 同一回调/账目)。"""
1179
+ if self._stdin_fd is None or self._stdin_cb is None:
1180
+ return
1181
+ try:
1182
+ asyncio.get_running_loop().add_reader(
1183
+ self._stdin_fd, self._stdin_cb)
1184
+ except RuntimeError:
1185
+ pass # loop 已关闭
1186
+
1187
+ # --- 渲染:与 PTY 字节共用同一串行 outbox(不撕裂)---
1188
+
1189
+ async def _render_loop(self) -> None:
1190
+ while True:
1191
+ kind, payload = await self._outbox.get()
1192
+ if kind == "bytes":
1193
+ # 擦除协议(Review Focus #3):写 PTY 字节前擦状态行;
1194
+ # 写完按尾字符同步光标状态(note_write)——无尾换行的
1195
+ # payload(进度条 \r 帧 / 半行回显)光标在行中,此刻
1196
+ # redraw 会原位覆盖 PTY 输出,绝不能重画(与流式 token
1197
+ # 路径同一笔账);有尾换行时 fresh 恢复,下个 tick 重画
1198
+ self.status.erase()
1199
+ self._writer(payload)
1200
+ self.status.note_write(payload)
1201
+ elif kind == "msg":
1202
+ await self._on_msg(payload)
1203
+ if payload.type == "closed":
1204
+ self._stop.set() # 先渲染后停
1205
+ return
1206
+ elif kind == "tick":
1207
+ # 状态行 0.25s 重画(仅 active 态写屏;与 PTY 字节串行)
1208
+ self.status.redraw()
1209
+
1210
+ async def _on_msg(self, msg: ServerMsg) -> None:
1211
+ """结构化消息钩子:委托 CliRenderer.render(事件→呈现映射)。"""
1212
+ await self._renderer.render(msg)
1213
+
1214
+ # --- 状态行 tick(0.25s)---
1215
+
1216
+ async def _tick_loop(self) -> None:
1217
+ """状态行重画节拍:仅 active 时经 _outbox 入队 ("tick", None),
1218
+ 由 _render_loop 串行消费重画——tick 与 PTY 字节/渲染共用同一队列,
1219
+ 不撕裂(Review Focus #3)。deactivate 后自动静默。"""
1220
+ try:
1221
+ while True:
1222
+ await asyncio.sleep(0.25)
1223
+ if self.status is not None and self.status.active:
1224
+ self._outbox.put_nowait(("tick", None))
1225
+ except asyncio.CancelledError:
1226
+ raise
1227
+
1228
+ # --- 输入泵 ---
1229
+
1230
+ async def _input_pump(self) -> None:
1231
+ try:
1232
+ while not self._stop.is_set():
1233
+ data = await self._reader()
1234
+ if not data: # b"" = EOF(管道关闭 / Ctrl+Z)
1235
+ self._stop.set()
1236
+ return
1237
+ kind, rest = self._capture.on_keys(data)
1238
+ while kind == "edit":
1239
+ # 截获层请求编辑流:内联 await 本协程内完成——泵在此
1240
+ # 期间自然停读,不会与 prompt_toolkit 并发抢 stdin
1241
+ # (纪律 #5);余量(e 键同 chunk 剩余)编辑后继续路由
1242
+ await self._run_edit()
1243
+ kind, rest = self._capture.on_keys(rest) if rest \
1244
+ else ("pass", b"")
1245
+ if kind == "pass" and rest:
1246
+ await self.core.feed_input(rest)
1247
+ except asyncio.CancelledError:
1248
+ raise
1249
+ except Exception as e: # noqa: BLE001 - 泵死亡记日志后向外传播:
1250
+ # run() 对 _stop/泵任务的等待会感知(finally 还原终端后抛出),
1251
+ # 不允许裸抛打死 run() 的清理路径。前缀 \r\n:raw 态无 ONLCR,
1252
+ # 直接打 stderr 会阶梯错位
1253
+ print(f"\r\n[ot] 输入泵异常:{type(e).__name__}: {e}",
1254
+ file=sys.stderr, flush=True)
1255
+ raise
1256
+
1257
+ # --- 审批编辑流(截获层 e 键)---
1258
+
1259
+ async def _run_edit(self) -> None:
1260
+ """审批编辑流。termios 交接顺序(Review Focus #5,测试钉死):
1261
+
1262
+ 暂停泵 → _restore_input → prompt_toolkit 多行输入 → _set_raw_input
1263
+ → 恢复泵 → feed_msg。
1264
+
1265
+ 泵暂停 = _input_pump 内联 await 本协程(单泵串行,无并发抢 stdin);
1266
+ 异常/EOF/取消路径经 finally 同样恢复泵并重新置 raw,编辑结果为空
1267
+ 时回待决态不发决策。结果过 approval.reclassify_edited:deny →
1268
+ reject(message="编辑后命令被策略拒绝")+ 红行;approve 级 →
1269
+ approve;其余 → edit_decision(new)。
1270
+ """
1271
+ cap = self._capture
1272
+ original = cap.pending_command
1273
+ self._pump_paused = True
1274
+ self._status_was_active = self.status.active if self.status is not None \
1275
+ else False
1276
+ new: str | None = None
1277
+ try:
1278
+ try:
1279
+ # 摘 stdin 监听必须先于 _restore_input:cooked 模式下
1280
+ # add_reader 的 os.read 会阻塞冻结整 loop(审查 B1)
1281
+ self._suspend_stdin_reader()
1282
+ _restore_input(self._raw_state)
1283
+ self._raw_state = None
1284
+ if self.status is not None:
1285
+ self.status.deactivate() # 提示行前清屏(prompt 接管显示)
1286
+ new = await self._prompt_edit(original)
1287
+ except (EOFError, KeyboardInterrupt, Exception): # noqa: BLE001
1288
+ new = None # EOF/取消/异常 → 回待决态不发决策
1289
+ finally:
1290
+ # 重新置 raw(仅 tty;非 tty 生产路径即 pytest/管道,保持 None)
1291
+ self._raw_state = _set_raw_input() if sys.stdin.isatty() else None
1292
+ self._resume_stdin_reader() # 先重挂监听再放泵(泵随后 await reader)
1293
+ self._pump_paused = False # 恢复泵(feed_msg 之前,顺序钉死)
1294
+ if new is None or not new.strip():
1295
+ cap.cancel_edit()
1296
+ self._status_reactivate() # 任务还在跑:状态行复活
1297
+ return
1298
+ d = reclassify_edited(new, self.core.policy)
1299
+ if d.level == "deny":
1300
+ self._capture_notice("✗ 编辑后的命令被策略拒绝")
1301
+ cap.send_reject("编辑后命令被策略拒绝") # 任务将终止:状态行不复活
1302
+ elif d.level == "approve":
1303
+ cap.send_approve()
1304
+ self._status_reactivate()
1305
+ else:
1306
+ cap.send_edit(new)
1307
+ self._status_reactivate()
1308
+
1309
+ def _status_reactivate(self) -> None:
1310
+ """编辑流结束后复活状态行(任务继续跑的路径):reactivate 不清
1311
+ 计时/计数(activate 会归零,审查 minor 4)。任务终止路径不调用。"""
1312
+ if self.status is not None and self._status_was_active:
1313
+ self.status.reactivate()
1314
+
1315
+ async def _prompt_edit(self, original: str) -> str:
1316
+ """prompt_toolkit 多行编辑(termios 已交还 cooked)。风格对齐
1317
+ cli._make_prompt_session/_prompt_text(executor 内跑 session.prompt),
1318
+ 但不 import cli——Task 6/7 会动 cli。Esc+Enter 提交(multiline
1319
+ 惯例),Ctrl+D/Ctrl+C 取消(上层回待决态)。"""
1320
+ from prompt_toolkit import PromptSession
1321
+ session = PromptSession(multiline=True)
1322
+ loop = asyncio.get_running_loop()
1323
+ return await loop.run_in_executor(
1324
+ None, lambda: session.prompt("编辑命令(Esc+Enter 提交 · "
1325
+ "Ctrl+D 取消):\n",
1326
+ default=original))
1327
+
1328
+ def _capture_notice(self, text: str) -> None:
1329
+ """截获层本地提示行(confirming 二次确认 / 编辑拒绝红字):走渲染器
1330
+ _print 协议(擦状态行→写→重画),不经 PTY。"""
1331
+ self._renderer._print(Text(text, style="bold red"))
1332
+
1333
+ def _receipt_decide(self, decision: dict) -> None:
1334
+ """决策回执本地合成(live 套件 D1):web store 在前端合成 decide
1335
+ 呈现,CLI 等价物——审批框原位收回执行/拒绝单行;呈现与防御性
1336
+ 承接共用 renderer._on_decide(spec §3.2 decide 行)。"""
1337
+ if self._renderer is not None:
1338
+ self._renderer._on_decide({"decision": decision})
1339
+
1340
+ def _receipt_rescue_decide(self, accept: bool) -> None:
1341
+ """救援决策回执本地合成(live 套件 D2):✓ 已交给 AI / 已忽略
1342
+ 单行定格,与 web store rescue_decide 同语义。"""
1343
+ if self._renderer is not None:
1344
+ self._renderer._on_rescue_decide({"accept": accept})
1345
+
1346
+ # --- SIGWINCH ---
1347
+
1348
+ def _install_winch(self) -> None:
1349
+ # win32 无 SIGWINCH:跳过(尺寸变化不感知,防御性降级)
1350
+ if sys.platform == "win32" or not hasattr(signal, "SIGWINCH"):
1351
+ return
1352
+ loop = asyncio.get_running_loop()
1353
+ try:
1354
+ loop.add_signal_handler(signal.SIGWINCH, self._on_winch)
1355
+ self._winch_installed = True
1356
+ except (NotImplementedError, ValueError, RuntimeError):
1357
+ pass # 平台不支持 / 非主线程:无 SIGWINCH 也能跑
1358
+
1359
+ def _remove_winch(self) -> None:
1360
+ if not self._winch_installed:
1361
+ return
1362
+ self._winch_installed = False
1363
+ try:
1364
+ asyncio.get_running_loop().remove_signal_handler(signal.SIGWINCH)
1365
+ except (NotImplementedError, RuntimeError, ValueError):
1366
+ pass
1367
+
1368
+ def _on_winch(self) -> None:
1369
+ # 信号上下文不做 await:create_task 包装排队进核心
1370
+ rows, cols = _term_size()
1371
+ if self._console is not None:
1372
+ # _WriterFile 非 tty,rich 无法自探宽度:winch 时同步刷新,
1373
+ # 否则 resize 后 Panel/总结框仍按构造期宽度渲染
1374
+ try:
1375
+ self._console.width = cols
1376
+ except Exception: # noqa: BLE001 - 假 console 不可写宽度时忽略
1377
+ pass
1378
+ asyncio.create_task(self.core.feed_msg(
1379
+ ClientMsg(type="resize", rows=rows, cols=cols)))
1380
+
1381
+
1382
+ # --- raw 原语(原复制自 rawmode.py;旧双管线已于 Task 7 删除)---
1383
+
1384
+ def _set_raw_input():
1385
+ if sys.platform == "win32":
1386
+ return None
1387
+ import termios
1388
+ import tty
1389
+ fd = sys.stdin.fileno()
1390
+ old = termios.tcgetattr(fd)
1391
+ tty.setraw(fd)
1392
+ return fd, old
1393
+
1394
+
1395
+ def _restore_input(state) -> None:
1396
+ if state is None:
1397
+ return
1398
+ import termios
1399
+ fd, old = state
1400
+ termios.tcsetattr(fd, termios.TCSADRAIN, old)
1401
+
1402
+
1403
+ def _read_one_key() -> bytes | None:
1404
+ if sys.platform == "win32":
1405
+ import msvcrt
1406
+ return msvcrt.getwch().encode("utf-8", errors="replace")
1407
+ import os
1408
+ return os.read(sys.stdin.fileno(), 1)
1409
+
1410
+
1411
+ def _term_size() -> tuple[int, int]:
1412
+ import shutil
1413
+ try:
1414
+ sz = shutil.get_terminal_size()
1415
+ return sz.lines, sz.columns
1416
+ except Exception:
1417
+ return 40, 120