letee 0.1.22__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.
letee/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.22"
letee/__main__.py ADDED
@@ -0,0 +1,117 @@
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, save_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="letee")
16
+ sub = parser.add_subparsers(dest="command")
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 in (None, "cockpit"):
50
+ return cockpit.cockpit()
51
+ if args.command == "init":
52
+ cfg, wrapper = ensure_config()
53
+ print(f"Config: {cfg}")
54
+ print(f"Wrapper: {wrapper}")
55
+ return 0
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
+ target = parse_target(args.target)
85
+ sessions.kill(target)
86
+ favorites = load_sessions()
87
+ if target in favorites:
88
+ favorites.remove(target)
89
+ save_sessions(favorites)
90
+ return 0
91
+ if args.command == "create":
92
+ target = Target("local", args.session) if args.create_kind == "local" else Target("ssh", args.session, args.host)
93
+ sessions.create(target)
94
+ cockpit.switch(target, sessions.attach_command(target))
95
+ return 0
96
+
97
+
98
+ def run_cli(argv: list[str] | None = None) -> int:
99
+ try:
100
+ return main(argv)
101
+ except subprocess.CalledProcessError as error:
102
+ command = Path(str(error.cmd[0] if isinstance(error.cmd, (list, tuple)) else error.cmd)).name
103
+ reason = (error.stderr or error.stdout or "").strip() or f"exit status {error.returncode}"
104
+ print(f"letee: {command} failed: {reason}", file=sys.stderr)
105
+ except OSError as error:
106
+ reason = error.strerror or str(error)
107
+ detail = f"{error.filename}: {reason}" if error.filename else reason
108
+ print(f"letee: {detail}", file=sys.stderr)
109
+ except UnicodeError as error:
110
+ print(f"letee: text decoding failed: {error}", file=sys.stderr)
111
+ except subprocess.SubprocessError as error:
112
+ print(f"letee: subprocess failed: {error}", file=sys.stderr)
113
+ return 1
114
+
115
+
116
+ if __name__ == "__main__":
117
+ raise SystemExit(run_cli())
letee/cockpit.py ADDED
@@ -0,0 +1,379 @@
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"""letee
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 activate selected row
35
+ a open Add session menu
36
+ r remove selected session (session keeps running)
37
+ K/J move session up/down
38
+ x kill and remove selected session
39
+ Right-click open session Remove/Kill menu
40
+ / search untracked existing sessions
41
+
42
+ Agent actions
43
+ j/k navigate agents
44
+ Enter switch to agent pane
45
+ h/l cycle ordering on selected ordering row (Priority / Session)
46
+ [ / ] resize agent panel
47
+
48
+ Recovery
49
+ {prefix} d detach cockpit
50
+ {prefix} s restart/focus Sessions
51
+ {prefix} a restart/focus Agents
52
+ {prefix} + open Add session menu
53
+ Esc cancel prompts/filter
54
+
55
+ Examples
56
+ /work find available sessions matching work
57
+ Enter recreate missing session or activate selected Add row
58
+ """
59
+ return f"printf %s {shlex.quote(text)}; exec tail -f /dev/null"
60
+
61
+
62
+ SIDEBAR = f"{shlex.quote(sys.executable)} -m letee sidebar"
63
+ FOCUS_SIDEBAR = f"{shlex.quote(sys.executable)} -m letee focus-sidebar"
64
+ TARGET = f"{tmux.SESSION}:{tmux.WINDOW}"
65
+ COCKPIT_OPTION = "@letee_cockpit"
66
+ SIDEBAR_PANE_OPTION = "@letee_sidebar_pane"
67
+ SIDEBAR_WIDTH_OPTION = "@letee_sidebar_width"
68
+ RIGHT_PANE_OPTION = "@letee_right_pane"
69
+ CURRENT_TARGET_OPTION = "@letee_current_target"
70
+ CURRENT_AGENT_OPTION = "@letee_current_agent"
71
+ BELL_TARGET_OPTION = "@letee_bell_target"
72
+ NO_COCKPIT = "No valid letee. Run: letee"
73
+
74
+
75
+ def _option(name: str) -> str:
76
+ return tmux.out("show-options", "-v", "-t", tmux.SESSION, name, check=False)
77
+
78
+
79
+ def _window_exists() -> bool:
80
+ return tmux.tmux("has-session", "-t", TARGET, check=False).returncode == 0
81
+
82
+
83
+ def _valid() -> bool:
84
+ if _option(COCKPIT_OPTION) != "1":
85
+ return False
86
+ left = _option(SIDEBAR_PANE_OPTION)
87
+ right = _option(RIGHT_PANE_OPTION)
88
+ return bool(left and right and tmux.has_pane(left) and tmux.has_pane(right))
89
+
90
+
91
+ def _set_markers(left: str, right: str) -> None:
92
+ tmux.tmux("set-option", "-t", tmux.SESSION, COCKPIT_OPTION, "1")
93
+ tmux.tmux("set-option", "-t", tmux.SESSION, SIDEBAR_PANE_OPTION, left)
94
+ tmux.tmux("set-option", "-t", tmux.SESSION, RIGHT_PANE_OPTION, right)
95
+
96
+
97
+ def _fix_layout(left: str, sidebar_width: int) -> None:
98
+ tmux.tmux("set-window-option", "-t", TARGET, SIDEBAR_WIDTH_OPTION, str(sidebar_width))
99
+ tmux.tmux("set-window-option", "-t", TARGET, "main-pane-width", str(sidebar_width))
100
+ tmux.tmux("set-window-option", "-u", "-t", TARGET, "window-style")
101
+ tmux.tmux("set-window-option", "-u", "-t", TARGET, "window-active-style")
102
+ tmux.tmux("set-window-option", "-t", TARGET, "pane-border-style", "fg=terminal")
103
+ tmux.tmux("set-window-option", "-t", TARGET, "pane-active-border-style", "fg=terminal")
104
+ tmux.tmux("set-window-option", "-t", TARGET, "pane-border-lines", "single")
105
+ tmux.tmux("resize-pane", "-t", left, "-x", str(sidebar_width), check=False)
106
+
107
+
108
+ def repair_layout(left: str) -> None:
109
+ state = tmux.out(
110
+ "display-message", "-p", "-t", left,
111
+ f"#{{pane_width}}:#{{{SIDEBAR_WIDTH_OPTION}}}:#{{window_width}}:#{{client_width}}:#{{window_offset_x}}",
112
+ check=False,
113
+ ).split(":")
114
+ if len(state) == 5 and state[0] == state[1] and state[2] == state[3] and state[4] in ("", "0"):
115
+ return
116
+ command = f"resize-window -a -t {TARGET} ; resize-pane -t {left} -x '#{{{SIDEBAR_WIDTH_OPTION}}}'"
117
+ tmux.tmux("run-shell", "-C", "-t", left, command, check=False)
118
+
119
+
120
+ def _install_layout_hooks(left: str, sidebar_width: int) -> None:
121
+ tmux.tmux("set-hook", "-u", "-t", tmux.SESSION, "client-attached")
122
+ tmux.tmux("set-hook", "-u", "-t", tmux.SESSION, "client-resized")
123
+ tmux.tmux("set-hook", "-w", "-t", TARGET, "window-resized", f"resize-pane -t {left} -x {sidebar_width}")
124
+
125
+
126
+ def _install_bindings(prefix: str, sidebar_pane: str, right_pane: str) -> None:
127
+ tmux.tmux("unbind-key", "-a", "-T", "prefix")
128
+ tmux.tmux("bind-key", prefix, "send-prefix")
129
+ tmux.tmux("bind-key", "d", "detach-client")
130
+ tmux.tmux("bind-key", "h", "kill-pane", "-t", sidebar_pane)
131
+ tmux.tmux("bind-key", "q", "kill-session", "-t", tmux.SESSION)
132
+ tmux.tmux("bind-key", "a", "run-shell", f"{FOCUS_SIDEBAR} agents")
133
+ tmux.tmux("bind-key", "s", "run-shell", f"{FOCUS_SIDEBAR} sessions")
134
+ tmux.tmux("bind-key", "+", "run-shell", f"{FOCUS_SIDEBAR} add")
135
+ tmux.tmux("bind-key", "?", "respawn-pane", "-k", "-t", right_pane, help_command(prefix))
136
+ for slot in range(1, 10):
137
+ tmux.tmux("bind-key", str(slot), "run-shell", f"{shlex.quote(sys.executable)} -m letee switch-session {slot}")
138
+
139
+
140
+ def _enable_mouse() -> None:
141
+ tmux.tmux("set-option", "-t", tmux.SESSION, "mouse", "on")
142
+ tmux.tmux("unbind-key", "-q", "-T", "root", "MouseDrag1Border")
143
+
144
+
145
+ def _enable_clipboard() -> None:
146
+ tmux.tmux("set-option", "-s", "set-clipboard", "on")
147
+
148
+
149
+ def _enable_truecolor() -> None:
150
+ """Propagate RGB terminal capability when the host reports truecolor."""
151
+ if _truecolor_enabled():
152
+ tmux.tmux("set-option", "-as", "terminal-features", ",xterm-256color:RGB")
153
+
154
+
155
+ def _install_bell_hook() -> None:
156
+ tmux.tmux("set-window-option", "-t", TARGET, "monitor-bell", "on")
157
+ tmux.tmux("set-option", "-t", tmux.SESSION, "bell-action", "any")
158
+ tmux.tmux("set-hook", "-t", tmux.SESSION, "alert-bell", "set-option -F -t letee @letee_bell_target '#{@letee_current_target}'")
159
+
160
+
161
+ def _unavailable_command(target: Target | None = None) -> str:
162
+ name = f"Session {target.format()}" if target else "Active session"
163
+ text = f"{name} is unavailable.\n\nSelect another session from the sidebar.\n"
164
+ return f"printf %s {shlex.quote(text)}; exec sh"
165
+
166
+
167
+ def _missing_command(target: Target) -> str:
168
+ text = f"Session {target.format()} is missing.\n\nSelect it in the sidebar. Press Enter to recreate it.\n"
169
+ return f"printf %s {shlex.quote(text)}; exec sh"
170
+
171
+
172
+ def _reconnecting_command(target: Target) -> str:
173
+ reset, cyan, dim = "\033[0m", "\033[38;5;81m", "\033[2m"
174
+ ascii_mode = os.environ.get("LETEE_ASCII") == "1"
175
+ banner = "+-- Connection interrupted --+" if ascii_mode else "╭─ Connection interrupted ─╮"
176
+ underline = "+----------------------------+" if ascii_mode else "╰──────────────────────────╯"
177
+ indent = " "
178
+ 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"
179
+ frames = ("|", "/", "-", "\\") if ascii_mode else ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏")
180
+ dots = (". ", ".. ", "...") if ascii_mode else ("· ", "·· ", "···", " ··", " ·")
181
+ colors = (45, 51, 87, 123, 159, 123, 87, 51)
182
+ states = " ".join(shlex.quote(f"{color}:{frame}:{dots[index % len(dots)]}") for index, (color, frame) in enumerate(zip(colors, frames)))
183
+ save_cursor = shlex.quote("\0337")
184
+ spinner = shlex.quote(f"\0338\033[2K{indent}\033[38;5;%sm%s\033[0m Reconnecting%s")
185
+ animate = 'color=${state%%:*}; rest=${state#*:}; frame=${rest%%:*}; dots=${rest#*:}'
186
+ 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"
187
+
188
+
189
+ def _install_right_pane_reset(left: str, right: str) -> None:
190
+ tmux.tmux("set-option", "-p", "-t", right, "remain-on-exit", "on")
191
+ command = f"if-shell -F '#{{==:#{{hook_pane}},{right}}}' {{ set-option -u -t {tmux.SESSION} @letee_current_agent ; set-option -u -t {tmux.SESSION} @letee_bell_target ; respawn-pane -k -t {right} {shlex.quote(_unavailable_command())} ; select-pane -t {left} }}"
192
+ tmux.tmux("set-hook", "-t", tmux.SESSION, "pane-died", command)
193
+
194
+
195
+ def _configure_cockpit(left: str, right: str, prefix: str, sidebar_width: int) -> None:
196
+ _set_markers(left, right)
197
+ _fix_layout(left, sidebar_width)
198
+ _install_layout_hooks(left, sidebar_width)
199
+ _install_bell_hook()
200
+ _install_right_pane_reset(left, right)
201
+ tmux.tmux("set-option", "-t", tmux.SESSION, "prefix", prefix)
202
+ tmux.tmux("set-option", "-t", tmux.SESSION, "status", "off")
203
+ tmux.tmux("set-option", "-s", "escape-time", "0")
204
+ _enable_mouse()
205
+ _enable_clipboard()
206
+ _enable_truecolor()
207
+ _install_bindings(prefix, left, right)
208
+
209
+
210
+ def _build(prefix: str, sidebar_width: int) -> None:
211
+ _, wrapper = ensure_config()
212
+ help_cmd = help_command(prefix)
213
+ if _window_exists():
214
+ tmux.tmux("kill-window", "-t", TARGET, check=False)
215
+ if tmux.tmux("has-session", "-t", tmux.SESSION, check=False).returncode != 0:
216
+ tmux.tmux("new-session", "-d", "-s", tmux.SESSION, "-n", tmux.WINDOW, help_cmd, config=wrapper)
217
+ else:
218
+ tmux.tmux("new-window", "-d", "-t", tmux.SESSION, "-n", tmux.WINDOW, help_cmd)
219
+ right = tmux.out("display-message", "-p", "-t", TARGET, "#{pane_id}")
220
+ left = tmux.out("split-window", "-h", "-b", "-l", str(sidebar_width), "-P", "-F", "#{pane_id}", "-t", right, SIDEBAR)
221
+ _configure_cockpit(left, right, prefix, sidebar_width)
222
+
223
+
224
+ def ensure_cockpit() -> None:
225
+ prefix = load_prefix()
226
+ sidebar_width = load_sidebar_width()
227
+ if _valid():
228
+ left = _option(SIDEBAR_PANE_OPTION)
229
+ right = _option(RIGHT_PANE_OPTION)
230
+ _configure_cockpit(left, right, prefix, sidebar_width)
231
+ return
232
+ if _option(COCKPIT_OPTION) == "1":
233
+ right = _option(RIGHT_PANE_OPTION)
234
+ if right and tmux.has_pane(right):
235
+ left = tmux.out("split-window", "-h", "-b", "-l", str(sidebar_width), "-P", "-F", "#{pane_id}", "-t", right, SIDEBAR)
236
+ _configure_cockpit(left, right, prefix, sidebar_width)
237
+ return
238
+ _build(prefix, sidebar_width)
239
+
240
+
241
+ def _attach() -> int:
242
+ attach_cmd = "tmux -L letee attach -d -t letee:cockpit"
243
+ if not (sys.stdin.isatty() and sys.stdout.isatty()):
244
+ print(f"Cockpit ready. Attach from a real terminal: {attach_cmd}")
245
+ return 0
246
+ try:
247
+ tty = os.ttyname(0)
248
+ except OSError:
249
+ tty = ""
250
+ if tty == "/dev/tty":
251
+ print(f"Cockpit ready. Current fd is /dev/tty; tmux refuses it. Run: {attach_cmd}")
252
+ return 0
253
+ cmd = ["tmux", "-L", tmux.SOCKET, "attach-session", "-d", "-t", TARGET]
254
+ if shutil.which("script"):
255
+ shell_cmd = " ".join(shlex.quote(part) for part in cmd)
256
+ os.execvp("script", ["script", "-q", "-c", shell_cmd, "/dev/null"])
257
+ os.execvp("tmux", cmd)
258
+ return 0
259
+
260
+
261
+ def cockpit() -> int:
262
+ ensure_config()
263
+ width = shutil.get_terminal_size((80, 24)).columns
264
+ if width < 90:
265
+ print("Terminal too narrow for letee: need at least 90 columns.", file=sys.stderr)
266
+ return 2
267
+ ensure_cockpit()
268
+ return _attach()
269
+
270
+
271
+ def focus_sidebar(region: str = "sessions") -> int:
272
+ ensure_config()
273
+ ensure_cockpit()
274
+ pane = _option(SIDEBAR_PANE_OPTION)
275
+ tmux.tmux("select-pane", "-t", pane)
276
+ keys = {"sessions": ("F6",), "agents": ("F7",), "add": ("F6", "a")}[region]
277
+ tmux.tmux("send-keys", "-t", pane, *keys)
278
+ return 0
279
+
280
+
281
+ def right_pane() -> str | None:
282
+ if not _valid():
283
+ return None
284
+ pane = _option(RIGHT_PANE_OPTION)
285
+ return pane if pane and tmux.has_pane(pane) else None
286
+
287
+
288
+ def _require_right_pane() -> str:
289
+ pane = right_pane()
290
+ if not pane:
291
+ raise SystemExit(NO_COCKPIT)
292
+ return pane
293
+
294
+
295
+ def switch(target: Target, attach_command: str, agent_id: str | None = None) -> None:
296
+ pane = _require_right_pane()
297
+ tmux.tmux("set-option", "-t", tmux.SESSION, CURRENT_TARGET_OPTION, target.format())
298
+ if agent_id:
299
+ tmux.tmux("set-option", "-t", tmux.SESSION, CURRENT_AGENT_OPTION, agent_id)
300
+ else:
301
+ tmux.tmux("set-option", "-u", "-t", tmux.SESSION, CURRENT_AGENT_OPTION)
302
+ tmux.tmux("set-option", "-u", "-t", tmux.SESSION, BELL_TARGET_OPTION)
303
+ tmux.tmux("respawn-pane", "-k", "-t", pane, attach_command)
304
+ tmux.tmux("select-pane", "-t", pane)
305
+
306
+
307
+ def show_help() -> None:
308
+ tmux.tmux("respawn-pane", "-k", "-t", _require_right_pane(), help_command(load_prefix()))
309
+
310
+
311
+ def show_session_menu(target: Target, x: int, y: int) -> None:
312
+ pane = _option(SIDEBAR_PANE_OPTION)
313
+ title = f"{target.session}@{target.host or 'localhost'}"
314
+ tmux.tmux(
315
+ "display-menu", "-M", "-O", "-T", title, "-x", str(x), "-y", str(y), "-t", pane,
316
+ "Remove", "r", f"send-keys -t {pane} r",
317
+ "Kill", "x", f"send-keys -t {pane} x y",
318
+ timeout=None,
319
+ )
320
+
321
+
322
+ def show_unavailable(target: Target) -> None:
323
+ tmux.tmux("respawn-pane", "-k", "-t", _require_right_pane(), _unavailable_command(target))
324
+
325
+
326
+ def show_missing(target: Target) -> None:
327
+ tmux.tmux("respawn-pane", "-k", "-t", _require_right_pane(), _missing_command(target))
328
+
329
+
330
+ def show_reconnecting(target: Target) -> None:
331
+ tmux.tmux("respawn-pane", "-k", "-t", _require_right_pane(), _reconnecting_command(target))
332
+
333
+
334
+ def _target_option(name: str) -> Target | None:
335
+ text = _option(name)
336
+ if not text:
337
+ return None
338
+ try:
339
+ return parse_target(text)
340
+ except SystemExit:
341
+ return None
342
+
343
+
344
+ def current_target() -> Target | None:
345
+ if target := _target_option(CURRENT_TARGET_OPTION):
346
+ return target
347
+ pane = right_pane()
348
+ command = tmux.out("display-message", "-p", "-t", pane or "", "#{pane_start_command}", check=False)
349
+ try:
350
+ parts = shlex.split(command)
351
+ if parts and parts[0] == "ssh":
352
+ index = 1
353
+ while index < len(parts):
354
+ if parts[index] == "-o":
355
+ index += 2
356
+ elif parts[index].startswith("-"):
357
+ index += 1
358
+ else:
359
+ break
360
+ if index < len(parts) and (match := re.search(r"(?:^| )tmux .* -s ([A-Za-z0-9_.-]+)", " ".join(parts[index + 1:]))):
361
+ return Target("ssh", match.group(1), parts[index])
362
+ if match := re.search(r"(?:^| )tmux new-session .* -s ([A-Za-z0-9_.-]+)", command):
363
+ return Target("local", match.group(1))
364
+ except SystemExit:
365
+ pass
366
+ return None
367
+
368
+
369
+ def current_agent() -> str | None:
370
+ return _option(CURRENT_AGENT_OPTION) or None
371
+
372
+
373
+ def bell_target() -> Target | None:
374
+ return _target_option(BELL_TARGET_OPTION)
375
+
376
+
377
+ def sidebar_active() -> bool:
378
+ pane = _option(SIDEBAR_PANE_OPTION)
379
+ return not pane or tmux.out("display-message", "-p", "-t", pane, "#{pane_active}", check=False) == "1"
letee/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("LETEE_CONFIG_DIR")
22
+ if override:
23
+ return Path(override).expanduser()
24
+ return Path.home() / ".config" / "letee"
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))