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,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()