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.
- cc_session_control/__init__.py +3 -0
- cc_session_control/__main__.py +5 -0
- cc_session_control/actions/__init__.py +0 -0
- cc_session_control/actions/agent_ops.py +201 -0
- cc_session_control/actions/session_ops.py +150 -0
- cc_session_control/app.py +264 -0
- cc_session_control/cli.py +288 -0
- cc_session_control/clipboard.py +44 -0
- cc_session_control/config.py +132 -0
- cc_session_control/data/__init__.py +0 -0
- cc_session_control/data/agents.py +12 -0
- cc_session_control/data/cleanup.py +402 -0
- cc_session_control/data/environments.py +444 -0
- cc_session_control/data/liveness.py +140 -0
- cc_session_control/data/proc.py +214 -0
- cc_session_control/data/rc.py +411 -0
- cc_session_control/data/registry.py +155 -0
- cc_session_control/data/sessions.py +188 -0
- cc_session_control/data/snapshot.py +115 -0
- cc_session_control/models.py +170 -0
- cc_session_control/views/__init__.py +0 -0
- cc_session_control/views/_session_row.py +145 -0
- cc_session_control/views/agents.py +293 -0
- cc_session_control/views/rc.py +374 -0
- cc_session_control/views/sessions.py +595 -0
- cc_session_control-0.4.0.dist-info/METADATA +115 -0
- cc_session_control-0.4.0.dist-info/RECORD +31 -0
- cc_session_control-0.4.0.dist-info/WHEEL +5 -0
- cc_session_control-0.4.0.dist-info/entry_points.txt +2 -0
- cc_session_control-0.4.0.dist-info/licenses/LICENSE +21 -0
- cc_session_control-0.4.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,595 @@
|
|
|
1
|
+
"""Sessions view — urwid ListBox with keyboard actions and cleanup submenu."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from dataclasses import replace
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
import urwid
|
|
10
|
+
|
|
11
|
+
from ..actions.session_ops import (
|
|
12
|
+
relaunch_in_tmux,
|
|
13
|
+
resume_cmd,
|
|
14
|
+
terminate_session,
|
|
15
|
+
to_clipboard,
|
|
16
|
+
would_take_over,
|
|
17
|
+
)
|
|
18
|
+
from ..data import liveness, proc, registry
|
|
19
|
+
from ..data.cleanup import (
|
|
20
|
+
cleanup_classified,
|
|
21
|
+
list_aged_entries,
|
|
22
|
+
list_orphan_dirs,
|
|
23
|
+
prune_sessions,
|
|
24
|
+
remove_aged_entries,
|
|
25
|
+
remove_orphan_dirs,
|
|
26
|
+
remove_session,
|
|
27
|
+
remove_zombie_session_files,
|
|
28
|
+
select_zombie_pids,
|
|
29
|
+
)
|
|
30
|
+
from ..data.sessions import scan
|
|
31
|
+
from ..models import AgentJob, Session, SessionProc
|
|
32
|
+
from ._session_row import (
|
|
33
|
+
_SESSION_HEADER,
|
|
34
|
+
SessionRow,
|
|
35
|
+
_ActionRow,
|
|
36
|
+
_hidden_marker,
|
|
37
|
+
_PreviewRow,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
if TYPE_CHECKING:
|
|
41
|
+
from ..data.snapshot import WorldSnapshot
|
|
42
|
+
|
|
43
|
+
from ..app import App
|
|
44
|
+
|
|
45
|
+
# R10/D7: refusal shown when the "current" session can't be determined (no /proc)
|
|
46
|
+
# — session-keyed destructive ops are disabled rather than silently doing nothing.
|
|
47
|
+
_DEGRADED = "liveness 降级:破坏性操作已禁用"
|
|
48
|
+
|
|
49
|
+
# Submenu actions. `stat` keys index `cleanup_classified`. The age sweep
|
|
50
|
+
# (Strategy B) is mtime-only/session-agnostic, so it is NOT R10-gated; every
|
|
51
|
+
# other action is.
|
|
52
|
+
_CLEANUP_ACTIONS = [
|
|
53
|
+
{"key": "empty", "label": "空壳会话(0提问)", "stat": "empty", "gated": True},
|
|
54
|
+
{"key": "short", "label": "短会话(≤2提问)", "stat": "short", "gated": True},
|
|
55
|
+
{"key": "orphans", "label": "孤儿目录(sid 键)", "stat": "orphan_dirs", "gated": True},
|
|
56
|
+
{"key": "zombies", "label": "僵尸会话文件(pid 键)", "stat": "zombie_procs", "gated": True},
|
|
57
|
+
{"key": "aged", "label": "过期全局文件(按天)", "stat": "aged_entries", "gated": False},
|
|
58
|
+
]
|
|
59
|
+
_GATED_ACTIONS = {a["key"] for a in _CLEANUP_ACTIONS if a["gated"]}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class SessionsView:
|
|
63
|
+
# mode: "list" | "filter" | "cleanup" | "preview"
|
|
64
|
+
def __init__(self, app: App) -> None:
|
|
65
|
+
self.app = app
|
|
66
|
+
self._sessions: list[Session] = []
|
|
67
|
+
self._all_sessions: list[Session] = []
|
|
68
|
+
self._pending: list[Session] | None = None
|
|
69
|
+
self._loaded = False
|
|
70
|
+
self._mode = "list"
|
|
71
|
+
self._filter_text = ""
|
|
72
|
+
self._cleanup_stats: dict[str, int] = {}
|
|
73
|
+
self._classified: dict[str, int] = {}
|
|
74
|
+
self._preview_action: str | None = None
|
|
75
|
+
self._preview_sessions: list[Session] = []
|
|
76
|
+
self._show_hidden = True
|
|
77
|
+
# Shared-snapshot liveness inputs for the pid-keyed zombie sweep + the
|
|
78
|
+
# classified counts (R11/D8 — projected, never re-scanned per view).
|
|
79
|
+
self._session_procs: list[SessionProc] = []
|
|
80
|
+
self._cur: set[int] = set()
|
|
81
|
+
self._pending_procs: list[SessionProc] | None = None
|
|
82
|
+
self._pending_cur: set[int] | None = None
|
|
83
|
+
self._pending_classified: dict[str, int] | None = None
|
|
84
|
+
|
|
85
|
+
self.status = urwid.AttrMap(urwid.Text(" 扫描中…"), "status")
|
|
86
|
+
col_header = urwid.AttrMap(_SESSION_HEADER, "col_header")
|
|
87
|
+
self.walker = urwid.SimpleFocusListWalker([])
|
|
88
|
+
self.listbox = urwid.ListBox(self.walker)
|
|
89
|
+
self._list_body = urwid.AttrMap(self.listbox, {None: "body"})
|
|
90
|
+
self._body = urwid.WidgetPlaceholder(self._list_body)
|
|
91
|
+
self.widget = urwid.Frame(self._body, header=col_header, footer=self.status)
|
|
92
|
+
self._cleanup_walker = urwid.SimpleFocusListWalker([])
|
|
93
|
+
|
|
94
|
+
def keyhints(self) -> str:
|
|
95
|
+
if self._mode == "help":
|
|
96
|
+
return "按任意键返回"
|
|
97
|
+
if self._mode == "cleanup":
|
|
98
|
+
return "Enter 预览待清理项 · Esc 返回会话列表"
|
|
99
|
+
if self._mode == "preview":
|
|
100
|
+
return "Enter 确认清理 · Esc 取消"
|
|
101
|
+
# High-frequency keys only; y/R/c/h move to `?` help to keep one line
|
|
102
|
+
# (D3). `r 刷新` is in the App-level footer prefix, not here.
|
|
103
|
+
return "Enter 接回 · s 停止 · f 分叉 · d 删除 · / 过滤 · ? 帮助"
|
|
104
|
+
|
|
105
|
+
def _update_footer(self) -> None:
|
|
106
|
+
if self.app.views[self.app._active] is not self:
|
|
107
|
+
return
|
|
108
|
+
self.app.set_hints(self.keyhints())
|
|
109
|
+
|
|
110
|
+
def load(self) -> None:
|
|
111
|
+
sessions = scan()
|
|
112
|
+
procs, cur, jobs, agents = self._self_fetch_liveness()
|
|
113
|
+
self._all_sessions = sessions
|
|
114
|
+
self._session_procs = procs
|
|
115
|
+
self._cur = cur
|
|
116
|
+
self._classified = self._classify(sessions, procs, cur, jobs, agents)
|
|
117
|
+
self._cleanup_stats = self._derive_stats(sessions, self._classified)
|
|
118
|
+
self._loaded = True
|
|
119
|
+
self._apply_filter()
|
|
120
|
+
self._rebuild()
|
|
121
|
+
|
|
122
|
+
def _self_fetch_liveness(
|
|
123
|
+
self,
|
|
124
|
+
) -> tuple[list[SessionProc], set[int], list[AgentJob], dict[str, int | None]]:
|
|
125
|
+
"""No-snapshot liveness inputs (back-compat / tests). Swallows errors.
|
|
126
|
+
|
|
127
|
+
Mirrors what `build_world_snapshot` computes so the submenu counts + the
|
|
128
|
+
zombie sweep work even without a shared snapshot. `proc_alive` is injected
|
|
129
|
+
here exactly as the snapshot path does it.
|
|
130
|
+
"""
|
|
131
|
+
try:
|
|
132
|
+
procs = [
|
|
133
|
+
replace(sp, proc_alive=proc.pid_alive(sp.pid, sp.proc_start))
|
|
134
|
+
for sp in registry.read_session_procs()
|
|
135
|
+
]
|
|
136
|
+
except Exception:
|
|
137
|
+
procs = []
|
|
138
|
+
try:
|
|
139
|
+
jobs = registry.read_agent_jobs()
|
|
140
|
+
except Exception:
|
|
141
|
+
jobs = []
|
|
142
|
+
try:
|
|
143
|
+
agents = liveness.alive_map()
|
|
144
|
+
except Exception:
|
|
145
|
+
agents = {}
|
|
146
|
+
return procs, proc.ancestor_pids(), jobs, agents
|
|
147
|
+
|
|
148
|
+
def _classify(
|
|
149
|
+
self,
|
|
150
|
+
sessions: list[Session],
|
|
151
|
+
procs: list[SessionProc],
|
|
152
|
+
cur: set[int],
|
|
153
|
+
jobs: list[AgentJob],
|
|
154
|
+
agents: dict[str, int | None],
|
|
155
|
+
) -> dict[str, int]:
|
|
156
|
+
try:
|
|
157
|
+
return cleanup_classified(sessions, procs, cur, jobs, agents)
|
|
158
|
+
except Exception:
|
|
159
|
+
return {}
|
|
160
|
+
|
|
161
|
+
def _derive_stats(self, sessions: list[Session], classified: dict[str, int]) -> dict[str, int]:
|
|
162
|
+
"""The legacy 4-key status-bar shape, derived from the classified counts."""
|
|
163
|
+
return {
|
|
164
|
+
"total": len(sessions),
|
|
165
|
+
"empty": classified.get("empty", 0),
|
|
166
|
+
"short": classified.get("short", 0),
|
|
167
|
+
"orphans": classified.get("orphan_dirs", 0),
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
def fetch_pending(self, snapshot: WorldSnapshot | None = None) -> None:
|
|
171
|
+
"""Worker-thread data fetch. Only sets pending fields — no widgets.
|
|
172
|
+
|
|
173
|
+
Projects the shared `snapshot` when given (R11/D8 — no per-view re-scan);
|
|
174
|
+
falls back to a self-contained scan when called with no snapshot
|
|
175
|
+
(back-compat / tests). The liveness inputs (`session_procs`/`cur` +
|
|
176
|
+
`agent_jobs`/`agents_map`) feed the pid-keyed zombie sweep and the
|
|
177
|
+
classified counts — taken straight from the snapshot, never re-scanned.
|
|
178
|
+
"""
|
|
179
|
+
if snapshot is not None:
|
|
180
|
+
sessions = snapshot.sessions
|
|
181
|
+
procs, cur = snapshot.session_procs, snapshot.cur
|
|
182
|
+
jobs, agents = snapshot.agent_jobs, snapshot.agents_map
|
|
183
|
+
else:
|
|
184
|
+
sessions = scan()
|
|
185
|
+
procs, cur, jobs, agents = self._self_fetch_liveness()
|
|
186
|
+
classified = self._classify(sessions, procs, cur, jobs, agents)
|
|
187
|
+
self.set_pending(sessions)
|
|
188
|
+
self._pending_procs = procs
|
|
189
|
+
self._pending_cur = cur
|
|
190
|
+
self._pending_classified = classified
|
|
191
|
+
self.set_pending_stats(self._derive_stats(sessions, classified))
|
|
192
|
+
|
|
193
|
+
def set_pending(self, sessions: list[Session]) -> None:
|
|
194
|
+
self._pending = sessions
|
|
195
|
+
|
|
196
|
+
def set_pending_stats(self, stats: dict[str, int]) -> None:
|
|
197
|
+
self._cleanup_stats = stats
|
|
198
|
+
|
|
199
|
+
def apply_data(self) -> None:
|
|
200
|
+
if self._pending is not None:
|
|
201
|
+
self._all_sessions = self._pending
|
|
202
|
+
self._pending = None
|
|
203
|
+
if self._pending_procs is not None:
|
|
204
|
+
self._session_procs = self._pending_procs
|
|
205
|
+
self._pending_procs = None
|
|
206
|
+
if self._pending_cur is not None:
|
|
207
|
+
self._cur = self._pending_cur
|
|
208
|
+
self._pending_cur = None
|
|
209
|
+
if self._pending_classified is not None:
|
|
210
|
+
self._classified = self._pending_classified
|
|
211
|
+
self._pending_classified = None
|
|
212
|
+
self._loaded = True
|
|
213
|
+
if self._mode == "list" or self._mode == "filter":
|
|
214
|
+
self._apply_filter()
|
|
215
|
+
self._rebuild()
|
|
216
|
+
elif self._mode == "cleanup":
|
|
217
|
+
self._rebuild_cleanup()
|
|
218
|
+
|
|
219
|
+
def _rebuild(self) -> None:
|
|
220
|
+
focus_pos = self.walker.get_focus()[1] if self.walker else 0
|
|
221
|
+
self.walker.clear()
|
|
222
|
+
for s in self._sessions:
|
|
223
|
+
self.walker.append(SessionRow(s))
|
|
224
|
+
if not self._sessions:
|
|
225
|
+
empty = "无匹配 · 按 / 改过滤 · Esc 清空" if self._filter_text else "暂无会话"
|
|
226
|
+
self.walker.append(urwid.AttrMap(urwid.Text(f" {empty}"), "dead"))
|
|
227
|
+
if self.walker and focus_pos is not None:
|
|
228
|
+
self.walker.set_focus(min(focus_pos, len(self.walker) - 1))
|
|
229
|
+
alive_n = sum(1 for s in self._all_sessions if s.alive)
|
|
230
|
+
flt = f" · 过滤「{self._filter_text}」" if self._filter_text else ""
|
|
231
|
+
empty = self._cleanup_stats.get("empty", 0)
|
|
232
|
+
short = self._cleanup_stats.get("short", 0)
|
|
233
|
+
orphans = self._cleanup_stats.get("orphans", 0)
|
|
234
|
+
cleanup_text = ""
|
|
235
|
+
hidden_n = sum(1 for s in self._all_sessions if s.bridge_or_sdk)
|
|
236
|
+
hidden_text = ""
|
|
237
|
+
if hidden_n:
|
|
238
|
+
hidden_text = f" · 桥接/SDK {hidden_n}" if self._show_hidden else f" · 桥接/SDK已隐藏 {hidden_n}"
|
|
239
|
+
if empty or short or orphans:
|
|
240
|
+
parts = []
|
|
241
|
+
if empty:
|
|
242
|
+
parts.append(f"空壳 {empty}")
|
|
243
|
+
if short:
|
|
244
|
+
parts.append(f"短 {short}")
|
|
245
|
+
if orphans:
|
|
246
|
+
parts.append(f"孤儿 {orphans}")
|
|
247
|
+
cleanup_text = f" · {' · '.join(parts)}"
|
|
248
|
+
self.status.original_widget.set_text(
|
|
249
|
+
f" 共 {len(self._all_sessions)} 条会话 · 运行 {alive_n} · 显示 {len(self._sessions)}{flt}{hidden_text}{cleanup_text}"
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
def _rebuild_cleanup(self) -> None:
|
|
253
|
+
c = self._classified
|
|
254
|
+
self._cleanup_walker.clear()
|
|
255
|
+
for a in _CLEANUP_ACTIONS:
|
|
256
|
+
count = c.get(a["stat"], 0)
|
|
257
|
+
self._cleanup_walker.append(_ActionRow(a["key"], a["label"], count))
|
|
258
|
+
|
|
259
|
+
def _selected(self) -> Session | None:
|
|
260
|
+
if not self.walker:
|
|
261
|
+
return None
|
|
262
|
+
widget = self.walker.get_focus()[0]
|
|
263
|
+
if isinstance(widget, SessionRow):
|
|
264
|
+
return widget.session
|
|
265
|
+
return None
|
|
266
|
+
|
|
267
|
+
def _apply_filter(self) -> None:
|
|
268
|
+
# D9: the hide filter unions the transcript `hidden` tags with the
|
|
269
|
+
# registry `source == "sdk"` signal (Session.bridge_or_sdk), so the
|
|
270
|
+
# badge and the `h` toggle stay consistent regardless of which signal
|
|
271
|
+
# flagged the session.
|
|
272
|
+
visible = [
|
|
273
|
+
s for s in self._all_sessions
|
|
274
|
+
if self._show_hidden or not s.bridge_or_sdk
|
|
275
|
+
]
|
|
276
|
+
if not self._filter_text:
|
|
277
|
+
self._sessions = visible
|
|
278
|
+
else:
|
|
279
|
+
k = self._filter_text.lower()
|
|
280
|
+
self._sessions = [
|
|
281
|
+
s for s in visible
|
|
282
|
+
if k in (
|
|
283
|
+
s.label + " " + s.cwd + " " + s.sid + " "
|
|
284
|
+
+ _hidden_marker(s) + " " + " ".join(sorted(s.hidden))
|
|
285
|
+
).lower()
|
|
286
|
+
]
|
|
287
|
+
|
|
288
|
+
def _enter_filter(self) -> None:
|
|
289
|
+
self._mode = "filter"
|
|
290
|
+
self._filter_edit = urwid.Edit("过滤: ")
|
|
291
|
+
self.app.frame.footer = urwid.AttrMap(self._filter_edit, "notify")
|
|
292
|
+
|
|
293
|
+
def _exit_filter(self, cancel: bool = False) -> None:
|
|
294
|
+
self._mode = "list"
|
|
295
|
+
if cancel:
|
|
296
|
+
self._filter_text = ""
|
|
297
|
+
else:
|
|
298
|
+
self._filter_text = self._filter_edit.get_edit_text()
|
|
299
|
+
self._apply_filter()
|
|
300
|
+
self._rebuild()
|
|
301
|
+
self.app._restore_footer()
|
|
302
|
+
|
|
303
|
+
# --- Cleanup submenu ---
|
|
304
|
+
|
|
305
|
+
def _enter_cleanup(self) -> None:
|
|
306
|
+
self._mode = "cleanup"
|
|
307
|
+
self._rebuild_cleanup()
|
|
308
|
+
cleanup_list = urwid.ListBox(self._cleanup_walker)
|
|
309
|
+
title = urwid.AttrMap(urwid.Text(" 清理会话", align="center"), "col_header")
|
|
310
|
+
box_content = urwid.Frame(cleanup_list, header=title)
|
|
311
|
+
box = urwid.LineBox(box_content)
|
|
312
|
+
overlay = urwid.Overlay(
|
|
313
|
+
box, self._list_body,
|
|
314
|
+
align="center", width=("relative", 50),
|
|
315
|
+
valign="middle", height=min(len(self._cleanup_walker) + 4, 20),
|
|
316
|
+
)
|
|
317
|
+
self._body.original_widget = overlay
|
|
318
|
+
self._update_footer()
|
|
319
|
+
|
|
320
|
+
def _exit_cleanup(self) -> None:
|
|
321
|
+
self._mode = "list"
|
|
322
|
+
self._body.original_widget = self._list_body
|
|
323
|
+
self._update_footer()
|
|
324
|
+
|
|
325
|
+
def _selected_action(self) -> str | None:
|
|
326
|
+
if not self._cleanup_walker:
|
|
327
|
+
return None
|
|
328
|
+
widget = self._cleanup_walker.get_focus()[0]
|
|
329
|
+
if isinstance(widget, _ActionRow):
|
|
330
|
+
return widget.action_key
|
|
331
|
+
return None
|
|
332
|
+
|
|
333
|
+
def _show_overlay(self, title: str, rows: list, height: int | None = None) -> None:
|
|
334
|
+
preview_walker = urwid.SimpleFocusListWalker(rows)
|
|
335
|
+
preview_list = urwid.ListBox(preview_walker)
|
|
336
|
+
header = urwid.AttrMap(urwid.Text(f" {title}", align="center"), "col_header")
|
|
337
|
+
box = urwid.LineBox(urwid.Frame(preview_list, header=header))
|
|
338
|
+
h = height or min(len(rows) + 4, 30)
|
|
339
|
+
overlay = urwid.Overlay(
|
|
340
|
+
box, self._list_body,
|
|
341
|
+
align="center", width=("relative", 70),
|
|
342
|
+
valign="middle", height=h,
|
|
343
|
+
)
|
|
344
|
+
self._body.original_widget = overlay
|
|
345
|
+
|
|
346
|
+
def _open_preview(self, action: str, title: str, rows: list) -> None:
|
|
347
|
+
"""Shared preview-overlay entry for a dir/file sweep (no session list)."""
|
|
348
|
+
self._mode = "preview"
|
|
349
|
+
self._preview_action = action
|
|
350
|
+
self._preview_sessions = []
|
|
351
|
+
self._show_overlay(title, rows)
|
|
352
|
+
self._update_footer()
|
|
353
|
+
|
|
354
|
+
def _enter_preview(self, action: str) -> None:
|
|
355
|
+
# R10/D7: session-keyed destructive sweeps need a determinable "current"
|
|
356
|
+
# (without /proc every pid looks dead, so they'd nuke the live session).
|
|
357
|
+
# Refuse HONESTLY — never let the refusal read as "nothing to clean".
|
|
358
|
+
if action in _GATED_ACTIONS and not proc.current_determinable():
|
|
359
|
+
self.app.notify(_DEGRADED)
|
|
360
|
+
return
|
|
361
|
+
|
|
362
|
+
if action in ("empty", "short"):
|
|
363
|
+
sessions = scan()
|
|
364
|
+
if action == "empty":
|
|
365
|
+
targets = prune_sessions(sessions, max_prompts=0)
|
|
366
|
+
label = "空壳会话"
|
|
367
|
+
else:
|
|
368
|
+
targets = [s for s in prune_sessions(sessions, max_prompts=2) if s.prompts > 0]
|
|
369
|
+
label = "短会话(≤2提问)"
|
|
370
|
+
if not targets:
|
|
371
|
+
self.app.notify(f"无{label}需要清理")
|
|
372
|
+
return
|
|
373
|
+
self._mode = "preview"
|
|
374
|
+
self._preview_action = action
|
|
375
|
+
self._preview_sessions = targets
|
|
376
|
+
rows = []
|
|
377
|
+
for s in targets:
|
|
378
|
+
when = time.strftime("%m-%d %H:%M", time.localtime(s.mtime))
|
|
379
|
+
cwd = s.cwd.rstrip("/").rsplit("/", 1)[-1] if s.cwd else ""
|
|
380
|
+
line = f"{when} p{s.prompts} {s.label[:60]} ({cwd})"
|
|
381
|
+
rows.append(_PreviewRow(line))
|
|
382
|
+
self._show_overlay(f"将清理 {len(targets)} 条{label}", rows)
|
|
383
|
+
self._update_footer()
|
|
384
|
+
elif action == "orphans":
|
|
385
|
+
orphan_paths = list_orphan_dirs(scan())
|
|
386
|
+
if not orphan_paths:
|
|
387
|
+
self.app.notify("无孤儿目录需要清理")
|
|
388
|
+
return
|
|
389
|
+
rows = [_PreviewRow(p) for p in orphan_paths]
|
|
390
|
+
self._open_preview(action, f"将清理 {len(orphan_paths)} 个孤儿目录", rows)
|
|
391
|
+
elif action == "zombies":
|
|
392
|
+
pids = select_zombie_pids(self._session_procs, self._cur)
|
|
393
|
+
if not pids:
|
|
394
|
+
self.app.notify("无僵尸会话文件需要清理")
|
|
395
|
+
return
|
|
396
|
+
rows = [_PreviewRow(f"sessions/{pid}.json") for pid in pids]
|
|
397
|
+
self._open_preview(action, f"将清理 {len(pids)} 个僵尸会话文件", rows)
|
|
398
|
+
elif action == "aged":
|
|
399
|
+
entries = list_aged_entries()
|
|
400
|
+
if not entries:
|
|
401
|
+
self.app.notify("无过期文件需要清理")
|
|
402
|
+
return
|
|
403
|
+
rows = [_PreviewRow(e) for e in entries]
|
|
404
|
+
self._open_preview(action, f"将清理 {len(entries)} 个过期项", rows)
|
|
405
|
+
|
|
406
|
+
def _confirm_cleanup(self) -> None:
|
|
407
|
+
action = self._preview_action
|
|
408
|
+
if action in ("empty", "short"):
|
|
409
|
+
removed = sum(1 for t in self._preview_sessions if remove_session(t))
|
|
410
|
+
self.app.notify(f"已清理 {removed} 条会话")
|
|
411
|
+
elif action == "orphans":
|
|
412
|
+
count = remove_orphan_dirs(scan())
|
|
413
|
+
self.app.notify(f"已清理 {count} 个孤儿目录")
|
|
414
|
+
elif action == "zombies":
|
|
415
|
+
count = remove_zombie_session_files(self._session_procs, self._cur)
|
|
416
|
+
self.app.notify(f"已清理 {count} 个僵尸会话文件")
|
|
417
|
+
elif action == "aged":
|
|
418
|
+
count = remove_aged_entries()
|
|
419
|
+
self.app.notify(f"已清理 {count} 个过期项")
|
|
420
|
+
self._preview_action = None
|
|
421
|
+
self._preview_sessions = []
|
|
422
|
+
self._enter_cleanup()
|
|
423
|
+
self.app.trigger_async_refresh()
|
|
424
|
+
|
|
425
|
+
def _do_terminate(self, s: Session) -> None:
|
|
426
|
+
"""Stop body, run only after the y/n confirm accepts."""
|
|
427
|
+
ok = terminate_session(s)
|
|
428
|
+
self.app.notify("已停止" if ok else "停止失败")
|
|
429
|
+
self.app.trigger_async_refresh()
|
|
430
|
+
|
|
431
|
+
def _do_relaunch(self, s: Session) -> None:
|
|
432
|
+
"""Relaunch-into-tmux body (after confirm when it takes over a live one)."""
|
|
433
|
+
ok = relaunch_in_tmux(s)
|
|
434
|
+
self.app.notify(
|
|
435
|
+
"已转入后台 + 远控(手机/网页可接管)" if ok else "转入后台失败"
|
|
436
|
+
)
|
|
437
|
+
self.app.trigger_async_refresh()
|
|
438
|
+
|
|
439
|
+
def _resume_or_confirm(self, s: Session, fork: bool) -> None:
|
|
440
|
+
"""Resume now, or confirm first when it would take over a live session.
|
|
441
|
+
|
|
442
|
+
Reads `would_take_over` (= should_kill, the single source) so the confirm
|
|
443
|
+
gate never re-derives the takeover condition (CLAUDE.md invariant).
|
|
444
|
+
"""
|
|
445
|
+
if would_take_over(s, fork):
|
|
446
|
+
self.app.confirm(
|
|
447
|
+
f"接回会话「{s.label[:30]}」?将先终止原进程。",
|
|
448
|
+
lambda: self.app.exit_with_resume(s, fork),
|
|
449
|
+
)
|
|
450
|
+
else:
|
|
451
|
+
self.app.exit_with_resume(s, fork)
|
|
452
|
+
|
|
453
|
+
# --- Key dispatch ---
|
|
454
|
+
|
|
455
|
+
def handle_key(self, key: str) -> None:
|
|
456
|
+
if self._mode == "help":
|
|
457
|
+
self._mode = "list"
|
|
458
|
+
self._body.original_widget = self._list_body
|
|
459
|
+
self._update_footer()
|
|
460
|
+
return
|
|
461
|
+
|
|
462
|
+
if self._mode == "filter":
|
|
463
|
+
if key == "enter":
|
|
464
|
+
self._exit_filter()
|
|
465
|
+
elif key == "esc":
|
|
466
|
+
self._exit_filter(cancel=True)
|
|
467
|
+
else:
|
|
468
|
+
self._filter_edit.keypress((80,), key)
|
|
469
|
+
return
|
|
470
|
+
|
|
471
|
+
if self._mode == "preview":
|
|
472
|
+
if key == "enter":
|
|
473
|
+
self._confirm_cleanup()
|
|
474
|
+
elif key == "esc":
|
|
475
|
+
self._enter_cleanup()
|
|
476
|
+
return
|
|
477
|
+
|
|
478
|
+
if self._mode == "cleanup":
|
|
479
|
+
if key == "enter":
|
|
480
|
+
action = self._selected_action()
|
|
481
|
+
if action:
|
|
482
|
+
self._enter_preview(action)
|
|
483
|
+
elif key == "esc":
|
|
484
|
+
self._exit_cleanup()
|
|
485
|
+
elif key == "r":
|
|
486
|
+
self.app.trigger_async_refresh()
|
|
487
|
+
self.app.notify("刷新中…")
|
|
488
|
+
return
|
|
489
|
+
|
|
490
|
+
# Normal list mode
|
|
491
|
+
s = self._selected()
|
|
492
|
+
|
|
493
|
+
if key == "enter" and s:
|
|
494
|
+
if s.current:
|
|
495
|
+
self.app.notify("不能接回当前会话")
|
|
496
|
+
return
|
|
497
|
+
self._resume_or_confirm(s, fork=False)
|
|
498
|
+
elif key == "f" and s:
|
|
499
|
+
if s.current:
|
|
500
|
+
self.app.notify("不能分叉当前会话")
|
|
501
|
+
return
|
|
502
|
+
self.app.exit_with_resume(s, fork=True)
|
|
503
|
+
elif key == "s" and s:
|
|
504
|
+
# Degrade gate FIRST (R2): off /proc every pid looks dead, so a stop
|
|
505
|
+
# could hit csctl's own session — refuse honestly before confirming.
|
|
506
|
+
if not proc.current_determinable():
|
|
507
|
+
self.app.notify(_DEGRADED)
|
|
508
|
+
return
|
|
509
|
+
if not s.alive:
|
|
510
|
+
self.app.notify("会话未在运行")
|
|
511
|
+
return
|
|
512
|
+
if s.current:
|
|
513
|
+
self.app.notify("不能停止当前会话")
|
|
514
|
+
return
|
|
515
|
+
self.app.confirm(
|
|
516
|
+
f"停止会话「{s.label[:30]}」?将终止其进程。",
|
|
517
|
+
lambda: self._do_terminate(s),
|
|
518
|
+
)
|
|
519
|
+
elif key == "R" and s:
|
|
520
|
+
if s.current:
|
|
521
|
+
self.app.notify("不能转入后台当前会话")
|
|
522
|
+
return
|
|
523
|
+
# Degrade gate only for a takeover (live): relaunching a DEAD session
|
|
524
|
+
# kills nothing and data refuses nothing, so it stays usable off
|
|
525
|
+
# /proc (B3). `would_take_over` is the single should_kill source.
|
|
526
|
+
if would_take_over(s) and not proc.current_determinable():
|
|
527
|
+
self.app.notify(_DEGRADED)
|
|
528
|
+
return
|
|
529
|
+
if would_take_over(s):
|
|
530
|
+
self.app.confirm(
|
|
531
|
+
f"转入后台「{s.label[:30]}」?将先终止原进程。",
|
|
532
|
+
lambda: self._do_relaunch(s),
|
|
533
|
+
)
|
|
534
|
+
else:
|
|
535
|
+
self._do_relaunch(s)
|
|
536
|
+
elif key == "d" and s:
|
|
537
|
+
if s.alive:
|
|
538
|
+
self.app.notify("运行中的会话不删,先停止")
|
|
539
|
+
return
|
|
540
|
+
if not proc.current_determinable():
|
|
541
|
+
self.app.notify(_DEGRADED)
|
|
542
|
+
return
|
|
543
|
+
# L4: honour remove_session's bool — only claim success when it truly
|
|
544
|
+
# removed something; a False here means there was nothing to delete.
|
|
545
|
+
if remove_session(s):
|
|
546
|
+
self.app.notify("已删除")
|
|
547
|
+
else:
|
|
548
|
+
self.app.notify("无可删除内容")
|
|
549
|
+
self.app.trigger_async_refresh()
|
|
550
|
+
elif key == "y" and s:
|
|
551
|
+
cmd = resume_cmd(s)
|
|
552
|
+
ok = to_clipboard(cmd)
|
|
553
|
+
self.app.notify("已复制" if ok else f"复制失败: {cmd}")
|
|
554
|
+
elif key == "c":
|
|
555
|
+
self._enter_cleanup()
|
|
556
|
+
elif key == "h":
|
|
557
|
+
self._show_hidden = not self._show_hidden
|
|
558
|
+
self._apply_filter()
|
|
559
|
+
self._rebuild()
|
|
560
|
+
self._update_footer()
|
|
561
|
+
elif key == "r":
|
|
562
|
+
self.app.trigger_async_refresh()
|
|
563
|
+
self.app.notify("刷新中…")
|
|
564
|
+
elif key == "/":
|
|
565
|
+
self._enter_filter()
|
|
566
|
+
elif key == "?":
|
|
567
|
+
self._show_help()
|
|
568
|
+
|
|
569
|
+
def _show_help(self) -> None:
|
|
570
|
+
lines = [
|
|
571
|
+
"(footer 未列的键也可用)",
|
|
572
|
+
"",
|
|
573
|
+
"会话操作:",
|
|
574
|
+
" Enter 接回选中的会话(在终端中恢复;接运行中的会话会先确认接管)",
|
|
575
|
+
" f 分叉会话(创建副本后接回,不影响原会话)",
|
|
576
|
+
" s 停止运行中的会话(发送 SIGTERM,需二次确认)",
|
|
577
|
+
" R 转入 tmux 后台并开启远控(脱离终端,手机/网页可接管;",
|
|
578
|
+
" 接运行中的会话会先确认接管)",
|
|
579
|
+
" d 删除已结束的会话记录",
|
|
580
|
+
" y 复制接回命令到剪贴板",
|
|
581
|
+
" h 显示/隐藏桥接、SDK 会话",
|
|
582
|
+
"",
|
|
583
|
+
"清理与过滤:",
|
|
584
|
+
" c 打开清理子菜单",
|
|
585
|
+
" / 按关键词过滤会话列表",
|
|
586
|
+
" r 刷新",
|
|
587
|
+
"",
|
|
588
|
+
"导航:",
|
|
589
|
+
" Tab 切换标签页",
|
|
590
|
+
" q 退出",
|
|
591
|
+
]
|
|
592
|
+
rows = [_PreviewRow(line) for line in lines]
|
|
593
|
+
self._mode = "help"
|
|
594
|
+
self._show_overlay("快捷键帮助", rows)
|
|
595
|
+
self._update_footer()
|