From 2cf83537e8199fd10910011e6e6150b5fc57eae6 Mon Sep 17 00:00:00 2001 From: retoor Date: Sun, 25 Oct 2026 01:44:53 +0200 Subject: [PATCH] mplex: MplexCommands port (68 commands) with differential tests against tai.py, tmux and a pty client Co-Authored-By: Claude Sonnet 5.5 --- taicpp/src/mplex/mplex_commands.cpp | 2308 ++++++++++++++++++++++++++ taicpp/tests/mplex_commands_pty.py | 253 +++ taicpp/tests/mplex_commands_test.cpp | 490 ++++++ 3 files changed, 3051 insertions(+) create mode 100644 taicpp/src/mplex/mplex_commands.cpp create mode 100644 taicpp/tests/mplex_commands_pty.py create mode 100644 taicpp/tests/mplex_commands_test.cpp diff --git a/taicpp/src/mplex/mplex_commands.cpp b/taicpp/src/mplex/mplex_commands.cpp new file mode 100644 index 0000000..34ea82d --- /dev/null +++ b/taicpp/src/mplex/mplex_commands.cpp @@ -0,0 +1,2308 @@ +// MplexCommands: tmux's command set, a transcription of tai.py:15423-16800 (MplexCommands). +// Every cmd_ keeps the Python control flow, output text and error text. Where the Python +// code leans on a dynamic feature the mapping is noted next to it (see mplex.md, Port notes). +#include "mplex_commands.h" + +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include "mplex_args.h" +#include "mplex_canvas.h" +#include "mplex_chooser.h" +#include "mplex_clientconn.h" +#include "mplex_clockmode.h" +#include "mplex_copymode.h" +#include "mplex_format.h" +#include "mplex_json.h" +#include "mplex_keys.h" +#include "mplex_layout.h" +#include "mplex_panenumbers.h" +#include "mplex_prompt.h" +#include "mplex_py.h" +#include "mplex_server.h" + +extern char** environ; + +namespace detz::mplex { + +namespace { + +using SessionP = std::shared_ptr; +using WindowP = std::shared_ptr; +using PaneP = std::shared_ptr; +using ClientP = std::shared_ptr; +using EnvList = MplexPane::EnvList; + +// Python truthiness of args.get(flag) (None and "" are falsy). +bool truthy(const std::optional& value) { return value.has_value() && !value->empty(); } + +long long pymod(long long a, long long n) { + long long r = a % n; + if (r < 0) r += n; + return r; +} + +long long pyfloordiv(long long a, long long b) { + long long q = a / b; + if ((a % b != 0) && ((a < 0) != (b < 0))) --q; + return q; +} + +bool is_digits(const std::string& text) { return py::all_digits(to_ustr(text)); } + +std::string short_text(const std::string& text, const std::string& fallback = "OSError") { + std::string value = text.empty() ? fallback : text; + return py::slice(value, 0, 300); +} + +std::string oserror_text(int err, const std::string& path) { + return "[Errno " + std::to_string(err) + "] " + std::strerror(err) + ": " + py::repr(path); +} + +void env_set(EnvList& env, const std::string& key, const std::string& value) { + for (auto& item : env) { + if (item.first == key) { + item.second = value; + return; + } + } + env.emplace_back(key, value); +} + +EnvList env_from_args(const MplexArgs& args) { + EnvList out; + for (const std::string& item : args.all('e')) { + auto eq = item.find('='); + if (eq == std::string::npos) + env_set(out, item, ""); + else + env_set(out, item.substr(0, eq), item.substr(eq + 1)); + } + return out; +} + +std::string before_colon(const std::optional& value) { + if (!truthy(value)) return ""; + auto pos = value->find(':'); + return pos == std::string::npos ? *value : value->substr(0, pos); +} + +bool contains_pane(const std::vector& panes, const PaneP& pane) { + return pane && std::find(panes.begin(), panes.end(), pane) != panes.end(); +} + +long index_of_pane(const std::vector& panes, const PaneP& pane) { + auto it = std::find(panes.begin(), panes.end(), pane); + if (it == panes.end()) throw ValueError("x is not in list"); + return it - panes.begin(); +} + +// int(text, 16) as bytes() element: strip, sign, 0x prefix, underscores between digits. +bool parse_hex_byte(const std::string& raw, int& value) { + std::string text = py::strip(raw); + bool negative = false; + size_t pos = 0; + if (pos < text.size() && (text[pos] == '+' || text[pos] == '-')) { + negative = text[pos] == '-'; + ++pos; + } + if (pos + 1 < text.size() && text[pos] == '0' && (text[pos + 1] == 'x' || text[pos + 1] == 'X')) { + pos += 2; + if (pos < text.size() && text[pos] == '_') ++pos; + } + if (pos >= text.size()) return false; + long long acc = 0; + bool last_underscore = true; + bool any = false; + for (; pos < text.size(); ++pos) { + char ch = text[pos]; + int digit; + if (ch == '_') { + if (last_underscore) return false; + last_underscore = true; + continue; + } + if (ch >= '0' && ch <= '9') + digit = ch - '0'; + else if (ch >= 'a' && ch <= 'f') + digit = ch - 'a' + 10; + else if (ch >= 'A' && ch <= 'F') + digit = ch - 'A' + 10; + else + return false; + last_underscore = false; + any = true; + acc = acc * 16 + digit; + if (acc > 1000000) acc = 1000000; + } + if (!any || last_underscore) return false; + if (negative) acc = -acc; + if (acc < 0 || acc > 255) return false; + value = static_cast(acc); + return true; +} + +// ---- child processes (subprocess.run / Popen with cwd and env) -------------------------- + +struct ShellResult { + int returncode = 0; + Bytes out; +}; + +[[noreturn]] void child_fail(int fd, int err, const std::string& what) { + std::string note = std::to_string(err) + "\n" + what; + ssize_t ignored = ::write(fd, note.data(), note.size()); + (void)ignored; + _exit(255); +} + +std::string read_child_error(int fd) { + std::string data; + char buf[512]; + for (;;) { + ssize_t got = ::read(fd, buf, sizeof(buf)); + if (got <= 0) break; + data.append(buf, static_cast(got)); + } + if (data.empty()) return ""; + auto nl = data.find('\n'); + int err = std::atoi(data.substr(0, nl).c_str()); + std::string what = nl == std::string::npos ? "" : data.substr(nl + 1); + return oserror_text(err, what); +} + +std::vector make_envp(const EnvList& env, std::vector& storage) { + storage.clear(); + for (const auto& item : env) storage.push_back(item.first + "=" + item.second); + std::vector out; + for (auto& text : storage) out.push_back(const_cast(text.c_str())); + out.push_back(nullptr); + return out; +} + +// subprocess.run(["/bin/sh", "-c", command], stdin=DEVNULL, stdout=PIPE, stderr=STDOUT, timeout=T, cwd, env) +ShellResult run_sh(const std::string& command, const std::optional& cwd, const EnvList* env) { + int outp[2], errp[2]; + if (::pipe(outp) != 0) throw MplexError("shell command failed: " + short_text(oserror_text(errno, ""))); + if (::pipe2(errp, O_CLOEXEC) != 0) { + ::close(outp[0]); + ::close(outp[1]); + throw MplexError("shell command failed: " + short_text(oserror_text(errno, ""))); + } + std::vector storage; + std::vector envp; + if (env) envp = make_envp(*env, storage); + pid_t pid = ::fork(); + if (pid < 0) { + int err = errno; + ::close(outp[0]); + ::close(outp[1]); + ::close(errp[0]); + ::close(errp[1]); + throw MplexError("shell command failed: " + short_text(oserror_text(err, ""))); + } + if (pid == 0) { + ::close(outp[0]); + ::close(errp[0]); + int devnull = ::open("/dev/null", O_RDONLY); + if (devnull >= 0) { + ::dup2(devnull, 0); + if (devnull > 2) ::close(devnull); + } + ::dup2(outp[1], 1); + ::dup2(outp[1], 2); + if (outp[1] > 2) ::close(outp[1]); + ::signal(SIGPIPE, SIG_DFL); // subprocess restore_signals=True + ::signal(SIGXFSZ, SIG_DFL); + if (cwd && !cwd->empty() && ::chdir(cwd->c_str()) != 0) child_fail(errp[1], errno, *cwd); + char* argv[] = {const_cast("/bin/sh"), const_cast("-c"), const_cast(command.c_str()), nullptr}; + if (env) + ::execve("/bin/sh", argv, envp.data()); + else + ::execv("/bin/sh", argv); + child_fail(errp[1], errno, "/bin/sh"); + } + ::close(outp[1]); + ::close(errp[1]); + std::string failure = read_child_error(errp[0]); + ::close(errp[0]); + if (!failure.empty()) { + ::close(outp[0]); + int status; + ::waitpid(pid, &status, 0); + throw MplexError("shell command failed: " + short_text(failure)); + } + ShellResult result; + double deadline = py::monotonic() + MPLEX_SHELL_TIMEOUT; + bool timed_out = false; + for (;;) { + double left = deadline - py::monotonic(); + if (left <= 0) { + timed_out = true; + break; + } + struct pollfd pfd = {outp[0], POLLIN, 0}; + int ready = ::poll(&pfd, 1, static_cast(left * 1000.0) + 1); + if (ready < 0) { + if (errno == EINTR) continue; + break; + } + if (ready == 0) continue; + char buf[65536]; + ssize_t got = ::read(outp[0], buf, sizeof(buf)); + if (got < 0) { + if (errno == EINTR || errno == EAGAIN) continue; + break; + } + if (got == 0) break; + size_t room = result.out.size() < static_cast(MPLEX_SHELL_OUTPUT_MAX) + ? static_cast(MPLEX_SHELL_OUTPUT_MAX) - result.out.size() + : 0; + result.out.append(buf, std::min(room, static_cast(got))); + } + ::close(outp[0]); + if (timed_out) { + ::kill(pid, SIGKILL); + int status; + ::waitpid(pid, &status, 0); + throw MplexError("shell command failed: Command '['/bin/sh', '-c', " + py::repr(command) + "]' timed out after " + + py::format_float(MPLEX_SHELL_TIMEOUT) + " seconds"); + } + int status = 0; + while (::waitpid(pid, &status, 0) < 0 && errno == EINTR) { + } + if (WIFEXITED(status)) + result.returncode = WEXITSTATUS(status); + else if (WIFSIGNALED(status)) + result.returncode = -WTERMSIG(status); + return result; +} + +// subprocess.Popen(["/bin/sh", "-c", command], all stdio DEVNULL, cwd, start_new_session=True), not waited for. +void run_sh_detached(const std::string& command, const std::string& cwd) { + int errp[2]; + if (::pipe2(errp, O_CLOEXEC) != 0) throw MplexError("shell command failed: " + short_text(oserror_text(errno, ""))); + pid_t first = ::fork(); + if (first < 0) { + int err = errno; + ::close(errp[0]); + ::close(errp[1]); + throw MplexError("shell command failed: " + short_text(oserror_text(err, ""))); + } + if (first == 0) { + ::close(errp[0]); + pid_t second = ::fork(); + if (second != 0) _exit(0); // the grandchild is re-parented, so the server never owns a zombie + ::setsid(); + int devnull = ::open("/dev/null", O_RDWR); + if (devnull >= 0) { + ::dup2(devnull, 0); + ::dup2(devnull, 1); + ::dup2(devnull, 2); + if (devnull > 2) ::close(devnull); + } + if (!cwd.empty() && ::chdir(cwd.c_str()) != 0) child_fail(errp[1], errno, cwd); + char* argv[] = {const_cast("/bin/sh"), const_cast("-c"), const_cast(command.c_str()), nullptr}; + ::execv("/bin/sh", argv); + child_fail(errp[1], errno, "/bin/sh"); + } + ::close(errp[1]); + std::string failure = read_child_error(errp[0]); + ::close(errp[0]); + int status; + while (::waitpid(first, &status, 0) < 0 && errno == EINTR) { + } + if (!failure.empty()) throw MplexError("shell command failed: " + short_text(failure)); +} + +// ---- prompt chains (command-prompt closures) ------------------------------------------------ + +// A ctx kept by a closure that lives inside a client's prompt: the client is held weakly so +// that client -> prompt -> closure -> client is not a reference cycle. +struct CtxHold { + MplexCtx ctx; + std::weak_ptr client; + explicit CtxHold(const MplexCtx& source) : ctx(source), client(source.client) { ctx.client = nullptr; } + MplexCtx get() const { + MplexCtx copy = ctx; + copy.client = client.lock(); + return copy; + } +}; + +} // namespace + +struct MplexCommands::Impl { + using Handler = std::string (Impl::*)(MplexCtx&, const MplexArgs&); + MplexServer* s; + std::map> last_session; // client.last_session (a dynamic attribute in Python) + explicit Impl(MplexServer* server) : s(server) {} + + static const std::map& table(); + + // ---- helpers ------------------------------------------------------------------------------- + + ClientP need_client(MplexCtx& ctx) { + if (!ctx.client || !ctx.client->attached) throw MplexError("no attached client"); + return ctx.client; + } + MplexTargets::PaneTarget pane_target(MplexCtx& ctx, const MplexArgs& args, char flag = 't') { + return MplexTargets::pane(ctx, args.get(flag, "")); + } + MplexTargets::WindowTarget window_target(MplexCtx& ctx, const MplexArgs& args, char flag = 't') { + return MplexTargets::window(ctx, args.get(flag, "")); + } + SessionP session_target(MplexCtx& ctx, const MplexArgs& args, char flag = 't') { + return MplexTargets::session(ctx, args.get(flag, "")); + } + + std::string fmt(const std::string& text, const SessionP& session = nullptr, const WindowP& window = nullptr, + const PaneP& pane = nullptr, const ClientP& client = nullptr, + const std::map* extra = nullptr) { + MplexFormat::Resolver base = s->resolver_for(session, window, pane, client); + MplexFormat::Resolver resolve = base; + if (extra && !extra->empty()) { + resolve = [base, extra](const std::string& name) -> std::optional { + auto it = extra->find(name); + if (it != extra->end()) return it->second; + return base(name); + }; + } + return MplexFormat::with_time(MplexFormat::expand(text, resolve)); + } + + struct ListRow { + SessionP session; + WindowP window; + PaneP pane; + ClientP client; + std::optional> extra; + }; + + std::string listing(const std::string& command, const MplexArgs& args, const std::vector& rows) { + auto f_template = args.get('F'); + std::string tmpl = truthy(f_template) ? *f_template : mplex_default_format(command); + auto flt = args.get('f'); + std::vector lines; + for (const ListRow& row : rows) { + const std::map* extra = row.extra ? &*row.extra : nullptr; + if (truthy(flt) && !MplexFormat::truthy(fmt(*flt, row.session, row.window, row.pane, row.client, extra))) continue; + lines.push_back(fmt(tmpl, row.session, row.window, row.pane, row.client, extra)); + } + return py::join("\n", lines); + } + + static std::string substitute(const std::string& tmpl, const std::vector& answers) { + std::string out; + bool in_quote = false; + size_t pos = 0; + while (pos < tmpl.size()) { + char ch = tmpl[pos]; + if (ch == '\\' && pos + 1 < tmpl.size()) { + out += tmpl.substr(pos, 2); + pos += 2; + continue; + } + if (ch == '"') in_quote = !in_quote; + if (ch == '%' && pos + 1 < tmpl.size()) { + char nxt = tmpl[pos + 1]; + long index = -1; + if (nxt == '%') + index = 0; + else if (nxt >= '1' && nxt <= '9') + index = nxt - '1'; + if (index >= 0) { + std::string value = static_cast(index) < answers.size() ? answers[static_cast(index)] : ""; + if (in_quote) { + value = py::replace(value, "\\", "\\\\"); + value = py::replace(value, "\"", "\\\""); + value = py::replace(value, "$", "\\$"); + } + out += value; + pos += 2; + continue; + } + } + out.push_back(ch); + ++pos; + } + return out; + } + + // Run template after a prompt finished, against the pane the prompt was opened for. + void run_later(const CtxHold& hold, const std::string& tmpl, const std::vector& answers) { + MplexCtx ctx = hold.get(); + PaneP pane = (ctx.pane && s->panes.contains(ctx.pane->id)) ? ctx.pane : nullptr; + MplexCtx fresh(s, ctx.client, pane, true); + fresh.depth = ctx.depth + 1; + s->run_checked(fresh, substitute(tmpl, answers)); + } + + std::vector sorted_sessions() { + std::vector out = s->sessions.values(); + std::stable_sort(out.begin(), out.end(), [](const SessionP& a, const SessionP& b) { return a->id < b->id; }); + return out; + } + + // ---- sessions and clients ---------------------------------------------------------------------- + + std::string cmd_new_session(MplexCtx& ctx, const MplexArgs& args) { + auto s_flag = args.get('s'); + std::string name = truthy(s_flag) ? *s_flag : s->auto_session_name(); + if (name.find(':') != std::string::npos || name.find('.') != std::string::npos) + throw MplexError("bad session name: " + name); + SessionP existing; + for (const SessionP& item : s->sessions.values()) { + if (item->name == name) { + existing = item; + break; + } + } + if (existing && args.has('A')) return attach(ctx, existing, args.has('D')); + if (existing) throw MplexError("duplicate session: " + name); + ClientP client = ctx.client; + auto c_flag = args.get('c'); + std::string cwd_raw; + if (truthy(c_flag)) { + cwd_raw = *c_flag; + } else if (client) { + cwd_raw = client->cwd; + } else { + char buf[4096]; + cwd_raw = ::getcwd(buf, sizeof(buf)) ? buf : ""; + } + std::string cwd = py::expanduser(cwd_raw); + EnvList env; + if (client && !client->env.empty()) { + env = client->env; + } else { + for (char** item = environ; item && *item; ++item) { + std::string entry = *item; + auto eq = entry.find('='); + if (eq == std::string::npos) continue; + env_set(env, entry.substr(0, eq), entry.substr(eq + 1)); + } + } + for (const std::string& item : args.all('e')) { + auto eq = item.find('='); + if (eq == std::string::npos) + env_set(env, item, ""); + else + env_set(env, item.substr(0, eq), item.substr(eq + 1)); + } + int status_row = s->options->get("status").truthy() ? 1 : 0; + std::pair size{80, 24 + status_row}; + if (truthy(args.get('x')) || truthy(args.get('y'))) { + size = {static_cast(*args.int_flag('x', 80)), static_cast(*args.int_flag('y', 24)) + status_row}; + } else if (client && !args.has('d')) { + size = {client->cols, client->rows}; + } + auto n_flag = args.get('n'); + SessionP session = s->create_session(name, cwd, env, args.positional, n_flag ? *n_flag : "", size); + if (!args.has('d') && client) s->attach_client(client, session); + if (args.has('P')) { + auto f = args.get('F'); + return fmt(truthy(f) ? *f : "#{session_name}:", session, session->window(), session->window()->active); + } + return ""; + } + + ClientP need_client_for_attach(MplexCtx& ctx) { + if (!ctx.client) throw MplexError("no client to attach"); + return ctx.client; + } + + std::string attach(MplexCtx& ctx, const SessionP& session, bool detach_others) { + ClientP client = need_client_for_attach(ctx); + if (detach_others) { + for (const ClientP& other : s->attached_clients(session)) { + if (other != client) s->detach_client(other, "[detached (from session " + session->name + ")]"); + } + } + s->attach_client(client, session); + return ""; + } + + std::string cmd_attach_session(MplexCtx& ctx, const MplexArgs& args) { + SessionP session; + if (truthy(args.get('t'))) { + session = MplexTargets::session(ctx, *args.get('t')); + } else { + session = s->latest_session(); + if (!session) throw MplexError("no sessions"); + } + return attach(ctx, session, args.has('d')); + } + + ClientP find_client(MplexCtx& ctx, const std::string& spec) { + if (spec.empty()) return need_client(ctx); + for (const ClientP& client : s->attached_clients()) { + if (spec == client->tty || spec == std::to_string(client->id) || spec == std::to_string(client->pid)) return client; + } + throw MplexError("can't find client: " + spec); + } + + std::string cmd_detach_client(MplexCtx& ctx, const MplexArgs& args) { + std::vector targets; + if (truthy(args.get('s'))) { + SessionP session = MplexTargets::session(ctx, *args.get('s')); + targets = s->attached_clients(session); + } else { + ClientP current = find_client(ctx, args.get('t', "")); + if (args.has('a')) { + for (const ClientP& c : s->attached_clients()) + if (c != current) targets.push_back(c); + } else { + targets = {current}; + } + } + for (const ClientP& client : targets) s->detach_client(client, "[detached (from session " + client->session->name + ")]"); + return ""; + } + + std::string cmd_has_session(MplexCtx& ctx, const MplexArgs& args) { + auto t = args.get('t'); + std::string spec = truthy(t) ? *t : (ctx.session ? ctx.session->name : ""); + MplexTargets::session(ctx, spec); + return ""; + } + + std::string cmd_kill_session(MplexCtx& ctx, const MplexArgs& args) { + SessionP target = session_target(ctx, args); + if (args.has('a')) { + for (const SessionP& session : s->sessions.values()) + if (session != target) s->kill_session(session); + } else { + s->kill_session(target); + } + return ""; + } + + std::string cmd_kill_server(MplexCtx&, const MplexArgs&) { + s->stop(); + return ""; + } + + std::string cmd_start_server(MplexCtx&, const MplexArgs&) { return ""; } + + std::string cmd_list_sessions(MplexCtx&, const MplexArgs& args) { + std::vector rows; + for (const SessionP& item : sorted_sessions()) rows.push_back({item, item->window(), item->window()->active, nullptr, std::nullopt}); + return listing("list-sessions", args, rows); + } + + std::string cmd_list_windows(MplexCtx& ctx, const MplexArgs& args) { + std::vector sessions; + if (args.has('a')) + sessions = sorted_sessions(); + else + sessions = {session_target(ctx, args)}; + std::vector rows; + for (const SessionP& sess : sessions) { + for (int i : sess->indexes()) { + WindowP window = *sess->windows.find(i); + rows.push_back({sess, window, window->active, nullptr, std::nullopt}); + } + } + return listing("list-windows", args, rows); + } + + std::string cmd_list_panes(MplexCtx& ctx, const MplexArgs& args) { + std::vector> windows; + if (args.has('a') || args.has('s')) { + std::vector sessions = args.has('a') ? sorted_sessions() : std::vector{session_target(ctx, args)}; + for (const SessionP& sess : sessions) + for (int i : sess->indexes()) windows.emplace_back(sess, *sess->windows.find(i)); + } else { + auto target = window_target(ctx, args); + windows.emplace_back(target.session, target.window); + } + std::vector rows; + for (auto& [sess, window] : windows) + for (const PaneP& pane : window->panes()) rows.push_back({sess, window, pane, nullptr, std::nullopt}); + return listing("list-panes", args, rows); + } + + std::string cmd_list_clients(MplexCtx&, const MplexArgs& args) { + std::vector rows; + for (const ClientP& c : s->attached_clients()) + rows.push_back({c->session, c->session->window(), c->session->window()->active, c, std::nullopt}); + return listing("list-clients", args, rows); + } + + std::string cmd_rename_session(MplexCtx& ctx, const MplexArgs& args) { + SessionP session = session_target(ctx, args); + const std::string& name = args.positional[0]; + if (name.empty() || name.find(':') != std::string::npos || name.find('.') != std::string::npos) + throw MplexError("bad session name: " + name); + for (const SessionP& item : s->sessions.values()) + if (item->name == name && item != session) throw MplexError("duplicate session: " + name); + session->name = name; + s->mark_all_dirty(); + return ""; + } + + std::string cmd_switch_client(MplexCtx& ctx, const MplexArgs& args) { + ClientP client = need_client(ctx); + if (args.has('T')) { + client->table = args.get('T').value(); + client->repeating = false; + return ""; + } + std::vector ordered = sorted_sessions(); + auto here_it = std::find(ordered.begin(), ordered.end(), client->session); + if (here_it == ordered.end()) throw ValueError("x is not in list"); + long long here = here_it - ordered.begin(); + long long count = static_cast(ordered.size()); + WindowP window; + SessionP target; + if (args.has('n')) { + target = ordered[static_cast(pymod(here + 1, count))]; + } else if (args.has('p')) { + target = ordered[static_cast(pymod(here - 1, count))]; + } else if (args.has('l')) { + auto it = last_session.find(client->id); + target = it == last_session.end() ? nullptr : it->second; + if (!target || !s->sessions.contains(target->id)) throw MplexError("no last session"); + } else if (truthy(args.get('t'))) { + std::string spec = *args.get('t'); + if (py::startswith(spec, "%")) { + auto found = MplexTargets::pane(ctx, spec); + target = found.session; + window = found.window; + } else if (spec.find(':') != std::string::npos || py::startswith(spec, "@")) { + auto found = MplexTargets::window(ctx, spec); + target = found.session; + window = found.window; + } else { + target = MplexTargets::session(ctx, spec); + } + } else { + throw MplexError("switch-client needs -n, -p, -l, -t or -T"); + } + SessionP previous = client->session; + s->attach_client(client, target); + if (target != previous) last_session[client->id] = previous; + if (window) { + auto idx = target->index_of(window); + if (idx) target->select(*idx); + } + return ""; + } + + std::string cmd_suspend_client(MplexCtx& ctx, const MplexArgs&) { + need_client(ctx)->send("Z"); + return ""; + } + + std::string cmd_refresh_client(MplexCtx& ctx, const MplexArgs&) { + ClientP client = need_client(ctx); + client->prev.reset(); + client->dirty = true; + return ""; + } + + std::string cmd_list_commands(MplexCtx&, const MplexArgs&) { + std::vector sorted; + for (const auto& spec : mplex_command_specs()) sorted.push_back(&spec); + std::sort(sorted.begin(), sorted.end(), [](const MplexCommandSpec* a, const MplexCommandSpec* b) { return a->name < b->name; }); + std::vector lines; + for (const MplexCommandSpec* spec : sorted) { + std::string alias = spec->aliases.empty() ? "" : " (" + spec->aliases[0] + ")"; + std::string flags; + if (!spec->spec.empty()) flags = "[" + py::strip(py::replace(spec->spec, ":", " arg")) + "]"; + lines.push_back(spec->name + alias + " " + flags); + } + return py::join("\n", lines); + } + + // ---- windows -------------------------------------------------------------------------------------- + + std::string cmd_new_window(MplexCtx& ctx, const MplexArgs& args) { + std::string spec = args.get('t', ""); + SessionP session = ctx.session; + std::optional index; + WindowP anchor; + auto starts_special = [&](const std::string& text) { + for (const char* prefix : {"@", "%", "+", "-", "!", "^", "$", "{"}) + if (py::startswith(text, prefix)) return true; + return false; + }; + if (!spec.empty() && !py::endswith(spec, ":") && spec.find(':') == std::string::npos && + !is_digits(py::lstrip(spec, "=")) && !starts_special(spec)) { + bool has_window = ctx.session && MplexTargets::window_index(ctx.session, spec).has_value(); + if (!has_window) spec = spec + ":"; + } + if (!spec.empty() && py::endswith(spec, ":")) { + session = MplexTargets::session(ctx, spec.substr(0, spec.size() - 1)); + } else if (!spec.empty() && is_new_index(ctx, spec)) { + auto colon = spec.rfind(':'); + std::string sess_part = spec.substr(0, colon); + std::string idx_part = spec.substr(colon + 1); + session = !sess_part.empty() ? MplexTargets::session(ctx, sess_part) : ctx.session; + index = static_cast(py::to_int(py::lstrip(idx_part, "="))); + } else if (!spec.empty()) { + auto found = MplexTargets::window(ctx, spec); + session = found.session; + index = found.index; + anchor = found.window; + } + if (!session) throw MplexError("no current session"); + if (!index) { + index = session->free_index(static_cast(s->options->get("base-index").i)); + std::optional current = session->current; + if ((args.has('a') || args.has('b')) && current) { + index = args.has('a') ? *current + 1 : *current; + make_room(session, *index); + } + } else if (anchor) { + if (args.has('a')) { + *index += 1; + make_room(session, *index); + } else if (args.has('b')) { + make_room(session, *index); + } else if (args.has('k')) { + s->kill_window(anchor); + } else { + throw MplexError("index in use: " + std::to_string(*index)); + } + } + auto c_flag = args.get('c'); + std::string cwd = py::expanduser(truthy(c_flag) ? *c_flag : session->cwd); + EnvList env_extra = env_from_args(args); + if (args.has('S')) { + auto n_flag = args.get('n'); + for (int idx : session->indexes()) { + if (n_flag && (*session->windows.find(idx))->name == *n_flag) { + session->select(idx); + return ""; + } + } + } + auto n_flag = args.get('n'); + WindowP created = s->create_window(session, n_flag ? *n_flag : "", cwd, args.positional, &env_extra, *index, !args.has('d')); + if (args.has('P')) { + auto f = args.get('F'); + return fmt(truthy(f) ? *f : "#{session_name}:#{window_index}.#{pane_index}", session, created, created->active); + } + return ""; + } + + // -t names an index that does not exist yet (the usual new-window -t :N case). + bool is_new_index(MplexCtx& ctx, const std::string& spec) { + auto colon = spec.rfind(':'); + if (colon == std::string::npos) return false; + std::string sess_part = spec.substr(0, colon); + std::string idx_part = py::lstrip(spec.substr(colon + 1), "="); + if (!is_digits(idx_part)) return false; + SessionP session; + try { + session = !sess_part.empty() ? MplexTargets::session(ctx, sess_part) : ctx.session; + } catch (const MplexError&) { + return false; + } + if (!session) throw ValueError("'NoneType' object has no attribute 'windows'"); + return !session->windows.contains(static_cast(py::to_int(idx_part))); + } + + void make_room(const SessionP& session, int index) { + if (!session->windows.contains(index)) return; + WindowP current = session->window(); + OrderedMap moved; + for (int idx : session->indexes()) moved.set(idx >= index ? idx + 1 : idx, *session->windows.find(idx)); + session->windows = moved; + session->current = current ? session->index_of(current) : std::nullopt; + } + + std::string cmd_kill_window(MplexCtx& ctx, const MplexArgs& args) { + auto target = window_target(ctx, args); + if (args.has('a')) { + std::vector others; + for (int i : target.session->indexes()) + if (i != *target.index) others.push_back(*target.session->windows.find(i)); + for (const WindowP& other : others) s->kill_window(other); + } else { + s->kill_window(target.window); + } + return ""; + } + + std::string cmd_select_window(MplexCtx& ctx, const MplexArgs& args) { + if (args.has('n') || args.has('p')) return step_window(ctx, args, args.has('n') ? 1 : -1); + if (args.has('l')) return cmd_last_window(ctx, args); + auto target = window_target(ctx, args); + SessionP session = target.session; + int index = *target.index; + if (args.has('T') && session->current && index == *session->current) { + auto last = session->last(); + if (!last) throw MplexError("no last window"); + session->select(*last); + s->mark_dirty(); + return ""; + } + session->select(index); + s->mark_dirty(); + return ""; + } + + std::string step_window(MplexCtx& ctx, const MplexArgs& args, int step) { + SessionP session = MplexTargets::session(ctx, before_colon(args.get('t'))); + if (args.has('a')) throw MplexError(std::string("no ") + (step > 0 ? "next" : "previous") + " window"); + std::vector indexes = session->indexes(); + if (!session->current) throw ValueError("None is not in list"); + auto it = std::find(indexes.begin(), indexes.end(), *session->current); + if (it == indexes.end()) throw ValueError("x is not in list"); + long long here = it - indexes.begin(); + session->select(indexes[static_cast(pymod(here + step, static_cast(indexes.size())))]); + s->mark_dirty(); + return ""; + } + + std::string cmd_next_window(MplexCtx& ctx, const MplexArgs& args) { return step_window(ctx, args, 1); } + std::string cmd_previous_window(MplexCtx& ctx, const MplexArgs& args) { return step_window(ctx, args, -1); } + + std::string cmd_last_window(MplexCtx& ctx, const MplexArgs& args) { + SessionP session = MplexTargets::session(ctx, args.get('t', "")); + auto last = session->last(); + if (!last || !session->windows.contains(*last)) throw MplexError("no last window"); + session->select(*last); + s->mark_dirty(); + return ""; + } + + std::string cmd_rename_window(MplexCtx& ctx, const MplexArgs& args) { + auto target = window_target(ctx, args); + if (args.positional[0].empty()) throw MplexError("empty window name"); + target.window->name = py::slice(args.positional[0], 0, 64); + target.window->auto_rename = false; + s->mark_all_dirty(); + return ""; + } + + std::pair parse_window_destination(MplexCtx& ctx, const std::string& spec) { + SessionP session; + std::string idx_part; + if (spec.find(':') != std::string::npos) { + auto colon = spec.rfind(':'); + std::string sess_part = spec.substr(0, colon); + idx_part = spec.substr(colon + 1); + session = !sess_part.empty() ? MplexTargets::session(ctx, sess_part) : ctx.session; + } else { + session = ctx.session; + idx_part = spec; + } + idx_part = py::lstrip(idx_part, "="); + if (!is_digits(py::lstrip(idx_part, "-"))) throw MplexError("bad window index: " + idx_part); + return {session, static_cast(py::to_int(idx_part))}; + } + + std::string cmd_move_window(MplexCtx& ctx, const MplexArgs& args) { + if (args.has('r')) { + s->renumber(MplexTargets::session(ctx, before_colon(args.get('t')))); + s->mark_all_dirty(); + return ""; + } + auto src = MplexTargets::window(ctx, args.get('s', "")); + SessionP src_session = src.session; + int src_index = *src.index; + WindowP window = src.window; + if (!truthy(args.get('t'))) throw MplexError("move-window needs -t"); + auto dest = parse_window_destination(ctx, *args.get('t')); + SessionP dst_session = dest.first; + int idx = dest.second; + if (args.has('a')) { + idx += 1; + make_room(dst_session, idx); + } else if (args.has('b')) { + make_room(dst_session, idx); + } else if (dst_session->windows.contains(idx) && *dst_session->windows.find(idx) != window) { + if (args.has('k')) + s->kill_window(*dst_session->windows.find(idx)); + else + throw MplexError("index in use: " + std::to_string(idx)); + } + if (dst_session == src_session && idx == src_index) return ""; + bool was_current = src_session->current && *src_session->current == src_index; + src_session->windows.erase(src_index); + std::vector kept; + for (const WindowP& item : src_session->history) + if (item != window) kept.push_back(item); + src_session->history = kept; + window->set_session(dst_session); + window->options.parent = &dst_session->options; + dst_session->windows.set(idx, window); + if (was_current && !src_session->windows.empty() && src_session != dst_session) + src_session->current = src_session->indexes()[0]; + else if (was_current && src_session == dst_session) + src_session->current = idx; + if (!args.has('d')) dst_session->select(idx); + if (src_session->windows.empty()) + s->kill_session(src_session); + else + s->resize_session(src_session); + s->resize_session(dst_session); + return ""; + } + + std::string cmd_swap_window(MplexCtx& ctx, const MplexArgs& args) { + auto src = MplexTargets::window(ctx, args.get('s', "")); + auto dst = MplexTargets::window(ctx, args.get('t', "")); + WindowP was0 = src.session->window(); + WindowP was1 = dst.session->window(); + src.session->windows.set(*src.index, dst.window); + dst.session->windows.set(*dst.index, src.window); + src.window->set_session(dst.session); + dst.window->set_session(src.session); + src.window->options.parent = &dst.session->options; + dst.window->options.parent = &src.session->options; + if (was0) src.session->current = src.session->index_of(was0); + if (was1) dst.session->current = dst.session->index_of(was1); + s->resize_session(src.session); + s->resize_session(dst.session); + return ""; + } + + std::string cmd_find_window(MplexCtx& ctx, const MplexArgs& args) { + const std::string& text = args.positional[0]; + std::string fields = std::string(args.has('C') ? "C" : "") + (args.has('N') ? "N" : "") + (args.has('T') ? "T" : ""); + if (fields.empty()) fields = "CNT"; + std::function matcher; + if (args.has('r')) { + try { + auto regex = std::make_shared(to_ustr(text), args.has('i')); + matcher = [regex](const std::string& value) { return regex->search(to_ustr(value)).found; }; + } catch (const py::RegexError& exc) { + throw MplexError(std::string("bad regular expression: ") + exc.what()); + } + } else { + std::string pattern = "*" + text + "*"; + bool fold = args.has('i'); + std::string low = fold ? py::lower(pattern) : pattern; + matcher = [fold, low](const std::string& value) { return py::fnmatchcase(fold ? py::lower(value) : value, low); }; + } + struct Hit { + SessionP session; + int index; + WindowP window; + }; + std::vector found; + for (const SessionP& session : sorted_sessions()) { + for (int index : session->indexes()) { + WindowP window = *session->windows.find(index); + bool hit = false; + if (fields.find('N') != std::string::npos && matcher(window->name)) hit = true; + for (const PaneP& pane : window->panes()) { + if (fields.find('T') != std::string::npos && matcher(pane->title())) hit = true; + if (fields.find('C') != std::string::npos && matcher(pane->screen.text())) hit = true; + } + if (hit) found.push_back({session, index, window}); + } + } + if (found.empty()) throw MplexError("no windows matching: " + text); + if (found.size() == 1) { + ClientP client = ctx.client; + if (client && client->attached && client->session != found[0].session) s->attach_client(client, found[0].session); + found[0].session->select(found[0].index); + s->mark_dirty(); + return ""; + } + std::vector> items; + for (const Hit& hit : found) { + items.emplace_back(hit.session->name + ":" + std::to_string(hit.index) + ": " + hit.window->name + + s->window_flags(hit.session, hit.window), + Json::array({Json::string("window"), Json::integer(hit.window->id)})); + } + auto builder = [items]() { + std::vector out; + for (const auto& item : items) out.push_back(std::make_shared(item.first, item.second)); + return out; + }; + return open_chooser(ctx, "tree-mode", builder, args); + } + + // ---- panes ------------------------------------------------------------------------------------------- + + static long long size_arg(const std::string& raw, long long total) { + std::string text = py::strip(raw); + if (py::endswith(text, "%")) { + long long pct = py::to_int(text.substr(0, text.size() - 1)); + return std::max(1, pyfloordiv(total * pct, 100)); + } + return py::to_int(text); + } + + std::string cmd_split_window(MplexCtx& ctx, const MplexArgs& args) { + auto target = pane_target(ctx, args); + SessionP session = target.session; + WindowP window = target.window; + PaneP pane_target_ = target.pane; + std::string kind = args.has('h') ? "lr" : "tb"; + MplexLayoutNode::Ptr leaf = window->leaf_of(pane_target_); + int size = -1; + if (truthy(args.get('l'))) { + if (!leaf) throw ValueError("'NoneType' object has no attribute 'w'"); + try { + long long value = size_arg(*args.get('l'), kind == "lr" ? leaf->w : leaf->h); + size = static_cast(std::max(0, value)); + } catch (const ValueError&) { + throw MplexError("bad size: " + *args.get('l')); + } + } + auto c_flag = args.get('c'); + std::string cwd = py::expanduser(truthy(c_flag) ? *c_flag : session->cwd); + EnvList env_extra = env_from_args(args); + PaneP pane = s->split_pane(window, pane_target_, kind, args.has('b'), args.positional, cwd, &env_extra, size, !args.has('d')); + if (args.has('Z') && !args.has('d')) { + window->zoomed = pane; + window->relayout(); + } + if (args.has('P')) { + auto f = args.get('F'); + return fmt(truthy(f) ? *f : "#{session_name}:#{window_index}.#{pane_index}", session, window, pane); + } + return ""; + } + + std::string cmd_kill_pane(MplexCtx& ctx, const MplexArgs& args) { + auto target = pane_target(ctx, args); + if (args.has('a')) { + for (const PaneP& other : target.window->panes()) + if (other != target.pane) s->kill_pane(other); + } else { + s->kill_pane(target.pane); + } + return ""; + } + + std::string cmd_select_pane(MplexCtx& ctx, const MplexArgs& args) { + if (args.has('e') || args.has('d')) throw MplexError("select-pane -e/-d (pane input switch) is not supported by mplex"); + if (args.has('M')) { + s->marked = nullptr; + s->mark_all_dirty(); + return ""; + } + auto target = pane_target(ctx, args); + SessionP session = target.session; + WindowP window = target.window; + PaneP pane = target.pane; + if (args.has('m')) { + s->marked = (s->marked == pane) ? nullptr : pane; + s->mark_all_dirty(); + return ""; + } + if (args.get('T').has_value()) { + pane->screen.title = py::slice(*args.get('T'), 0, 256); + s->mark_all_dirty(); + return ""; + } + PaneP fresh = pane; + for (char flag : std::string("UDLR")) { + if (args.has(flag)) { + PaneP found = MplexLayout::neighbour(window->zoomed ? window->visible_panes() : window->panes(), pane, flag, + window->width, window->height); + if (!found) throw MplexError("no pane in that direction"); + fresh = found; + } + } + if (args.has('l')) { + if (!contains_pane(window->panes(), window->last_pane)) throw MplexError("no last pane"); + fresh = window->last_pane; + } + bool keep_zoom = args.has('Z') && window->zoomed; + PaneP zoomed = window->zoomed; + s->select_pane(window, fresh); + if (keep_zoom && zoomed) { + window->zoomed = fresh; + window->relayout(); + } + if (session->window() != window) { + auto idx = session->index_of(window); + if (idx) session->select(*idx); + } + return ""; + } + + std::string cmd_last_pane(MplexCtx& ctx, const MplexArgs& args) { + auto target = MplexTargets::window(ctx, args.get('t', "")); + WindowP window = target.window; + if (!contains_pane(window->panes(), window->last_pane)) throw MplexError("no last pane"); + s->select_pane(window, window->last_pane); + return ""; + } + + std::string cmd_resize_pane(MplexCtx& ctx, const MplexArgs& args) { + if (args.has('M') || args.has('T')) throw MplexError("resize-pane -M/-T is not supported by mplex"); + auto target = pane_target(ctx, args); + WindowP window = target.window; + PaneP pane = target.pane; + if (args.has('Z')) { + if (window->zoomed) + window->zoomed = nullptr; + else if (window->panes().size() > 1) + window->zoomed = pane; + window->relayout(); + s->mark_dirty(window); + return ""; + } + MplexLayoutNode::Ptr leaf = window->leaf_of(pane); + long long amount = 1; + if (!args.positional.empty()) { + try { + amount = py::to_int(args.positional[0]); + } catch (const ValueError&) { + throw MplexError("bad adjustment: " + args.positional[0]); + } + } + try { + if (truthy(args.get('x'))) MplexLayout::resize_to(leaf, "lr", static_cast(size_arg(*args.get('x'), window->width))); + if (truthy(args.get('y'))) MplexLayout::resize_to(leaf, "tb", static_cast(size_arg(*args.get('y'), window->height))); + if (args.has('R')) + MplexLayout::resize_by(leaf, "lr", static_cast(amount), true); + else if (args.has('L')) + MplexLayout::resize_by(leaf, "lr", static_cast(-amount), false); + else if (args.has('D')) + MplexLayout::resize_by(leaf, "tb", static_cast(amount), true); + else if (args.has('U')) + MplexLayout::resize_by(leaf, "tb", static_cast(-amount), false); + } catch (const MplexError&) { + } + window->layout_index = -1; + window->relayout(); + s->mark_dirty(window); + return ""; + } + + std::string cmd_swap_pane(MplexCtx& ctx, const MplexArgs& args) { + PaneP src, dst; + if (args.has('U') || args.has('D')) { + auto target = pane_target(ctx, args); + dst = target.pane; + std::vector panes = target.window->panes(); + long long here = index_of_pane(panes, dst); + long long count = static_cast(panes.size()); + src = args.has('U') ? panes[static_cast(pymod(here - 1, count))] : panes[static_cast(pymod(here + 1, count))]; + } else { + dst = pane_target(ctx, args).pane; + if (truthy(args.get('s'))) + src = MplexTargets::pane(ctx, *args.get('s')).pane; + else if (s->marked) + src = s->marked; + else + throw MplexError("no marked pane"); + } + if (src == dst) return ""; + WindowP wa = src->window(), wb = dst->window(); + MplexLayoutNode::Ptr leaf_a = wa->leaf_of(src), leaf_b = wb->leaf_of(dst); + leaf_a->pane = dst; + leaf_b->pane = src; + src->set_window(wb); + dst->set_window(wa); + if (wa != wb) { + if (wa->active == src) wa->active = dst; + if (wb->active == dst) wb->active = src; + } + if (wa == wb) { + wa->relayout(); + } else { + wa->relayout(); + wb->relayout(); + } + s->marked = nullptr; + s->mark_all_dirty(); + return ""; + } + + std::string cmd_rotate_window(MplexCtx& ctx, const MplexArgs& args) { + auto target = window_target(ctx, args); + WindowP window = target.window; + std::vector panes = window->panes(); + if (panes.size() < 2) return ""; + std::vector order; + if (args.has('D')) { + order.push_back(panes.back()); + order.insert(order.end(), panes.begin(), panes.end() - 1); + } else { + order.insert(order.end(), panes.begin() + 1, panes.end()); + order.push_back(panes.front()); + } + long position = index_of_pane(panes, window->active); + std::vector leaves = window->root->leaves(); + for (size_t i = 0; i < leaves.size() && i < order.size(); ++i) leaves[i]->pane = order[i]; + window->active = order[static_cast(position)]; + window->relayout(); + s->mark_dirty(window); + return ""; + } + + std::string cmd_break_pane(MplexCtx& ctx, const MplexArgs& args) { + auto source = MplexTargets::pane(ctx, args.get('s', "")); + SessionP session = source.session; + WindowP window = source.window; + PaneP pane = source.pane; + if (window->panes().size() < 2) throw MplexError("can't break with only one pane"); + SessionP dst_session = session; + int index = dst_session->free_index(static_cast(s->options->get("base-index").i)); + if (truthy(args.get('t'))) { + auto dest = parse_window_destination(ctx, *args.get('t')); + dst_session = dest.first; + index = dest.second; + if (dst_session->windows.contains(index)) throw MplexError("index in use: " + std::to_string(index)); + } + MplexLayoutNode::Ptr leaf = window->leaf_of(pane); + bool was_active = window->active == pane; + window->root = MplexLayout::remove(window->root, leaf); + if (window->zoomed == pane) window->zoomed = nullptr; + std::vector rest = window->panes(); + if (was_active || window->active == pane) + window->active = contains_pane(rest, window->last_pane) ? window->last_pane : rest[0]; + if (window->last_pane == pane) window->last_pane = nullptr; + window->relayout(); + std::pair size = dst_session->size ? *dst_session->size : std::pair{80, 24}; + int cols = size.first, rows = size.second; + auto n_flag = args.get('n'); + std::string name; + if (truthy(n_flag)) { + name = *n_flag; + } else { + name = pane->foreground_command(); + if (name.empty()) name = pane->shell_name; + } + auto fresh = std::make_shared(s->next_id("window"), name, cols, std::max(1, rows - (s->options->get("status").truthy() ? 1 : 0)), + &dst_session->options); + fresh->auto_rename = !truthy(n_flag); + fresh->root = std::make_shared("pane", pane, fresh->width, fresh->height); + fresh->active = pane; + pane->set_window(fresh); + s->register_window(dst_session, fresh, index, !args.has('d')); + fresh->relayout(); + s->mark_all_dirty(); + if (args.has('P')) { + auto f = args.get('F'); + return fmt(truthy(f) ? *f : "#{session_name}:#{window_index}.#{pane_index}", dst_session, fresh, pane); + } + return ""; + } + + void apply_layout(const WindowP& window, const std::string& name, int index) { + std::vector panes = window->panes(); + MplexOptions& opts = window->options; + window->zoomed = nullptr; + window->root = MplexLayout::preset(name, panes, window->width, window->height, static_cast(opts.get("main-pane-width").i), + static_cast(opts.get("main-pane-height").i)); + window->layout_index = index; + window->relayout(); + s->mark_dirty(window); + } + + std::string cmd_select_layout(MplexCtx& ctx, const MplexArgs& args) { + auto target = pane_target(ctx, args); + WindowP window = target.window; + const std::vector& names = mplex_layout_names(); + if (args.has('E')) { + MplexLayoutNode::Ptr leaf = window->leaf_of(target.pane); + MplexLayoutNode::Ptr parent = leaf->parent_node(); + if (parent) { + for (const auto& child : parent->children) MplexLayout::set_size(child, parent->kind, 1); + window->relayout(); + s->mark_dirty(window); + } + return ""; + } + if (!args.positional.empty()) { + const std::string& wanted = args.positional[0]; + std::vector matches; + for (const auto& item : names) + if (item == wanted) matches.push_back(item); + if (matches.empty()) + for (const auto& item : names) + if (py::startswith(item, wanted)) matches.push_back(item); + if (matches.size() != 1) throw MplexError("unknown or ambiguous layout: " + wanted); + int idx = static_cast(std::find(names.begin(), names.end(), matches[0]) - names.begin()); + apply_layout(window, matches[0], idx); + return ""; + } + long long count = static_cast(names.size()); + long long index; + if (args.has('p')) + index = pymod(window->layout_index - 1, count); + else if (args.has('o')) + index = window->layout_index >= 0 ? window->layout_index : 0; + else + index = pymod(window->layout_index + 1, count); + apply_layout(window, names[static_cast(index)], static_cast(index)); + return ""; + } + + std::string cmd_next_layout(MplexCtx& ctx, const MplexArgs& args) { + auto target = window_target(ctx, args); + const auto& names = mplex_layout_names(); + int idx = static_cast(pymod(target.window->layout_index + 1, static_cast(names.size()))); + apply_layout(target.window, names[static_cast(idx)], idx); + return ""; + } + + std::string cmd_previous_layout(MplexCtx& ctx, const MplexArgs& args) { + auto target = window_target(ctx, args); + const auto& names = mplex_layout_names(); + int step = static_cast(pymod(target.window->layout_index - 1, static_cast(names.size()))); + apply_layout(target.window, names[static_cast(step)], step); + return ""; + } + + std::string cmd_clear_history(MplexCtx& ctx, const MplexArgs& args) { + pane_target(ctx, args).pane->clear_history(); + return ""; + } + + std::string cmd_capture_pane(MplexCtx& ctx, const MplexArgs& args) { + PaneP pane = pane_target(ctx, args).pane; + MplexVtScreen& screen = pane->screen; + if (args.has('a') && !screen.alt_active()) { + if (args.has('q')) return ""; + throw MplexError("no alternate screen"); + } + std::vector rows; + if (screen.history) + for (const Row& row : screen.history->rows) rows.push_back(&row); + long long base = static_cast(rows.size()); + for (const Row& row : screen.grid) rows.push_back(&row); + bool keep_blanks = args.has('N'); + auto line_of = [&](const Row& row) { + UStr text; + for (const Cell& cell : row) text.push_back(cell.present() ? cell.ch : L' '); + std::string out = to_utf8(text); + return keep_blanks ? out : py::rstrip(out); + }; + // bound(value, default): nullopt stands for Python None ("-") + auto bound = [&](const std::optional& value, long long fallback) -> std::optional { + if (!value.has_value()) return fallback; + if (*value == "-") return std::nullopt; + try { + return base + py::to_int(*value); + } catch (const ValueError&) { + throw MplexError("bad line number: " + *value); + } + }; + std::optional start_opt = bound(args.get('S'), base); + std::optional end_opt = bound(args.get('E'), base + pane->h - 1); + long long start = start_opt ? std::max(0, *start_opt) : 0; + long long end = end_opt ? std::min(static_cast(rows.size()) - 1, *end_opt) : static_cast(rows.size()) - 1; + std::vector lines; + for (long long i = start; i <= end; ++i) lines.push_back(line_of(*rows[static_cast(i)])); + std::string text = py::join("\n", lines); + if (args.has('p')) return text; + s->add_buffer(text + "\n", args.get('b')); + return ""; + } + + // ---- keys, prompts, input ---------------------------------------------------------------------------- + + std::string cmd_send_prefix(MplexCtx& ctx, const MplexArgs& args) { + auto target = pane_target(ctx, args); + std::string name = target.session->options.get(args.has('2') ? "prefix2" : "prefix").s; + if (name == "None") throw MplexError("no prefix key set"); + s->forward(target.pane, target.window, MplexKeys::to_bytes(name, target.pane->screen.app_cursor)); + return ""; + } + + std::string cmd_send_keys(MplexCtx& ctx, const MplexArgs& args) { + auto target = pane_target(ctx, args); + SessionP session = target.session; + WindowP window = target.window; + PaneP pane = target.pane; + std::vector words = args.positional; + std::optional count = args.int_flag('N', std::nullopt); + if (args.has('M')) throw MplexError("send-keys -M (mouse) is not supported by mplex"); + if (args.has('X')) { + auto mode = std::dynamic_pointer_cast(pane->top_mode()); + if (!mode) throw MplexError("pane is not in a mode"); + if (words.empty()) throw MplexError("send-keys -X needs a command"); + if (words.size() > 1 && words[1] == "--") words.erase(words.begin() + 1); + if (args.has('F')) + for (auto& item : words) item = fmt(item, session, window, pane, ctx.client); + if (count && *count != 0) pane->prefix_count = static_cast(*count); + std::vector rest(words.begin() + 1, words.end()); + mode->command(words[0], rest, ctx.client.get()); + return ""; + } + if (args.has('R')) pane->screen.reset(); + if (words.empty()) { + if (count && *count != 0) pane->prefix_count = static_cast(*count); + return ""; + } + if (args.has('F')) + for (auto& item : words) item = fmt(item, session, window, pane, ctx.client); + Bytes data; + if (args.has('l')) { + for (const auto& word : words) data += word; + } else if (args.has('H')) { + for (const auto& item : words) { + int value = 0; + if (!parse_hex_byte(item, value)) throw MplexError("bad hex byte in send-keys -H"); + data.push_back(static_cast(value)); + } + } else { + for (const auto& word : words) { + try { + data += MplexKeys::to_bytes(MplexKeys::canonical(word), pane->screen.app_cursor); + } catch (const MplexError&) { + data += word; + } + } + } + auto mode = pane->top_mode(); + long long repeat = count && *count != 0 ? *count : 1; + repeat = std::max(1, std::min(repeat, 1000)); + std::shared_ptr scratch; + for (long long i = 0; i < repeat; ++i) { + if (mode && !args.has('l') && !args.has('H')) { + auto split = MplexKeys::split(data, true); + for (const auto& [name, raw] : split.tokens) { + if (!ctx.client && !scratch) scratch = std::make_shared(s, -1, -1); + mode->key(name, raw, ctx.client ? *ctx.client : *scratch); + } + } else if (!pane->dead) { + pane->write(data); + } + } + s->mark_dirty(window); + return ""; + } + + std::string cmd_bind_key(MplexCtx&, const MplexArgs& args) { + std::string table = args.has('n') ? "root" : (truthy(args.get('T')) ? *args.get('T') : "prefix"); + std::string key = MplexKeys::canonical(args.positional[0]); + std::vector words(args.positional.begin() + 1, args.positional.end()); + if (words.empty()) throw MplexError("bind-key needs a command"); + std::vector quoted; + for (const auto& word : words) quoted.push_back(MplexCommandParser::quote(word)); + std::string command = py::join(" ", quoted); + for (const auto& parsed : MplexCommandParser::parse(command)) MplexCommands::resolve(parsed[0]); + auto n_flag = args.get('N'); + s->config->bind(table, key, command, args.has('r'), truthy(n_flag) ? *n_flag : ""); + return ""; + } + + std::string cmd_unbind_key(MplexCtx&, const MplexArgs& args) { + std::string table = args.has('n') ? "root" : (truthy(args.get('T')) ? *args.get('T') : "prefix"); + auto& config = s->config; + if (args.has('a')) { + config->tables.set(table, MplexConfig::Table()); + return ""; + } + if (args.positional.empty()) throw MplexError("unbind-key needs a key (or -a)"); + std::string key = MplexKeys::canonical(args.positional[0]); + if (!config->unbind(table, key) && !args.has('q')) throw MplexError("unknown key: " + args.positional[0]); + return ""; + } + + std::string cmd_list_keys(MplexCtx& ctx, const MplexArgs& args) { + auto& config = s->config; + MplexOptions& opts = ctx.session ? ctx.session->options : *s->options; + std::optional only; + if (!args.positional.empty()) only = MplexKeys::canonical(args.positional[0]); + std::vector tables; + if (truthy(args.get('T'))) + tables = {*args.get('T')}; + else + tables = config->tables.keys(); + std::vector lines; + if (args.has('N')) { + std::string prefix = opts.get("prefix").s; + std::vector> rows; + std::vector note_tables; + if (truthy(args.get('T'))) + note_tables = {*args.get('T')}; + else + note_tables = {"root", "prefix"}; + for (const std::string& table : note_tables) { + const MplexConfig::Table* found = config->tables.find(table); + if (!found) continue; + for (const auto& [key, binding] : found->items()) { + if (only && key != *only) continue; + std::string note = !binding.note.empty() ? binding.note : (args.has('a') ? binding.command : ""); + if (note.empty()) continue; + std::string label = table == "root" ? key : prefix + " " + key; + if (args.get('P').has_value()) label = *args.get('P') + key; + rows.emplace_back(label, note); + } + } + if (only && rows.empty()) throw MplexError("unknown key: " + args.positional[0]); + if (args.has('1') && rows.size() > 1) rows.resize(1); + size_t width = 0; + for (const auto& row : rows) width = std::max(width, py::len(row.first)); + std::vector out; + for (const auto& row : rows) out.push_back(py::ljust(row.first, width) + " " + row.second); + return py::join("\n", out); + } + for (const std::string& table : tables) { + const MplexConfig::Table* found = config->tables.find(table); + if (!found) continue; + for (const auto& [key, binding] : found->items()) { + if (only && key != *only) continue; + lines.push_back(std::string("bind-key ") + (binding.repeat ? "-r" : " ") + " -T " + py::ljust(table, 12) + " " + + py::ljust(key, 12) + " " + binding.command); + } + } + if (args.has('1') && !lines.empty()) lines.resize(1); + return py::join("\n", lines); + } + + // One command-prompt run: answers collect across the -p prompts, then the template runs. + struct PromptChain { + Impl* impl; + std::shared_ptr hold; + std::shared_ptr> answers = std::make_shared>(); + std::optional tmpl; + std::vector prompts, inputs; + size_t count = 1; + bool incremental = false, single = false, numeric = false, key_name = false; + MplexPrompt::History history; + std::shared_ptr copy_mode; + std::weak_ptr client; + + void ask(const std::shared_ptr& self, size_t position) { + ClientP target = client.lock(); + if (!target) return; + std::string label = !prompts.empty() ? prompts[position] + " " : ":"; + std::string initial = position < inputs.size() ? inputs[position] : ""; + auto done = [self, position](const std::string& value) { + self->answers->push_back(value); + if (position + 1 < self->count) { + self->ask(self, position + 1); + return; + } + if (!self->tmpl) + self->impl->run_later(*self->hold, "%%", *self->answers); + else + self->impl->run_later(*self->hold, *self->tmpl, *self->answers); + }; + MplexPromptOptions options; + options.history = history; + options.single = single; + options.numeric = numeric; + options.key_name = key_name; + if (incremental && tmpl) { + options.on_change = [self](const std::string& text) { self->impl->run_later(*self->hold, *self->tmpl, {"=" + text}); }; + options.on_step = [self](const std::string& name, const std::string& text) { + self->impl->run_later(*self->hold, *self->tmpl, {std::string(name == "C-s" ? "+" : "-") + text}); + }; + if (copy_mode) { + std::shared_ptr mode = copy_mode; + options.on_cancel = [mode]() { mode->search_cancel(); }; + } + } + target->prompt = std::make_shared(label, initial, done, options); + target->dirty = true; + } + }; + + std::string cmd_command_prompt(MplexCtx& ctx, const MplexArgs& args) { + ClientP client = need_client(ctx); + auto chain = std::make_shared(); + chain->impl = this; + chain->hold = std::make_shared(ctx); + if (!args.positional.empty()) chain->tmpl = args.positional[0]; + std::vector prompts, inputs; + if (truthy(args.get('p'))) prompts = py::split(*args.get('p'), ","); + if (truthy(args.get('I'))) inputs = py::split(*args.get('I'), ","); + for (auto& item : prompts) item = MplexFormat::strip_styles(fmt(item, ctx.session, ctx.window, ctx.pane, client)); + for (auto& item : inputs) item = fmt(item, ctx.session, ctx.window, ctx.pane, client); + if (chain->tmpl && args.has('F')) chain->tmpl = fmt(*chain->tmpl, ctx.session, ctx.window, ctx.pane, client); + chain->prompts = prompts; + chain->inputs = inputs; + std::string kind = truthy(args.get('T')) ? *args.get('T') : "command"; + auto& slot = s->prompt_history[kind]; + if (!slot) slot = std::make_shared>(); + chain->history = slot; + chain->count = std::max(1, prompts.size()); + chain->incremental = args.has('i'); + chain->single = args.has('1'); + chain->numeric = args.has('N'); + chain->key_name = args.has('k'); + chain->client = client; + if (ctx.pane) chain->copy_mode = ctx.pane->find_mode(); + chain->ask(chain, 0); + return ""; + } + + std::string cmd_confirm_before(MplexCtx& ctx, const MplexArgs& args) { + ClientP client = need_client(ctx); + const std::string& command = args.positional[0]; + auto p_flag = args.get('p'); + std::string text = truthy(p_flag) ? *p_flag : "Confirm '" + command + "'? (y/n)"; + text = fmt(text, ctx.session, ctx.window, ctx.pane, client); + auto hold = std::make_shared(ctx); + std::weak_ptr weak_client = client; + MplexServer* server = s; + int depth = ctx.depth; + PaneP pane = ctx.pane; + auto run = [server, weak_client, pane, depth, command]() { + ClientP target = weak_client.lock(); + if (!target) return; + MplexCtx fresh(server, target, (pane && server->panes.contains(pane->id)) ? pane : nullptr, true); + fresh.depth = depth + 1; + server->run_checked(fresh, command); + }; + s->ask_confirm(client, text, run); + return ""; + } + + std::string cmd_display_message(MplexCtx& ctx, const MplexArgs& args) { + auto target = pane_target(ctx, args); + std::string tmpl = !args.positional.empty() ? args.positional[0] : mplex_default_format("display-message"); + std::string text = fmt(tmpl, target.session, target.window, target.pane, ctx.client); + text = MplexFormat::strip_styles(text); + if (args.has('p')) return text; + ClientP client = ctx.client; + if (client && client->attached) { + std::optional delay = args.int_flag('d', std::nullopt); + s->message(client, text, delay ? std::optional(static_cast(*delay)) : std::nullopt); + } + return ""; + } + + std::string cmd_display_panes(MplexCtx& ctx, const MplexArgs& args) { + ClientP client = need_client(ctx); + WindowP window = client->session->window(); + std::optional given = args.int_flag('d', std::nullopt); + long long duration = (given && *given != 0) ? *given : window->options.get("display-panes-time").i; + client->overlay = std::make_shared(s, window, py::monotonic() + static_cast(duration) / 1000.0); + client->dirty = true; + return ""; + } + + std::string cmd_clock_mode(MplexCtx& ctx, const MplexArgs& args) { + auto target = pane_target(ctx, args); + if (!target.pane->find_mode()) target.pane->push_mode(std::make_shared(s, target.pane)); + s->mark_dirty(target.window); + return ""; + } + + std::string cmd_copy_mode(MplexCtx& ctx, const MplexArgs& args) { + if (args.has('M')) throw MplexError("copy-mode -M (mouse) is not supported by mplex"); + auto target = pane_target(ctx, args); + PaneP pane = target.pane; + auto existing = pane->find_mode(); + if (args.has('q')) { + if (existing) existing->close(); + return ""; + } + if (!existing) { + existing = std::make_shared(s, pane, nullptr, false, args.has('e'), !args.has('H')); + pane->push_mode(existing); + } + if (args.has('u')) existing->command("page-up", {}, ctx.client.get()); + s->mark_dirty(target.window); + return ""; + } + + std::string cmd_show_messages(MplexCtx&, const MplexArgs&) { + std::vector lines; + for (const auto& [stamp, text] : s->messages) lines.push_back(py::strftime("%H:%M:%S", stamp) + " " + text); + return py::join("\n", lines); + } + + // ---- buffers ------------------------------------------------------------------------------------------- + + std::vector buffer_rows() { + std::vector rows; + for (const MplexBuffer& item : s->buffers) { + std::string sample = py::replace(py::slice(item.data, 0, 50), "\n", " "); + std::map extra{ + {"buffer_name", item.name}, {"buffer_size", std::to_string(item.data.size())}, {"buffer_sample", sample}}; + rows.push_back({nullptr, nullptr, nullptr, nullptr, extra}); + } + return rows; + } + + std::string cmd_list_buffers(MplexCtx&, const MplexArgs& args) { return listing("list-buffers", args, buffer_rows()); } + + std::string cmd_set_buffer(MplexCtx&, const MplexArgs& args) { + std::string name = args.get('b', ""); + if (truthy(args.get('n'))) { + MplexBuffer& item = s->find_buffer(name); + item.name = *args.get('n'); + return ""; + } + if (args.positional.empty()) throw MplexError("set-buffer needs data"); + const std::string& data = args.positional[0]; + if (args.has('a')) { + MplexBuffer& item = s->find_buffer(name); + item.data = py::slice(item.data + data, 0, MPLEX_PASTE_MAX); + } else { + s->add_buffer(data, args.get('b')); + } + return ""; + } + + std::string cmd_show_buffer(MplexCtx&, const MplexArgs& args) { return s->find_buffer(args.get('b', "")).data; } + + void remove_buffer(const std::string& name, const std::string& data) { + for (size_t i = 0; i < s->buffers.size(); ++i) { + if (s->buffers[i].name == name && s->buffers[i].data == data) { + s->buffers.erase(s->buffers.begin() + static_cast(i)); + return; + } + } + throw ValueError("list.remove(x): x not in list"); + } + + std::string cmd_delete_buffer(MplexCtx&, const MplexArgs& args) { + MplexBuffer item = s->find_buffer(args.get('b', "")); + remove_buffer(item.name, item.data); + s->refresh_choosers(); + return ""; + } + + std::string cmd_save_buffer(MplexCtx&, const MplexArgs& args) { + MplexBuffer item = s->find_buffer(args.get('b', "")); + std::string path = py::expanduser(args.positional[0]); + int fd = ::open(path.c_str(), O_WRONLY | O_CREAT | (args.has('a') ? O_APPEND : O_TRUNC), 0666); + if (fd < 0) throw MplexError("cannot write " + path + ": " + short_text(oserror_text(errno, path))); + size_t done = 0; + while (done < item.data.size()) { + ssize_t put = ::write(fd, item.data.data() + done, item.data.size() - done); + if (put < 0) { + if (errno == EINTR) continue; + int err = errno; + ::close(fd); + throw MplexError("cannot write " + path + ": " + short_text(std::string("[Errno ") + std::to_string(err) + "] " + std::strerror(err))); + } + done += static_cast(put); + } + ::close(fd); + return ""; + } + + std::string cmd_load_buffer(MplexCtx&, const MplexArgs& args) { + std::string path = py::expanduser(args.positional[0]); + int fd = ::open(path.c_str(), O_RDONLY); + if (fd < 0) throw MplexError("cannot read " + path + ": " + short_text(oserror_text(errno, path))); + struct stat info; + if (::fstat(fd, &info) == 0 && S_ISDIR(info.st_mode)) { + ::close(fd); + throw MplexError("cannot read " + path + ": " + short_text(oserror_text(EISDIR, path))); + } + Bytes data; + char buf[65536]; + while (data.size() < static_cast(MPLEX_PASTE_MAX)) { + size_t want = std::min(sizeof(buf), static_cast(MPLEX_PASTE_MAX) - data.size()); + ssize_t got = ::read(fd, buf, want); + if (got < 0) { + if (errno == EINTR) continue; + int err = errno; + ::close(fd); + throw MplexError("cannot read " + path + ": " + short_text(std::string("[Errno ") + std::to_string(err) + "] " + std::strerror(err))); + } + if (got == 0) break; + data.append(buf, static_cast(got)); + } + ::close(fd); + s->add_buffer(to_utf8(to_ustr(data)), args.get('b')); + return ""; + } + + std::string cmd_paste_buffer(MplexCtx& ctx, const MplexArgs& args) { + auto target = pane_target(ctx, args); + PaneP pane = target.pane; + MplexBuffer item = s->find_buffer(args.get('b', "")); + std::string separator = args.get('s').has_value() ? *args.get('s') : "\r"; + std::string text = args.has('r') ? item.data : py::replace(item.data, "\n", separator); + Bytes data = text; + if (args.has('p') && pane->screen.bracketed_paste) data = "\x1b[200~" + data + "\x1b[201~"; + if (!pane->dead) pane->write(data); + if (args.has('d')) remove_buffer(item.name, item.data); + return ""; + } + + // ---- choosers --------------------------------------------------------------------------------------------- + + std::string open_chooser(MplexCtx& ctx, const std::string& kind, MplexChooser::Builder builder, const MplexArgs& args, + MplexChooser::OnChoose on_choose = nullptr, MplexChooser::OnDelete on_delete = nullptr, + MplexChooser::Preview preview = nullptr, std::optional selected = std::nullopt) { + PaneP pane = ctx.pane; + if (!pane) throw MplexError("no pane"); + WindowP window = pane->window(); + if (!window) throw ValueError("'NoneType' object has no attribute 'zoomed'"); + bool zoom_on_open = args.has('Z') && !window->zoomed && window->panes().size() > 1; + if (zoom_on_open) { + window->zoomed = pane; + window->relayout(); + } + std::weak_ptr weak_pane = pane; + std::weak_ptr weak_window = window; + auto restore = [zoom_on_open, weak_pane, weak_window]() { + auto p = weak_pane.lock(); + auto w = weak_window.lock(); + if (zoom_on_open && p && w && w->zoomed == p) { + w->zoomed = nullptr; + w->relayout(); + } + }; + MplexServer* server = s; + auto choose = [server](const MplexChoice::Ptr& node, MplexClientConn& client_ref) { + MplexClientConn* client = &client_ref; + const auto& payload = node->payload.as_array(); + std::string kind_tag = payload[0].as_string(); + int ident = static_cast(payload[1].as_int()); + auto shared_client = client->shared_from_this(); + if (kind_tag == "window") { + WindowP* found = server->windows.find(ident); + if (!found) return; + WindowP target = *found; + SessionP target_session = target->session(); + if (client->session != target_session) server->attach_client(shared_client, target_session); + auto idx = target_session->index_of(target); + if (idx) target_session->select(*idx); + } else if (kind_tag == "pane") { + PaneP* found = server->panes.find(ident); + if (!found || !(*found)->window()) return; + PaneP target = *found; + WindowP target_window = target->window(); + SessionP target_session = target_window->session(); + if (client->session != target_session) server->attach_client(shared_client, target_session); + auto idx = target_session->index_of(target_window); + if (idx) target_session->select(*idx); + server->select_pane(target_window, target); + } else if (kind_tag == "session") { + SessionP* found = server->sessions.find(ident); + if (found) server->attach_client(shared_client, *found); + } + server->mark_all_dirty(); + }; + auto mode = std::make_shared(s, pane, kind, builder, on_choose ? on_choose : MplexChooser::OnChoose(choose), on_delete, + preview, selected, restore); + pane->push_mode(mode); + s->mark_dirty(window); + return ""; + } + + std::string cmd_choose_tree(MplexCtx& ctx, const MplexArgs& args) { + MplexServer* server = s; + bool sessions_only = args.has('s'); + bool windows_collapsed = args.has('w'); + auto builder = [server, sessions_only, windows_collapsed]() { + std::vector items; + std::vector sessions = server->sessions.values(); + std::stable_sort(sessions.begin(), sessions.end(), [](const SessionP& a, const SessionP& b) { return a->id < b->id; }); + for (const SessionP& session : sessions) { + std::vector kids; + for (int index : session->indexes()) { + WindowP window = *session->windows.find(index); + std::vector panes; + long number = 0; + for (const PaneP& pane : window->panes()) { + panes.push_back(std::make_shared( + std::to_string(number + window->options.get("pane-base-index").i) + ": " + pane->foreground_command() + " \"" + + pane->title() + "\"" + (pane == window->active ? "*" : ""), + Json::array({Json::string("pane"), Json::integer(pane->id)}))); + ++number; + } + kids.push_back(std::make_shared( + std::to_string(index) + ": " + window->name + server->window_flags(session, window) + " (" + std::to_string(panes.size()) + + " panes) [" + std::to_string(window->width) + "x" + std::to_string(window->height) + "]", + Json::array({Json::string("window"), Json::integer(window->id)}), panes, + !(windows_collapsed || sessions_only) && panes.size() > 1)); + } + std::string attached = server->attached_clients(session).empty() ? "" : " (attached)"; + items.push_back(std::make_shared( + session->name + ": " + std::to_string(session->windows.size()) + " windows" + attached, + Json::array({Json::string("session"), Json::integer(session->id)}), kids, !sessions_only)); + } + return items; + }; + auto preview = [server](const MplexChoice::Ptr& node, int width, int height) { + const auto& payload = node->payload.as_array(); + std::string kind_tag = payload[0].as_string(); + int ident = static_cast(payload[1].as_int()); + PaneP pane; + if (kind_tag == "pane") { + PaneP* found = server->panes.find(ident); + if (found) pane = *found; + } else if (kind_tag == "window") { + WindowP* window = server->windows.find(ident); + if (window) pane = (*window)->active; + } else { + SessionP* session = server->sessions.find(ident); + if (session) pane = (*session)->window()->active; + } + Grid out; + if (!pane) return out; + const Grid& grid = pane->screen.grid; + for (size_t r = 0; r < grid.size() && static_cast(r) < height; ++r) { + Row row; + for (size_t c = 0; c < grid[r].size() && static_cast(c) < width; ++c) + row.push_back(grid[r][c].present() ? grid[r][c] : blank_cell()); + out.push_back(row); + } + return out; + }; + auto del = [server](const MplexChoice::Ptr& node, MplexClientConn&) { + const auto& payload = node->payload.as_array(); + std::string kind_tag = payload[0].as_string(); + int ident = static_cast(payload[1].as_int()); + if (kind_tag == "session" && server->sessions.contains(ident)) + server->kill_session(*server->sessions.find(ident)); + else if (kind_tag == "window" && server->windows.contains(ident)) + server->kill_window(*server->windows.find(ident)); + else if (kind_tag == "pane" && server->panes.contains(ident)) + server->kill_pane(*server->panes.find(ident)); + }; + std::optional selected; + if (ctx.window) selected = Json::array({Json::string("window"), Json::integer(ctx.window->id)}); + return open_chooser(ctx, "tree-mode", builder, args, nullptr, del, preview, selected); + } + + std::string cmd_choose_buffer(MplexCtx& ctx, const MplexArgs& args) { + MplexServer* server = s; + PaneP pane = ctx.pane; + auto builder = [server]() { + std::vector out; + for (const MplexBuffer& item : server->buffers) { + out.push_back(std::make_shared( + item.name + ": " + std::to_string(py::len(item.data)) + " bytes: \"" + py::replace(py::slice(item.data, 0, 50), "\n", " ") + "\"", + Json::array({Json::string("buffer"), Json::string(item.name)}))); + } + return out; + }; + std::weak_ptr weak_pane = pane; + auto choose = [server, weak_pane](const MplexChoice::Ptr& node, MplexClientConn&) { + PaneP target = weak_pane.lock(); + if (target && server->panes.contains(target->id) && !target->dead) { + MplexBuffer item = server->find_buffer(node->payload.as_array()[1].as_string()); + target->write(py::replace(item.data, "\n", "\r")); + } + }; + auto del = [server](const MplexChoice::Ptr& node, MplexClientConn&) { + const std::string& name = node->payload.as_array()[1].as_string(); + std::vector kept; + for (const auto& item : server->buffers) + if (item.name != name) kept.push_back(item); + server->buffers = kept; + }; + auto preview = [server](const MplexChoice::Ptr& node, int width, int height) { + const std::string& name = node->payload.as_array()[1].as_string(); + std::string data; + for (const auto& item : server->buffers) { + if (item.name == name) { + data = item.data; + break; + } + } + Grid out; + for (const std::string& line : py::split(data, "\n")) { + if (static_cast(out.size()) >= height) break; + Row row; + for (wchar_t ch : to_ustr(py::slice(line, 0, width))) row.push_back(Cell{ch, 0, 0, 0}); + out.push_back(row); + } + return out; + }; + if (s->buffers.empty()) throw MplexError("no buffers"); + return open_chooser(ctx, "buffer-mode", builder, args, choose, del, preview); + } + + std::string cmd_choose_client(MplexCtx& ctx, const MplexArgs& args) { + MplexServer* server = s; + auto builder = [server]() { + std::vector out; + for (const ClientP& c : server->attached_clients()) { + std::string who = !c->tty.empty() ? c->tty : std::to_string(c->id); + out.push_back(std::make_shared( + who + ": " + c->session->name + " [" + std::to_string(c->cols) + "x" + std::to_string(c->rows) + " " + c->term + "]", + Json::array({Json::string("client"), Json::integer(c->id)}))); + } + return out; + }; + auto choose = [server](const MplexChoice::Ptr& node, MplexClientConn&) { + int id = static_cast(node->payload.as_array()[1].as_int()); + for (const ClientP& target : server->attached_clients()) + if (target->id == id) server->detach_client(target, "[detached (from session " + target->session->name + ")]"); + }; + return open_chooser(ctx, "client-mode", builder, args, choose, choose); + } + + // ---- options, shell, files ------------------------------------------------------------------------------------ + + std::string option_name(const std::string& name) { + if (MplexOptions::find_def(name)) return name; + std::vector matches; + for (const OptionDef& item : MplexOptions::defs()) + if (py::startswith(item.name, name)) matches.push_back(item.name); + if (matches.size() == 1) return matches[0]; + throw MplexError("unknown or unsupported option: " + name); + } + + MplexOptions* option_scope(MplexCtx& ctx, const MplexArgs& args, bool window_scope) { + if (args.has('g') || args.has('s')) return s->options; + if (window_scope || args.has('w')) return &MplexTargets::window(ctx, args.get('t', "")).window->options; + return &MplexTargets::session(ctx, before_colon(args.get('t')))->options; + } + + std::string set_option(MplexCtx& ctx, const MplexArgs& args, bool window_scope) { + std::string name; + try { + name = option_name(args.positional[0]); + } catch (const MplexError&) { + if (args.has('q')) return ""; + throw; + } + MplexOptions* scope = option_scope(ctx, args, window_scope); + if (args.has('u')) { + scope->unset(name); + } else { + if (args.positional.size() < 2) { + scope->toggle(name); + } else { + std::string value = args.positional[1]; + if (args.has('F')) value = fmt(value, ctx.session, ctx.window, ctx.pane, ctx.client); + if (args.has('o') && scope->is_set(name)) throw MplexError("already set: " + name); + scope->set(name, value, args.has('a')); + } + } + s->resize_all(); + s->mark_all_dirty(); + return ""; + } + + std::string cmd_set_option(MplexCtx& ctx, const MplexArgs& args) { return set_option(ctx, args, false); } + std::string cmd_set_window_option(MplexCtx& ctx, const MplexArgs& args) { return set_option(ctx, args, true); } + + std::string show_options(MplexCtx& ctx, const MplexArgs& args, bool window_scope) { + MplexOptions* scope = option_scope(ctx, args, window_scope); + std::vector names; + if (!args.positional.empty()) { + names = {option_name(args.positional[0])}; + } else { + for (const OptionDef& item : MplexOptions::defs()) names.push_back(item.name); + std::sort(names.begin(), names.end()); + } + std::vector lines; + for (const std::string& name : names) { + if (!args.positional.empty() || args.has('A') || args.has('g') || args.has('s') || scope->is_set(name)) { + OptValue value = scope->get(name); + std::string text = MplexOptions::show(value); + if (args.has('v')) + lines.push_back(text); + else + lines.push_back(name + " " + (value.is_str() ? MplexCommandParser::quote(text) : text)); + } + } + return py::join("\n", lines); + } + + std::string cmd_show_options(MplexCtx& ctx, const MplexArgs& args) { return show_options(ctx, args, false); } + std::string cmd_show_window_options(MplexCtx& ctx, const MplexArgs& args) { return show_options(ctx, args, true); } + + std::pair shell_in(MplexCtx& ctx, const std::string& command, const PaneP& pane) { + std::optional cwd; + if (pane) { + std::string path = pane->current_path(); + if (!path.empty()) cwd = path; + } + const EnvList* env = ctx.session ? &ctx.session->env : nullptr; + ShellResult result = run_sh(command, cwd, env); + return {result.returncode, to_utf8(to_ustr(result.out))}; + } + + std::string cmd_if_shell(MplexCtx& ctx, const MplexArgs& args) { + auto target = pane_target(ctx, args); + const std::string& condition = args.positional[0]; + bool ok; + if (args.has('F')) + ok = MplexFormat::truthy(fmt(condition, target.session, target.window, target.pane, ctx.client)); + else + ok = shell_in(ctx, fmt(condition, target.session, target.window, target.pane, ctx.client), target.pane).first == 0; + std::string chosen = ok ? args.positional[1] : (args.positional.size() > 2 ? args.positional[2] : ""); + if (chosen.empty()) return ""; + return s->run_line(ctx, chosen, ctx.depth + 1); + } + + std::string cmd_run_shell(MplexCtx& ctx, const MplexArgs& args) { + auto target = pane_target(ctx, args); + if (truthy(args.get('d'))) throw MplexError("run-shell -d (delay) is not supported by mplex"); + if (args.positional.empty()) return ""; + std::string command = fmt(args.positional[0], target.session, target.window, target.pane, ctx.client); + if (args.has('C')) return s->run_line(ctx, command, ctx.depth + 1); + if (args.has('b')) { + run_sh_detached(command, target.pane->current_path()); + return ""; + } + return py::rstrip(shell_in(ctx, command, target.pane).second, "\n"); + } + + std::string cmd_source_file(MplexCtx& ctx, const MplexArgs& args) { + std::vector out; + for (const std::string& raw : args.positional) { + std::string path = py::expanduser(raw); + struct stat info; + if (::stat(path.c_str(), &info) != 0) { + if (args.has('q')) continue; + throw MplexError("no such file: " + raw); + } + if (py::endswith(path, ".toml")) { + size_t before = static_cast(s->config->skipped); + s->config->apply_file(path); + for (size_t i = before; i < s->config->errors.size(); ++i) s->log_message("mplex.toml: " + s->config->errors[i]); + int skipped = s->config->skipped - static_cast(before); + if (skipped) out.push_back(path + ": " + std::to_string(skipped) + " problem(s) skipped, see show-messages"); + s->resize_all(); + s->mark_all_dirty(); + continue; + } + int fd = ::open(path.c_str(), O_RDONLY); + if (fd < 0) throw MplexError("cannot read " + raw + ": " + short_text(oserror_text(errno, path))); + if (::fstat(fd, &info) == 0 && S_ISDIR(info.st_mode)) { + ::close(fd); + throw MplexError("cannot read " + raw + ": " + short_text(oserror_text(EISDIR, path))); + } + Bytes data; + char buf[65536]; + size_t cap = static_cast(MPLEX_CONFIG_MAX) * 4; + while (data.size() < cap) { + ssize_t got = ::read(fd, buf, std::min(sizeof(buf), cap - data.size())); + if (got < 0) { + if (errno == EINTR) continue; + int err = errno; + ::close(fd); + throw MplexError("cannot read " + raw + ": " + short_text(std::string("[Errno ") + std::to_string(err) + "] " + std::strerror(err))); + } + if (got == 0) break; + data.append(buf, static_cast(got)); + } + ::close(fd); + UStr decoded = to_ustr(data); // strict decoding is Python's; invalid bytes are replaced here + if (decoded.size() > static_cast(MPLEX_CONFIG_MAX)) decoded.resize(static_cast(MPLEX_CONFIG_MAX)); + std::vector lines = py::splitlines(to_utf8(decoded)); + int number = 0; + for (const std::string& line : lines) { + ++number; + std::string stripped = py::strip(line); + if (stripped.empty() || py::startswith(stripped, "#")) continue; + std::string text; + try { + text = s->run_line(ctx, line, ctx.depth + 1); + } catch (const MplexError& exc) { + throw MplexError(raw + ":" + std::to_string(number) + ": " + exc.what()); + } + if (!text.empty()) out.push_back(text); + } + } + return py::join("\n", out); + } +}; + +const std::map& MplexCommands::Impl::table() { + static const std::map handlers = { + {"attach-session", &Impl::cmd_attach_session}, + {"bind-key", &Impl::cmd_bind_key}, + {"break-pane", &Impl::cmd_break_pane}, + {"capture-pane", &Impl::cmd_capture_pane}, + {"choose-buffer", &Impl::cmd_choose_buffer}, + {"choose-client", &Impl::cmd_choose_client}, + {"choose-tree", &Impl::cmd_choose_tree}, + {"clear-history", &Impl::cmd_clear_history}, + {"clock-mode", &Impl::cmd_clock_mode}, + {"command-prompt", &Impl::cmd_command_prompt}, + {"confirm-before", &Impl::cmd_confirm_before}, + {"copy-mode", &Impl::cmd_copy_mode}, + {"delete-buffer", &Impl::cmd_delete_buffer}, + {"detach-client", &Impl::cmd_detach_client}, + {"display-message", &Impl::cmd_display_message}, + {"display-panes", &Impl::cmd_display_panes}, + {"find-window", &Impl::cmd_find_window}, + {"has-session", &Impl::cmd_has_session}, + {"if-shell", &Impl::cmd_if_shell}, + {"kill-pane", &Impl::cmd_kill_pane}, + {"kill-server", &Impl::cmd_kill_server}, + {"kill-session", &Impl::cmd_kill_session}, + {"kill-window", &Impl::cmd_kill_window}, + {"last-pane", &Impl::cmd_last_pane}, + {"last-window", &Impl::cmd_last_window}, + {"list-buffers", &Impl::cmd_list_buffers}, + {"list-clients", &Impl::cmd_list_clients}, + {"list-commands", &Impl::cmd_list_commands}, + {"list-keys", &Impl::cmd_list_keys}, + {"list-panes", &Impl::cmd_list_panes}, + {"list-sessions", &Impl::cmd_list_sessions}, + {"list-windows", &Impl::cmd_list_windows}, + {"load-buffer", &Impl::cmd_load_buffer}, + {"move-window", &Impl::cmd_move_window}, + {"new-session", &Impl::cmd_new_session}, + {"new-window", &Impl::cmd_new_window}, + {"next-layout", &Impl::cmd_next_layout}, + {"next-window", &Impl::cmd_next_window}, + {"paste-buffer", &Impl::cmd_paste_buffer}, + {"previous-layout", &Impl::cmd_previous_layout}, + {"previous-window", &Impl::cmd_previous_window}, + {"refresh-client", &Impl::cmd_refresh_client}, + {"rename-session", &Impl::cmd_rename_session}, + {"rename-window", &Impl::cmd_rename_window}, + {"resize-pane", &Impl::cmd_resize_pane}, + {"rotate-window", &Impl::cmd_rotate_window}, + {"run-shell", &Impl::cmd_run_shell}, + {"save-buffer", &Impl::cmd_save_buffer}, + {"select-layout", &Impl::cmd_select_layout}, + {"select-pane", &Impl::cmd_select_pane}, + {"select-window", &Impl::cmd_select_window}, + {"send-keys", &Impl::cmd_send_keys}, + {"send-prefix", &Impl::cmd_send_prefix}, + {"set-buffer", &Impl::cmd_set_buffer}, + {"set-option", &Impl::cmd_set_option}, + {"set-window-option", &Impl::cmd_set_window_option}, + {"show-buffer", &Impl::cmd_show_buffer}, + {"show-messages", &Impl::cmd_show_messages}, + {"show-options", &Impl::cmd_show_options}, + {"show-window-options", &Impl::cmd_show_window_options}, + {"source-file", &Impl::cmd_source_file}, + {"split-window", &Impl::cmd_split_window}, + {"start-server", &Impl::cmd_start_server}, + {"suspend-client", &Impl::cmd_suspend_client}, + {"swap-pane", &Impl::cmd_swap_pane}, + {"swap-window", &Impl::cmd_swap_window}, + {"switch-client", &Impl::cmd_switch_client}, + {"unbind-key", &Impl::cmd_unbind_key}, + }; + return handlers; +} + +MplexCommands::MplexCommands(MplexServer* server) : impl_(new Impl(server)) {} +MplexCommands::~MplexCommands() = default; + +std::string MplexCommands::resolve(const std::string& word) { + if (mplex_find_spec(word)) return word; + for (const auto& spec : mplex_command_specs()) { + if (std::find(spec.aliases.begin(), spec.aliases.end(), word) != spec.aliases.end()) return spec.name; + } + const auto& unsupported = mplex_unsupported(); + if (std::find(unsupported.begin(), unsupported.end(), word) != unsupported.end()) + throw MplexError("command not supported by mplex: " + word); + std::vector matches; + for (const auto& spec : mplex_command_specs()) + if (py::startswith(spec.name, word)) matches.push_back(spec.name); + if (matches.size() == 1) return matches[0]; + if (matches.size() > 1) { + std::sort(matches.begin(), matches.end()); + if (matches.size() > 5) matches.resize(5); + throw MplexError("ambiguous command: " + word + " (" + py::join(", ", matches) + ")"); + } + throw MplexError("unknown command: " + word); +} + +std::string MplexCommands::call(const std::string& name, MplexCtx& ctx, const MplexArgs& args) { + const auto& handlers = Impl::table(); + auto it = handlers.find(name); + if (it == handlers.end()) throw ValueError("'MplexCommands' object has no attribute 'cmd_" + py::replace(name, "-", "_") + "'"); + return (impl_.get()->*(it->second))(ctx, args); +} + +} // namespace detz::mplex diff --git a/taicpp/tests/mplex_commands_pty.py b/taicpp/tests/mplex_commands_pty.py new file mode 100644 index 0000000..5fdad5d --- /dev/null +++ b/taicpp/tests/mplex_commands_pty.py @@ -0,0 +1,253 @@ +#!/usr/bin/env python3 +"""Differential pty test for the commands that need an attached client (F6). + +A real client runs under a real pty against the C++ server and against tai.py's server, the same +key bytes (prefix, prompts, choosers, confirmations) are typed into both, and after every step the +server state read back through `--mplex ` must be identical (times, homes normalised). + + python3 tests/mplex_commands_pty.py [workdir] +""" +import fcntl +import os +import pty +import re +import select +import shlex +import shutil +import signal +import struct +import subprocess +import sys +import termios +import time + +PREFIX = b"\x02" +ENTER = b"\r" +ESC = b"\x1b" +UP = b"\x1b[A" +DOWN = b"\x1b[B" + +STEPS = [ + ("cli", "new-session -d -s other -x 80 -y 24 'sleep 600'"), + ("attach", None), + ("keys", PREFIX + b"c"), + ("keys", PREFIX + b","), + ("keys", b"\x15renamed" + ENTER), + ("keys", PREFIX + b"%"), + ("keys", PREFIX + b'"'), + ("keys", PREFIX + b"o"), + ("keys", PREFIX + b"z"), + ("keys", PREFIX + b"z"), + ("keys", PREFIX + b"x"), + ("keys", b"n"), + ("keys", PREFIX + b"x"), + ("keys", b"y"), + ("keys", PREFIX + b":"), + ("keys", b"split-window -h" + ENTER), + ("keys", PREFIX + b":"), + ("keys", b"nosuchcommand" + ENTER), + ("keys", PREFIX + b":"), + ("keys", b"set-option -g status-left 'X ' ; rename-window viaprompt" + ENTER), + ("keys", PREFIX + b"$"), + ("keys", b"\x15sess2" + ENTER), + ("keys", PREFIX + b"&"), + ("keys", b"y"), + ("keys", PREFIX + b"'"), + ("keys", b"0" + ENTER), + ("keys", PREFIX + b"("), + ("keys", PREFIX + b")"), + ("keys", PREFIX + b"L"), + ("keys", PREFIX + b"l"), + ("keys", PREFIX + b"n"), + ("keys", PREFIX + b"p"), + ("keys", PREFIX + b"q"), + ("keys", b"1"), + ("keys", PREFIX + b"q"), + ("keys", b"x"), + ("keys", PREFIX + b"w"), + ("keys", DOWN + DOWN + b"j"), + ("keys", ENTER), + ("keys", PREFIX + b"s"), + ("keys", b"j" + ENTER), + ("keys", PREFIX + b"w"), + ("keys", b"q"), + ("keys", PREFIX + b"="), + ("keys", PREFIX + b"i"), + ("keys", PREFIX + b"t"), + ("keys", b"x"), + ("cli", "set-buffer clip"), + ("keys", PREFIX + b"="), + ("keys", ENTER), + ("keys", PREFIX + b"]"), + ("keys", PREFIX + b"["), + ("keys", b"k"), + ("keys", ESC), + ("keys", PREFIX + b"["), + ("keys", PREFIX + b"f"), + ("keys", b"renamed" + ENTER), + ("keys", PREFIX + b"r"), + ("keys", PREFIX + b"~"), + ("keys", b"q"), + ("keys", PREFIX + b"?"), + ("keys", b"q"), + ("keys", PREFIX + b"/"), + ("keys", b"c"), + ("keys", PREFIX + b"D"), + ("keys", b"q"), + ("keys", PREFIX + b"{"), + ("keys", PREFIX + b"}"), + ("keys", PREFIX + b"\x0f"), + ("keys", PREFIX + b"\x1bo" if False else PREFIX + b"!"), + ("keys", PREFIX + b" "), + ("keys", PREFIX + b"E"), + ("keys", PREFIX + b"\x1b1"), + ("keys", PREFIX + b"\x1b5"), + ("keys", PREFIX + b"m"), + ("keys", PREFIX + b"M"), + ("keys", PREFIX + b"\x1b[1;5A"), + ("keys", PREFIX + b"\x1b[A"), + ("keys", PREFIX + b"\x02"), + ("cli", "display-panes"), + ("cli", "command-prompt"), + ("cli", "switch-client -n"), + ("keys", PREFIX + b"d"), + ("cli", "list-clients"), +] + +SNAPSHOT = [ + "list-sessions -F '#{session_name}:#{session_windows}:#{session_attached}'", + "list-windows -a -F '#{session_name}:#{window_index}:#{window_id}#{window_flags}:#{window_panes}:#{window_zoomed_flag}'", + "list-panes -a -F '#{session_name}:#{window_index}.#{pane_index} #{pane_width}x#{pane_height} #{pane_active} #{pane_mode} #{pane_marked}'", + "list-clients -F '#{client_key_table} #{session_name} #{client_prefix}'", + "list-buffers -F '#{buffer_name}:#{buffer_size}'", + "show-options -g status-left", +] + + +class Impl: + def __init__(self, name, cli, base): + self.name = name + self.cli = cli + self.home = os.path.join(base, name + "-home") + self.tmp = os.path.join(base, name + "-tmp") + self.pid = None + self.fd = None + + def env(self): + env = {k: v for k, v in os.environ.items() if k not in ("TMUX", "TAI_PLEXER_ACTIVE", "TAI_PLEXER_SOCKET", "TAI_PLEXER_PANE")} + env.update(TAI_HOME=self.home, HOME=self.tmp, SHELL="/bin/sh", TERM="xterm", LANG="C.UTF-8") + return env + + def run(self, line): + done = subprocess.run(self.cli + " " + line, shell=True, env=self.env(), stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=60) + return done.stdout.decode("utf-8", "replace"), done.returncode + + def reset(self): + try: + self.run("kill-server") + except subprocess.TimeoutExpired: + pass + time.sleep(0.2) + shutil.rmtree(self.home, ignore_errors=True) + shutil.rmtree(self.tmp, ignore_errors=True) + os.makedirs(self.home) + os.makedirs(self.tmp) + + def attach(self): + pid, fd = pty.fork() + if pid == 0: + fcntl.ioctl(0, termios.TIOCSWINSZ, struct.pack("HHHH", 24, 80, 0, 0)) + os.chdir(self.tmp) + os.execvpe("/bin/sh", ["sh", "-c", self.cli + " new-session -s main 'sleep 600'"], self.env()) + self.pid, self.fd = pid, fd + fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", 24, 80, 0, 0)) + + def drain(self, seconds): + end = time.time() + seconds + while time.time() < end: + ready, _w, _x = select.select([self.fd], [], [], 0.05) if self.fd is not None else ([], [], []) + if ready: + try: + if not os.read(self.fd, 65536): + self.fd = None + except OSError: + self.fd = None + + def type(self, data): + if self.fd is not None: + os.write(self.fd, data) + + def close(self): + if self.pid: + try: + os.kill(self.pid, signal.SIGKILL) + os.waitpid(self.pid, 0) + except OSError: + pass + self.pid = None + if self.fd is not None: + try: + os.close(self.fd) + except OSError: + pass + self.fd = None + + +def normalise(text, impl): + text = text.replace(impl.home, "").replace(impl.tmp, "") + text = re.sub(r"\d\d:\d\d:\d\d", "HH:MM:SS", text) + text = re.sub(r"/dev/pts/\d+", "", text) + return text + + +def main(): + cli_bin = os.path.abspath(sys.argv[1]) + root = os.path.abspath(sys.argv[2]) + base = os.path.abspath(sys.argv[3] if len(sys.argv) > 3 else "/tmp/mplex_commands_pty_work") + os.makedirs(base, exist_ok=True) + boot = "import sys; sys.path.insert(0, %r); import tai; sys.exit(tai.main())" % root + cpp = Impl("cpp", shlex.quote(cli_bin) + " --mplex", base) + py = Impl("py", "python3 -I -X pycache_prefix=%s -c %s --mplex" % (shlex.quote(os.path.join(base, "pyc")), shlex.quote(boot)), base) + impls = [cpp, py] + for impl in impls: + impl.reset() + # the server and the client must start from the same minimal state: the client creates "main" + checks = 0 + failures = 0 + for index, (kind, arg) in enumerate(STEPS, 1): + outcomes = [] + for impl in impls: + if kind == "attach": + impl.attach() + impl.drain(1.0) + elif kind == "keys": + impl.type(arg) + impl.drain(0.5) + elif kind == "cli": + text, rc = impl.run(arg) + outcomes.append(("cli", normalise(text, impl), rc)) + impl.drain(0.2) + if kind in ("keys", "attach", "cli"): + snap = [] + for line in SNAPSHOT: + text, rc = impl.run(line) + snap.append(normalise(text, impl) + "|rc=%d" % rc) + outcomes.append(("snap", "\n".join(snap), 0)) + checks += 1 + if outcomes[: len(outcomes) // 2] != outcomes[len(outcomes) // 2:]: + failures += 1 + half = len(outcomes) // 2 + print("MISMATCH step %d: %s %r" % (index, kind, arg)) + for a, b in zip(outcomes[:half], outcomes[half:]): + if a != b: + print("--- cpp\n%s\n--- python\n%s" % (a, b)) + for impl in impls: + impl.close() + impl.run("kill-server") + print("mplex_commands_pty: %d steps, %d mismatches" % (checks, failures)) + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/taicpp/tests/mplex_commands_test.cpp b/taicpp/tests/mplex_commands_test.cpp new file mode 100644 index 0000000..2a4620e --- /dev/null +++ b/taicpp/tests/mplex_commands_test.cpp @@ -0,0 +1,490 @@ +// Differential test of MplexCommands (F6): the same scripted corpus of `--mplex ` calls +// runs against the C++ server (a built cli binary) and against tai.py's Python server, each in +// a throwaway TAI_HOME, and every command's output and exit status must be identical after +// normalising times, homes and temp paths. A few layout scenarios are also checked against real +// tmux 3.6 (the oracle named in mplex.md). +// +// g++ -std=c++23 -Wall -Wextra -Isrc/mplex tests/mplex_commands_test.cpp -o build/mplex_commands_test +// build/mplex_commands_test [--filter NAME] [--keep] +// +// It links nothing from src/mplex: both servers run as separate processes. +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +struct Scenario { + std::string name; + std::vector lines; // "!..." raw shell, "!sleep N" pause, otherwise args after --mplex +}; + +struct Impl { + std::string name; + std::string cli; // command prefix, e.g. "'/x/mplex_cli' --mplex" + std::string home; + std::string tmp; +}; + +std::string g_root; +bool g_keep = false; + +std::string run_shell(const std::string& command, int* rc) { + int out[2]; + if (::pipe(out) != 0) return "pipe failed"; + pid_t pid = ::fork(); + if (pid == 0) { + ::dup2(out[1], 1); + ::dup2(out[1], 2); + ::close(out[0]); + ::close(out[1]); + int devnull = ::open("/dev/null", O_RDONLY); + if (devnull >= 0) ::dup2(devnull, 0); + ::execl("/bin/sh", "sh", "-c", command.c_str(), static_cast(nullptr)); + _exit(127); + } + ::close(out[1]); + std::string data; + char buf[4096]; + for (;;) { + ssize_t got = ::read(out[0], buf, sizeof(buf)); + if (got <= 0) break; + data.append(buf, static_cast(got)); + } + ::close(out[0]); + int status = 0; + ::waitpid(pid, &status, 0); + *rc = WIFEXITED(status) ? WEXITSTATUS(status) : 128 + WTERMSIG(status); + return data; +} + +std::string shquote(const std::string& text) { + std::string out = "'"; + for (char ch : text) { + if (ch == '\'') + out += "'\\''"; + else + out.push_back(ch); + } + return out + "'"; +} + +std::string normalise(std::string text, const Impl& impl) { + auto replace_all = [&](const std::string& from, const std::string& to) { + if (from.empty()) return; + size_t pos = 0; + while ((pos = text.find(from, pos)) != std::string::npos) { + text.replace(pos, from.size(), to); + pos += to.size(); + } + }; + replace_all(impl.tmp, ""); + replace_all(impl.home, ""); + // The regex engines differ (std::wregex versus Python re): only the wording of the error differs. + text = std::regex_replace(text, std::regex("bad regular expression: [^\\n]*"), "bad regular expression: "); + text = std::regex_replace(text, std::regex("[0-9][0-9]:[0-9][0-9]:[0-9][0-9]"), "HH:MM:SS"); + text = std::regex_replace(text, std::regex("created [A-Za-z]{3} [A-Za-z]{3} [ 0-9]{1,2} HH:MM:SS [0-9]{4}"), "created "); + text = std::regex_replace(text, std::regex("[0-9][0-9]:[0-9][0-9] [0-9][0-9]-[A-Za-z]{3}-[0-9][0-9]"), ""); + return text; +} + +std::string env_prefix(const Impl& impl) { + return "env -u TMUX -u TAI_PLEXER_ACTIVE -u TAI_PLEXER_SOCKET -u TAI_PLEXER_PANE TAI_HOME=" + shquote(impl.home) + + " HOME=" + shquote(impl.tmp) + " T=" + shquote(impl.tmp) + " SHELL=/bin/sh TERM=xterm LANG=C.UTF-8 "; +} + +std::vector corpus(); + +} // namespace + +namespace { + +std::vector corpus() { + std::vector all; + const std::string S = "sleep 600"; + auto sess = [&](const std::string& extra) { return "new-session -d " + extra + " '" + S + "'"; }; + + all.push_back({"sessions", { + "list-sessions", "has-session", "has-session -t nope", "kill-session -t nope", + sess("-s main -x 100 -y 30"), + "list-sessions", + "list-sessions -F '#{session_id} #{session_name} #{session_windows} #{window_width}x#{window_height} #{session_attached}'", + sess("-s two -n w2"), sess("-s two"), "new-session -d -s 'bad:name'", "new-session -d -s 'bad.name'", + "new-session -A -d -s two", "new-session -A -D -d -s two", + "has-session -t main", "has-session -t two", "has-session -t nope", "has-session -t =ma", "has-session -t m", + "has-session -t '$0'", "has-session -t '$99'", + "rename-session -t two three", "rename-session -t three main", "rename-session -t three 'a.b'", "rename-session -t three ''", + "rename-session -t nope zz", + "list-sessions -F '#{session_id} #{session_name}'", "list-sessions -f '#{==:#{session_name},three}' -F '#{session_name}'", + "list-sessions -F '#{session_windows}x'", "ls -F '#{session_name}'", + "new-session -d -s sized -x 50 -y 12 'sleep 600'", "list-windows -t sized -F '#{window_width}x#{window_height}'", + "new-session -d -s named -n nm -P -F '#{session_name}:#{window_name}'", + "new-session -d -P", "new-session -d -P -F '#{session_id}'", + "new-session -d -s envs -e FOO=bar -e BAZ= 'sleep 600'", + "kill-session -t sized", "kill-session -t sized", "kill-session -a -t main", "list-sessions -F '#{session_name}'", + "kill-session", "list-sessions", "kill-server", "!sleep 0.2", "list-sessions", + }}); + + all.push_back({"windows", { + sess("-s main -x 100 -y 30"), "!sleep 0.2", + "new-window -t main -n alpha 'sleep 600'", "new-window -t main: -n beta -P -F '#{window_index}:#{window_name}'", + "new-window -t main:7 -n seven", "new-window -a -t main:0 -n after0", "new-window -b -t main:0 -n before0", + "new-window -d -n detached", "new-window -t main:7 -n clash", "new-window -k -t main:7 -n replaced", + "list-windows -t main -F '#{window_index}:#{window_name}#{window_flags}:#{window_active}:#{window_last_flag}:#{window_id}'", + "select-window -t main:alpha", "select-window -t :=2", "select-window -t main:99", "select-window -t nowin", + "next-window", "previous-window", "last-window", "select-window -l", "select-window -n", "select-window -p", + "select-window -T -t main:alpha", "select-window -T -t main:beta", + "next-window -a", "previous-window -a", + "list-windows -F '#{window_index}:#{window_name}#{window_flags}'", + "rename-window -t main:0 foo", "rename-window ''", "rename-window -t main:99 x", "rename-window verylongnamebbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "list-windows -F '#{window_index}:#{window_name}'", + "move-window -s main:1 -t main:20", "move-window -s main:20 -t main:3", "move-window -s main:3 -t main:5 -d", + "move-window -s main:5", "move-window -s main:alpha -t main:0", "move-window -k -s main:alpha -t main:0", + "move-window -a -s main:0 -t main:1", "move-window -b -s main:1 -t main:0", + "list-windows -F '#{window_index}:#{window_name}#{window_flags}'", + "swap-window -s main:0 -t main:1", "swap-window -d -s main:0 -t main:1", "swap-window -s main:0 -t main:99", + "move-window -r", "move-window -r -t main", + "list-windows -F '#{window_index}:#{window_name}#{window_flags}'", + "find-window foo", "find-window -N alp", "find-window -r '^al'", "find-window -r '(' ", "find-window -i ALPHA", "find-window zzzzz", + "find-window -C sleep", "find-window -T nothing", + "list-windows -a -F '#{session_name}:#{window_index}'", "list-windows -a -f '#{window_active}' -F '#{window_index}'", + "kill-window -t main:99", "kill-window -t main:7", "kill-window -a -t main:2", + "list-windows -F '#{window_index}:#{window_name}#{window_flags}'", + sess("-s other"), "move-window -s main:2 -t other:9", "!sleep 1.5", "list-windows -t other -F '#{window_index}:#{window_name}'", + "list-windows -t main -F '#{window_index}:#{window_name}'", + "swap-window -s main:2 -t other:9", + "move-window -s other:9 -t main:2", "move-window -s other:0 -t main:44", "list-sessions -F '#{session_name} #{session_windows}'", + "kill-window -a", "kill-window", "kill-window", + }}); + + all.push_back({"panes", { + sess("-s main -x 100 -y 30"), "!sleep 0.2", + "split-window -h", "split-window -v", "split-window -h -b -P -F '#{pane_index}:#{pane_id}:#{pane_width}x#{pane_height}'", + "split-window -d -l 5", "split-window -l 30%", "split-window -l 50% -h", "split-window -l abc", "split-window -l 1 -h", + "list-panes -F '#{pane_index} #{pane_id} #{pane_width}x#{pane_height} #{pane_left},#{pane_top} #{pane_active} #{pane_last}'", + "select-pane -U", "select-pane -D", "select-pane -L", "select-pane -R", "select-pane -l", "select-pane -t 0", "select-pane -t %1", + "select-pane -t 99", "select-pane -m", "list-panes -F '#{pane_index} #{pane_marked} #{pane_marked_set}'", "select-pane -M", + "select-pane -T 'my title'", "list-panes -F '#{pane_index} #{pane_title}'", "select-pane -e", "select-pane -d", + "resize-pane -U", "resize-pane -D 3", "resize-pane -L 2", "resize-pane -R 4", "resize-pane -x 20", "resize-pane -y 5", + "resize-pane -x 30%", "resize-pane -x abc", "resize-pane -D abc", "resize-pane -M", "resize-pane -T", + "list-panes -F '#{pane_index} #{pane_width}x#{pane_height} #{pane_left},#{pane_top}'", + "resize-pane -Z", "list-panes -F '#{pane_index} #{pane_width}x#{pane_height} #{window_zoomed_flag}'", "resize-pane -Z", + "swap-pane -U", "swap-pane -D", "swap-pane -s 0 -t 1", "swap-pane", "select-pane -m -t 0", "swap-pane -t 1", + "list-panes -F '#{pane_index} #{pane_id} #{pane_width}x#{pane_height}'", + "rotate-window", "rotate-window -D", "rotate-window -U", + "list-panes -F '#{pane_index} #{pane_id} #{pane_width}x#{pane_height} #{pane_active}'", + "last-pane", "last-pane", "kill-pane -t 99", "kill-pane -t 1", "kill-pane -a -t 0", + "list-panes -F '#{pane_index} #{pane_id} #{pane_width}x#{pane_height}'", + "last-pane", "split-window -Z", "list-panes -F '#{pane_index} #{window_zoomed_flag}'", + "split-window -h -P", "break-pane", "break-pane -s 0 -n broken -d -P -F '#{window_index}:#{window_name}'", + "break-pane -t 9", "list-windows -F '#{window_index}:#{window_name}#{window_flags}'", + "split-window", "break-pane -d -t 9", "break-pane -d -t 3", "list-windows -F '#{window_index}:#{window_name}#{window_flags}'", + "clear-history", "clear-history -t 99", + }}); + + all.push_back({"layouts", { + sess("-s main -x 100 -y 30"), "!sleep 0.2", + "split-window", "split-window", "split-window", "split-window", + "select-layout even-horizontal", "list-panes -F '#{pane_index} #{pane_width}x#{pane_height} #{pane_left},#{pane_top}'", + "select-layout even-vertical", "list-panes -F '#{pane_index} #{pane_width}x#{pane_height} #{pane_left},#{pane_top}'", + "select-layout main-horizontal", "list-panes -F '#{pane_index} #{pane_width}x#{pane_height} #{pane_left},#{pane_top}'", + "select-layout main-vertical", "list-panes -F '#{pane_index} #{pane_width}x#{pane_height} #{pane_left},#{pane_top}'", + "select-layout tiled", "list-panes -F '#{pane_index} #{pane_width}x#{pane_height} #{pane_left},#{pane_top}'", + "select-layout main-horizontal-mirrored", "list-panes -F '#{pane_index} #{pane_width}x#{pane_height} #{pane_left},#{pane_top}'", + "select-layout main-vertical-mirrored", "list-panes -F '#{pane_index} #{pane_width}x#{pane_height} #{pane_left},#{pane_top}'", + "select-layout even", "select-layout main-h", "select-layout main-horizontal-m", "select-layout nonsense", + "select-layout", "list-panes -F '#{pane_index} #{pane_width}x#{pane_height}'", "select-layout -n", "select-layout -p", "select-layout -o", + "next-layout", "next-layout", "previous-layout", "previous-layout", "previous-layout", + "list-panes -F '#{pane_index} #{pane_width}x#{pane_height} #{pane_left},#{pane_top}'", + "select-layout -E", "list-panes -F '#{pane_index} #{pane_width}x#{pane_height} #{pane_left},#{pane_top}'", + "set-option -w main-pane-width 40", "set-option -w main-pane-height 10", "select-layout main-vertical", "select-layout main-horizontal", + "list-panes -F '#{pane_index} #{pane_width}x#{pane_height} #{pane_left},#{pane_top}'", + "select-pane -t 2", "resize-pane -U 2", "select-pane -D", "select-pane -U", "select-pane -L", "select-pane -R", + "list-panes -F '#{pane_index} #{pane_active} #{pane_width}x#{pane_height}'", + }}); + + all.push_back({"keys", { + sess("-s main"), + "list-keys -T root", "list-keys -T prefix | head -5", "list-keys -1", "list-keys -T copy-mode-vi -1", "list-keys -T nosuch", + "list-keys -N | head -8", "list-keys -N -1", "list-keys -Na | head -4", "list-keys -N -T root", "list-keys -N -P 'pfx ' -1", + "list-keys -N c", "list-keys -N zzzz", "list-keys c", "list-keys -T prefix c", + "bind-key -n F5 new-window", "bind-key -T prefix -r M-x 'split-window -h'", "bind-key -N 'a note' X display-message hi", + "bind-key", "bind-key Z", "bind-key Z nonsense-cmd", "bind-key nokeyxx new-window", "bind-key -T custom a list-keys", + "list-keys -T root", "list-keys -T custom", "list-keys X", "list-keys -N X", "list-keys -T prefix M-x", + "unbind-key -n F5", "unbind-key F5", "unbind-key -q F5", "unbind-key -T custom a", "unbind-key", "unbind-key -a -T custom", + "list-keys -T custom", "unbind-key -a", "list-keys -T prefix", "unbind-key -a -T root", "list-keys | head -3", + "bind-key a if-shell 'true' 'display-message -p yes' 'display-message -p no'", + "bind-key b send-keys -X cancel", "list-keys -T prefix a", "list-keys -T prefix b", + "bind-key q command-prompt -p 'x,y' 'display-message \"%1 %2\"'", "list-keys -T prefix q", + "list-commands", "list-commands -F x", + }}); + + all.push_back({"options", { + sess("-s main -x 100 -y 30"), + "show-options -g | head -70", "show-options", "show-options -w", "show-options -g status", "show-options -gv status", + "show-options -gv prefix", "show-options -g prefix2", "show-options -gv mode-keys", "show-options -g status-left", + "show-options -v status-right", "show-options -gq nosuch", "show-options -g nosuch", "show-options -g stat", + "show-options -g history", "show-options -A", "show-window-options", "show-window-options -g | head -5", + "set-option -g status off", "show-options -g status", "set-option -g status", "show-options -gv status", "set-option -g status on", + "set-option -g prefix C-a", "show-options -g prefix", "set-option -g prefix None", "show-options -g prefix", "set-option -g prefix nonsense", + "set-option -g prefix2 F12", "show-options -gv prefix2", + "set-option -g history-limit 50", "set-option -g history-limit 999999", "set-option -g history-limit abc", "show-options -gv history-limit", + "set-option -g base-index 1", "show-options -gv base-index", "new-window", "list-windows -F '#{window_index}'", + "set-option -g status-left '[#S] '", "set-option -ga status-left 'more'", "show-options -g status-left", "set-option -g status-left-style 'fg=red,bg=blue,bold'", + "set-option -g status-left-style 'fg=nosuch'", "set-option -g status-style 'bad=1'", "show-options -g status-left-style", + "set-option -gu status-left", "show-options -g status-left", "set-option -go status-position top", "set-option -go status-position bottom", + "set-option -g status-position top", "set-option -go status-position bottom", "show-options -gv status-position", + "set-option -gq nosuch 1", "set-option -g nosuch 1", "set-option -q nosuch 1", "set-option", "set-option -g mode-keys vi", "set-option -g mode-keys bad", + "set-option -w -t main:0 automatic-rename off", "show-window-options -t main:0 automatic-rename", "show-options -w -t main:0 automatic-rename", + "set-option -t main status-interval 3", "show-options -t main status-interval", "show-options -v -t main status-interval", + "set-window-option -t main:0 main-pane-width 33", "show-window-options -t main:0 main-pane-width", "set-window-option -g mode-keys emacs", + "set-option -F -g status-left '#{session_name}-x'", "show-options -gv status-left", + "set-option -g escape-time 500", "set-option -g escape-time 100000", "set-option -g word-separators ' -_'", "show-options -g word-separators", + "set-option -g pane-border-lines double", "set-option -g pane-border-lines other", "set-option -g clock-mode-style 12", "set-option -g clock-mode-colour red", + "set-option -g clock-mode-colour #zzzzzz", "set-option -g clock-mode-colour '#ff0000'", "show-options -g clock-mode-colour", + "show-options -g copy-command", "set-option -g copy-command 'cat >/dev/null'", "show-options -g copy-command", "set-option -g set-clipboard external", + "set-option -g buffer-limit 3", "set-option -g buffer-limit 0", + }}); + + all.push_back({"buffers", { + sess("-s main"), "!sleep 0.1", + "list-buffers", "show-buffer", "delete-buffer", "paste-buffer", "save-buffer $T/b.txt", "set-buffer", "show-buffer -b nope", "delete-buffer -b nope", + "set-buffer hello", "set-buffer -b named 'two\nlines'", "set-buffer -b third third", "list-buffers", "list-buffers -F '#{buffer_name}:#{buffer_size}:#{buffer_sample}'", + "list-buffers -f '#{==:#{buffer_name},named}'", "show-buffer", "show-buffer -b named", "set-buffer -a -b named MORE", "show-buffer -b named", + "set-buffer -a -b nope x", "set-buffer -n renamed -b named", "set-buffer -n x -b nope", "list-buffers -F '#{buffer_name}'", + "save-buffer -b renamed $T/b1.txt", "!cat $T/b1.txt", "save-buffer -a -b renamed $T/b1.txt", "!cat $T/b1.txt", "save-buffer $T/nodir/x.txt", + "save-buffer -b nope $T/b2.txt", "save-buffer", "load-buffer $T/b1.txt", "load-buffer -b loaded $T/b1.txt", "load-buffer $T/nofile", "load-buffer $T", + "list-buffers -F '#{buffer_name}:#{buffer_size}'", "show-buffer -b loaded", "capture-pane -b cap -p", "list-buffers -F '#{buffer_name}'", + "delete-buffer -b loaded", "delete-buffer", "list-buffers -F '#{buffer_name}'", + "set-buffer -b e1 ä€x", "list-buffers -F '#{buffer_name}:#{buffer_size}:#{buffer_sample}'", "choose-buffer", "choose-client", "choose-tree", + "set-option -g buffer-limit 2", "set-buffer 1", "set-buffer 2", "set-buffer 3", "list-buffers -F '#{buffer_name}'", + }}); + + all.push_back({"panes_io", { + "new-session -d -s main -x 80 -y 24 'printf \"line1\\nline2\\n\"; exec cat'", "!sleep 0.5", + "capture-pane -p", "capture-pane -p -S 0 -E 3", "capture-pane -p -S - -E -", "capture-pane -p -N -E 1", "capture-pane -a", "capture-pane -a -q", + "capture-pane -p -S abc", "capture-pane -p -S -5", "capture-pane -p -E 100", "capture-pane -b mycap", "show-buffer -b mycap", + "send-keys 'hello world' Enter", "!sleep 0.3", "capture-pane -p -E 4", "send-keys -l 'literal Enter'", "!sleep 0.2", "capture-pane -p -E 6", + "send-keys Enter", "send-keys C-c", "send-keys -H 68 69 0d", "!sleep 0.3", "send-keys -H zz", "send-keys -H 100", "capture-pane -p -E 9", + "send-keys", "send-keys -N 3 a", "!sleep 0.3", "capture-pane -p -E 12", "send-keys -R", "send-keys -M x", "send-keys -X cancel", + "send-keys -F '#{pane_id}' Enter", "!sleep 0.3", "capture-pane -p -E 13", "send-keys -t 99 x", + "set-buffer 'pasted text\nsecond'", "paste-buffer", "!sleep 0.3", "capture-pane -p -E 17", "paste-buffer -r -s X", "paste-buffer -d", "list-buffers", + "paste-buffer -p -b nope", "send-prefix", "send-prefix -2", "set-option -g prefix2 F12", "send-prefix -2", "set-option -g prefix None", "send-prefix", + "display-message -p '#{pane_current_command} #{pane_width} #{pane_height} #{history_size} #{cursor_x} #{cursor_y}'", + "display-message -p", "display-message -p 'hello #S #I #W #P #T #F'", "display-message -p '#{pane_dead} #{pane_mode} #{alternate_on}'", + "display-message -p -t 99 x", "display-message hi", "display-message -d 5 -p x", "display-message -p '#[fg=red]styled#[default]'", + }}); + + all.push_back({"modes", { + "new-session -d -s main -x 80 -y 24 'printf \"alpha beta\\ngamma delta\\n\"; exec cat'", "!sleep 0.5", + "copy-mode", "display-message -p '#{pane_in_mode} #{pane_mode} #{copy_cursor_x} #{copy_cursor_y} #{scroll_position}'", + "send-keys -X cursor-down", "send-keys -X next-word", "send-keys -X end-of-line", "send-keys -X begin-selection", "send-keys -X start-of-line", + "display-message -p '#{selection_present} #{copy_cursor_x} #{copy_cursor_y} #{copy_cursor_line} #{copy_cursor_word}'", + "send-keys -X copy-selection", "list-buffers -F '#{buffer_size}:#{buffer_sample}'", "send-keys -X nosuchcommand", "send-keys -X", "send-keys -X cursor-up", + "send-keys -X search-forward gam", "send-keys -X search-again", "send-keys -X search-reverse", + "send-keys -N 2 -X cursor-right", "send-keys -X cancel", "display-message -p '#{pane_in_mode}'", "send-keys -X cancel", + "copy-mode -u", "display-message -p '#{pane_in_mode} #{scroll_position}'", "copy-mode -q", "display-message -p '#{pane_in_mode}'", + "copy-mode -e", "copy-mode -H", "copy-mode -M", "copy-mode -q", "clock-mode", "display-message -p '#{pane_mode}'", "clock-mode", + "send-keys q", "display-message -p '#{pane_mode}'", "copy-mode", "send-keys Escape", "display-message -p '#{pane_in_mode}'", + "copy-mode", "send-keys -X search-forward-incremental =alpha", "send-keys -X search-forward-incremental +", "send-keys -X search-backward-incremental x", + "send-keys -X jump-forward a", "send-keys -X jump-to-forward l", "send-keys -X goto-line 1", "send-keys -F -X search-forward '#{copy_cursor_word}'", + "copy-mode -q", "choose-buffer", "set-buffer abc", "choose-buffer", "display-message -p '#{pane_mode}'", "send-keys Down", "send-keys v", "send-keys q", + "choose-tree", "display-message -p '#{pane_mode}'", "send-keys Down Up j k g G", "send-keys Right Left l h + -", "send-keys t v", "send-keys Enter", + "display-message -p '#{pane_mode}'", "choose-tree -s", "send-keys q", "choose-tree -w -Z", "send-keys q", "choose-client", "send-keys q", + "find-window -N main", "split-window", "find-window -C alpha", "display-message -p '#{pane_mode}'", "send-keys q", + "display-panes", "display-panes -d 10", "command-prompt", "command-prompt -p 'q' 'display-message hi'", "confirm-before kill-pane", "confirm-before -p 'sure?' kill-pane", + "switch-client -n", "switch-client -p", "switch-client -l", "switch-client -t main", "switch-client -T root", "switch-client", + "detach-client", "detach-client -a", "detach-client -s main", "detach-client -t 99", "suspend-client", "refresh-client", "list-clients", + "list-clients -F '#{client_tty}'", "attach-session", "attach-session -t nope", + }}); + + all.push_back({"shell", { + sess("-s main"), "!sleep 0.1", + "if-shell true 'display-message -p yes' 'display-message -p no'", "if-shell false 'display-message -p yes' 'display-message -p no'", + "if-shell false 'display-message -p yes'", "if-shell -F '1' 'display-message -p fyes'", "if-shell -F '' 'display-message -p fyes' 'display-message -p fno'", + "if-shell -F '#{==:#{session_name},main}' 'display-message -p named'", "if-shell 'exit 3' x", "if-shell true ''", "if-shell true", + "run-shell 'echo out; echo err >&2'", "run-shell 'exit 7'", "run-shell", "run-shell -C 'display-message -p viaC'", "run-shell -d 1 x", + "run-shell -b 'touch $T/bg.flag'", "!sleep 0.3", "!ls $T/bg.flag", "run-shell 'printf \"a\\n\\n\"'", "run-shell '#{session_name}'", "run-shell -t 99 x", + "run-shell 'pwd'", "run-shell 'echo $FOO'", "run-shell 'sleep 7'", "run-shell 'printf \\\\303\\\\251'", "run-shell 'printf \\\\377x'", + "!printf 'set-option -g status-interval 9\\n# comment\\n\\n display-message -p fromfile\\nnosuchcmd\\n' > $T/c1.conf", "source-file $T/c1.conf", + "show-options -gv status-interval", "source-file $T/nofile", "source-file -q $T/nofile", "source-file", + "!printf '[options]\\nstatus-interval = 7\\nnosuch = 1\\n[keys.prefix]\\n\"Z\" = \"new-window\"\\n\"Y\" = \"bogus\"\\n' > $T/c2.toml", "source-file $T/c2.toml", + "show-options -gv status-interval", "list-keys -T prefix Z", "show-messages | tail -4", "source-file $T/c1.conf $T/c2.toml", + "!printf 'not toml [[' > $T/c3.toml", "source-file $T/c3.toml", "show-messages | tail -2", "source-file $T", + "!printf 'display-message -p a ; display-message -p b\\n' > $T/c4.conf", "source-file $T/c4.conf", + "display-message -p a ';' display-message -p b", "display-message -p a \\; display-message -p b", + "!printf 'source-file $T/c5.conf\\n' > $T/c5.conf", "source-file $T/c5.conf", + "frobnicate", "kill-ser", "list-s", "new", "display", "ls", "has", "kill-", "choose", "show-m", "unbind", "select-", "set", "a", "", + "display-menu", "display-popup", "pipe-pane", "join-pane", "set-hook", "wait-for", "respawn-pane", + "kill-server", "!sleep 0.2", "ls", + }}); + + return all; +} + +struct Outcome { + std::string text; + int rc = 0; +}; + +Outcome run_line(const Impl& impl, const std::string& line) { + Outcome out; + if (!line.empty() && line[0] == '!') { + std::string raw = line.substr(1); + if (raw.rfind("sleep ", 0) == 0) { + std::string command = raw; + out.text = run_shell(command, &out.rc); + out.text.clear(); + out.rc = 0; + return out; + } + out.text = run_shell(env_prefix(impl) + "sh -c " + shquote(raw), &out.rc); + return out; + } + std::string command = env_prefix(impl) + "sh -c " + shquote(impl.cli + " " + line); + out.text = run_shell(command, &out.rc); + return out; +} + +void reset_home(const Impl& impl) { + int rc; + run_shell(env_prefix(impl) + "sh -c " + shquote(impl.cli + " kill-server >/dev/null 2>&1; sleep 0.2; rm -rf " + shquote(impl.home) + " " + + shquote(impl.tmp) + "; mkdir -p " + shquote(impl.home) + " " + shquote(impl.tmp)), + &rc); +} + +bool contains_fatal(const std::string& text) { + return text.find("could not start the mplex server") != std::string::npos || text.find("Traceback") != std::string::npos || + text.find("path too long") != std::string::npos || text.find("SyntaxError") != std::string::npos || + text.find("terminate called") != std::string::npos; +} + +int g_checks = 0; +int g_failures = 0; + +bool run_scenario(const Scenario& scenario, Impl& cpp, Impl& py) { + reset_home(cpp); + reset_home(py); + bool ok = true; + int index = 0; + for (const std::string& line : scenario.lines) { + ++index; + Outcome a = run_line(cpp, line); + Outcome b = run_line(py, line); + std::string ta = normalise(a.text, cpp), tb = normalise(b.text, py); + if (!line.empty() && line[0] == '!' && line.rfind("!sleep", 0) == 0) continue; + if (contains_fatal(ta) || contains_fatal(tb)) { + std::cout << "HARNESS ERROR [" << scenario.name << " #" << index << "] " << line << "\n--- cpp\n" << ta << "\n--- python\n" << tb << "\n"; + ++g_failures; + return false; + } + ++g_checks; + if (ta != tb || a.rc != b.rc) { + ++g_failures; + ok = false; + std::cout << "MISMATCH [" << scenario.name << " #" << index << "] " << line << "\n--- cpp (rc " << a.rc << ")\n" + << ta << "\n--- python (rc " << b.rc << ")\n" << tb << "\n"; + } + } + run_line(cpp, "kill-server"); + run_line(py, "kill-server"); + if (!g_keep) { + int rc; + run_shell("rm -rf " + shquote(cpp.home) + " " + shquote(cpp.tmp) + " " + shquote(py.home) + " " + shquote(py.tmp), &rc); + } + return ok; +} + +// Real tmux 3.6 oracle for pane geometry: the same split sequence and layout in tmux and mplex. +bool tmux_scenarios(Impl& cpp) { + int rc; + std::string have = run_shell("command -v tmux", &rc); + if (rc != 0) { + std::cout << "tmux not installed, geometry oracle skipped\n"; + return true; + } + bool ok = true; + const char* layouts[] = {"even-horizontal", "even-vertical", "main-horizontal", "main-vertical", "tiled", + "main-horizontal-mirrored", "main-vertical-mirrored"}; + for (int panes = 2; panes <= 5; ++panes) { + for (const char* layout : layouts) { + reset_home(cpp); + std::string sock = cpp.tmp + "/tmux.sock"; + std::string t = "tmux -S " + shquote(sock) + " -f /dev/null "; + std::string script = t + "new-session -d -x 100 -y 30 -s o 'sleep 600'"; + for (int i = 1; i < panes; ++i) script += " && " + t + "split-window -d -t o 'sleep 600'"; + script += std::string(" && ") + t + "select-layout -t o " + layout + " && " + t + + "list-panes -t o -F '#{pane_index} #{pane_width}x#{pane_height} #{pane_left},#{pane_top}'; " + t + "kill-server"; + std::string want = run_shell(script, &rc); + Outcome first = run_line(cpp, "new-session -d -s o -x 100 -y 30 'sleep 600'"); + (void)first; + std::string commands; + for (int i = 1; i < panes; ++i) run_line(cpp, "split-window -d -t o 'sleep 600'"); + run_line(cpp, std::string("select-layout -t o ") + layout); + Outcome got = run_line(cpp, "list-panes -t o -F '#{pane_index} #{pane_width}x#{pane_height} #{pane_left},#{pane_top}'"); + ++g_checks; + if (got.text != want) { + // tmux has no status line in -x/-y sized detached sessions beyond one row: both use 31 vs 30 rows + ++g_failures; + ok = false; + std::cout << "TMUX MISMATCH panes=" << panes << " layout=" << layout << "\n--- mplex\n" << got.text << "--- tmux\n" << want; + } + run_line(cpp, "kill-server"); + } + } + return ok; +} + +} // namespace + +int main(int argc, char** argv) { + if (argc < 3) { + std::cerr << "usage: " << argv[0] << " [--filter NAME] [--keep] [--no-tmux]\n"; + return 2; + } + std::string cli = argv[1]; + g_root = argv[2]; + std::string filter; + bool tmux = true; + for (int i = 3; i < argc; ++i) { + if (!std::strcmp(argv[i], "--filter") && i + 1 < argc) filter = argv[++i]; + else if (!std::strcmp(argv[i], "--keep")) g_keep = true; + else if (!std::strcmp(argv[i], "--no-tmux")) tmux = false; + } + char cwd[4096]; + if (!::getcwd(cwd, sizeof(cwd))) cwd[0] = 0; + // AF_UNIX socket paths are limited to 107 bytes, so the work dir has to be short (MPLEX_WORK overrides). + const char* work = std::getenv("MPLEX_WORK"); + std::string base = work ? work : "/tmp/mplex_commands_work"; + ::mkdir(base.c_str(), 0755); + Impl cpp{"cpp", shquote(cli) + " --mplex", base + "/cpp-home", base + "/cpp-tmp"}; + // tai.py is imported (so its bytecode is cached under build/) instead of run as a script: 40k lines + // compile in about a second on every call otherwise. + std::string boot = "import sys; sys.path.insert(0, '" + g_root + "'); import tai; sys.exit(tai.main())"; + Impl py{"python", "python3 -I -X pycache_prefix=" + shquote(base + "/pyc") + " -c " + shquote(boot) + " --mplex", + base + "/py-home", base + "/py-tmp"}; + int scenarios = 0, failed = 0; + for (const Scenario& scenario : corpus()) { + if (!filter.empty() && scenario.name != filter) continue; + ++scenarios; + if (!run_scenario(scenario, cpp, py)) ++failed; + } + if (tmux && (filter.empty() || filter == "tmux")) { + ++scenarios; + if (!tmux_scenarios(cpp)) ++failed; + } + std::cout << "mplex_commands_test: " << scenarios << " scenarios, " << g_checks << " comparisons, " << g_failures + << " mismatches, " << failed << " scenarios failed\n"; + return g_failures == 0 ? 0 : 1; +}