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