diff --git a/examples/fts-search.py b/examples/fts-search.py
new file mode 100755
index 0000000..5c3a255
--- /dev/null
+++ b/examples/fts-search.py
@@ -0,0 +1,456 @@
+#!/usr/bin/env python3
+"""SQLite FTS5 full-text search over een directory. Stdlib only, 3.6+.
+
+Run:
+ python3 fts-search.py index ./docs
+ python3 fts-search.py search "deploy checklist"
+ python3 fts-search.py serve --port 8083
+
+Spec (dit bestand is de spec):
+
+ CLI:
+ index
[--db] walk + (her)indexeer, incrementeel op mtime+size
+ search BM25-ranking, snippets met -highlights
+ serve [--port] HTTP UI + JSON API op /api/search
+
+ JSON API:
+ /api/search?q=&limit=&offset= 200 {"total","items":[{path,title,
+ snippet,rank,mtime,size}]}
+ /api/stats 200 {"docs","bytes","indexed_at"}
+
+ Ranking: SQLite bm25(fts), snippets via snippet() met -markering.
+ Query-taal: woorden worden AND-gecombineerd als frasen ("a" AND "b"),
+ veilig ge-escaped, nooit rauwe MATCH-syntax van de gebruiker.
+
+ Indexeer-regels: hidden files/dirs overgeslagen, binaire bestanden
+ overgeslagen (extensie-lijst + NUL-byte sniff), alleen .txt/.md/.rst/
+ .py/.js/.html/.css/.json/.toml/.yaml/.xml/.log/.csv en extensieloze
+ tekstbestanden.
+
+Caps: MAX_FILE 1 MiB, MAX_FILES 10000, MAX_QUERY 200 chars, MAX_LIMIT 50,
+MAX_BODY-index 200 KiB per bestand.
+"""
+
+import argparse
+import json
+import os
+import socketserver
+import sqlite3
+import threading
+import time
+from http.server import BaseHTTPRequestHandler, HTTPServer
+from urllib.parse import parse_qs, urlparse
+
+TEXT_EXTS = frozenset([
+ ".txt", ".md", ".markdown", ".rst", ".py", ".js", ".ts", ".html",
+ ".htm", ".css", ".json", ".toml", ".yaml", ".yml", ".xml", ".log",
+ ".csv", ".tsv", ".ini", ".cfg", ".sh", ".sql", ".java", ".c", ".h",
+ ".go", ".rs", "",
+])
+MAX_FILE = 1024 * 1024
+MAX_FILES = 10000
+MAX_INDEX_CHARS = 200 * 1024
+
+
+class SearchError(Exception):
+ pass
+
+
+class SearchEngine(object):
+ """SQLite + FTS5 index. Een verbinding, RLock, WAL."""
+
+ SCHEMA = ("CREATE TABLE IF NOT EXISTS docs ("
+ "path TEXT PRIMARY KEY,"
+ "title TEXT NOT NULL,"
+ "body TEXT NOT NULL,"
+ "mtime REAL NOT NULL,"
+ "size INTEGER NOT NULL,"
+ "indexed_at TEXT NOT NULL)")
+
+ def __init__(self, db_path):
+ self.db_path = db_path
+ self._lock = threading.RLock()
+ self.db = sqlite3.connect(db_path, check_same_thread=False)
+ self.db.row_factory = sqlite3.Row
+ self.db.execute("PRAGMA journal_mode=WAL")
+ self.db.execute("PRAGMA busy_timeout=5000")
+ self.db.execute("PRAGMA synchronous=NORMAL")
+ with self.db:
+ self.db.execute(self.SCHEMA)
+ try:
+ self.db.execute("CREATE VIRTUAL TABLE IF NOT EXISTS fts "
+ "USING fts5(path, title, body, "
+ "tokenize='porter unicode61')")
+ except sqlite3.OperationalError:
+ raise SearchError(
+ "deze SQLite heeft geen FTS5 (nodig voor ranking). "
+ "Python >= 3.9 bundelt het vrijwel altijd.")
+ self.db.execute(
+ "CREATE TRIGGER IF NOT EXISTS docs_ai AFTER INSERT ON docs "
+ "BEGIN INSERT INTO fts(path, title, body) "
+ "VALUES (new.path, new.title, new.body); END")
+ self.db.execute(
+ "CREATE TRIGGER IF NOT EXISTS docs_ad AFTER DELETE ON docs "
+ "BEGIN DELETE FROM fts WHERE path=old.path; END")
+ self.db.execute(
+ "CREATE TRIGGER IF NOT EXISTS docs_au AFTER UPDATE ON docs "
+ "BEGIN DELETE FROM fts WHERE path=old.path; "
+ "INSERT INTO fts(path, title, body) "
+ "VALUES (new.path, new.title, new.body); END")
+
+ @staticmethod
+ def match_query(query):
+ """Bouw veilige MATCH-query: frasen met AND, alles ge-escaped."""
+ words = [w for w in query.split() if w.strip("\"'")]
+ terms = []
+ for word in words[:10]:
+ clean = word.strip("\"'()*:^").replace('"', "")
+ if clean:
+ terms.append('"%s"' % clean)
+ return " AND ".join(terms)
+
+ def search(self, query, limit=20, offset=0):
+ query = (query or "")[:200].strip()
+ if not query:
+ return 0, []
+ match = self.match_query(query)
+ if not match:
+ return 0, []
+ limit = min(max(limit, 1), 50)
+ offset = max(offset, 0)
+ with self._lock:
+ try:
+ total = self.db.execute(
+ "SELECT COUNT(*) AS n FROM fts WHERE fts MATCH ?",
+ (match,)).fetchone()["n"]
+ rows = self.db.execute(
+ "SELECT d.path, d.title, d.mtime, d.size,"
+ " bm25(fts) AS rank,"
+ " snippet(fts, 2, '', '', ' ... ', 32) AS snippet"
+ " FROM fts JOIN docs d ON d.path=fts.path"
+ " WHERE fts MATCH ? ORDER BY rank LIMIT ? OFFSET ?",
+ (match, limit, offset)).fetchall()
+ except sqlite3.OperationalError:
+ return 0, []
+ return total, [{"path": r["path"], "title": r["title"],
+ "mtime": r["mtime"], "size": r["size"],
+ "rank": r["rank"], "snippet": r["snippet"]}
+ for r in rows]
+
+ def stats(self):
+ with self._lock:
+ row = self.db.execute(
+ "SELECT COUNT(*) AS docs, COALESCE(SUM(size),0) AS bytes,"
+ " MAX(indexed_at) AS indexed_at FROM docs").fetchone()
+ return {"docs": row["docs"], "bytes": row["bytes"],
+ "indexed_at": row["indexed_at"]}
+
+ def upsert(self, path, title, body, mtime, size):
+ with self._lock:
+ now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
+ self.db.execute(
+ "INSERT OR REPLACE INTO docs"
+ " (path, title, body, mtime, size, indexed_at)"
+ " VALUES (?, ?, ?, ?, ?, ?)",
+ (path, title, body[:MAX_INDEX_CHARS], mtime, size, now))
+
+ def remove(self, path):
+ with self._lock:
+ self.db.execute("DELETE FROM docs WHERE path=?", (path,))
+
+ def known(self):
+ with self._lock:
+ return {r["path"]: (r["mtime"], r["size"])
+ for r in self.db.execute("SELECT path, mtime, size"
+ " FROM docs").fetchall()}
+
+ def commit(self):
+ with self._lock:
+ self.db.commit()
+
+
+class DocIndexer(object):
+ """Walk + filter + incrementele indexering. Puur, testbaar."""
+
+ def __init__(self, engine):
+ self.engine = engine
+
+ @staticmethod
+ def is_indexable(name, full):
+ base = os.path.basename(name)
+ if base.startswith("."):
+ return False
+ _, ext = os.path.splitext(base)
+ if ext.lower() not in TEXT_EXTS:
+ return False
+ try:
+ if os.path.getsize(full) > MAX_FILE:
+ return False
+ except OSError:
+ return False
+ return True
+
+ @staticmethod
+ def read_text(full):
+ try:
+ with open(full, "rb") as fh:
+ raw = fh.read(MAX_FILE + 1)
+ except OSError:
+ return None
+ if b"\x00" in raw[:8192]:
+ return None
+ try:
+ return raw.decode("utf-8")
+ except UnicodeDecodeError:
+ try:
+ return raw.decode("latin-1")
+ except UnicodeDecodeError:
+ return None
+
+ def index_dir(self, root):
+ root = os.path.abspath(root)
+ known = self.engine.known()
+ seen = set()
+ added = updated = skipped = 0
+ for dirpath, dirnames, filenames in os.walk(root):
+ dirnames[:] = [d for d in dirnames if not d.startswith(".")]
+ dirnames.sort()
+ for name in sorted(filenames):
+ full = os.path.join(dirpath, name)
+ rel = os.path.relpath(full, root)
+ if len(seen) >= MAX_FILES:
+ skipped += 1
+ continue
+ if not self.is_indexable(name, full):
+ skipped += 1
+ continue
+ try:
+ st = os.stat(full)
+ except OSError:
+ skipped += 1
+ continue
+ seen.add(rel)
+ prev = known.get(rel)
+ if prev == (st.st_mtime, st.st_size):
+ continue
+ text = self.read_text(full)
+ if text is None:
+ skipped += 1
+ continue
+ title = os.path.basename(rel)
+ self.engine.upsert(rel, title, text, st.st_mtime, st.st_size)
+ if prev is None:
+ added += 1
+ else:
+ updated += 1
+ removed = 0
+ for rel in known:
+ if rel not in seen:
+ self.engine.remove(rel)
+ removed += 1
+ self.engine.commit()
+ return {"added": added, "updated": updated, "removed": removed,
+ "skipped": skipped, "total": len(seen)}
+
+
+class SearchHandler(BaseHTTPRequestHandler):
+ server_version = "TaiSearch/1.0"
+ protocol_version = "HTTP/1.1"
+
+ def log_message(self, fmt, *args):
+ pass
+
+ def _send(self, code, body=b"", ctype="text/plain; charset=utf-8"):
+ if isinstance(body, str):
+ body = body.encode("utf-8")
+ self.send_response(code)
+ self.send_header("Content-Type", ctype)
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ if self.command != "HEAD" and body:
+ self.wfile.write(body)
+
+ def _json(self, code, obj):
+ self._send(code, json.dumps(obj), "application/json")
+
+ def do_GET(self):
+ try:
+ parsed = urlparse(self.path)
+ if parsed.path == "/":
+ self._send(200, SEARCH_HTML, "text/html; charset=utf-8")
+ return
+ if parsed.path == "/api/stats":
+ self._json(200, self.server.engine.stats())
+ return
+ if parsed.path == "/api/search":
+ qs = parse_qs(parsed.query)
+ try:
+ limit = int(qs.get("limit", ["20"])[0])
+ offset = int(qs.get("offset", ["0"])[0])
+ except ValueError:
+ limit, offset = 20, 0
+ total, items = self.server.engine.search(
+ qs.get("q", [""])[0], limit=limit, offset=offset)
+ self._json(200, {"total": total, "limit": limit,
+ "offset": offset, "items": items})
+ return
+ self._send(404, "not found\n")
+ except BrokenPipeError:
+ pass
+
+
+SEARCH_HTML = """
+
+
+Zoeken
+
+Zoeken
+
+
+
+
+"""
+
+
+class SearchServer(socketserver.ThreadingMixIn, HTTPServer):
+ daemon_threads = True
+ allow_reuse_address = True
+
+
+def default_db():
+ home = os.environ.get("TAI_HOME") or os.path.expanduser("~/.tai")
+ try:
+ os.makedirs(home, exist_ok=True)
+ return os.path.join(home, "fts-example.db")
+ except OSError:
+ return os.path.abspath("./fts-example.db")
+
+
+def cmd_index(args):
+ if not os.path.isdir(args.dir):
+ raise SearchError("geen directory: %s" % args.dir)
+ engine = SearchEngine(args.db)
+ result = DocIndexer(engine).index_dir(args.dir)
+ print("index: %(added)d nieuw, %(updated)d bijgewerkt, "
+ "%(removed)d weg, %(skipped)d overgeslagen "
+ "(totaal %(total)d) -> %(db)s" % dict(result, db=args.db))
+
+
+def cmd_search(args):
+ if not os.path.exists(args.db):
+ raise SearchError("geen index (%s). Run eerst: index " % args.db)
+ engine = SearchEngine(args.db)
+ total, items = engine.search(args.query, limit=args.limit,
+ offset=args.offset)
+ print("%d resultaat(en) voor %r:" % (total, args.query))
+ for item in items:
+ clean = item["snippet"].replace("", "").replace("", "")
+ print(" [%.2f] %s\n %s" % (item["rank"], item["path"], clean))
+
+
+def cmd_serve(args):
+ if not os.path.exists(args.db):
+ raise SearchError("geen index (%s). Run eerst: index " % args.db)
+ engine = SearchEngine(args.db)
+ stats = engine.stats()
+ server = SearchServer((args.host, args.port), SearchHandler)
+ server.engine = engine
+ print("search: http://%s:%d/ (%d docs)" % (args.host, args.port,
+ stats["docs"]))
+ try:
+ server.serve_forever()
+ except KeyboardInterrupt:
+ pass
+
+
+def main(argv=None):
+ parser = argparse.ArgumentParser(
+ description="SQLite FTS5 full-text search over een directory.")
+ parser.add_argument("--db", default=None, help="sqlite index file")
+ sub = parser.add_subparsers(dest="cmd") # 3.6: geen required-kwarg
+ sub.required = True
+ p_index = sub.add_parser("index", help="directory indexeren")
+ p_index.add_argument("dir")
+ p_search = sub.add_parser("search", help="zoeken op CLI")
+ p_search.add_argument("query")
+ p_search.add_argument("--limit", type=int, default=20)
+ p_search.add_argument("--offset", type=int, default=0)
+ p_serve = sub.add_parser("serve", help="HTTP UI + JSON API starten")
+ p_serve.add_argument("--host", default="127.0.0.1")
+ p_serve.add_argument("--port", type=int, default=8083)
+ args = parser.parse_args(argv)
+ if args.db is None:
+ args.db = default_db()
+ try:
+ if args.cmd == "index":
+ cmd_index(args)
+ elif args.cmd == "search":
+ cmd_search(args)
+ elif args.cmd == "serve":
+ cmd_serve(args)
+ except SearchError as exc:
+ parser.error(str(exc))
+
+
+if __name__ == "__main__":
+ main()
+
diff --git a/examples/reverse-proxy.py b/examples/reverse-proxy.py
new file mode 100755
index 0000000..44665a3
--- /dev/null
+++ b/examples/reverse-proxy.py
@@ -0,0 +1,310 @@
+#!/usr/bin/env python3
+"""Round-robin reverse proxy met healthchecks. Stdlib only, single file, 3.6+.
+
+Run:
+ python3 reverse-proxy.py --backends http://127.0.0.1:9001,http://127.0.0.1:9002
+ python3 reverse-proxy.py --backends http://127.0.0.1:9001 --port 8080 --health-path /healthz
+
+Spec (dit bestand is de spec):
+
+ Gedrag:
+ - Round-robin over gezonde backends (alleen http, geen https-targets).
+ - Actieve healthcheck: GET {health-path} elke --health-interval s;
+ 2xx/3xx is gezond. Ongezonde backend tijdelijk uit rotatie.
+ - Passieve healthcheck: connect/read-fout markeert backend direct down
+ en de request failovert naar de volgende gezonde backend.
+ - Retries: maximaal N pogingen (N = aantal backends), nooit een body
+ tweemaal sturen bij POST/PUT tenzij klein genoeg om te bufferen.
+ - Hop-header hygiene: Connection, Keep-Alive, Proxy-*, TE, Trailer,
+ Transfer-Encoding en Upgrade worden nooit doorgestuurd.
+ - X-Forwarded-For (client-IP appended), X-Forwarded-Proto, X-Forwarded-Host
+ en Via worden gezet.
+ - GET /__proxy__/status: JSON met backend-staten (alleen dit pad is
+ van de proxy zelf; de rest wordt 1-op-1 doorgezet).
+
+Caps: MAX_BODY 10 MiB, TIMEOUT 10 s (connect) / 30 s (read),
+HEALTH_INTERVAL 10 s, HEALTH_TIMEOUT 3 s, MAX_BACKENDS 32.
+"""
+
+import argparse
+import json
+import socketserver
+import threading
+import time
+from http.client import HTTPConnection, HTTPException
+from http.server import BaseHTTPRequestHandler, HTTPServer
+from urllib.parse import urlparse
+
+HOP_HEADERS = frozenset([
+ "connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
+ "te", "trailer", "transfer-encoding", "upgrade",
+])
+MAX_BODY = 10 * 1024 * 1024
+CONNECT_TIMEOUT = 10
+READ_TIMEOUT = 30
+
+
+class Backend(object):
+ """Een upstream + zijn gezondheidsstaat."""
+
+ def __init__(self, url):
+ parsed = urlparse(url.strip())
+ if parsed.scheme != "http" or not parsed.hostname:
+ raise ValueError("backend must be http://host[:port][/prefix]: %s"
+ % url)
+ self.name = url.strip()
+ self.host = parsed.hostname
+ self.port = parsed.port or 80
+ self.prefix = parsed.path.rstrip("/")
+ self._lock = threading.RLock()
+ self.healthy = True
+ self.last_check = 0.0
+ self.last_error = ""
+ self.requests = 0
+ self.failures = 0
+
+ def mark(self, healthy, error=""):
+ with self._lock:
+ self.healthy = healthy
+ self.last_check = time.time()
+ self.last_error = error[:200] if error else ""
+ if healthy:
+ self.requests += 1
+ else:
+ self.failures += 1
+
+ def status(self):
+ with self._lock:
+ return {"name": self.name, "healthy": self.healthy,
+ "last_check": self.last_check,
+ "last_error": self.last_error,
+ "requests": self.requests, "failures": self.failures}
+
+
+class BackendPool(object):
+ """Round-robin over gezonde backends. Thread-safe."""
+
+ def __init__(self, backends):
+ self.backends = backends
+ self._lock = threading.RLock()
+ self._next = 0
+
+ def pick_order(self):
+ """Volgorde voor deze request: rotatie-start, gezonde eerst."""
+ with self._lock:
+ backends = list(self.backends)
+ start = self._next % len(backends)
+ self._next += 1
+ ordered = backends[start:] + backends[:start]
+ healthy = [b for b in ordered if b.healthy]
+ return healthy + [b for b in ordered if not b.healthy]
+
+ def status(self):
+ return {"backends": [b.status() for b in self.backends]}
+
+
+class HealthChecker(threading.Thread):
+ """Achtergrondlus: pollt elke interval het health-path per backend."""
+
+ daemon = True
+
+ def __init__(self, pool, path="/", interval=10, timeout=3):
+ super(HealthChecker, self).__init__(name="healthchecker")
+ self.pool = pool
+ self.path = path or "/"
+ self.interval = max(1, interval)
+ self.timeout = timeout
+ self._stop = threading.Event()
+
+ def stop(self):
+ self._stop.set()
+
+ def run(self):
+ while not self._stop.wait(self.interval):
+ for backend in self.pool.backends:
+ self.check_once(backend)
+
+ def check_once(self, backend):
+ try:
+ conn = HTTPConnection(backend.host, backend.port,
+ timeout=self.timeout)
+ try:
+ conn.request("GET", backend.prefix + self.path,
+ headers={"Connection": "close",
+ "Host": backend.host})
+ resp = conn.getresponse()
+ resp.read(65536)
+ ok = 200 <= resp.status < 400
+ backend.mark(True if ok else False,
+ "" if ok else "health %d" % resp.status)
+ finally:
+ conn.close()
+ except Exception as exc:
+ backend.mark(False, "%s: %s" % (type(exc).__name__, exc))
+
+
+class ProxyHandler(BaseHTTPRequestHandler):
+ server_version = "TaiProxy/1.0"
+ protocol_version = "HTTP/1.1"
+
+ def log_message(self, fmt, *args):
+ pass
+
+ def _send(self, code, body=b"", ctype="text/plain; charset=utf-8"):
+ if isinstance(body, str):
+ body = body.encode("utf-8")
+ self.send_response(code)
+ self.send_header("Content-Type", ctype)
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ if self.command != "HEAD" and body:
+ self.wfile.write(body)
+
+ def _forward_headers(self):
+ client_ip = self.client_address[0]
+ fwd = self.headers.get("X-Forwarded-For", "")
+ out = {}
+ for key, value in self.headers.items():
+ if key.lower() in HOP_HEADERS:
+ continue
+ out[key] = value
+ out["X-Forwarded-For"] = (fwd + ", " + client_ip) if fwd else client_ip
+ out["X-Forwarded-Proto"] = "http"
+ out["X-Forwarded-Host"] = self.headers.get("Host", "")
+ via = self.headers.get("Via", "")
+ out["Via"] = (via + ", " if via else "") + "1.1 TaiProxy"
+ out["Connection"] = "close"
+ return out
+
+ def _read_body(self):
+ try:
+ length = int(self.headers.get("Content-Length", "0") or "0")
+ except ValueError:
+ return None
+ if length < 0 or length > MAX_BODY:
+ return None
+ if length == 0:
+ return b""
+ return self.rfile.read(length)
+
+ def _try_backend(self, backend, path, body, headers):
+ target = backend.prefix + path
+ conn = HTTPConnection(backend.host, backend.port,
+ timeout=CONNECT_TIMEOUT)
+ try:
+ out_headers = dict(headers)
+ out_headers["Host"] = backend.host + (
+ "" if backend.port == 80 else ":%d" % backend.port)
+ conn.request(self.command, target, body=body,
+ headers=out_headers)
+ conn.sock.settimeout(READ_TIMEOUT)
+ resp = conn.getresponse()
+ self.send_response(resp.status, resp.reason)
+ for key, value in resp.getheaders():
+ if key.lower() in HOP_HEADERS:
+ continue
+ self.send_header(key, value)
+ self.send_header("Via", "1.1 TaiProxy")
+ self.end_headers()
+ if self.command != "HEAD":
+ while True:
+ chunk = resp.read(65536)
+ if not chunk:
+ break
+ self.wfile.write(chunk)
+ backend.mark(True)
+ return True
+ finally:
+ try:
+ conn.close()
+ except Exception:
+ pass
+
+ def _proxy(self):
+ pool = self.server.pool # type: BackendPool
+ if self.path == "/__proxy__/status" and self.command == "GET":
+ self._send(200, json.dumps(pool.status()), "application/json")
+ return
+ body = self._read_body()
+ if body is None:
+ self._send(413, "body too large or bad length (max 10 MiB)")
+ return
+ headers = self._forward_headers()
+ errors = []
+ for backend in pool.pick_order():
+ try:
+ if self._try_backend(backend, self.path, body, headers):
+ return
+ except (HTTPException, OSError) as exc:
+ backend.mark(False, "%s: %s" % (type(exc).__name__, exc))
+ errors.append("%s: %s" % (backend.name, exc))
+ continue
+ except BrokenPipeError:
+ return
+ self._send(502, "no healthy backend (%s)" % "; ".join(errors))
+
+ def _dispatch(self):
+ try:
+ self._proxy()
+ except BrokenPipeError:
+ pass
+ except Exception as exc:
+ try:
+ self._send(500, "proxy error: %s" % exc)
+ except (BrokenPipeError, OSError):
+ pass
+
+ do_GET = _dispatch
+ do_HEAD = _dispatch
+ do_POST = _dispatch
+ do_PUT = _dispatch
+ do_PATCH = _dispatch
+ do_DELETE = _dispatch
+ do_OPTIONS = _dispatch
+
+
+class ProxyServer(socketserver.ThreadingMixIn, HTTPServer):
+ daemon_threads = True
+ allow_reuse_address = True
+
+
+def main(argv=None):
+ parser = argparse.ArgumentParser(
+ description="Round-robin reverse proxy met healthchecks.")
+ parser.add_argument("--backends", required=True,
+ help="comma-separated http://host:port[/prefix]")
+ parser.add_argument("--host", default="127.0.0.1")
+ parser.add_argument("--port", type=int, default=8080)
+ parser.add_argument("--health-path", default="/",
+ help="pad voor healthchecks (default /)")
+ parser.add_argument("--health-interval", type=int, default=10,
+ help="seconden tussen healthchecks (default 10)")
+ args = parser.parse_args(argv)
+ urls = [u for u in args.backends.split(",") if u.strip()]
+ if not urls or len(urls) > 32:
+ parser.error("need 1..32 backends")
+ try:
+ backends = [Backend(u) for u in urls]
+ except ValueError as exc:
+ parser.error(str(exc))
+ pool = BackendPool(backends)
+ server = ProxyServer((args.host, args.port), ProxyHandler)
+ server.pool = pool
+ checker = HealthChecker(pool, path=args.health_path,
+ interval=args.health_interval)
+ for backend in backends:
+ checker.check_once(backend)
+ checker.start()
+ print("proxy: http://%s:%d/ -> %s" % (args.host, args.port,
+ ", ".join(urls)))
+ try:
+ server.serve_forever()
+ except KeyboardInterrupt:
+ pass
+ finally:
+ checker.stop()
+
+
+if __name__ == "__main__":
+ main()
+
diff --git a/examples/sse-chat.py b/examples/sse-chat.py
new file mode 100755
index 0000000..8e46154
--- /dev/null
+++ b/examples/sse-chat.py
@@ -0,0 +1,435 @@
+#!/usr/bin/env python3
+"""Multi-room chat met Server-Sent Events. Stdlib only, single file, 3.6+.
+
+Run:
+ python3 sse-chat.py --port 8082
+
+Open http://127.0.0.1:8082/ in twee browsers en chat. Geen JS-deps:
+alleen EventSource + fetch.
+
+Spec (dit bestand is de spec):
+
+ Endpoint Method Statusen Beschrijving
+ /api/rooms GET 200 Kamers + aantallen
+ /api/rooms POST 201/400 Kamer aanmaken {"room"}
+ /api/rooms/{room}/messages GET 200/404 History (?since=&limit=)
+ /api/rooms/{room}/messages POST 201/400/404 Bericht {"nick","text"}
+ /events?room={room} GET 200/404 SSE-stream (id/event/retry)
+ / GET 200 Embedded UI
+ /healthz GET 200 {"ok":true,...}
+
+ SSE-events: "message" (JSON-bericht), "hello" (laatste id bij connect).
+ Reconnect: Last-Event-ID-header of ?since= wordt gerespecteerd, gemiste
+ berichten worden nagezonden. Heartbeat: ": ping" elke 15 s.
+
+ Kamernamen: ^[a-z0-9-]{1,32}$. Standaardkamers: lobby, random, tech.
+
+Caps: MAX_ROOMS 50, MAX_HISTORY 200/room, MAX_NICK 32, MAX_TEXT 2000,
+MAX_BODY 8 KiB, HEARTBEAT 15 s.
+"""
+
+import argparse
+import json
+import re
+import socketserver
+import threading
+import time
+from collections import deque
+from http.server import BaseHTTPRequestHandler, HTTPServer
+from urllib.parse import parse_qs, urlparse
+
+try:
+ from queue import Queue, Empty
+except ImportError: # Python 2 fallback, nooit gebruikt maar goedkoop
+ from Queue import Queue, Empty
+
+ROOM_RE = re.compile(r"^[a-z0-9-]{1,32}$")
+
+
+class ChatError(Exception):
+ def __init__(self, status, message):
+ super(ChatError, self).__init__(message)
+ self.status = status
+ self.message = message
+
+
+class ChatStore(object):
+ """Kamers, history en SSE-abonnees. Alles achter een RLock."""
+
+ MAX_ROOMS = 50
+ MAX_HISTORY = 200
+ MAX_SUBSCRIBERS = 500
+ DEFAULT_ROOMS = ("lobby", "random", "tech")
+
+ def __init__(self):
+ self._lock = threading.RLock()
+ self._rooms = {}
+ self._subs = {}
+ for room in self.DEFAULT_ROOMS:
+ self._rooms[room] = {"messages": deque(maxlen=self.MAX_HISTORY),
+ "next_id": 1}
+
+ @staticmethod
+ def _now():
+ return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
+
+ def rooms(self):
+ with self._lock:
+ return [{"room": name, "messages": len(info["messages"])}
+ for name, info in sorted(self._rooms.items())]
+
+ def create_room(self, room):
+ if not isinstance(room, str) or not ROOM_RE.match(room):
+ raise ChatError(400, "bad room (want [a-z0-9-]{1,32})")
+ with self._lock:
+ if room in self._rooms:
+ raise ChatError(409, "room exists")
+ if len(self._rooms) >= self.MAX_ROOMS:
+ raise ChatError(507, "too many rooms")
+ self._rooms[room] = {"messages": deque(maxlen=self.MAX_HISTORY),
+ "next_id": 1}
+ return {"room": room, "messages": 0}
+
+ def _require(self, room):
+ if room not in self._rooms:
+ raise ChatError(404, "no such room")
+ return self._rooms[room]
+
+ def history(self, room, since=0, limit=100):
+ with self._lock:
+ info = self._require(room)
+ items = [m for m in info["messages"] if m["id"] > since]
+ return items[-limit:]
+
+ def post(self, room, nick, text):
+ if not isinstance(nick, str) or not 1 <= len(nick.strip()) <= 32:
+ raise ChatError(400, "nick must be 1..32 chars")
+ if not isinstance(text, str) or not 1 <= len(text.strip()) <= 2000:
+ raise ChatError(400, "text must be 1..2000 chars")
+ with self._lock:
+ info = self._require(room)
+ msg = {"id": info["next_id"], "room": room,
+ "nick": nick.strip()[:32], "text": text.strip()[:2000],
+ "ts": self._now()}
+ info["next_id"] += 1
+ info["messages"].append(msg)
+ subs = list(self._subs.get(room, []))
+ for queue in subs:
+ try:
+ queue.put_nowait(msg)
+ except Exception:
+ pass
+ return msg
+
+ def subscribe(self, room):
+ with self._lock:
+ self._require(room)
+ total = sum(len(v) for v in self._subs.values())
+ if total >= self.MAX_SUBSCRIBERS:
+ raise ChatError(503, "too many subscribers")
+ queue = Queue(maxsize=200)
+ self._subs.setdefault(room, []).append(queue)
+ return queue
+
+ def unsubscribe(self, room, queue):
+ with self._lock:
+ subs = self._subs.get(room, [])
+ if queue in subs:
+ subs.remove(queue)
+
+
+class ChatHandler(BaseHTTPRequestHandler):
+ server_version = "TaiChat/1.0"
+ protocol_version = "HTTP/1.1"
+ MAX_BODY = 8 * 1024
+ HEARTBEAT = 15
+
+ def log_message(self, fmt, *args):
+ pass
+
+ def _send(self, code, body=b"", ctype="text/plain; charset=utf-8"):
+ if isinstance(body, str):
+ body = body.encode("utf-8")
+ self.send_response(code)
+ self.send_header("Content-Type", ctype)
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ if self.command != "HEAD" and body:
+ self.wfile.write(body)
+
+ def _json(self, code, obj):
+ self._send(code, json.dumps(obj), "application/json")
+
+ def _read_json(self):
+ try:
+ length = int(self.headers.get("Content-Length", "0") or "0")
+ except ValueError:
+ raise ChatError(400, "bad Content-Length")
+ if length < 0 or length > self.MAX_BODY:
+ raise ChatError(413, "body too large")
+ raw = self.rfile.read(length) if length else b""
+ try:
+ data = json.loads((raw or b"{}").decode("utf-8"))
+ except (UnicodeDecodeError, ValueError):
+ raise ChatError(400, "body is not valid JSON")
+ if not isinstance(data, dict):
+ raise ChatError(400, "body must be a JSON object")
+ return data
+
+ def _events(self, room, since):
+ store = self.server.chat # type: ChatStore
+ try:
+ queue = store.subscribe(room)
+ except ChatError as exc:
+ self._json(exc.status, {"error": exc.message})
+ return
+ self.send_response(200)
+ self.send_header("Content-Type", "text/event-stream")
+ self.send_header("Cache-Control", "no-cache")
+ self.send_header("Connection", "keep-alive")
+ self.send_header("X-Accel-Buffering", "no")
+ self.end_headers()
+ try:
+ self._sse("hello", {"room": room, "since": since})
+ for msg in store.history(room, since=since, limit=200):
+ self._sse("message", msg, msg["id"])
+ while True:
+ try:
+ msg = queue.get(timeout=self.HEARTBEAT)
+ except Empty:
+ self.wfile.write(b": ping\n\n")
+ self.wfile.flush()
+ continue
+ if msg["id"] <= since:
+ continue
+ since = msg["id"]
+ self._sse("message", msg, msg["id"])
+ except (BrokenPipeError, ConnectionResetError, OSError):
+ pass
+ finally:
+ store.unsubscribe(room, queue)
+
+ def _sse(self, event, obj, msg_id=None):
+ lines = ["event: %s" % event]
+ if msg_id is not None:
+ lines.append("id: %d" % msg_id)
+ lines.append("retry: 3000")
+ for chunk in json.dumps(obj).split("\n"):
+ lines.append("data: %s" % chunk)
+ self.wfile.write(("\n".join(lines) + "\n\n").encode("utf-8"))
+ self.wfile.flush()
+
+ def _handle(self):
+ store = self.server.chat # type: ChatStore
+ parsed = urlparse(self.path)
+ parts = [p for p in parsed.path.split("/") if p]
+ method = self.command
+ if not parts:
+ if method != "GET":
+ raise ChatError(405, "method not allowed")
+ self._send(200, CHAT_HTML, "text/html; charset=utf-8")
+ return
+ if parts == ["healthz"]:
+ rooms = store.rooms()
+ self._json(200, {"ok": True, "rooms": len(rooms),
+ "messages": sum(r["messages"] for r in rooms)})
+ return
+ if parts == ["events"] and method == "GET":
+ qs = parse_qs(parsed.query)
+ room = qs.get("room", ["lobby"])[0]
+ since = qs.get("since", ["0"])[0]
+ last_id = self.headers.get("Last-Event-ID", "").strip()
+ if last_id.isdigit():
+ since = last_id
+ try:
+ since = max(0, int(since))
+ except ValueError:
+ raise ChatError(400, "bad since")
+ self._events(room, since)
+ return
+ if parts[0] != "api" or len(parts) < 2 or parts[1] != "rooms":
+ raise ChatError(404, "unknown endpoint")
+ if len(parts) == 2:
+ if method == "GET":
+ self._json(200, {"rooms": store.rooms()})
+ elif method == "POST":
+ data = self._read_json()
+ self._json(201, store.create_room(data.get("room", "")))
+ else:
+ raise ChatError(405, "method not allowed")
+ return
+ room = parts[2]
+ if len(parts) == 4 and parts[3] == "messages":
+ if method == "GET":
+ qs = parse_qs(parsed.query)
+ try:
+ since = max(0, int(qs.get("since", ["0"])[0]))
+ limit = min(max(int(qs.get("limit", ["100"])[0]), 1), 200)
+ except ValueError:
+ raise ChatError(400, "bad since/limit")
+ self._json(200, {"room": room, "messages": store.history(
+ room, since=since, limit=limit)})
+ elif method == "POST":
+ data = self._read_json()
+ self._json(201, store.post(room, data.get("nick", ""),
+ data.get("text", "")))
+ else:
+ raise ChatError(405, "method not allowed")
+ return
+ raise ChatError(404, "unknown endpoint")
+
+ def _dispatch(self):
+ try:
+ self._handle()
+ except ChatError as exc:
+ self._json(exc.status, {"error": exc.message})
+ except BrokenPipeError:
+ pass
+
+ do_GET = _dispatch
+ do_HEAD = _dispatch
+ do_POST = _dispatch
+
+
+CHAT_HTML = """
+
+
+Chat
+
+
+
+
+
+
+
+
+
+"""
+
+
+class ChatServer(socketserver.ThreadingMixIn, HTTPServer):
+ daemon_threads = True
+ allow_reuse_address = True
+
+
+def main(argv=None):
+ parser = argparse.ArgumentParser(
+ description="Multi-room SSE chat (stdlib only).")
+ parser.add_argument("--host", default="127.0.0.1")
+ parser.add_argument("--port", type=int, default=8082)
+ args = parser.parse_args(argv)
+ server = ChatServer((args.host, args.port), ChatHandler)
+ server.chat = ChatStore()
+ print("chat: http://%s:%d/" % (args.host, args.port))
+ try:
+ server.serve_forever()
+ except KeyboardInterrupt:
+ pass
+
+
+if __name__ == "__main__":
+ main()
+
diff --git a/examples/todo-rest-api.py b/examples/todo-rest-api.py
new file mode 100755
index 0000000..4cca260
--- /dev/null
+++ b/examples/todo-rest-api.py
@@ -0,0 +1,501 @@
+#!/usr/bin/env python3
+"""Todo REST API + SQLite + embedded UI. Stdlib only, single file, 3.6+.
+
+Run:
+ python3 todo-rest-api.py --port 8081
+ python3 todo-rest-api.py --port 8081 --db ./todos.db
+
+Open http://127.0.0.1:8081/ voor de UI, of praat JSON tegen /api/todos.
+
+Spec (dit bestand is de spec):
+
+ Endpoint Method Statusen Beschrijving
+ /api/todos GET 200 Lijst (filter/paging/sort)
+ /api/todos POST 201/400/413 Aanmaken, JSON body
+ /api/todos/{id} GET 200/404 Een todo
+ /api/todos/{id} PUT 200/400/404 Vervangen (volledig)
+ /api/todos/{id} PATCH 200/400/404 Deels bijwerken
+ /api/todos/{id} DELETE 204/404 Verwijderen
+ /api/todos/{id}/toggle POST 200/404 done-flag omklappen
+ / GET 200 Embedded UI (geen deps)
+ /healthz GET 200 {"ok": true, "todos": N}
+
+ Query params (lijst): status=open|done|all (default all), q=zoektekst,
+ limit=1..100 (default 50), offset>=0 (default 0),
+ sort=created|updated|priority|title (default updated),
+ order=asc|desc (default desc).
+
+ Todo-object: {"id": int, "title": str, "notes": str, "priority": 1..5,
+ "done": bool, "due": "YYYY-MM-DD"|null,
+ "created_at": iso, "updated_at": iso}.
+
+Caps: MAX_TITLE 200, MAX_NOTES 5000, MAX_BODY 64 KiB, MAX_LIMIT 100,
+MAX_TODOS 100000. Concurrency: een SQLite-verbinding + RLock, WAL-mode,
+busy_timeout 5s.
+"""
+
+import argparse
+import json
+import os
+import re
+import socketserver
+import sqlite3
+import threading
+import time
+from http.server import BaseHTTPRequestHandler, HTTPServer
+from urllib.parse import parse_qs, urlparse
+
+DUE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
+
+
+class TodoError(Exception):
+ def __init__(self, status, message):
+ super(TodoError, self).__init__(message)
+ self.status = status
+ self.message = message
+
+
+class TodoValidator(object):
+ """Enige plek die beslist wat een geldige todo is."""
+
+ MAX_TITLE = 200
+ MAX_NOTES = 5000
+
+ @classmethod
+ def clean(cls, data, partial=False):
+ if not isinstance(data, dict):
+ raise TodoError(400, "body must be a JSON object")
+ out = {}
+ if "title" in data or not partial:
+ title = data.get("title", "")
+ if not isinstance(title, str) or not title.strip():
+ raise TodoError(400, "title is required (1..200 chars)")
+ title = title.strip()
+ if len(title) > cls.MAX_TITLE:
+ raise TodoError(400, "title too long (max 200)")
+ out["title"] = title
+ if "notes" in data:
+ notes = data["notes"]
+ if notes is None:
+ notes = ""
+ if not isinstance(notes, str):
+ raise TodoError(400, "notes must be a string")
+ if len(notes) > cls.MAX_NOTES:
+ raise TodoError(400, "notes too long (max 5000)")
+ out["notes"] = notes
+ if "priority" in data:
+ prio = data["priority"]
+ if not isinstance(prio, int) or not 1 <= prio <= 5:
+ raise TodoError(400, "priority must be int 1..5")
+ out["priority"] = prio
+ if "done" in data:
+ done = data["done"]
+ if not isinstance(done, bool):
+ raise TodoError(400, "done must be boolean")
+ out["done"] = done
+ if "due" in data:
+ due = data["due"]
+ if due is not None and (not isinstance(due, str)
+ or not DUE_RE.match(due)):
+ raise TodoError(400, "due must be YYYY-MM-DD or null")
+ out["due"] = due
+ return out
+
+
+class TodoStore(object):
+ """SQLite-opslag. Thread-safe via een RLock rond een verbinding."""
+
+ SCHEMA = ("CREATE TABLE IF NOT EXISTS todos ("
+ "id INTEGER PRIMARY KEY AUTOINCREMENT,"
+ "title TEXT NOT NULL,"
+ "notes TEXT NOT NULL DEFAULT '',"
+ "priority INTEGER NOT NULL DEFAULT 3,"
+ "done INTEGER NOT NULL DEFAULT 0,"
+ "due TEXT,"
+ "created_at TEXT NOT NULL,"
+ "updated_at TEXT NOT NULL)")
+ INDEXES = ("CREATE INDEX IF NOT EXISTS idx_todos_done ON todos(done)",
+ "CREATE INDEX IF NOT EXISTS idx_todos_updated "
+ "ON todos(updated_at)",
+ "CREATE INDEX IF NOT EXISTS idx_todos_priority "
+ "ON todos(priority)")
+
+ SORTS = {"created": "created_at", "updated": "updated_at",
+ "priority": "priority", "title": "title"}
+ MAX_TODOS = 100000
+
+ def __init__(self, path):
+ self._lock = threading.RLock()
+ self.db = sqlite3.connect(path, check_same_thread=False)
+ self.db.row_factory = sqlite3.Row
+ self.db.execute("PRAGMA journal_mode=WAL")
+ self.db.execute("PRAGMA busy_timeout=5000")
+ self.db.execute("PRAGMA synchronous=NORMAL")
+ with self.db:
+ self.db.execute(self.SCHEMA)
+ for index in self.INDEXES:
+ self.db.execute(index)
+
+ @staticmethod
+ def _now():
+ return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
+
+ @staticmethod
+ def _row_to_dict(row):
+ return {"id": row["id"], "title": row["title"],
+ "notes": row["notes"], "priority": row["priority"],
+ "done": bool(row["done"]), "due": row["due"],
+ "created_at": row["created_at"],
+ "updated_at": row["updated_at"]}
+
+ def count(self):
+ with self._lock:
+ row = self.db.execute("SELECT COUNT(*) AS n FROM todos").fetchone()
+ return row["n"]
+
+ def create(self, fields):
+ with self._lock:
+ if self.count() >= self.MAX_TODOS:
+ raise TodoError(507, "todo store full")
+ now = self._now()
+ cur = self.db.execute(
+ "INSERT INTO todos (title, notes, priority, done, due,"
+ " created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
+ (fields["title"], fields.get("notes", ""),
+ fields.get("priority", 3), int(fields.get("done", False)),
+ fields.get("due"), now, now))
+ self.db.commit()
+ return self.get(cur.lastrowid)
+
+ def get(self, todo_id):
+ with self._lock:
+ row = self.db.execute("SELECT * FROM todos WHERE id=?",
+ (todo_id,)).fetchone()
+ if row is None:
+ raise TodoError(404, "no such todo")
+ return self._row_to_dict(row)
+
+ def list(self, status="all", q="", limit=50, offset=0,
+ sort="updated", order="desc"):
+ column = self.SORTS.get(sort, "updated_at")
+ direction = "ASC" if order == "asc" else "DESC"
+ where = []
+ params = []
+ if status == "open":
+ where.append("done=0")
+ elif status == "done":
+ where.append("done=1")
+ if q:
+ where.append("(title LIKE ? OR notes LIKE ?)")
+ params.extend(["%" + q + "%", "%" + q + "%"])
+ sql = "SELECT * FROM todos"
+ if where:
+ sql += " WHERE " + " AND ".join(where)
+ sql += " ORDER BY %s %s, id %s LIMIT ? OFFSET ?" % (column, direction,
+ direction)
+ params.extend([limit, offset])
+ count_sql = "SELECT COUNT(*) AS n FROM todos"
+ if where:
+ count_sql += " WHERE " + " AND ".join(where)
+ with self._lock:
+ total = self.db.execute(count_sql, params[:-2]).fetchone()["n"]
+ rows = self.db.execute(sql, params).fetchall()
+ return total, [self._row_to_dict(r) for r in rows]
+
+ def update(self, todo_id, fields):
+ with self._lock:
+ current = self.get(todo_id)
+ merged = {"title": current["title"], "notes": current["notes"],
+ "priority": current["priority"], "done": current["done"],
+ "due": current["due"]}
+ merged.update(fields)
+ now = self._now()
+ self.db.execute(
+ "UPDATE todos SET title=?, notes=?, priority=?, done=?,"
+ " due=?, updated_at=? WHERE id=?",
+ (merged["title"], merged["notes"], merged["priority"],
+ int(merged["done"]), merged["due"], now, todo_id))
+ self.db.commit()
+ return self.get(todo_id)
+
+ def delete(self, todo_id):
+ with self._lock:
+ cur = self.db.execute("DELETE FROM todos WHERE id=?", (todo_id,))
+ self.db.commit()
+ if cur.rowcount == 0:
+ raise TodoError(404, "no such todo")
+
+ def toggle(self, todo_id):
+ with self._lock:
+ current = self.get(todo_id)
+ return self.update(todo_id, {"done": not current["done"]})
+
+
+class TodoHandler(BaseHTTPRequestHandler):
+ """Een instantie per request. Deelt de store via server.store."""
+
+ server_version = "TaiTodo/1.0"
+ protocol_version = "HTTP/1.1"
+ MAX_BODY = 64 * 1024
+
+ def log_message(self, fmt, *args):
+ pass
+
+ # -- generieke hulpjes ------------------------------------------------
+
+ def _send(self, code, body=b"", ctype="text/plain; charset=utf-8",
+ extra=None):
+ if isinstance(body, str):
+ body = body.encode("utf-8")
+ self.send_response(code)
+ self.send_header("Content-Type", ctype)
+ self.send_header("Content-Length", str(len(body)))
+ for key, value in (extra or {}).items():
+ self.send_header(key, value)
+ self.end_headers()
+ if self.command != "HEAD" and body:
+ self.wfile.write(body)
+
+ def _json(self, code, obj):
+ self._send(code, json.dumps(obj), "application/json")
+
+ def _read_json(self):
+ try:
+ length = int(self.headers.get("Content-Length", "0") or "0")
+ except ValueError:
+ raise TodoError(400, "bad Content-Length")
+ if length < 0 or length > self.MAX_BODY:
+ raise TodoError(413, "body too large (max 64 KiB)")
+ raw = self.rfile.read(length) if length else b""
+ if not raw:
+ raise TodoError(400, "empty body, want JSON object")
+ try:
+ return json.loads(raw.decode("utf-8"))
+ except (UnicodeDecodeError, ValueError):
+ raise TodoError(400, "body is not valid JSON")
+
+ def _route(self):
+ """Splits pad in (resource, id, action). Gooit TodoError bij 404."""
+ parsed = urlparse(self.path)
+ parts = [p for p in parsed.path.split("/") if p]
+ if not parts:
+ return ("ui", None, None, parsed)
+ if parts == ["healthz"]:
+ return ("healthz", None, None, parsed)
+ if parts[0] != "api" or len(parts) < 2 or parts[1] != "todos":
+ raise TodoError(404, "unknown endpoint")
+ if len(parts) == 2:
+ return ("todos", None, None, parsed)
+ try:
+ todo_id = int(parts[2])
+ if todo_id <= 0:
+ raise ValueError
+ except ValueError:
+ raise TodoError(404, "bad todo id")
+ if len(parts) == 3:
+ return ("todos", todo_id, None, parsed)
+ if len(parts) == 4 and parts[3] == "toggle":
+ return ("todos", todo_id, "toggle", parsed)
+ raise TodoError(404, "unknown endpoint")
+
+ def _handle(self):
+ store = self.server.store # type: TodoStore
+ resource, todo_id, action, parsed = self._route()
+ method = self.command
+ if resource == "ui":
+ if method not in ("GET", "HEAD"):
+ raise TodoError(405, "method not allowed")
+ self._send(200, UI_HTML, "text/html; charset=utf-8")
+ return
+ if resource == "healthz":
+ if method not in ("GET", "HEAD"):
+ raise TodoError(405, "method not allowed")
+ self._json(200, {"ok": True, "todos": store.count()})
+ return
+ if todo_id is None:
+ if method == "GET":
+ qs = parse_qs(parsed.query)
+ status = qs.get("status", ["all"])[0]
+ if status not in ("open", "done", "all"):
+ raise TodoError(400, "status must be open|done|all")
+ try:
+ limit = int(qs.get("limit", ["50"])[0])
+ offset = int(qs.get("offset", ["0"])[0])
+ except ValueError:
+ raise TodoError(400, "limit/offset must be ints")
+ limit = min(max(limit, 1), 100)
+ offset = max(offset, 0)
+ total, items = store.list(
+ status=status, q=qs.get("q", [""])[0][:200],
+ limit=limit, offset=offset,
+ sort=qs.get("sort", ["updated"])[0],
+ order=qs.get("order", ["desc"])[0])
+ self._json(200, {"total": total, "limit": limit,
+ "offset": offset, "items": items})
+ elif method == "POST":
+ todo = store.create(TodoValidator.clean(self._read_json()))
+ self._json(201, todo)
+ else:
+ raise TodoError(405, "method not allowed")
+ return
+ if action == "toggle":
+ if method != "POST":
+ raise TodoError(405, "method not allowed")
+ self._json(200, store.toggle(todo_id))
+ return
+ if method == "GET":
+ self._json(200, store.get(todo_id))
+ elif method == "PUT":
+ self._json(200, store.update(
+ todo_id, TodoValidator.clean(self._read_json())))
+ elif method == "PATCH":
+ self._json(200, store.update(
+ todo_id, TodoValidator.clean(self._read_json(), partial=True)))
+ elif method == "DELETE":
+ store.delete(todo_id)
+ self._send(204, b"")
+ else:
+ raise TodoError(405, "method not allowed")
+
+ def _dispatch(self):
+ try:
+ self._handle()
+ except TodoError as exc:
+ self._json(exc.status, {"error": exc.message})
+ except BrokenPipeError:
+ pass
+
+ do_GET = _dispatch
+ do_HEAD = _dispatch
+ do_POST = _dispatch
+ do_PUT = _dispatch
+ do_PATCH = _dispatch
+ do_DELETE = _dispatch
+
+
+UI_HTML = """
+
+
+Todo
+
+Todo
+
+
+
+
+
+"""
+
+
+class TodoServer(socketserver.ThreadingMixIn, HTTPServer):
+ daemon_threads = True
+ allow_reuse_address = True
+
+
+def main(argv=None):
+ parser = argparse.ArgumentParser(
+ description="Todo REST API + SQLite + embedded UI (stdlib only).")
+ parser.add_argument("--host", default="127.0.0.1")
+ parser.add_argument("--port", type=int, default=8081)
+ parser.add_argument("--db", default=":memory:",
+ help="sqlite file (default :memory:)")
+ args = parser.parse_args(argv)
+ if args.db != ":memory:":
+ parent = os.path.dirname(os.path.abspath(args.db))
+ if not os.path.isdir(parent):
+ parser.error("db directory does not exist: %s" % parent)
+ server = TodoServer((args.host, args.port), TodoHandler)
+ server.store = TodoStore(args.db)
+ print("todo: http://%s:%d/ (db=%s)" % (args.host, args.port, args.db))
+ try:
+ server.serve_forever()
+ except KeyboardInterrupt:
+ pass
+
+
+if __name__ == "__main__":
+ main()
+
diff --git a/examples/webdav-server.py b/examples/webdav-server.py
new file mode 100755
index 0000000..5a24c8c
--- /dev/null
+++ b/examples/webdav-server.py
@@ -0,0 +1,1051 @@
+#!/usr/bin/env python3
+"""Complete WebDAV Class 1 server. Stdlib only, single file, Python 3.6+.
+
+Run:
+ python3 webdav-server.py --root ./share --port 8080
+ python3 webdav-server.py --root ./share --port 8080 --user alice --password s3cret
+
+Works with: cadaver, Nautilus/GNOME Files (dav://), rclone (webdav remote),
+Windows Explorer (Map network drive), curl.
+
+Spec (this file is the spec, DAV class 1):
+
+ Method Statusen Beschrijving
+ OPTIONS 200 DAV: 1, Allow-lijst, MS-Author-Via
+ GET 200/206/304/404 File download, Range, ETag/If-*
+ HEAD als GET zonder body Headers only
+ PUT 201/204/409/423 File aanmaken/overschrijven
+ DELETE 204/404/423 File/dir verwijderen (recursief)
+ MKCOL 201/405/409/415 Directory aanmaken
+ PROPFIND 207 Metadata als multistatus XML
+ PROPPATCH 207 Dead properties set/remove
+ COPY 201/204/412/409 File/dir kopieren (Depth, Overwrite)
+ MOVE 201/204/412/409 File/dir verplaatsen
+ LOCK 200/423 Exclusive write lock, opaquelocktoken
+ UNLOCK 204/400/409 Lock opheffen via Lock-Token
+
+Caps (harde plafonds, zie DavConfig):
+ MAX_XML_BODY 1 MiB PROPFIND/PROPPATCH/LOCK request body
+ MAX_UPLOAD 200 MiB PUT body (instelbaar via --max-upload-mb)
+ MAX_PROPFIND 2000 max entries per PROPFIND (infinity afgekapt)
+ LOCK_TIMEOUT 1 uur default, max 1 dag via Timeout-header
+
+Veiligheid: alle paden gejailed onder --root (symlink-escape geeft 403),
+optionele Basic-auth met constant-time vergelijking.
+"""
+
+import argparse
+import base64
+import hashlib
+import hmac
+import json
+import mimetypes
+import os
+import posixpath
+import shutil
+import socketserver
+import threading
+import time
+import uuid
+from http.server import BaseHTTPRequestHandler, HTTPServer
+from urllib.parse import unquote, urlparse
+from xml.etree import ElementTree as ET
+
+
+DAV_NS = "DAV:"
+ET.register_namespace("D", DAV_NS)
+
+
+def _d(tag):
+ return "{%s}%s" % (DAV_NS, tag)
+
+
+def _http_date(ts):
+ from email.utils import formatdate
+ return formatdate(ts, usegmt=True)
+
+
+class DavConfig(object):
+ """Alle knoppen van de server op een plek. Defaults zijn de spec."""
+
+ MAX_XML_BODY = 1024 * 1024
+ MAX_PROPFIND = 2000
+ LOCK_DEFAULT_TIMEOUT = 3600
+ LOCK_MAX_TIMEOUT = 86400
+
+ def __init__(self, root, user=None, password=None, max_upload_mb=200,
+ readonly=False):
+ self.root = os.path.realpath(os.path.abspath(root))
+ self.user = user
+ self.password = password
+ self.max_upload = max_upload_mb * 1024 * 1024
+ self.readonly = readonly
+
+ @property
+ def auth_enabled(self):
+ return bool(self.user) and self.password is not None
+
+
+class DavStore(object):
+ """Filesystem-laag. Enige plek die paden naar schijf vertaalt."""
+
+ def __init__(self, config):
+ self.config = config
+ self._lock = threading.RLock()
+
+ def resolve(self, uri_path):
+ """Vertaal URI-pad naar schijfpad. None bij jail-escape."""
+ path = unquote(urlparse(uri_path).path)
+ path = posixpath.normpath(path)
+ if path.startswith("/"):
+ path = path[1:]
+ full = os.path.realpath(os.path.join(self.config.root, path))
+ root = self.config.root
+ if full != root and not full.startswith(root + os.sep):
+ return None
+ return full
+
+ def uri_of(self, full, is_dir):
+ rel = os.path.relpath(full, self.config.root)
+ if rel == ".":
+ return "/"
+ parts = rel.split(os.sep)
+ from urllib.parse import quote
+ uri = "/" + "/".join(quote(p, safe="") for p in parts)
+ if is_dir and not uri.endswith("/"):
+ uri += "/"
+ return uri
+
+ @staticmethod
+ def etag(full):
+ try:
+ st = os.stat(full)
+ except OSError:
+ return None
+ h = hashlib.sha1()
+ h.update(("%d:%d" % (int(st.st_mtime), st.st_size)).encode("ascii"))
+ return '"%s"' % h.hexdigest()[:16]
+
+ @staticmethod
+ def content_type(full):
+ guess = mimetypes.guess_type(full)[0]
+ return guess or "application/octet-stream"
+
+
+class DavLocks(object):
+ """In-memory exclusive write locks. Thread-safe."""
+
+ def __init__(self):
+ self._lock = threading.RLock()
+ self._by_token = {}
+ self._by_path = {}
+
+ def _prune(self):
+ now = time.time()
+ dead = [t for t, e in self._by_token.items() if e["expires"] < now]
+ for t in dead:
+ self._release_token(t)
+
+ def _release_token(self, token):
+ entry = self._by_token.pop(token, None)
+ if entry is not None:
+ paths = self._by_path.get(entry["path"], [])
+ if token in paths:
+ paths.remove(token)
+
+ def create(self, path, depth, owner, timeout):
+ with self._lock:
+ self._prune()
+ token = "opaquelocktoken:%s" % uuid.uuid4()
+ entry = {"token": token, "path": path, "depth": depth,
+ "owner": owner or "",
+ "expires": time.time() + timeout}
+ self._by_token[token] = entry
+ self._by_path.setdefault(path, []).append(token)
+ return entry
+
+ def release(self, token):
+ with self._lock:
+ if token not in self._by_token:
+ return False
+ self._release_token(token)
+ return True
+
+ def refresh(self, token, timeout):
+ with self._lock:
+ self._prune()
+ entry = self._by_token.get(token)
+ if entry is None:
+ return None
+ entry["expires"] = time.time() + timeout
+ return entry
+
+ def locked(self, path, token=None):
+ """True als path onder een lock valt die NIET via token is vrijgegeven."""
+ with self._lock:
+ self._prune()
+ if token and token in self._by_token:
+ entry = self._by_token[token]
+ if self._covers(entry, path):
+ return False
+ for entry in self._by_token.values():
+ if entry["token"] == token:
+ continue
+ if self._covers(entry, path):
+ return True
+ return False
+
+ @staticmethod
+ def _covers(entry, path):
+ if path == entry["path"]:
+ return True
+ if entry["depth"] == "infinity" and path.startswith(entry["path"] + os.sep):
+ return True
+ return False
+
+ def for_path(self, path):
+ with self._lock:
+ self._prune()
+ return [e for e in self._by_token.values() if e["path"] == path]
+
+
+class DavProps(object):
+ """Dead properties. JSON-persistent onder root, thread-safe."""
+
+ FILENAME = ".davprops.json"
+
+ def __init__(self, config):
+ self._lock = threading.RLock()
+ self._file = os.path.join(config.root, self.FILENAME)
+ self._data = {}
+ self._load()
+
+ def _load(self):
+ try:
+ with open(self._file, "r", encoding="utf-8") as fh:
+ self._data = json.load(fh)
+ except (OSError, ValueError):
+ self._data = {}
+
+ def _save(self):
+ tmp = self._file + ".tmp"
+ try:
+ with open(tmp, "w", encoding="utf-8") as fh:
+ json.dump(self._data, fh)
+ os.replace(tmp, self._file)
+ except OSError:
+ try:
+ os.unlink(tmp)
+ except OSError:
+ pass
+
+ def get_all(self, uri):
+ with self._lock:
+ return dict(self._data.get(uri, {}))
+
+ def patch(self, uri, set_props, remove_props):
+ with self._lock:
+ cur = self._data.setdefault(uri, {})
+ for qname, value in set_props.items():
+ cur[qname] = value
+ for qname in remove_props:
+ cur.pop(qname, None)
+ if not cur and uri in self._data:
+ del self._data[uri]
+ self._save()
+
+
+class DavXml(object):
+ """Bouw- en parse-hulpjes voor PROPFIND/PROPPATCH/LOCK bodies."""
+
+ ALLPROPS = ("creationdate", "displayname", "getcontentlength",
+ "getcontenttype", "getlastmodified", "getetag",
+ "resourcetype", "supportedlock", "lockdiscovery")
+
+ @staticmethod
+ def parse_body(handler, limit):
+ try:
+ length = int(handler.headers.get("Content-Length", "0") or "0")
+ except ValueError:
+ return None, 400
+ if length < 0 or length > limit:
+ return None, 413
+ if length == 0:
+ return None, 0
+ try:
+ raw = handler.rfile.read(length)
+ except OSError:
+ return None, 400
+ try:
+ return ET.fromstring(raw), 0
+ except ET.ParseError:
+ return None, 400
+
+ @staticmethod
+ def propfind_mode(root):
+ if root is None:
+ return "allprop", []
+ if root.find(_d("allprop")) is not None:
+ return "allprop", []
+ if root.find(_d("propname")) is not None:
+ return "propname", []
+ prop = root.find(_d("prop"))
+ if prop is None:
+ return "allprop", []
+ return "named", list(prop)
+
+ @staticmethod
+ def lockinfo(root):
+ scope = "exclusive"
+ owner = ""
+ if root is not None:
+ if root.find(".//%s" % _d("shared")) is not None:
+ scope = "shared"
+ owner_el = root.find(".//%s" % _d("owner"))
+ if owner_el is not None:
+ owner = "".join(owner_el.itertext()).strip()[:500]
+ return scope, owner
+
+ @staticmethod
+ def proppatch(root):
+ to_set, to_remove = {}, []
+ if root is None:
+ return to_set, to_remove
+ for op in root:
+ kind = op.tag.replace("{%s}" % DAV_NS, "")
+ prop = op.find(_d("prop"))
+ if prop is None:
+ continue
+ for child in prop:
+ if kind == "set":
+ inner = b"".join(
+ ET.tostring(g) for g in child) if len(child) else b""
+ text = (child.text or "").strip()
+ if inner:
+ value = inner.decode("utf-8", "replace")[:4000]
+ else:
+ value = text[:4000]
+ to_set[child.tag] = value
+ elif kind == "remove":
+ to_remove.append(child.tag)
+ return to_set, to_remove
+
+
+class DavHandler(BaseHTTPRequestHandler):
+ """Een DavHandler-instantie per request. Deelt store/locks/props via server."""
+
+ server_version = "TaiWebDAV/1.0"
+ protocol_version = "HTTP/1.1"
+
+ @property
+ def dav(self):
+ return self.server.dav
+
+ def log_message(self, fmt, *args):
+ pass
+
+ # -- generieke hulpjes ------------------------------------------------
+
+ def _send(self, code, body=b"", ctype="text/plain; charset=utf-8",
+ extra=None):
+ if isinstance(body, str):
+ body = body.encode("utf-8")
+ self.send_response(code)
+ self.send_header("Content-Type", ctype)
+ self.send_header("Content-Length", str(len(body)))
+ self.send_header("DAV", "1")
+ self.send_header("MS-Author-Via", "DAV")
+ for key, value in (extra or {}).items():
+ self.send_header(key, value)
+ self.end_headers()
+ if self.command != "HEAD" and body:
+ self.wfile.write(body)
+
+ def _error(self, code, message):
+ self._send(code, message + "\n")
+
+ def _check_auth(self):
+ cfg = self.dav["config"]
+ if not cfg.auth_enabled:
+ return True
+ auth = self.headers.get("Authorization", "")
+ expect = "Basic " + base64.b64encode(
+ ("%s:%s" % (cfg.user, cfg.password)).encode("utf-8")).decode("ascii")
+ if hmac.compare_digest(auth, expect):
+ return True
+ self.send_response(401)
+ self.send_header("WWW-Authenticate", 'Basic realm="webdav"')
+ self.send_header("Content-Length", "13")
+ self.send_header("DAV", "1")
+ self.end_headers()
+ self.wfile.write(b"unauthorized\n")
+ return False
+
+ def _if_token(self):
+ """Haal lock-token uit If-header: ()."""
+ header = self.headers.get("If", "")
+ start = header.find("(<")
+ if start == -1:
+ start = header.find("<")
+ if start == -1:
+ return None
+ end = header.find(">", start)
+ else:
+ end = header.find(">)", start)
+ if end == -1:
+ return None
+ return header[start + 2 if header[start:start + 2] == "(<" else start + 1:end]
+
+ # -- OPTIONS / GET / HEAD ---------------------------------------------
+
+ def do_OPTIONS(self):
+ if not self._check_auth():
+ return
+ self._send(200, "", extra={
+ "Allow": "OPTIONS, GET, HEAD, PUT, DELETE, MKCOL, PROPFIND, "
+ "PROPPATCH, COPY, MOVE, LOCK, UNLOCK",
+ })
+
+ def _serve_file(self, full, uri):
+ store = self.dav["store"] # type: DavStore
+ try:
+ size = os.path.getsize(full)
+ mtime = os.path.getmtime(full)
+ except OSError:
+ self._error(404, "not found")
+ return
+ etag = store.etag(full)
+ if self.headers.get("If-None-Match") == etag:
+ self._send(304, "", extra={"ETag": etag})
+ return
+ ctype = store.content_type(full)
+ range_header = self.headers.get("Range", "")
+ start, end = 0, size - 1
+ partial = False
+ if range_header.startswith("bytes="):
+ try:
+ spec = range_header[len("bytes="):].strip()
+ if spec.startswith("-"):
+ suffix = int(spec[1:])
+ start = max(0, size - suffix)
+ else:
+ first, _, last = spec.partition("-")
+ start = int(first)
+ if last.strip():
+ end = min(int(last), size - 1)
+ if 0 <= start <= end < size:
+ partial = True
+ else:
+ self._send(416, "range not satisfiable", extra={
+ "Content-Range": "bytes */%d" % size})
+ return
+ except ValueError:
+ pass
+ length = end - start + 1
+ self.send_response(206 if partial else 200)
+ self.send_header("Content-Type", ctype)
+ self.send_header("Content-Length", str(length))
+ self.send_header("ETag", etag)
+ self.send_header("Last-Modified", _http_date(mtime))
+ self.send_header("Accept-Ranges", "bytes")
+ self.send_header("DAV", "1")
+ if partial:
+ self.send_header("Content-Range",
+ "bytes %d-%d/%d" % (start, end, size))
+ self.end_headers()
+ if self.command == "HEAD":
+ return
+ try:
+ with open(full, "rb") as fh:
+ fh.seek(start)
+ remaining = length
+ while remaining > 0:
+ chunk = fh.read(min(65536, remaining))
+ if not chunk:
+ break
+ self.wfile.write(chunk)
+ remaining -= len(chunk)
+ except OSError:
+ pass
+
+ def _serve_dir_listing(self, full, uri):
+ try:
+ names = sorted(os.listdir(full))
+ except OSError:
+ self._error(404, "not found")
+ return
+ rows = []
+ for name in names:
+ if name == DavProps.FILENAME:
+ continue
+ child = os.path.join(full, name)
+ is_dir = os.path.isdir(child)
+ href = self.dav["store"].uri_of(child, is_dir)
+ label = name + ("/" if is_dir else "")
+ try:
+ size = "" if is_dir else str(os.path.getsize(child))
+ except OSError:
+ size = "?"
+ rows.append('| %s | %s |
'
+ % (href, label, size))
+ html = (""
+ ""
+ "Index of %s"
+ ""
+ "Index of %s
"
+ "") % (uri, uri, "".join(rows))
+ self._send(200, html, "text/html; charset=utf-8",
+ extra={"ETag": self.dav["store"].etag(full) or ""})
+
+ def do_GET(self):
+ if not self._check_auth():
+ return
+ store = self.dav["store"] # type: DavStore
+ full = store.resolve(self.path)
+ if full is None:
+ self._error(403, "outside root")
+ return
+ if os.path.isdir(full):
+ uri = store.uri_of(full, True)
+ if not self.path.endswith("/"):
+ self.send_response(301)
+ self.send_header("Location", uri)
+ self.send_header("Content-Length", "0")
+ self.send_header("DAV", "1")
+ self.end_headers()
+ return
+ self._serve_dir_listing(full, uri)
+ elif os.path.exists(full):
+ self._serve_file(full, store.uri_of(full, False))
+ else:
+ self._error(404, "not found")
+
+ def do_HEAD(self):
+ self.do_GET()
+
+ # -- PUT / DELETE / MKCOL ---------------------------------------------
+
+ def _mutate_guards(self, full, token=None):
+ cfg = self.dav["config"] # type: DavConfig
+ if cfg.readonly:
+ self._error(403, "read-only server")
+ return False
+ if self.dav["locks"].locked(full, token):
+ self._error(423, "locked")
+ return False
+ return True
+
+ def do_PUT(self):
+ if not self._check_auth():
+ return
+ store = self.dav["store"] # type: DavStore
+ cfg = self.dav["config"] # type: DavConfig
+ full = store.resolve(self.path)
+ if full is None:
+ self._error(403, "outside root")
+ return
+ if os.path.isdir(full):
+ self._error(405, "is a collection")
+ return
+ token = self._if_token()
+ if not self._mutate_guards(full, token):
+ return
+ parent = os.path.dirname(full)
+ if not os.path.isdir(parent):
+ self._error(409, "parent missing")
+ return
+ try:
+ length = int(self.headers.get("Content-Length", "0") or "0")
+ except ValueError:
+ self._error(400, "bad Content-Length")
+ return
+ if length < 0 or length > cfg.max_upload:
+ self._error(413, "upload too large (max %d bytes)" % cfg.max_upload)
+ return
+ existed = os.path.exists(full)
+ tmp = full + ".davtmp-%d" % os.getpid()
+ try:
+ remaining = length
+ with open(tmp, "wb") as fh:
+ while remaining > 0:
+ chunk = self.rfile.read(min(65536, remaining))
+ if not chunk:
+ break
+ fh.write(chunk)
+ remaining -= len(chunk)
+ os.replace(tmp, full)
+ except OSError:
+ try:
+ os.unlink(tmp)
+ except OSError:
+ pass
+ self._error(507, "insufficient storage")
+ return
+ self._send(204 if existed else 201, "",
+ extra={"ETag": store.etag(full) or ""})
+
+ def do_DELETE(self):
+ if not self._check_auth():
+ return
+ store = self.dav["store"] # type: DavStore
+ full = store.resolve(self.path)
+ if full is None:
+ self._error(403, "outside root")
+ return
+ if full == self.dav["config"].root:
+ self._error(403, "refusing to delete root")
+ return
+ if not os.path.exists(full):
+ self._error(404, "not found")
+ return
+ if not self._mutate_guards(full, self._if_token()):
+ return
+ try:
+ if os.path.isdir(full) and not os.path.islink(full):
+ shutil.rmtree(full)
+ else:
+ os.unlink(full)
+ except OSError:
+ self._error(507, "cannot delete")
+ return
+ self._send(204, "")
+
+ def do_MKCOL(self):
+ if not self._check_auth():
+ return
+ store = self.dav["store"] # type: DavStore
+ full = store.resolve(self.path)
+ if full is None:
+ self._error(403, "outside root")
+ return
+ try:
+ length = int(self.headers.get("Content-Length", "0") or "0")
+ except ValueError:
+ length = 0
+ if length:
+ self._error(415, "MKCOL takes no body")
+ return
+ if os.path.exists(full):
+ self._error(405, "already exists")
+ return
+ if not self._mutate_guards(full, self._if_token()):
+ return
+ parent = os.path.dirname(full)
+ if not os.path.isdir(parent):
+ self._error(409, "parent missing")
+ return
+ try:
+ os.mkdir(full)
+ except OSError:
+ self._error(507, "cannot create collection")
+ return
+ self._send(201, "")
+
+ # -- PROPFIND / PROPPATCH ---------------------------------------------
+
+ def _live_props(self, full, uri, is_dir):
+ store = self.dav["store"] # type: DavStore
+ try:
+ st = os.stat(full)
+ except OSError:
+ return {}
+ name = posixpath.basename(uri.rstrip("/")) or "/"
+ props = {
+ _d("creationdate"): time.strftime("%Y-%m-%dT%H:%M:%SZ",
+ time.gmtime(st.st_ctime)),
+ _d("displayname"): name,
+ _d("getlastmodified"): _http_date(st.st_mtime),
+ _d("getetag"): store.etag(full) or "",
+ _d("supportedlock"): ""
+ ""
+ "",
+ }
+ if is_dir:
+ rt = ET.Element(_d("resourcetype"))
+ ET.SubElement(rt, _d("collection"))
+ props[_d("resourcetype")] = rt
+ else:
+ props[_d("resourcetype")] = ET.Element(_d("resourcetype"))
+ props[_d("getcontentlength")] = str(st.st_size)
+ props[_d("getcontenttype")] = store.content_type(full)
+ locks = self.dav["locks"].for_path(full)
+ if locks:
+ discs = []
+ for entry in locks:
+ discs.append(
+ ""
+ ""
+ "%s"
+ "%s"
+ "" % (entry["depth"], entry["token"]))
+ props[_d("lockdiscovery")] = "".join(discs)
+ else:
+ props[_d("lockdiscovery")] = ET.Element(_d("lockdiscovery"))
+ return props
+
+ def _prop_response(self, full, uri, mode, wanted):
+ is_dir = os.path.isdir(full)
+ live = self._live_props(full, uri, is_dir)
+ dead = self.dav["props"].get_all(uri)
+ resp = ET.Element(_d("response"))
+ href = ET.SubElement(resp, _d("href"))
+ href.text = uri
+ if mode == "propname":
+ propstat = ET.SubElement(resp, _d("propstat"))
+ prop = ET.SubElement(propstat, _d("prop"))
+ for qname in list(live) + list(dead):
+ ET.SubElement(prop, qname)
+ status = ET.SubElement(propstat, _d("status"))
+ status.text = "HTTP/1.1 200 OK"
+ return resp
+ if mode == "allprop":
+ names = [q for q in DavXml.ALLPROPS]
+ wanted_tags = [_d(n) for n in names] + list(dead)
+ else:
+ wanted_tags = [child.tag for child in wanted]
+ found = ET.Element(_d("prop"))
+ missing = ET.Element(_d("prop"))
+ for qname in wanted_tags:
+ if qname in live:
+ el = ET.SubElement(found, qname)
+ value = live[qname]
+ if isinstance(value, ET.Element):
+ for child in list(value):
+ el.append(child)
+ elif value.startswith("<"):
+ try:
+ el.append(ET.fromstring(
+ "%s" % value))
+ except ET.ParseError:
+ el.text = value
+ else:
+ el.text = value
+ elif qname in dead:
+ el = ET.SubElement(found, qname)
+ el.text = dead[qname]
+ else:
+ ET.SubElement(missing, qname)
+ if len(found):
+ propstat = ET.SubElement(resp, _d("propstat"))
+ propstat.append(found)
+ status = ET.SubElement(propstat, _d("status"))
+ status.text = "HTTP/1.1 200 OK"
+ if len(missing):
+ propstat = ET.SubElement(resp, _d("propstat"))
+ propstat.append(missing)
+ status = ET.SubElement(propstat, _d("status"))
+ status.text = "HTTP/1.1 404 Not Found"
+ return resp
+
+ def do_PROPFIND(self):
+ if not self._check_auth():
+ return
+ store = self.dav["store"] # type: DavStore
+ full = store.resolve(self.path)
+ if full is None:
+ self._error(403, "outside root")
+ return
+ if not os.path.exists(full):
+ self._error(404, "not found")
+ return
+ root, err = DavXml.parse_body(self, DavConfig.MAX_XML_BODY)
+ if err:
+ self._error(err, "bad xml body")
+ return
+ mode, wanted = DavXml.propfind_mode(root)
+ depth = self.headers.get("Depth", "infinity").strip().lower()
+ if depth not in ("0", "1", "infinity"):
+ self._error(400, "bad Depth (want 0, 1, infinity)")
+ return
+ targets = [(full, store.uri_of(full, os.path.isdir(full)))]
+ if depth in ("1", "infinity") and os.path.isdir(full):
+ try:
+ names = sorted(os.listdir(full))
+ except OSError:
+ self._error(404, "not found")
+ return
+ for name in names:
+ if name == DavProps.FILENAME:
+ continue
+ child = os.path.join(full, name)
+ targets.append(
+ (child, store.uri_of(child, os.path.isdir(child))))
+ if len(targets) >= DavConfig.MAX_PROPFIND:
+ break
+ if depth == "infinity":
+ extra = []
+ for base, _ in list(targets[1:]):
+ if not os.path.isdir(base):
+ continue
+ for dirpath, dirnames, filenames in os.walk(base):
+ for name in sorted(dirnames) + sorted(filenames):
+ if name == DavProps.FILENAME:
+ continue
+ child = os.path.join(dirpath, name)
+ extra.append(
+ (child, store.uri_of(
+ child, os.path.isdir(child))))
+ if len(targets) + len(extra) >= DavConfig.MAX_PROPFIND:
+ break
+ if len(targets) + len(extra) >= DavConfig.MAX_PROPFIND:
+ break
+ targets.extend(extra[:DavConfig.MAX_PROPFIND - len(targets)])
+ multi = ET.Element(_d("multistatus"))
+ for target_full, uri in targets:
+ multi.append(self._prop_response(target_full, uri, mode, wanted))
+ body = (b'\n' + ET.tostring(
+ multi, encoding="utf-8"))
+ self._send(207, body, "application/xml; charset=utf-8")
+
+ def do_PROPPATCH(self):
+ if not self._check_auth():
+ return
+ store = self.dav["store"] # type: DavStore
+ full = store.resolve(self.path)
+ if full is None:
+ self._error(403, "outside root")
+ return
+ if not os.path.exists(full):
+ self._error(404, "not found")
+ return
+ if not self._mutate_guards(full, self._if_token()):
+ return
+ root, err = DavXml.parse_body(self, DavConfig.MAX_XML_BODY)
+ if err or root is None:
+ self._error(err or 400, "bad xml body")
+ return
+ to_set, to_remove = DavXml.proppatch(root)
+ uri = store.uri_of(full, os.path.isdir(full))
+ self.dav["props"].patch(uri, to_set, to_remove)
+ multi = ET.Element(_d("multistatus"))
+ resp = ET.SubElement(multi, _d("response"))
+ href = ET.SubElement(resp, _d("href"))
+ href.text = uri
+ propstat = ET.SubElement(resp, _d("propstat"))
+ prop = ET.SubElement(propstat, _d("prop"))
+ for qname in list(to_set) + to_remove:
+ ET.SubElement(prop, qname)
+ status = ET.SubElement(propstat, _d("status"))
+ status.text = "HTTP/1.1 200 OK"
+ body = (b'\n' + ET.tostring(
+ multi, encoding="utf-8"))
+ self._send(207, body, "application/xml; charset=utf-8")
+
+ # -- COPY / MOVE -------------------------------------------------------
+
+ def _destination(self):
+ dest = self.headers.get("Destination", "")
+ if not dest:
+ return None
+ parsed = urlparse(dest)
+ return self.dav["store"].resolve(parsed.path or "/")
+
+ def _copy_move(self, is_move):
+ if not self._check_auth():
+ return
+ store = self.dav["store"] # type: DavStore
+ src = store.resolve(self.path)
+ dst = self._destination()
+ if src is None or dst is None:
+ self._error(403, "outside root")
+ return
+ if not os.path.exists(src):
+ self._error(404, "source not found")
+ return
+ if src == dst or dst.startswith(src + os.sep):
+ self._error(502, "bad gateway: overlapping paths")
+ return
+ if not self._mutate_guards(src, self._if_token()):
+ return
+ overwrite = self.headers.get("Overwrite", "T").strip().upper() != "F"
+ existed = os.path.exists(dst)
+ if existed and not overwrite:
+ self._error(412, "destination exists")
+ return
+ parent = os.path.dirname(dst)
+ if not os.path.isdir(parent):
+ self._error(409, "destination parent missing")
+ return
+ try:
+ if existed:
+ if os.path.isdir(dst) and not os.path.islink(dst):
+ shutil.rmtree(dst)
+ else:
+ os.unlink(dst)
+ if os.path.isdir(src) and not os.path.islink(src):
+ if not is_move and self.headers.get(
+ "Depth", "infinity").strip() == "0":
+ os.mkdir(dst)
+ else:
+ shutil.copytree(src, dst, symlinks=True)
+ else:
+ shutil.copy2(src, dst)
+ if is_move:
+ if os.path.isdir(src) and not os.path.islink(src):
+ shutil.rmtree(src)
+ else:
+ os.unlink(src)
+ except OSError:
+ self._error(507, "insufficient storage")
+ return
+ self._send(204 if existed else 201, "")
+
+ def do_COPY(self):
+ self._copy_move(False)
+
+ def do_MOVE(self):
+ self._copy_move(True)
+
+ # -- LOCK / UNLOCK -----------------------------------------------------
+
+ @staticmethod
+ def _parse_timeout(header):
+ if not header:
+ return DavConfig.LOCK_DEFAULT_TIMEOUT
+ header = header.strip().lower()
+ if header.startswith("second-"):
+ try:
+ value = int(header[len("second-"):])
+ except ValueError:
+ return DavConfig.LOCK_DEFAULT_TIMEOUT
+ return min(max(1, value), DavConfig.LOCK_MAX_TIMEOUT)
+ return DavConfig.LOCK_DEFAULT_TIMEOUT
+
+ def do_LOCK(self):
+ if not self._check_auth():
+ return
+ store = self.dav["store"] # type: DavStore
+ full = store.resolve(self.path)
+ if full is None:
+ self._error(403, "outside root")
+ return
+ if self.dav["config"].readonly:
+ self._error(403, "read-only server")
+ return
+ try:
+ length = int(self.headers.get("Content-Length", "0") or "0")
+ except ValueError:
+ length = 0
+ if length == 0:
+ token = self._if_token()
+ if not token:
+ self._error(400, "lock refresh needs If-header with token")
+ return
+ entry = self.dav["locks"].refresh(
+ token, self._parse_timeout(self.headers.get("Timeout")))
+ if entry is None:
+ self._error(409, "unknown lock token")
+ return
+ self._send_lock_response(entry)
+ return
+ if not os.path.exists(full):
+ parent = os.path.dirname(full)
+ if not os.path.isdir(parent):
+ self._error(409, "parent missing")
+ return
+ try:
+ open(full, "wb").close()
+ except OSError:
+ self._error(507, "insufficient storage")
+ return
+ if self.dav["locks"].locked(full):
+ self._error(423, "locked")
+ return
+ root, err = DavXml.parse_body(self, DavConfig.MAX_XML_BODY)
+ if err:
+ self._error(err, "bad xml body")
+ return
+ scope, owner = DavXml.lockinfo(root)
+ if scope != "exclusive":
+ self._error(422, "only exclusive locks supported")
+ return
+ depth = self.headers.get("Depth", "infinity").strip().lower()
+ if depth not in ("0", "infinity"):
+ depth = "infinity"
+ entry = self.dav["locks"].create(
+ full, depth, owner,
+ self._parse_timeout(self.headers.get("Timeout")))
+ self._send_lock_response(entry)
+
+ def _send_lock_response(self, entry):
+ prop = ET.Element(_d("prop"))
+ disco = ET.SubElement(prop, _d("lockdiscovery"))
+ active = ET.SubElement(disco, _d("activelock"))
+ ET.SubElement(ET.SubElement(active, _d("locktype")), _d("write"))
+ ET.SubElement(ET.SubElement(active, _d("lockscope")), _d("exclusive"))
+ depth = ET.SubElement(active, _d("depth"))
+ depth.text = entry["depth"]
+ owner = ET.SubElement(active, _d("owner"))
+ owner.text = entry["owner"]
+ token = ET.SubElement(active, _d("locktoken"))
+ href = ET.SubElement(token, _d("href"))
+ href.text = entry["token"]
+ body = (b'\n' + ET.tostring(
+ prop, encoding="utf-8"))
+ self._send(200, body, "application/xml; charset=utf-8",
+ extra={"Lock-Token": "<%s>" % entry["token"]})
+
+ def do_UNLOCK(self):
+ if not self._check_auth():
+ return
+ header = self.headers.get("Lock-Token", "").strip()
+ if header.startswith("<") and header.endswith(">"):
+ header = header[1:-1]
+ if not header or not self.dav["locks"].release(header):
+ self._error(409, "unknown lock token")
+ return
+ self._send(204, "")
+
+
+class DavServer(socketserver.ThreadingMixIn, HTTPServer):
+ """Thread-per-request HTTP server (3.6-safe ThreadingHTTPServer)."""
+
+ daemon_threads = True
+ allow_reuse_address = True
+
+
+def build_server(host, port, config):
+ server = DavServer((host, port), DavHandler)
+ server.dav = {"config": config,
+ "store": DavStore(config),
+ "locks": DavLocks(),
+ "props": DavProps(config)}
+ return server
+
+
+def main(argv=None):
+ parser = argparse.ArgumentParser(
+ description="Complete WebDAV Class 1 server (stdlib only).")
+ parser.add_argument("--root", default="./share",
+ help="directory to share (created if missing)")
+ parser.add_argument("--host", default="127.0.0.1",
+ help="bind host (default 127.0.0.1)")
+ parser.add_argument("--port", type=int, default=8080)
+ parser.add_argument("--user", default=None, help="Basic-auth user")
+ parser.add_argument("--password", default=None, help="Basic-auth password")
+ parser.add_argument("--max-upload-mb", type=int, default=200)
+ parser.add_argument("--readonly", action="store_true",
+ help="refuse all mutations")
+ args = parser.parse_args(argv)
+ try:
+ os.makedirs(args.root, exist_ok=True)
+ except OSError as exc:
+ parser.error("cannot create --root: %s" % exc)
+ config = DavConfig(args.root, user=args.user, password=args.password,
+ max_upload_mb=args.max_upload_mb,
+ readonly=args.readonly)
+ server = build_server(args.host, args.port, config)
+ print("webdav: serving %s on http://%s:%d/ (DAV class 1)%s" % (
+ config.root, args.host, args.port,
+ " [auth: %s]" % args.user if args.user else ""))
+ try:
+ server.serve_forever()
+ except KeyboardInterrupt:
+ pass
+
+
+if __name__ == "__main__":
+ main()
+
diff --git a/mplex.py b/mplex.py
new file mode 100644
index 0000000..afb3265
--- /dev/null
+++ b/mplex.py
@@ -0,0 +1,6848 @@
+#!/usr/bin/env python3
+# retoor
+import argparse
+import asyncio
+import base64
+import codecs
+import collections
+import contextlib
+import difflib
+import getpass
+import glob
+import hashlib
+import importlib.util
+import hmac
+import html
+import io
+import itertools
+import json
+import operator
+import os
+import queue
+import random
+import re
+import shutil
+import shlex
+import signal
+import socket
+import sqlite3
+import ssl
+import struct
+import subprocess
+import sys
+import tempfile
+import textwrap
+import threading
+import time
+import urllib.error
+import urllib.parse
+import urllib.request
+import uuid
+import weakref
+import zipfile
+from datetime import datetime, timedelta, timezone
+
+try:
+ import readline
+except ImportError:
+ readline = None
+
+try:
+ import fcntl
+except ImportError:
+ fcntl = None
+
+try:
+ import pty
+ import select
+ import termios
+ import tty
+except ImportError:
+ pty = select = termios = tty = None
+
+PLEXER_DIR_NAME = "plexer"
+PLEXER_PANE_ENV = "TAI_PLEXER_PANE"
+PLEXER_SOCKET_ENV = "TAI_PLEXER_SOCKET"
+PLEXER_ACTIVE_ENV = "TAI_PLEXER_ACTIVE"
+PLEXER_RECV_MAX = 1 << 20
+
+
+def plexer_dir(home):
+ path = os.path.join(home, PLEXER_DIR_NAME)
+ os.makedirs(path, exist_ok=True)
+ try:
+ os.chmod(path, 0o700)
+ except OSError:
+ pass
+ return path
+
+
+class PlexerBackend:
+ def run(self):
+ raise NotImplementedError
+
+
+# --------------------------------------------------------------------------- #
+# Mplex: tai's own terminal multiplexer. One server process per TAI_HOME owns
+# every pty, window layout and scrollback; clients attach over a Unix socket
+# and receive finished frames. Key bindings, command names and option names
+# follow tmux 3.6 (the default tables below were dumped from `tmux list-keys`),
+# and ~/.tai/mplex.toml rebinds or retunes any of them. Spec: mplex.md.
+# --------------------------------------------------------------------------- #
+
+MPLEX_SOCKET_NAME = "server.sock"
+MPLEX_LOCK_NAME = "server.lock"
+MPLEX_LOG_NAME = "server.log"
+MPLEX_CONFIG_NAME = "mplex.toml"
+MPLEX_MAGIC = b"\x01"
+MPLEX_FRAME_MAX = 1 << 22
+MPLEX_CLIENT_OUT_MAX = 1 << 22
+MPLEX_PANE_IN_MAX = 1 << 22
+MPLEX_MAX_SESSIONS = 64
+MPLEX_MAX_WINDOWS = 256
+MPLEX_MAX_PANES = 256
+MPLEX_MAX_CLIENTS = 32
+MPLEX_MESSAGES_MAX = 200
+MPLEX_HISTORY_CAP = 200000
+MPLEX_BUFFERS_CAP = 100
+MPLEX_PROMPT_HISTORY_CAP = 100
+MPLEX_COMMAND_DEPTH_MAX = 16
+MPLEX_SHELL_TIMEOUT = 5.0
+MPLEX_SHELL_OUTPUT_MAX = 65536
+MPLEX_PASTE_MAX = 1 << 22
+MPLEX_RENDER_INTERVAL = 0.012
+MPLEX_REAP_KILL_AFTER = 2.0
+MPLEX_IDLE_EXIT = 15.0
+MPLEX_LOOP_ERRORS_MAX = 50
+MPLEX_LOG_MAX = 1 << 24
+MPLEX_CONFIG_MAX = 1 << 20
+MPLEX_UNSUPPORTED = ("display-menu", "display-popup", "customize-mode", "lock-server", "lock-session", "lock-client", "pipe-pane", "join-pane", "move-pane", "link-window", "unlink-window", "respawn-pane", "respawn-window", "resize-window", "set-hook", "show-hooks", "wait-for", "server-access", "choose-tree-mouse")
+
+# (table, key, command, repeat, note): tmux 3.6 defaults minus mouse bindings.
+MPLEX_DEFAULT_KEYS = (
+ ('copy-mode', 'Escape', 'send-keys -X cancel', False, ''),
+ ('copy-mode', 'Space', 'send-keys -X page-down', False, ''),
+ ('copy-mode', ',', 'send-keys -X jump-reverse', False, ''),
+ ('copy-mode', ';', 'send-keys -X jump-again', False, ''),
+ ('copy-mode', 'F', 'command-prompt -1 -p "(jump backward)" { send-keys -X jump-backward -- "%%" }', False, ''),
+ ('copy-mode', 'N', 'send-keys -X search-reverse', False, ''),
+ ('copy-mode', 'P', 'send-keys -X toggle-position', False, ''),
+ ('copy-mode', 'R', 'send-keys -X rectangle-toggle', False, ''),
+ ('copy-mode', 'T', 'command-prompt -1 -p "(jump to backward)" { send-keys -X jump-to-backward -- "%%" }', False, ''),
+ ('copy-mode', 'X', 'send-keys -X set-mark', False, ''),
+ ('copy-mode', 'f', 'command-prompt -1 -p "(jump forward)" { send-keys -X jump-forward -- "%%" }', False, ''),
+ ('copy-mode', 'g', 'command-prompt -p "(goto line)" { send-keys -X goto-line -- "%%" }', False, ''),
+ ('copy-mode', 'n', 'send-keys -X search-again', False, ''),
+ ('copy-mode', 'q', 'send-keys -X cancel', False, ''),
+ ('copy-mode', 'r', 'send-keys -X refresh-from-pane', False, ''),
+ ('copy-mode', 't', 'command-prompt -1 -p "(jump to forward)" { send-keys -X jump-to-forward -- "%%" }', False, ''),
+ ('copy-mode', 'Home', 'send-keys -X start-of-line', False, ''),
+ ('copy-mode', 'End', 'send-keys -X end-of-line', False, ''),
+ ('copy-mode', 'NPage', 'send-keys -X page-down', False, ''),
+ ('copy-mode', 'PPage', 'send-keys -X page-up', False, ''),
+ ('copy-mode', 'Up', 'send-keys -X cursor-up', False, ''),
+ ('copy-mode', 'Down', 'send-keys -X cursor-down', False, ''),
+ ('copy-mode', 'Left', 'send-keys -X cursor-left', False, ''),
+ ('copy-mode', 'Right', 'send-keys -X cursor-right', False, ''),
+ ('copy-mode', 'M-1', 'command-prompt -N -I 1 -p (repeat) { send-keys -N "%%" }', False, ''),
+ ('copy-mode', 'M-2', 'command-prompt -N -I 2 -p (repeat) { send-keys -N "%%" }', False, ''),
+ ('copy-mode', 'M-3', 'command-prompt -N -I 3 -p (repeat) { send-keys -N "%%" }', False, ''),
+ ('copy-mode', 'M-4', 'command-prompt -N -I 4 -p (repeat) { send-keys -N "%%" }', False, ''),
+ ('copy-mode', 'M-5', 'command-prompt -N -I 5 -p (repeat) { send-keys -N "%%" }', False, ''),
+ ('copy-mode', 'M-6', 'command-prompt -N -I 6 -p (repeat) { send-keys -N "%%" }', False, ''),
+ ('copy-mode', 'M-7', 'command-prompt -N -I 7 -p (repeat) { send-keys -N "%%" }', False, ''),
+ ('copy-mode', 'M-8', 'command-prompt -N -I 8 -p (repeat) { send-keys -N "%%" }', False, ''),
+ ('copy-mode', 'M-9', 'command-prompt -N -I 9 -p (repeat) { send-keys -N "%%" }', False, ''),
+ ('copy-mode', 'M-<', 'send-keys -X history-top', False, ''),
+ ('copy-mode', 'M->', 'send-keys -X history-bottom', False, ''),
+ ('copy-mode', 'M-R', 'send-keys -X top-line', False, ''),
+ ('copy-mode', 'M-b', 'send-keys -X previous-word', False, ''),
+ ('copy-mode', 'M-f', 'send-keys -X next-word-end', False, ''),
+ ('copy-mode', 'M-l', 'send-keys -X cursor-centre-horizontal', False, ''),
+ ('copy-mode', 'M-m', 'send-keys -X back-to-indentation', False, ''),
+ ('copy-mode', 'M-r', 'send-keys -X middle-line', False, ''),
+ ('copy-mode', 'M-v', 'send-keys -X page-up', False, ''),
+ ('copy-mode', 'M-w', 'send-keys -X copy-pipe-and-cancel', False, ''),
+ ('copy-mode', 'M-x', 'send-keys -X jump-to-mark', False, ''),
+ ('copy-mode', 'M-{', 'send-keys -X previous-paragraph', False, ''),
+ ('copy-mode', 'M-}', 'send-keys -X next-paragraph', False, ''),
+ ('copy-mode', 'M-Up', 'send-keys -X halfpage-up', False, ''),
+ ('copy-mode', 'M-Down', 'send-keys -X halfpage-down', False, ''),
+ ('copy-mode', 'C-Space', 'send-keys -X begin-selection', False, ''),
+ ('copy-mode', 'C-a', 'send-keys -X start-of-line', False, ''),
+ ('copy-mode', 'C-b', 'send-keys -X cursor-left', False, ''),
+ ('copy-mode', 'C-c', 'send-keys -X cancel', False, ''),
+ ('copy-mode', 'C-e', 'send-keys -X end-of-line', False, ''),
+ ('copy-mode', 'C-f', 'send-keys -X cursor-right', False, ''),
+ ('copy-mode', 'C-g', 'send-keys -X clear-selection', False, ''),
+ ('copy-mode', 'C-k', 'send-keys -X copy-pipe-end-of-line-and-cancel', False, ''),
+ ('copy-mode', 'C-l', 'send-keys -X cursor-centre-vertical', False, ''),
+ ('copy-mode', 'C-n', 'send-keys -X cursor-down', False, ''),
+ ('copy-mode', 'C-p', 'send-keys -X cursor-up', False, ''),
+ ('copy-mode', 'C-r', 'command-prompt -i -I "#{pane_search_string}" -T search -p "(search up)" { send-keys -X search-backward-incremental -- "%%" }', False, ''),
+ ('copy-mode', 'C-s', 'command-prompt -i -I "#{pane_search_string}" -T search -p "(search down)" { send-keys -X search-forward-incremental -- "%%" }', False, ''),
+ ('copy-mode', 'C-v', 'send-keys -X page-down', False, ''),
+ ('copy-mode', 'C-w', 'send-keys -X copy-pipe-and-cancel', False, ''),
+ ('copy-mode', 'C-Up', 'send-keys -X scroll-up', False, ''),
+ ('copy-mode', 'C-Down', 'send-keys -X scroll-down', False, ''),
+ ('copy-mode', 'C-M-b', 'send-keys -X previous-matching-bracket', False, ''),
+ ('copy-mode', 'C-M-f', 'send-keys -X next-matching-bracket', False, ''),
+ ('copy-mode-vi', 'Enter', 'send-keys -X copy-pipe-and-cancel', False, ''),
+ ('copy-mode-vi', 'Escape', 'send-keys -X clear-selection', False, ''),
+ ('copy-mode-vi', 'Space', 'send-keys -X begin-selection', False, ''),
+ ('copy-mode-vi', '#', 'send-keys -FX search-backward -- "#{copy_cursor_word}"', False, ''),
+ ('copy-mode-vi', '$', 'send-keys -X end-of-line', False, ''),
+ ('copy-mode-vi', '%', 'send-keys -X next-matching-bracket', False, ''),
+ ('copy-mode-vi', '*', 'send-keys -FX search-forward -- "#{copy_cursor_word}"', False, ''),
+ ('copy-mode-vi', ',', 'send-keys -X jump-reverse', False, ''),
+ ('copy-mode-vi', '/', 'command-prompt -T search -p "(search down)" { send-keys -X search-forward -- "%%" }', False, ''),
+ ('copy-mode-vi', '0', 'send-keys -X start-of-line', False, ''),
+ ('copy-mode-vi', '1', 'command-prompt -N -I 1 -p (repeat) { send-keys -N "%%" }', False, ''),
+ ('copy-mode-vi', '2', 'command-prompt -N -I 2 -p (repeat) { send-keys -N "%%" }', False, ''),
+ ('copy-mode-vi', '3', 'command-prompt -N -I 3 -p (repeat) { send-keys -N "%%" }', False, ''),
+ ('copy-mode-vi', '4', 'command-prompt -N -I 4 -p (repeat) { send-keys -N "%%" }', False, ''),
+ ('copy-mode-vi', '5', 'command-prompt -N -I 5 -p (repeat) { send-keys -N "%%" }', False, ''),
+ ('copy-mode-vi', '6', 'command-prompt -N -I 6 -p (repeat) { send-keys -N "%%" }', False, ''),
+ ('copy-mode-vi', '7', 'command-prompt -N -I 7 -p (repeat) { send-keys -N "%%" }', False, ''),
+ ('copy-mode-vi', '8', 'command-prompt -N -I 8 -p (repeat) { send-keys -N "%%" }', False, ''),
+ ('copy-mode-vi', '9', 'command-prompt -N -I 9 -p (repeat) { send-keys -N "%%" }', False, ''),
+ ('copy-mode-vi', ':', 'command-prompt -p "(goto line)" { send-keys -X goto-line -- "%%" }', False, ''),
+ ('copy-mode-vi', ';', 'send-keys -X jump-again', False, ''),
+ ('copy-mode-vi', '?', 'command-prompt -T search -p "(search up)" { send-keys -X search-backward -- "%%" }', False, ''),
+ ('copy-mode-vi', 'A', 'send-keys -X append-selection-and-cancel', False, ''),
+ ('copy-mode-vi', 'B', 'send-keys -X previous-space', False, ''),
+ ('copy-mode-vi', 'D', 'send-keys -X copy-pipe-end-of-line-and-cancel', False, ''),
+ ('copy-mode-vi', 'E', 'send-keys -X next-space-end', False, ''),
+ ('copy-mode-vi', 'F', 'command-prompt -1 -p "(jump backward)" { send-keys -X jump-backward -- "%%" }', False, ''),
+ ('copy-mode-vi', 'G', 'send-keys -X history-bottom', False, ''),
+ ('copy-mode-vi', 'H', 'send-keys -X top-line', False, ''),
+ ('copy-mode-vi', 'J', 'send-keys -X scroll-down', False, ''),
+ ('copy-mode-vi', 'K', 'send-keys -X scroll-up', False, ''),
+ ('copy-mode-vi', 'L', 'send-keys -X bottom-line', False, ''),
+ ('copy-mode-vi', 'M', 'send-keys -X middle-line', False, ''),
+ ('copy-mode-vi', 'N', 'send-keys -X search-reverse', False, ''),
+ ('copy-mode-vi', 'P', 'send-keys -X toggle-position', False, ''),
+ ('copy-mode-vi', 'T', 'command-prompt -1 -p "(jump to backward)" { send-keys -X jump-to-backward -- "%%" }', False, ''),
+ ('copy-mode-vi', 'V', 'send-keys -X select-line', False, ''),
+ ('copy-mode-vi', 'W', 'send-keys -X next-space', False, ''),
+ ('copy-mode-vi', 'X', 'send-keys -X set-mark', False, ''),
+ ('copy-mode-vi', '^', 'send-keys -X back-to-indentation', False, ''),
+ ('copy-mode-vi', 'b', 'send-keys -X previous-word', False, ''),
+ ('copy-mode-vi', 'e', 'send-keys -X next-word-end', False, ''),
+ ('copy-mode-vi', 'f', 'command-prompt -1 -p "(jump forward)" { send-keys -X jump-forward -- "%%" }', False, ''),
+ ('copy-mode-vi', 'g', 'send-keys -X history-top', False, ''),
+ ('copy-mode-vi', 'h', 'send-keys -X cursor-left', False, ''),
+ ('copy-mode-vi', 'j', 'send-keys -X cursor-down', False, ''),
+ ('copy-mode-vi', 'k', 'send-keys -X cursor-up', False, ''),
+ ('copy-mode-vi', 'l', 'send-keys -X cursor-right', False, ''),
+ ('copy-mode-vi', 'n', 'send-keys -X search-again', False, ''),
+ ('copy-mode-vi', 'o', 'send-keys -X other-end', False, ''),
+ ('copy-mode-vi', 'q', 'send-keys -X cancel', False, ''),
+ ('copy-mode-vi', 'r', 'send-keys -X refresh-from-pane', False, ''),
+ ('copy-mode-vi', 't', 'command-prompt -1 -p "(jump to forward)" { send-keys -X jump-to-forward -- "%%" }', False, ''),
+ ('copy-mode-vi', 'v', 'send-keys -X rectangle-toggle', False, ''),
+ ('copy-mode-vi', 'w', 'send-keys -X next-word', False, ''),
+ ('copy-mode-vi', 'z', 'send-keys -X scroll-middle', False, ''),
+ ('copy-mode-vi', '{', 'send-keys -X previous-paragraph', False, ''),
+ ('copy-mode-vi', '}', 'send-keys -X next-paragraph', False, ''),
+ ('copy-mode-vi', 'BSpace', 'send-keys -X cursor-left', False, ''),
+ ('copy-mode-vi', 'Home', 'send-keys -X start-of-line', False, ''),
+ ('copy-mode-vi', 'End', 'send-keys -X end-of-line', False, ''),
+ ('copy-mode-vi', 'NPage', 'send-keys -X page-down', False, ''),
+ ('copy-mode-vi', 'PPage', 'send-keys -X page-up', False, ''),
+ ('copy-mode-vi', 'Up', 'send-keys -X cursor-up', False, ''),
+ ('copy-mode-vi', 'Down', 'send-keys -X cursor-down', False, ''),
+ ('copy-mode-vi', 'Left', 'send-keys -X cursor-left', False, ''),
+ ('copy-mode-vi', 'Right', 'send-keys -X cursor-right', False, ''),
+ ('copy-mode-vi', 'M-x', 'send-keys -X jump-to-mark', False, ''),
+ ('copy-mode-vi', 'C-b', 'send-keys -X page-up', False, ''),
+ ('copy-mode-vi', 'C-c', 'send-keys -X cancel', False, ''),
+ ('copy-mode-vi', 'C-d', 'send-keys -X halfpage-down', False, ''),
+ ('copy-mode-vi', 'C-e', 'send-keys -X scroll-down', False, ''),
+ ('copy-mode-vi', 'C-f', 'send-keys -X page-down', False, ''),
+ ('copy-mode-vi', 'C-h', 'send-keys -X cursor-left', False, ''),
+ ('copy-mode-vi', 'C-j', 'send-keys -X copy-pipe-and-cancel', False, ''),
+ ('copy-mode-vi', 'C-u', 'send-keys -X halfpage-up', False, ''),
+ ('copy-mode-vi', 'C-v', 'send-keys -X rectangle-toggle', False, ''),
+ ('copy-mode-vi', 'C-y', 'send-keys -X scroll-up', False, ''),
+ ('copy-mode-vi', 'C-Up', 'send-keys -X scroll-up', False, ''),
+ ('copy-mode-vi', 'C-Down', 'send-keys -X scroll-down', False, ''),
+ ('prefix', 'Space', 'next-layout', False, 'Select next layout'),
+ ('prefix', '!', 'break-pane', False, 'Break pane to a new window'),
+ ('prefix', '"', 'split-window', False, 'Split window vertically'),
+ ('prefix', '#', 'list-buffers', False, 'List all paste buffers'),
+ ('prefix', '$', 'command-prompt -I "#S" { rename-session "%%" }', False, 'Rename current session'),
+ ('prefix', '%', 'split-window -h', False, 'Split window horizontally'),
+ ('prefix', '&', 'confirm-before -p "kill-window #W? (y/n)" kill-window', False, 'Kill current window'),
+ ('prefix', "'", 'command-prompt -T window-target -p index { select-window -t ":%%" }', False, 'Prompt for window index to select'),
+ ('prefix', '(', 'switch-client -p', False, 'Switch to previous client'),
+ ('prefix', ')', 'switch-client -n', False, 'Switch to next client'),
+ ('prefix', ',', 'command-prompt -I "#W" { rename-window "%%" }', False, 'Rename current window'),
+ ('prefix', '-', 'delete-buffer', False, 'Delete the most recent paste buffer'),
+ ('prefix', '.', 'command-prompt -T target { move-window -t "%%" }', False, 'Move the current window'),
+ ('prefix', '/', 'command-prompt -k -p key { list-keys -1N "%%" }', False, 'Describe key binding'),
+ ('prefix', '0', 'select-window -t :=0', False, 'Select window 0'),
+ ('prefix', '1', 'select-window -t :=1', False, 'Select window 1'),
+ ('prefix', '2', 'select-window -t :=2', False, 'Select window 2'),
+ ('prefix', '3', 'select-window -t :=3', False, 'Select window 3'),
+ ('prefix', '4', 'select-window -t :=4', False, 'Select window 4'),
+ ('prefix', '5', 'select-window -t :=5', False, 'Select window 5'),
+ ('prefix', '6', 'select-window -t :=6', False, 'Select window 6'),
+ ('prefix', '7', 'select-window -t :=7', False, 'Select window 7'),
+ ('prefix', '8', 'select-window -t :=8', False, 'Select window 8'),
+ ('prefix', '9', 'select-window -t :=9', False, 'Select window 9'),
+ ('prefix', ':', 'command-prompt', False, 'Prompt for a command'),
+ ('prefix', ';', 'last-pane', False, 'Move to the previously active pane'),
+ ('prefix', '<', 'display-menu', False, 'Display window menu'),
+ ('prefix', '=', 'choose-buffer -Z', False, 'Choose a paste buffer from a list'),
+ ('prefix', '>', 'display-menu', False, 'Display pane menu'),
+ ('prefix', '?', 'list-keys -N', False, 'List key bindings'),
+ ('prefix', 'C', 'customize-mode -Z', False, 'Customize options'),
+ ('prefix', 'D', 'choose-client -Z', False, 'Choose and detach a client from a list'),
+ ('prefix', 'E', 'select-layout -E', False, 'Spread panes out evenly'),
+ ('prefix', 'L', 'switch-client -l', False, 'Switch to the last client'),
+ ('prefix', 'M', 'select-pane -M', False, 'Clear the marked pane'),
+ ('prefix', '[', 'copy-mode', False, 'Enter copy mode'),
+ ('prefix', ']', 'paste-buffer -p', False, 'Paste the most recent paste buffer'),
+ ('prefix', 'c', 'new-window', False, 'Create a new window'),
+ ('prefix', 'd', 'detach-client', False, 'Detach the current client'),
+ ('prefix', 'f', 'command-prompt { find-window -Z "%%" }', False, 'Search for a pane'),
+ ('prefix', 'i', 'display-message', False, 'Display window information'),
+ ('prefix', 'l', 'last-window', False, 'Select the previously current window'),
+ ('prefix', 'm', 'select-pane -m', False, 'Toggle the marked pane'),
+ ('prefix', 'n', 'next-window', False, 'Select the next window'),
+ ('prefix', 'o', 'select-pane -t :.+', False, 'Select the next pane'),
+ ('prefix', 'p', 'previous-window', False, 'Select the previous window'),
+ ('prefix', 'q', 'display-panes', False, 'Display pane numbers'),
+ ('prefix', 'r', 'refresh-client', False, 'Redraw the current client'),
+ ('prefix', 's', 'choose-tree -Zs', False, 'Choose a session from a list'),
+ ('prefix', 't', 'clock-mode', False, 'Show a clock'),
+ ('prefix', 'w', 'choose-tree -Zw', False, 'Choose a window from a list'),
+ ('prefix', 'x', 'confirm-before -p "kill-pane #P? (y/n)" kill-pane', False, 'Kill the active pane'),
+ ('prefix', 'z', 'resize-pane -Z', False, 'Zoom the active pane'),
+ ('prefix', '{', 'swap-pane -U', False, 'Swap the active pane with the pane above'),
+ ('prefix', '}', 'swap-pane -D', False, 'Swap the active pane with the pane below'),
+ ('prefix', '~', 'show-messages', False, 'Show messages'),
+ ('prefix', 'DC', 'refresh-client -c', True, 'Reset so the visible part of the window follows the cursor'),
+ ('prefix', 'PPage', 'copy-mode -u', False, 'Enter copy mode and scroll up'),
+ ('prefix', 'Up', 'select-pane -U', True, 'Select the pane above the active pane'),
+ ('prefix', 'Down', 'select-pane -D', True, 'Select the pane below the active pane'),
+ ('prefix', 'Left', 'select-pane -L', True, 'Select the pane to the left of the active pane'),
+ ('prefix', 'Right', 'select-pane -R', True, 'Select the pane to the right of the active pane'),
+ ('prefix', 'M-1', 'select-layout even-horizontal', False, 'Set the even-horizontal layout'),
+ ('prefix', 'M-2', 'select-layout even-vertical', False, 'Set the even-vertical layout'),
+ ('prefix', 'M-3', 'select-layout main-horizontal', False, 'Set the main-horizontal layout'),
+ ('prefix', 'M-4', 'select-layout main-vertical', False, 'Set the main-vertical layout'),
+ ('prefix', 'M-5', 'select-layout tiled', False, 'Select the tiled layout'),
+ ('prefix', 'M-6', 'select-layout main-horizontal-mirrored', False, 'Set the main-horizontal-mirrored layout'),
+ ('prefix', 'M-7', 'select-layout main-vertical-mirrored', False, 'Set the main-vertical-mirrored layout'),
+ ('prefix', 'M-n', 'next-window -a', False, 'Select the next window with an alert'),
+ ('prefix', 'M-o', 'rotate-window -D', False, 'Rotate through the panes in reverse'),
+ ('prefix', 'M-p', 'previous-window -a', False, 'Select the previous window with an alert'),
+ ('prefix', 'M-Up', 'resize-pane -U 5', True, 'Resize the pane up by 5'),
+ ('prefix', 'M-Down', 'resize-pane -D 5', True, 'Resize the pane down by 5'),
+ ('prefix', 'M-Left', 'resize-pane -L 5', True, 'Resize the pane left by 5'),
+ ('prefix', 'M-Right', 'resize-pane -R 5', True, 'Resize the pane right by 5'),
+ ('prefix', 'C-b', 'send-prefix', False, 'Send the prefix key'),
+ ('prefix', 'C-o', 'rotate-window', False, 'Rotate through the panes'),
+ ('prefix', 'C-z', 'suspend-client', False, 'Suspend the current client'),
+ ('prefix', 'C-Up', 'resize-pane -U', True, 'Resize the pane up'),
+ ('prefix', 'C-Down', 'resize-pane -D', True, 'Resize the pane down'),
+ ('prefix', 'C-Left', 'resize-pane -L', True, 'Resize the pane left'),
+ ('prefix', 'C-Right', 'resize-pane -R', True, 'Resize the pane right'),
+ ('prefix', 'S-Up', 'refresh-client -U 10', True, 'Move the visible part of the window up'),
+ ('prefix', 'S-Down', 'refresh-client -D 10', True, 'Move the visible part of the window down'),
+ ('prefix', 'S-Left', 'refresh-client -L 10', True, 'Move the visible part of the window left'),
+ ('prefix', 'S-Right', 'refresh-client -R 10', True, 'Move the visible part of the window right'),
+)
+
+MPLEX_LAYOUT_NAMES = ("even-horizontal", "even-vertical", "main-horizontal", "main-horizontal-mirrored", "main-vertical", "main-vertical-mirrored", "tiled")
+
+
+class MplexError(Exception):
+ """A user-facing failure: the text is shown verbatim as the command's error."""
+
+
+class MplexKeys:
+ """Key names in tmux notation (C-b, M-x, S-Up, PPage, F5 ...), byte decoding of what
+ a terminal sends, and byte encoding for send-keys."""
+
+ ALIASES = {"PageUp": "PPage", "PgUp": "PPage", "PageDown": "NPage", "PgDn": "NPage", "Insert": "IC", "Delete": "DC",
+ "Return": "Enter", "Backspace": "BSpace", "Esc": "Escape", "Backtab": "BTab"}
+ NAMED = ("Up", "Down", "Left", "Right", "Home", "End", "PPage", "NPage", "IC", "DC", "Enter", "Tab", "BTab", "BSpace",
+ "Escape", "Space") + tuple("F%d" % n for n in range(1, 13))
+ FROM_TUI = {"up": "Up", "down": "Down", "left": "Left", "right": "Right", "home": "Home", "end": "End",
+ "pageup": "PPage", "pagedown": "NPage", "insert": "IC", "delete": "DC", "backtab": "BTab",
+ "enter": "Enter", "tab": "Tab", "backspace": "BSpace", "escape": "Escape", "space": "Space"}
+ CSI_FINAL = {"Up": "A", "Down": "B", "Right": "C", "Left": "D", "Home": "H", "End": "F"}
+ CSI_TILDE = {"IC": 2, "DC": 3, "PPage": 5, "NPage": 6, "F5": 15, "F6": 17, "F7": 18, "F8": 19, "F9": 20, "F10": 21, "F11": 23, "F12": 24}
+ SS3_FUNC = {"F1": "P", "F2": "Q", "F3": "R", "F4": "S"}
+ CTRL_PUNCT = {"\\": 0x1C, "]": 0x1D, "^": 0x1E, "_": 0x1F, "@": 0x00, "[": 0x1B, "?": 0x7F}
+
+ @classmethod
+ def canonical(cls, spec):
+ if spec == " ":
+ return "Space"
+ text = str(spec).strip()
+ if not text:
+ raise MplexError("empty key name")
+ mods = set()
+ while len(text) >= 3 and text[1] == "-" and text[0] in "CMS":
+ mods.add(text[0])
+ text = text[2:]
+ if len(text) == 2 and text[0] == "^":
+ mods.add("C")
+ text = text[1]
+ text = cls.ALIASES.get(text, text)
+ if len(text) == 1:
+ if text == " ":
+ text = "Space"
+ elif "C" in mods and text.isalpha():
+ text = text.lower()
+ elif "S" in mods and text.isalpha():
+ text = text.upper()
+ mods.discard("S")
+ elif text not in cls.NAMED:
+ raise MplexError("unknown key: %s" % spec)
+ if "C" in mods:
+ if text == "i":
+ mods.discard("C")
+ text = "Tab"
+ elif text == "m":
+ mods.discard("C")
+ text = "Enter"
+ elif text == "[":
+ mods.discard("C")
+ text = "Escape"
+ return ("C-" if "C" in mods else "") + ("M-" if "M" in mods else "") + ("S-" if "S" in mods else "") + text
+
+ @classmethod
+ def name_of_char(cls, raw):
+ """Canonical name for one decoded character (no escape prefix)."""
+ if len(raw) == 1:
+ byte = raw[0]
+ if byte == 0x00:
+ return "C-Space"
+ if byte == 0x09:
+ return "Tab"
+ if byte == 0x0D:
+ return "Enter"
+ if byte == 0x1B:
+ return "Escape"
+ if byte == 0x20:
+ return "Space"
+ if byte == 0x7F:
+ return "BSpace"
+ if 0x01 <= byte <= 0x1A:
+ return "C-" + chr(byte + 0x60)
+ if 0x1C <= byte <= 0x1F:
+ return "C-" + "\\]^_"[byte - 0x1C]
+ return chr(byte)
+ return raw.decode("utf-8", "replace")
+
+ @classmethod
+ def split(cls, data, final=False):
+ """bytes -> ([(name, raw)], rest). name is "" for sequences that are not keys
+ (they are still forwarded). A lone ESC waits until final=True."""
+ tokens = []
+ buf = bytes(data)
+ while buf:
+ if buf[0] != 0x1B:
+ length = 1
+ lead = buf[0]
+ if lead >= 0xF0:
+ length = 4
+ elif lead >= 0xE0:
+ length = 3
+ elif lead >= 0xC0:
+ length = 2
+ if length > len(buf):
+ if not final:
+ break
+ length = len(buf)
+ raw = buf[:length]
+ tokens.append((cls.name_of_char(raw), raw))
+ buf = buf[length:]
+ continue
+ if len(buf) == 1:
+ if not final:
+ break
+ tokens.append(("Escape", b"\x1b"))
+ buf = b""
+ break
+ event, rest = tui_parse_one(buf, final)
+ if event == "need_more":
+ if not final:
+ break
+ tokens.append(("Escape", b"\x1b"))
+ buf = buf[1:]
+ continue
+ if event == "escape":
+ tokens.append(("Escape", b"\x1b"))
+ buf = buf[1:]
+ continue
+ consumed = len(buf) - len(rest)
+ if consumed <= 0:
+ consumed = 1
+ raw = buf[:consumed]
+ buf = buf[consumed:]
+ tokens.append((cls._name_of_event(event, raw), raw))
+ return tokens, buf
+
+ @classmethod
+ def _name_of_event(cls, event, raw):
+ if not isinstance(event, TuiKey):
+ return ""
+ if len(raw) >= 2 and raw[1:2] not in (b"[", b"O"):
+ inner = cls.name_of_char(raw[1:])
+ try:
+ return cls.canonical("M-" + inner)
+ except MplexError:
+ return ""
+ key = event.key
+ base = key.split("+")[-1] if not key.endswith("+") else "+"
+ if base in cls.FROM_TUI:
+ base = cls.FROM_TUI[base]
+ elif len(base) >= 2 and base[0] == "f" and base[1:].isdigit():
+ base = "F" + base[1:]
+ else:
+ return ""
+ prefix = ""
+ mods = set(getattr(event, "modifiers", None) or [])
+ if "ctrl" in mods:
+ prefix += "C-"
+ if "alt" in mods or "meta" in mods:
+ prefix += "M-"
+ if "shift" in mods:
+ prefix += "S-"
+ try:
+ return cls.canonical(prefix + base)
+ except MplexError:
+ return ""
+
+ @classmethod
+ def to_bytes(cls, name, app_cursor=False):
+ """Bytes a terminal would send for a canonical key name."""
+ mods = ""
+ text = name
+ while len(text) >= 3 and text[1] == "-" and text[0] in "CMS":
+ mods += text[0]
+ text = text[2:]
+ if len(text) == 1 or text == "Space":
+ char = " " if text == "Space" else text
+ data = char.encode("utf-8")
+ if "C" in mods:
+ if char == " ":
+ data = b"\x00"
+ elif char.isalpha() and char.isascii():
+ data = bytes([ord(char.upper()) & 0x1F])
+ elif char in cls.CTRL_PUNCT:
+ data = bytes([cls.CTRL_PUNCT[char]])
+ return (b"\x1b" if "M" in mods else b"") + data
+ simple = {"Enter": b"\r", "Tab": b"\t", "BSpace": b"\x7f", "Escape": b"\x1b", "BTab": b"\x1b[Z"}
+ if text in simple and not ("S" in mods and text not in ("BTab",)):
+ return (b"\x1b" if "M" in mods else b"") + simple[text]
+ mod_code = 1 + (1 if "S" in mods else 0) + (2 if "M" in mods else 0) + (4 if "C" in mods else 0)
+ if text in cls.CSI_FINAL:
+ if mod_code > 1:
+ return ("\x1b[1;%d%s" % (mod_code, cls.CSI_FINAL[text])).encode("ascii")
+ return (b"\x1bO" if app_cursor else b"\x1b[") + cls.CSI_FINAL[text].encode("ascii")
+ if text in cls.CSI_TILDE:
+ if mod_code > 1:
+ return ("\x1b[%d;%d~" % (cls.CSI_TILDE[text], mod_code)).encode("ascii")
+ return ("\x1b[%d~" % cls.CSI_TILDE[text]).encode("ascii")
+ if text in cls.SS3_FUNC:
+ if mod_code > 1:
+ return ("\x1b[1;%d%s" % (mod_code, cls.SS3_FUNC[text])).encode("ascii")
+ return ("\x1bO" + cls.SS3_FUNC[text]).encode("ascii")
+ raise MplexError("unknown key: %s" % name)
+
+ @classmethod
+ def app_cursor_form(cls, raw):
+ """Plain CSI cursor keys become SS3 when the pane asked for application cursor mode."""
+ if len(raw) == 3 and raw[:2] == b"\x1b[" and raw[2:3] in (b"A", b"B", b"C", b"D", b"H", b"F"):
+ return b"\x1bO" + raw[2:3]
+ return raw
+
+
+class MplexArgs:
+ """getopt-style tmux argument parsing. spec "bdt:" => -b and -d are flags, -t takes a value."""
+
+ def __init__(self, flags, positional):
+ self.flags = flags
+ self.positional = positional
+
+ def has(self, flag):
+ return flag in self.flags
+
+ def get(self, flag, default=None):
+ values = self.flags.get(flag)
+ if not values:
+ return default
+ return values[-1]
+
+ def all(self, flag):
+ return list(self.flags.get(flag) or [])
+
+ def int(self, flag, default=None):
+ value = self.get(flag)
+ if value is None or value is True:
+ return default
+ try:
+ return int(value)
+ except ValueError:
+ raise MplexError("-%s expects a number: %s" % (flag, value))
+
+ @classmethod
+ def parse(cls, name, spec, words, min_args=0, max_args=None):
+ takes = set()
+ known = set()
+ index = 0
+ while index < len(spec):
+ known.add(spec[index])
+ if index + 1 < len(spec) and spec[index + 1] == ":":
+ takes.add(spec[index])
+ index += 1
+ index += 1
+ flags = {}
+ positional = []
+ pos = 0
+ while pos < len(words):
+ word = words[pos]
+ if word == "--":
+ positional.extend(words[pos + 1:])
+ break
+ if len(word) < 2 or word[0] != "-":
+ positional.extend(words[pos:])
+ break
+ inner = 1
+ while inner < len(word):
+ letter = word[inner]
+ if letter not in known:
+ raise MplexError("command %s: unknown flag -%s" % (name, letter))
+ if letter in takes:
+ if inner + 1 < len(word):
+ value = word[inner + 1:]
+ else:
+ pos += 1
+ if pos >= len(words):
+ raise MplexError("command %s: -%s expects an argument" % (name, letter))
+ value = words[pos]
+ flags.setdefault(letter, []).append(value)
+ break
+ flags.setdefault(letter, []).append(True)
+ inner += 1
+ pos += 1
+ if len(positional) < min_args or (max_args is not None and len(positional) > max_args):
+ raise MplexError("command %s: wrong number of arguments" % name)
+ return cls(flags, positional)
+
+
+class MplexCommandParser:
+ """Splits tmux-syntax command text into commands of words: quotes, backslash,
+ {braces} as one argument, ';' and newlines between commands."""
+
+ @staticmethod
+ def parse(text):
+ commands = []
+ words = []
+ cur = []
+ has = False
+ i = 0
+ n = len(text)
+
+ def end_word():
+ nonlocal cur, has
+ if has:
+ words.append("".join(cur))
+ cur = []
+ has = False
+
+ def end_command():
+ nonlocal words
+ end_word()
+ if words:
+ commands.append(words)
+ words = []
+
+ while i < n:
+ c = text[i]
+ if c in " \t\r":
+ end_word()
+ i += 1
+ elif c == "\n" or c == ";":
+ end_command()
+ i += 1
+ elif c == "\\":
+ if i + 1 < n:
+ if text[i + 1] == "\n":
+ i += 2
+ continue
+ cur.append(text[i + 1])
+ has = True
+ i += 2
+ elif c == "'":
+ j = text.find("'", i + 1)
+ if j < 0:
+ raise MplexError("unterminated single quote")
+ cur.append(text[i + 1:j])
+ has = True
+ i = j + 1
+ elif c == '"':
+ j = i + 1
+ piece = []
+ while j < n and text[j] != '"':
+ if text[j] == "\\" and j + 1 < n:
+ nxt = text[j + 1]
+ piece.append({"n": "\n", "t": "\t", "e": "\x1b", "r": "\r"}.get(nxt, nxt))
+ j += 2
+ else:
+ piece.append(text[j])
+ j += 1
+ if j >= n:
+ raise MplexError("unterminated double quote")
+ cur.append("".join(piece))
+ has = True
+ i = j + 1
+ elif c == "{" and not has:
+ depth = 0
+ j = i
+ quote = ""
+ while j < n:
+ ch = text[j]
+ if quote:
+ if ch == "\\" and quote == '"':
+ j += 1
+ elif ch == quote:
+ quote = ""
+ elif ch in "'\"":
+ quote = ch
+ elif ch == "\\":
+ j += 1
+ elif ch == "{":
+ depth += 1
+ elif ch == "}":
+ depth -= 1
+ if depth == 0:
+ break
+ j += 1
+ if depth != 0 or j >= n:
+ raise MplexError("unterminated { block")
+ cur.append(text[i + 1:j].strip())
+ has = True
+ i = j + 1
+ else:
+ cur.append(c)
+ has = True
+ i += 1
+ end_command()
+ return commands
+
+ @staticmethod
+ def quote(word):
+ word = str(word)
+ if word and all(ch.isalnum() or ch in "-_./:=%@+," for ch in word):
+ return word
+ return '"' + word.replace("\\", "\\\\").replace('"', '\\"').replace("$", "\\$") + '"'
+
+
+class MplexStyle:
+ """tmux style strings ("bg=green,fg=black,bold") to (fg, bg, attr) in the cell
+ format TuiVtScreen uses: SGR fragments for colors, a bitmask for attributes."""
+
+ NAMES = ("black", "red", "green", "yellow", "blue", "magenta", "cyan", "white")
+ ATTRS = {"bold": 1, "bright": 1, "dim": 2, "italics": 32, "underscore": 4, "underline": 4, "blink": 64,
+ "reverse": 8, "hidden": 128, "strikethrough": 16}
+
+ @classmethod
+ def color(cls, text, background):
+ text = text.strip().lower()
+ if text in ("default", "terminal", ""):
+ return None
+ base = 40 if background else 30
+ if text in cls.NAMES:
+ return str(base + cls.NAMES.index(text))
+ if text.startswith("bright") and text[6:] in cls.NAMES:
+ return str(base + 60 + cls.NAMES.index(text[6:]))
+ if text.startswith("colour") or text.startswith("color"):
+ digits = text[6:] if text.startswith("colour") else text[5:]
+ if digits.isdigit() and int(digits) < 256:
+ return "%d;5;%d" % (48 if background else 38, int(digits))
+ if text.startswith("#") and len(text) == 7:
+ try:
+ red, green, blue = int(text[1:3], 16), int(text[3:5], 16), int(text[5:7], 16)
+ except ValueError:
+ raise MplexError("bad colour: %s" % text)
+ return "%d;2;%d;%d;%d" % (48 if background else 38, red, green, blue)
+ raise MplexError("bad colour: %s" % text)
+
+ @classmethod
+ def parse(cls, text, base=(None, None, 0), reset=None):
+ """Apply a style string on top of base; "default" returns to reset (base when omitted)."""
+ fg, bg, attr = base
+ reset = base if reset is None else reset
+ for item in re.split(r"[,\s]+", str(text).strip()):
+ if not item:
+ continue
+ low = item.lower()
+ if low == "default":
+ fg, bg, attr = reset
+ elif low in ("none", "noattr"):
+ attr = 0
+ elif low.startswith("fg="):
+ fg = cls.color(item[3:], False)
+ elif low.startswith("bg="):
+ bg = cls.color(item[3:], True)
+ elif low in cls.ATTRS:
+ attr |= cls.ATTRS[low]
+ elif low.startswith("no") and low[2:] in cls.ATTRS:
+ attr &= ~cls.ATTRS[low[2:]]
+ elif low.startswith(("align=", "list=", "range=", "push-default", "pop-default", "fill=", "norange", "nolist")):
+ continue
+ else:
+ raise MplexError("bad style: %s" % item)
+ return (fg, bg, attr)
+
+ @staticmethod
+ def sgr(fg, bg, attr):
+ codes = ["0"]
+ for bit, code in ((1, "1"), (2, "2"), (32, "3"), (4, "4"), (64, "5"), (8, "7"), (128, "8"), (16, "9")):
+ if attr & bit:
+ codes.append(code)
+ if fg:
+ codes.append(fg)
+ if bg:
+ codes.append(bg)
+ return "\x1b[" + ";".join(codes) + "m"
+
+
+class MplexOptions:
+ """Typed option store with a parent chain: window, session, then global."""
+
+ WORD_SEPARATORS = "!\"#$%&'()*+,-./:;<=>?@[\\]^`{|}~"
+ DEFS = {
+ "prefix": ("key", "C-b"), "prefix2": ("key", "None"),
+ "base-index": ("int:0:9999", 0), "pane-base-index": ("int:0:9999", 0),
+ "escape-time": ("int:0:10000", 10), "repeat-time": ("int:0:10000", 500),
+ "history-limit": ("int:0:%d" % MPLEX_HISTORY_CAP, 2000),
+ "mode-keys": ("choice:vi|emacs", ""), "status-keys": ("choice:vi|emacs", ""),
+ "status": ("bool", True), "status-position": ("choice:top|bottom", "bottom"),
+ "status-interval": ("int:0:3600", 15), "status-justify": ("choice:left|centre|center|right", "left"),
+ "status-left": ("str", "[#{session_name}] "),
+ "status-right": ("str", "\"#{=21:pane_title}\" %H:%M %d-%b-%y"),
+ "status-left-length": ("int:0:1000", 10), "status-right-length": ("int:0:1000", 40),
+ "status-style": ("style", "bg=green,fg=black"), "status-left-style": ("style", "default"), "status-right-style": ("style", "default"),
+ "message-style": ("style", "bg=yellow,fg=black"), "message-command-style": ("style", "bg=black,fg=yellow"),
+ "mode-style": ("style", "noattr,bg=yellow,fg=black"),
+ "copy-mode-match-style": ("style", "bg=cyan,fg=black"), "copy-mode-current-match-style": ("style", "bg=magenta,fg=black"),
+ "copy-mode-mark-style": ("style", "bg=red,fg=black"),
+ "display-time": ("int:0:600000", 750), "display-panes-time": ("int:1:600000", 1000),
+ "display-panes-colour": ("colour", "blue"), "display-panes-active-colour": ("colour", "red"),
+ "window-status-format": ("str", "#I:#W#{?window_flags,#{window_flags}, }"),
+ "window-status-current-format": ("str", "#I:#W#{?window_flags,#{window_flags}, }"),
+ "window-status-separator": ("str", " "),
+ "window-status-style": ("style", "default"), "window-status-current-style": ("style", "default"), "window-status-last-style": ("style", "default"),
+ "pane-border-style": ("style", "default"),
+ "pane-active-border-style": ("str", "#{?pane_in_mode,fg=yellow,#{?synchronize-panes,fg=red,fg=green}}"),
+ "pane-border-lines": ("choice:single|double|heavy|simple", "single"),
+ "clock-mode-colour": ("colour", "blue"), "clock-mode-style": ("choice:12|24", "24"),
+ "main-pane-width": ("int:1:10000", 80), "main-pane-height": ("int:1:10000", 24),
+ "renumber-windows": ("bool", False), "automatic-rename": ("bool", True),
+ "automatic-rename-format": ("str", "#{?pane_in_mode,[mplex],#{pane_current_command}}#{?pane_dead,[dead],}"),
+ "allow-set-title": ("bool", True),
+ "default-shell": ("str", ""), "default-command": ("str", ""), "default-terminal": ("str", "tmux-256color"),
+ "detach-on-destroy": ("bool", True), "wrap-search": ("bool", True),
+ "word-separators": ("str", WORD_SEPARATORS), "remain-on-exit": ("bool", False),
+ "synchronize-panes": ("bool", False), "copy-command": ("str", ""),
+ "buffer-limit": ("int:1:%d" % MPLEX_BUFFERS_CAP, 50),
+ "set-clipboard": ("choice:on|external|off", "off"),
+ }
+
+ def __init__(self, parent=None):
+ self.parent = parent
+ self.values = {}
+
+ @classmethod
+ def check(cls, name, raw):
+ if name not in cls.DEFS:
+ raise MplexError("unknown or unsupported option: %s" % name)
+ kind = cls.DEFS[name][0]
+ text = str(raw)
+ if kind == "str":
+ return text
+ if kind == "bool":
+ low = text.strip().lower()
+ if low in ("on", "yes", "true", "1"):
+ return True
+ if low in ("off", "no", "false", "0"):
+ return False
+ raise MplexError("value is invalid: %s" % text)
+ if kind == "key":
+ return "None" if text.strip() in ("None", "none", "") else MplexKeys.canonical(text)
+ if kind == "colour":
+ MplexStyle.color(text, False)
+ return text
+ if kind == "style":
+ if "#{" not in text:
+ MplexStyle.parse(text)
+ return text
+ if kind.startswith("int:"):
+ _tag, low, high = kind.split(":")
+ try:
+ value = int(text)
+ except ValueError:
+ raise MplexError("value is invalid: %s" % text)
+ if value < int(low) or value > int(high):
+ raise MplexError("value out of range %s..%s: %s" % (low, high, text))
+ return value
+ if kind.startswith("choice:"):
+ choices = kind.split(":", 1)[1].split("|")
+ if text not in choices:
+ raise MplexError("value is invalid (use %s): %s" % ("|".join(choices), text))
+ return text
+ raise MplexError("bad option kind for %s" % name)
+
+ def get(self, name):
+ node = self
+ while node is not None:
+ if name in node.values:
+ return node.values[name]
+ node = node.parent
+ default = self.DEFS[name][1]
+ if name in ("mode-keys", "status-keys"):
+ editor = os.environ.get("VISUAL") or os.environ.get("EDITOR") or ""
+ return "vi" if "vi" in editor else "emacs"
+ return default
+
+ def is_set(self, name):
+ return name in self.values
+
+ def set(self, name, raw, append=False):
+ value = self.check(name, raw)
+ if append and isinstance(value, str) and name in self.values:
+ value = self.values[name] + value
+ self.values[name] = value
+ return value
+
+ def toggle(self, name):
+ if self.DEFS.get(name, ("",))[0] != "bool":
+ raise MplexError("only boolean options can be toggled: %s" % name)
+ self.values[name] = not self.get(name)
+
+ def unset(self, name):
+ if name not in self.DEFS:
+ raise MplexError("unknown or unsupported option: %s" % name)
+ self.values.pop(name, None)
+
+ @staticmethod
+ def show(value):
+ if value is True:
+ return "on"
+ if value is False:
+ return "off"
+ return str(value)
+
+
+class MplexBinding:
+ __slots__ = ("command", "repeat", "note")
+
+ def __init__(self, command, repeat=False, note=""):
+ self.command = command
+ self.repeat = repeat
+ self.note = note
+
+
+class MplexConfig:
+ """Defaults (tmux 3.6) plus ~/.tai/mplex.toml. Problems never abort the load:
+ each bad entry is skipped, counted in errors, and shown to the first client."""
+
+ ERRORS_MAX = 50
+ TOP_KEYS = ("options", "keys")
+
+ def __init__(self):
+ self.options = MplexOptions()
+ self.tables = {}
+ self.errors = []
+ self.path = ""
+ self.skipped = 0
+ for table, key, command, repeat, note in MPLEX_DEFAULT_KEYS:
+ self.bind(table, MplexKeys.canonical(key), command, repeat, note)
+
+ def error(self, text):
+ self.skipped += 1
+ if len(self.errors) < self.ERRORS_MAX:
+ self.errors.append(text)
+
+ def bind(self, table, key, command, repeat=False, note=""):
+ self.tables.setdefault(table, {})[key] = MplexBinding(command, repeat, note)
+
+ def unbind(self, table, key):
+ return self.tables.get(table, {}).pop(key, None) is not None
+
+ def lookup(self, table, key):
+ return self.tables.get(table, {}).get(key)
+
+ @staticmethod
+ def default_path(home):
+ return os.path.join(home, MPLEX_CONFIG_NAME)
+
+ @classmethod
+ def load(cls, path):
+ config = cls()
+ config.path = path
+ config.apply_file(path)
+ return config
+
+ def apply_file(self, path):
+ try:
+ if os.path.getsize(path) > MPLEX_CONFIG_MAX:
+ self.error("%s: larger than %d bytes, ignored" % (path, MPLEX_CONFIG_MAX))
+ return
+ with open(path, "rb") as handle:
+ raw = handle.read()
+ except FileNotFoundError:
+ return
+ except OSError as exc:
+ self.error("%s: cannot read: %s" % (path, short_error(exc)))
+ return
+ try:
+ import tomllib
+ except ImportError:
+ self.error("%s: needs Python 3.11 or newer (tomllib), file ignored" % path)
+ return
+ try:
+ data = tomllib.loads(raw.decode("utf-8"))
+ except (ValueError, UnicodeDecodeError) as exc:
+ self.error("%s: not valid TOML: %s" % (path, exc))
+ return
+ self.apply_data(data)
+
+ def apply_data(self, data):
+ for key in data:
+ if key not in self.TOP_KEYS:
+ self.error("unknown top-level key [%s] (use options, keys)" % key)
+ options = data.get("options", {})
+ if not isinstance(options, dict):
+ self.error("[options] must be a table")
+ options = {}
+ for name, value in options.items():
+ try:
+ if isinstance(value, bool):
+ value = "on" if value else "off"
+ elif not isinstance(value, (str, int)):
+ raise MplexError("value must be a string, number or boolean")
+ self.options.set(name, value)
+ except MplexError as exc:
+ self.error("options.%s: %s" % (name, exc))
+ keys = data.get("keys", {})
+ if not isinstance(keys, dict):
+ self.error("[keys] must be a table")
+ return
+ clear = keys.get("clear", [])
+ if not isinstance(clear, list):
+ self.error("keys.clear must be a list of table names")
+ clear = []
+ for table in clear:
+ self.tables[str(table)] = {}
+ for table, entries in keys.items():
+ if table == "clear":
+ continue
+ if not isinstance(entries, dict):
+ self.error("keys.%s must be a table of key = command" % table)
+ continue
+ for key, spec in entries.items():
+ self._apply_binding(table, key, spec)
+
+ def _apply_binding(self, table, key, spec):
+ where = "keys.%s.%s" % (table, key)
+ try:
+ name = MplexKeys.canonical(key)
+ if spec is False or spec == "":
+ self.unbind(table, name)
+ return
+ note = ""
+ repeat = False
+ if isinstance(spec, dict):
+ unknown = [item for item in spec if item not in ("command", "repeat", "note")]
+ if unknown:
+ raise MplexError("unknown field %s (use command, repeat, note)" % unknown[0])
+ command = spec.get("command")
+ repeat = bool(spec.get("repeat", False))
+ note = str(spec.get("note", ""))
+ else:
+ command = spec
+ if not isinstance(command, str) or not command.strip():
+ raise MplexError("command must be a non-empty string, or false to unbind")
+ for words in MplexCommandParser.parse(command):
+ MplexCommands.resolve(words[0])
+ self.bind(table, name, command, repeat, note)
+ except MplexError as exc:
+ self.error("%s: %s" % (where, exc))
+
+
+class MplexFormat:
+ """tmux format strings: #S style shorthands, #{var}, #{?cond,a,b}, modifiers such as
+ =N:, E:, q:, ==:, m:, s/a/b/: and #(shell). Style markers #[...] pass through."""
+
+ SHORT = {"D": "pane_id", "F": "window_flags", "H": "host", "I": "window_index", "P": "pane_index",
+ "S": "session_name", "T": "pane_title", "W": "window_name", "h": "host_short"}
+ DEPTH_MAX = 12
+
+ @staticmethod
+ def match_close(text, start, opener, closer):
+ """Index of the closer matching the opener at text[start] (nested #{ / #( count), or -1."""
+ depth = 1
+ pos = start + 1
+ while pos < len(text):
+ ch = text[pos]
+ if ch == "#" and pos + 1 < len(text):
+ if text[pos + 1] == opener:
+ depth += 1
+ pos += 2
+ continue
+ if ch == closer:
+ depth -= 1
+ if depth == 0:
+ return pos
+ pos += 1
+ return -1
+
+ @classmethod
+ def split_commas(cls, text):
+ parts = []
+ cur = []
+ depth = 0
+ pos = 0
+ while pos < len(text):
+ ch = text[pos]
+ if ch == "#" and pos + 1 < len(text):
+ nxt = text[pos + 1]
+ if nxt == "," and depth == 0:
+ cur.append("#,")
+ pos += 2
+ continue
+ if nxt in "{(":
+ depth += 1
+ cur.append(text[pos:pos + 2])
+ pos += 2
+ continue
+ cur.append(text[pos:pos + 2])
+ pos += 2
+ continue
+ if ch == "}" or ch == ")":
+ depth = max(0, depth - 1)
+ if ch == "," and depth == 0:
+ parts.append("".join(cur))
+ cur = []
+ else:
+ cur.append(ch)
+ pos += 1
+ parts.append("".join(cur))
+ return parts
+
+ @classmethod
+ def expand(cls, text, resolve, depth=0):
+ if depth > cls.DEPTH_MAX or not text:
+ return ""
+ out = []
+ pos = 0
+ n = len(text)
+ while pos < n:
+ ch = text[pos]
+ if ch != "#" or pos + 1 >= n:
+ out.append(ch)
+ pos += 1
+ continue
+ nxt = text[pos + 1]
+ if nxt == "#":
+ out.append("#")
+ pos += 2
+ elif nxt == "{":
+ end = cls.match_close(text, pos + 1, "{", "}")
+ if end < 0:
+ out.append(ch)
+ pos += 1
+ else:
+ out.append(cls.evaluate(text[pos + 2:end], resolve, depth))
+ pos = end + 1
+ elif nxt == "(":
+ end = cls.match_close(text, pos + 1, "(", ")")
+ if end < 0:
+ out.append(ch)
+ pos += 1
+ else:
+ out.append(resolve("(" + text[pos + 2:end]) or "")
+ pos = end + 1
+ elif nxt == "[":
+ end = text.find("]", pos + 2)
+ if end < 0:
+ out.append(ch)
+ pos += 1
+ else:
+ out.append(text[pos:end + 1])
+ pos = end + 1
+ elif nxt in cls.SHORT:
+ out.append(resolve(cls.SHORT[nxt]) or "")
+ pos += 2
+ elif nxt in ",}":
+ out.append(nxt)
+ pos += 2
+ else:
+ out.append(ch)
+ pos += 1
+ return "".join(out)
+
+ @staticmethod
+ def truthy(value):
+ return value not in ("", "0", None)
+
+ @classmethod
+ def value_of(cls, text, resolve, depth):
+ """A variable name or a nested format: its text value."""
+ if "#" in text:
+ return cls.expand(text, resolve, depth + 1)
+ value = resolve(text)
+ return "" if value is None else str(value)
+
+ @classmethod
+ def evaluate(cls, inner, resolve, depth):
+ if inner.startswith("?"):
+ parts = cls.split_commas(inner[1:])
+ index = 0
+ while index + 1 < len(parts):
+ cond = cls.value_of(parts[index], resolve, depth)
+ if cls.truthy(cond):
+ return cls.expand(parts[index + 1], resolve, depth + 1)
+ index += 2
+ if index < len(parts):
+ return cls.expand(parts[index], resolve, depth + 1)
+ return ""
+ colon = cls.modifier_colon(inner)
+ if colon < 0:
+ value = resolve(inner)
+ return "" if value is None else str(value)
+ mods = inner[:colon]
+ rest = inner[colon + 1:]
+ return cls.apply_modifiers(mods, rest, resolve, depth)
+
+ @staticmethod
+ def modifier_colon(inner):
+ """Index of the ':' that ends a modifier list, or -1 when inner is a plain name."""
+ colon = inner.find(":")
+ if colon <= 0:
+ return -1
+ head = inner[:colon]
+ if "#" in head or "{" in head or " " in head:
+ return -1
+ return colon
+
+ @classmethod
+ def apply_modifiers(cls, mods, rest, resolve, depth):
+ specs = [item for item in mods.split(";") if item]
+ text = None
+ for spec in specs:
+ if spec in ("==", "!=", "<", ">", "<=", ">=", "&&", "||") or spec.startswith("e|"):
+ parts = cls.split_commas(rest)
+ left = cls.expand(parts[0], resolve, depth + 1) if parts else ""
+ right = cls.expand(parts[1], resolve, depth + 1) if len(parts) > 1 else ""
+ return cls.compare(spec, left, right)
+ if spec in ("m", "m/r", "m/ri", "m/i"):
+ parts = cls.split_commas(rest)
+ pattern = cls.expand(parts[0], resolve, depth + 1) if parts else ""
+ subject = cls.expand(parts[1], resolve, depth + 1) if len(parts) > 1 else ""
+ flags = re.IGNORECASE if spec.endswith("i") else 0
+ if spec.startswith("m/r"):
+ try:
+ return "1" if re.search(pattern, subject, flags) else "0"
+ except re.error:
+ return "0"
+ import fnmatch
+ return "1" if fnmatch.fnmatchcase(subject.lower() if flags else subject, pattern.lower() if flags else pattern) else "0"
+ if spec == "l":
+ return rest
+ if text is None:
+ text = cls.value_of(rest, resolve, depth)
+ text = cls.apply_one(spec, text, resolve, depth)
+ return text or ""
+
+ @classmethod
+ def apply_one(cls, spec, text, resolve, depth):
+ if spec == "E" or spec == "T":
+ return cls.expand(text, resolve, depth + 1)
+ if spec == "q":
+ return re.sub(r"([\\\"'$#;{}\[\]()*?<>&|~ ])", r"\\\1", text)
+ if spec == "t":
+ try:
+ return time.strftime("%a %b %d %H:%M:%S %Y", time.localtime(float(text)))
+ except (ValueError, OverflowError, OSError):
+ return text
+ if spec == "n":
+ return str(len(text))
+ if spec == "b":
+ return os.path.basename(text)
+ if spec == "d":
+ return os.path.dirname(text)
+ if spec == "!":
+ return "0" if cls.truthy(text) else "1"
+ if spec == "!!":
+ return "1" if cls.truthy(text) else "0"
+ if spec.startswith("="):
+ body = spec[1:]
+ marker = ""
+ if body.startswith("/"):
+ _blank, count, marker = (body.split("/", 2) + ["", ""])[:3]
+ body = count
+ try:
+ limit = int(body)
+ except ValueError:
+ return text
+ if limit >= 0 and len(text) > limit:
+ return text[:limit] + marker
+ if limit < 0 and len(text) > -limit:
+ return marker + text[limit:]
+ return text
+ if spec.startswith("s/"):
+ fields = spec.split("/")
+ if len(fields) >= 3:
+ try:
+ return re.sub(fields[1], fields[2], text)
+ except re.error:
+ return text
+ return text
+
+ @staticmethod
+ def compare(op, left, right):
+ if op.startswith("e|"):
+ kind = op.split("|")[1] if "|" in op else ""
+ try:
+ a, b = float(left), float(right)
+ except ValueError:
+ return "0"
+ table = {"+": a + b, "-": a - b, "*": a * b, "/": a / b if b else 0, "%": a % b if b else 0,
+ "==": a == b, "!=": a != b, "<": a < b, ">": a > b, "<=": a <= b, ">=": a >= b}
+ if kind not in table:
+ return "0"
+ value = table[kind]
+ if isinstance(value, bool):
+ return "1" if value else "0"
+ return str(int(value)) if float(value).is_integer() else str(value)
+ if op == "==":
+ return "1" if left == right else "0"
+ if op == "!=":
+ return "1" if left != right else "0"
+ if op == "&&":
+ return "1" if MplexFormat.truthy(left) and MplexFormat.truthy(right) else "0"
+ if op == "||":
+ return "1" if MplexFormat.truthy(left) or MplexFormat.truthy(right) else "0"
+ try:
+ a, b = float(left), float(right)
+ except ValueError:
+ a, b = left, right
+ return "1" if {"<": a < b, ">": a > b, "<=": a <= b, ">=": a >= b}[op] else "0"
+
+ @staticmethod
+ def with_time(text, stamp=None):
+ if "%" not in text:
+ return text
+ try:
+ return time.strftime(text, time.localtime(stamp))
+ except (ValueError, OverflowError):
+ return text
+
+ @staticmethod
+ def strip_styles(text):
+ return re.sub(r"#\[[^\]]*\]", "", text)
+
+
+class MplexLayoutNode:
+ __slots__ = ("kind", "children", "parent", "pane", "x", "y", "w", "h", "reverse")
+
+ def __init__(self, kind, pane=None, w=1, h=1):
+ self.reverse = False
+ self.kind = kind
+ self.children = []
+ self.parent = None
+ self.pane = pane
+ self.x = 0
+ self.y = 0
+ self.w = w
+ self.h = h
+
+ def leaves(self):
+ if self.kind == "pane":
+ return [self]
+ found = []
+ for child in self.children:
+ found.extend(child.leaves())
+ return found
+
+ def adopt(self, child):
+ child.parent = self
+ self.children.append(child)
+ return child
+
+
+class MplexLayout:
+ """Split tree: a node is a pane or a container laid out side by side ("lr") or
+ stacked ("tb"); a one-cell border separates siblings. Sizes are absolute and are
+ rescaled proportionally when the window changes size."""
+
+ @staticmethod
+ def size_of(node, kind):
+ return node.w if kind == "lr" else node.h
+
+ @staticmethod
+ def set_size(node, kind, value):
+ if kind == "lr":
+ node.w = value
+ else:
+ node.h = value
+
+ @classmethod
+ def assign(cls, node, x, y, w, h):
+ node.x, node.y, node.w, node.h = x, y, w, h
+ if node.kind == "pane":
+ return
+ kids = node.children
+ count = len(kids)
+ kind = node.kind
+ total = (w if kind == "lr" else h) - (count - 1)
+ if total < count:
+ sizes = [1] * count
+ else:
+ sizes = [max(1, cls.size_of(kid, kind)) for kid in kids]
+ weight = sum(sizes) or count
+ if weight != total:
+ sizes = [max(1, int(round(size * total / weight))) for size in sizes]
+ diff = total - sum(sizes)
+ while diff != 0:
+ moved = False
+ for index in (range(count) if diff > 0 else range(count - 1, -1, -1)):
+ if diff > 0:
+ sizes[index] += 1
+ diff -= 1
+ moved = True
+ elif sizes[index] > 1:
+ sizes[index] -= 1
+ diff += 1
+ moved = True
+ if diff == 0:
+ break
+ if not moved:
+ break
+ order = list(zip(kids, sizes))
+ if node.reverse:
+ order.reverse()
+ pos = x if kind == "lr" else y
+ for kid, size in order:
+ if kind == "lr":
+ cls.assign(kid, pos, y, size, h)
+ else:
+ cls.assign(kid, x, pos, w, size)
+ pos += size + 1
+
+ @classmethod
+ def split(cls, root, leaf, kind, before, new_pane, size=None):
+ """Insert new_pane beside leaf. Returns (root, new_leaf)."""
+ total = cls.size_of(leaf, kind)
+ if total < 3:
+ raise MplexError("no space for new pane")
+ avail = total - 1
+ if size is None:
+ new_size = avail - avail // 2 if before else avail // 2
+ else:
+ new_size = max(1, min(avail - 1, size))
+ old_size = avail - new_size
+ fresh = MplexLayoutNode("pane", pane=new_pane, w=leaf.w, h=leaf.h)
+ parent = leaf.parent
+ if parent is not None and parent.kind == kind:
+ index = parent.children.index(leaf)
+ parent.children.insert(index if before else index + 1, fresh)
+ fresh.parent = parent
+ else:
+ box = MplexLayoutNode(kind, w=leaf.w, h=leaf.h)
+ box.x, box.y = leaf.x, leaf.y
+ box.parent = parent
+ if parent is None:
+ root = box
+ else:
+ parent.children[parent.children.index(leaf)] = box
+ for item in ((fresh, leaf) if before else (leaf, fresh)):
+ box.adopt(item)
+ cls.set_size(leaf, kind, old_size)
+ cls.set_size(fresh, kind, new_size)
+ return root, fresh
+
+ @classmethod
+ def remove(cls, root, leaf):
+ """Drop leaf, hand its space to a neighbour. Returns the new root (None when empty)."""
+ parent = leaf.parent
+ if parent is None:
+ return None
+ index = parent.children.index(leaf)
+ parent.children.remove(leaf)
+ neighbour = parent.children[index - 1] if index > 0 else parent.children[0]
+ kind = parent.kind
+ cls.set_size(neighbour, kind, cls.size_of(neighbour, kind) + cls.size_of(leaf, kind) + 1)
+ if len(parent.children) == 1:
+ only = parent.children[0]
+ grand = parent.parent
+ only.w, only.h = parent.w, parent.h
+ only.parent = grand
+ if grand is None:
+ return only
+ slot = grand.children.index(parent)
+ if only.kind == grand.kind:
+ grand.children[slot:slot + 1] = only.children
+ for child in only.children:
+ child.parent = grand
+ else:
+ grand.children[slot] = only
+ return root
+
+ @classmethod
+ def resize_by(cls, leaf, kind, delta, prefer_after):
+ """Move the border of leaf's nearest enclosing "kind" container by delta cells."""
+ node = leaf
+ while node.parent is not None and node.parent.kind != kind:
+ node = node.parent
+ parent = node.parent
+ if parent is None:
+ raise MplexError("no border to resize")
+ kids = parent.children
+ index = kids.index(node)
+ use_after = (index < len(kids) - 1) if prefer_after else (index == 0)
+ first = index if use_after else index - 1
+ a, b = kids[first], kids[first + 1]
+ size_a, size_b = cls.size_of(a, kind), cls.size_of(b, kind)
+ new_a = max(1, min(size_a + size_b - 1, size_a + delta))
+ cls.set_size(a, kind, new_a)
+ cls.set_size(b, kind, size_a + size_b - new_a)
+
+ @staticmethod
+ def shares(total, count, spare_first):
+ """Split total cells among count panes with count-1 one-cell borders; the remainder goes first or last."""
+ room = total - (count - 1)
+ base, extra = divmod(room, count)
+ sizes = [base] * count
+ for index in (range(extra) if spare_first else range(count - extra, count)):
+ sizes[index] += 1
+ return sizes
+
+ @classmethod
+ def resize_to(cls, leaf, kind, size):
+ """Make leaf exactly size cells along kind by moving the border it can move (the one after it, else the one before)."""
+ change = size - cls.size_of(leaf, kind)
+ node = leaf
+ while node.parent is not None and node.parent.kind != kind:
+ node = node.parent
+ if node.parent is None:
+ raise MplexError("no border to resize")
+ has_next = node.parent.children.index(node) < len(node.parent.children) - 1
+ cls.resize_by(leaf, kind, change if has_next else -change, True)
+
+ @classmethod
+ def preset(cls, name, panes, width, height, main_width, main_height):
+ count = len(panes)
+ leaves = [MplexLayoutNode("pane", pane=pane, w=1, h=1) for pane in panes]
+ mirrored = name.endswith("-mirrored")
+ base = name[:-len("-mirrored")] if mirrored else name
+ if count == 1:
+ leaves[0].w, leaves[0].h = width, height
+ return leaves[0]
+
+ def box(kind, items, w, h, spare_first=True):
+ node = MplexLayoutNode(kind, w=w, h=h)
+ for item in items:
+ node.adopt(item)
+ for item, size in zip(items, cls.shares(w if kind == "lr" else h, len(items), spare_first)):
+ if kind == "lr":
+ item.w, item.h = size, h
+ else:
+ item.w, item.h = w, size
+ return node
+
+ if base == "even-horizontal":
+ return box("lr", leaves, width, height)
+ if base == "even-vertical":
+ return box("tb", leaves, width, height)
+ if base in ("main-horizontal", "main-vertical"):
+ horizontal = base == "main-horizontal"
+ span = height if horizontal else width
+ main_size = max(1, min(span - 2, main_height if horizontal else main_width))
+ main = leaves[0]
+ rest = leaves[1:]
+ other_size = span - main_size - 1
+ if horizontal:
+ others = rest[0] if len(rest) == 1 else box("lr", rest, width, other_size)
+ main.w, main.h, others.w, others.h = width, main_size, width, other_size
+ root = box("tb", [main, others], width, height)
+ else:
+ others = rest[0] if len(rest) == 1 else box("tb", rest, other_size, height)
+ main.w, main.h, others.w, others.h = main_size, height, other_size, height
+ root = box("lr", [main, others], width, height)
+ if horizontal:
+ main.h, others.h = main_size, other_size
+ else:
+ main.w, others.w = main_size, other_size
+ root.reverse = mirrored
+ return root
+ rows = columns = 1
+ while rows * columns < count:
+ rows += 1
+ if rows * columns < count:
+ columns += 1
+ lines = []
+ for start in range(0, count, columns):
+ chunk = leaves[start:start + columns]
+ lines.append(chunk[0] if len(chunk) == 1 else box("lr", chunk, width, 1, spare_first=False))
+ if len(lines) == 1:
+ return lines[0]
+ root = box("tb", lines, width, height, spare_first=False)
+ for line in lines:
+ line.w = width
+ return root
+
+ @staticmethod
+ def neighbour(panes, current, direction, width, height):
+ left, top = current.x, current.y
+ right, bottom = current.x + current.w, current.y + current.h
+
+ def overlaps(other):
+ if direction in ("L", "R"):
+ return other.y < bottom and other.y + other.h > top
+ return other.x < right and other.x + other.w > left
+
+ wanted = []
+ for other in panes:
+ if other is current or not overlaps(other):
+ continue
+ if direction == "L" and other.x + other.w + 1 == left:
+ wanted.append(other)
+ elif direction == "R" and other.x == right + 1:
+ wanted.append(other)
+ elif direction == "U" and other.y + other.h + 1 == top:
+ wanted.append(other)
+ elif direction == "D" and other.y == bottom + 1:
+ wanted.append(other)
+ if not wanted:
+ for other in panes:
+ if other is current or not overlaps(other):
+ continue
+ if direction == "L" and other.x + other.w >= width:
+ wanted.append(other)
+ elif direction == "R" and other.x == 0:
+ wanted.append(other)
+ elif direction == "U" and other.y + other.h >= height:
+ wanted.append(other)
+ elif direction == "D" and other.y == 0:
+ wanted.append(other)
+ if not wanted:
+ return None
+ return max(wanted, key=lambda item: item.last_active)
+
+
+class MplexPane:
+ """One pty-backed shell: its screen, scrollback, output queue and mode stack."""
+
+ def __init__(self, ident, cols, rows, history_limit, home_dir, stamp="0"):
+ self.id = ident
+ self.window = None
+ self.x = 0
+ self.y = 0
+ self.w = max(1, cols)
+ self.h = max(1, rows)
+ self.screen = TuiVtScreen(self.h, self.w)
+ self.screen.history = collections.deque(maxlen=max(0, history_limit))
+ self.master_fd = None
+ self.pid = None
+ self.dead = False
+ self.exit_status = None
+ self.pending_in = bytearray()
+ self.dropped = 0
+ self.modes = []
+ self.prefix_count = 1
+ self.created = time.time()
+ self.last_active = 0.0
+ self.shell_name = ""
+ self.last_search = None
+ self.log_path = os.path.join(home_dir, "%s-pane%d.log" % (stamp, ident))
+ self._log = None
+ self._log_size = 0
+ self.input_bytes = 0
+
+ @property
+ def tag(self):
+ return "%%%d" % self.id
+
+ @property
+ def title(self):
+ return self.screen.title or socket.gethostname()
+
+ def spawn(self, file, argv, cwd, env):
+ master, slave = os.openpty()
+ try:
+ fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", self.h, self.w, 0, 0))
+ attrs = termios.tcgetattr(slave)
+ attrs[0] |= getattr(termios, "IUTF8", 0)
+ termios.tcsetattr(slave, termios.TCSANOW, attrs)
+ except (OSError, termios.error):
+ pass
+ log_fd = os.open(self.log_path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
+ self._log = os.fdopen(log_fd, "ab", buffering=0)
+ pid = os.fork()
+ if pid == 0:
+ try:
+ os.setsid()
+ fcntl.ioctl(slave, termios.TIOCSCTTY, 0)
+ for fd in (0, 1, 2):
+ os.dup2(slave, fd)
+ if slave > 2:
+ os.close(slave)
+ os.close(master)
+ for sig in (signal.SIGPIPE, signal.SIGINT, signal.SIGHUP, signal.SIGTERM):
+ signal.signal(sig, signal.SIG_DFL)
+ try:
+ os.chdir(cwd)
+ except OSError:
+ pass
+ os.execvpe(file, argv, env)
+ except BaseException:
+ pass
+ finally:
+ os._exit(127)
+ os.close(slave)
+ os.set_blocking(master, False)
+ self.master_fd = master
+ self.pid = pid
+ self.shell_name = os.path.basename(file)
+
+ def place(self, x, y, w, h):
+ self.x, self.y = x, y
+ w, h = max(1, w), max(1, h)
+ if (w, h) == (self.w, self.h):
+ return
+ self.w, self.h = w, h
+ self.screen.resize(h, w)
+ self.apply_winsize()
+ for mode in self.modes:
+ mode.on_resize()
+
+ def apply_winsize(self):
+ if self.master_fd is None:
+ return
+ try:
+ fcntl.ioctl(self.master_fd, termios.TIOCSWINSZ, struct.pack("HHHH", self.h, self.w, 0, 0))
+ except OSError:
+ pass
+
+ def feed(self, data):
+ history = self.screen.history
+ before = self.screen.history_pushed
+ before_len = len(history) if history is not None else 0
+ self.screen.feed(data)
+ pushed = self.screen.history_pushed - before
+ evicted = max(0, before_len + pushed - len(history)) if history is not None and pushed else 0
+ if self._log is not None:
+ self.log(data)
+ replies = self.screen.take_replies()
+ if replies:
+ self.write(replies)
+ for mode in self.modes:
+ mode.on_output(pushed, evicted)
+
+ def log(self, data):
+ """Append to the session log; at MPLEX_LOG_MAX it rolls over to .1 (one generation kept)."""
+ try:
+ if self._log_size + len(data) > MPLEX_LOG_MAX:
+ self._log.close()
+ os.replace(self.log_path, self.log_path + ".1")
+ fd = os.open(self.log_path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
+ self._log = os.fdopen(fd, "ab", buffering=0)
+ self._log_size = 0
+ self._log.write(data)
+ self._log_size += len(data)
+ except OSError:
+ pass
+
+ def write(self, data):
+ if not data:
+ return
+ if len(self.pending_in) + len(data) > MPLEX_PANE_IN_MAX:
+ self.dropped += len(data)
+ return
+ self.pending_in += data
+
+ def drain(self):
+ if not self.pending_in or self.master_fd is None:
+ return
+ try:
+ written = os.write(self.master_fd, bytes(self.pending_in[:4096]))
+ del self.pending_in[:written]
+ except BlockingIOError:
+ pass
+ except OSError:
+ self.pending_in.clear()
+
+ def top_mode(self):
+ return self.modes[-1] if self.modes else None
+
+ def push_mode(self, mode):
+ self.modes.append(mode)
+ return mode
+
+ def pop_mode(self, mode=None):
+ if mode is None:
+ if self.modes:
+ self.modes.pop()
+ elif mode in self.modes:
+ self.modes.remove(mode)
+
+ def find_mode(self, cls):
+ for mode in self.modes:
+ if isinstance(mode, cls):
+ return mode
+ return None
+
+ def foreground_command(self):
+ try:
+ group = os.tcgetpgrp(self.master_fd)
+ with open("/proc/%d/comm" % group, "r") as handle:
+ return handle.read().strip() or self.shell_name
+ except (OSError, TypeError, ValueError):
+ return self.shell_name
+
+ def current_path(self):
+ try:
+ return os.readlink("/proc/%d/cwd" % self.pid)
+ except (OSError, TypeError):
+ return ""
+
+ def history_size(self):
+ history = self.screen.history
+ return len(history) if history is not None else 0
+
+ def clear_history(self):
+ if self.screen.history is not None:
+ self.screen.history.clear()
+
+ def close(self, hard=False):
+ """Hang up the child: SIGHUP to its group, close the master; hard SIGKILLs instead."""
+ if self.pid is not None:
+ sig = signal.SIGKILL if hard else signal.SIGHUP
+ try:
+ os.killpg(self.pid, sig)
+ except OSError:
+ try:
+ os.kill(self.pid, sig)
+ except OSError:
+ pass
+ if self.master_fd is not None:
+ try:
+ os.close(self.master_fd)
+ except OSError:
+ pass
+ self.master_fd = None
+ if self._log is not None:
+ try:
+ self._log.close()
+ except OSError:
+ pass
+ self._log = None
+
+ def tail_log(self, lines, chunk_size=65536, max_bytes=1 << 24):
+ """Last lines of the raw session log, reading only the tail in growing chunks."""
+ try:
+ size = os.path.getsize(self.log_path)
+ except OSError:
+ return ""
+ with open(self.log_path, "rb") as handle:
+ read_from = size
+ data = b""
+ while True:
+ read_from = max(0, read_from - chunk_size)
+ handle.seek(read_from)
+ data = handle.read(size - read_from)
+ if data.count(b"\n") >= lines or read_from == 0 or len(data) >= max_bytes:
+ break
+ return "\n".join(data.decode("utf-8", "replace").splitlines()[-lines:])
+
+
+class MplexWindow:
+ def __init__(self, ident, name, width, height, parent_options):
+ self.id = ident
+ self.name = name
+ self.root = None
+ self.active = None
+ self.last_pane = None
+ self.zoomed = None
+ self.width = width
+ self.height = height
+ self.layout_index = -1
+ self.options = MplexOptions(parent_options)
+ self.auto_rename = True
+ self.created = time.time()
+ self.session = None
+
+ @property
+ def tag(self):
+ return "@%d" % self.id
+
+ def panes(self):
+ return [leaf.pane for leaf in self.root.leaves()] if self.root is not None else []
+
+ def relayout(self):
+ if self.root is None:
+ return
+ MplexLayout.assign(self.root, 0, 0, self.width, self.height)
+ for leaf in self.root.leaves():
+ leaf.pane.place(leaf.x, leaf.y, leaf.w, leaf.h)
+ if self.zoomed is not None and self.zoomed in self.panes():
+ self.zoomed.place(0, 0, self.width, self.height)
+ else:
+ self.zoomed = None
+
+ def visible_panes(self):
+ return [self.zoomed] if self.zoomed is not None else self.panes()
+
+ def leaf_of(self, pane):
+ for leaf in self.root.leaves():
+ if leaf.pane is pane:
+ return leaf
+ return None
+
+ def set_active(self, pane):
+ if pane is not self.active:
+ self.last_pane = self.active
+ self.active = pane
+ pane.last_active = time.monotonic()
+
+
+class MplexSession:
+ def __init__(self, ident, name, parent_options, cwd, env):
+ self.id = ident
+ self.name = name
+ self.windows = {}
+ self.current = None
+ self.history = []
+ self.created = time.time()
+ self.options = MplexOptions(parent_options)
+ self.cwd = cwd
+ self.env = env
+ self.last_attached = time.time()
+
+ @property
+ def tag(self):
+ return "$%d" % self.id
+
+ def window(self):
+ return self.windows.get(self.current)
+
+ def indexes(self):
+ return sorted(self.windows)
+
+ def free_index(self, base):
+ index = base
+ while index in self.windows:
+ index += 1
+ return index
+
+ @property
+ def last(self):
+ """Index of the window that was current before this one, or None."""
+ for window in self.history:
+ index = self.index_of(window)
+ if index is not None:
+ return index
+ return None
+
+ def select(self, index):
+ if index not in self.windows:
+ return False
+ if index != self.current:
+ previous = self.window()
+ target = self.windows[index]
+ self.history = [item for item in self.history if item is not previous and item is not target]
+ if previous is not None:
+ self.history.insert(0, previous)
+ self.current = index
+ return True
+
+ def index_of(self, window):
+ for index, item in self.windows.items():
+ if item is window:
+ return index
+ return None
+
+
+class MplexCanvas:
+ """A client-sized grid of (char, fg, bg, attr) cells plus a cursor, and the diff that
+ turns two successive grids into the escape sequences that repaint only what changed."""
+
+ BLANK = (" ", None, None, 0)
+ GLYPHS = {
+ "single": "─│┌┐└┘├┤┬┴┼",
+ "double": "═║╔╗╚╝╠╣╦╩╬",
+ "heavy": "━┃┏┓┗┛┣┫┳┻╋",
+ "simple": "-|+++++++++",
+ }
+
+ def __init__(self, rows, cols):
+ self.rows = rows
+ self.cols = cols
+ self.cells = [[self.BLANK] * cols for _row in range(rows)]
+ self.cursor = None
+
+ def put(self, x, y, text, style=(None, None, 0), limit=None):
+ if y < 0 or y >= self.rows:
+ return x
+ fg, bg, attr = style
+ row = self.cells[y]
+ for char in text:
+ if limit is not None and limit <= 0:
+ break
+ if 0 <= x < self.cols:
+ row[x] = (char, fg, bg, attr)
+ x += 1
+ if limit is not None:
+ limit -= 1
+ return x
+
+ def put_cells(self, x, y, cells):
+ if y < 0 or y >= self.rows:
+ return
+ row = self.cells[y]
+ for cell in cells:
+ if 0 <= x < self.cols:
+ row[x] = cell
+ x += 1
+
+ def blit(self, x, y, block, width):
+ for index, source in enumerate(block):
+ target = y + index
+ if target < 0 or target >= self.rows:
+ continue
+ row = self.cells[target]
+ take = min(width, len(source), self.cols - x)
+ if take > 0:
+ row[x:x + take] = source[:take]
+
+ def fill(self, x, y, w, h, style=(None, None, 0), char=" "):
+ cell = (char, style[0], style[1], style[2])
+ for target in range(max(0, y), min(self.rows, y + h)):
+ row = self.cells[target]
+ for col in range(max(0, x), min(self.cols, x + w)):
+ row[col] = cell
+
+ @classmethod
+ def border_char(cls, up, down, left, right, lines):
+ glyphs = cls.GLYPHS.get(lines, cls.GLYPHS["single"])
+ horiz, vert, tl, tr, bl, br, tee_r, tee_l, tee_d, tee_u, cross = glyphs
+ if (up or down) and not (left or right):
+ return vert
+ if (left or right) and not (up or down):
+ return horiz
+ if up and down and left and right:
+ return cross
+ if up and down and right:
+ return tee_r
+ if up and down and left:
+ return tee_l
+ if left and right and down:
+ return tee_d
+ if left and right and up:
+ return tee_u
+ if down and right:
+ return tl
+ if down and left:
+ return tr
+ if up and right:
+ return bl
+ if up and left:
+ return br
+ return cross
+
+ def encode(self, previous):
+ """Escape bytes repainting this grid over previous (a list of row tuples, or None)."""
+ snapshot = [tuple(row) for row in self.cells]
+ out = ["\x1b[?25l"]
+ if previous is None or len(previous) != len(snapshot):
+ out.append("\x1b[0m\x1b[2J")
+ previous = None
+ for index, row in enumerate(snapshot):
+ if previous is not None and previous[index] == row:
+ continue
+ out.append("\x1b[%d;1H" % (index + 1))
+ current = None
+ for char, fg, bg, attr in row:
+ style = (fg, bg, attr)
+ if style != current:
+ out.append(MplexStyle.sgr(fg, bg, attr))
+ current = style
+ out.append(char)
+ out.append("\x1b[0m")
+ if self.cursor is not None:
+ out.append("\x1b[%d;%dH\x1b[?25h" % (self.cursor[1] + 1, self.cursor[0] + 1))
+ return "".join(out).encode("utf-8", "replace"), snapshot
+
+
+class MplexStatusText:
+ """Format-expanded text with #[...] style markers turned into styled cells."""
+
+ @staticmethod
+ def cells(text, base, reset=None):
+ reset = base if reset is None else reset
+ current = base
+ out = []
+ pos = 0
+ while pos < len(text):
+ if text.startswith("#[", pos):
+ end = text.find("]", pos + 2)
+ if end > 0:
+ try:
+ current = MplexStyle.parse(text[pos + 2:end], current, reset)
+ except MplexError:
+ pass
+ pos = end + 1
+ continue
+ out.append((text[pos], current[0], current[1], current[2]))
+ pos += 1
+ return out
+
+
+class MplexMode:
+ """Pane mode base: owns the pane's keyboard and its drawing while it is on top."""
+
+ name = "mode"
+
+ def __init__(self, server, pane):
+ self.server = server
+ self.pane = pane
+
+ def render(self, client):
+ raise NotImplementedError
+
+ def key(self, name, raw, client):
+ return None
+
+ def on_output(self, pushed, evicted):
+ return None
+
+ def on_resize(self):
+ return None
+
+ def close(self):
+ self.pane.pop_mode(self)
+ self.server.mark_dirty(self.pane.window)
+
+
+class MplexCopyMode(MplexMode):
+ """Scrollback browser: movement, search, selection and copy, driven by the
+ copy-mode / copy-mode-vi key tables through "send-keys -X "."""
+
+ name = "copy-mode"
+
+ def __init__(self, server, pane, lines=None, view=False, exit_at_bottom=False, show_position=True):
+ super().__init__(server, pane)
+ self.static_rows = None
+ if lines is not None:
+ self.name = "view-mode"
+ self.static_rows = [[(char, None, None, 0) for char in line] for line in lines]
+ while len(self.static_rows) < pane.h:
+ self.static_rows.append([])
+ self.oy = 0
+ self.cx = 0 if lines is not None else pane.screen.cx
+ self.cy = 0 if lines is not None else pane.screen.cy
+ self.anchor = None
+ self.rect = False
+ self.line_select = False
+ self.mark = None
+ self.jump = None
+ self.inc_origin = None
+ self.show_position = show_position
+ self.exit_at_bottom = exit_at_bottom
+ self.count = 1
+ self.lastcx = 0
+ self.lastsx = 0
+ # a search string outlives copy mode; re-entering it starts searching upward, as tmux does
+ self.search = (pane.last_search[0], False, pane.last_search[2]) if lines is None and pane.last_search else None
+ self._line_cache = (-1, "")
+ if lines is not None:
+ self.oy = self.hist()
+
+ @property
+ def rows(self):
+ return self.pane.h
+
+ @property
+ def cols(self):
+ return self.pane.w
+
+ def hist(self):
+ if self.static_rows is not None:
+ return max(0, len(self.static_rows) - self.rows)
+ return self.pane.history_size()
+
+ def total(self):
+ return self.hist() + self.rows
+
+ def top(self):
+ return self.hist() - self.oy
+
+ def abs_y(self):
+ return self.top() + self.cy
+
+ def row(self, ay):
+ if self.static_rows is not None:
+ return self.static_rows[ay] if 0 <= ay < len(self.static_rows) else []
+ hist = self.hist()
+ if ay < hist:
+ return self.pane.screen.history[ay]
+ grid = self.pane.screen.grid
+ index = ay - hist
+ return grid[index] if 0 <= index < len(grid) else []
+
+ def text(self, ay):
+ return "".join(cell[0] if cell else " " for cell in self.row(ay)).rstrip()
+
+ def clamp(self):
+ self.oy = max(0, min(self.hist(), self.oy))
+ self.cy = max(0, min(self.rows - 1, self.cy))
+ self.cx = max(0, min(self.cols, self.cx))
+
+ def goto(self, x, ay):
+ ay = max(0, min(self.total() - 1, ay))
+ top = self.top()
+ if ay < top:
+ top = ay
+ elif ay >= top + self.rows:
+ top = ay - self.rows + 1
+ self.oy = max(0, min(self.hist(), self.hist() - top))
+ self.cy = ay - self.top()
+ self.cx = max(0, min(self.cols, x))
+
+ def on_output(self, pushed, evicted):
+ if self.static_rows is not None:
+ return
+ if self.oy > 0 and pushed:
+ self.oy += pushed
+ if evicted:
+ if self.anchor:
+ self.anchor = (self.anchor[0], self.anchor[1] - evicted)
+ if self.mark:
+ self.mark = (self.mark[0], self.mark[1] - evicted)
+ self.clamp()
+
+ def on_resize(self):
+ self.clamp()
+
+ # ---- rendering -------------------------------------------------------
+
+ def vi_keys(self):
+ return self.server.pane_options(self.pane).get("mode-keys") == "vi"
+
+ def selected(self, x, ay):
+ if self.anchor is None:
+ return False
+ ax, ay0 = self.anchor
+ cx, cy = self.cx, self.abs_y()
+ extra = 1 if self.vi_keys() else 0
+ if self.rect:
+ return min(ay0, cy) <= ay <= max(ay0, cy) and min(ax, cx) <= x < max(ax, cx) + extra
+ if self.line_select:
+ return min(ay0, cy) <= ay <= max(ay0, cy)
+ first, last = sorted(((ay0, ax), (cy, cx)))
+ return first <= (ay, x) < (last[0], last[1] + extra)
+
+ def pattern(self):
+ if not self.search:
+ return None
+ return self.compile(self.search[0], len(self.search) > 2 and self.search[2])
+
+ def render(self, client):
+ opts = self.server.pane_options(self.pane)
+ mode_style = MplexStyle.parse(opts.get("mode-style"))
+ match_style = MplexStyle.parse(opts.get("copy-mode-match-style"))
+ pattern = self.pattern()
+ top = self.top()
+ out = []
+ for view_row in range(self.rows):
+ ay = top + view_row
+ source = self.row(ay)
+ cells = []
+ for col in range(self.cols):
+ cell = source[col] if col < len(source) and source[col] is not None else MplexCanvas.BLANK
+ cells.append(cell)
+ if pattern is not None:
+ line = "".join(cell[0] for cell in cells)
+ for match in pattern.finditer(line):
+ if match.end() == match.start():
+ continue
+ for col in range(match.start(), match.end()):
+ cells[col] = self.tint(cells[col], match_style)
+ if self.anchor is not None:
+ for col in range(self.cols):
+ if self.selected(col, ay):
+ cells[col] = self.tint(cells[col], mode_style)
+ out.append(cells)
+ if self.show_position and out:
+ label = "[%d/%d]" % (self.oy, self.hist())
+ start = max(0, self.cols - len(label))
+ for index, char in enumerate(label[:self.cols]):
+ out[0][start + index] = (char, mode_style[0], mode_style[1], mode_style[2])
+ return out, (self.cx, self.cy)
+
+ @staticmethod
+ def tint(cell, style):
+ char, fg, bg, attr = cell
+ return (char, style[0] or fg, style[1] or bg, attr | style[2])
+
+ # ---- keys ------------------------------------------------------------
+
+ def key(self, name, raw, client):
+ table = "copy-mode-vi" if self.server.pane_options(self.pane).get("mode-keys") == "vi" else "copy-mode"
+ binding = self.server.config.lookup(table, name)
+ if binding is not None:
+ self.server.run_binding(client, binding, self.pane)
+ return True
+
+ def command(self, name, words, client):
+ """Run one copy-mode command (send-keys -X name args) count times."""
+ method = getattr(self, "c_" + name.replace("-", "_"), None)
+ if method is None:
+ raise MplexError("unknown copy mode command: %s" % name)
+ count = max(1, min(self.pane.prefix_count, 10000))
+ self.pane.prefix_count = 1
+ self.count = count
+ self._line_cache = (-1, "")
+ result = method(words, client)
+ self.clamp()
+ self.server.mark_dirty(self.pane.window)
+ if result == "cancel":
+ self.close()
+
+ # ---- cursor and scrolling --------------------------------------------
+ # The cursor sits between characters like tmux's: it may stand one past the
+ # last character of a line (x == line length), and vertical moves remember the
+ # column they started from (lastcx / lastsx), snapping to the line end the way
+ # tmux does. These rules were checked against tmux 3.6 with a differential fuzz.
+
+ def llen(self, ay):
+ return len(self.text(ay))
+
+ def snap(self):
+ px = self.llen(self.abs_y())
+ if (self.cx >= self.lastsx and self.cx != px) or self.cx > px:
+ self.cx = px
+
+ def c_cancel(self, words, client):
+ return "cancel"
+
+ def step_up(self, scroll_only):
+ ox = self.llen(self.abs_y())
+ if self.cx != ox:
+ self.lastcx, self.lastsx = self.cx, ox
+ self.cx = self.lastcx
+ if scroll_only or self.cy == 0:
+ if self.oy < self.hist():
+ self.oy += 1
+ else:
+ self.cy -= 1
+ self.snap()
+
+ def step_down(self, scroll_only):
+ ox = self.llen(self.abs_y())
+ if self.cx != ox:
+ self.lastcx, self.lastsx = self.cx, ox
+ self.cx = self.lastcx
+ if scroll_only or self.cy == self.rows - 1:
+ if self.oy > 0:
+ self.oy -= 1
+ else:
+ self.cy += 1
+ self.snap()
+
+ def c_cursor_up(self, words, client):
+ for _i in range(self.count):
+ self.step_up(False)
+
+ def c_cursor_down(self, words, client):
+ for _i in range(self.count):
+ self.step_down(False)
+ return self.bottom_exit()
+
+ def c_cursor_down_and_cancel(self, words, client):
+ for _i in range(self.count):
+ self.step_down(False)
+ if self.oy == 0 and self.cy == self.rows - 1:
+ return "cancel"
+
+ def bottom_exit(self):
+ return "cancel" if self.exit_at_bottom and self.oy == 0 else None
+
+ def c_cursor_left(self, words, client):
+ for _i in range(self.count):
+ ay = self.abs_y()
+ if self.cx == 0 and ay > 0:
+ self.goto(self.llen(ay - 1), ay - 1)
+ elif self.cx > 0:
+ self.cx -= 1
+
+ def c_cursor_right(self, words, client):
+ for _i in range(self.count):
+ ay = self.abs_y()
+ if self.cx >= self.llen(ay):
+ if ay < self.total() - 1:
+ self.goto(0, ay + 1)
+ else:
+ self.cx += 1
+
+ def c_start_of_line(self, words, client):
+ self.cx = 0
+
+ def c_end_of_line(self, words, client):
+ self.cx = self.llen(self.abs_y())
+
+ def c_back_to_indentation(self, words, client):
+ line = self.text(self.abs_y())
+ if line:
+ self.cx = len(line) - len(line.lstrip(" "))
+
+ def c_top_line(self, words, client):
+ self.cx, self.cy = 0, 0
+
+ def c_middle_line(self, words, client):
+ self.cx, self.cy = 0, (self.rows - 1) // 2
+
+ def c_bottom_line(self, words, client):
+ self.cx, self.cy = 0, self.rows - 1
+
+ def c_history_top(self, words, client):
+ self.oy, self.cx, self.cy = self.hist(), 0, 0
+
+ def c_history_bottom(self, words, client):
+ self.oy, self.cy = 0, self.rows - 1
+ self.cx = self.llen(self.abs_y())
+ return self.bottom_exit()
+
+ def page_size(self, half):
+ return max(1, self.rows // 2 if half else self.rows - 2) if self.rows > 2 else 1
+
+ def remember_column(self):
+ ox = self.llen(self.abs_y())
+ if self.cx != ox:
+ self.lastcx, self.lastsx = self.cx, ox
+ self.cx = self.lastcx
+
+ def page_up(self, half):
+ size = self.page_size(half)
+ self.remember_column()
+ if self.oy + size > self.hist():
+ self.oy = self.hist()
+ self.cy = self.cy - size if self.cy > size else 0
+ else:
+ self.oy += size
+ self.snap()
+
+ def page_down(self, half):
+ size = self.page_size(half)
+ self.remember_column()
+ if self.oy < size:
+ self.cy = min(self.rows - 1, self.cy + size)
+ self.oy = 0
+ else:
+ self.oy -= size
+ self.snap()
+
+ def c_page_up(self, words, client):
+ for _i in range(self.count):
+ self.page_up(False)
+
+ def c_page_down(self, words, client):
+ for _i in range(self.count):
+ self.page_down(False)
+ return self.bottom_exit()
+
+ def c_page_down_and_cancel(self, words, client):
+ for _i in range(self.count):
+ self.page_down(False)
+ if self.oy == 0:
+ return "cancel"
+
+ def c_halfpage_up(self, words, client):
+ for _i in range(self.count):
+ self.page_up(True)
+
+ def c_halfpage_down(self, words, client):
+ for _i in range(self.count):
+ self.page_down(True)
+ return self.bottom_exit()
+
+ def c_halfpage_down_and_cancel(self, words, client):
+ for _i in range(self.count):
+ self.page_down(True)
+ if self.oy == 0:
+ return "cancel"
+
+ def c_scroll_up(self, words, client):
+ for _i in range(self.count):
+ self.step_up(True)
+
+ def c_scroll_down(self, words, client):
+ for _i in range(self.count):
+ self.step_down(True)
+ return self.bottom_exit()
+
+ def c_scroll_down_and_cancel(self, words, client):
+ for _i in range(self.count):
+ self.step_down(True)
+ if self.oy == 0:
+ return "cancel"
+
+ def c_scroll_middle(self, words, client):
+ mid = (self.rows - 1) // 2
+ delta = self.cy - mid
+ if delta > 0 and self.oy >= delta:
+ self.oy -= delta
+ self.cy = mid
+ elif delta < 0 and self.hist() - self.oy >= -delta:
+ self.oy += -delta
+ self.cy = mid
+
+ def c_cursor_centre_vertical(self, words, client):
+ self.cy = self.rows // 2
+
+ def c_cursor_centre_horizontal(self, words, client):
+ return None
+
+ def c_scroll_top(self, words, client):
+ if self.oy >= self.cy:
+ self.oy -= self.cy
+ self.cy = 0
+
+ def c_scroll_bottom(self, words, client):
+ need = self.rows - 1 - self.cy
+ if self.hist() - self.oy >= need:
+ self.oy += need
+ self.cy = self.rows - 1
+
+ def c_goto_line(self, words, client):
+ try:
+ line = int(words[0])
+ except (IndexError, ValueError):
+ raise MplexError("goto-line needs a line number")
+ self.oy = max(0, min(self.hist(), line))
+
+ def c_refresh_from_pane(self, words, client):
+ return None
+
+ def c_toggle_position(self, words, client):
+ self.show_position = not self.show_position
+
+ # ---- words, paragraphs, brackets -------------------------------------
+
+ def in_ws(self, x, ay):
+ return self.char_at(x, ay) == " "
+
+ def in_sep(self, x, ay, separators):
+ return self.char_at(x, ay) in separators
+
+ def separators(self, big):
+ return "" if big else self.server.pane_options(self.pane).get("word-separators")
+
+ def char_at(self, x, ay):
+ if self._line_cache[0] != ay:
+ self._line_cache = (ay, self.text(ay))
+ line = self._line_cache[1]
+ return line[x] if x < len(line) else " "
+
+ def advance_blanks(self, x, ay, xx):
+ """Skip whitespace, running off the line end onto the next line. Returns (x, ay, xx, hit_bottom)."""
+ last = self.total() - 1
+ while x > xx or self.in_ws(x, ay):
+ if x > xx:
+ if ay == last:
+ return x, ay, xx, True
+ ay += 1
+ x = 0
+ xx = self.llen(ay)
+ else:
+ x += 1
+ return x, ay, xx, False
+
+ def run_end(self, x, ay, xx, separators):
+ """x just past the run of separators (or of word characters) that starts at x."""
+ if self.in_sep(x, ay, separators) and not self.in_ws(x, ay):
+ x += 1
+ while x <= xx and self.in_sep(x, ay, separators) and not self.in_ws(x, ay):
+ x += 1
+ else:
+ x += 1
+ while x <= xx and not (self.in_sep(x, ay, separators) or self.in_ws(x, ay)):
+ x += 1
+ return x
+
+ def c_next_word(self, words, client, big=False):
+ separators = self.separators(big)
+ x, ay = self.cx, self.abs_y()
+ for _i in range(self.count):
+ xx = self.llen(ay)
+ if x > xx:
+ # past the line end (reachable after centre-vertical or goto-line): tmux carries on skipping
+ # the first word of the next line before it looks for a word start
+ if ay == self.total() - 1:
+ break
+ ay += 1
+ x = 0
+ xx = self.llen(ay)
+ if not self.in_ws(x, ay):
+ x = self.run_end(x, ay, xx, separators)
+ x, ay, xx, _bottom = self.advance_blanks(x, ay, xx)
+ self.goto(x, ay)
+
+ def c_next_space(self, words, client):
+ self.c_next_word(words, client, True)
+
+ def c_next_word_end(self, words, client, big=False):
+ separators = self.separators(big)
+ vi = self.server.pane_options(self.pane).get("mode-keys") == "vi"
+ x, ay = self.cx, self.abs_y()
+ for _i in range(self.count):
+ xx = self.llen(ay)
+ if vi:
+ if x < xx:
+ x += 1
+ elif ay < self.total() - 1:
+ self.goto(0, ay + 1)
+ x, ay = 0, ay + 1
+ xx = self.llen(ay)
+ x, ay, xx, bottom = self.advance_blanks(x, ay, xx)
+ if not bottom:
+ x = self.run_end(x, ay, xx, separators)
+ if vi and x > 0:
+ x -= 1
+ if bottom:
+ break
+ self.goto(x, ay)
+
+ def c_next_space_end(self, words, client):
+ self.c_next_word_end(words, client, True)
+
+ def c_previous_word(self, words, client, big=False):
+ separators = self.separators(big)
+ x, ay = self.cx, self.abs_y()
+ for _i in range(self.count):
+ # one step back, then over whitespace (and empty lines) to a word character
+ while True:
+ if x == 0:
+ if ay == 0:
+ break
+ ay -= 1
+ x = self.llen(ay)
+ else:
+ x -= 1
+ if not self.in_ws(x, ay):
+ break
+ if self.in_ws(x, ay):
+ break
+ letters = not self.in_sep(x, ay, separators)
+ while x > 0 and not self.in_ws(x - 1, ay) and letters == (not self.in_sep(x - 1, ay, separators)):
+ x -= 1
+ self.goto(x, ay)
+
+ def c_previous_space(self, words, client):
+ self.c_previous_word(words, client, True)
+
+ def c_next_paragraph(self, words, client):
+ ay = self.abs_y()
+ last = self.total() - 1
+ for _i in range(self.count):
+ while ay < last and not self.llen(ay):
+ ay += 1
+ while ay < last and self.llen(ay):
+ ay += 1
+ self.scroll_to(self.llen(ay), ay)
+
+ def c_previous_paragraph(self, words, client):
+ ay = self.abs_y()
+ for _i in range(self.count):
+ while ay > 0 and not self.llen(ay):
+ ay -= 1
+ while ay > 0 and self.llen(ay):
+ ay -= 1
+ self.scroll_to(0, ay)
+
+ def match_bracket(self, x, ay, forward):
+ """Position of the bracket matching the one at (x, ay), or None."""
+ pairs = {"(": ")", "[": "]", "{": "}"}
+ reverse = {value: key for key, value in pairs.items()}
+ opener = self.char_at(x, ay)
+ closer = pairs.get(opener) if forward else reverse.get(opener)
+ if closer is None:
+ return None
+ depth = 0
+ pos = (x, ay)
+ for _i in range(20000):
+ char = self.char_at(*pos)
+ if char == opener:
+ depth += 1
+ elif char == closer:
+ depth -= 1
+ if depth == 0:
+ return pos
+ pos = self.step(pos[0], pos[1], forward)
+ if pos is None:
+ return None
+ return None
+
+ def step(self, x, ay, forward):
+ """Next position, line ends counting as one blank cell. None at the buffer edge."""
+ if forward:
+ if x < self.llen(ay):
+ return x + 1, ay
+ return (0, ay + 1) if ay + 1 < self.total() else None
+ if x > 0:
+ return x - 1, ay
+ return (self.llen(ay - 1), ay - 1) if ay > 0 else None
+
+ def c_next_matching_bracket(self, words, client):
+ ay = self.abs_y()
+ if not self.llen(ay):
+ return
+ here = self.char_at(self.cx, ay)
+ found = None
+ if self.vi_keys():
+ if here in "([{":
+ found = self.match_bracket(self.cx, ay, True)
+ elif here in ")]}":
+ found = self.match_bracket(self.cx, ay, False)
+ else:
+ for x in (self.cx, self.cx + 1):
+ if self.char_at(x, ay) in "([{" and found is None and (x == self.cx or here == " "):
+ found = self.match_bracket(x, ay, True)
+ if found is not None:
+ self.goto(*found)
+ else:
+ self.c_next_word_end([], None, True)
+
+ def c_previous_matching_bracket(self, words, client):
+ ay = self.abs_y()
+ if not self.llen(ay):
+ return
+ here = self.char_at(self.cx, ay)
+ found = None
+ if here in ")]}":
+ found = self.match_bracket(self.cx, ay, False)
+ elif self.vi_keys():
+ if here in "([{" or self.char_at(self.cx + 1, ay) in "([{":
+ return
+ elif self.cx > 0 and self.char_at(self.cx - 1, ay) in ")]}":
+ found = self.match_bracket(self.cx - 1, ay, False)
+ if found is not None:
+ self.goto(*found)
+ else:
+ self.c_previous_word([], None, True)
+
+ # ---- jump ------------------------------------------------------------
+
+ def do_jump(self, kind, char):
+ line = self.text(self.abs_y())
+ x = self.cx
+ for _i in range(self.count):
+ if kind == "f":
+ found = line.find(char, x + 1)
+ elif kind == "t":
+ found = line.find(char, x + 2)
+ found = found - 1 if found >= 0 else -1
+ elif kind == "F":
+ found = line.rfind(char, 0, x)
+ else:
+ found = line.rfind(char, 0, max(0, x - 1))
+ found = found + 1 if found >= 0 else -1
+ if found < 0:
+ return
+ x = found
+ self.cx = x
+
+ def set_jump(self, kind, words):
+ if not words or not words[0]:
+ raise MplexError("jump needs a character")
+ self.jump = (kind, words[0][0])
+ self.do_jump(*self.jump)
+
+ def c_jump_forward(self, words, client):
+ self.set_jump("f", words)
+
+ def c_jump_backward(self, words, client):
+ self.set_jump("F", words)
+
+ def c_jump_to_forward(self, words, client):
+ self.set_jump("t", words)
+
+ def c_jump_to_backward(self, words, client):
+ self.set_jump("T", words)
+
+ def c_jump_again(self, words, client):
+ if self.jump:
+ self.do_jump(*self.jump)
+
+ def c_jump_reverse(self, words, client):
+ if self.jump:
+ opposite = {"f": "F", "F": "f", "t": "T", "T": "t"}[self.jump[0]]
+ self.do_jump(opposite, self.jump[1])
+
+ # ---- selection and copy ----------------------------------------------
+
+ def c_begin_selection(self, words, client):
+ self.anchor = (self.cx, self.abs_y())
+ self.line_select = False
+
+ def c_stop_selection(self, words, client):
+ return None
+
+ def c_clear_selection(self, words, client):
+ self.anchor = None
+ self.line_select = False
+
+ def c_select_line(self, words, client):
+ self.anchor = (0, self.abs_y())
+ self.line_select = True
+ self.rect = False
+ for _i in range(self.count - 1):
+ self.step_down(False)
+ self.cx = self.llen(self.abs_y())
+
+ def c_select_word(self, words, client):
+ ay = self.abs_y()
+ separators = self.separators(False)
+ line = self.text(ay)
+ if not line:
+ return
+ start = min(self.cx, len(line) - 1)
+ while start > 0 and self.in_ws(start, ay):
+ start -= 1
+ if self.in_ws(start, ay):
+ return
+ letters = not self.in_sep(start, ay, separators)
+ end = start
+ while start > 0 and not self.in_ws(start - 1, ay) and letters == (not self.in_sep(start - 1, ay, separators)):
+ start -= 1
+ while end + 1 < len(line) and not self.in_ws(end + 1, ay) and letters == (not self.in_sep(end + 1, ay, separators)):
+ end += 1
+ self.anchor = (start, ay)
+ self.line_select = False
+ self.cx = end if self.vi_keys() else end + 1
+
+ def c_rectangle_toggle(self, words, client):
+ self.rect = not self.rect
+
+ def c_rectangle_on(self, words, client):
+ self.rect = True
+
+ def c_rectangle_off(self, words, client):
+ self.rect = False
+
+ def c_other_end(self, words, client):
+ if self.anchor is None:
+ return
+ old = (self.cx, self.abs_y())
+ self.goto(*self.anchor)
+ self.anchor = old
+
+ def c_set_mark(self, words, client):
+ self.mark = (self.cx, self.abs_y())
+
+ def c_jump_to_mark(self, words, client):
+ if self.mark is None:
+ return
+ old = (self.cx, self.abs_y())
+ self.goto(*self.mark)
+ self.mark = old
+
+ def selection_text(self):
+ """The selected text. Emacs keys: the cursor stands between characters, so the end is
+ exclusive; vi keys: the character under the cursor is included, and so is the line end
+ (a newline) when the cursor stands on it. Both checked against tmux 3.6."""
+ if self.anchor is None:
+ return ""
+ vi = self.vi_keys()
+ ax, ay0 = self.anchor
+ cx, cy = self.cx, self.abs_y()
+ extra = 1 if vi else 0
+ if self.line_select:
+ lines = [self.text(ay) for ay in range(min(ay0, cy), max(ay0, cy) + 1)]
+ return "\n".join(lines) + ("\n" if vi else "")
+ if self.rect:
+ left, right = min(ax, cx), max(ax, cx) + extra
+ return "\n".join(self.text(ay)[left:right] for ay in range(min(ay0, cy), max(ay0, cy) + 1))
+ (sy, sx), (ey, ex) = sorted(((ay0, ax), (cy, cx)))
+ parts = []
+ for ay in range(sy, ey + 1):
+ line = self.text(ay)
+ first = sx if ay == sy else 0
+ last = ex + extra if ay == ey else len(line)
+ piece = line[first:last]
+ if ay == ey and vi and last > len(line):
+ piece += "\n"
+ parts.append(piece)
+ return "\n".join(parts)
+
+ def deliver(self, text, words, append=False):
+ if not text:
+ return
+ command = words[0] if words else ""
+ self.server.store_copy(text, command, append)
+
+ def c_copy_selection(self, words, client):
+ self.deliver(self.selection_text(), [])
+ self.c_clear_selection(words, client)
+
+ def c_copy_selection_and_cancel(self, words, client):
+ self.deliver(self.selection_text(), [])
+ return "cancel"
+
+ def c_copy_pipe(self, words, client):
+ self.deliver(self.selection_text(), words)
+ self.c_clear_selection(words, client)
+
+ def c_copy_pipe_and_cancel(self, words, client):
+ self.deliver(self.selection_text(), words)
+ return "cancel"
+
+ def c_append_selection(self, words, client):
+ self.deliver(self.selection_text(), [], True)
+ self.c_clear_selection(words, client)
+
+ def c_append_selection_and_cancel(self, words, client):
+ self.deliver(self.selection_text(), [], True)
+ return "cancel"
+
+ def copy_span(self, from_start, words, cancel):
+ """copy-end-of-line / copy-line: select from the cursor (or the line start) to the line end, count lines."""
+ ay = self.abs_y()
+ self.anchor = (0 if from_start else self.cx, ay)
+ self.line_select = False
+ self.rect = False
+ for _i in range(self.count - 1):
+ self.step_down(False)
+ self.cx = self.llen(self.abs_y())
+ self.deliver(self.selection_text(), words)
+ self.c_clear_selection(words, None)
+ return "cancel" if cancel else None
+
+ def c_copy_end_of_line(self, words, client):
+ return self.copy_span(False, [], False)
+
+ def c_copy_end_of_line_and_cancel(self, words, client):
+ return self.copy_span(False, [], True)
+
+ def c_copy_pipe_end_of_line(self, words, client):
+ return self.copy_span(False, words, False)
+
+ def c_copy_pipe_end_of_line_and_cancel(self, words, client):
+ return self.copy_span(False, words, True)
+
+ def c_copy_line(self, words, client):
+ return self.copy_span(True, [], False)
+
+ def c_copy_line_and_cancel(self, words, client):
+ return self.copy_span(True, [], True)
+
+ def c_copy_pipe_line(self, words, client):
+ return self.copy_span(True, words, False)
+
+ def c_copy_pipe_line_and_cancel(self, words, client):
+ return self.copy_span(True, words, True)
+
+ # ---- search ----------------------------------------------------------
+
+ def compile(self, text, literal=False):
+ flags = re.IGNORECASE if text == text.lower() else 0
+ if literal:
+ return re.compile(re.escape(text), flags)
+ try:
+ return re.compile(text, flags)
+ except re.error:
+ return None
+
+ def find(self, pattern, forward, x, ay, wrap):
+ """First match at or after (x, ay) going forward, or before it going backward; returns (start, end, line)."""
+ total = self.total()
+
+ def matches_of(index):
+ return [m for m in pattern.finditer(self.text(index)) if m.end() > m.start()]
+
+ order = range(ay, total) if forward else range(ay, -1, -1)
+ for index in order:
+ found = matches_of(index)
+ if index == ay:
+ found = [m for m in found if (m.start() >= x if forward else m.start() < x)]
+ if found:
+ match = found[0] if forward else found[-1]
+ return match.start(), match.end(), index
+ if wrap:
+ for index in (range(0, ay + 1) if forward else range(total - 1, ay - 1, -1)):
+ found = matches_of(index)
+ if found:
+ match = found[0] if forward else found[-1]
+ return match.start(), match.end(), index
+ return None
+
+ def scroll_to(self, x, ay):
+ """Place the cursor on (x, ay), scrolling the way tmux does when the line is out of view."""
+ hist, rows = self.hist(), self.rows
+ self.cx = max(0, min(self.cols, x))
+ top = hist - self.oy
+ if top <= ay < top + rows:
+ self.cy = ay - top
+ return
+ gap = rows // 4
+ if ay < rows:
+ offset, self.cy = 0, ay
+ elif ay > hist + rows - gap:
+ offset, self.cy = hist, ay - hist
+ else:
+ offset, self.cy = ay + gap - rows, rows - gap
+ self.oy = max(0, min(hist, hist - offset))
+
+ def run_search(self, text, forward, literal=False, origin=None):
+ if not text:
+ return False
+ self.search = (text, forward, literal)
+ if self.static_rows is None:
+ self.pane.last_search = self.search
+ pattern = self.compile(text, literal)
+ if pattern is None:
+ return False
+ wrap = bool(self.server.pane_options(self.pane).get("wrap-search"))
+ vi = self.server.pane_options(self.pane).get("mode-keys") == "vi"
+ x, ay = origin if origin is not None else (self.cx, self.abs_y())
+ if forward and vi and origin is None:
+ x += 1
+ found = self.find(pattern, forward, x, ay, wrap)
+ if found is None:
+ return False
+ start, end, line = found
+ self.scroll_to(end if forward and not vi else start, line)
+ return True
+
+ def search_failed(self, client):
+ """tmux reports a failed search as a status message, not as a command error."""
+ self.server.message(client, "Search failed")
+
+ def search_command(self, words, forward, literal=False, client=None):
+ text = words[0] if words else ""
+ if not text and self.search:
+ text = self.search[0]
+ if not self.run_search(text, forward, literal):
+ self.search_failed(client)
+
+ def c_search_forward(self, words, client):
+ self.search_command(words, True, False, client)
+
+ def c_search_backward(self, words, client):
+ self.search_command(words, False, False, client)
+
+ def c_search_forward_text(self, words, client):
+ self.search_command(words, True, True, client)
+
+ def c_search_backward_text(self, words, client):
+ self.search_command(words, False, True, client)
+
+ def c_search_again(self, words, client):
+ if not self.search:
+ return
+ text, forward, literal = self.search
+ if not self.run_search(text, forward, literal):
+ self.search_failed(client)
+
+ def c_search_reverse(self, words, client):
+ if not self.search:
+ return
+ text, forward, literal = self.search
+ if not self.run_search(text, not forward, literal):
+ self.search_failed(client)
+ self.search = (text, forward, literal)
+
+ def incremental(self, words, forward):
+ arg = words[0] if words else ""
+ if self.inc_origin is None:
+ self.inc_origin = (self.cx, self.abs_y())
+ mark = arg[:1]
+ if mark == "=":
+ origin = (self.inc_origin[0] - 1 if forward else self.inc_origin[0] + 1, self.inc_origin[1])
+ self.run_search(arg[1:], forward, origin=origin)
+ elif mark in ("+", "-"):
+ self.run_search(arg[1:], mark == "+")
+ else:
+ self.inc_origin = None
+ if arg:
+ self.search = (arg, forward, False)
+
+ def c_search_forward_incremental(self, words, client):
+ self.incremental(words, True)
+
+ def c_search_backward_incremental(self, words, client):
+ self.incremental(words, False)
+
+ def search_cancel(self):
+ if self.inc_origin is not None:
+ self.goto(*self.inc_origin)
+ self.inc_origin = None
+ self.server.mark_dirty(self.pane.window)
+
+
+class MplexPrompt:
+ """The command line in the status row: emacs-style editing, history, and the
+ single-key (-1), numeric (-N), key-name (-k) and incremental (-i) variants."""
+
+ def __init__(self, label, text, on_done, history=None, single=False, numeric=False, key_name=False,
+ on_change=None, on_cancel=None, on_step=None, confirm=False):
+ self.label = label
+ self.text = text
+ self.pos = len(text)
+ self.on_done = on_done
+ self.history = history if history is not None else []
+ self.hist_index = None
+ self.single = single
+ self.numeric = numeric
+ self.key_name = key_name
+ self.on_change = on_change
+ self.on_cancel = on_cancel
+ self.on_step = on_step
+ self.confirm = confirm
+
+ def view(self):
+ return self.label + self.text, len(self.label) + self.pos
+
+ def finish(self, server, client, value):
+ client.prompt = None
+ if value and not (self.single or self.numeric or self.key_name or self.confirm):
+ self.history.append(value)
+ del self.history[:-MPLEX_PROMPT_HISTORY_CAP]
+ self.on_done(value)
+
+ def cancel(self, client):
+ client.prompt = None
+ if self.on_cancel:
+ self.on_cancel()
+
+ def changed(self):
+ if self.on_change:
+ self.on_change(self.text)
+
+ @staticmethod
+ def printable(name, raw):
+ if name == "Space":
+ return " "
+ if len(raw) >= 1 and (len(name) == 1 or raw.decode("utf-8", "ignore") == name) and name.isprintable():
+ return name
+ return ""
+
+ def key(self, server, client, name, raw):
+ char = self.printable(name, raw)
+ if self.confirm:
+ if char == "y":
+ self.finish(server, client, "y")
+ else:
+ self.cancel(client)
+ return
+ if self.key_name:
+ self.finish(server, client, name)
+ return
+ if self.single:
+ if char:
+ self.finish(server, client, char)
+ elif name in ("Escape", "C-c", "C-g"):
+ self.cancel(client)
+ return
+ if self.numeric:
+ if char.isdigit():
+ self.text += char
+ self.pos = len(self.text)
+ return
+ self.finish(server, client, self.text)
+ client.redispatch(name, raw)
+ return
+ text, pos = self.text, self.pos
+ if name in ("Enter", "C-j"):
+ self.finish(server, client, text)
+ return
+ if name in ("Escape", "C-c", "C-g"):
+ self.cancel(client)
+ return
+ if name in ("C-a", "Home"):
+ pos = 0
+ elif name in ("C-e", "End"):
+ pos = len(text)
+ elif name in ("C-b", "Left"):
+ pos = max(0, pos - 1)
+ elif name in ("C-f", "Right"):
+ pos = min(len(text), pos + 1)
+ elif name == "M-b":
+ while pos > 0 and text[pos - 1] == " ":
+ pos -= 1
+ while pos > 0 and text[pos - 1] != " ":
+ pos -= 1
+ elif name == "M-f":
+ while pos < len(text) and text[pos] == " ":
+ pos += 1
+ while pos < len(text) and text[pos] != " ":
+ pos += 1
+ elif name in ("BSpace", "C-h"):
+ if pos > 0:
+ text = text[:pos - 1] + text[pos:]
+ pos -= 1
+ elif name in ("DC", "C-d"):
+ text = text[:pos] + text[pos + 1:]
+ elif name == "C-k":
+ text = text[:pos]
+ elif name == "C-u":
+ text = text[pos:]
+ pos = 0
+ elif name == "C-w":
+ start = pos
+ while start > 0 and text[start - 1] == " ":
+ start -= 1
+ while start > 0 and text[start - 1] != " ":
+ start -= 1
+ text = text[:start] + text[pos:]
+ pos = start
+ elif name == "C-y":
+ piece = server.top_buffer_text()
+ text = text[:pos] + piece + text[pos:]
+ pos += len(piece)
+ elif name == "C-t":
+ if 0 < pos < len(text):
+ text = text[:pos - 1] + text[pos] + text[pos - 1] + text[pos + 1:]
+ pos += 1
+ elif name in ("Up", "C-p", "Down", "C-n"):
+ if self.history:
+ if name in ("Up", "C-p"):
+ self.hist_index = len(self.history) - 1 if self.hist_index is None else max(0, self.hist_index - 1)
+ elif self.hist_index is not None:
+ self.hist_index += 1
+ if self.hist_index is not None and self.hist_index >= len(self.history):
+ self.hist_index = None
+ text = ""
+ elif self.hist_index is not None:
+ text = self.history[self.hist_index]
+ pos = len(text)
+ elif name in ("C-s", "C-r") and self.on_step:
+ self.on_step(name, text)
+ return
+ elif char:
+ text = text[:pos] + char + text[pos:]
+ pos += len(char)
+ else:
+ return
+ edited = text != self.text
+ self.text, self.pos = text, pos
+ if edited:
+ self.changed()
+
+
+class MplexFont:
+ GLYPHS = {
+ "0": ("#####", "#...#", "#...#", "#...#", "#####"), "1": ("..#..", "..#..", "..#..", "..#..", "..#.."),
+ "2": ("#####", "....#", "#####", "#....", "#####"), "3": ("#####", "....#", "#####", "....#", "#####"),
+ "4": ("#...#", "#...#", "#####", "....#", "....#"), "5": ("#####", "#....", "#####", "....#", "#####"),
+ "6": ("#####", "#....", "#####", "#...#", "#####"), "7": ("#####", "....#", "....#", "....#", "....#"),
+ "8": ("#####", "#...#", "#####", "#...#", "#####"), "9": ("#####", "#...#", "#####", "....#", "#####"),
+ ":": (".....", "..#..", ".....", "..#..", "....."), "A": ("#####", "#...#", "#####", "#...#", "#...#"),
+ "P": ("#####", "#...#", "#####", "#....", "#...."), "M": ("#...#", "##.##", "#.#.#", "#...#", "#...#"),
+ }
+ HEIGHT = 5
+
+ @classmethod
+ def width(cls, text):
+ return len(text) * 6 - 1 if text else 0
+
+ @classmethod
+ def draw(cls, text, bg):
+ """Five rows of cells for text in big glyphs, lit pixels painted with background bg."""
+ rows = []
+ lit = (" ", None, bg, 0)
+ dark = MplexCanvas.BLANK
+ for line in range(cls.HEIGHT):
+ row = []
+ for index, char in enumerate(text):
+ glyph = cls.GLYPHS.get(char, cls.GLYPHS["0"])
+ row.extend(lit if pixel == "#" else dark for pixel in glyph[line])
+ if index != len(text) - 1:
+ row.append(dark)
+ rows.append(row)
+ return rows
+
+
+class MplexClockMode(MplexMode):
+ name = "clock-mode"
+
+ def render(self, client):
+ opts = self.server.pane_options(self.pane)
+ bg = MplexStyle.color(opts.get("clock-mode-colour"), True)
+ now = time.localtime()
+ text = time.strftime("%H:%M", now) if opts.get("clock-mode-style") == "24" else time.strftime("%I:%M%p", now)
+ grid = [[MplexCanvas.BLANK] * self.pane.w for _row in range(self.pane.h)]
+ if self.pane.h >= MplexFont.HEIGHT and self.pane.w >= MplexFont.width(text):
+ big = MplexFont.draw(text, bg)
+ top = (self.pane.h - MplexFont.HEIGHT) // 2
+ left = (self.pane.w - MplexFont.width(text)) // 2
+ for index, row in enumerate(big):
+ grid[top + index][left:left + len(row)] = row
+ else:
+ top = self.pane.h // 2
+ left = max(0, (self.pane.w - len(text)) // 2)
+ for index, char in enumerate(text[:self.pane.w]):
+ grid[top][left + index] = (char, None, bg, 0)
+ return grid, None
+
+ def key(self, name, raw, client):
+ self.close()
+ return True
+
+
+class MplexPaneNumbers:
+ """display-panes overlay: a big index on every pane until a key or the timer ends it."""
+
+ def __init__(self, server, window, until):
+ self.server = server
+ self.window = window
+ self.until = until
+
+ def key(self, name, raw, client):
+ client.overlay = None
+ if len(name) == 1 and name.isdigit():
+ base = self.server.pane_options(self.window.active).get("pane-base-index")
+ panes = self.window.panes()
+ index = int(name) - base
+ if 0 <= index < len(panes):
+ self.server.select_pane(self.window, panes[index])
+ self.server.mark_dirty(self.window)
+
+ def draw(self, canvas, area_y):
+ opts = self.window.options
+ base = opts.get("pane-base-index")
+ normal = MplexStyle.color(opts.get("display-panes-colour"), True)
+ active = MplexStyle.color(opts.get("display-panes-active-colour"), True)
+ for index, pane in enumerate(self.window.visible_panes() if self.window.zoomed else self.window.panes()):
+ bg = active if pane is self.window.active else normal
+ text = str(index + base)
+ if pane.h >= MplexFont.HEIGHT and pane.w >= MplexFont.width(text):
+ big = MplexFont.draw(text, bg)
+ top = pane.y + (pane.h - MplexFont.HEIGHT) // 2
+ left = pane.x + (pane.w - MplexFont.width(text)) // 2
+ for offset, row in enumerate(big):
+ canvas.put_cells(left, area_y + top + offset, row)
+ else:
+ canvas.put(pane.x + max(0, (pane.w - len(text)) // 2), area_y + pane.y + pane.h // 2, text, (None, bg, 0))
+ size = "%dx%d" % (pane.w, pane.h)
+ if pane.w > len(size) + 1:
+ canvas.put(pane.x + pane.w - len(size), area_y + pane.y, size, (None, bg, 0))
+
+
+class MplexChoice:
+ __slots__ = ("label", "payload", "children", "expanded", "depth", "tag")
+
+ def __init__(self, label, payload=None, children=None, expanded=False):
+ self.label = label
+ self.payload = payload
+ self.children = children or []
+ self.expanded = expanded
+ self.depth = 0
+ self.tag = False
+
+
+class MplexChooser(MplexMode):
+ """choose-tree / choose-buffer / choose-client: a list you move through, with an
+ optional live preview of the selected item below it."""
+
+ name = "tree-mode"
+ HOT_KEYS = "0123456789"
+
+ def __init__(self, server, pane, kind, builder, on_choose, on_delete=None, preview=None, selected=None, on_close=None):
+ super().__init__(server, pane)
+ self.name = kind
+ self.builder = builder
+ self.items = builder()
+ self.on_close = on_close
+ self.on_choose = on_choose
+ self.on_delete = on_delete
+ self.preview = preview
+ self.show_preview = preview is not None
+ self.cursor = 0
+ self.visible = []
+ self.reflatten()
+ if selected is not None:
+ for index, item in enumerate(self.visible):
+ if item.payload == selected:
+ self.cursor = index
+
+ def reflatten(self):
+ self.visible = []
+
+ def walk(nodes, depth):
+ for node in nodes:
+ node.depth = depth
+ self.visible.append(node)
+ if node.children and node.expanded:
+ walk(node.children, depth + 1)
+
+ walk(self.items, 0)
+ self.cursor = max(0, min(len(self.visible) - 1, self.cursor))
+
+ def selected(self):
+ return self.visible[self.cursor] if self.visible else None
+
+ def rebuild(self):
+ opened = set()
+
+ def collect(nodes):
+ for node in nodes:
+ if node.expanded:
+ opened.add(repr(node.payload))
+ collect(node.children)
+
+ def restore(nodes):
+ for node in nodes:
+ node.expanded = node.expanded or repr(node.payload) in opened
+ restore(node.children)
+
+ collect(self.items)
+ self.items = self.builder()
+ restore(self.items)
+ self.reflatten()
+ if not self.visible:
+ self.close()
+
+ def close(self):
+ super().close()
+ if self.on_close is not None:
+ callback, self.on_close = self.on_close, None
+ callback()
+
+ def list_height(self):
+ if self.show_preview and self.pane.h >= 8:
+ return max(3, self.pane.h // 2)
+ return self.pane.h
+
+ def render(self, client):
+ w, h = self.pane.w, self.pane.h
+ opts = self.server.pane_options(self.pane)
+ mode_style = MplexStyle.parse(opts.get("mode-style"))
+ grid = [[MplexCanvas.BLANK] * w for _row in range(h)]
+ list_h = self.list_height()
+ top = max(0, min(self.cursor - list_h + 1, len(self.visible) - list_h)) if self.cursor >= list_h else 0
+ for row in range(list_h):
+ index = top + row
+ if index >= len(self.visible):
+ break
+ node = self.visible[index]
+ hot = self.HOT_KEYS[index] if index < len(self.HOT_KEYS) else " "
+ marker = ("- " if node.expanded else "+ ") if node.children else " "
+ line = ("(%s) %s%s%s%s" % (hot, " " * node.depth, marker, "* " if node.tag else "", node.label))[:w].ljust(w)
+ style = mode_style if index == self.cursor else (None, None, 0)
+ grid[row] = [(char, style[0], style[1], style[2]) for char in line]
+ if self.show_preview and list_h < h:
+ border = MplexCanvas.GLYPHS["single"][0]
+ grid[list_h] = [(border, None, None, 0)] * w
+ chosen = self.selected()
+ if chosen is not None:
+ block = self.preview(chosen, w, h - list_h - 1) or []
+ for offset, cells in enumerate(block[:h - list_h - 1]):
+ grid[list_h + 1 + offset] = (list(cells) + [MplexCanvas.BLANK] * w)[:w]
+ return grid, None
+
+ def move(self, delta):
+ self.cursor = max(0, min(len(self.visible) - 1, self.cursor + delta))
+
+ def key(self, name, raw, client):
+ node = self.selected()
+ page = max(1, self.list_height() - 1)
+ if name in ("q", "Escape", "C-c"):
+ self.close()
+ elif name in ("Up", "k", "C-p"):
+ self.move(-1)
+ elif name in ("Down", "j", "C-n"):
+ self.move(1)
+ elif name in ("PPage", "C-b"):
+ self.move(-page)
+ elif name in ("NPage", "C-f"):
+ self.move(page)
+ elif name in ("Home", "g"):
+ self.cursor = 0
+ elif name in ("End", "G"):
+ self.cursor = max(0, len(self.visible) - 1)
+ elif name in ("Right", "l", "+") and node is not None and node.children:
+ node.expanded = True
+ self.reflatten()
+ elif name in ("Left", "h", "-") and node is not None:
+ if node.children and node.expanded:
+ node.expanded = False
+ else:
+ for index in range(self.cursor - 1, -1, -1):
+ if self.visible[index].depth < node.depth:
+ self.cursor = index
+ break
+ self.reflatten()
+ elif name == "t" and node is not None:
+ node.tag = not node.tag
+ elif name == "v" and self.preview is not None:
+ self.show_preview = not self.show_preview
+ elif name == "x" and node is not None and self.on_delete is not None:
+ self.server.ask_confirm(client, "Kill %s? (y/n)" % node.label.split(":")[0], lambda: self.delete(node, client))
+ elif name == "Enter" and node is not None:
+ self.close()
+ self.on_choose(node, client)
+ elif len(name) == 1 and name in self.HOT_KEYS:
+ index = self.HOT_KEYS.index(name)
+ if index < len(self.visible):
+ self.cursor = index
+ self.close()
+ self.on_choose(self.visible[index], client)
+ self.server.mark_dirty(self.pane.window)
+ return True
+
+ def delete(self, node, client):
+ self.on_delete(node, client)
+ self.server.refresh_choosers()
+
+
+class MplexRenderer:
+ """Composes one client's whole screen: panes, borders, status row, prompt, overlays."""
+
+ def __init__(self, server):
+ self.server = server
+
+ def block_of(self, pane, client):
+ mode = pane.top_mode()
+ if mode is not None:
+ return mode.render(client)
+ grid = pane.screen.grid
+ blank = MplexCanvas.BLANK
+ block = [[cell if cell is not None else blank for cell in row] for row in grid]
+ cursor = (pane.screen.cx, pane.screen.cy) if pane.screen.cursor_visible else None
+ return block, cursor
+
+ def frame(self, client):
+ server = self.server
+ canvas = MplexCanvas(client.rows, client.cols)
+ session = client.session
+ window = session.window()
+ opts = window.options
+ status_on = bool(opts.get("status"))
+ on_top = opts.get("status-position") == "top"
+ area_y = 1 if (status_on and on_top) else 0
+ cursor = None
+ active = window.active
+ for pane in window.visible_panes():
+ block, pane_cursor = self.block_of(pane, client)
+ canvas.blit(pane.x, area_y + pane.y, block, pane.w)
+ if pane is active and pane_cursor is not None:
+ cursor = (pane.x + pane_cursor[0], area_y + pane.y + pane_cursor[1])
+ if window.zoomed is None and len(window.panes()) > 1:
+ self.borders(canvas, window, area_y)
+ if isinstance(client.overlay, MplexPaneNumbers):
+ client.overlay.draw(canvas, area_y)
+ cursor = None
+ status_row = 0 if on_top else client.rows - 1
+ if status_on:
+ self.status(canvas, client, session, window, status_row)
+ line_row = status_row if status_on else client.rows - 1
+ if client.prompt is not None:
+ text, pos = client.prompt.view()
+ style = MplexStyle.parse(opts.get("message-style"))
+ canvas.fill(0, line_row, client.cols, 1, style)
+ canvas.put(0, line_row, text, style, client.cols)
+ cursor = (min(client.cols - 1, pos), line_row)
+ elif client.message is not None and time.monotonic() < client.message[1]:
+ style = MplexStyle.parse(opts.get("message-style"))
+ canvas.fill(0, line_row, client.cols, 1, style)
+ canvas.put(0, line_row, client.message[0].replace("\n", " "), style, client.cols)
+ canvas.cursor = cursor
+ return canvas
+
+ def borders(self, canvas, window, area_y):
+ W, H = window.width, window.height
+ panes = window.panes()
+ cells = set()
+ for pane in panes:
+ if pane.x + pane.w < W:
+ for row in range(pane.y, min(H, pane.y + pane.h + 1)):
+ cells.add((pane.x + pane.w, row))
+ if pane.y + pane.h < H:
+ for col in range(pane.x, min(W, pane.x + pane.w + 1)):
+ cells.add((col, pane.y + pane.h))
+ active = window.active
+ hot = set()
+ if active is not None:
+ for col in range(active.x - 1, active.x + active.w + 1):
+ hot.add((col, active.y - 1))
+ hot.add((col, active.y + active.h))
+ for row in range(active.y - 1, active.y + active.h + 1):
+ hot.add((active.x - 1, row))
+ hot.add((active.x + active.w, row))
+ hot &= cells
+ opts = window.options
+ lines = opts.get("pane-border-lines")
+ plain = MplexStyle.parse(opts.get("pane-border-style"))
+ hot_text = MplexFormat.expand(opts.get("pane-active-border-style"), self.server.resolver_for(window.session, window, active))
+ hot_style = MplexStyle.parse(hot_text, plain)
+ for (x, y) in cells:
+ style = hot_style if (x, y) in hot else plain
+ glyph = MplexCanvas.border_char((x, y - 1) in cells, (x, y + 1) in cells, (x - 1, y) in cells, (x + 1, y) in cells, lines)
+ canvas.put(x, area_y + y, glyph, style)
+
+ def status(self, canvas, client, session, window, row):
+ server = self.server
+ opts = window.options
+ base = MplexStyle.parse(opts.get("status-style"))
+ canvas.fill(0, row, client.cols, 1, base)
+ resolver = server.resolver_for(session, window, window.active, client)
+ left = MplexStatusText.cells(MplexFormat.with_time(MplexFormat.expand(opts.get("status-left"), resolver)),
+ MplexStyle.parse(opts.get("status-left-style"), base), base)
+ right = MplexStatusText.cells(MplexFormat.with_time(MplexFormat.expand(opts.get("status-right"), resolver)),
+ MplexStyle.parse(opts.get("status-right-style"), base), base)
+ left = left[:opts.get("status-left-length")]
+ right = right[:opts.get("status-right-length")]
+ if len(left) > client.cols:
+ left = left[:client.cols]
+ room_right = max(0, client.cols - len(left))
+ right = right[-room_right:] if len(right) > room_right and room_right else right[:room_right]
+ avail = client.cols - len(left) - len(right)
+ entries = []
+ current_at = 0
+ total = 0
+ last_index = session.last
+ separator = opts.get("window-status-separator")
+ for index in session.indexes():
+ item = session.windows[index]
+ wres = server.resolver_for(session, item, item.active, client)
+ is_current = index == session.current
+ fmt = item.options.get("window-status-current-format" if is_current else "window-status-format")
+ style_name = "window-status-current-style" if is_current else ("window-status-last-style" if index == last_index else "window-status-style")
+ style = MplexStyle.parse(item.options.get(style_name), base)
+ cells = MplexStatusText.cells(MplexFormat.with_time(MplexFormat.expand(fmt, wres)), style, style)
+ if entries:
+ cells = [(char, base[0], base[1], base[2]) for char in separator] + cells
+ if is_current:
+ current_at = total + (len(separator) if entries else 0)
+ entries.append(cells)
+ total += len(cells)
+ listing = [cell for cells in entries for cell in cells]
+ if len(listing) > avail:
+ listing = self.fit_list(listing, avail, current_at, base)
+ justify = opts.get("status-justify")
+ offset = 0
+ if justify in ("centre", "center"):
+ offset = max(0, (avail - len(listing)) // 2)
+ elif justify == "right":
+ offset = max(0, avail - len(listing))
+ canvas.put_cells(0, row, left)
+ canvas.put_cells(len(left) + offset, row, listing)
+ canvas.put_cells(client.cols - len(right), row, right)
+
+ @staticmethod
+ def fit_list(cells, avail, current_at, base):
+ """The slice of the window list around the current window, with < and > markers."""
+ if avail <= 0:
+ return []
+ start = max(0, min(current_at - avail // 3, len(cells) - avail))
+ piece = cells[start:start + avail]
+ marker = lambda char: (char, base[0], base[1], base[2] | 1)
+ if start > 0 and piece:
+ piece[0] = marker("<")
+ if start + avail < len(cells) and piece:
+ piece[-1] = marker(">")
+ return piece
+
+
+class MplexCtx:
+ """What a command runs against: the issuing client and its current session, window, pane."""
+
+ def __init__(self, server, client=None, pane=None, interactive=False):
+ self.server = server
+ self.client = client
+ self.interactive = interactive
+ self.depth = 0
+ self.pane = pane
+ self.window = pane.window if pane is not None else None
+ self.session = self.window.session if self.window is not None else None
+ if client is not None and client.session is not None and pane is None:
+ self.session = client.session
+ self.window = self.session.window()
+ self.pane = self.window.active if self.window is not None else None
+ elif self.session is None and client is not None and client.env_pane is not None:
+ found = server.panes.get(client.env_pane)
+ if found is not None and found.window is not None:
+ self.pane = found
+ self.window = found.window
+ self.session = found.window.session
+ if self.session is None:
+ self.session = server.latest_session()
+ if self.session is not None:
+ self.window = self.session.window()
+ self.pane = self.window.active if self.window is not None else None
+
+
+class MplexTargets:
+ """tmux target syntax: session, session:window, session:window.pane, %pane, @window, $session."""
+
+ @staticmethod
+ def session(ctx, spec):
+ server = ctx.server
+ if not spec:
+ if ctx.session is None:
+ raise MplexError("no current session")
+ return ctx.session
+ found = None
+ if spec.startswith("$") and spec[1:].isdigit():
+ found = next((s for s in server.sessions.values() if s.id == int(spec[1:])), None)
+ else:
+ spec = spec.lstrip("=")
+ found = next((s for s in server.sessions.values() if s.name == spec), None)
+ if found is None:
+ matches = [s for s in server.sessions.values() if s.name.startswith(spec)]
+ if len(matches) == 1:
+ found = matches[0]
+ if found is None:
+ import fnmatch
+ matches = [s for s in server.sessions.values() if fnmatch.fnmatchcase(s.name, spec)]
+ if len(matches) == 1:
+ found = matches[0]
+ if found is None:
+ raise MplexError("can't find session: %s" % spec)
+ return found
+
+ @classmethod
+ def window(cls, ctx, spec):
+ """Returns (session, index, window)."""
+ server = ctx.server
+ spec = spec or ""
+ if spec.startswith("@") and spec[1:].isdigit():
+ window = server.windows.get(int(spec[1:]))
+ if window is None:
+ raise MplexError("can't find window: %s" % spec)
+ return window.session, window.session.index_of(window), window
+ if ":" in spec:
+ sess_part, _sep, win_part = spec.partition(":")
+ session = cls.session(ctx, sess_part)
+ else:
+ sess_part, win_part, session = "", spec, ctx.session
+ if session is None:
+ raise MplexError("no current session")
+ index = cls.window_index(session, win_part)
+ if index is None and ":" not in spec and win_part:
+ try:
+ session = cls.session(ctx, win_part)
+ index = session.current
+ except MplexError:
+ index = None
+ if index is None or index not in session.windows:
+ raise MplexError("can't find window: %s" % (win_part or spec))
+ return session, index, session.windows[index]
+
+ @staticmethod
+ def window_index(session, text):
+ indexes = session.indexes()
+ if not indexes:
+ return None
+ text = text.lstrip("=") if not text.startswith("=") or text[1:].isdigit() else text
+ if text == "":
+ return session.current
+ if text in ("!", "{last}"):
+ return session.last
+ if text in ("^", "{start}"):
+ return indexes[0]
+ if text in ("$", "{end}"):
+ return indexes[-1]
+ if text == "{next}":
+ text = "+"
+ if text == "{previous}":
+ text = "-"
+ if text[0] in "+-":
+ step = int(text[1:]) if text[1:].isdigit() else 1
+ here = indexes.index(session.current) if session.current in indexes else 0
+ return indexes[(here + (step if text[0] == "+" else -step)) % len(indexes)]
+ if text.isdigit():
+ return int(text)
+ for index in indexes:
+ if session.windows[index].name == text:
+ return index
+ matches = [index for index in indexes if session.windows[index].name.startswith(text)]
+ if len(matches) == 1:
+ return matches[0]
+ import fnmatch
+ matches = [index for index in indexes if fnmatch.fnmatchcase(session.windows[index].name, text)]
+ return matches[0] if len(matches) == 1 else None
+
+ @classmethod
+ def pane(cls, ctx, spec):
+ """Returns (session, window, pane)."""
+ server = ctx.server
+ spec = spec or ""
+ if spec.startswith("%") and spec[1:].isdigit():
+ pane = server.panes.get(int(spec[1:]))
+ if pane is None or pane.window is None:
+ raise MplexError("can't find pane: %s" % spec)
+ return pane.window.session, pane.window, pane
+ if ":" in spec:
+ sess_part, _sep, rest = spec.partition(":")
+ if "." in rest:
+ win_part, _dot, pane_part = rest.rpartition(".")
+ else:
+ win_part, pane_part = rest, ""
+ ctx_for_window = sess_part + ":" + win_part
+ session, _index, window = cls.window(ctx, ctx_for_window)
+ elif "." in spec:
+ win_part, _dot, pane_part = spec.rpartition(".")
+ session, _index, window = cls.window(ctx, win_part) if win_part else (ctx.session, None, ctx.window)
+ else:
+ pane_part = spec
+ session, window = ctx.session, ctx.window
+ if pane_part and not (pane_part.isdigit() or pane_part[0] in "+-!{"):
+ try:
+ session, _index, window = cls.window(ctx, pane_part)
+ pane_part = ""
+ except MplexError:
+ raise MplexError("can't find pane: %s" % spec)
+ if window is None:
+ raise MplexError("no current window")
+ panes = window.panes()
+ if not pane_part:
+ return session, window, window.active
+ base = window.options.get("pane-base-index")
+ if pane_part.isdigit():
+ index = int(pane_part) - base
+ if 0 <= index < len(panes):
+ return session, window, panes[index]
+ raise MplexError("can't find pane: %s" % pane_part)
+ here = panes.index(window.active) if window.active in panes else 0
+ if pane_part in ("!", "{last}"):
+ if window.last_pane in panes:
+ return session, window, window.last_pane
+ raise MplexError("can't find pane: %s" % pane_part)
+ if pane_part[0] in "+-" or pane_part in ("{next}", "{previous}"):
+ sign = "-" if pane_part in ("-", "{previous}") or pane_part.startswith("-") else "+"
+ amount = int(pane_part[1:]) if pane_part[1:].isdigit() else 1
+ return session, window, panes[(here + (amount if sign == "+" else -amount)) % len(panes)]
+ edges = {"{top}": lambda p: p.y == 0, "{bottom}": lambda p: p.y + p.h >= window.height,
+ "{left}": lambda p: p.x == 0, "{right}": lambda p: p.x + p.w >= window.width,
+ "{top-left}": lambda p: p.y == 0 and p.x == 0, "{top-right}": lambda p: p.y == 0 and p.x + p.w >= window.width,
+ "{bottom-left}": lambda p: p.y + p.h >= window.height and p.x == 0,
+ "{bottom-right}": lambda p: p.y + p.h >= window.height and p.x + p.w >= window.width}
+ if pane_part in edges:
+ for pane in panes:
+ if edges[pane_part](pane):
+ return session, window, pane
+ raise MplexError("can't find pane: %s" % pane_part)
+
+
+class MplexClientConn:
+ """Server-side state of one connection: framing, key table position, prompt, overlay, last frame."""
+
+ def __init__(self, server, sock, ident):
+ self.server = server
+ self.sock = sock
+ self.id = ident
+ self.inbuf = bytearray()
+ self.out = bytearray()
+ self.rows = 24
+ self.cols = 80
+ self.session = None
+ self.attached = False
+ self.table = "root"
+ self.repeating = False
+ self.repeat_until = 0.0
+ self.keybuf = b""
+ self.esc_deadline = None
+ self.prompt = None
+ self.message = None
+ self.overlay = None
+ self.prev = None
+ self.cursor = None
+ self.dirty = True
+ self.last_render = 0.0
+ self.pid = 0
+ self.tty = ""
+ self.term = ""
+ self.cwd = os.getcwd()
+ self.env = {}
+ self.env_pane = None
+ self.closing = False
+ self.created = time.time()
+
+ def send(self, kind, payload=b""):
+ if len(self.out) > MPLEX_CLIENT_OUT_MAX:
+ self.prev = None
+ return False
+ self.out += kind + struct.pack(">I", len(payload)) + payload
+ return True
+
+ def frames(self, data):
+ """Complete (kind, payload) frames in what arrived; None when the stream is corrupt."""
+ self.inbuf += data
+ found = []
+ while len(self.inbuf) >= 5:
+ length = struct.unpack(">I", bytes(self.inbuf[1:5]))[0]
+ if length > MPLEX_FRAME_MAX:
+ return None
+ if len(self.inbuf) < 5 + length:
+ break
+ found.append((bytes(self.inbuf[0:1]), bytes(self.inbuf[5:5 + length])))
+ del self.inbuf[:5 + length]
+ return found
+
+ def redispatch(self, name, raw):
+ self.server.handle_key(self, name, raw)
+
+ def flush(self):
+ if not self.out:
+ return
+ try:
+ sent = self.sock.send(bytes(self.out[:65536]))
+ del self.out[:sent]
+ except (BlockingIOError, InterruptedError):
+ pass
+ except OSError:
+ self.out.clear()
+ self.closing = True
+
+ def close(self):
+ try:
+ self.sock.close()
+ except OSError:
+ pass
+
+
+class MplexServer:
+ """Owns every session, window, pane and client. One select loop, no threads."""
+
+ def __init__(self, home):
+ self.home = home
+ self.dir = plexer_dir(home)
+ self.sock_path = os.path.join(self.dir, MPLEX_SOCKET_NAME)
+ self.config = MplexConfig.load(MplexConfig.default_path(home))
+ self.options = self.config.options
+ self.sessions = {}
+ self.windows = {}
+ self.panes = {}
+ self.clients = []
+ self.conns = []
+ self.buffers = []
+ self.messages = collections.deque(maxlen=MPLEX_MESSAGES_MAX)
+ self.prompt_history = {}
+ self.timers = []
+ self.reaping = []
+ self.marked = None
+ self.ids = {"session": 0, "window": 0, "pane": 0, "client": 0, "buffer": 0}
+ self.had_session = False
+ self.running = False
+ self.listener = None
+ self.renderer = MplexRenderer(self)
+ self.commands = MplexCommands(self)
+ self.shell_cache = {}
+ self.started = time.monotonic()
+ self.stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S")
+ self.next_tick = 0.0
+ self.notice = None
+ for problem in self.config.errors:
+ self.log_message("mplex.toml: " + problem)
+ if self.config.skipped:
+ self.notice = "mplex.toml: %d problem(s) skipped, see show-messages" % self.config.skipped
+
+ # ---- bookkeeping -----------------------------------------------------
+
+ def next_id(self, kind):
+ value = self.ids[kind]
+ self.ids[kind] += 1
+ return value
+
+ def log_message(self, text):
+ self.messages.append((time.time(), text))
+
+ def latest_session(self):
+ if not self.sessions:
+ return None
+ return max(self.sessions.values(), key=lambda item: (item.last_attached, item.id))
+
+ def attached_clients(self, session=None):
+ return [c for c in self.clients if c.attached and (session is None or c.session is session)]
+
+ def pane_options(self, pane):
+ return pane.window.options if pane.window is not None else self.options
+
+ def message(self, client, text, milliseconds=None):
+ self.log_message(text)
+ if client is None:
+ return
+ if milliseconds is None:
+ milliseconds = self.options.get("display-time") if client.session is None else client.session.options.get("display-time")
+ client.message = (text, time.monotonic() + milliseconds / 1000.0)
+ client.dirty = True
+ self.add_timer(client.message[1] + 0.01, lambda: setattr(client, "dirty", True))
+
+ def mark_dirty(self, window=None):
+ for client in self.clients:
+ if client.attached and (window is None or client.session.window() is window):
+ client.dirty = True
+
+ def mark_all_dirty(self):
+ for client in self.clients:
+ client.dirty = True
+
+ def add_timer(self, deadline, callback):
+ if len(self.timers) < 1024:
+ self.timers.append((deadline, callback))
+
+ # ---- buffers and copy ------------------------------------------------
+
+ def add_buffer(self, text, name=None):
+ if name is None:
+ name = "buffer%d" % self.next_id("buffer")
+ self.buffers = [item for item in self.buffers if item["name"] != name]
+ self.buffers.insert(0, {"name": name, "data": text[:MPLEX_PASTE_MAX]})
+ del self.buffers[self.options.get("buffer-limit"):]
+ return name
+
+ def top_buffer_text(self):
+ return self.buffers[0]["data"] if self.buffers else ""
+
+ def find_buffer(self, name):
+ if not name:
+ if not self.buffers:
+ raise MplexError("no buffers")
+ return self.buffers[0]
+ for item in self.buffers:
+ if item["name"] == name:
+ return item
+ raise MplexError("unknown buffer: %s" % name)
+
+ def run_shell_input(self, command, text):
+ try:
+ done = subprocess.run(command, shell=True, input=text.encode("utf-8", "replace"), stdout=subprocess.DEVNULL,
+ stderr=subprocess.PIPE, timeout=MPLEX_SHELL_TIMEOUT)
+ if done.returncode != 0:
+ raise MplexError("copy command failed: " + done.stderr.decode("utf-8", "replace").strip()[:200])
+ except (OSError, subprocess.SubprocessError) as exc:
+ raise MplexError("copy command failed: " + short_error(exc))
+
+ def store_copy(self, text, command="", append=False):
+ if append and self.buffers:
+ self.buffers[0]["data"] = (self.buffers[0]["data"] + text)[:MPLEX_PASTE_MAX]
+ text = self.buffers[0]["data"]
+ else:
+ self.add_buffer(text)
+ pipe = command or self.options.get("copy-command")
+ if pipe:
+ self.run_shell_input(pipe, text)
+ if self.options.get("set-clipboard") != "off":
+ sequence = "\x1b]52;c;" + base64.b64encode(text.encode("utf-8")).decode("ascii") + "\x07"
+ for client in self.attached_clients():
+ client.send(b"O", sequence.encode("ascii"))
+
+ # ---- sizing ----------------------------------------------------------
+
+ def resize_session(self, session):
+ sizes = [(c.cols, c.rows) for c in self.attached_clients(session)]
+ if sizes:
+ session.size = (min(item[0] for item in sizes), min(item[1] for item in sizes))
+ elif not hasattr(session, "size"):
+ session.size = (80, 24)
+ cols, rows = session.size
+ for window in session.windows.values():
+ window.width = max(1, cols)
+ window.height = max(1, rows - (1 if window.options.get("status") else 0))
+ window.relayout()
+ self.mark_dirty()
+
+ def resize_all(self):
+ for session in self.sessions.values():
+ self.resize_session(session)
+
+ # ---- model: panes, windows, sessions ---------------------------------
+
+ def default_shell(self, options):
+ return options.get("default-shell") or os.environ.get("SHELL") or "/bin/sh"
+
+ def resolve_term(self, name):
+ wanted = [name, "screen-256color", "xterm-256color", "xterm", "vt100"]
+ roots = [os.environ.get("TERMINFO", ""), os.path.expanduser("~/.terminfo"), "/etc/terminfo", "/lib/terminfo", "/usr/share/terminfo", "/usr/lib/terminfo"]
+ for term in wanted:
+ for root in roots:
+ if root and (os.path.exists(os.path.join(root, term[0], term)) or os.path.exists(os.path.join(root, "%02x" % ord(term[0]), term))):
+ return term
+ return "xterm"
+
+ def spawn_pane(self, window, width, height, words, cwd, env_extra=None):
+ if len(self.panes) >= MPLEX_MAX_PANES:
+ raise MplexError("too many panes (limit %d)" % MPLEX_MAX_PANES)
+ session = window.session
+ options = window.options
+ pane = MplexPane(self.next_id("pane"), width, height, options.get("history-limit"), self.dir, self.stamp)
+ shell = self.default_shell(options)
+ if words:
+ file = shell if len(words) == 1 else words[0]
+ argv = [shell, "-c", words[0]] if len(words) == 1 else list(words)
+ elif options.get("default-command"):
+ file, argv = shell, [shell, "-c", options.get("default-command")]
+ else:
+ file, argv = shell, ["-" + os.path.basename(shell)]
+ env = dict(session.env)
+ env.pop("LINES", None)
+ env.pop("COLUMNS", None)
+ env["TERM"] = self.resolve_term(options.get("default-terminal"))
+ env[PLEXER_ACTIVE_ENV] = "1"
+ env[PLEXER_SOCKET_ENV] = self.sock_path
+ env[PLEXER_PANE_ENV] = str(pane.id)
+ env.update(env_extra or {})
+ pane.window = window
+ pane.spawn(file, argv, cwd or session.cwd, env)
+ self.panes[pane.id] = pane
+ return pane
+
+ def register_window(self, session, window, index, select):
+ window.session = session
+ window.options.parent = session.options
+ session.windows[index] = window
+ self.windows[window.id] = window
+ if select or session.current is None:
+ session.select(index)
+
+ def create_window(self, session, name, cwd, words, env_extra, index, select):
+ if len(self.windows) >= MPLEX_MAX_WINDOWS:
+ raise MplexError("too many windows (limit %d)" % MPLEX_MAX_WINDOWS)
+ cols, rows = getattr(session, "size", (80, 24))
+ window = MplexWindow(self.next_id("window"), name or "", max(1, cols), max(1, rows - (1 if self.options.get("status") else 0)), session.options)
+ window.session = session
+ pane = self.spawn_pane(window, window.width, window.height, words, cwd, env_extra)
+ window.root = MplexLayoutNode("pane", pane=pane, w=window.width, h=window.height)
+ window.active = pane
+ pane.last_active = time.monotonic()
+ window.auto_rename = not name
+ if not name:
+ window.name = pane.shell_name if not words else os.path.basename(words[0].split()[0] if len(words) == 1 else words[0])
+ self.register_window(session, window, index, select)
+ window.relayout()
+ self.mark_dirty()
+ return window
+
+ def create_session(self, name, cwd, env, words, window_name, size, index_hint=None):
+ if len(self.sessions) >= MPLEX_MAX_SESSIONS:
+ raise MplexError("too many sessions (limit %d)" % MPLEX_MAX_SESSIONS)
+ session = MplexSession(self.next_id("session"), name, self.options, cwd, env)
+ session.size = size
+ self.sessions[session.id] = session
+ self.had_session = True
+ try:
+ self.create_window(session, window_name, cwd, words, None, self.options.get("base-index"), True)
+ except Exception:
+ self.sessions.pop(session.id, None)
+ raise
+ return session
+
+ def auto_session_name(self):
+ taken = {s.name for s in self.sessions.values()}
+ number = 0
+ while str(number) in taken:
+ number += 1
+ return str(number)
+
+ def split_pane(self, window, target, kind, before, words, cwd, env_extra, size, select):
+ leaf = window.leaf_of(target)
+ if leaf is None:
+ raise MplexError("pane is not in this window")
+ if window.zoomed is not None:
+ window.zoomed = None
+ window.relayout()
+ leaf = window.leaf_of(target)
+ span = leaf.w if kind == "lr" else leaf.h
+ if span < 3:
+ raise MplexError("no space for new pane")
+ pane = self.spawn_pane(window, 1, 1, words, cwd, env_extra)
+ try:
+ window.root, fresh = MplexLayout.split(window.root, leaf, kind, before, pane, size)
+ except MplexError:
+ self.discard_pane(pane)
+ raise
+ window.layout_index = -1
+ window.relayout()
+ if select:
+ window.set_active(pane)
+ self.mark_dirty(window)
+ return pane
+
+ def discard_pane(self, pane):
+ pane.close()
+ self.panes.pop(pane.id, None)
+ if pane.pid is not None:
+ self.reaping.append((pane.pid, time.monotonic()))
+ pane.pid = None
+
+ def select_pane(self, window, pane):
+ if window.zoomed is not None and pane is not window.zoomed:
+ window.zoomed = None
+ window.relayout()
+ window.set_active(pane)
+ self.mark_dirty(window)
+
+ def kill_pane(self, pane):
+ window = pane.window
+ if window is None:
+ return
+ if len(window.panes()) == 1:
+ self.kill_window(window)
+ return
+ was_active = window.active is pane
+ window.root = MplexLayout.remove(window.root, window.leaf_of(pane))
+ if window.zoomed is pane:
+ window.zoomed = None
+ if self.marked is pane:
+ self.marked = None
+ self.discard_pane(pane)
+ rest = window.panes()
+ if window.last_pane is pane:
+ window.last_pane = None
+ if was_active:
+ window.active = window.last_pane if window.last_pane in rest else max(rest, key=lambda item: item.last_active)
+ window.last_pane = None
+ window.layout_index = -1
+ window.relayout()
+ self.mark_dirty(window)
+
+ def kill_window(self, window):
+ session = window.session
+ index = session.index_of(window)
+ for pane in window.panes():
+ if self.marked is pane:
+ self.marked = None
+ self.discard_pane(pane)
+ self.windows.pop(window.id, None)
+ if index is not None:
+ session.windows.pop(index, None)
+ if not session.windows:
+ self.kill_session(session)
+ return
+ session.history = [item for item in session.history if item is not window]
+ if session.current == index:
+ nxt = session.last
+ if nxt is None:
+ lower = [i for i in session.indexes() if i < index]
+ nxt = lower[-1] if lower else session.indexes()[-1]
+ session.current = nxt
+ session.history = [item for item in session.history if item is not session.windows[nxt]]
+ if session.options.get("renumber-windows"):
+ self.renumber(session)
+ self.mark_dirty()
+
+ def renumber(self, session):
+ base = self.options.get("base-index")
+ current = session.window()
+ ordered = [session.windows[i] for i in session.indexes()]
+ session.windows = {base + n: window for n, window in enumerate(ordered)}
+ session.current = session.index_of(current) if current else None
+
+ def kill_session(self, session):
+ for window in list(session.windows.values()):
+ for pane in window.panes():
+ if self.marked is pane:
+ self.marked = None
+ self.discard_pane(pane)
+ self.windows.pop(window.id, None)
+ session.windows.clear()
+ self.sessions.pop(session.id, None)
+ for client in list(self.attached_clients(session)):
+ others = [s for s in self.sessions.values()]
+ if others and not self.options.get("detach-on-destroy"):
+ self.attach_client(client, self.latest_session())
+ else:
+ self.detach_client(client, "[exited]", b"X")
+ self.mark_dirty()
+
+ def attach_client(self, client, session):
+ old = client.session
+ client.session = session
+ session.last_attached = time.time()
+ client.table = "root"
+ client.prompt = None
+ client.overlay = None
+ client.prev = None
+ if not client.attached:
+ client.attached = True
+ client.send(b"A")
+ if old is not None and old is not session:
+ self.resize_session(old)
+ self.resize_session(session)
+ client.dirty = True
+ if self.notice:
+ self.message(client, self.notice, 4000)
+ self.notice = None
+
+ def detach_client(self, client, text="", kind=b"D"):
+ old = client.session
+ client.send(kind, text.encode("utf-8"))
+ client.attached = False
+ client.session = None
+ client.closing = True
+ if old is not None and old.id in self.sessions:
+ self.resize_session(old)
+
+ # ---- formats ---------------------------------------------------------
+
+ def window_flags(self, session, window):
+ flags = ""
+ index = session.index_of(window)
+ if index == session.current:
+ flags += "*"
+ elif index == session.last:
+ flags += "-"
+ if window.zoomed is not None:
+ flags += "Z"
+ if self.marked is not None and self.marked.window is window:
+ flags += "M"
+ return flags
+
+ def resolver_for(self, session, window, pane, client=None):
+ server = self
+
+ def get(name):
+ if name.startswith("("):
+ return server.shell_output(name[1:])
+ return server.variable(name, session, window, pane, client)
+
+ return get
+
+ def shell_output(self, command):
+ now = time.monotonic()
+ cached = self.shell_cache.get(command)
+ interval = max(1, self.options.get("status-interval"))
+ if cached and now - cached[0] < interval:
+ return cached[1]
+ try:
+ done = subprocess.run(command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=MPLEX_SHELL_TIMEOUT)
+ text = done.stdout[:MPLEX_SHELL_OUTPUT_MAX].decode("utf-8", "replace").strip().split("\n")[0]
+ except (OSError, subprocess.SubprocessError):
+ text = ""
+ if len(self.shell_cache) > 64:
+ self.shell_cache.clear()
+ self.shell_cache[command] = (now, text)
+ return text
+
+ def variable(self, name, session, window, pane, client):
+ if name in MplexOptions.DEFS:
+ scope = window.options if window is not None else (session.options if session is not None else self.options)
+ value = scope.get(name)
+ if isinstance(value, bool):
+ return "1" if value else "0"
+ return str(value)
+ if name == "host":
+ return socket.gethostname()
+ if name == "host_short":
+ return socket.gethostname().split(".")[0]
+ if name == "version":
+ return VERSION
+ if session is not None:
+ if name == "session_name":
+ return session.name
+ if name == "session_id":
+ return session.tag
+ if name == "session_windows":
+ return str(len(session.windows))
+ if name == "session_created":
+ return str(int(session.created))
+ if name == "session_attached":
+ return str(len(self.attached_clients(session)))
+ if window is not None and session is not None:
+ index = session.index_of(window)
+ if name == "window_index":
+ return str(index)
+ if name == "window_name":
+ return window.name
+ if name == "window_id":
+ return window.tag
+ if name == "window_flags":
+ return self.window_flags(session, window)
+ if name == "window_active":
+ return "1" if index == session.current else "0"
+ if name == "window_last_flag":
+ return "1" if index == session.last else "0"
+ if name == "window_panes":
+ return str(len(window.panes()))
+ if name == "window_zoomed_flag":
+ return "1" if window.zoomed is not None else "0"
+ if name == "window_width":
+ return str(window.width)
+ if name == "window_height":
+ return str(window.height)
+ if name in ("window_activity_flag", "window_bell_flag", "window_silence_flag", "window_bigger"):
+ return "0"
+ if name == "window_marked_flag":
+ return "1" if self.marked is not None and self.marked.window is window else "0"
+ if pane is not None:
+ mode = pane.top_mode()
+ screen = pane.screen
+ if name == "pane_id":
+ return pane.tag
+ if name == "pane_index":
+ return str(pane.window.panes().index(pane) + pane.window.options.get("pane-base-index")) if pane.window else "0"
+ if name == "pane_title":
+ return pane.title
+ if name == "pane_current_command":
+ return pane.foreground_command()
+ if name == "pane_current_path":
+ return pane.current_path()
+ if name == "pane_pid":
+ return str(pane.pid or "")
+ if name == "pane_width":
+ return str(pane.w)
+ if name == "pane_height":
+ return str(pane.h)
+ if name == "pane_active":
+ return "1" if pane.window is not None and pane.window.active is pane else "0"
+ if name == "pane_last":
+ return "1" if pane.window is not None and pane.window.last_pane is pane else "0"
+ if name == "pane_in_mode":
+ return "1" if mode is not None else "0"
+ if name == "pane_mode":
+ return mode.name if mode is not None else ""
+ if name == "pane_dead":
+ return "1" if pane.dead else "0"
+ if name == "pane_dead_status":
+ return "" if pane.exit_status is None else str(pane.exit_status)
+ if name == "pane_left":
+ return str(pane.x)
+ if name == "pane_top":
+ return str(pane.y)
+ if name == "pane_right":
+ return str(pane.x + pane.w - 1)
+ if name == "pane_bottom":
+ return str(pane.y + pane.h - 1)
+ if name == "pane_synchronized":
+ return "1" if pane.window is not None and pane.window.options.get("synchronize-panes") else "0"
+ if name == "pane_marked":
+ return "1" if self.marked is pane else "0"
+ if name == "pane_marked_set":
+ return "1" if self.marked is not None else "0"
+ if name == "alternate_on":
+ return "1" if screen.alt_active else "0"
+ if name == "history_size":
+ return str(pane.history_size())
+ if name == "history_limit":
+ return str(screen.history.maxlen if screen.history is not None else 0)
+ if name == "cursor_x":
+ return str(screen.cx)
+ if name == "cursor_y":
+ return str(screen.cy)
+ if name == "pane_search_string":
+ return mode.search[0] if isinstance(mode, MplexCopyMode) and mode.search else ""
+ if isinstance(mode, MplexCopyMode):
+ if name == "scroll_position":
+ return str(mode.oy)
+ if name == "copy_cursor_x":
+ return str(mode.cx)
+ if name == "copy_cursor_y":
+ return str(mode.cy)
+ if name == "selection_present":
+ return "1" if mode.anchor is not None else "0"
+ if name == "copy_cursor_line":
+ return mode.text(mode.abs_y())
+ if name == "copy_cursor_word":
+ line = mode.text(mode.abs_y())
+ if not line:
+ return ""
+ x = min(mode.cx, len(line) - 1)
+ seps = pane.window.options.get("word-separators") + " "
+ start = end = x
+ while start > 0 and line[start - 1] not in seps:
+ start -= 1
+ while end + 1 < len(line) and line[end + 1] not in seps:
+ end += 1
+ return line[start:end + 1]
+ if name == "scroll_position":
+ return ""
+ if client is not None:
+ if name == "client_width":
+ return str(client.cols)
+ if name == "client_height":
+ return str(client.rows)
+ if name == "client_session":
+ return client.session.name if client.session else ""
+ if name == "client_tty":
+ return client.tty
+ if name == "client_termname":
+ return client.term
+ if name == "client_pid":
+ return str(client.pid)
+ if name == "client_key_table":
+ return client.table
+ if name == "client_prefix":
+ return "1" if client.table == "prefix" else "0"
+ if name == "client_created":
+ return str(int(client.created))
+ return None
+
+ def expand(self, text, ctx, extra=None):
+ """Format-expand text for a command context (strftime applied)."""
+ base = self.resolver_for(ctx.session, ctx.window, ctx.pane, ctx.client)
+ if extra:
+ def resolve(name):
+ return extra[name] if name in extra else base(name)
+ else:
+ resolve = base
+ return MplexFormat.with_time(MplexFormat.expand(text, resolve))
+
+ # ---- key handling ----------------------------------------------------
+
+ def is_prefix(self, session, name):
+ opts = session.options
+ return name in (opts.get("prefix"), opts.get("prefix2")) and name != "None"
+
+ def input_bytes(self, client, data):
+ client.keybuf = (client.keybuf + data)[-65536:]
+ self.process_keys(client, False)
+
+ def process_keys(self, client, final):
+ tokens, rest = MplexKeys.split(client.keybuf, final)
+ client.keybuf = rest
+ for name, raw in tokens:
+ if not client.attached:
+ break
+ self.handle_key(client, name, raw)
+ if client.keybuf and client.attached:
+ client.esc_deadline = time.monotonic() + client.session.options.get("escape-time") / 1000.0
+ else:
+ client.esc_deadline = None
+
+ def handle_key(self, client, name, raw):
+ if not client.attached:
+ return
+ client.dirty = True
+ if client.prompt is not None:
+ client.prompt.key(self, client, name, raw)
+ return
+ if client.overlay is not None:
+ client.overlay.key(name, raw, client)
+ return
+ client.message = None
+ session = client.session
+ window = session.window()
+ pane = window.active
+ if not name:
+ self.forward(pane, window, raw)
+ return
+ table = client.table
+ if table == "root":
+ binding = self.config.lookup("root", name)
+ if binding is not None:
+ self.run_binding(client, binding, pane)
+ return
+ if self.is_prefix(session, name):
+ client.table = "prefix"
+ client.repeating = False
+ return
+ mode = pane.top_mode()
+ if mode is not None:
+ mode.key(name, raw, client)
+ return
+ self.forward(pane, window, raw)
+ return
+ binding = self.config.lookup(table, name)
+ if client.repeating and not (binding is not None and binding.repeat):
+ client.table = "root"
+ client.repeating = False
+ self.handle_key(client, name, raw)
+ return
+ client.table = "root"
+ client.repeating = False
+ if binding is not None:
+ self.run_binding(client, binding, pane)
+ if binding.repeat and client.attached and client.table == "root":
+ client.table = table
+ client.repeating = True
+ client.repeat_until = time.monotonic() + client.session.options.get("repeat-time") / 1000.0
+
+ def forward(self, pane, window, raw):
+ data = MplexKeys.app_cursor_form(raw) if pane.screen.app_cursor else raw
+ targets = window.panes() if window.options.get("synchronize-panes") else [pane]
+ for target in targets:
+ if not target.dead:
+ target.write(data)
+ target.input_bytes += len(data)
+
+ def run_binding(self, client, binding, pane=None):
+ self.run_checked(MplexCtx(self, client=client, pane=pane, interactive=True), binding.command)
+
+ def run_checked(self, ctx, text):
+ try:
+ output = self.run_line(ctx, text)
+ if output:
+ self.show_output(ctx, output)
+ except MplexError as exc:
+ self.message(ctx.client, str(exc))
+ except Exception as exc: # noqa: BLE001 -- a bad command must never take the server down
+ import traceback
+ sys.stderr.write(traceback.format_exc())
+ self.message(ctx.client, "internal error: " + short_error(exc))
+
+ def show_output(self, ctx, text):
+ pane = ctx.pane
+ lines = text.rstrip("\n").split("\n")
+ if pane is None or (len(lines) == 1 and len(lines[0]) < 200):
+ self.message(ctx.client, lines[0] if lines else "")
+ return
+ old = pane.find_mode(MplexCopyMode)
+ if old is not None and old.static_rows is not None:
+ old.close()
+ pane.push_mode(MplexCopyMode(self, pane, lines=lines))
+ self.mark_dirty(pane.window)
+
+ def ask_confirm(self, client, text, callback):
+ def done(value):
+ callback()
+ client.prompt = MplexPrompt(text + " ", "", done, confirm=True)
+ client.dirty = True
+
+ def refresh_choosers(self):
+ for pane in list(self.panes.values()):
+ for mode in list(pane.modes):
+ if isinstance(mode, MplexChooser):
+ mode.rebuild()
+ self.mark_dirty()
+
+ # ---- command execution -----------------------------------------------
+
+ def run_line(self, ctx, text, depth=0):
+ return self.run_commands(ctx, MplexCommandParser.parse(text), depth)
+
+ def run_commands(self, ctx, commands, depth=0):
+ if depth > MPLEX_COMMAND_DEPTH_MAX:
+ raise MplexError("commands nested too deeply")
+ flat = []
+ for words in commands:
+ piece = []
+ for word in words:
+ if word == ";":
+ if piece:
+ flat.append(piece)
+ piece = []
+ else:
+ piece.append(word)
+ if piece:
+ flat.append(piece)
+ output = []
+ for words in flat:
+ text = self.run_one(ctx, words, depth)
+ if text:
+ output.append(text)
+ return "\n".join(output)
+
+ def run_words(self, ctx, words):
+ return self.run_commands(ctx, [words], 0)
+
+ def run_one(self, ctx, words, depth):
+ name = MplexCommands.resolve(words[0])
+ aliases, spec, low, high = MPLEX_COMMAND_SPECS[name]
+ args = MplexArgs.parse(name, spec, words[1:], low, high)
+ ctx.depth = depth
+ handler = getattr(self.commands, "cmd_" + name.replace("-", "_"))
+ return handler(ctx, args) or ""
+
+ # ---- sockets ---------------------------------------------------------
+
+ def open_listener(self):
+ lock_fd = os.open(os.path.join(self.dir, MPLEX_LOCK_NAME), os.O_RDWR | os.O_CREAT, 0o600)
+ try:
+ fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
+ except OSError:
+ os.close(lock_fd)
+ return None
+ self.lock_fd = lock_fd
+ try:
+ os.unlink(self.sock_path)
+ except OSError:
+ pass
+ listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+ listener.bind(self.sock_path)
+ os.chmod(self.sock_path, 0o600)
+ listener.listen(16)
+ listener.setblocking(False)
+ return listener
+
+ def accept(self):
+ try:
+ sock, _addr = self.listener.accept()
+ except OSError:
+ return
+ sock.setblocking(False)
+ if len(self.conns) + len(self.clients) >= MPLEX_MAX_CLIENTS * 2:
+ sock.close()
+ return
+ self.conns.append(MplexPending(sock))
+
+ def pending_read(self, pending):
+ try:
+ data = pending.sock.recv(65536)
+ except (BlockingIOError, InterruptedError):
+ return
+ except OSError:
+ data = b""
+ if not data:
+ self.drop_pending(pending)
+ return
+ pending.buf += data
+ if pending.buf[:1] == MPLEX_MAGIC:
+ if len(self.clients) >= MPLEX_MAX_CLIENTS:
+ self.drop_pending(pending)
+ return
+ self.conns.remove(pending)
+ client = MplexClientConn(self, pending.sock, self.next_id("client"))
+ self.clients.append(client)
+ self.client_data(client, bytes(pending.buf[1:]))
+ return
+ if b"\n" in pending.buf or len(pending.buf) > PLEXER_RECV_MAX:
+ self.control(pending)
+
+ def drop_pending(self, pending):
+ if pending in self.conns:
+ self.conns.remove(pending)
+ try:
+ pending.sock.close()
+ except OSError:
+ pass
+
+ def control(self, pending):
+ """Legacy JSON line protocol: send / read / status against a pane (see NativePlexerController)."""
+ try:
+ request = json.loads(bytes(pending.buf).decode("utf-8", "replace"))
+ if not isinstance(request, dict):
+ raise ValueError("request must be a JSON object")
+ reply = self.dispatch_control(request)
+ except Exception as exc: # noqa: BLE001 -- one bad request must not end the session
+ reply = {"ok": False, "error": short_error(exc)}
+ try:
+ pending.sock.setblocking(True)
+ pending.sock.settimeout(2)
+ pending.sock.sendall((json.dumps(reply) + "\n").encode("utf-8"))
+ except OSError:
+ pass
+ self.drop_pending(pending)
+
+ def control_pane(self, request):
+ ident = request.get("pane")
+ if ident not in (None, ""):
+ pane = self.panes.get(int(str(ident).lstrip("%")))
+ if pane is None:
+ raise ValueError("no such pane: %s" % ident)
+ return pane
+ session = self.latest_session()
+ if session is None or session.window() is None:
+ raise ValueError("no session")
+ return session.window().active
+
+ def dispatch_control(self, request):
+ cmd = request.get("cmd")
+ if cmd == "send":
+ pane = self.control_pane(request)
+ text = str(request.get("text") or "")
+ pane.write(text.encode("utf-8", "replace"))
+ if request.get("enter"):
+ pane.write(b"\r")
+ return {"ok": True, "data": True}
+ if cmd == "read":
+ pane = self.control_pane(request)
+ return {"ok": True, "data": pane.tail_log(max(1, min(int(request.get("lines") or 200), 5000)))}
+ if cmd == "status":
+ pane = self.control_pane(request)
+ window = pane.window
+ return {"ok": True, "data": {"backend": "native", "pid": pane.pid, "log": pane.log_path, "socket": self.sock_path,
+ "pane": pane.tag, "session": window.session.name, "window": window.session.index_of(window)}}
+ return {"ok": False, "error": "unknown command %r" % cmd}
+
+ def client_read(self, client):
+ try:
+ data = client.sock.recv(65536)
+ except (BlockingIOError, InterruptedError):
+ return
+ except OSError:
+ data = b""
+ if not data:
+ self.drop_client(client)
+ return
+ self.client_data(client, data)
+
+ def client_data(self, client, data):
+ frames = client.frames(data)
+ if frames is None:
+ self.drop_client(client)
+ return
+ for kind, payload in frames:
+ if kind == b"H":
+ self.on_hello(client, payload)
+ elif kind == b"I" and client.attached:
+ self.input_bytes(client, payload)
+ elif kind == b"R":
+ try:
+ size = json.loads(payload.decode("utf-8", "replace"))
+ client.rows = max(2, min(1000, int(size["rows"])))
+ client.cols = max(2, min(2000, int(size["cols"])))
+ except (ValueError, KeyError, TypeError):
+ continue
+ client.prev = None
+ if client.attached:
+ self.resize_session(client.session)
+ client.dirty = True
+
+ def on_hello(self, client, payload):
+ try:
+ info = json.loads(payload.decode("utf-8", "replace"))
+ if not isinstance(info, dict):
+ raise ValueError("hello must be an object")
+ client.rows = max(2, min(1000, int(info.get("rows") or 24)))
+ client.cols = max(2, min(2000, int(info.get("cols") or 80)))
+ client.term = str(info.get("term") or "")[:64]
+ client.tty = str(info.get("tty") or "")[:128]
+ client.cwd = str(info.get("cwd") or "/")[:4096]
+ client.pid = int(info.get("pid") or 0)
+ env = info.get("env") or {}
+ client.env = {str(k)[:256]: str(v)[:8192] for k, v in list(env.items())[:512]} if isinstance(env, dict) else {}
+ pane = info.get("pane")
+ client.env_pane = int(pane) if pane not in (None, "") else None
+ commands = [[str(word) for word in item][:256] for item in (info.get("commands") or [["new-session"]])[:64] if item]
+ except (ValueError, TypeError) as exc:
+ client.send(b"E", ("bad hello: " + short_error(exc)).encode("utf-8"))
+ client.closing = True
+ return
+ ctx = MplexCtx(self, client=client)
+ try:
+ output = "\n".join(text for text in (self.run_one(ctx, words, 0) for words in commands) if text)
+ except MplexError as exc:
+ client.send(b"E", str(exc).encode("utf-8"))
+ client.closing = True
+ return
+ except Exception as exc: # noqa: BLE001
+ import traceback
+ sys.stderr.write(traceback.format_exc())
+ client.send(b"E", ("internal error: " + short_error(exc)).encode("utf-8"))
+ client.closing = True
+ return
+ if not client.attached:
+ if output:
+ client.send(b"M", output.encode("utf-8"))
+ client.send(b"X", b"")
+ client.closing = True
+
+ def drop_client(self, client):
+ if client in self.clients:
+ self.clients.remove(client)
+ session = client.session
+ client.attached = False
+ client.session = None
+ client.close()
+ if session is not None and session.id in self.sessions:
+ self.resize_session(session)
+
+ # ---- main loop -------------------------------------------------------
+
+ def serve(self):
+ self.listener = self.open_listener()
+ if self.listener is None:
+ return 1
+ signal.signal(signal.SIGTERM, lambda _s, _f: setattr(self, "running", False))
+ signal.signal(signal.SIGHUP, signal.SIG_IGN)
+ signal.signal(signal.SIGINT, signal.SIG_IGN)
+ self.running = True
+ try:
+ self.loop()
+ finally:
+ self.shutdown()
+ return 0
+
+ def stop(self):
+ self.running = False
+
+ def loop(self):
+ """Run until stopped. One failing iteration is logged and survived, because it would otherwise
+ end every pane; MPLEX_LOOP_ERRORS_MAX failures in a row end the server."""
+ failures = 0
+ while self.running:
+ try:
+ self.iterate()
+ failures = 0
+ except Exception: # noqa: BLE001 -- see docstring
+ import traceback
+ sys.stderr.write(traceback.format_exc())
+ sys.stderr.flush()
+ failures += 1
+ if failures >= MPLEX_LOOP_ERRORS_MAX:
+ sys.stderr.write("mplex server: %d failed iterations in a row, exiting\n" % failures)
+ self.running = False
+ time.sleep(0.05)
+
+ def iterate(self):
+ now = time.monotonic()
+ self.run_deadlines(now)
+ fd_map = {self.listener.fileno(): ("listen", None)}
+ for pending in self.conns:
+ fd_map[pending.sock.fileno()] = ("pending", pending)
+ for client in self.clients:
+ fd_map[client.sock.fileno()] = ("client", client)
+ for pane in self.panes.values():
+ if pane.master_fd is not None:
+ fd_map[pane.master_fd] = ("pane", pane)
+ writers = {}
+ for client in self.clients:
+ if client.out:
+ writers[client.sock.fileno()] = client
+ for pane in self.panes.values():
+ if pane.pending_in and pane.master_fd is not None:
+ writers[pane.master_fd] = pane
+ try:
+ ready, can_write, _ = select.select(list(fd_map), list(writers), [], self.wakeup(now))
+ except InterruptedError:
+ return
+ except (OSError, ValueError):
+ self.sweep_bad_fds()
+ return
+ for fd in ready:
+ kind, obj = fd_map[fd]
+ if kind == "listen":
+ self.accept()
+ elif kind == "pending":
+ self.pending_read(obj)
+ elif kind == "client":
+ self.client_read(obj)
+ elif kind == "pane":
+ self.pane_read(obj)
+ for fd in can_write:
+ target = writers[fd]
+ if isinstance(target, MplexPane):
+ target.drain()
+ else:
+ target.flush()
+ now = time.monotonic()
+ self.reap_children(now)
+ self.render_clients(now)
+ self.close_finished()
+ if not self.sessions and (self.had_session or now - self.started > MPLEX_IDLE_EXIT) and not self.clients_busy():
+ self.running = False
+
+ def clients_busy(self):
+ return any(client.out for client in self.clients)
+
+ def sweep_bad_fds(self):
+ for pending in list(self.conns):
+ if pending.sock.fileno() < 0:
+ self.conns.remove(pending)
+ for client in list(self.clients):
+ if client.sock.fileno() < 0:
+ self.drop_client(client)
+ for pane in list(self.panes.values()):
+ if pane.master_fd is not None:
+ try:
+ os.fstat(pane.master_fd)
+ except OSError:
+ self.pane_exited(pane)
+
+ def wakeup(self, now):
+ due = [now + 1.0]
+ due.extend(deadline for deadline, _cb in self.timers)
+ for client in self.clients:
+ if client.esc_deadline:
+ due.append(client.esc_deadline)
+ if client.repeating:
+ due.append(client.repeat_until)
+ if client.dirty and client.attached and len(client.out) <= MPLEX_CLIENT_OUT_MAX // 4:
+ due.append(client.last_render + MPLEX_RENDER_INTERVAL)
+ if client.overlay is not None and hasattr(client.overlay, "until"):
+ due.append(client.overlay.until)
+ return max(0.0, min(due) - now)
+
+ def run_deadlines(self, now):
+ if self.timers:
+ ripe = [item for item in self.timers if item[0] <= now]
+ self.timers = [item for item in self.timers if item[0] > now]
+ for _deadline, callback in ripe:
+ callback()
+ for client in list(self.clients):
+ if client.esc_deadline and now >= client.esc_deadline and client.attached:
+ client.esc_deadline = None
+ self.process_keys(client, True)
+ if client.repeating and now >= client.repeat_until:
+ client.repeating = False
+ client.table = "root"
+ client.dirty = True
+ overlay = client.overlay
+ if overlay is not None and hasattr(overlay, "until") and now >= overlay.until:
+ client.overlay = None
+ client.dirty = True
+ if now >= self.next_tick:
+ self.next_tick = now + 1.0
+ self.tick(now)
+ for pending in list(self.conns):
+ if now - pending.created > 5.0:
+ self.drop_pending(pending)
+
+ def tick(self, now):
+ for window in list(self.windows.values()):
+ if window.auto_rename and window.active is not None and window.session is not None:
+ template = window.options.get("automatic-rename-format")
+ if window.options.get("automatic-rename"):
+ resolver = self.resolver_for(window.session, window, window.active)
+ name = MplexFormat.expand(template, resolver)
+ if name and name != window.name:
+ window.name = name[:64]
+ self.mark_all_dirty()
+ for pane in window.panes():
+ if isinstance(pane.top_mode(), MplexClockMode):
+ self.mark_dirty(window)
+ interval = self.options.get("status-interval")
+ if interval and int(now) % interval == 0:
+ self.mark_all_dirty()
+
+ def pane_read(self, pane):
+ try:
+ data = os.read(pane.master_fd, 65536)
+ except (BlockingIOError, InterruptedError):
+ return
+ except OSError:
+ data = b""
+ if not data:
+ self.pane_exited(pane)
+ return
+ pane.feed(data)
+ self.mark_dirty(pane.window)
+
+ def pane_exited(self, pane):
+ status = None
+ if pane.pid is not None:
+ try:
+ done, raw = os.waitpid(pane.pid, os.WNOHANG)
+ if done:
+ status = os.waitstatus_to_exitcode(raw) if hasattr(os, "waitstatus_to_exitcode") else raw
+ pane.pid = None
+ except OSError:
+ pane.pid = None
+ if pane.window is not None and pane.window.options.get("remain-on-exit") and not pane.dead:
+ pane.dead = True
+ pane.exit_status = status
+ pane.screen.feed(("\r\nPane is dead (status %s)\r\n" % status).encode("utf-8"))
+ if pane.master_fd is not None:
+ try:
+ os.close(pane.master_fd)
+ except OSError:
+ pass
+ pane.master_fd = None
+ self.mark_dirty(pane.window)
+ return
+ if pane.window is not None and pane.id in self.panes:
+ self.kill_pane(pane)
+
+ def reap_children(self, now):
+ keep = []
+ for pid, since in self.reaping:
+ try:
+ done, _status = os.waitpid(pid, os.WNOHANG)
+ except OSError:
+ continue
+ if done:
+ continue
+ if now - since > MPLEX_REAP_KILL_AFTER:
+ try:
+ os.killpg(pid, signal.SIGKILL)
+ except OSError:
+ try:
+ os.kill(pid, signal.SIGKILL)
+ except OSError:
+ pass
+ keep.append((pid, since))
+ self.reaping = keep
+
+ def render_clients(self, now):
+ for client in self.clients:
+ if not client.attached or not client.dirty:
+ continue
+ if now - client.last_render < MPLEX_RENDER_INTERVAL:
+ continue
+ if len(client.out) > MPLEX_CLIENT_OUT_MAX // 4:
+ continue
+ canvas = self.renderer.frame(client)
+ data, snapshot = canvas.encode(client.prev)
+ client.last_render = now
+ client.dirty = False
+ if client.prev is not None and snapshot == client.prev and canvas.cursor == client.cursor:
+ continue
+ client.prev = snapshot
+ client.cursor = canvas.cursor
+ client.send(b"O", data)
+
+ def close_finished(self):
+ for client in list(self.clients):
+ if client.closing and not client.out:
+ self.drop_client(client)
+
+ def shutdown(self):
+ for client in list(self.clients):
+ if client.attached:
+ client.send(b"X", b"[server exited]")
+ for _try in range(20):
+ if not client.out:
+ break
+ client.flush()
+ if client.out:
+ time.sleep(0.005)
+ client.close()
+ self.clients = []
+ for pane in list(self.panes.values()):
+ pane.close(hard=True)
+ if pane.pid is not None:
+ try:
+ os.waitpid(pane.pid, os.WNOHANG)
+ except OSError:
+ pass
+ for item in (self.listener,):
+ try:
+ item.close()
+ except (OSError, AttributeError):
+ pass
+ try:
+ os.unlink(self.sock_path)
+ except OSError:
+ pass
+
+ @staticmethod
+ def probe(path):
+ probe = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+ probe.settimeout(1)
+ try:
+ probe.connect(path)
+ return True
+ except OSError:
+ return False
+ finally:
+ probe.close()
+
+ @classmethod
+ def ensure_running(cls, home, wait=5.0):
+ """Start the server as a detached daemon unless one already answers on its socket."""
+ path = os.path.join(plexer_dir(home), MPLEX_SOCKET_NAME)
+ if cls.probe(path):
+ return True
+ pid = os.fork()
+ if pid == 0:
+ code = 1
+ try:
+ os.setsid()
+ if os.fork() > 0:
+ os._exit(0)
+ devnull = os.open(os.devnull, os.O_RDWR)
+ log = os.open(os.path.join(plexer_dir(home), MPLEX_LOG_NAME), os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
+ os.dup2(devnull, 0)
+ os.dup2(log, 1)
+ os.dup2(log, 2)
+ os.chdir("/")
+ code = cls(home).serve()
+ except BaseException: # noqa: BLE001
+ import traceback
+ traceback.print_exc()
+ os._exit(code)
+ os.waitpid(pid, 0)
+ deadline = time.monotonic() + wait
+ while time.monotonic() < deadline:
+ if cls.probe(path):
+ return True
+ time.sleep(0.05)
+ return False
+
+
+class MplexPending:
+ """A connection that has not yet said whether it speaks frames or one JSON line."""
+
+ def __init__(self, sock):
+ self.sock = sock
+ self.buf = bytearray()
+ self.created = time.monotonic()
+
+
+# name: (aliases, getopt spec, min positional, max positional)
+MPLEX_COMMAND_SPECS = {
+ "attach-session": (("attach", "a"), "dErxc:f:t:", 0, 0),
+ "bind-key": (("bind",), "nrT:N:", 1, None),
+ "break-pane": (("breakp",), "abdPF:n:s:t:", 0, 0),
+ "capture-pane": (("capturep",), "aCeJMNpPqTb:E:S:t:", 0, 0),
+ "choose-buffer": ((), "NrZF:f:K:O:t:", 0, 1),
+ "choose-client": ((), "NrZF:f:K:O:t:", 0, 1),
+ "choose-tree": ((), "GNrswZF:f:K:O:t:", 0, 1),
+ "clear-history": (("clearhist",), "Ht:", 0, 0),
+ "clock-mode": ((), "t:", 0, 0),
+ "command-prompt": ((), "1bFkliNI:p:t:T:", 0, 1),
+ "confirm-before": (("confirm",), "byc:p:t:", 1, 1),
+ "copy-mode": ((), "deHMqSus:t:", 0, 0),
+ "delete-buffer": (("deleteb",), "b:", 0, 0),
+ "detach-client": (("detach",), "aPE:s:t:", 0, 0),
+ "display-message": (("display",), "aCIlNpvc:d:F:t:", 0, 1),
+ "display-panes": (("displayp",), "bNd:t:", 0, 1),
+ "find-window": (("findw",), "CiNrTZt:", 1, 1),
+ "has-session": (("has",), "t:", 0, 0),
+ "if-shell": (("if",), "bFt:", 2, 3),
+ "kill-pane": (("killp",), "at:", 0, 0),
+ "kill-server": ((), "", 0, 0),
+ "kill-session": ((), "aCt:", 0, 0),
+ "kill-window": (("killw",), "at:", 0, 0),
+ "last-pane": (("lastp",), "deZt:", 0, 0),
+ "last-window": (("last",), "t:", 0, 0),
+ "list-buffers": (("lsb",), "F:f:", 0, 0),
+ "list-clients": (("lsc",), "F:f:t:", 0, 0),
+ "list-commands": (("lscm",), "F:", 0, 1),
+ "list-keys": (("lsk",), "1aNP:T:", 0, 1),
+ "list-panes": (("lsp",), "asF:f:t:", 0, 0),
+ "list-sessions": (("ls",), "F:f:", 0, 0),
+ "list-windows": (("lsw",), "aF:f:t:", 0, 0),
+ "load-buffer": (("loadb",), "b:t:", 1, 1),
+ "move-window": (("movew",), "abdkrs:t:", 0, 0),
+ "new-session": (("new",), "AdDEPXc:e:F:f:n:s:t:x:y:", 0, None),
+ "new-window": (("neww",), "abdkPSc:e:F:n:t:", 0, None),
+ "next-layout": (("nextl",), "t:", 0, 0),
+ "next-window": (("next",), "at:", 0, 0),
+ "paste-buffer": (("pasteb",), "dprs:b:t:", 0, 0),
+ "previous-layout": (("prevl",), "t:", 0, 0),
+ "previous-window": (("prev",), "at:", 0, 0),
+ "refresh-client": (("refresh",), "cDlLRSUA:B:C:f:r:t:", 0, 1),
+ "rename-session": (("rename",), "t:", 1, 1),
+ "rename-window": (("renamew",), "t:", 1, 1),
+ "resize-pane": (("resizep",), "DLMRTUZx:y:t:", 0, 1),
+ "rotate-window": (("rotatew",), "DUZt:", 0, 0),
+ "run-shell": (("run",), "bCEc:d:t:", 0, 1),
+ "save-buffer": (("saveb",), "ab:", 1, 1),
+ "select-layout": (("selectl",), "Enopt:", 0, 1),
+ "select-pane": (("selectp",), "DdeLlMmRUZT:t:", 0, 0),
+ "select-window": (("selectw",), "lnpTt:", 0, 0),
+ "send-keys": (("send",), "FHKlMRXc:N:t:", 0, None),
+ "send-prefix": ((), "2t:", 0, 0),
+ "set-buffer": (("setb",), "awb:n:t:", 0, 1),
+ "set-option": (("set",), "aFgopqsuUwt:", 1, 2),
+ "set-window-option": (("setw",), "aFgoqut:", 1, 2),
+ "show-buffer": (("showb",), "b:", 0, 0),
+ "show-messages": (("showmsgs",), "JTt:", 0, 0),
+ "show-options": (("show",), "AgHpqsvwt:", 0, 1),
+ "show-window-options": (("showw",), "gvt:", 0, 1),
+ "source-file": (("source",), "Fnqvt:", 1, None),
+ "split-window": (("splitw",), "bdefhIPvZc:e:F:l:t:", 0, None),
+ "start-server": (("start",), "", 0, 0),
+ "suspend-client": (("suspendc",), "t:", 0, 0),
+ "swap-pane": (("swapp",), "dDUZs:t:", 0, 0),
+ "swap-window": (("swapw",), "ds:t:", 0, 0),
+ "switch-client": (("switchc",), "ElnprZc:t:T:", 0, 0),
+ "unbind-key": (("unbind",), "anqT:", 0, 1),
+}
+
+MPLEX_DEFAULT_FORMATS = {
+ "list-sessions": "#{session_name}: #{session_windows} windows (created #{t:session_created})#{?session_attached, (attached),}",
+ "list-windows": "#{window_index}: #{window_name}#{window_flags} (#{window_panes} panes) [#{window_width}x#{window_height}] #{window_id}#{?window_active, (active),}",
+ "list-panes": "#{pane_index}: [#{pane_width}x#{pane_height}] [history #{history_size}/#{history_limit}] #{pane_id}#{?pane_active, (active),}",
+ "list-clients": "#{client_tty}: #{session_name} [#{client_width}x#{client_height} #{client_termname}]",
+ "list-buffers": "#{buffer_name}: #{buffer_size} bytes: \"#{buffer_sample}\"",
+ "display-message": "[#{session_name}] #{window_index}:#{window_name}, current pane #{pane_index} - (%H:%M %d-%b-%y)",
+}
+
+
+class MplexCommands:
+ """tmux's command set as methods named cmd_(ctx, args)."""
+
+ def __init__(self, server):
+ self.s = server
+
+ @staticmethod
+ def resolve(word):
+ if word in MPLEX_COMMAND_SPECS:
+ return word
+ for name, spec in MPLEX_COMMAND_SPECS.items():
+ if word in spec[0]:
+ return name
+ if word in MPLEX_UNSUPPORTED:
+ raise MplexError("command not supported by mplex: %s" % word)
+ matches = [name for name in MPLEX_COMMAND_SPECS if name.startswith(word)]
+ if len(matches) == 1:
+ return matches[0]
+ if len(matches) > 1:
+ raise MplexError("ambiguous command: %s (%s)" % (word, ", ".join(sorted(matches)[:5])))
+ raise MplexError("unknown command: %s" % word)
+
+ # ---- helpers ---------------------------------------------------------
+
+ def need_client(self, ctx):
+ if ctx.client is None or not ctx.client.attached:
+ raise MplexError("no attached client")
+ return ctx.client
+
+ def pane_target(self, ctx, args, flag="t"):
+ return MplexTargets.pane(ctx, args.get(flag))
+
+ def window_target(self, ctx, args, flag="t"):
+ return MplexTargets.window(ctx, args.get(flag))
+
+ def session_target(self, ctx, args, flag="t"):
+ return MplexTargets.session(ctx, args.get(flag))
+
+ def fmt(self, text, session=None, window=None, pane=None, client=None, extra=None):
+ base = self.s.resolver_for(session, window, pane, client)
+ if extra:
+ def resolve(name):
+ return extra[name] if name in extra else base(name)
+ else:
+ resolve = base
+ return MplexFormat.with_time(MplexFormat.expand(text, resolve))
+
+ def listing(self, command, args, rows):
+ template = args.get("F") or MPLEX_DEFAULT_FORMATS[command]
+ flt = args.get("f")
+ lines = []
+ for session, window, pane, client, extra in rows:
+ if flt and not MplexFormat.truthy(self.fmt(flt, session, window, pane, client, extra)):
+ continue
+ lines.append(self.fmt(template, session, window, pane, client, extra))
+ return "\n".join(lines)
+
+ def substitute(self, template, answers):
+ out = []
+ in_quote = False
+ pos = 0
+ while pos < len(template):
+ char = template[pos]
+ if char == "\\" and pos + 1 < len(template):
+ out.append(template[pos:pos + 2])
+ pos += 2
+ continue
+ if char == '"':
+ in_quote = not in_quote
+ if char == "%" and pos + 1 < len(template):
+ nxt = template[pos + 1]
+ index = 0 if nxt == "%" else (int(nxt) - 1 if nxt.isdigit() and nxt != "0" else None)
+ if index is not None:
+ value = answers[index] if index < len(answers) else ""
+ if in_quote:
+ value = value.replace("\\", "\\\\").replace('"', '\\"').replace("$", "\\$")
+ out.append(value)
+ pos += 2
+ continue
+ out.append(char)
+ pos += 1
+ return "".join(out)
+
+ def run_later(self, ctx, template, answers):
+ """Run template after a prompt finished, against the pane the prompt was opened for."""
+ pane = ctx.pane if ctx.pane is not None and ctx.pane.id in self.s.panes else None
+ fresh = MplexCtx(self.s, client=ctx.client, pane=pane, interactive=True)
+ fresh.depth = ctx.depth + 1
+ self.s.run_checked(fresh, self.substitute(template, answers))
+
+ # ---- sessions and clients --------------------------------------------
+
+ def cmd_new_session(self, ctx, args):
+ s = self.s
+ name = args.get("s") or s.auto_session_name()
+ if ":" in name or "." in name:
+ raise MplexError("bad session name: %s" % name)
+ existing = next((item for item in s.sessions.values() if item.name == name), None)
+ if existing is not None and args.has("A"):
+ return self.attach(ctx, existing, args.has("D"))
+ if existing is not None:
+ raise MplexError("duplicate session: %s" % name)
+ client = ctx.client
+ cwd = os.path.expanduser(args.get("c") or (client.cwd if client else os.getcwd()))
+ env = dict(client.env) if client is not None and client.env else dict(os.environ)
+ for item in args.all("e"):
+ key, _eq, value = item.partition("=")
+ env[key] = value
+ size = (80, 24 + (1 if s.options.get("status") else 0))
+ if args.get("x") or args.get("y"):
+ size = (args.int("x", 80), args.int("y", 24) + (1 if s.options.get("status") else 0))
+ elif client is not None and not args.has("d"):
+ size = (client.cols, client.rows)
+ session = s.create_session(name, cwd, env, args.positional, args.get("n"), size)
+ if not args.has("d") and client is not None:
+ s.attach_client(client, session)
+ if args.has("P"):
+ return self.fmt(args.get("F") or "#{session_name}:", session, session.window(), session.window().active)
+ return ""
+
+ def attach(self, ctx, session, detach_others):
+ client = self.need_client_for_attach(ctx)
+ if detach_others:
+ for other in list(self.s.attached_clients(session)):
+ if other is not client:
+ self.s.detach_client(other, "[detached (from session %s)]" % session.name)
+ self.s.attach_client(client, session)
+ return ""
+
+ def need_client_for_attach(self, ctx):
+ if ctx.client is None:
+ raise MplexError("no client to attach")
+ return ctx.client
+
+ def cmd_attach_session(self, ctx, args):
+ if args.get("t"):
+ session = MplexTargets.session(ctx, args.get("t"))
+ else:
+ session = self.s.latest_session()
+ if session is None:
+ raise MplexError("no sessions")
+ return self.attach(ctx, session, args.has("d"))
+
+ def cmd_detach_client(self, ctx, args):
+ s = self.s
+ if args.get("s"):
+ session = MplexTargets.session(ctx, args.get("s"))
+ targets = list(s.attached_clients(session))
+ else:
+ current = self.find_client(ctx, args.get("t"))
+ targets = [c for c in s.attached_clients() if c is not current] if args.has("a") else [current]
+ for client in targets:
+ s.detach_client(client, "[detached (from session %s)]" % client.session.name)
+ return ""
+
+ def find_client(self, ctx, spec):
+ if not spec:
+ return self.need_client(ctx)
+ for client in self.s.attached_clients():
+ if spec in (client.tty, str(client.id), str(client.pid)):
+ return client
+ raise MplexError("can't find client: %s" % spec)
+
+ def cmd_has_session(self, ctx, args):
+ MplexTargets.session(ctx, args.get("t") or (ctx.session.name if ctx.session else None))
+ return ""
+
+ def cmd_kill_session(self, ctx, args):
+ target = self.session_target(ctx, args)
+ if args.has("a"):
+ for session in [item for item in self.s.sessions.values() if item is not target]:
+ self.s.kill_session(session)
+ else:
+ self.s.kill_session(target)
+ return ""
+
+ def cmd_kill_server(self, ctx, args):
+ self.s.stop()
+ return ""
+
+ def cmd_start_server(self, ctx, args):
+ return ""
+
+ def cmd_list_sessions(self, ctx, args):
+ rows = [(item, item.window(), item.window().active, None, None) for item in sorted(self.s.sessions.values(), key=lambda x: x.id)]
+ return self.listing("list-sessions", args, rows)
+
+ def cmd_list_windows(self, ctx, args):
+ sessions = sorted(self.s.sessions.values(), key=lambda x: x.id) if args.has("a") else [self.session_target(ctx, args)]
+ rows = [(sess, sess.windows[i], sess.windows[i].active, None, None) for sess in sessions for i in sess.indexes()]
+ return self.listing("list-windows", args, rows)
+
+ def cmd_list_panes(self, ctx, args):
+ s = self.s
+ if args.has("a") or args.has("s"):
+ sessions = sorted(s.sessions.values(), key=lambda x: x.id) if args.has("a") else [self.session_target(ctx, args)]
+ windows = [(sess, sess.windows[i]) for sess in sessions for i in sess.indexes()]
+ else:
+ sess, _index, window = self.window_target(ctx, args)
+ windows = [(sess, window)]
+ rows = [(sess, window, pane, None, None) for sess, window in windows for pane in window.panes()]
+ return self.listing("list-panes", args, rows)
+
+ def cmd_list_clients(self, ctx, args):
+ rows = [(c.session, c.session.window(), c.session.window().active, c, None) for c in self.s.attached_clients()]
+ return self.listing("list-clients", args, rows)
+
+ def cmd_rename_session(self, ctx, args):
+ session = self.session_target(ctx, args)
+ name = args.positional[0]
+ if not name or ":" in name or "." in name:
+ raise MplexError("bad session name: %s" % name)
+ if any(item.name == name and item is not session for item in self.s.sessions.values()):
+ raise MplexError("duplicate session: %s" % name)
+ session.name = name
+ self.s.mark_all_dirty()
+ return ""
+
+ def cmd_switch_client(self, ctx, args):
+ client = self.need_client(ctx)
+ s = self.s
+ if args.has("T"):
+ client.table = args.get("T")
+ client.repeating = False
+ return ""
+ ordered = sorted(s.sessions.values(), key=lambda x: x.id)
+ here = ordered.index(client.session)
+ window = None
+ if args.has("n"):
+ target = ordered[(here + 1) % len(ordered)]
+ elif args.has("p"):
+ target = ordered[(here - 1) % len(ordered)]
+ elif args.has("l"):
+ target = getattr(client, "last_session", None)
+ if target is None or target.id not in s.sessions:
+ raise MplexError("no last session")
+ elif args.get("t"):
+ spec = args.get("t")
+ if spec.startswith("%"):
+ target, window = MplexTargets.pane(ctx, spec)[:2]
+ elif ":" in spec or spec.startswith("@"):
+ target, _index, window = MplexTargets.window(ctx, spec)
+ else:
+ target = MplexTargets.session(ctx, spec)
+ else:
+ raise MplexError("switch-client needs -n, -p, -l, -t or -T")
+ previous = client.session
+ s.attach_client(client, target)
+ if target is not previous:
+ client.last_session = previous
+ if window is not None:
+ target.select(target.index_of(window))
+ return ""
+
+ def cmd_suspend_client(self, ctx, args):
+ self.need_client(ctx).send(b"Z")
+ return ""
+
+ def cmd_refresh_client(self, ctx, args):
+ client = self.need_client(ctx)
+ client.prev = None
+ client.dirty = True
+ return ""
+
+ def cmd_list_commands(self, ctx, args):
+ lines = []
+ for name in sorted(MPLEX_COMMAND_SPECS):
+ aliases, spec, low, high = MPLEX_COMMAND_SPECS[name]
+ lines.append("%s%s %s" % (name, " (%s)" % aliases[0] if aliases else "", "[" + spec.replace(":", " arg").strip() + "]" if spec else ""))
+ return "\n".join(lines)
+
+ # ---- windows ---------------------------------------------------------
+
+ def cmd_new_window(self, ctx, args):
+ s = self.s
+ spec = args.get("t")
+ session = ctx.session
+ index = None
+ anchor = None
+ if spec and not spec.endswith(":") and ":" not in spec and not spec.lstrip("=").isdigit() and not spec.startswith(("@", "%", "+", "-", "!", "^", "$", "{")):
+ has_window = ctx.session is not None and MplexTargets.window_index(ctx.session, spec) is not None
+ if not has_window:
+ spec = spec + ":"
+ if spec and spec.endswith(":"):
+ session = MplexTargets.session(ctx, spec[:-1])
+ elif spec and self.is_new_index(ctx, spec):
+ sess_part, _sep, idx_part = spec.rpartition(":")
+ session = MplexTargets.session(ctx, sess_part) if sess_part else ctx.session
+ index = int(idx_part.lstrip("="))
+ elif spec:
+ session, index, anchor = MplexTargets.window(ctx, spec)
+ if session is None:
+ raise MplexError("no current session")
+ if index is None:
+ index = session.free_index(s.options.get("base-index"))
+ current = session.current
+ if (args.has("a") or args.has("b")) and current is not None:
+ index = current + 1 if args.has("a") else current
+ self.make_room(session, index)
+ elif anchor is not None:
+ if args.has("a"):
+ index += 1
+ self.make_room(session, index)
+ elif args.has("b"):
+ self.make_room(session, index)
+ elif args.has("k"):
+ s.kill_window(anchor)
+ else:
+ raise MplexError("index in use: %d" % index)
+ cwd = os.path.expanduser(args.get("c") or session.cwd)
+ env_extra = dict(item.partition("=")[::2] for item in args.all("e"))
+ if args.has("S"):
+ for idx in session.indexes():
+ if session.windows[idx].name == args.get("n"):
+ session.select(idx)
+ return ""
+ created = s.create_window(session, args.get("n"), cwd, args.positional, env_extra, index, not args.has("d"))
+ if args.has("P"):
+ return self.fmt(args.get("F") or "#{session_name}:#{window_index}.#{pane_index}", session, created, created.active)
+ return ""
+
+ def is_new_index(self, ctx, spec):
+ """-t names an index that does not exist yet (the usual new-window -t :N case)."""
+ if ":" not in spec:
+ return False
+ sess_part, _sep, idx_part = spec.rpartition(":")
+ idx_part = idx_part.lstrip("=")
+ if not idx_part.isdigit():
+ return False
+ try:
+ session = MplexTargets.session(ctx, sess_part) if sess_part else ctx.session
+ except MplexError:
+ return False
+ return int(idx_part) not in session.windows
+
+ def make_room(self, session, index):
+ if index not in session.windows:
+ return
+ current = session.window()
+ moved = {}
+ for idx in session.indexes():
+ moved[idx + 1 if idx >= index else idx] = session.windows[idx]
+ session.windows = moved
+ session.current = session.index_of(current) if current else None
+
+ def cmd_kill_window(self, ctx, args):
+ session, index, window = self.window_target(ctx, args)
+ if args.has("a"):
+ for other in [session.windows[i] for i in session.indexes() if i != index]:
+ self.s.kill_window(other)
+ else:
+ self.s.kill_window(window)
+ return ""
+
+ def cmd_select_window(self, ctx, args):
+ if args.has("n") or args.has("p"):
+ return self.step_window(ctx, args, 1 if args.has("n") else -1)
+ if args.has("l"):
+ return self.cmd_last_window(ctx, args)
+ session, index, window = self.window_target(ctx, args)
+ if args.has("T") and index == session.current:
+ if session.last is None:
+ raise MplexError("no last window")
+ session.select(session.last)
+ self.s.mark_dirty()
+ return ""
+ session.select(index)
+ self.s.mark_dirty()
+ return ""
+
+ def step_window(self, ctx, args, step):
+ session = MplexTargets.session(ctx, args.get("t") and args.get("t").partition(":")[0])
+ if args.has("a"):
+ raise MplexError("no %s window" % ("next" if step > 0 else "previous"))
+ indexes = session.indexes()
+ here = indexes.index(session.current)
+ session.select(indexes[(here + step) % len(indexes)])
+ self.s.mark_dirty()
+ return ""
+
+ def cmd_next_window(self, ctx, args):
+ return self.step_window(ctx, args, 1)
+
+ def cmd_previous_window(self, ctx, args):
+ return self.step_window(ctx, args, -1)
+
+ def cmd_last_window(self, ctx, args):
+ session = MplexTargets.session(ctx, args.get("t"))
+ if session.last not in session.windows:
+ raise MplexError("no last window")
+ session.select(session.last)
+ self.s.mark_dirty()
+ return ""
+
+ def cmd_rename_window(self, ctx, args):
+ session, index, window = self.window_target(ctx, args)
+ if not args.positional[0]:
+ raise MplexError("empty window name")
+ window.name = args.positional[0][:64]
+ window.auto_rename = False
+ self.s.mark_all_dirty()
+ return ""
+
+ def parse_window_destination(self, ctx, spec):
+ if ":" in spec:
+ sess_part, _sep, idx_part = spec.rpartition(":")
+ session = MplexTargets.session(ctx, sess_part) if sess_part else ctx.session
+ else:
+ session, idx_part = ctx.session, spec
+ idx_part = idx_part.lstrip("=")
+ if not idx_part.lstrip("-").isdigit():
+ raise MplexError("bad window index: %s" % idx_part)
+ return session, int(idx_part)
+
+ def cmd_move_window(self, ctx, args):
+ s = self.s
+ if args.has("r"):
+ s.renumber(MplexTargets.session(ctx, (args.get("t") or "").partition(":")[0]))
+ s.mark_all_dirty()
+ return ""
+ src_session, src_index, window = MplexTargets.window(ctx, args.get("s"))
+ if not args.get("t"):
+ raise MplexError("move-window needs -t")
+ dst_session, idx = self.parse_window_destination(ctx, args.get("t"))
+ if args.has("a"):
+ idx += 1
+ self.make_room(dst_session, idx)
+ elif args.has("b"):
+ self.make_room(dst_session, idx)
+ elif idx in dst_session.windows and dst_session.windows[idx] is not window:
+ if args.has("k"):
+ s.kill_window(dst_session.windows[idx])
+ else:
+ raise MplexError("index in use: %d" % idx)
+ if dst_session is src_session and idx == src_index:
+ return ""
+ was_current = src_session.current == src_index
+ src_session.windows.pop(src_index)
+ src_session.history = [item for item in src_session.history if item is not window]
+ window.session = dst_session
+ window.options.parent = dst_session.options
+ dst_session.windows[idx] = window
+ if was_current and src_session.windows and src_session is not dst_session:
+ src_session.current = src_session.indexes()[0]
+ elif was_current and src_session is dst_session:
+ src_session.current = idx
+ if not args.has("d"):
+ dst_session.select(idx)
+ if not src_session.windows:
+ s.kill_session(src_session)
+ else:
+ s.resize_session(src_session)
+ s.resize_session(dst_session)
+ return ""
+
+ def cmd_swap_window(self, ctx, args):
+ src_session, src_index, src = MplexTargets.window(ctx, args.get("s"))
+ dst_session, dst_index, dst = MplexTargets.window(ctx, args.get("t"))
+ was = (src_session.window(), dst_session.window())
+ src_session.windows[src_index], dst_session.windows[dst_index] = dst, src
+ src.session, dst.session = dst_session, src_session
+ src.options.parent, dst.options.parent = dst_session.options, src_session.options
+ for session, current in ((src_session, was[0]), (dst_session, was[1])):
+ if current is not None:
+ session.current = session.index_of(current)
+ self.s.resize_session(src_session)
+ self.s.resize_session(dst_session)
+ return ""
+
+ def cmd_find_window(self, ctx, args):
+ s = self.s
+ text = args.positional[0]
+ fields = ("C" if args.has("C") else "") + ("N" if args.has("N") else "") + ("T" if args.has("T") else "")
+ fields = fields or "CNT"
+ if args.has("r"):
+ flags = re.IGNORECASE if args.has("i") else 0
+ try:
+ matcher = re.compile(text, flags).search
+ except re.error as exc:
+ raise MplexError("bad regular expression: %s" % exc)
+ else:
+ import fnmatch
+ pattern = "*%s*" % text
+ low = pattern.lower() if args.has("i") else pattern
+
+ def matcher(value):
+ return fnmatch.fnmatchcase(value.lower() if args.has("i") else value, low)
+
+ found = []
+ for session in sorted(s.sessions.values(), key=lambda x: x.id):
+ for index in session.indexes():
+ window = session.windows[index]
+ hit = False
+ if "N" in fields and matcher(window.name):
+ hit = True
+ for pane in window.panes():
+ if "T" in fields and matcher(pane.title):
+ hit = True
+ if "C" in fields and matcher(pane.screen.text()):
+ hit = True
+ if hit:
+ found.append((session, index, window))
+ if not found:
+ raise MplexError("no windows matching: %s" % text)
+ if len(found) == 1:
+ session, index, _window = found[0]
+ client = ctx.client
+ if client is not None and client.attached and client.session is not session:
+ s.attach_client(client, session)
+ session.select(index)
+ s.mark_dirty()
+ return ""
+ items = [MplexChoice("%s:%d: %s%s" % (sess.name, idx, win.name, s.window_flags(sess, win)), ("window", win.id)) for sess, idx, win in found]
+ return self.open_chooser(ctx, "tree-mode", lambda: [MplexChoice(i.label, i.payload) for i in items], args)
+
+ # ---- panes -----------------------------------------------------------
+
+ def size_arg(self, text, total):
+ text = text.strip()
+ if text.endswith("%"):
+ return max(1, total * int(text[:-1]) // 100)
+ return int(text)
+
+ def cmd_split_window(self, ctx, args):
+ s = self.s
+ session, window, target = self.pane_target(ctx, args)
+ kind = "lr" if args.has("h") else "tb"
+ leaf = window.leaf_of(target)
+ size = None
+ if args.get("l"):
+ try:
+ size = self.size_arg(args.get("l"), leaf.w if kind == "lr" else leaf.h)
+ except ValueError:
+ raise MplexError("bad size: %s" % args.get("l"))
+ cwd = os.path.expanduser(args.get("c") or session.cwd)
+ env_extra = dict(item.partition("=")[::2] for item in args.all("e"))
+ pane = s.split_pane(window, target, kind, args.has("b"), args.positional, cwd, env_extra, size, not args.has("d"))
+ if args.has("Z") and not args.has("d"):
+ window.zoomed = pane
+ window.relayout()
+ if args.has("P"):
+ return self.fmt(args.get("F") or "#{session_name}:#{window_index}.#{pane_index}", session, window, pane)
+ return ""
+
+ def cmd_kill_pane(self, ctx, args):
+ session, window, pane = self.pane_target(ctx, args)
+ if args.has("a"):
+ for other in [item for item in window.panes() if item is not pane]:
+ self.s.kill_pane(other)
+ else:
+ self.s.kill_pane(pane)
+ return ""
+
+ def cmd_select_pane(self, ctx, args):
+ s = self.s
+ if args.has("e") or args.has("d"):
+ raise MplexError("select-pane -e/-d (pane input switch) is not supported by mplex")
+ if args.has("M"):
+ s.marked = None
+ s.mark_all_dirty()
+ return ""
+ session, window, pane = self.pane_target(ctx, args)
+ if args.has("m"):
+ s.marked = None if s.marked is pane else pane
+ s.mark_all_dirty()
+ return ""
+ if args.get("T") is not None:
+ pane.screen.title = args.get("T")[:256]
+ s.mark_all_dirty()
+ return ""
+ new = pane
+ for flag in "UDLR":
+ if args.has(flag):
+ found = MplexLayout.neighbour(window.visible_panes() if window.zoomed else window.panes(), pane, flag, window.width, window.height)
+ if found is None:
+ raise MplexError("no pane in that direction")
+ new = found
+ if args.has("l"):
+ if window.last_pane not in window.panes():
+ raise MplexError("no last pane")
+ new = window.last_pane
+ keep_zoom = args.has("Z") and window.zoomed is not None
+ zoomed = window.zoomed
+ s.select_pane(window, new)
+ if keep_zoom and zoomed is not None:
+ window.zoomed = new
+ window.relayout()
+ if session.window() is not window:
+ session.select(session.index_of(window))
+ return ""
+
+ def cmd_last_pane(self, ctx, args):
+ _session, _index, window = MplexTargets.window(ctx, args.get("t"))
+ if window.last_pane not in window.panes():
+ raise MplexError("no last pane")
+ self.s.select_pane(window, window.last_pane)
+ return ""
+
+ def cmd_resize_pane(self, ctx, args):
+ if args.has("M") or args.has("T"):
+ raise MplexError("resize-pane -M/-T is not supported by mplex")
+ session, window, pane = self.pane_target(ctx, args)
+ if args.has("Z"):
+ if window.zoomed is not None:
+ window.zoomed = None
+ elif len(window.panes()) > 1:
+ window.zoomed = pane
+ window.relayout()
+ self.s.mark_dirty(window)
+ return ""
+ leaf = window.leaf_of(pane)
+ amount = 1
+ if args.positional:
+ try:
+ amount = int(args.positional[0])
+ except ValueError:
+ raise MplexError("bad adjustment: %s" % args.positional[0])
+ try:
+ if args.get("x"):
+ MplexLayout.resize_to(leaf, "lr", self.size_arg(args.get("x"), window.width))
+ if args.get("y"):
+ MplexLayout.resize_to(leaf, "tb", self.size_arg(args.get("y"), window.height))
+ if args.has("R"):
+ MplexLayout.resize_by(leaf, "lr", amount, True)
+ elif args.has("L"):
+ MplexLayout.resize_by(leaf, "lr", -amount, False)
+ elif args.has("D"):
+ MplexLayout.resize_by(leaf, "tb", amount, True)
+ elif args.has("U"):
+ MplexLayout.resize_by(leaf, "tb", -amount, False)
+ except MplexError:
+ pass
+ window.layout_index = -1
+ window.relayout()
+ self.s.mark_dirty(window)
+ return ""
+
+ def cmd_swap_pane(self, ctx, args):
+ s = self.s
+ if args.has("U") or args.has("D"):
+ _s, window, dst = self.pane_target(ctx, args)
+ panes = window.panes()
+ here = panes.index(dst)
+ src = panes[(here - 1) % len(panes)] if args.has("U") else panes[(here + 1) % len(panes)]
+ else:
+ _s, _w, dst = self.pane_target(ctx, args)
+ if args.get("s"):
+ _s2, _w2, src = MplexTargets.pane(ctx, args.get("s"))
+ elif s.marked is not None:
+ src = s.marked
+ else:
+ raise MplexError("no marked pane")
+ if src is dst:
+ return ""
+ wa, wb = src.window, dst.window
+ leaf_a, leaf_b = wa.leaf_of(src), wb.leaf_of(dst)
+ leaf_a.pane, leaf_b.pane = dst, src
+ src.window, dst.window = wb, wa
+ if wa is not wb:
+ if wa.active is src:
+ wa.active = dst
+ if wb.active is dst:
+ wb.active = src
+ for window in (wa,) if wa is wb else (wa, wb):
+ window.relayout()
+ s.marked = None
+ s.mark_all_dirty()
+ return ""
+
+ def cmd_rotate_window(self, ctx, args):
+ session, index, window = self.window_target(ctx, args)
+ panes = window.panes()
+ if len(panes) < 2:
+ return ""
+ order = panes[-1:] + panes[:-1] if args.has("D") else panes[1:] + panes[:1]
+ position = panes.index(window.active)
+ for leaf, pane in zip(window.root.leaves(), order):
+ leaf.pane = pane
+ window.active = order[position]
+ window.relayout()
+ s = self.s
+ s.mark_dirty(window)
+ return ""
+
+ def cmd_break_pane(self, ctx, args):
+ s = self.s
+ session, window, pane = MplexTargets.pane(ctx, args.get("s"))
+ if len(window.panes()) < 2:
+ raise MplexError("can't break with only one pane")
+ dst_session = session
+ index = dst_session.free_index(s.options.get("base-index"))
+ if args.get("t"):
+ dst_session, index = self.parse_window_destination(ctx, args.get("t"))
+ if index in dst_session.windows:
+ raise MplexError("index in use: %d" % index)
+ leaf = window.leaf_of(pane)
+ was_active = window.active is pane
+ window.root = MplexLayout.remove(window.root, leaf)
+ if window.zoomed is pane:
+ window.zoomed = None
+ rest = window.panes()
+ if was_active or window.active is pane:
+ window.active = window.last_pane if window.last_pane in rest else rest[0]
+ if window.last_pane is pane:
+ window.last_pane = None
+ window.relayout()
+ cols, rows = getattr(dst_session, "size", (80, 24))
+ fresh = MplexWindow(s.next_id("window"), args.get("n") or pane.foreground_command() or pane.shell_name, cols, max(1, rows - (1 if s.options.get("status") else 0)), dst_session.options)
+ fresh.auto_rename = not args.get("n")
+ fresh.root = MplexLayoutNode("pane", pane=pane, w=fresh.width, h=fresh.height)
+ fresh.active = pane
+ pane.window = fresh
+ s.register_window(dst_session, fresh, index, not args.has("d"))
+ fresh.relayout()
+ s.mark_all_dirty()
+ if args.has("P"):
+ return self.fmt(args.get("F") or "#{session_name}:#{window_index}.#{pane_index}", dst_session, fresh, pane)
+ return ""
+
+ def apply_layout(self, window, name, index):
+ panes = window.panes()
+ opts = window.options
+ window.zoomed = None
+ window.root = MplexLayout.preset(name, panes, window.width, window.height, opts.get("main-pane-width"), opts.get("main-pane-height"))
+ window.layout_index = index
+ window.relayout()
+ self.s.mark_dirty(window)
+
+ def cmd_select_layout(self, ctx, args):
+ session, window, pane = self.pane_target(ctx, args)
+ names = MPLEX_LAYOUT_NAMES
+ if args.has("E"):
+ leaf = window.leaf_of(pane)
+ parent = leaf.parent
+ if parent is not None:
+ for child in parent.children:
+ MplexLayout.set_size(child, parent.kind, 1)
+ window.relayout()
+ self.s.mark_dirty(window)
+ return ""
+ if args.positional:
+ wanted = args.positional[0]
+ matches = [item for item in names if item == wanted] or [item for item in names if item.startswith(wanted)]
+ if len(matches) != 1:
+ raise MplexError("unknown or ambiguous layout: %s" % wanted)
+ self.apply_layout(window, matches[0], names.index(matches[0]))
+ return ""
+ if args.has("p"):
+ index = (window.layout_index - 1) % len(names)
+ elif args.has("o"):
+ index = window.layout_index if window.layout_index >= 0 else 0
+ else:
+ index = (window.layout_index + 1) % len(names)
+ self.apply_layout(window, names[index], index)
+ return ""
+
+ def cmd_next_layout(self, ctx, args):
+ session, index, window = self.window_target(ctx, args)
+ self.apply_layout(window, MPLEX_LAYOUT_NAMES[(window.layout_index + 1) % len(MPLEX_LAYOUT_NAMES)], (window.layout_index + 1) % len(MPLEX_LAYOUT_NAMES))
+ return ""
+
+ def cmd_previous_layout(self, ctx, args):
+ session, index, window = self.window_target(ctx, args)
+ step = (window.layout_index - 1) % len(MPLEX_LAYOUT_NAMES)
+ self.apply_layout(window, MPLEX_LAYOUT_NAMES[step], step)
+ return ""
+
+ def cmd_clear_history(self, ctx, args):
+ _s, _w, pane = self.pane_target(ctx, args)
+ pane.clear_history()
+ return ""
+
+ def cmd_capture_pane(self, ctx, args):
+ _s, _w, pane = self.pane_target(ctx, args)
+ screen = pane.screen
+ if args.has("a") and not screen.alt_active:
+ if args.has("q"):
+ return ""
+ raise MplexError("no alternate screen")
+ history = list(screen.history or [])
+ rows = history + [list(row) for row in screen.grid]
+ base = len(history)
+
+ def line_of(row):
+ text = "".join(cell[0] if cell else " " for cell in row)
+ return text if args.has("N") else text.rstrip()
+
+ def bound(value, default):
+ if value is None:
+ return default
+ if value == "-":
+ return None
+ try:
+ return base + int(value)
+ except ValueError:
+ raise MplexError("bad line number: %s" % value)
+
+ start = bound(args.get("S"), base)
+ end = bound(args.get("E"), base + pane.h - 1)
+ start = 0 if start is None else max(0, start)
+ end = len(rows) - 1 if end is None else min(len(rows) - 1, end)
+ text = "\n".join(line_of(rows[i]) for i in range(start, end + 1))
+ if args.has("p"):
+ return text
+ self.s.add_buffer(text + "\n", args.get("b"))
+ return ""
+
+ # ---- keys, prompts, input --------------------------------------------
+
+ def cmd_send_prefix(self, ctx, args):
+ session, window, pane = self.pane_target(ctx, args)
+ name = session.options.get("prefix2" if args.has("2") else "prefix")
+ if name == "None":
+ raise MplexError("no prefix key set")
+ self.s.forward(pane, window, MplexKeys.to_bytes(name, pane.screen.app_cursor))
+ return ""
+
+ def cmd_send_keys(self, ctx, args):
+ session, window, pane = self.pane_target(ctx, args)
+ words = list(args.positional)
+ count = args.int("N", None)
+ if args.has("M"):
+ raise MplexError("send-keys -M (mouse) is not supported by mplex")
+ if args.has("X"):
+ mode = pane.top_mode()
+ if not isinstance(mode, MplexCopyMode):
+ raise MplexError("pane is not in a mode")
+ if not words:
+ raise MplexError("send-keys -X needs a command")
+ if len(words) > 1 and words[1] == "--":
+ del words[1]
+ if args.has("F"):
+ words = [self.fmt(item, session, window, pane, ctx.client) for item in words]
+ if count:
+ pane.prefix_count = count
+ mode.command(words[0], words[1:], ctx.client)
+ return ""
+ if args.has("R"):
+ pane.screen.reset()
+ if not words:
+ if count:
+ pane.prefix_count = count
+ return ""
+ if args.has("F"):
+ words = [self.fmt(item, session, window, pane, ctx.client) for item in words]
+ data = b""
+ if args.has("l"):
+ data = "".join(words).encode("utf-8")
+ elif args.has("H"):
+ try:
+ data = bytes(int(item, 16) for item in words)
+ except ValueError:
+ raise MplexError("bad hex byte in send-keys -H")
+ else:
+ for word in words:
+ try:
+ data += MplexKeys.to_bytes(MplexKeys.canonical(word), pane.screen.app_cursor)
+ except MplexError:
+ data += word.encode("utf-8")
+ mode = pane.top_mode()
+ for _i in range(max(1, min(count or 1, 1000))):
+ if mode is not None and not args.has("l") and not args.has("H"):
+ tokens, _rest = MplexKeys.split(data, True)
+ for name, raw in tokens:
+ mode.key(name, raw, ctx.client)
+ elif not pane.dead:
+ pane.write(data)
+ self.s.mark_dirty(window)
+ return ""
+
+ def cmd_bind_key(self, ctx, args):
+ table = "root" if args.has("n") else (args.get("T") or "prefix")
+ key = MplexKeys.canonical(args.positional[0])
+ words = args.positional[1:]
+ if not words:
+ raise MplexError("bind-key needs a command")
+ command = " ".join(MplexCommandParser.quote(word) for word in words)
+ for parsed in MplexCommandParser.parse(command):
+ self.resolve(parsed[0])
+ self.s.config.bind(table, key, command, args.has("r"), args.get("N") or "")
+ return ""
+
+ def cmd_unbind_key(self, ctx, args):
+ table = "root" if args.has("n") else (args.get("T") or "prefix")
+ config = self.s.config
+ if args.has("a"):
+ config.tables[table] = {}
+ return ""
+ if not args.positional:
+ raise MplexError("unbind-key needs a key (or -a)")
+ key = MplexKeys.canonical(args.positional[0])
+ if not config.unbind(table, key) and not args.has("q"):
+ raise MplexError("unknown key: %s" % args.positional[0])
+ return ""
+
+ def cmd_list_keys(self, ctx, args):
+ config = self.s.config
+ opts = ctx.session.options if ctx.session else self.s.options
+ only = MplexKeys.canonical(args.positional[0]) if args.positional else None
+ tables = [args.get("T")] if args.get("T") else list(config.tables)
+ lines = []
+ if args.has("N"):
+ prefix = opts.get("prefix")
+ rows = []
+ for table in ([args.get("T")] if args.get("T") else ["root", "prefix"]):
+ for key, binding in config.tables.get(table, {}).items():
+ if only is not None and key != only:
+ continue
+ note = binding.note or (binding.command if args.has("a") else "")
+ if not note:
+ continue
+ label = key if table == "root" else "%s %s" % (prefix, key)
+ if args.get("P") is not None:
+ label = args.get("P") + key
+ rows.append((label, note))
+ if only is not None and not rows:
+ raise MplexError("unknown key: %s" % args.positional[0])
+ if args.has("1"):
+ rows = rows[:1]
+ width = max([len(label) for label, _note in rows] or [0])
+ return "\n".join("%s %s" % (label.ljust(width), note) for label, note in rows)
+ for table in tables:
+ for key, binding in config.tables.get(table, {}).items():
+ if only is not None and key != only:
+ continue
+ lines.append("bind-key %s -T %-12s %-12s %s" % ("-r" if binding.repeat else " ", table, key, binding.command))
+ if args.has("1") and lines:
+ lines = lines[:1]
+ return "\n".join(lines)
+
+ def cmd_command_prompt(self, ctx, args):
+ s = self.s
+ client = self.need_client(ctx)
+ template = args.positional[0] if args.positional else None
+ prompts = args.get("p").split(",") if args.get("p") else []
+ inputs = args.get("I").split(",") if args.get("I") else []
+ prompts = [MplexFormat.strip_styles(self.fmt(item, ctx.session, ctx.window, ctx.pane, client)) for item in prompts]
+ inputs = [self.fmt(item, ctx.session, ctx.window, ctx.pane, client) for item in inputs]
+ if template is not None and args.has("F"):
+ template = self.fmt(template, ctx.session, ctx.window, ctx.pane, client)
+ kind = args.get("T") or "command"
+ history = s.prompt_history.setdefault(kind, [])
+ answers = []
+ count = max(1, len(prompts))
+ pane = ctx.pane
+ copy_mode = pane.find_mode(MplexCopyMode) if pane is not None else None
+
+ def ask(position):
+ label = (prompts[position] + " ") if prompts else ":"
+ initial = inputs[position] if position < len(inputs) else ""
+
+ def done(value):
+ answers.append(value)
+ if position + 1 < count:
+ ask(position + 1)
+ return
+ if template is None:
+ self.run_later(ctx, "%%", answers)
+ else:
+ self.run_later(ctx, template, answers)
+
+ kwargs = {}
+ if args.has("i") and template is not None:
+ kwargs["on_change"] = lambda text: self.run_later(ctx, template, ["=" + text])
+ kwargs["on_step"] = lambda name, text: self.run_later(ctx, template, [("+" if name == "C-s" else "-") + text])
+ if copy_mode is not None:
+ kwargs["on_cancel"] = copy_mode.search_cancel
+ client.prompt = MplexPrompt(label, initial, done, history, single=args.has("1"), numeric=args.has("N"), key_name=args.has("k"), **kwargs)
+ client.dirty = True
+
+ ask(0)
+ return ""
+
+ def cmd_confirm_before(self, ctx, args):
+ client = self.need_client(ctx)
+ command = args.positional[0]
+ text = args.get("p") or "Confirm '%s'? (y/n)" % command
+ text = self.fmt(text, ctx.session, ctx.window, ctx.pane, client)
+ pane = ctx.pane
+
+ def run():
+ fresh = MplexCtx(self.s, client=client, pane=pane if pane is not None and pane.id in self.s.panes else None, interactive=True)
+ fresh.depth = ctx.depth + 1
+ self.s.run_checked(fresh, command)
+
+ self.s.ask_confirm(client, text, run)
+ return ""
+
+ def cmd_display_message(self, ctx, args):
+ session, window, pane = self.pane_target(ctx, args)
+ template = args.positional[0] if args.positional else MPLEX_DEFAULT_FORMATS["display-message"]
+ text = self.fmt(template, session, window, pane, ctx.client)
+ text = MplexFormat.strip_styles(text)
+ if args.has("p"):
+ return text
+ client = ctx.client
+ if client is not None and client.attached:
+ delay = args.int("d", None)
+ self.s.message(client, text, delay)
+ return ""
+
+ def cmd_display_panes(self, ctx, args):
+ client = self.need_client(ctx)
+ window = client.session.window()
+ duration = args.int("d", None) or window.options.get("display-panes-time")
+ client.overlay = MplexPaneNumbers(self.s, window, time.monotonic() + duration / 1000.0)
+ client.dirty = True
+ return ""
+
+ def cmd_clock_mode(self, ctx, args):
+ _s, window, pane = self.pane_target(ctx, args)
+ if pane.find_mode(MplexClockMode) is None:
+ pane.push_mode(MplexClockMode(self.s, pane))
+ self.s.mark_dirty(window)
+ return ""
+
+ def cmd_copy_mode(self, ctx, args):
+ if args.has("M"):
+ raise MplexError("copy-mode -M (mouse) is not supported by mplex")
+ _s, window, pane = self.pane_target(ctx, args)
+ existing = pane.find_mode(MplexCopyMode)
+ if args.has("q"):
+ if existing is not None:
+ existing.close()
+ return ""
+ if existing is None:
+ existing = MplexCopyMode(self.s, pane, exit_at_bottom=args.has("e"), show_position=not args.has("H"))
+ pane.push_mode(existing)
+ if args.has("u"):
+ existing.command("page-up", [], ctx.client)
+ self.s.mark_dirty(window)
+ return ""
+
+ def cmd_show_messages(self, ctx, args):
+ return "\n".join("%s %s" % (time.strftime("%H:%M:%S", time.localtime(stamp)), text) for stamp, text in self.s.messages)
+
+ # ---- buffers ---------------------------------------------------------
+
+ def buffer_rows(self):
+ rows = []
+ for item in self.s.buffers:
+ sample = item["data"][:50].replace("\n", " ")
+ rows.append((None, None, None, None, {"buffer_name": item["name"], "buffer_size": str(len(item["data"].encode("utf-8"))), "buffer_sample": sample}))
+ return rows
+
+ def cmd_list_buffers(self, ctx, args):
+ return self.listing("list-buffers", args, self.buffer_rows())
+
+ def cmd_set_buffer(self, ctx, args):
+ name = args.get("b")
+ if args.get("n"):
+ item = self.s.find_buffer(name)
+ item["name"] = args.get("n")
+ return ""
+ if not args.positional:
+ raise MplexError("set-buffer needs data")
+ data = args.positional[0]
+ if args.has("a"):
+ item = self.s.find_buffer(name)
+ item["data"] = (item["data"] + data)[:MPLEX_PASTE_MAX]
+ else:
+ self.s.add_buffer(data, name)
+ return ""
+
+ def cmd_show_buffer(self, ctx, args):
+ return self.s.find_buffer(args.get("b"))["data"]
+
+ def cmd_delete_buffer(self, ctx, args):
+ item = self.s.find_buffer(args.get("b"))
+ self.s.buffers.remove(item)
+ self.s.refresh_choosers()
+ return ""
+
+ def cmd_save_buffer(self, ctx, args):
+ item = self.s.find_buffer(args.get("b"))
+ path = os.path.expanduser(args.positional[0])
+ try:
+ with open(path, "ab" if args.has("a") else "wb") as handle:
+ handle.write(item["data"].encode("utf-8"))
+ except OSError as exc:
+ raise MplexError("cannot write %s: %s" % (path, short_error(exc)))
+ return ""
+
+ def cmd_load_buffer(self, ctx, args):
+ path = os.path.expanduser(args.positional[0])
+ try:
+ with open(path, "rb") as handle:
+ data = handle.read(MPLEX_PASTE_MAX)
+ except OSError as exc:
+ raise MplexError("cannot read %s: %s" % (path, short_error(exc)))
+ self.s.add_buffer(data.decode("utf-8", "replace"), args.get("b"))
+ return ""
+
+ def cmd_paste_buffer(self, ctx, args):
+ session, window, pane = self.pane_target(ctx, args)
+ item = self.s.find_buffer(args.get("b"))
+ separator = args.get("s") if args.get("s") is not None else "\r"
+ text = item["data"] if args.has("r") else item["data"].replace("\n", separator)
+ data = text.encode("utf-8")
+ if args.has("p") and pane.screen.bracketed_paste:
+ data = b"\x1b[200~" + data + b"\x1b[201~"
+ if not pane.dead:
+ pane.write(data)
+ if args.has("d"):
+ self.s.buffers.remove(item)
+ return ""
+
+ # ---- choosers --------------------------------------------------------
+
+ def open_chooser(self, ctx, kind, builder, args, on_choose=None, on_delete=None, preview=None, selected=None):
+ s = self.s
+ pane = ctx.pane
+ if pane is None:
+ raise MplexError("no pane")
+ window = pane.window
+ zoom_on_open = args.has("Z") and window.zoomed is None and len(window.panes()) > 1
+ if zoom_on_open:
+ window.zoomed = pane
+ window.relayout()
+
+ def restore():
+ if zoom_on_open and window.zoomed is pane:
+ window.zoomed = None
+ window.relayout()
+
+ def choose(node, client):
+ kind_tag, ident = node.payload
+ if kind_tag == "window":
+ target = s.windows.get(ident)
+ if target is None:
+ return
+ if client is not None and client.session is not target.session:
+ s.attach_client(client, target.session)
+ target.session.select(target.session.index_of(target))
+ elif kind_tag == "pane":
+ target = s.panes.get(ident)
+ if target is None or target.window is None:
+ return
+ if client is not None and client.session is not target.window.session:
+ s.attach_client(client, target.window.session)
+ target.window.session.select(target.window.session.index_of(target.window))
+ s.select_pane(target.window, target)
+ elif kind_tag == "session":
+ target = s.sessions.get(ident)
+ if target is not None and client is not None:
+ s.attach_client(client, target)
+ s.mark_all_dirty()
+
+ mode = MplexChooser(s, pane, kind, builder, on_choose or choose, on_delete, preview, selected, on_close=restore)
+ pane.push_mode(mode)
+ s.mark_dirty(window)
+ return ""
+
+ def cmd_choose_tree(self, ctx, args):
+ s = self.s
+ sessions_only = args.has("s")
+ windows_collapsed = args.has("w")
+
+ def builder():
+ items = []
+ for session in sorted(s.sessions.values(), key=lambda x: x.id):
+ kids = []
+ for index in session.indexes():
+ window = session.windows[index]
+ panes = []
+ for number, pane in enumerate(window.panes()):
+ panes.append(MplexChoice("%d: %s \"%s\"%s" % (number + window.options.get("pane-base-index"), pane.foreground_command(), pane.title, "*" if pane is window.active else ""), ("pane", pane.id)))
+ kids.append(MplexChoice("%d: %s%s (%d panes) [%dx%d]" % (index, window.name, s.window_flags(session, window), len(panes), window.width, window.height),
+ ("window", window.id), panes, expanded=not (windows_collapsed or sessions_only) and len(panes) > 1))
+ attached = " (attached)" if s.attached_clients(session) else ""
+ items.append(MplexChoice("%s: %d windows%s" % (session.name, len(session.windows), attached), ("session", session.id), kids, expanded=not sessions_only))
+ return items
+
+ def preview(node, width, height):
+ kind_tag, ident = node.payload
+ pane = None
+ if kind_tag == "pane":
+ pane = s.panes.get(ident)
+ elif kind_tag == "window":
+ window = s.windows.get(ident)
+ pane = window.active if window else None
+ else:
+ session = s.sessions.get(ident)
+ pane = session.window().active if session else None
+ if pane is None:
+ return []
+ blank = MplexCanvas.BLANK
+ return [[cell if cell is not None else blank for cell in row[:width]] for row in pane.screen.grid[:height]]
+
+ def delete(node, client):
+ kind_tag, ident = node.payload
+ if kind_tag == "session" and ident in s.sessions:
+ s.kill_session(s.sessions[ident])
+ elif kind_tag == "window" and ident in s.windows:
+ s.kill_window(s.windows[ident])
+ elif kind_tag == "pane" and ident in s.panes:
+ s.kill_pane(s.panes[ident])
+
+ selected = ("window", ctx.window.id) if ctx.window is not None else None
+ return self.open_chooser(ctx, "tree-mode", builder, args, on_delete=delete, preview=preview, selected=selected)
+
+ def cmd_choose_buffer(self, ctx, args):
+ s = self.s
+ pane = ctx.pane
+
+ def builder():
+ return [MplexChoice("%s: %d bytes: \"%s\"" % (item["name"], len(item["data"]), item["data"][:50].replace("\n", " ")), ("buffer", item["name"])) for item in s.buffers]
+
+ def choose(node, client):
+ if pane is not None and pane.id in s.panes and not pane.dead:
+ item = s.find_buffer(node.payload[1])
+ pane.write(item["data"].replace("\n", "\r").encode("utf-8"))
+
+ def delete(node, client):
+ s.buffers[:] = [item for item in s.buffers if item["name"] != node.payload[1]]
+
+ def preview(node, width, height):
+ data = next((item["data"] for item in s.buffers if item["name"] == node.payload[1]), "")
+ return [[(char, None, None, 0) for char in line[:width]] for line in data.split("\n")[:height]]
+
+ if not s.buffers:
+ raise MplexError("no buffers")
+ return self.open_chooser(ctx, "buffer-mode", builder, args, on_choose=choose, on_delete=delete, preview=preview)
+
+ def cmd_choose_client(self, ctx, args):
+ s = self.s
+
+ def builder():
+ return [MplexChoice("%s: %s [%dx%d %s]" % (c.tty or c.id, c.session.name, c.cols, c.rows, c.term), ("client", c.id)) for c in s.attached_clients()]
+
+ def choose(node, client):
+ for target in s.attached_clients():
+ if target.id == node.payload[1]:
+ s.detach_client(target, "[detached (from session %s)]" % target.session.name)
+
+ return self.open_chooser(ctx, "client-mode", builder, args, on_choose=choose, on_delete=choose)
+
+ # ---- options, shell, files -------------------------------------------
+
+ def option_name(self, name):
+ if name in MplexOptions.DEFS:
+ return name
+ matches = [item for item in MplexOptions.DEFS if item.startswith(name)]
+ if len(matches) == 1:
+ return matches[0]
+ raise MplexError("unknown or unsupported option: %s" % name)
+
+ def option_scope(self, ctx, args, window_scope):
+ if args.has("g") or args.has("s"):
+ return self.s.options
+ if window_scope or args.has("w"):
+ return MplexTargets.window(ctx, args.get("t"))[2].options
+ return MplexTargets.session(ctx, args.get("t") and args.get("t").partition(":")[0]).options
+
+ def set_option(self, ctx, args, window_scope):
+ try:
+ name = self.option_name(args.positional[0])
+ except MplexError:
+ if args.has("q"):
+ return ""
+ raise
+ scope = self.option_scope(ctx, args, window_scope)
+ if args.has("u"):
+ scope.unset(name)
+ else:
+ if len(args.positional) < 2:
+ scope.toggle(name)
+ else:
+ value = args.positional[1]
+ if args.has("F"):
+ value = self.fmt(value, ctx.session, ctx.window, ctx.pane, ctx.client)
+ if args.has("o") and scope.is_set(name):
+ raise MplexError("already set: %s" % name)
+ scope.set(name, value, args.has("a"))
+ self.s.resize_all()
+ self.s.mark_all_dirty()
+ return ""
+
+ def cmd_set_option(self, ctx, args):
+ return self.set_option(ctx, args, False)
+
+ def cmd_set_window_option(self, ctx, args):
+ return self.set_option(ctx, args, True)
+
+ def show_options(self, ctx, args, window_scope):
+ scope = self.option_scope(ctx, args, window_scope)
+ names = [self.option_name(args.positional[0])] if args.positional else sorted(MplexOptions.DEFS)
+ lines = []
+ for name in names:
+ if args.positional or args.has("A") or args.has("g") or args.has("s") or scope.is_set(name):
+ value = scope.get(name)
+ text = MplexOptions.show(value)
+ if args.has("v"):
+ lines.append(text)
+ else:
+ lines.append("%s %s" % (name, MplexCommandParser.quote(text) if isinstance(value, str) else text))
+ return "\n".join(lines)
+
+ def cmd_show_options(self, ctx, args):
+ return self.show_options(ctx, args, False)
+
+ def cmd_show_window_options(self, ctx, args):
+ return self.show_options(ctx, args, True)
+
+ def shell_in(self, ctx, command, pane):
+ cwd = pane.current_path() if pane is not None else None
+ env = dict(ctx.session.env) if ctx.session is not None else None
+ try:
+ done = subprocess.run(["/bin/sh", "-c", command], stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
+ timeout=MPLEX_SHELL_TIMEOUT, cwd=cwd or None, env=env)
+ except (OSError, subprocess.SubprocessError) as exc:
+ raise MplexError("shell command failed: " + short_error(exc))
+ return done.returncode, done.stdout[:MPLEX_SHELL_OUTPUT_MAX].decode("utf-8", "replace")
+
+ def cmd_if_shell(self, ctx, args):
+ session, window, pane = self.pane_target(ctx, args)
+ condition = args.positional[0]
+ if args.has("F"):
+ ok = MplexFormat.truthy(self.fmt(condition, session, window, pane, ctx.client))
+ else:
+ ok = self.shell_in(ctx, self.fmt(condition, session, window, pane, ctx.client), pane)[0] == 0
+ chosen = args.positional[1] if ok else (args.positional[2] if len(args.positional) > 2 else "")
+ if not chosen:
+ return ""
+ return self.s.run_line(ctx, chosen, ctx.depth + 1)
+
+ def cmd_run_shell(self, ctx, args):
+ session, window, pane = self.pane_target(ctx, args)
+ if args.get("d"):
+ raise MplexError("run-shell -d (delay) is not supported by mplex")
+ if not args.positional:
+ return ""
+ command = self.fmt(args.positional[0], session, window, pane, ctx.client)
+ if args.has("C"):
+ return self.s.run_line(ctx, command, ctx.depth + 1)
+ if args.has("b"):
+ try:
+ subprocess.Popen(["/bin/sh", "-c", command], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
+ cwd=pane.current_path() or None, start_new_session=True)
+ except OSError as exc:
+ raise MplexError("shell command failed: " + short_error(exc))
+ return ""
+ return self.shell_in(ctx, command, pane)[1].rstrip("\n")
+
+ def cmd_source_file(self, ctx, args):
+ s = self.s
+ out = []
+ for raw in args.positional:
+ path = os.path.expanduser(raw)
+ if not os.path.exists(path):
+ if args.has("q"):
+ continue
+ raise MplexError("no such file: %s" % raw)
+ if path.endswith(".toml"):
+ before = s.config.skipped
+ s.config.apply_file(path)
+ for problem in s.config.errors[before:]:
+ s.log_message("mplex.toml: " + problem)
+ skipped = s.config.skipped - before
+ if skipped:
+ out.append("%s: %d problem(s) skipped, see show-messages" % (path, skipped))
+ s.resize_all()
+ s.mark_all_dirty()
+ continue
+ try:
+ with open(path, "r", encoding="utf-8") as handle:
+ lines = handle.read(MPLEX_CONFIG_MAX).splitlines()
+ except OSError as exc:
+ raise MplexError("cannot read %s: %s" % (path, short_error(exc)))
+ for number, line in enumerate(lines, 1):
+ if not line.strip() or line.lstrip().startswith("#"):
+ continue
+ try:
+ text = s.run_line(ctx, line, ctx.depth + 1)
+ except MplexError as exc:
+ raise MplexError("%s:%d: %s" % (raw, number, exc))
+ if text:
+ out.append(text)
+ return "\n".join(out)
+
+
+class MplexClient:
+ """The attached terminal: forwards keys and resizes to the server, paints what it sends."""
+
+ def __init__(self, home, commands):
+ self.home = home
+ self.commands = commands
+ self.sock_path = os.path.join(plexer_dir(home), MPLEX_SOCKET_NAME)
+ self.stdin_fd = None
+ self.saved = None
+ self.attached = False
+ self.wake_r = None
+ self.wake_w = None
+
+ @staticmethod
+ def frame(kind, payload=b""):
+ return kind + struct.pack(">I", len(payload)) + payload
+
+ def size(self):
+ try:
+ packed = fcntl.ioctl(sys.stdout.fileno(), termios.TIOCGWINSZ, b"\0" * 8)
+ rows, cols = struct.unpack("HH", packed[:4])
+ if rows and cols:
+ return rows, cols
+ except (OSError, ValueError, AttributeError):
+ pass
+ return 24, 80
+
+ def hello(self):
+ rows, cols = self.size()
+ pane = os.environ.get(PLEXER_PANE_ENV, "")
+ info = {"rows": rows, "cols": cols, "term": os.environ.get("TERM", ""), "cwd": os.getcwd(), "pid": os.getpid(),
+ "tty": os.ttyname(sys.stdin.fileno()) if sys.stdin.isatty() else "", "commands": self.commands,
+ "env": dict(list(os.environ.items())[:512]), "pane": pane if pane.isdigit() else ""}
+ return self.frame(b"H", json.dumps(info).encode("utf-8"))
+
+ def enter(self):
+ self.stdin_fd = sys.stdin.fileno()
+ try:
+ self.saved = termios.tcgetattr(self.stdin_fd)
+ tty.setraw(self.stdin_fd)
+ except (termios.error, OSError):
+ self.saved = None
+ os.write(sys.stdout.fileno(), b"\x1b[?1049h\x1b[0m\x1b[2J\x1b[H")
+ self.attached = True
+
+ def leave(self):
+ if not self.attached:
+ return
+ self.attached = False
+ try:
+ os.write(sys.stdout.fileno(), b"\x1b[0m\x1b[?25h\x1b[?1049l")
+ except OSError:
+ pass
+ if self.saved is not None:
+ try:
+ termios.tcsetattr(self.stdin_fd, termios.TCSADRAIN, self.saved)
+ except (termios.error, OSError):
+ pass
+
+ def write_out(self, data):
+ fd = sys.stdout.fileno()
+ view = memoryview(data)
+ while view:
+ try:
+ written = os.write(fd, view)
+ except BlockingIOError:
+ select.select([], [fd], [], 1.0)
+ continue
+ view = view[written:]
+
+ def on_winch(self, _signum, _frame):
+ try:
+ os.write(self.wake_w, b"w")
+ except OSError:
+ pass
+
+ def run(self):
+ sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+ try:
+ sock.connect(self.sock_path)
+ except OSError:
+ sys.stderr.write("no server running on %s\n" % self.sock_path)
+ return 1
+ sock.sendall(MPLEX_MAGIC + self.hello())
+ self.wake_r, self.wake_w = os.pipe()
+ os.set_blocking(self.wake_w, False)
+ previous = signal.signal(signal.SIGWINCH, self.on_winch) if hasattr(signal, "SIGWINCH") else None
+ buf = bytearray()
+ code = 0
+ message = ""
+ try:
+ while True:
+ watch = [sock, self.wake_r] + ([self.stdin_fd] if self.attached else [])
+ try:
+ ready, _w, _x = select.select(watch, [], [])
+ except InterruptedError:
+ continue
+ if self.wake_r in ready:
+ os.read(self.wake_r, 4096)
+ rows, cols = self.size()
+ sock.sendall(self.frame(b"R", json.dumps({"rows": rows, "cols": cols}).encode("ascii")))
+ if self.attached and self.stdin_fd in ready:
+ data = os.read(self.stdin_fd, 4096)
+ if not data:
+ message = "[detached]"
+ break
+ sock.sendall(self.frame(b"I", data))
+ if sock in ready:
+ data = sock.recv(262144)
+ if not data:
+ code = 1
+ message = "[lost server]"
+ break
+ buf += data
+ done = self.handle_frames(sock, buf)
+ if done is not None:
+ code, message = done
+ break
+ finally:
+ self.leave()
+ if previous is not None:
+ signal.signal(signal.SIGWINCH, previous)
+ sock.close()
+ os.close(self.wake_r)
+ os.close(self.wake_w)
+ if message:
+ (sys.stderr if code else sys.stdout).write(message + "\n")
+ return code
+
+ def handle_frames(self, sock, buf):
+ while len(buf) >= 5:
+ length = struct.unpack(">I", bytes(buf[1:5]))[0]
+ if len(buf) < 5 + length:
+ return None
+ kind, payload = bytes(buf[0:1]), bytes(buf[5:5 + length])
+ del buf[:5 + length]
+ if kind == b"A":
+ self.enter()
+ elif kind == b"O":
+ self.write_out(payload)
+ elif kind == b"M":
+ sys.stdout.write(payload.decode("utf-8", "replace") + "\n")
+ sys.stdout.flush()
+ elif kind == b"E":
+ return 1, payload.decode("utf-8", "replace")
+ elif kind in (b"D", b"X"):
+ return 0, payload.decode("utf-8", "replace")
+ elif kind == b"Z":
+ self.suspend(sock)
+ return None
+
+ def suspend(self, sock):
+ self.leave()
+ os.kill(os.getpid(), signal.SIGTSTP)
+ self.enter()
+ rows, cols = self.size()
+ sock.sendall(self.frame(b"R", json.dumps({"rows": rows, "cols": cols}).encode("ascii")))
+
+
+class MplexCli:
+ """tai.py --mplex : any mplex command; new-session and attach-session attach this terminal."""
+
+ @staticmethod
+ def attaches(name, rest):
+ if name == "attach-session":
+ return True
+ if name != "new-session":
+ return False
+ try:
+ return not MplexArgs.parse(name, MPLEX_COMMAND_SPECS[name][1], rest, 0, None).has("d")
+ except MplexError:
+ return True
+
+ @staticmethod
+ def split_commands(words):
+ """argv to commands: a lone ";" separates them, "\\;" is a literal semicolon argument."""
+ commands = [[]]
+ for word in words:
+ if word == ";":
+ commands.append([])
+ else:
+ commands[-1].append(";" if word == "\\;" else word)
+ return [item for item in commands if item]
+
+ @classmethod
+ def main(cls, home, words):
+ commands = cls.split_commands(words) or [["new-session"]]
+ try:
+ name = MplexCommands.resolve(commands[0][0])
+ except MplexError as exc:
+ sys.stderr.write("%s\n" % exc)
+ return 1
+ commands[0][0] = name
+ words = commands[0]
+ if cls.attaches(name, words[1:]):
+ if os.environ.get(PLEXER_ACTIVE_ENV):
+ sys.stderr.write("sessions should be nested with care, unset %s to force\n" % PLEXER_ACTIVE_ENV)
+ return 1
+ if not (sys.stdin.isatty() and sys.stdout.isatty()):
+ sys.stderr.write("open terminal failed: not a terminal\n")
+ return 1
+ if name in ("new-session", "start-server"):
+ if not MplexServer.ensure_running(home):
+ sys.stderr.write("could not start the mplex server, see %s\n" % os.path.join(plexer_dir(home), MPLEX_LOG_NAME))
+ return 1
+ return MplexClient(home, commands).run()
+
+
+class NativePlexerBackend(PlexerBackend):
+ """What the shell hook execs into: a new mplex session attached to this terminal."""
+
+ def __init__(self, home, profile, shell_cmd=None):
+ self.home = home
+ self.profile = profile
+ self.shell_cmd = shell_cmd
+
+ def run(self):
+ if pty is None or termios is None or select is None or fcntl is None:
+ return "error: pty/termios/select not available on this platform"
+ return MplexCli.main(self.home, ["new-session"] + (list(self.shell_cmd) if self.shell_cmd else []))
diff --git a/tai.py b/tai.py
old mode 100755
new mode 100644
index c6f75e6..fc0f539
--- a/tai.py
+++ b/tai.py
@@ -331,13 +331,13 @@ def orientation_block(directory=None, sandbox=False):
def capability_block(yolo=False, auto=False):
"""Live approval state for the system prompt. Grounds the model in what
- this session can actually do, so it never invents a read-only jail."""
+ this session can actually do, so it never invents false limits."""
if yolo or auto:
return ("\n\n## Session capabilities\nApprovals are off and every tool is callable, "
- "including shell and file writes. This session is not read-only: only a failed "
+ "including shell and file writes. Only a failed "
"tool call proves a limit, so act first and adjust on evidence.")
return ("\n\n## Session capabilities\nEvery tool is callable, including shell and file writes; "
- "some commands ask for approval first. This session is not read-only: only a failed "
+ "some commands ask for approval first. Only a failed "
"tool call proves a limit, so act first and adjust on evidence.")
@@ -4441,6 +4441,13 @@ def codex_parse_output(stdout, outfile_text="", used_schema=False):
return opencode_parse(stdout)
+def codex_sandbox_args():
+ """Codex CLI sandbox flags. Default off; set TAI_CODEX_SANDBOX=1 to re-enable."""
+ if (os.environ.get("TAI_CODEX_SANDBOX") or "").strip().lower() in ("1", "true", "yes", "on", "read-only", "readonly"):
+ return ["--sandbox", "read-only"]
+ return []
+
+
def codex_run(prompt, timeout):
binary = codex_binary()
if not binary:
@@ -4451,7 +4458,7 @@ def codex_run(prompt, timeout):
out_path = os.path.join(folder, "last.txt")
with open(schema_path, "w", encoding="utf-8") as handle:
handle.write(schema)
- base = [binary, "exec", "--json", "--ephemeral", "--sandbox", "read-only", "--skip-git-repo-check"]
+ base = [binary, "exec", "--json", "--ephemeral"] + codex_sandbox_args() + ["--skip-git-repo-check"]
if len(prompt.encode("utf-8", "replace")) > 120000:
variants = [
(base + ["--output-schema", schema_path, "-o", out_path, "-"], prompt, True),
@@ -4509,16 +4516,23 @@ def gemini_parse_output(stdout):
return opencode_parse(stdout)
+def gemini_plan_enabled():
+ """Gemini plan-mode variant. Default off; set TAI_GEMINI_PLAN=1 to re-enable."""
+ return (os.environ.get("TAI_GEMINI_PLAN") or "").strip().lower() in ("1", "true", "yes", "on", "plan")
+
+
def gemini_run(prompt, timeout):
binary = gemini_binary()
if not binary:
raise BackendError("gemini CLI not found (GEMINI_BIN or PATH)")
base = [binary, "-p", prompt]
- variants = [
- base + ["--output-format", "json", "--approval-mode", "plan"],
+ variants = []
+ if gemini_plan_enabled():
+ variants.append(base + ["--output-format", "json", "--approval-mode", "plan"])
+ variants.extend([
base + ["--output-format", "json"],
base,
- ]
+ ])
last_error = ""
for argv in variants:
done = _cli_run(argv, None, timeout, "gemini")
diff --git a/test_tai.py b/test_tai.py
index 855ac86..55617dd 100644
--- a/test_tai.py
+++ b/test_tai.py
@@ -2569,13 +2569,39 @@ class CapabilityTests(unittest.TestCase):
def test_yolo_variant(self):
block = tai.capability_block(yolo=True)
self.assertIn("Approvals are off", block)
- self.assertIn("not read-only", block)
- self.assertIn("only a failed tool call proves a limit", block)
+ self.assertIn("Only a failed tool call proves a limit", block)
+ self.assertNotIn("read-only", block.lower())
def test_default_variant(self):
block = tai.capability_block()
self.assertIn("ask for approval", block)
- self.assertIn("not read-only", block)
+ self.assertIn("Only a failed tool call proves a limit", block)
+ self.assertNotIn("read-only", block.lower())
+
+ def test_no_session_mode_mentions(self):
+ for block in (tai.capability_block(), tai.capability_block(yolo=True), tai.capability_block(auto=True)):
+ lowered = block.lower()
+ self.assertNotIn("read-only", lowered)
+ self.assertNotIn("read only", lowered)
+ self.assertNotIn("readonly", lowered)
+
+ def test_codex_sandbox_default_off(self):
+ os.environ.pop("TAI_CODEX_SANDBOX", None)
+ self.assertEqual(tai.codex_sandbox_args(), [])
+ os.environ["TAI_CODEX_SANDBOX"] = "1"
+ try:
+ self.assertEqual(tai.codex_sandbox_args(), ["--sandbox", "read-only"])
+ finally:
+ os.environ.pop("TAI_CODEX_SANDBOX", None)
+
+ def test_gemini_plan_default_off(self):
+ os.environ.pop("TAI_GEMINI_PLAN", None)
+ self.assertFalse(tai.gemini_plan_enabled())
+ os.environ["TAI_GEMINI_PLAN"] = "1"
+ try:
+ self.assertTrue(tai.gemini_plan_enabled())
+ finally:
+ os.environ.pop("TAI_GEMINI_PLAN", None)
def test_apply_system_injects_capabilities(self):
system = self.agent.messages[0]["content"]
@@ -4445,7 +4471,7 @@ class ToolArgumentsTests(unittest.TestCase):
class OpencodeAgentTests(unittest.TestCase):
def test_agent_is_not_plan(self):
- # "plan" makes opencode inject "Plan mode ACTIVE - READ-ONLY" into the
+ # "plan" makes opencode inject a plan-mode notice into the
# model, which then refuses to emit tai's write tool calls.
self.assertNotEqual(tai.OPENCODE_AGENT, "plan")
captured = {}
@@ -10563,6 +10589,8 @@ class CtxStoreTests(unittest.TestCase):
self.assertTrue(any("c00" in line for line in lines))
self.assertFalse(any("c04" in line for line in lines))
+
+
# ---------------------------------------------------------------------------
# The grr client behind `tai.py --tui`: strings, formats, client, stream,
# router, page engine and the shell against a small fake grr server.