mtmux 0.1.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.
- mtmux/__init__.py +1 -0
- mtmux/__main__.py +112 -0
- mtmux/cockpit.py +356 -0
- mtmux/config.py +115 -0
- mtmux/discovery.py +458 -0
- mtmux/names.py +73 -0
- mtmux/sessions.py +73 -0
- mtmux/sidebar.py +1730 -0
- mtmux/tmux.py +25 -0
- mtmux-0.1.0.dist-info/METADATA +5 -0
- mtmux-0.1.0.dist-info/RECORD +14 -0
- mtmux-0.1.0.dist-info/WHEEL +5 -0
- mtmux-0.1.0.dist-info/entry_points.txt +2 -0
- mtmux-0.1.0.dist-info/top_level.txt +1 -0
mtmux/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
mtmux/__main__.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from . import cockpit, sessions
|
|
9
|
+
from .config import ensure_config, load_sessions
|
|
10
|
+
from .discovery import discover
|
|
11
|
+
from .names import Target, parse_target
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
15
|
+
parser = argparse.ArgumentParser(prog="mtmux")
|
|
16
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
17
|
+
|
|
18
|
+
sub.add_parser("cockpit", help="launch or attach cockpit")
|
|
19
|
+
focus_sidebar = sub.add_parser("focus-sidebar", help="focus/open cockpit sidebar")
|
|
20
|
+
focus_sidebar.add_argument("region", nargs="?", choices=("sessions", "agents", "add"), default="sessions")
|
|
21
|
+
sub.add_parser("init", help="create missing config files")
|
|
22
|
+
sub.add_parser("list", help="list discovered targets")
|
|
23
|
+
|
|
24
|
+
switch = sub.add_parser("switch", help="switch cockpit target")
|
|
25
|
+
switch.add_argument("target")
|
|
26
|
+
|
|
27
|
+
switch_star = sub.add_parser("switch-session", help="switch to numbered tracked target")
|
|
28
|
+
switch_star.add_argument("slot", type=int, choices=range(1, 10))
|
|
29
|
+
|
|
30
|
+
kill_parser = sub.add_parser("kill", help="kill target tmux session")
|
|
31
|
+
kill_parser.add_argument("target")
|
|
32
|
+
|
|
33
|
+
create = sub.add_parser("create", help="create target then switch")
|
|
34
|
+
create_sub = create.add_subparsers(dest="create_kind", required=True)
|
|
35
|
+
local = create_sub.add_parser("local", help="create local tmux session")
|
|
36
|
+
local.add_argument("session")
|
|
37
|
+
ssh = create_sub.add_parser("ssh", help="create remote tmux session")
|
|
38
|
+
ssh.add_argument("host")
|
|
39
|
+
ssh.add_argument("session")
|
|
40
|
+
return parser
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def main(argv: list[str] | None = None) -> int:
|
|
44
|
+
argv = sys.argv[1:] if argv is None else argv
|
|
45
|
+
if argv[:1] == ["sidebar"]:
|
|
46
|
+
from .sidebar import main as sidebar_main
|
|
47
|
+
return sidebar_main()
|
|
48
|
+
args = build_parser().parse_args(argv)
|
|
49
|
+
if args.command == "init":
|
|
50
|
+
cfg, wrapper = ensure_config()
|
|
51
|
+
print(f"Config: {cfg}")
|
|
52
|
+
print(f"Wrapper: {wrapper}")
|
|
53
|
+
return 0
|
|
54
|
+
if args.command == "cockpit":
|
|
55
|
+
return cockpit.cockpit()
|
|
56
|
+
if args.command == "focus-sidebar":
|
|
57
|
+
return cockpit.focus_sidebar(args.region)
|
|
58
|
+
if args.command == "list":
|
|
59
|
+
snapshot = discover()
|
|
60
|
+
if not snapshot.local.available:
|
|
61
|
+
print(f"local unavailable: {snapshot.local.error or 'unknown error'}")
|
|
62
|
+
else:
|
|
63
|
+
for target in snapshot.local.sessions:
|
|
64
|
+
print(target.format())
|
|
65
|
+
for host, source in snapshot.remotes.items():
|
|
66
|
+
if not source or not source.available:
|
|
67
|
+
print(f"ssh:{host} unavailable")
|
|
68
|
+
else:
|
|
69
|
+
for target in source.sessions:
|
|
70
|
+
print(target.format())
|
|
71
|
+
return 0
|
|
72
|
+
if args.command == "switch":
|
|
73
|
+
target = parse_target(args.target)
|
|
74
|
+
cockpit.switch(target, sessions.attach_command(target))
|
|
75
|
+
return 0
|
|
76
|
+
if args.command == "switch-session":
|
|
77
|
+
favorites = load_sessions()
|
|
78
|
+
if args.slot > len(favorites):
|
|
79
|
+
raise SystemExit(f"No session in slot {args.slot}")
|
|
80
|
+
target = favorites[args.slot - 1]
|
|
81
|
+
cockpit.switch(target, sessions.attach_command(target))
|
|
82
|
+
return 0
|
|
83
|
+
if args.command == "kill":
|
|
84
|
+
sessions.kill(parse_target(args.target))
|
|
85
|
+
return 0
|
|
86
|
+
if args.command == "create":
|
|
87
|
+
target = Target("local", args.session) if args.create_kind == "local" else Target("ssh", args.session, args.host)
|
|
88
|
+
sessions.create(target)
|
|
89
|
+
cockpit.switch(target, sessions.attach_command(target))
|
|
90
|
+
return 0
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def run_cli(argv: list[str] | None = None) -> int:
|
|
94
|
+
try:
|
|
95
|
+
return main(argv)
|
|
96
|
+
except subprocess.CalledProcessError as error:
|
|
97
|
+
command = Path(str(error.cmd[0] if isinstance(error.cmd, (list, tuple)) else error.cmd)).name
|
|
98
|
+
reason = (error.stderr or error.stdout or "").strip() or f"exit status {error.returncode}"
|
|
99
|
+
print(f"mtmux: {command} failed: {reason}", file=sys.stderr)
|
|
100
|
+
except OSError as error:
|
|
101
|
+
reason = error.strerror or str(error)
|
|
102
|
+
detail = f"{error.filename}: {reason}" if error.filename else reason
|
|
103
|
+
print(f"mtmux: {detail}", file=sys.stderr)
|
|
104
|
+
except UnicodeError as error:
|
|
105
|
+
print(f"mtmux: text decoding failed: {error}", file=sys.stderr)
|
|
106
|
+
except subprocess.SubprocessError as error:
|
|
107
|
+
print(f"mtmux: subprocess failed: {error}", file=sys.stderr)
|
|
108
|
+
return 1
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
if __name__ == "__main__":
|
|
112
|
+
raise SystemExit(run_cli())
|
mtmux/cockpit.py
ADDED
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import shlex
|
|
6
|
+
import shutil
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
from .config import ensure_config, load_prefix, load_sidebar_width
|
|
10
|
+
from .names import Target, parse_target
|
|
11
|
+
from . import tmux
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _truecolor_enabled() -> bool:
|
|
15
|
+
"""Detect whether the host terminal supports truecolor (24-bit color)."""
|
|
16
|
+
colorterm = os.environ.get("COLORTERM", "").lower()
|
|
17
|
+
return colorterm in ("truecolor", "24bit")
|
|
18
|
+
|
|
19
|
+
def help_command(prefix: str) -> str:
|
|
20
|
+
text = f"""mtmux cockpit
|
|
21
|
+
|
|
22
|
+
Navigation
|
|
23
|
+
{prefix} a focus/open Agents
|
|
24
|
+
{prefix} s focus/open Sessions
|
|
25
|
+
{prefix} + add session
|
|
26
|
+
{prefix} h hide sidebar
|
|
27
|
+
{prefix} q quit cockpit
|
|
28
|
+
{prefix} 1-9 switch session
|
|
29
|
+
{prefix} ? open help
|
|
30
|
+
? open help from sidebar
|
|
31
|
+
q quit sidebar only
|
|
32
|
+
|
|
33
|
+
Session actions
|
|
34
|
+
Enter switch session / select Add choice
|
|
35
|
+
a open Add session menu
|
|
36
|
+
r remove selected session (session keeps running)
|
|
37
|
+
K/J move session up/down
|
|
38
|
+
x kill selected session
|
|
39
|
+
/ search untracked existing sessions
|
|
40
|
+
|
|
41
|
+
Agent actions
|
|
42
|
+
j/k navigate agents
|
|
43
|
+
Enter switch to agent pane
|
|
44
|
+
h/l cycle agent ordering (Priority / Session)
|
|
45
|
+
[ / ] resize agent panel
|
|
46
|
+
|
|
47
|
+
Recovery
|
|
48
|
+
{prefix} d detach cockpit
|
|
49
|
+
{prefix} s restart/focus Sessions
|
|
50
|
+
Esc cancel prompts/filter
|
|
51
|
+
|
|
52
|
+
Examples
|
|
53
|
+
/work find available sessions matching work
|
|
54
|
+
Enter recreate missing session or activate selected Add row
|
|
55
|
+
"""
|
|
56
|
+
return f"printf %s {shlex.quote(text)}; exec tail -f /dev/null"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
SIDEBAR = f"{shlex.quote(sys.executable)} -m mtmux sidebar"
|
|
60
|
+
FOCUS_SIDEBAR = f"{shlex.quote(sys.executable)} -m mtmux focus-sidebar"
|
|
61
|
+
TARGET = f"{tmux.SESSION}:{tmux.WINDOW}"
|
|
62
|
+
COCKPIT_OPTION = "@mtmux_cockpit"
|
|
63
|
+
SIDEBAR_PANE_OPTION = "@mtmux_sidebar_pane"
|
|
64
|
+
SIDEBAR_WIDTH_OPTION = "@mtmux_sidebar_width"
|
|
65
|
+
RIGHT_PANE_OPTION = "@mtmux_right_pane"
|
|
66
|
+
CURRENT_TARGET_OPTION = "@mtmux_current_target"
|
|
67
|
+
CURRENT_AGENT_OPTION = "@mtmux_current_agent"
|
|
68
|
+
BELL_TARGET_OPTION = "@mtmux_bell_target"
|
|
69
|
+
NO_COCKPIT = "No valid mtmux cockpit. Run: mtmux cockpit"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _option(name: str) -> str:
|
|
73
|
+
return tmux.out("show-options", "-v", "-t", tmux.SESSION, name, check=False)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _window_exists() -> bool:
|
|
77
|
+
return tmux.tmux("has-session", "-t", TARGET, check=False).returncode == 0
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _valid() -> bool:
|
|
81
|
+
if _option(COCKPIT_OPTION) != "1":
|
|
82
|
+
return False
|
|
83
|
+
left = _option(SIDEBAR_PANE_OPTION)
|
|
84
|
+
right = _option(RIGHT_PANE_OPTION)
|
|
85
|
+
return bool(left and right and tmux.has_pane(left) and tmux.has_pane(right))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _set_markers(left: str, right: str) -> None:
|
|
89
|
+
tmux.tmux("set-option", "-t", tmux.SESSION, COCKPIT_OPTION, "1")
|
|
90
|
+
tmux.tmux("set-option", "-t", tmux.SESSION, SIDEBAR_PANE_OPTION, left)
|
|
91
|
+
tmux.tmux("set-option", "-t", tmux.SESSION, RIGHT_PANE_OPTION, right)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _fix_layout(left: str, sidebar_width: int) -> None:
|
|
95
|
+
tmux.tmux("set-window-option", "-t", TARGET, SIDEBAR_WIDTH_OPTION, str(sidebar_width))
|
|
96
|
+
tmux.tmux("set-window-option", "-t", TARGET, "main-pane-width", str(sidebar_width))
|
|
97
|
+
tmux.tmux("set-window-option", "-u", "-t", TARGET, "window-style")
|
|
98
|
+
tmux.tmux("set-window-option", "-u", "-t", TARGET, "window-active-style")
|
|
99
|
+
tmux.tmux("set-window-option", "-t", TARGET, "pane-border-style", "fg=terminal")
|
|
100
|
+
tmux.tmux("set-window-option", "-t", TARGET, "pane-active-border-style", "fg=terminal")
|
|
101
|
+
tmux.tmux("set-window-option", "-t", TARGET, "pane-border-lines", "single")
|
|
102
|
+
tmux.tmux("resize-pane", "-t", left, "-x", str(sidebar_width), check=False)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def repair_layout(left: str) -> None:
|
|
106
|
+
state = tmux.out(
|
|
107
|
+
"display-message", "-p", "-t", left,
|
|
108
|
+
f"#{{pane_width}}:#{{{SIDEBAR_WIDTH_OPTION}}}:#{{window_width}}:#{{client_width}}:#{{window_offset_x}}",
|
|
109
|
+
check=False,
|
|
110
|
+
).split(":")
|
|
111
|
+
if len(state) == 5 and state[0] == state[1] and state[2] == state[3] and state[4] in ("", "0"):
|
|
112
|
+
return
|
|
113
|
+
command = f"resize-window -a -t {TARGET} ; resize-pane -t {left} -x '#{{{SIDEBAR_WIDTH_OPTION}}}'"
|
|
114
|
+
tmux.tmux("run-shell", "-C", "-t", left, command, check=False)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _install_layout_hooks(left: str, sidebar_width: int) -> None:
|
|
118
|
+
tmux.tmux("set-hook", "-u", "-t", tmux.SESSION, "client-attached")
|
|
119
|
+
tmux.tmux("set-hook", "-u", "-t", tmux.SESSION, "client-resized")
|
|
120
|
+
tmux.tmux("set-hook", "-w", "-t", TARGET, "window-resized", f"resize-pane -t {left} -x {sidebar_width}")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _install_bindings(prefix: str, sidebar_pane: str, right_pane: str) -> None:
|
|
124
|
+
tmux.tmux("unbind-key", "-a", "-T", "prefix")
|
|
125
|
+
tmux.tmux("bind-key", prefix, "send-prefix")
|
|
126
|
+
tmux.tmux("bind-key", "d", "detach-client")
|
|
127
|
+
tmux.tmux("bind-key", "h", "kill-pane", "-t", sidebar_pane)
|
|
128
|
+
tmux.tmux("bind-key", "q", "kill-session", "-t", tmux.SESSION)
|
|
129
|
+
tmux.tmux("bind-key", "a", "run-shell", f"{FOCUS_SIDEBAR} agents")
|
|
130
|
+
tmux.tmux("bind-key", "s", "run-shell", f"{FOCUS_SIDEBAR} sessions")
|
|
131
|
+
tmux.tmux("bind-key", "+", "run-shell", f"{FOCUS_SIDEBAR} add")
|
|
132
|
+
tmux.tmux("bind-key", "?", "respawn-pane", "-k", "-t", right_pane, help_command(prefix))
|
|
133
|
+
for slot in range(1, 10):
|
|
134
|
+
tmux.tmux("bind-key", str(slot), "run-shell", f"{shlex.quote(sys.executable)} -m mtmux switch-session {slot}")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _enable_mouse() -> None:
|
|
138
|
+
tmux.tmux("set-option", "-t", tmux.SESSION, "mouse", "on")
|
|
139
|
+
tmux.tmux("unbind-key", "-q", "-T", "root", "MouseDrag1Border")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _enable_clipboard() -> None:
|
|
143
|
+
tmux.tmux("set-option", "-s", "set-clipboard", "on")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _enable_truecolor() -> None:
|
|
147
|
+
"""Propagate RGB terminal capability when the host reports truecolor."""
|
|
148
|
+
if _truecolor_enabled():
|
|
149
|
+
tmux.tmux("set-option", "-as", "terminal-features", ",xterm-256color:RGB")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _install_bell_hook() -> None:
|
|
153
|
+
tmux.tmux("set-window-option", "-t", TARGET, "monitor-bell", "on")
|
|
154
|
+
tmux.tmux("set-option", "-t", tmux.SESSION, "bell-action", "any")
|
|
155
|
+
tmux.tmux("set-hook", "-t", tmux.SESSION, "alert-bell", "set-option -F -t mtmux @mtmux_bell_target '#{@mtmux_current_target}'")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _unavailable_command(target: Target | None = None) -> str:
|
|
159
|
+
name = f"Session {target.format()}" if target else "Active session"
|
|
160
|
+
text = f"{name} is unavailable.\n\nSelect another session from the sidebar.\n"
|
|
161
|
+
return f"printf %s {shlex.quote(text)}; exec sh"
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _reconnecting_command(target: Target) -> str:
|
|
165
|
+
reset, cyan, dim = "\033[0m", "\033[38;5;81m", "\033[2m"
|
|
166
|
+
ascii_mode = os.environ.get("MTMUX_ASCII") == "1"
|
|
167
|
+
banner = "+-- Connection interrupted --+" if ascii_mode else "╭─ Connection interrupted ─╮"
|
|
168
|
+
underline = "+----------------------------+" if ascii_mode else "╰──────────────────────────╯"
|
|
169
|
+
indent = " "
|
|
170
|
+
text = f"\n{indent}{cyan}{banner}\n{indent}{underline}{reset}\n\n{indent}{dim}Session{reset} {target.session}\n{indent}{dim}Host{reset} {target.host or 'local'}\n\n"
|
|
171
|
+
frames = ("|", "/", "-", "\\") if ascii_mode else ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏")
|
|
172
|
+
dots = (". ", ".. ", "...") if ascii_mode else ("· ", "·· ", "···", " ··", " ·")
|
|
173
|
+
colors = (45, 51, 87, 123, 159, 123, 87, 51)
|
|
174
|
+
states = " ".join(shlex.quote(f"{color}:{frame}:{dots[index % len(dots)]}") for index, (color, frame) in enumerate(zip(colors, frames)))
|
|
175
|
+
save_cursor = shlex.quote("\0337")
|
|
176
|
+
spinner = shlex.quote(f"\0338\033[2K{indent}\033[38;5;%sm%s\033[0m Reconnecting%s")
|
|
177
|
+
animate = 'color=${state%%:*}; rest=${state#*:}; frame=${rest%%:*}; dots=${rest#*:}'
|
|
178
|
+
return f"printf %s {shlex.quote(text)}; printf %s {save_cursor}; while :; do for state in {states}; do {animate}; printf {spinner} \"$color\" \"$frame\" \"$dots\"; sleep 0.1; done; done"
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _install_right_pane_reset(left: str, right: str) -> None:
|
|
182
|
+
tmux.tmux("set-option", "-p", "-t", right, "remain-on-exit", "on")
|
|
183
|
+
command = f"if-shell -F '#{{==:#{{hook_pane}},{right}}}' {{ set-option -u -t {tmux.SESSION} @mtmux_current_agent ; set-option -u -t {tmux.SESSION} @mtmux_bell_target ; respawn-pane -k -t {right} {shlex.quote(_unavailable_command())} ; select-pane -t {left} }}"
|
|
184
|
+
tmux.tmux("set-hook", "-t", tmux.SESSION, "pane-died", command)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _configure_cockpit(left: str, right: str, prefix: str, sidebar_width: int) -> None:
|
|
188
|
+
_set_markers(left, right)
|
|
189
|
+
_fix_layout(left, sidebar_width)
|
|
190
|
+
_install_layout_hooks(left, sidebar_width)
|
|
191
|
+
_install_bell_hook()
|
|
192
|
+
_install_right_pane_reset(left, right)
|
|
193
|
+
tmux.tmux("set-option", "-t", tmux.SESSION, "prefix", prefix)
|
|
194
|
+
tmux.tmux("set-option", "-t", tmux.SESSION, "status", "off")
|
|
195
|
+
tmux.tmux("set-option", "-s", "escape-time", "0")
|
|
196
|
+
_enable_mouse()
|
|
197
|
+
_enable_clipboard()
|
|
198
|
+
_enable_truecolor()
|
|
199
|
+
_install_bindings(prefix, left, right)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _build(prefix: str, sidebar_width: int) -> None:
|
|
203
|
+
_, wrapper = ensure_config()
|
|
204
|
+
help_cmd = help_command(prefix)
|
|
205
|
+
if _window_exists():
|
|
206
|
+
tmux.tmux("kill-window", "-t", TARGET, check=False)
|
|
207
|
+
if tmux.tmux("has-session", "-t", tmux.SESSION, check=False).returncode != 0:
|
|
208
|
+
tmux.tmux("new-session", "-d", "-s", tmux.SESSION, "-n", tmux.WINDOW, help_cmd, config=wrapper)
|
|
209
|
+
else:
|
|
210
|
+
tmux.tmux("new-window", "-d", "-t", tmux.SESSION, "-n", tmux.WINDOW, help_cmd)
|
|
211
|
+
right = tmux.out("display-message", "-p", "-t", TARGET, "#{pane_id}")
|
|
212
|
+
left = tmux.out("split-window", "-h", "-b", "-l", str(sidebar_width), "-P", "-F", "#{pane_id}", "-t", right, SIDEBAR)
|
|
213
|
+
_configure_cockpit(left, right, prefix, sidebar_width)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def ensure_cockpit() -> None:
|
|
217
|
+
prefix = load_prefix()
|
|
218
|
+
sidebar_width = load_sidebar_width()
|
|
219
|
+
if _valid():
|
|
220
|
+
left = _option(SIDEBAR_PANE_OPTION)
|
|
221
|
+
right = _option(RIGHT_PANE_OPTION)
|
|
222
|
+
_configure_cockpit(left, right, prefix, sidebar_width)
|
|
223
|
+
return
|
|
224
|
+
if _option(COCKPIT_OPTION) == "1":
|
|
225
|
+
right = _option(RIGHT_PANE_OPTION)
|
|
226
|
+
if right and tmux.has_pane(right):
|
|
227
|
+
left = tmux.out("split-window", "-h", "-b", "-l", str(sidebar_width), "-P", "-F", "#{pane_id}", "-t", right, SIDEBAR)
|
|
228
|
+
_configure_cockpit(left, right, prefix, sidebar_width)
|
|
229
|
+
return
|
|
230
|
+
_build(prefix, sidebar_width)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _attach() -> int:
|
|
234
|
+
attach_cmd = "tmux -L mtmux attach -d -t mtmux:cockpit"
|
|
235
|
+
if not (sys.stdin.isatty() and sys.stdout.isatty()):
|
|
236
|
+
print(f"Cockpit ready. Attach from a real terminal: {attach_cmd}")
|
|
237
|
+
return 0
|
|
238
|
+
try:
|
|
239
|
+
tty = os.ttyname(0)
|
|
240
|
+
except OSError:
|
|
241
|
+
tty = ""
|
|
242
|
+
if tty == "/dev/tty":
|
|
243
|
+
print(f"Cockpit ready. Current fd is /dev/tty; tmux refuses it. Run: {attach_cmd}")
|
|
244
|
+
return 0
|
|
245
|
+
cmd = ["tmux", "-L", tmux.SOCKET, "attach-session", "-d", "-t", TARGET]
|
|
246
|
+
if shutil.which("script"):
|
|
247
|
+
shell_cmd = " ".join(shlex.quote(part) for part in cmd)
|
|
248
|
+
os.execvp("script", ["script", "-q", "-c", shell_cmd, "/dev/null"])
|
|
249
|
+
os.execvp("tmux", cmd)
|
|
250
|
+
return 0
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def cockpit() -> int:
|
|
254
|
+
ensure_config()
|
|
255
|
+
width = shutil.get_terminal_size((80, 24)).columns
|
|
256
|
+
if width < 90:
|
|
257
|
+
print("Terminal too narrow for mtmux cockpit: need at least 90 columns.", file=sys.stderr)
|
|
258
|
+
return 2
|
|
259
|
+
ensure_cockpit()
|
|
260
|
+
return _attach()
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def focus_sidebar(region: str = "sessions") -> int:
|
|
264
|
+
ensure_config()
|
|
265
|
+
ensure_cockpit()
|
|
266
|
+
pane = _option(SIDEBAR_PANE_OPTION)
|
|
267
|
+
tmux.tmux("select-pane", "-t", pane)
|
|
268
|
+
keys = {"sessions": ("F6",), "agents": ("F7",), "add": ("F6", "a")}[region]
|
|
269
|
+
tmux.tmux("send-keys", "-t", pane, *keys)
|
|
270
|
+
return 0
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def right_pane() -> str | None:
|
|
274
|
+
if not _valid():
|
|
275
|
+
return None
|
|
276
|
+
pane = _option(RIGHT_PANE_OPTION)
|
|
277
|
+
return pane if pane and tmux.has_pane(pane) else None
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _require_right_pane() -> str:
|
|
281
|
+
pane = right_pane()
|
|
282
|
+
if not pane:
|
|
283
|
+
raise SystemExit(NO_COCKPIT)
|
|
284
|
+
return pane
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def switch(target: Target, attach_command: str, agent_id: str | None = None) -> None:
|
|
288
|
+
pane = _require_right_pane()
|
|
289
|
+
tmux.tmux("set-option", "-t", tmux.SESSION, CURRENT_TARGET_OPTION, target.format())
|
|
290
|
+
if agent_id:
|
|
291
|
+
tmux.tmux("set-option", "-t", tmux.SESSION, CURRENT_AGENT_OPTION, agent_id)
|
|
292
|
+
else:
|
|
293
|
+
tmux.tmux("set-option", "-u", "-t", tmux.SESSION, CURRENT_AGENT_OPTION)
|
|
294
|
+
tmux.tmux("set-option", "-u", "-t", tmux.SESSION, BELL_TARGET_OPTION)
|
|
295
|
+
tmux.tmux("respawn-pane", "-k", "-t", pane, attach_command)
|
|
296
|
+
tmux.tmux("select-pane", "-t", pane)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def show_help() -> None:
|
|
300
|
+
tmux.tmux("respawn-pane", "-k", "-t", _require_right_pane(), help_command(load_prefix()))
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def show_unavailable(target: Target) -> None:
|
|
304
|
+
tmux.tmux("respawn-pane", "-k", "-t", _require_right_pane(), _unavailable_command(target))
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def show_reconnecting(target: Target) -> None:
|
|
308
|
+
tmux.tmux("respawn-pane", "-k", "-t", _require_right_pane(), _reconnecting_command(target))
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _target_option(name: str) -> Target | None:
|
|
312
|
+
text = _option(name)
|
|
313
|
+
if not text:
|
|
314
|
+
return None
|
|
315
|
+
try:
|
|
316
|
+
return parse_target(text)
|
|
317
|
+
except SystemExit:
|
|
318
|
+
return None
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def current_target() -> Target | None:
|
|
322
|
+
if target := _target_option(CURRENT_TARGET_OPTION):
|
|
323
|
+
return target
|
|
324
|
+
pane = right_pane()
|
|
325
|
+
command = tmux.out("display-message", "-p", "-t", pane or "", "#{pane_start_command}", check=False)
|
|
326
|
+
try:
|
|
327
|
+
parts = shlex.split(command)
|
|
328
|
+
if parts and parts[0] == "ssh":
|
|
329
|
+
index = 1
|
|
330
|
+
while index < len(parts):
|
|
331
|
+
if parts[index] == "-o":
|
|
332
|
+
index += 2
|
|
333
|
+
elif parts[index].startswith("-"):
|
|
334
|
+
index += 1
|
|
335
|
+
else:
|
|
336
|
+
break
|
|
337
|
+
if index < len(parts) and (match := re.search(r"(?:^| )tmux .* -s ([A-Za-z0-9_.-]+)", " ".join(parts[index + 1:]))):
|
|
338
|
+
return Target("ssh", match.group(1), parts[index])
|
|
339
|
+
if match := re.search(r"(?:^| )tmux new-session .* -s ([A-Za-z0-9_.-]+)", command):
|
|
340
|
+
return Target("local", match.group(1))
|
|
341
|
+
except SystemExit:
|
|
342
|
+
pass
|
|
343
|
+
return None
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def current_agent() -> str | None:
|
|
347
|
+
return _option(CURRENT_AGENT_OPTION) or None
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def bell_target() -> Target | None:
|
|
351
|
+
return _target_option(BELL_TARGET_OPTION)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def sidebar_active() -> bool:
|
|
355
|
+
pane = _option(SIDEBAR_PANE_OPTION)
|
|
356
|
+
return not pane or tmux.out("display-message", "-p", "-t", pane, "#{pane_active}", check=False) == "1"
|
mtmux/config.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import tomllib
|
|
6
|
+
|
|
7
|
+
from .names import Target, parse_target, validate_host
|
|
8
|
+
|
|
9
|
+
DEFAULT_PREFIX = "C-s"
|
|
10
|
+
DEFAULT_SIDEBAR_WIDTH = 40
|
|
11
|
+
DEFAULT_STATUS_TIMEOUT = 5
|
|
12
|
+
DEFAULT_PERSISTENT_SSH = True
|
|
13
|
+
CONFIG_TEXT = f'hosts = []\nprefix = "{DEFAULT_PREFIX}"\nsidebar_width = {DEFAULT_SIDEBAR_WIDTH}\nstatus_timeout = {DEFAULT_STATUS_TIMEOUT}\npersistent_ssh = true\n'
|
|
14
|
+
WRAPPER_TEXT = """unbind C-b
|
|
15
|
+
set -g status off
|
|
16
|
+
set -g mouse on
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def config_dir() -> Path:
|
|
21
|
+
override = os.environ.get("MTMUX_CONFIG_DIR")
|
|
22
|
+
if override:
|
|
23
|
+
return Path(override).expanduser()
|
|
24
|
+
return Path.home() / ".config" / "mtmux"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def paths() -> tuple[Path, Path]:
|
|
28
|
+
base = config_dir()
|
|
29
|
+
return base / "config.toml", base / "wrapper.tmux.conf"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def ensure_config() -> tuple[Path, Path]:
|
|
33
|
+
cfg, wrapper = paths()
|
|
34
|
+
cfg.parent.mkdir(parents=True, exist_ok=True)
|
|
35
|
+
if not cfg.exists():
|
|
36
|
+
cfg.write_text(CONFIG_TEXT)
|
|
37
|
+
if not wrapper.exists():
|
|
38
|
+
wrapper.write_text(WRAPPER_TEXT)
|
|
39
|
+
return cfg, wrapper
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _load_config() -> tuple[Path, dict]:
|
|
43
|
+
cfg, _ = ensure_config()
|
|
44
|
+
try:
|
|
45
|
+
return cfg, tomllib.loads(cfg.read_text())
|
|
46
|
+
except tomllib.TOMLDecodeError as e:
|
|
47
|
+
raise SystemExit(f"Invalid config TOML {cfg}: {e}") from e
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def load_prefix() -> str:
|
|
51
|
+
cfg, data = _load_config()
|
|
52
|
+
prefix = data.get("prefix", DEFAULT_PREFIX)
|
|
53
|
+
if not isinstance(prefix, str) or not prefix or not prefix.isprintable() or any(char.isspace() for char in prefix):
|
|
54
|
+
raise SystemExit(f"Invalid config {cfg}: prefix must be a non-empty, printable, whitespace-free string")
|
|
55
|
+
return prefix
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def load_sidebar_width() -> int:
|
|
59
|
+
cfg, data = _load_config()
|
|
60
|
+
width = data.get("sidebar_width", DEFAULT_SIDEBAR_WIDTH)
|
|
61
|
+
if isinstance(width, bool) or not isinstance(width, int) or width < 1:
|
|
62
|
+
raise SystemExit(f"Invalid config {cfg}: sidebar_width must be a positive integer")
|
|
63
|
+
return width
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def load_status_timeout() -> int:
|
|
67
|
+
cfg, data = _load_config()
|
|
68
|
+
timeout = data.get("status_timeout", DEFAULT_STATUS_TIMEOUT)
|
|
69
|
+
if isinstance(timeout, bool) or not isinstance(timeout, int) or timeout < 1:
|
|
70
|
+
raise SystemExit(f"Invalid config {cfg}: status_timeout must be a positive integer")
|
|
71
|
+
return timeout
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def load_persistent_ssh() -> bool:
|
|
75
|
+
cfg, data = _load_config()
|
|
76
|
+
persistent = data.get("persistent_ssh", DEFAULT_PERSISTENT_SSH)
|
|
77
|
+
if not isinstance(persistent, bool):
|
|
78
|
+
raise SystemExit(f"Invalid config {cfg}: persistent_ssh must be a boolean")
|
|
79
|
+
return persistent
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def load_hosts() -> list[str]:
|
|
83
|
+
cfg, data = _load_config()
|
|
84
|
+
hosts = data.get("hosts", [])
|
|
85
|
+
if not isinstance(hosts, list) or not all(isinstance(h, str) for h in hosts):
|
|
86
|
+
raise SystemExit(f"Invalid config {cfg}: hosts must be a list of strings")
|
|
87
|
+
try:
|
|
88
|
+
return [validate_host(host) for host in hosts]
|
|
89
|
+
except SystemExit as error:
|
|
90
|
+
raise SystemExit(f"Invalid config {cfg}: {error}") from error
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def load_sessions() -> list[Target]:
|
|
94
|
+
path = config_dir() / "sessions"
|
|
95
|
+
if not path.exists():
|
|
96
|
+
return []
|
|
97
|
+
favorites: list[Target] = []
|
|
98
|
+
seen: set[Target] = set()
|
|
99
|
+
for line_number, line in enumerate(path.read_text().splitlines(), 1):
|
|
100
|
+
if not (text := line.strip()):
|
|
101
|
+
continue
|
|
102
|
+
try:
|
|
103
|
+
target = parse_target(text)
|
|
104
|
+
except SystemExit as e:
|
|
105
|
+
raise SystemExit(f"Invalid favorite in {path}:{line_number}: {e}") from e
|
|
106
|
+
if target not in seen:
|
|
107
|
+
favorites.append(target)
|
|
108
|
+
seen.add(target)
|
|
109
|
+
return favorites
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def save_sessions(favorites: list[Target] | tuple[Target, ...]) -> None:
|
|
113
|
+
path = config_dir() / "sessions"
|
|
114
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
115
|
+
path.write_text("".join(f"{target.format()}\n" for target in favorites))
|