ctally 0.2.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.
- ctally/__init__.py +3 -0
- ctally/__main__.py +5 -0
- ctally/claude_hooks.py +143 -0
- ctally/cli.py +180 -0
- ctally/focus.py +144 -0
- ctally/gui/__init__.py +0 -0
- ctally/gui/app.py +399 -0
- ctally/gui/mac.py +108 -0
- ctally/gui/settings.py +253 -0
- ctally/gui/view.py +858 -0
- ctally/hooks/ctally.sh +111 -0
- ctally/install.py +198 -0
- ctally/prefs.py +81 -0
- ctally/sessions.py +501 -0
- ctally/system.py +157 -0
- ctally/tmux_conf.py +100 -0
- ctally-0.2.0.dist-info/METADATA +322 -0
- ctally-0.2.0.dist-info/RECORD +22 -0
- ctally-0.2.0.dist-info/WHEEL +5 -0
- ctally-0.2.0.dist-info/entry_points.txt +2 -0
- ctally-0.2.0.dist-info/licenses/LICENSE +21 -0
- ctally-0.2.0.dist-info/top_level.txt +1 -0
ctally/__init__.py
ADDED
ctally/__main__.py
ADDED
ctally/claude_hooks.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Add CTally's hooks to Claude Code's settings, or take them out again.
|
|
2
|
+
|
|
3
|
+
The settings file (~/.claude/settings.json) is merged, never overwritten: other hooks and
|
|
4
|
+
settings are left as they are, and a timestamped backup is written before any change.
|
|
5
|
+
Earlier CTally hooks, and those from when it was called Claude Pet, are replaced rather
|
|
6
|
+
than duplicated, so running it twice is harmless.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import shutil
|
|
13
|
+
import tempfile
|
|
14
|
+
import time
|
|
15
|
+
from importlib import resources
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
SETTINGS = Path.home() / ".claude" / "settings.json"
|
|
19
|
+
SCRIPT = Path.home() / ".claude" / "hooks" / "ctally.sh"
|
|
20
|
+
HOOK = '"$HOME/.claude/hooks/ctally.sh"'
|
|
21
|
+
|
|
22
|
+
# Each event, and the state it reports. No "matcher": these events take none.
|
|
23
|
+
EVENTS = {
|
|
24
|
+
"UserPromptSubmit": "working", # a turn starts
|
|
25
|
+
"PreToolUse": "working", # a tool is about to run
|
|
26
|
+
"PostToolUse": "working", # back to work after a permission prompt
|
|
27
|
+
"Stop": "done", # the turn is over, unless agents still run behind it
|
|
28
|
+
"Notification": "waiting", # a permission prompt, or idle waiting for input
|
|
29
|
+
"SessionEnd": "end", # the session is gone
|
|
30
|
+
"SubagentStart": "agent-start", # a subagent, maybe one of a workflow's, sets off
|
|
31
|
+
"SubagentStop": "agent-stop", # and comes back
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
# "claude-pet" catches the hooks from before the rename, inline or scripted.
|
|
35
|
+
MARKERS = ("ctally", "claude-pet")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class SettingsError(Exception):
|
|
39
|
+
"""The settings file can't be read or isn't what we expect; it was left alone."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def is_ours(hook: dict) -> bool:
|
|
43
|
+
return any(marker in hook.get("command", "") for marker in MARKERS)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def configure(settings: dict, install: bool) -> dict:
|
|
47
|
+
"""Settings with every CTally hook removed, then (if installing) added back."""
|
|
48
|
+
settings = json.loads(json.dumps(settings)) # work on a copy
|
|
49
|
+
hooks = settings.get("hooks", {})
|
|
50
|
+
for event in list(hooks):
|
|
51
|
+
kept = []
|
|
52
|
+
for group in hooks[event]:
|
|
53
|
+
# Only our hooks go: others sharing a group with them (merged in by hand) stay.
|
|
54
|
+
theirs = [hook for hook in group.get("hooks", []) if not is_ours(hook)]
|
|
55
|
+
if len(theirs) == len(group.get("hooks", [])):
|
|
56
|
+
kept.append(group)
|
|
57
|
+
elif theirs:
|
|
58
|
+
kept.append({**group, "hooks": theirs})
|
|
59
|
+
if kept:
|
|
60
|
+
hooks[event] = kept
|
|
61
|
+
else:
|
|
62
|
+
del hooks[event]
|
|
63
|
+
if install:
|
|
64
|
+
for event, state in EVENTS.items():
|
|
65
|
+
hooks.setdefault(event, []).append(
|
|
66
|
+
{"hooks": [{"type": "command", "command": f"{HOOK} {state}"}]})
|
|
67
|
+
if hooks:
|
|
68
|
+
settings["hooks"] = hooks
|
|
69
|
+
else:
|
|
70
|
+
settings.pop("hooks", None)
|
|
71
|
+
return settings
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def update(install: bool, path: Path = SETTINGS) -> str:
|
|
75
|
+
"""Installs or removes the hooks in a settings file; returns what happened."""
|
|
76
|
+
settings = {}
|
|
77
|
+
# Only a file that is really not there counts as empty. One that merely can't be seen (no
|
|
78
|
+
# permission, or a sandbox hiding it) would otherwise be replaced by just our hooks.
|
|
79
|
+
try:
|
|
80
|
+
text = path.read_text(encoding="utf-8")
|
|
81
|
+
exists = True
|
|
82
|
+
except FileNotFoundError:
|
|
83
|
+
exists = False
|
|
84
|
+
except OSError as error:
|
|
85
|
+
raise SettingsError(f"Can't read {path} ({error.strerror}); left it alone.") from error
|
|
86
|
+
if exists:
|
|
87
|
+
try:
|
|
88
|
+
settings = json.loads(text) if text.strip() else {}
|
|
89
|
+
except json.JSONDecodeError as error:
|
|
90
|
+
raise SettingsError(f"{path} is not valid JSON ({error}); left it alone.") from error
|
|
91
|
+
if not isinstance(settings, dict):
|
|
92
|
+
raise SettingsError(f"{path} does not hold a JSON object; left it alone.")
|
|
93
|
+
|
|
94
|
+
updated = configure(settings, install)
|
|
95
|
+
if updated == settings and exists:
|
|
96
|
+
return f"Claude Code hooks already {'installed' if install else 'removed'} in {path}"
|
|
97
|
+
|
|
98
|
+
notes = []
|
|
99
|
+
if exists:
|
|
100
|
+
notes.append(f"Backed up {path} to {backup(path)}")
|
|
101
|
+
# A symlink (into a dotfiles checkout, say) is written through, not replaced by a file.
|
|
102
|
+
target = Path(os.path.realpath(path))
|
|
103
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
104
|
+
# Write beside the file, then swap it in, so a crash can't leave half a settings file.
|
|
105
|
+
fd, temp = tempfile.mkstemp(dir=target.parent, prefix=".settings.", suffix=".json")
|
|
106
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
107
|
+
json.dump(updated, f, indent=2, ensure_ascii=False)
|
|
108
|
+
f.write("\n")
|
|
109
|
+
if target.exists():
|
|
110
|
+
shutil.copymode(target, temp)
|
|
111
|
+
os.replace(temp, target)
|
|
112
|
+
notes.append(f"Claude Code hooks {'installed in' if install else 'removed from'} {path}")
|
|
113
|
+
return "\n".join(notes)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def backup(path: Path) -> Path:
|
|
117
|
+
"""Copies a file aside, never over an earlier backup, even one made the same second."""
|
|
118
|
+
stamp = time.strftime("%Y%m%d-%H%M%S")
|
|
119
|
+
copy, n = Path(f"{path}.bak.{stamp}"), 1
|
|
120
|
+
while copy.exists():
|
|
121
|
+
n += 1
|
|
122
|
+
copy = Path(f"{path}.bak.{stamp}-{n}")
|
|
123
|
+
shutil.copy2(path, copy)
|
|
124
|
+
return copy
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def install_script() -> None:
|
|
128
|
+
"""Puts the hook script where the hooks call it."""
|
|
129
|
+
SCRIPT.parent.mkdir(parents=True, exist_ok=True)
|
|
130
|
+
source = resources.files("ctally").joinpath("hooks/ctally.sh").read_bytes()
|
|
131
|
+
temp = SCRIPT.with_name(".ctally.sh.new")
|
|
132
|
+
temp.write_bytes(source)
|
|
133
|
+
temp.chmod(0o755)
|
|
134
|
+
os.replace(temp, SCRIPT)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def remove_scripts() -> None:
|
|
138
|
+
for name in ("ctally.sh", "claude-pet.sh"):
|
|
139
|
+
(SCRIPT.parent / name).unlink(missing_ok=True)
|
|
140
|
+
try:
|
|
141
|
+
SCRIPT.parent.rmdir() # only if nothing else lives there
|
|
142
|
+
except OSError:
|
|
143
|
+
pass
|
ctally/cli.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""ctally: CTally from the shell and from tmux.
|
|
2
|
+
|
|
3
|
+
ctally run start the indicator (setup arranges this at login)
|
|
4
|
+
ctally setup [--tmux] install the Claude Code hooks, start CTally at login, and start it
|
|
5
|
+
now; --tmux also binds prefix + J and puts the counts in tmux's
|
|
6
|
+
status line
|
|
7
|
+
ctally uninstall undo all of that (then: pipx uninstall ctally)
|
|
8
|
+
|
|
9
|
+
ctally status [--tmux] counts of live sessions by state, most urgent first: "!1 ▶2 ✓5 ·1"
|
|
10
|
+
(waiting, working, done, idle); --tmux adds status-line colours
|
|
11
|
+
ctally list one line per live session: state, tmux pane, subagents running,
|
|
12
|
+
project, id
|
|
13
|
+
ctally jump [pane client socket]
|
|
14
|
+
switch a tmux client to the session that needs you: waiting first,
|
|
15
|
+
then finished, longest-waiting first. Run it again to move on to
|
|
16
|
+
the next one. A key binding passes '#{pane_id}' '#{client_name}'
|
|
17
|
+
'#{socket_path}'.
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import argparse
|
|
22
|
+
import os
|
|
23
|
+
import sys
|
|
24
|
+
from typing import NamedTuple
|
|
25
|
+
|
|
26
|
+
from . import __version__, system
|
|
27
|
+
from .sessions import STATE_DIR
|
|
28
|
+
|
|
29
|
+
GLYPH = {"waiting": "!", "working": "▶", "done": "✓", "idle": "·"}
|
|
30
|
+
COLOUR = {"waiting": "#ffb329", "working": "#4accf2", "done": "#45e087", "idle": "#99a6bd"}
|
|
31
|
+
RANK = {"waiting": 0, "done": 1, "working": 2, "idle": 3}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Live(NamedTuple):
|
|
35
|
+
rank: int
|
|
36
|
+
modified: float
|
|
37
|
+
state: str
|
|
38
|
+
pid: int
|
|
39
|
+
id: str
|
|
40
|
+
project: str
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def live_sessions() -> list[Live]:
|
|
44
|
+
"""Each live session, most urgent first and, within a state, longest-waiting first.
|
|
45
|
+
Sessions whose claude process is gone are skipped."""
|
|
46
|
+
found = []
|
|
47
|
+
try:
|
|
48
|
+
names = [n for n in os.listdir(STATE_DIR) if not n.startswith(".")]
|
|
49
|
+
except OSError:
|
|
50
|
+
names = []
|
|
51
|
+
for name in names:
|
|
52
|
+
path = STATE_DIR / name
|
|
53
|
+
try:
|
|
54
|
+
fields = path.read_text(errors="replace").split()
|
|
55
|
+
modified = path.stat().st_mtime
|
|
56
|
+
except OSError:
|
|
57
|
+
continue
|
|
58
|
+
if len(fields) < 2 or not fields[1].isdigit():
|
|
59
|
+
continue
|
|
60
|
+
pid = int(fields[1])
|
|
61
|
+
if not system.is_alive(pid):
|
|
62
|
+
continue
|
|
63
|
+
state = fields[0] if fields[0] in RANK else "idle"
|
|
64
|
+
found.append(Live(RANK[state], modified, state, pid, name, " ".join(fields[2:]) or "?"))
|
|
65
|
+
return sorted(found)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def agents_running(sid: str) -> int:
|
|
69
|
+
try:
|
|
70
|
+
return sum(1 for n in os.listdir(STATE_DIR / ".agents" / sid) if not n.startswith("."))
|
|
71
|
+
except OSError:
|
|
72
|
+
return 0
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def status(tmux: bool) -> None:
|
|
76
|
+
counts: dict[str, int] = {}
|
|
77
|
+
for session in live_sessions():
|
|
78
|
+
counts[session.state] = counts.get(session.state, 0) + 1
|
|
79
|
+
items = []
|
|
80
|
+
for state in ("waiting", "working", "done", "idle"):
|
|
81
|
+
if counts.get(state):
|
|
82
|
+
item = f"{GLYPH[state]}{counts[state]}"
|
|
83
|
+
items.append(f"#[fg={COLOUR[state]}]{item}" if tmux else item)
|
|
84
|
+
out = " ".join(items)
|
|
85
|
+
print(out + "#[default]" if tmux and out else out)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def list_sessions() -> None:
|
|
89
|
+
for session in live_sessions():
|
|
90
|
+
target = system.tmux_pane(session.pid)
|
|
91
|
+
location = (system.tmux_location(target) if target else None) or "-"
|
|
92
|
+
n = agents_running(session.id)
|
|
93
|
+
agents = "-" if n == 0 else "1 agent" if n == 1 else f"{n} agents"
|
|
94
|
+
print(f"{session.state:<8} {location:<16} {agents:<9} {session.project:<24} {session.id}")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def jump(current: str | None, client: str | None, here: str | None) -> int:
|
|
98
|
+
current = current or os.environ.get("TMUX_PANE", "")
|
|
99
|
+
here = here or os.environ.get("TMUX", "").split(",", 1)[0]
|
|
100
|
+
binary = system.tmux_binary() or "tmux"
|
|
101
|
+
|
|
102
|
+
def say(message: str) -> None:
|
|
103
|
+
if client and here:
|
|
104
|
+
system.run([binary, "-S", here, "display-message", "-c", client, message])
|
|
105
|
+
else:
|
|
106
|
+
print(message)
|
|
107
|
+
|
|
108
|
+
# Sessions that need me, in tmux, in order.
|
|
109
|
+
queue = []
|
|
110
|
+
for session in live_sessions():
|
|
111
|
+
if session.rank > 1:
|
|
112
|
+
continue
|
|
113
|
+
target = system.tmux_pane(session.pid)
|
|
114
|
+
if target:
|
|
115
|
+
queue.append((target, session))
|
|
116
|
+
if not queue:
|
|
117
|
+
say("CTally: nothing needs you")
|
|
118
|
+
return 0
|
|
119
|
+
|
|
120
|
+
# The one after the pane I'm on, so pressing again moves along the queue.
|
|
121
|
+
at = next((i for i, (target, _) in enumerate(queue) if target.pane == current), None)
|
|
122
|
+
target, session = queue[at + 1] if at is not None and at + 1 < len(queue) else queue[0]
|
|
123
|
+
|
|
124
|
+
if not here:
|
|
125
|
+
print(f"ctally jump: run it inside tmux (or bind it to a key); next up is "
|
|
126
|
+
f"{session.project} ({session.state})", file=sys.stderr)
|
|
127
|
+
return 1
|
|
128
|
+
if target.socket != here:
|
|
129
|
+
say(f"CTally: {session.project} is on another tmux server")
|
|
130
|
+
return 1
|
|
131
|
+
switch = [binary, "-S", target.socket, "switch-client"] + (["-c", client] if client else []) + ["-t", target.pane]
|
|
132
|
+
if system.run(switch) is None:
|
|
133
|
+
say(f"CTally: couldn't switch to {session.project}")
|
|
134
|
+
return 1
|
|
135
|
+
say(f"CTally: {session.project} · {session.state}")
|
|
136
|
+
return 0
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def main(argv: list[str] | None = None) -> int:
|
|
140
|
+
parser = argparse.ArgumentParser(prog="ctally", description=__doc__.split("\n")[0],
|
|
141
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
142
|
+
epilog="\n".join(__doc__.split("\n")[2:]))
|
|
143
|
+
parser.add_argument("--version", action="version", version=f"ctally {__version__}")
|
|
144
|
+
commands = parser.add_subparsers(dest="command", metavar="command")
|
|
145
|
+
commands.add_parser("run", help="start the indicator")
|
|
146
|
+
setup = commands.add_parser("setup", help="install hooks, start at login, start now")
|
|
147
|
+
setup.add_argument("--tmux", action="store_true", help="also bind prefix + J and add tmux status-line counts")
|
|
148
|
+
setup.add_argument("--no-hooks", action="store_true", help="leave ~/.claude/settings.json alone")
|
|
149
|
+
setup.add_argument("--no-autostart", action="store_true", help="don't start CTally at login")
|
|
150
|
+
setup.add_argument("--no-launch", action="store_true", help="don't start CTally now")
|
|
151
|
+
commands.add_parser("uninstall", help="remove hooks, tmux setup, autostart and state")
|
|
152
|
+
status_ = commands.add_parser("status", help="counts by state")
|
|
153
|
+
status_.add_argument("--tmux", action="store_true", help="with tmux status-line colours")
|
|
154
|
+
commands.add_parser("list", help="one line per live session")
|
|
155
|
+
jump_ = commands.add_parser("jump", help="switch tmux to the session that needs you")
|
|
156
|
+
jump_.add_argument("pane", nargs="?")
|
|
157
|
+
jump_.add_argument("client", nargs="?")
|
|
158
|
+
jump_.add_argument("socket", nargs="?")
|
|
159
|
+
args = parser.parse_args(argv)
|
|
160
|
+
|
|
161
|
+
if args.command == "run":
|
|
162
|
+
from .gui.app import run # Qt loads only for the indicator
|
|
163
|
+
return run()
|
|
164
|
+
if args.command == "setup":
|
|
165
|
+
from . import install
|
|
166
|
+
return install.setup(tmux=args.tmux, hooks=not args.no_hooks,
|
|
167
|
+
autostart=not args.no_autostart, launch=not args.no_launch)
|
|
168
|
+
if args.command == "uninstall":
|
|
169
|
+
from . import install
|
|
170
|
+
return install.uninstall()
|
|
171
|
+
if args.command == "status":
|
|
172
|
+
status(args.tmux)
|
|
173
|
+
return 0
|
|
174
|
+
if args.command == "list":
|
|
175
|
+
list_sessions()
|
|
176
|
+
return 0
|
|
177
|
+
if args.command == "jump":
|
|
178
|
+
return jump(args.pane, args.client, args.socket)
|
|
179
|
+
parser.print_help()
|
|
180
|
+
return 2
|
ctally/focus.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Brings a session's terminal to the front.
|
|
2
|
+
|
|
3
|
+
Inside tmux, that means switching tmux to the session's pane, then raising the terminal
|
|
4
|
+
attached to that tmux session (or opening one if none is). Outside tmux, it means raising the
|
|
5
|
+
terminal the session runs in: on macOS the exact Terminal tab, or the app for anything else
|
|
6
|
+
(iTerm2, VS Code, the Claude desktop app); on Linux the terminal's window, where the desktop
|
|
7
|
+
allows it (X11, with xdotool installed; Wayland lets no app raise another's window).
|
|
8
|
+
|
|
9
|
+
All of this shells out, so it runs off the main thread, and it stays silent when anything
|
|
10
|
+
along the way is missing.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import shlex
|
|
15
|
+
import shutil
|
|
16
|
+
import subprocess
|
|
17
|
+
import threading
|
|
18
|
+
|
|
19
|
+
from . import system
|
|
20
|
+
|
|
21
|
+
TERMINAL = "com.apple.Terminal"
|
|
22
|
+
|
|
23
|
+
# Picks the Terminal tab whose tty matches, and puts its window in front.
|
|
24
|
+
RAISE_TAB = """
|
|
25
|
+
on run argv
|
|
26
|
+
set target to item 1 of argv
|
|
27
|
+
tell application "Terminal"
|
|
28
|
+
repeat with w in windows
|
|
29
|
+
repeat with t in tabs of w
|
|
30
|
+
if tty of t is target then
|
|
31
|
+
set miniaturized of w to false
|
|
32
|
+
set selected tab of w to t
|
|
33
|
+
set index of w to 1
|
|
34
|
+
activate
|
|
35
|
+
return
|
|
36
|
+
end if
|
|
37
|
+
end repeat
|
|
38
|
+
end repeat
|
|
39
|
+
end tell
|
|
40
|
+
end run
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def focus(pid: int) -> None:
|
|
45
|
+
if pid > 0:
|
|
46
|
+
threading.Thread(target=_focus_now, args=(pid,), daemon=True).start()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _focus_now(pid: int) -> None:
|
|
50
|
+
target = system.tmux_pane(pid)
|
|
51
|
+
if target:
|
|
52
|
+
_focus_tmux(target)
|
|
53
|
+
else:
|
|
54
|
+
_raise(system.tty(pid), pid)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _focus_tmux(target: system.TmuxPane) -> None:
|
|
58
|
+
def tmux(*args: str) -> str | None:
|
|
59
|
+
return system.run([target.binary, "-S", target.socket, *args])
|
|
60
|
+
|
|
61
|
+
tmux("select-window", "-t", target.pane)
|
|
62
|
+
tmux("select-pane", "-t", target.pane)
|
|
63
|
+
session = (tmux("display-message", "-p", "-t", target.pane, "#{session_name}") or "").strip()
|
|
64
|
+
if not session:
|
|
65
|
+
return
|
|
66
|
+
|
|
67
|
+
# The terminal attached to that tmux session that I used most recently.
|
|
68
|
+
clients = []
|
|
69
|
+
for line in (tmux("list-clients", "-t", "=" + session,
|
|
70
|
+
"-F", "#{client_activity} #{client_pid} #{client_tty}") or "").splitlines():
|
|
71
|
+
fields = line.split(" ", 2)
|
|
72
|
+
if len(fields) == 3 and fields[0].isdigit() and fields[1].isdigit():
|
|
73
|
+
clients.append((int(fields[0]), int(fields[1]), fields[2]))
|
|
74
|
+
if clients:
|
|
75
|
+
_, client_pid, client_tty = max(clients)
|
|
76
|
+
_raise(client_tty, client_pid)
|
|
77
|
+
return
|
|
78
|
+
|
|
79
|
+
# Nobody is looking at that session: open a terminal onto it.
|
|
80
|
+
attach = shlex.join([target.binary, "-S", target.socket, "attach-session", "-t", "=" + session])
|
|
81
|
+
if system.MAC:
|
|
82
|
+
quoted = attach.replace("\\", "\\\\").replace('"', '\\"')
|
|
83
|
+
system.run(["osascript", "-e", 'tell application "Terminal"', "-e", f'do script "{quoted}"',
|
|
84
|
+
"-e", "activate", "-e", "end tell"])
|
|
85
|
+
elif shutil.which("x-terminal-emulator"):
|
|
86
|
+
subprocess.Popen(["x-terminal-emulator", "-e", "sh", "-c", attach], start_new_session=True,
|
|
87
|
+
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _raise(tty: str | None, pid: int) -> None:
|
|
91
|
+
if system.MAC:
|
|
92
|
+
_raise_mac(tty, pid)
|
|
93
|
+
elif system.LINUX:
|
|
94
|
+
_raise_x11(pid)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _raise_mac(tty: str | None, pid: int) -> None:
|
|
98
|
+
app = _host_app(pid)
|
|
99
|
+
if app is None:
|
|
100
|
+
return
|
|
101
|
+
if tty and app.bundleIdentifier() == TERMINAL:
|
|
102
|
+
system.run(["osascript", "-e", RAISE_TAB, tty])
|
|
103
|
+
return
|
|
104
|
+
# The way the Dock does it: since macOS 14 an app can't push itself past the active one,
|
|
105
|
+
# and CTally is never active, so asking the app directly may be ignored.
|
|
106
|
+
from AppKit import NSWorkspace, NSWorkspaceOpenConfiguration
|
|
107
|
+
url = app.bundleURL()
|
|
108
|
+
if url is None:
|
|
109
|
+
app.activateWithOptions_(0)
|
|
110
|
+
return
|
|
111
|
+
configuration = NSWorkspaceOpenConfiguration.configuration()
|
|
112
|
+
configuration.setActivates_(True)
|
|
113
|
+
NSWorkspace.sharedWorkspace().openApplicationAtURL_configuration_completionHandler_(url, configuration, None)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _host_app(pid: int):
|
|
117
|
+
"""The nearest ancestor that is an ordinary app: Terminal, for a shell in one of its tabs."""
|
|
118
|
+
from AppKit import NSApplicationActivationPolicyRegular, NSRunningApplication
|
|
119
|
+
current = pid
|
|
120
|
+
for _ in range(32):
|
|
121
|
+
app = NSRunningApplication.runningApplicationWithProcessIdentifier_(current)
|
|
122
|
+
if app is not None and app.activationPolicy() == NSApplicationActivationPolicyRegular:
|
|
123
|
+
return app
|
|
124
|
+
up = system.parent(current)
|
|
125
|
+
if not up or up <= 1 or up == current:
|
|
126
|
+
return None
|
|
127
|
+
current = up
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _raise_x11(pid: int) -> None:
|
|
132
|
+
"""Activates the window of the nearest ancestor that has one: the terminal emulator."""
|
|
133
|
+
if not shutil.which("xdotool"):
|
|
134
|
+
return
|
|
135
|
+
current = pid
|
|
136
|
+
for _ in range(32):
|
|
137
|
+
windows = (system.run(["xdotool", "search", "--pid", str(current)]) or "").split()
|
|
138
|
+
if windows:
|
|
139
|
+
system.run(["xdotool", "windowactivate", windows[-1]])
|
|
140
|
+
return
|
|
141
|
+
up = system.parent(current)
|
|
142
|
+
if not up or up <= 1 or up == current:
|
|
143
|
+
return
|
|
144
|
+
current = up
|
ctally/gui/__init__.py
ADDED
|
File without changes
|