cc-session-control 0.4.0__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.
@@ -0,0 +1,374 @@
1
+ """RC view — the 远程控制 tab.
2
+
3
+ Shows three things for one machine-wide Remote Control surface:
4
+ 1. managed projects (RCProject) with the tri-state `remoteControlAtStartup`
5
+ and `remoteControlSpawnMode`, plus the existing start/stop/autostart keys;
6
+ 2. project RC servers (RCServer) discovered via tmux ∪ /proc, badged
7
+ managed/external — external servers are READ-ONLY (no takeover/restart key);
8
+ 3. the bridge-environment ledger (current vs orphan). Orphans are labelled
9
+ "云端需手动删除": csctl has NO local deregister — deletion is manual on
10
+ claude.ai/code (capability red line / AC9).
11
+
12
+ Only project rows are actionable. Server and environment rows are display-only,
13
+ so no key toggles RC on a running session, takes over an external server, or
14
+ deregisters a cloud environment.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from typing import TYPE_CHECKING
20
+
21
+ import urwid
22
+
23
+ from ..data import environments, rc
24
+ from ..data.rc import set_rc_at_startup
25
+ from ..models import BridgeEnv, RCProject, RCServer
26
+
27
+ if TYPE_CHECKING:
28
+ from ..data.snapshot import WorldSnapshot
29
+
30
+ from ..app import App
31
+
32
+ _STATUS_MAP = {"running": "● 运行中", "dead": "✖ 已退出", "stopped": "○ 已停止"}
33
+ _RC_TRISTATE = {True: "开", False: "关", None: "未设置"}
34
+ # `c` cycles the per-project remoteControlAtStartup tri-state in full so the user
35
+ # can return to an explicit True (the old 2-cycle could never set True again).
36
+ _NEXT_TRISTATE = {None: True, True: False, False: None}
37
+
38
+ # Literal required in the UI by AC9 — the manual-delete red line.
39
+ _MANUAL_DELETE = "云端需手动删除"
40
+
41
+ # Red-line #5 honesty: the orphan ledger can only see envs minted while csctl was
42
+ # running, and csctl can never deregister a cloud env. Surfaced in the env-ledger
43
+ # section AND the help so the incompleteness is never silently implied as complete.
44
+ _LEDGER_CAVEAT = (
45
+ " 注:孤儿清单不完整——csctl 未运行期间铸造的环境无法追踪;"
46
+ "且 csctl 不能注销云端环境,需在 claude.ai/code 手动删除。"
47
+ )
48
+
49
+
50
+ class RCRow(urwid.WidgetWrap):
51
+ def __init__(self, project: RCProject) -> None:
52
+ self.project = project
53
+ status_text = _STATUS_MAP.get(project.status, project.status)
54
+ auto = "✓" if project.auto_start else "✗"
55
+ rc_at = _RC_TRISTATE.get(project.rc_at_startup, "未设置")
56
+ spawn = project.spawn_mode or "—"
57
+ name = project.name if project.in_list or project.status == "running" else f"({project.name})"
58
+
59
+ cols = urwid.Columns([
60
+ (10, urwid.Text(status_text)),
61
+ (8, urwid.Text(auto, align="center")),
62
+ (8, urwid.Text(rc_at, align="center")),
63
+ (10, urwid.Text(spawn, wrap="clip")),
64
+ ("weight", 2, urwid.Text(name, wrap="clip")),
65
+ ("weight", 3, urwid.Text(project.directory, wrap="clip")),
66
+ ], min_width=6)
67
+
68
+ attr = "rc_running" if project.status == "running" else "rc_stopped"
69
+ mapped = urwid.AttrMap(cols, attr, focus_map={"rc_running": "selected", "rc_stopped": "selected", None: "selected"})
70
+ super().__init__(mapped)
71
+
72
+ def selectable(self) -> bool:
73
+ return True
74
+
75
+ def keypress(self, size: tuple, key: str) -> str | None:
76
+ return key
77
+
78
+
79
+ class _DividerRow(urwid.WidgetWrap):
80
+ """Non-selectable section separator (focus skips it)."""
81
+
82
+ def __init__(self, text: str) -> None:
83
+ super().__init__(urwid.AttrMap(urwid.Text(text), "col_header"))
84
+
85
+ def selectable(self) -> bool:
86
+ return False
87
+
88
+
89
+ class ServerRow(urwid.WidgetWrap):
90
+ """A project RC server (managed/external) — display only, never actionable."""
91
+
92
+ def __init__(self, server: RCServer) -> None:
93
+ self.server = server
94
+ status_text = _STATUS_MAP.get(server.status, server.status)
95
+ badge = "托管" if server.managed else "外部"
96
+ pid = str(server.pid) if server.pid else "-"
97
+ cols = urwid.Columns([
98
+ (10, urwid.Text(status_text)),
99
+ (8, urwid.Text(badge, align="center")),
100
+ (8, urwid.Text(pid, align="center")),
101
+ ("weight", 2, urwid.Text(server.name, wrap="clip")),
102
+ ("weight", 3, urwid.Text(server.cwd or "", wrap="clip")),
103
+ ], min_width=6)
104
+ attr = "rc_running" if server.status == "running" else "rc_stopped"
105
+ mapped = urwid.AttrMap(cols, attr, focus_map={"rc_running": "selected", "rc_stopped": "selected", None: "selected"})
106
+ super().__init__(mapped)
107
+
108
+ def selectable(self) -> bool:
109
+ # P4: display-only — focus SKIPS it (like _DividerRow) so the user never
110
+ # lands on a highlighted row whose keys are all silently inert.
111
+ return False
112
+
113
+ def keypress(self, size: tuple, key: str) -> str | None:
114
+ return key
115
+
116
+
117
+ class EnvRow(urwid.WidgetWrap):
118
+ """A bridge-environment ledger entry (current/orphan) — display only."""
119
+
120
+ def __init__(self, env: BridgeEnv) -> None:
121
+ self.env = env
122
+ if env.status == "current":
123
+ mark = "● 绑定中"
124
+ hint = ""
125
+ else:
126
+ mark = "○ 孤儿"
127
+ hint = _MANUAL_DELETE
128
+ cols = urwid.Columns([
129
+ (10, urwid.Text(mark)),
130
+ ("weight", 2, urwid.Text(env.env_id, wrap="clip")),
131
+ ("weight", 2, urwid.Text(env.bound_sid or "-", wrap="clip")),
132
+ ("weight", 2, urwid.Text(hint, wrap="clip")),
133
+ ], min_width=6)
134
+ attr = "rc_running" if env.status == "current" else "rc_stopped"
135
+ mapped = urwid.AttrMap(cols, attr, focus_map={"rc_running": "selected", "rc_stopped": "selected", None: "selected"})
136
+ super().__init__(mapped)
137
+
138
+ def selectable(self) -> bool:
139
+ # P4: display-only ledger row — focus SKIPS it (csctl has no deregister).
140
+ return False
141
+
142
+ def keypress(self, size: tuple, key: str) -> str | None:
143
+ return key
144
+
145
+
146
+ class RCView:
147
+ def __init__(self, app: App) -> None:
148
+ self.app = app
149
+ self._projects: list[RCProject] = []
150
+ self._servers: list[RCServer] = []
151
+ self._current: list[BridgeEnv] = []
152
+ self._orphans: list[BridgeEnv] = []
153
+ self._pending: list[RCProject] | None = None
154
+ self._pending_servers: list[RCServer] | None = None
155
+ self._pending_current: list[BridgeEnv] | None = None
156
+ self._pending_orphans: list[BridgeEnv] | None = None
157
+ self._loaded = False
158
+ self._help = False
159
+
160
+ self.status = urwid.AttrMap(urwid.Text(" 扫描中…"), "status")
161
+ col_header = urwid.AttrMap(urwid.Columns([
162
+ (10, urwid.Text("状态")),
163
+ (8, urwid.Text("开机自启", align="center")),
164
+ (8, urwid.Text("自动远控", align="center")),
165
+ (10, urwid.Text("启动模式")),
166
+ ("weight", 2, urwid.Text("项目")),
167
+ ("weight", 3, urwid.Text("目录")),
168
+ ], min_width=6), "col_header")
169
+ self.walker = urwid.SimpleFocusListWalker([])
170
+ self.listbox = urwid.ListBox(self.walker)
171
+ body = urwid.AttrMap(self.listbox, {None: "body"})
172
+ self.widget = urwid.Frame(body, header=col_header, footer=self.status)
173
+
174
+ def keyhints(self) -> str:
175
+ if self._help:
176
+ return "按任意键返回"
177
+ return "Enter 启动 · s 停止 · a 开机自启 · c 自动远控 · A/S 批量 · ? 帮助"
178
+
179
+ def load(self) -> None:
180
+ self._projects = rc.scan()
181
+ self._servers, self._current, self._orphans = self._scan_extras()
182
+ self._loaded = True
183
+ self._rebuild()
184
+
185
+ def _scan_extras(self) -> tuple[list[RCServer], list[BridgeEnv], list[BridgeEnv]]:
186
+ """Self-fetch the servers + environment ledger (no-snapshot path).
187
+
188
+ CURRENT uses the alive-gated `observe_live` so a zombie session's stale
189
+ bridge is not shown as bound (R3/R6); ORPHAN uses the bridge-truthy,
190
+ FILE-REFERENCED `observe` so it is precisely `ledger − file-referenced`
191
+ (an env still referenced by a file is NOT an orphan, even if its owner is
192
+ currently dead).
193
+ """
194
+ servers = rc.scan_servers()
195
+ observed = environments.observe_live(rc_servers=servers)
196
+ file_referenced = environments.observe(rc_servers=servers)
197
+ return (
198
+ servers,
199
+ environments.current_envs(observed),
200
+ environments.orphan_envs(file_referenced),
201
+ )
202
+
203
+ def fetch_pending(self, snapshot: WorldSnapshot | None = None) -> None:
204
+ """Worker-thread data fetch. Only sets pending fields — no widgets."""
205
+ if snapshot is not None:
206
+ self.set_pending(snapshot.rc_projects)
207
+ self._pending_servers = snapshot.rc_servers
208
+ self._pending_current = environments.current_envs(snapshot.observed_envs)
209
+ self._pending_orphans = environments.orphan_envs(snapshot.file_referenced_envs)
210
+ else:
211
+ self.set_pending(rc.scan())
212
+ servers, current, orphans = self._scan_extras()
213
+ self._pending_servers = servers
214
+ self._pending_current = current
215
+ self._pending_orphans = orphans
216
+
217
+ def set_pending(self, projects: list[RCProject]) -> None:
218
+ self._pending = projects
219
+
220
+ def apply_data(self) -> None:
221
+ if self._pending is not None:
222
+ self._projects = self._pending
223
+ self._pending = None
224
+ self._loaded = True
225
+ if self._pending_servers is not None:
226
+ self._servers = self._pending_servers
227
+ self._pending_servers = None
228
+ if self._pending_current is not None:
229
+ self._current = self._pending_current
230
+ self._pending_current = None
231
+ if self._pending_orphans is not None:
232
+ self._orphans = self._pending_orphans
233
+ self._pending_orphans = None
234
+ if not self._help:
235
+ self._rebuild()
236
+
237
+ def _rebuild(self) -> None:
238
+ focus_pos = self.walker.get_focus()[1] if self.walker else 0
239
+ self.walker.clear()
240
+ # Projects first, so default focus lands on an actionable row.
241
+ for p in self._projects:
242
+ self.walker.append(RCRow(p))
243
+ if self._servers:
244
+ self.walker.append(_DividerRow("── RC 服务(仅展示 · 托管见项目行 · 外部不可接管)──"))
245
+ for s in self._servers:
246
+ self.walker.append(ServerRow(s))
247
+ if self._current or self._orphans:
248
+ self.walker.append(_DividerRow(f"── 环境台账({_MANUAL_DELETE})──"))
249
+ self.walker.append(_DividerRow(_LEDGER_CAVEAT))
250
+ for e in self._current:
251
+ self.walker.append(EnvRow(e))
252
+ for e in self._orphans:
253
+ self.walker.append(EnvRow(e))
254
+ if not self.walker:
255
+ self.walker.append(urwid.AttrMap(urwid.Text(" 暂无远控项目"), "dead"))
256
+ if self.walker and focus_pos is not None:
257
+ self.walker.set_focus(min(focus_pos, len(self.walker) - 1))
258
+
259
+ running = sum(1 for p in self._projects if p.status == "running")
260
+ auto = sum(1 for p in self._projects if p.auto_start)
261
+ rc_off = sum(1 for p in self._projects if p.rc_at_startup is False)
262
+ rc_text = f" · 自动远控关 {rc_off}" if rc_off else ""
263
+ srv_text = f" · 服务 {len(self._servers)}" if self._servers else ""
264
+ env_text = f" · 孤儿环境 {len(self._orphans)}" if self._orphans else ""
265
+ self.status.original_widget.set_text(
266
+ f" 共 {len(self._projects)} 项目 · 运行 {running} · 开机自启 {auto}"
267
+ f"{rc_text}{srv_text}{env_text}"
268
+ )
269
+
270
+ def _selected(self) -> RCProject | None:
271
+ if not self.walker:
272
+ return None
273
+ widget = self.walker.get_focus()[0]
274
+ if isinstance(widget, RCRow):
275
+ return widget.project
276
+ return None
277
+
278
+ def _update_footer(self) -> None:
279
+ if self.app.views[self.app._active] is not self:
280
+ return
281
+ self.app.set_hints(self.keyhints())
282
+
283
+ def handle_key(self, key: str) -> None:
284
+ if self._help:
285
+ self._help = False
286
+ self._rebuild()
287
+ self._update_footer()
288
+ return
289
+
290
+ p = self._selected()
291
+
292
+ if key == "enter" and p:
293
+ if not p.trusted:
294
+ self.app.notify("未信任 — 先在该目录跑一次 claude")
295
+ return
296
+ if p.status == "running":
297
+ self.app.notify("已在运行")
298
+ return
299
+ ok = rc.start_one(p.name)
300
+ self.app.notify(f"已启动 ws/{p.name}" if ok else "启动失败")
301
+ self.app.trigger_async_refresh()
302
+ elif key == "s" and p:
303
+ if p.status != "running":
304
+ self.app.notify("未在运行")
305
+ return
306
+ self.app.confirm(
307
+ f"停止远控服务「{p.name}」?将终止其进程。",
308
+ lambda: self._do_stop_one(p.name),
309
+ )
310
+ elif key == "a" and p:
311
+ new = rc.toggle_autostart(p.name)
312
+ self.app.notify(f"{p.name} 开机自启: {'开' if new else '关'}")
313
+ self.app.trigger_async_refresh()
314
+ elif key == "c" and p:
315
+ # Full 3-cycle so explicit True is reachable again: None→True→False→None.
316
+ new = _NEXT_TRISTATE[p.rc_at_startup]
317
+ set_rc_at_startup(p.directory, new)
318
+ self.app.notify(f"{p.name} 自动远控: {_RC_TRISTATE[new]}")
319
+ self.app.trigger_async_refresh()
320
+ elif key == "A":
321
+ count = rc.start_all_listed()
322
+ self.app.notify(f"已启动 {count} 个项目")
323
+ self.app.trigger_async_refresh()
324
+ elif key == "S":
325
+ if not any(p.status == "running" for p in self._projects):
326
+ self.app.notify("本来就没在跑")
327
+ return
328
+ self.app.confirm("停止全部远控服务?", self._do_stop_all)
329
+ elif key == "r":
330
+ self.app.trigger_async_refresh()
331
+ self.app.notify("刷新中…")
332
+ elif key == "?":
333
+ self._show_help()
334
+
335
+ def _do_stop_one(self, name: str) -> None:
336
+ """Stop-one body, run only after the y/n confirm accepts."""
337
+ ok = rc.stop_one(name)
338
+ self.app.notify(f"已停止 {name}" if ok else "未在运行")
339
+ self.app.trigger_async_refresh()
340
+
341
+ def _do_stop_all(self) -> None:
342
+ """Stop-all body, run only after the y/n confirm accepts."""
343
+ ok = rc.stop_all()
344
+ self.app.notify("已停止全部" if ok else "本来就没在跑")
345
+ self.app.trigger_async_refresh()
346
+
347
+ def _show_help(self) -> None:
348
+ self._help = True
349
+ lines = [
350
+ "远程控制操作(仅对「项目」行生效):",
351
+ " Enter 启动选中项目的远程控制服务",
352
+ " s 停止选中项目的远程控制服务(需确认)",
353
+ " a 切换「开机自启」:A 键一键启动时是否带上本项目",
354
+ " c 切换「自动远控」:claude 启动时自动开远程控制,手机即可接管",
355
+ "",
356
+ "批量操作:",
357
+ " A 启动所有「开机自启」项目",
358
+ " S 停止全部远程控制服务(需确认)",
359
+ " r 重新扫描刷新",
360
+ "",
361
+ "RC 服务 / 环境台账(只读):",
362
+ " 外部服务只展示,不接管、不重启。",
363
+ f" 孤儿环境无法本地注销:{_MANUAL_DELETE}(claude.ai/code)。",
364
+ " 孤儿清单不完整:csctl 未运行期间铸造的环境无法追踪。",
365
+ "",
366
+ "导航:",
367
+ " Tab 切换标签页",
368
+ " q 退出",
369
+ ]
370
+ self.walker.clear()
371
+ for line in lines:
372
+ self.walker.append(urwid.AttrMap(urwid.Text(line), "dead"))
373
+ self.status.original_widget.set_text(" 按任意键返回")
374
+ self._update_footer()