cc-session-control 0.7.0__tar.gz → 0.7.2__tar.gz

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 (64) hide show
  1. {cc_session_control-0.7.0/src/cc_session_control.egg-info → cc_session_control-0.7.2}/PKG-INFO +3 -1
  2. {cc_session_control-0.7.0 → cc_session_control-0.7.2}/README.md +2 -0
  3. {cc_session_control-0.7.0 → cc_session_control-0.7.2}/src/cc_session_control/__init__.py +1 -1
  4. {cc_session_control-0.7.0 → cc_session_control-0.7.2}/src/cc_session_control/app.py +6 -23
  5. {cc_session_control-0.7.0 → cc_session_control-0.7.2}/src/cc_session_control/cli.py +9 -3
  6. {cc_session_control-0.7.0 → cc_session_control-0.7.2}/src/cc_session_control/config.py +2 -0
  7. {cc_session_control-0.7.0 → cc_session_control-0.7.2}/src/cc_session_control/data/rc.py +10 -1
  8. cc_session_control-0.7.2/src/cc_session_control/theme.py +189 -0
  9. {cc_session_control-0.7.0 → cc_session_control-0.7.2}/src/cc_session_control/views/rc.py +2 -2
  10. {cc_session_control-0.7.0 → cc_session_control-0.7.2/src/cc_session_control.egg-info}/PKG-INFO +3 -1
  11. {cc_session_control-0.7.0 → cc_session_control-0.7.2}/src/cc_session_control.egg-info/SOURCES.txt +2 -0
  12. {cc_session_control-0.7.0 → cc_session_control-0.7.2}/tests/test_cli.py +14 -0
  13. {cc_session_control-0.7.0 → cc_session_control-0.7.2}/tests/test_rc.py +11 -5
  14. cc_session_control-0.7.2/tests/test_theme.py +178 -0
  15. {cc_session_control-0.7.0 → cc_session_control-0.7.2}/LICENSE +0 -0
  16. {cc_session_control-0.7.0 → cc_session_control-0.7.2}/pyproject.toml +0 -0
  17. {cc_session_control-0.7.0 → cc_session_control-0.7.2}/setup.cfg +0 -0
  18. {cc_session_control-0.7.0 → cc_session_control-0.7.2}/src/cc_session_control/__main__.py +0 -0
  19. {cc_session_control-0.7.0 → cc_session_control-0.7.2}/src/cc_session_control/actions/__init__.py +0 -0
  20. {cc_session_control-0.7.0 → cc_session_control-0.7.2}/src/cc_session_control/actions/agent_ops.py +0 -0
  21. {cc_session_control-0.7.0 → cc_session_control-0.7.2}/src/cc_session_control/actions/resume_list.py +0 -0
  22. {cc_session_control-0.7.0 → cc_session_control-0.7.2}/src/cc_session_control/actions/session_ops.py +0 -0
  23. {cc_session_control-0.7.0 → cc_session_control-0.7.2}/src/cc_session_control/actions/skill_ops.py +0 -0
  24. {cc_session_control-0.7.0 → cc_session_control-0.7.2}/src/cc_session_control/clipboard.py +0 -0
  25. {cc_session_control-0.7.0 → cc_session_control-0.7.2}/src/cc_session_control/data/__init__.py +0 -0
  26. {cc_session_control-0.7.0 → cc_session_control-0.7.2}/src/cc_session_control/data/cleanup.py +0 -0
  27. {cc_session_control-0.7.0 → cc_session_control-0.7.2}/src/cc_session_control/data/environments.py +0 -0
  28. {cc_session_control-0.7.0 → cc_session_control-0.7.2}/src/cc_session_control/data/liveness.py +0 -0
  29. {cc_session_control-0.7.0 → cc_session_control-0.7.2}/src/cc_session_control/data/proc.py +0 -0
  30. {cc_session_control-0.7.0 → cc_session_control-0.7.2}/src/cc_session_control/data/registry.py +0 -0
  31. {cc_session_control-0.7.0 → cc_session_control-0.7.2}/src/cc_session_control/data/sessions.py +0 -0
  32. {cc_session_control-0.7.0 → cc_session_control-0.7.2}/src/cc_session_control/data/snapshot.py +0 -0
  33. {cc_session_control-0.7.0 → cc_session_control-0.7.2}/src/cc_session_control/data/tmux.py +0 -0
  34. {cc_session_control-0.7.0 → cc_session_control-0.7.2}/src/cc_session_control/models.py +0 -0
  35. {cc_session_control-0.7.0 → cc_session_control-0.7.2}/src/cc_session_control/skill/SKILL.md +0 -0
  36. {cc_session_control-0.7.0 → cc_session_control-0.7.2}/src/cc_session_control/views/__init__.py +0 -0
  37. {cc_session_control-0.7.0 → cc_session_control-0.7.2}/src/cc_session_control/views/_base.py +0 -0
  38. {cc_session_control-0.7.0 → cc_session_control-0.7.2}/src/cc_session_control/views/_colspec.py +0 -0
  39. {cc_session_control-0.7.0 → cc_session_control-0.7.2}/src/cc_session_control/views/_confirm.py +0 -0
  40. {cc_session_control-0.7.0 → cc_session_control-0.7.2}/src/cc_session_control/views/_keytable.py +0 -0
  41. {cc_session_control-0.7.0 → cc_session_control-0.7.2}/src/cc_session_control/views/_rows.py +0 -0
  42. {cc_session_control-0.7.0 → cc_session_control-0.7.2}/src/cc_session_control/views/_session_row.py +0 -0
  43. {cc_session_control-0.7.0 → cc_session_control-0.7.2}/src/cc_session_control/views/_sessions_cleanup.py +0 -0
  44. {cc_session_control-0.7.0 → cc_session_control-0.7.2}/src/cc_session_control/views/agents.py +0 -0
  45. {cc_session_control-0.7.0 → cc_session_control-0.7.2}/src/cc_session_control/views/sessions.py +0 -0
  46. {cc_session_control-0.7.0 → cc_session_control-0.7.2}/src/cc_session_control.egg-info/dependency_links.txt +0 -0
  47. {cc_session_control-0.7.0 → cc_session_control-0.7.2}/src/cc_session_control.egg-info/entry_points.txt +0 -0
  48. {cc_session_control-0.7.0 → cc_session_control-0.7.2}/src/cc_session_control.egg-info/requires.txt +0 -0
  49. {cc_session_control-0.7.0 → cc_session_control-0.7.2}/src/cc_session_control.egg-info/top_level.txt +0 -0
  50. {cc_session_control-0.7.0 → cc_session_control-0.7.2}/tests/test_agent_ops.py +0 -0
  51. {cc_session_control-0.7.0 → cc_session_control-0.7.2}/tests/test_agents.py +0 -0
  52. {cc_session_control-0.7.0 → cc_session_control-0.7.2}/tests/test_app.py +0 -0
  53. {cc_session_control-0.7.0 → cc_session_control-0.7.2}/tests/test_cleanup.py +0 -0
  54. {cc_session_control-0.7.0 → cc_session_control-0.7.2}/tests/test_data.py +0 -0
  55. {cc_session_control-0.7.0 → cc_session_control-0.7.2}/tests/test_environments.py +0 -0
  56. {cc_session_control-0.7.0 → cc_session_control-0.7.2}/tests/test_liveness.py +0 -0
  57. {cc_session_control-0.7.0 → cc_session_control-0.7.2}/tests/test_proc.py +0 -0
  58. {cc_session_control-0.7.0 → cc_session_control-0.7.2}/tests/test_registry.py +0 -0
  59. {cc_session_control-0.7.0 → cc_session_control-0.7.2}/tests/test_resume_list.py +0 -0
  60. {cc_session_control-0.7.0 → cc_session_control-0.7.2}/tests/test_sessions.py +0 -0
  61. {cc_session_control-0.7.0 → cc_session_control-0.7.2}/tests/test_skill_ops.py +0 -0
  62. {cc_session_control-0.7.0 → cc_session_control-0.7.2}/tests/test_smoke.py +0 -0
  63. {cc_session_control-0.7.0 → cc_session_control-0.7.2}/tests/test_snapshot.py +0 -0
  64. {cc_session_control-0.7.0 → cc_session_control-0.7.2}/tests/test_views.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cc-session-control
3
- Version: 0.7.0
3
+ Version: 0.7.2
4
4
  Summary: TUI manager for Claude Code sessions and Remote Control
5
5
  Author: dzshzx
6
6
  License-Expression: MIT
@@ -104,6 +104,7 @@ csctl skill uninstall
104
104
 
105
105
  # Options
106
106
  csctl --workspace ~/projects # Override workspace root
107
+ csctl --theme light # Force the TUI palette (auto/dark/light)
107
108
  csctl --version
108
109
  ```
109
110
 
@@ -114,6 +115,7 @@ csctl --version
114
115
  | `CSCTL_WORKSPACE` | `~/workspace` | Workspace root directory |
115
116
  | `CSCTL_RC_SESSION` | `rc` | tmux session name for RC servers |
116
117
  | `CSCTL_RC_STAGGER` | `2` | Seconds between starting RC servers |
118
+ | `CSCTL_THEME` | `auto` | TUI palette: `auto` (detect the terminal background via OSC 11 / `$COLORFGBG`) / `dark` / `light`. tmux typically doesn't answer the OSC 11 query, so inside tmux `auto` falls back to `dark` — set this (or `--theme`) explicitly for a light terminal |
117
119
  | `XDG_CONFIG_HOME` | `~/.config` | Config directory base |
118
120
 
119
121
  RC auto-start list is stored at `$XDG_CONFIG_HOME/csctl/rc-enabled`.
@@ -81,6 +81,7 @@ csctl skill uninstall
81
81
 
82
82
  # Options
83
83
  csctl --workspace ~/projects # Override workspace root
84
+ csctl --theme light # Force the TUI palette (auto/dark/light)
84
85
  csctl --version
85
86
  ```
86
87
 
@@ -91,6 +92,7 @@ csctl --version
91
92
  | `CSCTL_WORKSPACE` | `~/workspace` | Workspace root directory |
92
93
  | `CSCTL_RC_SESSION` | `rc` | tmux session name for RC servers |
93
94
  | `CSCTL_RC_STAGGER` | `2` | Seconds between starting RC servers |
95
+ | `CSCTL_THEME` | `auto` | TUI palette: `auto` (detect the terminal background via OSC 11 / `$COLORFGBG`) / `dark` / `light`. tmux typically doesn't answer the OSC 11 query, so inside tmux `auto` falls back to `dark` — set this (or `--theme`) explicitly for a light terminal |
94
96
  | `XDG_CONFIG_HOME` | `~/.config` | Config directory base |
95
97
 
96
98
  RC auto-start list is stored at `$XDG_CONFIG_HOME/csctl/rc-enabled`.
@@ -1,3 +1,3 @@
1
1
  """cc-session-control — TUI manager for Claude Code sessions and Remote Control."""
2
2
 
3
- __version__ = "0.7.0"
3
+ __version__ = "0.7.2"
@@ -10,6 +10,7 @@ from typing import TYPE_CHECKING, Protocol, runtime_checkable
10
10
 
11
11
  import urwid
12
12
 
13
+ from . import theme
13
14
  from .data import proc
14
15
  from .data.snapshot import WorldSnapshot, build_world_snapshot
15
16
  from .views.agents import AgentsView
@@ -47,28 +48,6 @@ class TabView(Protocol):
47
48
  def handle_key(self, key: str) -> None: ...
48
49
  def captures_text(self) -> bool: ... # True while a text mode owns EVERY key (incl. tab/q)
49
50
 
50
- # 6-tuple: (name, fg_16, bg_16, mono, fg_256, bg_256)
51
- # ONE semantic set — views reference these names only (no per-tab duplicates;
52
- # the old rc_running/rc_stopped were aliases of alive/dead and are gone).
53
- # 256-color foregrounds are kept ≥ 4.5:1 against their background (WCAG-AA-ish,
54
- # checked via relative luminance): #aaa/#111 ≈ 8.1, #7ab/#111 ≈ 7.4,
55
- # #9cc/#181818 ≈ 10.0, #bbb/#111 ≈ 9.8, #d66/#111 ≈ 5.5.
56
- PALETTE = [
57
- ("header", "white,bold", "black", "bold", "#fff,bold", "#111"),
58
- ("footer", "light gray", "black", None, "#aaa", "#111"),
59
- ("tab_on", "white,bold", "dark cyan", "bold,standout", "#fff,bold", "#068"),
60
- ("tab_off", "light cyan", "black", None, "#7ab", "#111"),
61
- ("alive", "light green", "black", None, "#6d6", "#111"),
62
- ("status_busy", "light green,bold", "black", "bold", "#6d6,bold", "#111"),
63
- ("status_err", "light red", "black", None, "#d66", "#111"),
64
- ("dead", "light gray", "black", None, "#ccc", "#111"),
65
- ("selected", "white,bold", "dark cyan", "standout", "#fff,bold", "#068"),
66
- ("notify", "yellow,bold", "black", "bold", "#ff0,bold", "#111"),
67
- ("status", "light gray", "black", None, "#bbb", "#111"),
68
- ("body", "light gray", "black", None, "#ccc", "#111"),
69
- ("col_header", "dark cyan", "black", None, "#9cc", "#181818"),
70
- ]
71
-
72
51
  # Launcher-first order (ADR-0001): startup lands on 项目 (the tmux-first
73
52
  # dispatch entry), then 会话 / 后台. In lockstep with `self.views` below.
74
53
  TAB_NAMES = ["项目", "会话", "后台"]
@@ -80,6 +59,10 @@ FOOTER_PREFIX = " Tab 切换 · q 退出 · r 刷新 · "
80
59
 
81
60
 
82
61
  def _make_screen() -> urwid.raw_display.Screen:
62
+ # The theme query (OSC 11) runs here, before `loop.run()` puts the tty
63
+ # into urwid's hands; the palette itself lives in `theme.py` (ONE semantic
64
+ # attr set, dark/light generated from a single spec).
65
+ mode = theme.detect_mode()
83
66
  screen = urwid.raw_display.Screen()
84
67
  try:
85
68
  curses.setupterm()
@@ -88,7 +71,7 @@ def _make_screen() -> urwid.raw_display.Screen:
88
71
  screen.set_terminal_properties(colors=256)
89
72
  except Exception:
90
73
  pass
91
- screen.register_palette(PALETTE)
74
+ screen.register_palette(theme.palette(mode))
92
75
  return screen
93
76
 
94
77
 
@@ -17,6 +17,10 @@ def _build_parser() -> argparse.ArgumentParser:
17
17
  )
18
18
  parser.add_argument("--version", action="version", version=f"csctl {__version__}")
19
19
  parser.add_argument("--workspace", type=Path, help="Override workspace root directory")
20
+ parser.add_argument(
21
+ "--theme", choices=("auto", "dark", "light"),
22
+ help="TUI palette (default: auto-detect the terminal background; env CSCTL_THEME)",
23
+ )
20
24
 
21
25
  sub = parser.add_subparsers(dest="command")
22
26
 
@@ -67,10 +71,12 @@ def _build_parser() -> argparse.ArgumentParser:
67
71
  return parser
68
72
 
69
73
 
70
- def _apply_workspace(args: argparse.Namespace) -> None:
74
+ def _apply_global_flags(args: argparse.Namespace) -> None:
75
+ from .config import cfg
71
76
  if args.workspace:
72
- from .config import cfg
73
77
  cfg.workspace = args.workspace
78
+ if args.theme:
79
+ cfg.theme = args.theme
74
80
 
75
81
 
76
82
  def _cmd_rc(args: argparse.Namespace) -> None:
@@ -311,7 +317,7 @@ def _cmd_tui(args: argparse.Namespace) -> None:
311
317
  def main() -> None:
312
318
  parser = _build_parser()
313
319
  args = parser.parse_args()
314
- _apply_workspace(args)
320
+ _apply_global_flags(args)
315
321
 
316
322
  if args.command == "rc":
317
323
  _cmd_rc(args)
@@ -18,6 +18,8 @@ class Config:
18
18
  self.rc_stagger: int = int(os.environ.get("CSCTL_RC_STAGGER", "2"))
19
19
  # Age threshold (days) for the time/global-keyed cleanup strategy.
20
20
  self.cleanup_age_days: int = int(os.environ.get("CSCTL_CLEANUP_AGE_DAYS", "14"))
21
+ # TUI palette: "auto" (detect the terminal background) | "dark" | "light".
22
+ self.theme: str = os.environ.get("CSCTL_THEME", "auto")
21
23
  self._workspace: Path | None = None
22
24
 
23
25
  @property
@@ -183,6 +183,15 @@ def scan() -> list[RCProject]:
183
183
  for name in all_names:
184
184
  directory = str(cfg.workspace / name)
185
185
  in_windows = name in windows
186
+ dir_exists = os.path.isdir(directory)
187
+ if not dir_exists and name not in enabled and not in_windows:
188
+ # Pure trust residue: the directory is gone and only claude's own
189
+ # trust record (~/.claude.json) still references it. csctl can't
190
+ # act on it (no start, and it never edits claude's files), so it
191
+ # is dropped instead of rendered as a ✖ 缺失 row. Missing-dir
192
+ # projects that ARE actionable (in the autostart list, or with a
193
+ # live/dead tmux window) stay listed.
194
+ continue
186
195
  if in_windows:
187
196
  status = "running" if _is_alive(name) else "dead"
188
197
  else:
@@ -195,7 +204,7 @@ def scan() -> list[RCProject]:
195
204
  auto_start=name in enabled,
196
205
  rc_at_startup=_read_rc_at_startup(directory),
197
206
  spawn_mode=_read_spawn_mode(name),
198
- dir_exists=os.path.isdir(directory),
207
+ dir_exists=dir_exists,
199
208
  ))
200
209
  return result
201
210
 
@@ -0,0 +1,189 @@
1
+ """Terminal-theme seam: background detection + the dark/light palettes.
2
+
3
+ Top-level helper like `clipboard.py` — it touches only the controlling tty
4
+ (an OSC 11 background-color query) and the process env; `app._make_screen`
5
+ is the sole consumer. Views keep referencing the ONE semantic attr set; this
6
+ module only decides which fg/bg pair each name resolves to.
7
+
8
+ Detection order (`detect_mode`): explicit `CSCTL_THEME` (dark/light) →
9
+ OSC 11 tty query → `$COLORFGBG` → dark. The query MUST run before urwid
10
+ takes over the tty — `App.__init__` builds the screen before `loop.run()`,
11
+ so stdin is still in normal mode there.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ import re
18
+ import select
19
+ import sys
20
+ import termios
21
+ import time
22
+
23
+ from .config import cfg
24
+
25
+ Mode = str # "light" | anything else = dark
26
+
27
+ # (name, mono, dark (fg16, bg16, fg256, bg256), light (fg16, bg16, fg256, bg256))
28
+ # ONE semantic set, table-driven like `_colspec`/`_keytable`: both palettes are
29
+ # generated from this single spec so their attr names can never diverge.
30
+ # Body-content attrs use bg "default" (inherit the terminal's own background —
31
+ # the adaptive part); only the structural bands (header/footer/tabs/status/
32
+ # notify) and the selection keep an explicit bg. 256-color foregrounds stay
33
+ # ≥ 4.5:1 (WCAG-AA-ish, relative luminance) against the assumed backdrop —
34
+ # dark: near-black (#6d6/#d66 ≈ 5.5+, #aaa ≈ 8.1); light: near-white
35
+ # (#070 ≈ 5.7, #a00 ≈ 7.7, #666 ≈ 5.7, #850/#ddd ≈ 4.7).
36
+ _SPEC: list[tuple[str, str | None, tuple[str, str, str, str], tuple[str, str, str, str]]] = [
37
+ ("header", "bold",
38
+ ("white,bold", "black", "#fff,bold", "#111"),
39
+ ("black,bold", "light gray", "#000,bold", "#ddd")),
40
+ ("footer", None,
41
+ ("light gray", "black", "#aaa", "#111"),
42
+ ("dark gray", "light gray", "#555", "#ddd")),
43
+ ("tab_on", "bold,standout",
44
+ ("white,bold", "dark cyan", "#fff,bold", "#068"),
45
+ ("white,bold", "dark cyan", "#fff,bold", "#068")),
46
+ ("tab_off", None,
47
+ ("light cyan", "black", "#7ab", "#111"),
48
+ ("dark cyan", "light gray", "#067", "#ddd")),
49
+ ("alive", None,
50
+ ("light green", "default", "#6d6", "default"),
51
+ ("dark green", "default", "#070", "default")),
52
+ ("status_busy", "bold",
53
+ ("light green,bold", "default", "#6d6,bold", "default"),
54
+ ("dark green,bold", "default", "#070,bold", "default")),
55
+ ("status_err", None,
56
+ ("light red", "default", "#d66", "default"),
57
+ ("dark red", "default", "#a00", "default")),
58
+ ("dead", None,
59
+ ("light gray", "default", "#ccc", "default"),
60
+ ("dark gray", "default", "#666", "default")),
61
+ ("selected", "standout",
62
+ ("white,bold", "dark cyan", "#fff,bold", "#068"),
63
+ ("white,bold", "dark cyan", "#fff,bold", "#068")),
64
+ ("notify", "bold",
65
+ ("yellow,bold", "black", "#ff0,bold", "#111"),
66
+ ("brown,bold", "light gray", "#850,bold", "#ddd")),
67
+ ("status", None,
68
+ ("light gray", "black", "#bbb", "#111"),
69
+ ("dark gray", "light gray", "#444", "#ddd")),
70
+ ("body", None,
71
+ ("light gray", "default", "#ccc", "default"),
72
+ ("black", "default", "#222", "default")),
73
+ ("col_header", None,
74
+ ("dark cyan", "default", "#9cc", "default"),
75
+ ("dark cyan", "default", "#067", "default")),
76
+ ]
77
+
78
+
79
+ def palette(mode: Mode) -> list[tuple[str, str, str, str | None, str, str]]:
80
+ """The urwid 6-tuple palette (name, fg16, bg16, mono, fg256, bg256)."""
81
+ out = []
82
+ for name, mono, dark, light in _SPEC:
83
+ fg16, bg16, fg256, bg256 = light if mode == "light" else dark
84
+ out.append((name, fg16, bg16, mono, fg256, bg256))
85
+ return out
86
+
87
+
88
+ def detect_mode() -> Mode:
89
+ """`CSCTL_THEME` override → OSC 11 query → `$COLORFGBG` → dark."""
90
+ forced = cfg.theme.strip().lower()
91
+ if forced in ("dark", "light"):
92
+ return forced
93
+ rgb = _query_bg_rgb()
94
+ if rgb is not None:
95
+ return _mode_from_rgb(*rgb)
96
+ return _parse_colorfgbg(os.environ.get("COLORFGBG", "")) or "dark"
97
+
98
+
99
+ # Reply looks like `ESC]11;rgb:1111/1111/1111 BEL` (xterm also emits `rgba:`
100
+ # and 1-4 hex digits per channel; terminator is BEL or ST `ESC \`).
101
+ _OSC11_RGB = re.compile(r"rgba?:([0-9a-fA-F]{1,4})/([0-9a-fA-F]{1,4})/([0-9a-fA-F]{1,4})")
102
+ # OSC 11 query + DA1 (Primary Device Attributes) as a SENTINEL: terminals
103
+ # answer queries in order and virtually every terminal answers DA1, so its
104
+ # reply (`ESC[?...c`) means "the OSC 11 answer, if any, has already arrived".
105
+ # Terminals that ignore OSC 11 (tmux 3.7 without an explicit bg — measured
106
+ # 0.3ms — the common csctl case) return in one round-trip instead of eating
107
+ # the full timeout on EVERY startup. Reading through to the DA1 reply also
108
+ # keeps the tty buffer clean: no reply bytes are left over to leak into urwid
109
+ # as phantom keys.
110
+ _QUERY = b"\x1b]11;?\x07\x1b[c"
111
+ _DA1_REPLY = re.compile(r"\x1b\[\?[0-9;]*c")
112
+ # Hard cap only — with the sentinel, any real terminal answers in one RTT;
113
+ # this bounds fake ptys that answer neither query.
114
+ _REPLY_TIMEOUT = 0.25
115
+
116
+
117
+ def _parse_osc11_reply(buf: str) -> tuple[float, float, float] | None:
118
+ m = _OSC11_RGB.search(buf)
119
+ if not m:
120
+ return None
121
+
122
+ def chan(h: str) -> float:
123
+ return int(h, 16) / (16 ** len(h) - 1)
124
+
125
+ return chan(m.group(1)), chan(m.group(2)), chan(m.group(3))
126
+
127
+
128
+ def _mode_from_rgb(r: float, g: float, b: float) -> Mode:
129
+ lum = 0.2126 * r + 0.7152 * g + 0.0722 * b
130
+ return "light" if lum > 0.5 else "dark"
131
+
132
+
133
+ def _parse_colorfgbg(value: str) -> Mode | None:
134
+ """rxvt/konsole convention: "fg;bg" or "fg;default;bg".
135
+
136
+ vim's rule for the bg code: 0-6 and 8 mean a dark background."""
137
+ parts = value.split(";")
138
+ if len(parts) < 2:
139
+ return None
140
+ try:
141
+ bg = int(parts[-1])
142
+ except ValueError:
143
+ return None
144
+ return "dark" if bg in (0, 1, 2, 3, 4, 5, 6, 8) else "light"
145
+
146
+
147
+ def _query_bg_rgb(timeout: float = _REPLY_TIMEOUT) -> tuple[float, float, float] | None:
148
+ """Ask the terminal its background color via OSC 11; None on any failure."""
149
+ try:
150
+ if not (sys.stdin.isatty() and sys.stdout.isatty()):
151
+ return None
152
+ return _query_bg_rgb_on(sys.stdin.fileno(), sys.stdout.fileno(), timeout)
153
+ except Exception:
154
+ return None
155
+
156
+
157
+ def _query_bg_rgb_on(
158
+ in_fd: int, out_fd: int, timeout: float
159
+ ) -> tuple[float, float, float] | None:
160
+ """The fd-level query — separated so tests can drive a bare pty pair
161
+ (pytest's capture owns `sys.stdin`/`sys.stdout` during a test, so the
162
+ wrapper above cannot be exercised end-to-end). May raise; the wrapper
163
+ swallows."""
164
+ old = termios.tcgetattr(in_fd)
165
+ try:
166
+ # Raw enough to read the reply: no line buffering, no echo (the reply
167
+ # bytes must not be painted onto the screen). Set directly instead of
168
+ # via tty.setcbreak, whose ECHO handling changed across 3.12.x.
169
+ new = termios.tcgetattr(in_fd)
170
+ new[3] &= ~(termios.ICANON | termios.ECHO)
171
+ termios.tcsetattr(in_fd, termios.TCSANOW, new)
172
+ os.write(out_fd, _QUERY)
173
+ buf = ""
174
+ deadline = time.monotonic() + timeout
175
+ # Stop on the DA1 sentinel, NOT on the OSC terminator — see _QUERY.
176
+ while not _DA1_REPLY.search(buf):
177
+ remaining = deadline - time.monotonic()
178
+ if remaining <= 0:
179
+ break
180
+ ready, _, _ = select.select([in_fd], [], [], remaining)
181
+ if not ready:
182
+ break
183
+ buf += os.read(in_fd, 128).decode("ascii", "ignore")
184
+ return _parse_osc11_reply(buf)
185
+ finally:
186
+ try:
187
+ termios.tcsetattr(in_fd, termios.TCSADRAIN, old)
188
+ except Exception:
189
+ pass
@@ -179,8 +179,8 @@ class RCView(ListTabView):
179
179
  sections=("项目操作(仅对「项目」行生效):", "批量操作:"),
180
180
  suffix=(
181
181
  "目录缺失(✖ 缺失):",
182
- " 项目目录已删除,但 claude 的信任记录(~/.claude.json)或自启列表仍引用它。",
183
- " csctl 不改写 claude 的文件;用 a 键可将其移出自启列表,信任记录需手动清理。",
182
+ " 项目目录已删除,但自启列表仍引用它(或其远控服务还在跑);用 a 键移出自启列表。",
183
+ " 只剩 claude 信任记录(~/.claude.json)引用的已删项目不再显示;信任记录需手动清理。",
184
184
  "",
185
185
  "RC 服务(只读):",
186
186
  " 外部服务只展示,不接管、不重启。",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cc-session-control
3
- Version: 0.7.0
3
+ Version: 0.7.2
4
4
  Summary: TUI manager for Claude Code sessions and Remote Control
5
5
  Author: dzshzx
6
6
  License-Expression: MIT
@@ -104,6 +104,7 @@ csctl skill uninstall
104
104
 
105
105
  # Options
106
106
  csctl --workspace ~/projects # Override workspace root
107
+ csctl --theme light # Force the TUI palette (auto/dark/light)
107
108
  csctl --version
108
109
  ```
109
110
 
@@ -114,6 +115,7 @@ csctl --version
114
115
  | `CSCTL_WORKSPACE` | `~/workspace` | Workspace root directory |
115
116
  | `CSCTL_RC_SESSION` | `rc` | tmux session name for RC servers |
116
117
  | `CSCTL_RC_STAGGER` | `2` | Seconds between starting RC servers |
118
+ | `CSCTL_THEME` | `auto` | TUI palette: `auto` (detect the terminal background via OSC 11 / `$COLORFGBG`) / `dark` / `light`. tmux typically doesn't answer the OSC 11 query, so inside tmux `auto` falls back to `dark` — set this (or `--theme`) explicitly for a light terminal |
117
119
  | `XDG_CONFIG_HOME` | `~/.config` | Config directory base |
118
120
 
119
121
  RC auto-start list is stored at `$XDG_CONFIG_HOME/csctl/rc-enabled`.
@@ -8,6 +8,7 @@ src/cc_session_control/cli.py
8
8
  src/cc_session_control/clipboard.py
9
9
  src/cc_session_control/config.py
10
10
  src/cc_session_control/models.py
11
+ src/cc_session_control/theme.py
11
12
  src/cc_session_control.egg-info/PKG-INFO
12
13
  src/cc_session_control.egg-info/SOURCES.txt
13
14
  src/cc_session_control.egg-info/dependency_links.txt
@@ -57,4 +58,5 @@ tests/test_sessions.py
57
58
  tests/test_skill_ops.py
58
59
  tests/test_smoke.py
59
60
  tests/test_snapshot.py
61
+ tests/test_theme.py
60
62
  tests/test_views.py
@@ -96,3 +96,17 @@ def test_prune_sweep_zombies_refuses_without_proc(tmp_path, monkeypatch, capsys)
96
96
  out = capsys.readouterr().out
97
97
  assert "Refused" in out
98
98
  assert os.path.exists(os.path.join(sessions_dir, "1.json")) # nothing removed
99
+
100
+
101
+ def test_theme_flag_sets_cfg(monkeypatch):
102
+ monkeypatch.setattr(cfg, "theme", "auto")
103
+ args = cli._build_parser().parse_args(["--theme", "light"])
104
+ cli._apply_global_flags(args)
105
+ assert cfg.theme == "light"
106
+
107
+
108
+ def test_theme_flag_absent_keeps_cfg(monkeypatch):
109
+ monkeypatch.setattr(cfg, "theme", "auto")
110
+ args = cli._build_parser().parse_args([])
111
+ cli._apply_global_flags(args)
112
+ assert cfg.theme == "auto"
@@ -204,21 +204,27 @@ def test_scan_populates_spawn_mode(tmp_path, monkeypatch):
204
204
 
205
205
 
206
206
  def test_scan_marks_missing_directory(tmp_path, monkeypatch):
207
- """A trusted/enabled project whose workspace dir is gone stays listed
208
- (claude.json / rc-enabled still reference it) but gets dir_exists=False."""
207
+ """A missing-dir project stays listed (dir_exists=False) only while it is
208
+ still actionable in the autostart list or holding a tmux window. Pure
209
+ trust residue (only ~/.claude.json references the deleted dir) is dropped:
210
+ csctl can't act on it and never edits claude's files."""
209
211
  ws = tmp_path / "workspace"
210
212
  (ws / "alive").mkdir(parents=True)
211
213
  cj = _write_claude_json(tmp_path, {
212
214
  str(ws / "alive"): {"hasTrustDialogAccepted": True},
213
215
  str(ws / "deleted"): {"hasTrustDialogAccepted": True},
216
+ str(ws / "gone-running"): {"hasTrustDialogAccepted": True},
214
217
  })
215
218
  monkeypatch.setattr(rc.cfg, "claude_json", cj)
216
219
  monkeypatch.setattr(rc.cfg, "workspace", ws)
217
220
  monkeypatch.setattr(rc, "list_enabled", lambda: ["gone-enabled"])
218
- monkeypatch.setattr(rc, "_tmux_windows", lambda: [])
221
+ monkeypatch.setattr(rc, "_tmux_windows", lambda: ["gone-running"])
222
+ monkeypatch.setattr(rc, "_is_alive", lambda name: True)
219
223
 
220
224
  rows = {p.name: p for p in rc.scan()}
221
- assert set(rows) == {"alive", "deleted", "gone-enabled"}
225
+ assert set(rows) == {"alive", "gone-enabled", "gone-running"}
226
+ assert "deleted" not in rows # trust-only residue hidden
222
227
  assert rows["alive"].dir_exists is True
223
- assert rows["deleted"].dir_exists is False # stale trust entry
224
228
  assert rows["gone-enabled"].dir_exists is False # stale rc-enabled entry
229
+ assert rows["gone-running"].dir_exists is False # window survives dir removal
230
+ assert rows["gone-running"].status == "running"
@@ -0,0 +1,178 @@
1
+ """Adaptive terminal theme: palette generation + background detection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import threading
7
+ import time
8
+
9
+ import pytest
10
+
11
+ from cc_session_control import theme
12
+ from cc_session_control.config import cfg
13
+
14
+ # The ONE semantic attr set views reference (see views/*).
15
+ EXPECTED_NAMES = {
16
+ "header", "footer", "tab_on", "tab_off", "alive", "status_busy",
17
+ "status_err", "dead", "selected", "notify", "status", "body", "col_header",
18
+ }
19
+
20
+
21
+ def test_palettes_share_one_semantic_set() -> None:
22
+ dark = theme.palette("dark")
23
+ light = theme.palette("light")
24
+ assert {e[0] for e in dark} == EXPECTED_NAMES
25
+ assert [e[0] for e in dark] == [e[0] for e in light]
26
+
27
+
28
+ def test_palette_entries_are_urwid_6_tuples() -> None:
29
+ for entry in theme.palette("dark") + theme.palette("light"):
30
+ assert len(entry) == 6
31
+ name, fg16, bg16, _mono, fg256, bg256 = entry
32
+ for field in (fg16, bg16, fg256, bg256):
33
+ assert isinstance(field, str) and field, (name, field)
34
+
35
+
36
+ def test_unknown_mode_falls_back_to_dark() -> None:
37
+ assert theme.palette("weird") == theme.palette("dark")
38
+
39
+
40
+ @pytest.mark.parametrize(
41
+ ("rgb", "expected"),
42
+ [
43
+ ((1.0, 1.0, 1.0), "light"), # white
44
+ ((0.0, 0.0, 0.0), "dark"), # black
45
+ ((0.99, 0.96, 0.89), "light"), # solarized light #fdf6e3
46
+ ((0.0, 0.17, 0.21), "dark"), # solarized dark #002b36
47
+ ],
48
+ )
49
+ def test_mode_from_rgb(rgb: tuple[float, float, float], expected: str) -> None:
50
+ assert theme._mode_from_rgb(*rgb) == expected
51
+
52
+
53
+ @pytest.mark.parametrize(
54
+ ("reply", "expected"),
55
+ [
56
+ ("\x1b]11;rgb:ffff/ffff/ffff\x07", (1.0, 1.0, 1.0)),
57
+ ("\x1b]11;rgb:0000/0000/0000\x1b\\", (0.0, 0.0, 0.0)),
58
+ ("\x1b]11;rgb:ff/00/00\x07", (1.0, 0.0, 0.0)), # 2-digit channels
59
+ ("\x1b]11;rgba:ffff/ffff/ffff/ffff\x07", (1.0, 1.0, 1.0)),
60
+ ("", None),
61
+ ("\x1b]11;?\x07", None), # our own query echoed back
62
+ ("garbage without rgb", None),
63
+ ],
64
+ )
65
+ def test_parse_osc11_reply(reply: str, expected: tuple | None) -> None:
66
+ got = theme._parse_osc11_reply(reply)
67
+ if expected is None:
68
+ assert got is None
69
+ else:
70
+ assert got == pytest.approx(expected)
71
+
72
+
73
+ @pytest.mark.parametrize(
74
+ ("value", "expected"),
75
+ [
76
+ ("15;0", "dark"),
77
+ ("0;15", "light"),
78
+ ("12;8", "dark"),
79
+ ("0;default;15", "light"), # 3-field rxvt form: bg is the LAST field
80
+ ("0;7", "light"),
81
+ ("", None),
82
+ ("15", None), # single field — no bg
83
+ ("15;default", None), # non-numeric bg
84
+ ],
85
+ )
86
+ def test_parse_colorfgbg(value: str, expected: str | None) -> None:
87
+ assert theme._parse_colorfgbg(value) == expected
88
+
89
+
90
+ def test_detect_mode_env_override_skips_probing(monkeypatch: pytest.MonkeyPatch) -> None:
91
+ monkeypatch.setattr(cfg, "theme", "LIGHT")
92
+
93
+ def boom() -> None:
94
+ raise AssertionError("forced theme must not probe the tty")
95
+
96
+ monkeypatch.setattr(theme, "_query_bg_rgb", boom)
97
+ assert theme.detect_mode() == "light"
98
+
99
+
100
+ def test_detect_mode_uses_osc_reply(monkeypatch: pytest.MonkeyPatch) -> None:
101
+ monkeypatch.setattr(cfg, "theme", "auto")
102
+ monkeypatch.setattr(theme, "_query_bg_rgb", lambda: (1.0, 1.0, 1.0))
103
+ assert theme.detect_mode() == "light"
104
+
105
+
106
+ def test_detect_mode_colorfgbg_fallback(monkeypatch: pytest.MonkeyPatch) -> None:
107
+ monkeypatch.setattr(cfg, "theme", "auto")
108
+ monkeypatch.setattr(theme, "_query_bg_rgb", lambda: None)
109
+ monkeypatch.setenv("COLORFGBG", "0;15")
110
+ assert theme.detect_mode() == "light"
111
+
112
+
113
+ def test_detect_mode_defaults_dark(monkeypatch: pytest.MonkeyPatch) -> None:
114
+ monkeypatch.setattr(cfg, "theme", "auto")
115
+ monkeypatch.setattr(theme, "_query_bg_rgb", lambda: None)
116
+ monkeypatch.delenv("COLORFGBG", raising=False)
117
+ assert theme.detect_mode() == "dark"
118
+
119
+
120
+ def test_query_bg_rgb_refuses_non_tty(monkeypatch: pytest.MonkeyPatch) -> None:
121
+ class NotATty:
122
+ def isatty(self) -> bool:
123
+ return False
124
+
125
+ monkeypatch.setattr(theme.sys, "stdin", NotATty())
126
+ assert theme._query_bg_rgb() is None
127
+
128
+
129
+ # --- end-to-end against a real pty. Tests drive the fd-level
130
+ # `_query_bg_rgb_on` directly: pytest's capture plugin owns
131
+ # `sys.stdin`/`sys.stdout` during a test, so the isatty wrapper can't be
132
+ # exercised here (it is covered by the non-tty refusal test above). The fake
133
+ # terminal answers on the master side. ---
134
+
135
+
136
+ @pytest.fixture
137
+ def pty_pair():
138
+ master, slave = os.openpty()
139
+ yield master, slave
140
+ os.close(master)
141
+ os.close(slave)
142
+
143
+
144
+ def _answer(master: int, reply: bytes) -> None:
145
+ os.read(master, 64) # wait until the query arrives
146
+ os.write(master, reply)
147
+
148
+
149
+ def test_query_bg_rgb_end_to_end(pty_pair: tuple[int, int]) -> None:
150
+ master, slave = pty_pair
151
+ # xterm-style: OSC 11 answer, then the DA1 sentinel reply.
152
+ t = threading.Thread(
153
+ target=_answer, args=(master, b"\x1b]11;rgb:ffff/ffff/ffff\x07\x1b[?1;2c")
154
+ )
155
+ t.start()
156
+ got = theme._query_bg_rgb_on(slave, slave, timeout=5.0)
157
+ t.join()
158
+ assert got == pytest.approx((1.0, 1.0, 1.0))
159
+
160
+
161
+ def test_query_bg_rgb_da1_sentinel_short_circuits(pty_pair: tuple[int, int]) -> None:
162
+ master, slave = pty_pair
163
+ # tmux-style: OSC 11 ignored, only DA1 answered — must return on the
164
+ # sentinel immediately, NOT sit out the timeout (given generously here so
165
+ # a cap-hit fails loudly even on a slow CI box).
166
+ t = threading.Thread(target=_answer, args=(master, b"\x1b[?1;2;4c"))
167
+ t.start()
168
+ t0 = time.monotonic()
169
+ got = theme._query_bg_rgb_on(slave, slave, timeout=5.0)
170
+ elapsed = time.monotonic() - t0
171
+ t.join()
172
+ assert got is None
173
+ assert elapsed < 2.0
174
+
175
+
176
+ def test_query_bg_rgb_times_out_on_silent_pty(pty_pair: tuple[int, int]) -> None:
177
+ _master, slave = pty_pair
178
+ assert theme._query_bg_rgb_on(slave, slave, timeout=0.05) is None