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,145 @@
1
+ """Row widgets + presentation helpers for the Sessions tab.
2
+
3
+ Split out of `views/sessions.py` so that file stays under the 600-line budget.
4
+ Holds the selectable `SessionRow` (with the D9 source badge + the 📱 remote-
5
+ control-exposure marker), the cleanup-submenu rows (`_ActionRow`, `_PreviewRow`),
6
+ and the column header constants. Rows never handle keys — `keypress` returns the key so the
7
+ view's single dispatcher sees it (see frontend/widget-patterns.md).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import time
13
+
14
+ import urwid
15
+
16
+ from ..models import Session
17
+
18
+ # Transcript-derived hidden tags -> compact Chinese row marker.
19
+ _HIDDEN_MARKERS = {
20
+ "bridge": "桥接",
21
+ "sdk": "SDK",
22
+ }
23
+
24
+ # Coarse registry `source` bucket -> short badge shown in the 来源 column.
25
+ _SOURCE_BADGES = {
26
+ "cli": "CLI",
27
+ "vscode": "IDE",
28
+ "sdk": "SDK",
29
+ "bg": "BG",
30
+ }
31
+
32
+
33
+ def _hidden_marker(session: Session) -> str:
34
+ """Compact `桥接 SDK` label from a session's transcript `hidden` tags."""
35
+ known = [label for key, label in _HIDDEN_MARKERS.items() if key in session.hidden]
36
+ unknown = sorted(key for key in session.hidden if key not in _HIDDEN_MARKERS)
37
+ return " ".join(known + unknown)
38
+
39
+
40
+ def _source_badge(session: Session) -> str:
41
+ """Short source badge (CLI / IDE / SDK / BG), or "" when unknown."""
42
+ return _SOURCE_BADGES.get(session.source, "")
43
+
44
+
45
+ def _flags(session: Session) -> str:
46
+ """Remote-control exposure marker for the 远控 column: 📱 when this session
47
+ exposes its own session-level remote control (phone / claude.ai/code can take
48
+ it over), else "". 📱 is Emoji_Presentation=Yes so its width is stable across
49
+ terminals (the old ⚙ agent glyph was the width-unstable one — text-default,
50
+ needs VS16 — and is the only thing P5 actually needed to drop). Agent-link is
51
+ deliberately NOT shown here: it is orthogonal to remote control and already
52
+ covered by the 来源 `BG` badge plus the dedicated 后台 tab."""
53
+ return "📱" if session.rc_exposed else ""
54
+
55
+
56
+ def _rel_time(mtime: float) -> str:
57
+ """Human relative time: 刚刚 / N 分钟前 / N 小时前 / N 天前; falls back to an
58
+ absolute %m-%d date past a week (and for a missing or future mtime)."""
59
+ if not mtime:
60
+ return "-"
61
+ delta = time.time() - mtime
62
+ if delta < 0:
63
+ return time.strftime("%m-%d %H:%M", time.localtime(mtime))
64
+ if delta < 60:
65
+ return "刚刚"
66
+ if delta < 3600:
67
+ return f"{int(delta // 60)} 分钟前"
68
+ if delta < 86400:
69
+ return f"{int(delta // 3600)} 小时前"
70
+ if delta < 7 * 86400:
71
+ return f"{int(delta // 86400)} 天前"
72
+ return time.strftime("%m-%d %H:%M", time.localtime(mtime))
73
+
74
+
75
+ class SessionRow(urwid.WidgetWrap):
76
+ def __init__(self, session: Session) -> None:
77
+ self.session = session
78
+ mark = "●" if session.alive else "○"
79
+ cur = "▸" if session.current else " "
80
+ when = _rel_time(session.mtime)
81
+ hidden = _hidden_marker(session)
82
+ label = f"[{hidden}] {session.label}" if hidden else session.label
83
+ if len(label) > 80:
84
+ label = label[:79] + "…"
85
+ cwd = session.cwd.rstrip("/").rsplit("/", 1)[-1] if session.cwd else ""
86
+
87
+ cols = urwid.Columns([
88
+ (3, urwid.Text(f"{cur}{mark}")),
89
+ (5, urwid.Text(_source_badge(session))),
90
+ (5, urwid.Text(_flags(session))),
91
+ (12, urwid.Text(when)),
92
+ (5, urwid.Text(f"p{session.prompts}")),
93
+ ("weight", 3, urwid.Text(label, wrap="clip")),
94
+ ("weight", 1, urwid.Text(cwd, wrap="clip")),
95
+ ], min_width=6)
96
+
97
+ attr = "alive" if session.alive else "dead"
98
+ mapped = urwid.AttrMap(cols, attr, focus_map={"alive": "selected", "dead": "selected", None: "selected"})
99
+ super().__init__(mapped)
100
+
101
+ def selectable(self) -> bool:
102
+ return True
103
+
104
+ def keypress(self, size: tuple, key: str) -> str | None:
105
+ return key
106
+
107
+
108
+ class _ActionRow(urwid.WidgetWrap):
109
+ def __init__(self, action_key: str, label: str, count: int) -> None:
110
+ self.action_key = action_key
111
+ cols = urwid.Columns([
112
+ ("weight", 1, urwid.Text(label)),
113
+ (8, urwid.Text(str(count), align="right")),
114
+ ])
115
+ mapped = urwid.AttrMap(cols, "dead", focus_map={"dead": "selected", None: "selected"})
116
+ super().__init__(mapped)
117
+
118
+ def selectable(self) -> bool:
119
+ return True
120
+
121
+ def keypress(self, size: tuple, key: str) -> str | None:
122
+ return key
123
+
124
+
125
+ class _PreviewRow(urwid.WidgetWrap):
126
+ def __init__(self, text: str) -> None:
127
+ mapped = urwid.AttrMap(urwid.Text(text), "dead", focus_map={"dead": "selected", None: "selected"})
128
+ super().__init__(mapped)
129
+
130
+ def selectable(self) -> bool:
131
+ return True
132
+
133
+ def keypress(self, size: tuple, key: str) -> str | None:
134
+ return key
135
+
136
+
137
+ _SESSION_HEADER = urwid.Columns([
138
+ (3, urwid.Text("")),
139
+ (5, urwid.Text("来源")),
140
+ (5, urwid.Text("远控")),
141
+ (12, urwid.Text("时间")),
142
+ (5, urwid.Text("提问")),
143
+ ("weight", 3, urwid.Text("标题")),
144
+ ("weight", 1, urwid.Text("项目")),
145
+ ], min_width=6)
@@ -0,0 +1,293 @@
1
+ """Agents view — the 后台 (background agents) tab.
2
+
3
+ Lists `jobs/<short>/state.json` records (registry.AgentJob, enriched with host
4
+ liveness) and wires their lifecycle to `actions/agent_ops`: respawn, takeover
5
+ (via the existing resume path), read-only watch, settled-only remove, and
6
+ live-only stop. Satisfies the TabView Protocol structurally so `app.py` drives
7
+ it generically. All user-facing strings are Simplified Chinese; the orphan-risk
8
+ warning surfaced on `stop` is a capability red line (R4.5 / AC4).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import replace
14
+ from typing import TYPE_CHECKING
15
+
16
+ import urwid
17
+
18
+ from ..actions import agent_ops
19
+ from ..actions.session_ops import would_take_over
20
+ from ..data import proc, registry
21
+ from ..models import AgentJob
22
+
23
+ if TYPE_CHECKING:
24
+ from ..data.snapshot import WorldSnapshot
25
+
26
+ from ..app import App
27
+
28
+
29
+ # R10/D7: refusal shown when "current" can't be determined (no /proc) — the
30
+ # destructive ops are gated honestly BEFORE any confirm (mirrors SessionsView).
31
+ _DEGRADED = "liveness 降级:破坏性操作已禁用"
32
+
33
+
34
+ _AGENTS_HEADER = urwid.Columns([
35
+ (4, urwid.Text("")),
36
+ ("weight", 2, urwid.Text("名称")),
37
+ (8, urwid.Text("状态")),
38
+ (8, urwid.Text("节奏")),
39
+ ("weight", 2, urwid.Text("目录")),
40
+ ("weight", 2, urwid.Text("环境后缀")),
41
+ ], min_width=4)
42
+
43
+
44
+ class AgentRow(urwid.WidgetWrap):
45
+ def __init__(self, job: AgentJob) -> None:
46
+ self.job = job
47
+ mark = "●" if job.host_alive else "○"
48
+ cwd = job.cwd.rstrip("/").rsplit("/", 1)[-1] if job.cwd else ""
49
+ cols = urwid.Columns([
50
+ (4, urwid.Text(mark, align="center")),
51
+ ("weight", 2, urwid.Text(job.name or job.short, wrap="clip")),
52
+ (8, urwid.Text(job.state or "-", wrap="clip")),
53
+ (8, urwid.Text(job.tempo or "-", wrap="clip")),
54
+ ("weight", 2, urwid.Text(cwd, wrap="clip")),
55
+ ("weight", 2, urwid.Text(job.env_suffix or "-", wrap="clip")),
56
+ ], min_width=4)
57
+ attr = "alive" if job.host_alive else "dead"
58
+ mapped = urwid.AttrMap(cols, attr, focus_map={"alive": "selected", "dead": "selected", None: "selected"})
59
+ super().__init__(mapped)
60
+
61
+ def selectable(self) -> bool:
62
+ return True
63
+
64
+ def keypress(self, size: tuple, key: str) -> str | None:
65
+ return key
66
+
67
+
68
+ class _TextRow(urwid.WidgetWrap):
69
+ """Read-only line used in the watch overlay."""
70
+
71
+ def __init__(self, text: str) -> None:
72
+ mapped = urwid.AttrMap(urwid.Text(text), "dead", focus_map={"dead": "selected", None: "selected"})
73
+ super().__init__(mapped)
74
+
75
+ def selectable(self) -> bool:
76
+ return True
77
+
78
+ def keypress(self, size: tuple, key: str) -> str | None:
79
+ return key
80
+
81
+
82
+ class AgentsView:
83
+ # mode: "list" | "help" | "watch"
84
+ def __init__(self, app: App) -> None:
85
+ self.app = app
86
+ self._jobs: list[AgentJob] = []
87
+ self._pending: list[AgentJob] | None = None
88
+ self._loaded = False
89
+ self._mode = "list"
90
+
91
+ self.status = urwid.AttrMap(urwid.Text(" 扫描中…"), "status")
92
+ col_header = urwid.AttrMap(_AGENTS_HEADER, "col_header")
93
+ self.walker = urwid.SimpleFocusListWalker([])
94
+ self.listbox = urwid.ListBox(self.walker)
95
+ self._list_body = urwid.AttrMap(self.listbox, {None: "body"})
96
+ self._body = urwid.WidgetPlaceholder(self._list_body)
97
+ self.widget = urwid.Frame(self._body, header=col_header, footer=self.status)
98
+
99
+ # --- TabView contract ---
100
+
101
+ def keyhints(self) -> str:
102
+ if self._mode in ("help", "watch"):
103
+ return "按任意键返回"
104
+ return f"{agent_ops.KEYHINTS} · ? 帮助"
105
+
106
+ def _enrich(self, jobs: list[AgentJob]) -> list[AgentJob]:
107
+ """Fill host liveness for the self-fetch path (snapshot already enriched).
108
+
109
+ Returns fresh copies via `dataclasses.replace` (like `snapshot._enrich_jobs`)
110
+ so the registry's ~5s-TTL cached AgentJob objects are never mutated.
111
+ """
112
+ out: list[AgentJob] = []
113
+ for job in jobs:
114
+ pid, alive = agent_ops.job_host(job)
115
+ out.append(replace(job, host_pid=pid, host_alive=alive))
116
+ return out
117
+
118
+ def load(self) -> None:
119
+ self._jobs = self._enrich(registry.read_agent_jobs())
120
+ self._loaded = True
121
+ self._rebuild()
122
+
123
+ def fetch_pending(self, snapshot: WorldSnapshot | None = None) -> None:
124
+ """Worker-thread data fetch. Only sets pending fields — no widgets."""
125
+ if snapshot is not None:
126
+ self._pending = snapshot.agent_jobs
127
+ else:
128
+ self._pending = self._enrich(registry.read_agent_jobs())
129
+
130
+ def apply_data(self) -> None:
131
+ if self._pending is not None:
132
+ self._jobs = self._pending
133
+ self._pending = None
134
+ self._loaded = True
135
+ if self._mode == "list":
136
+ self._rebuild()
137
+
138
+ # --- rendering ---
139
+
140
+ def _rebuild(self) -> None:
141
+ focus_pos = self.walker.get_focus()[1] if self.walker else 0
142
+ self.walker.clear()
143
+ for job in self._jobs:
144
+ self.walker.append(AgentRow(job))
145
+ if not self._jobs:
146
+ self.walker.append(urwid.AttrMap(urwid.Text(" 暂无后台 agent"), "dead"))
147
+ if self.walker and focus_pos is not None:
148
+ self.walker.set_focus(min(focus_pos, len(self.walker) - 1))
149
+ alive_n = sum(1 for j in self._jobs if j.host_alive)
150
+ self.status.original_widget.set_text(
151
+ f" 共 {len(self._jobs)} 个后台 agent · 运行 {alive_n}"
152
+ )
153
+
154
+ def _selected(self) -> AgentJob | None:
155
+ if not self.walker:
156
+ return None
157
+ widget = self.walker.get_focus()[0]
158
+ if isinstance(widget, AgentRow):
159
+ return widget.job
160
+ return None
161
+
162
+ def _update_footer(self) -> None:
163
+ if self.app.views[self.app._active] is not self:
164
+ return
165
+ self.app.set_hints(self.keyhints())
166
+
167
+ def _show_overlay(self, title: str, rows: list, height: int | None = None) -> None:
168
+ walker = urwid.SimpleFocusListWalker(rows)
169
+ listbox = urwid.ListBox(walker)
170
+ header = urwid.AttrMap(urwid.Text(f" {title}", align="center"), "col_header")
171
+ box = urwid.LineBox(urwid.Frame(listbox, header=header))
172
+ h = height or min(len(rows) + 4, 30)
173
+ overlay = urwid.Overlay(
174
+ box, self._list_body,
175
+ align="center", width=("relative", 80),
176
+ valign="middle", height=h,
177
+ )
178
+ self._body.original_widget = overlay
179
+
180
+ def _exit_overlay(self) -> None:
181
+ self._mode = "list"
182
+ self._body.original_widget = self._list_body
183
+ self._rebuild()
184
+ self._update_footer()
185
+
186
+ # --- key dispatch ---
187
+
188
+ def handle_key(self, key: str) -> None:
189
+ if self._mode in ("help", "watch"):
190
+ self._exit_overlay()
191
+ return
192
+
193
+ job = self._selected()
194
+
195
+ if key == "r":
196
+ # Unified verb table: `r` is refresh on EVERY tab (respawn moved to R).
197
+ self.app.trigger_async_refresh()
198
+ self.app.notify("刷新中…")
199
+ elif key == "R" and job:
200
+ cmd = agent_ops.respawn(job)
201
+ self.app.notify(f"已重启:{cmd}")
202
+ self.app.trigger_async_refresh()
203
+ elif key in ("enter", "o") and job:
204
+ self._takeover(job)
205
+ elif key == "w" and job:
206
+ self._watch(job)
207
+ elif key == "d" and job:
208
+ self._remove(job)
209
+ elif key == "s" and job:
210
+ self._stop(job)
211
+ elif key == "?":
212
+ self._show_help()
213
+
214
+ def _takeover(self, job: AgentJob) -> None:
215
+ s = agent_ops.resume_takeover(job)
216
+ if s.current:
217
+ self.app.notify("不能接回当前会话")
218
+ return
219
+ # B1: takeover of a RUNNING worker kills its host pid (should_kill) — same
220
+ # as Sessions Enter-live. Confirm first; a dead worker resumes directly.
221
+ if would_take_over(s):
222
+ self.app.confirm(
223
+ f"接回后台 agent「{(job.name or job.short)[:30]}」?将先终止原进程。",
224
+ lambda: self.app.exit_with_resume(s, fork=False),
225
+ )
226
+ else:
227
+ self.app.exit_with_resume(s, fork=False)
228
+
229
+ def _watch(self, job: AgentJob) -> None:
230
+ path = agent_ops.watch(job)
231
+ if not path:
232
+ self.app.notify("无 timeline 可查看")
233
+ return
234
+ lines: list[str] = []
235
+ try:
236
+ with open(path, errors="ignore") as fh:
237
+ lines = fh.read().splitlines()[-200:]
238
+ except Exception:
239
+ self.app.notify("读取 timeline 失败")
240
+ return
241
+ rows = [_TextRow(line) for line in lines] or [_TextRow("(空)")]
242
+ self._mode = "watch"
243
+ self._show_overlay(f"timeline(只读)· {job.name or job.short}", rows)
244
+ self._update_footer()
245
+
246
+ def _remove(self, job: AgentJob) -> None:
247
+ if job.host_alive:
248
+ self.app.notify("运行中的后台 agent 不能删除,先停止")
249
+ return
250
+ if not proc.current_determinable():
251
+ self.app.notify(_DEGRADED)
252
+ return
253
+ ok = agent_ops.remove_job(job)
254
+ self.app.notify("已删除" if ok else "删除失败")
255
+ self.app.trigger_async_refresh()
256
+
257
+ def _stop(self, job: AgentJob) -> None:
258
+ # Degrade gate FIRST (R2): off /proc we can't prove this isn't csctl's own
259
+ # session — refuse honestly before confirming.
260
+ if not proc.current_determinable():
261
+ self.app.notify(_DEGRADED)
262
+ return
263
+ if not job.host_alive:
264
+ self.app.notify("后台 agent 未在运行")
265
+ return
266
+ self.app.confirm(
267
+ f"停止后台 agent「{(job.name or job.short)[:30]}」?将终止其进程。",
268
+ lambda: self._do_stop(job),
269
+ )
270
+
271
+ def _do_stop(self, job: AgentJob) -> None:
272
+ """Stop body, run only after the y/n confirm accepts.
273
+
274
+ Reached only when "current" is determinable, so a False from `stop_job`
275
+ means no joined host pid (an unstoppable orphan) — surfaced honestly,
276
+ separate from the degrade refusal above (R2 split).
277
+ """
278
+ ok = agent_ops.stop_job(job)
279
+ if ok:
280
+ self.app.notify("已发送停止信号(可能残留孤儿进程,请手动确认)")
281
+ else:
282
+ self.app.notify("找不到该后台 agent 的进程,无法停止")
283
+ self.app.trigger_async_refresh()
284
+
285
+ def _show_help(self) -> None:
286
+ rows = [_TextRow(line) for line in agent_ops.HELP.splitlines()]
287
+ rows += [
288
+ _TextRow(""),
289
+ _TextRow("导航:Tab 切换标签 · q 退出"),
290
+ ]
291
+ self._mode = "help"
292
+ self._show_overlay("后台 agent 帮助", rows)
293
+ self._update_footer()