Everything compiles; main.cpp still uses the removed placeholder agent API. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
1050 lines
41 KiB
C++
1050 lines
41 KiB
C++
// Port of tai.py:9302-9985: PlexerController family, plexer_show spec validation, PlexerFlow.
|
|
#include "mplex_plexer_flow.h"
|
|
|
|
#include <errno.h>
|
|
#include <stdlib.h>
|
|
#include <sys/socket.h>
|
|
#include <sys/stat.h>
|
|
#include <sys/time.h>
|
|
#include <sys/un.h>
|
|
#include <unistd.h>
|
|
|
|
#include <algorithm>
|
|
#include <cstdio>
|
|
#include <cstring>
|
|
#include <fstream>
|
|
#include <sstream>
|
|
#include <string_view>
|
|
|
|
#include "mplex_common.h"
|
|
#include "mplex_py.h"
|
|
|
|
namespace detz::mplex_flow {
|
|
|
|
namespace py = detz::mplex::py;
|
|
using detz::mplex::to_ustr;
|
|
using detz::mplex::UStr;
|
|
|
|
const std::vector<std::string> PLEXER_FLOW_TYPES = {"choice", "text", "info"};
|
|
const std::vector<std::string> PLEXER_FLOW_KINDS = {"message", "choice", "text"};
|
|
|
|
namespace {
|
|
|
|
// ---- Python value helpers ---------------------------------------------------------------
|
|
|
|
std::string repr_json(const Json& v) {
|
|
switch (v.kind()) {
|
|
case Json::Kind::Null: return "None";
|
|
case Json::Kind::Bool: return v.as_bool() ? "True" : "False";
|
|
case Json::Kind::Int: return std::to_string(v.as_int());
|
|
case Json::Kind::Float: return py::format_float(v.as_float());
|
|
case Json::Kind::String: return py::repr(v.as_string());
|
|
case Json::Kind::Array: {
|
|
std::string out = "[";
|
|
bool first = true;
|
|
for (const auto& item : v.as_array()) {
|
|
if (!first) out += ", ";
|
|
first = false;
|
|
out += repr_json(item);
|
|
}
|
|
return out + "]";
|
|
}
|
|
case Json::Kind::Object: {
|
|
std::string out = "{";
|
|
bool first = true;
|
|
for (const auto& item : v.as_object()) {
|
|
if (!first) out += ", ";
|
|
first = false;
|
|
out += py::repr(item.first) + ": " + repr_json(item.second);
|
|
}
|
|
return out + "}";
|
|
}
|
|
}
|
|
return "None";
|
|
}
|
|
|
|
// str(value)
|
|
std::string str_json(const Json& v) { return v.is_string() ? v.as_string() : repr_json(v); }
|
|
|
|
// "%d" in error texts
|
|
std::string d(long long n) { return std::to_string(n); }
|
|
|
|
bool is_scalar(const Json& v) { return v.is_string() || v.is_number() || v.is_bool() || v.is_null(); }
|
|
|
|
// int(value) with Python's TypeError/ValueError turned into nullopt.
|
|
std::optional<long long> py_int(const Json& v) {
|
|
switch (v.kind()) {
|
|
case Json::Kind::Bool: return v.as_bool() ? 1 : 0;
|
|
case Json::Kind::Int: return v.as_int();
|
|
case Json::Kind::Float: {
|
|
double f = v.as_float();
|
|
if (f != f || f > 9e18 || f < -9e18) return std::nullopt;
|
|
return static_cast<long long>(f);
|
|
}
|
|
case Json::Kind::String:
|
|
try {
|
|
return py::to_int(v.as_string());
|
|
} catch (const std::exception&) {
|
|
return std::nullopt;
|
|
}
|
|
default: return std::nullopt;
|
|
}
|
|
}
|
|
|
|
const Json null_json;
|
|
|
|
const Json& jget(const Json& obj, std::string_view key) {
|
|
const Json* hit = obj.get(std::string(key));
|
|
return hit ? *hit : null_json;
|
|
}
|
|
|
|
Json jget_or(const Json& obj, const std::string& key, const Json& fallback) {
|
|
const Json* hit = obj.get(key);
|
|
return hit ? *hit : fallback;
|
|
}
|
|
|
|
bool in_list(const std::vector<std::string>& list, const std::string& value) {
|
|
return std::find(list.begin(), list.end(), value) != list.end();
|
|
}
|
|
|
|
std::string errno_text(int err) {
|
|
return "[Errno " + std::to_string(err) + "] " + std::strerror(err);
|
|
}
|
|
|
|
std::string cap300(const std::string& text) { return py::slice(text, 0, 300); }
|
|
|
|
std::string universal_newlines(const std::string& raw) {
|
|
std::string out;
|
|
out.reserve(raw.size());
|
|
for (size_t i = 0; i < raw.size(); ++i) {
|
|
if (raw[i] == '\r') {
|
|
out.push_back('\n');
|
|
if (i + 1 < raw.size() && raw[i + 1] == '\n') ++i;
|
|
} else {
|
|
out.push_back(raw[i]);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
std::string decode_replace(const std::string& raw) { return detz::mplex::to_utf8(to_ustr(raw)); }
|
|
|
|
// "text" % shlex-quoted arg, for the reader fragments: handles %% and a single %s.
|
|
std::string format_s(const std::string& fmt, const std::string& arg) {
|
|
std::string out;
|
|
for (size_t i = 0; i < fmt.size(); ++i) {
|
|
if (fmt[i] == '%' && i + 1 < fmt.size()) {
|
|
if (fmt[i + 1] == '%') {
|
|
out.push_back('%');
|
|
++i;
|
|
continue;
|
|
}
|
|
if (fmt[i + 1] == 's') {
|
|
out += arg;
|
|
++i;
|
|
continue;
|
|
}
|
|
}
|
|
out.push_back(fmt[i]);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
std::string temp_dir() {
|
|
for (const char* name : {"TMPDIR", "TEMP", "TMP"}) {
|
|
const char* v = std::getenv(name);
|
|
if (v && *v) return v;
|
|
}
|
|
return "/tmp";
|
|
}
|
|
|
|
// tempfile.mkstemp(prefix="tai-flow-", suffix=...): returns (fd, path); fd -1 on failure.
|
|
std::pair<int, std::string> make_temp(const std::string& suffix) {
|
|
std::string pattern = temp_dir() + "/tai-flow-XXXXXX" + suffix;
|
|
std::vector<char> buf(pattern.begin(), pattern.end());
|
|
buf.push_back('\0');
|
|
int fd = ::mkstemps(buf.data(), static_cast<int>(suffix.size()));
|
|
return {fd, std::string(buf.data())};
|
|
}
|
|
|
|
} // namespace
|
|
|
|
std::string plexer_dir(const std::string& home) {
|
|
std::string path = home + "/" + PLEXER_DIR_NAME;
|
|
std::string partial;
|
|
size_t pos = 0;
|
|
while (pos <= path.size()) {
|
|
size_t next = path.find('/', pos);
|
|
if (next == std::string::npos) next = path.size();
|
|
partial = path.substr(0, next);
|
|
if (!partial.empty()) ::mkdir(partial.c_str(), 0777);
|
|
pos = next + 1;
|
|
}
|
|
::chmod(path.c_str(), 0700);
|
|
return path;
|
|
}
|
|
|
|
// ---- PlexerController ---------------------------------------------------------------------
|
|
|
|
void PlexerController::type_text(const std::string& text, double delay, bool enter) {
|
|
UStr chars = to_ustr(text);
|
|
for (wchar_t ch : chars) {
|
|
send_keys(detz::mplex::to_utf8(ch));
|
|
::usleep(static_cast<useconds_t>(delay * 1e6));
|
|
}
|
|
if (enter) send_keys("", true);
|
|
}
|
|
|
|
// ---- NativePlexerController -----------------------------------------------------------------
|
|
|
|
NativePlexerController::NativePlexerController(const std::string& socket_path_in, const std::optional<std::string>& pane_in)
|
|
: socket_path(socket_path_in), pane(pane_in) {
|
|
kind = "native";
|
|
}
|
|
|
|
Json NativePlexerController::call(const Json& payload_in) {
|
|
Json payload = payload_in;
|
|
if (pane && !pane->empty()) payload.set("pane", Json::string(*pane));
|
|
int sock = ::socket(AF_UNIX, SOCK_STREAM, 0);
|
|
struct timeval tv {10, 0};
|
|
if (sock >= 0) {
|
|
::setsockopt(sock, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof tv);
|
|
::setsockopt(sock, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof tv);
|
|
}
|
|
struct sockaddr_un addr {};
|
|
addr.sun_family = AF_UNIX;
|
|
int err = 0;
|
|
if (sock < 0) {
|
|
err = errno;
|
|
} else if (socket_path.size() >= sizeof(addr.sun_path)) {
|
|
err = ENAMETOOLONG;
|
|
} else {
|
|
std::memcpy(addr.sun_path, socket_path.c_str(), socket_path.size() + 1);
|
|
if (::connect(sock, reinterpret_cast<sockaddr*>(&addr), sizeof addr) != 0) err = errno;
|
|
}
|
|
if (err) {
|
|
if (sock >= 0) ::close(sock);
|
|
throw PlexerError("cannot reach native plexer backend: " + cap300(err == EAGAIN ? "timed out" : errno_text(err)));
|
|
}
|
|
std::string chunks;
|
|
try {
|
|
std::string line = payload.dumps() + "\n";
|
|
size_t done = 0;
|
|
while (done < line.size()) {
|
|
ssize_t n = ::send(sock, line.data() + done, line.size() - done, MSG_NOSIGNAL);
|
|
if (n < 0) {
|
|
if (errno == EINTR) continue;
|
|
int e = errno;
|
|
throw PlexerError("native plexer backend read/write failed: " +
|
|
cap300(e == EAGAIN ? "timed out" : errno_text(e)));
|
|
}
|
|
done += static_cast<size_t>(n);
|
|
}
|
|
for (;;) {
|
|
char chunk[65536];
|
|
ssize_t n = ::recv(sock, chunk, sizeof chunk, 0);
|
|
if (n < 0) {
|
|
if (errno == EINTR) continue;
|
|
int e = errno;
|
|
throw PlexerError("native plexer backend read/write failed: " +
|
|
cap300(e == EAGAIN ? "timed out" : errno_text(e)));
|
|
}
|
|
if (n == 0) break;
|
|
chunks.append(chunk, static_cast<size_t>(n));
|
|
if (chunk[n - 1] == '\n') break;
|
|
}
|
|
} catch (...) {
|
|
::close(sock);
|
|
throw;
|
|
}
|
|
::close(sock);
|
|
std::string raw = py::strip(decode_replace(chunks));
|
|
Json reply = Json::object();
|
|
if (!raw.empty()) {
|
|
try {
|
|
reply = Json::loads(raw);
|
|
} catch (const std::exception&) {
|
|
throw PlexerError("native plexer backend sent an unparseable reply");
|
|
}
|
|
}
|
|
if (!reply.is_object()) throw PlexerError("native plexer backend refused the command");
|
|
const Json* ok = reply.get("ok");
|
|
if (!(ok && ok->truthy())) {
|
|
const Json* error = reply.get("error");
|
|
if (error && error->truthy()) throw PlexerError(str_json(*error));
|
|
throw PlexerError("native plexer backend refused the command");
|
|
}
|
|
return jget(reply, "data");
|
|
}
|
|
|
|
Json NativePlexerController::send_keys(const std::string& text, bool enter) {
|
|
Json p = Json::object();
|
|
p.set("cmd", Json::string("send"));
|
|
p.set("text", Json::string(text));
|
|
p.set("enter", Json::boolean(enter));
|
|
return call(p);
|
|
}
|
|
|
|
Json NativePlexerController::read_pane(int lines) {
|
|
Json p = Json::object();
|
|
p.set("cmd", Json::string("read"));
|
|
p.set("lines", Json::integer(lines));
|
|
return call(p);
|
|
}
|
|
|
|
Json NativePlexerController::status() {
|
|
Json p = Json::object();
|
|
p.set("cmd", Json::string("status"));
|
|
return call(p);
|
|
}
|
|
|
|
Json NativePlexerController::ask(const Json& screen, double timeout) {
|
|
Json show = Json::object();
|
|
show.set("cmd", Json::string("show"));
|
|
show.set("screen", screen);
|
|
show.set("timeout", Json::number(timeout));
|
|
Json handle = call(show);
|
|
if (!handle.is_object() || !handle.has("id")) throw PlexerError("native plexer backend sent no flow id");
|
|
Json flow_id = jget(handle, "id");
|
|
double deadline = py::monotonic() + std::max(1.0, timeout);
|
|
for (;;) {
|
|
Json q = Json::object();
|
|
q.set("cmd", Json::string("result"));
|
|
q.set("id", flow_id);
|
|
Json reply = call(q);
|
|
const Json* done = reply.is_object() ? reply.get("done") : nullptr;
|
|
if (done && done->truthy()) {
|
|
const Json* status = reply.get("status");
|
|
std::string st = (status && status->is_string()) ? status->as_string() : "";
|
|
if (st == "ok") {
|
|
Json out = Json::object();
|
|
out.set("status", Json::string("ok"));
|
|
out.set("value", jget(reply, "value"));
|
|
return out;
|
|
}
|
|
if (st == "cancelled" || st == "timeout") {
|
|
Json out = Json::object();
|
|
out.set("status", Json::string(st));
|
|
return out;
|
|
}
|
|
std::string shown = (status && status->truthy()) ? str_json(*status) : "unknown";
|
|
throw PlexerError("flow " + str_json(flow_id) + " ended: " + shown);
|
|
}
|
|
if (py::monotonic() >= deadline) {
|
|
try {
|
|
Json c = Json::object();
|
|
c.set("cmd", Json::string("cancel"));
|
|
c.set("id", flow_id);
|
|
call(c);
|
|
} catch (const PlexerError&) {
|
|
}
|
|
Json out = Json::object();
|
|
out.set("status", Json::string("timeout"));
|
|
return out;
|
|
}
|
|
::usleep(static_cast<useconds_t>(PLEXER_FLOW_POLL * 1e6));
|
|
}
|
|
}
|
|
|
|
// ---- TmuxPlexerController ---------------------------------------------------------------------
|
|
|
|
TmuxPlexerController::TmuxPlexerController(const std::string& pane_in) : pane(pane_in) { kind = "tmux"; }
|
|
|
|
std::string TmuxPlexerController::tmux(const std::vector<std::string>& args) {
|
|
std::vector<std::string> argv = {"tmux"};
|
|
argv.insert(argv.end(), args.begin(), args.end());
|
|
py::RunResult done;
|
|
try {
|
|
done = py::run_argv(argv, true, true, 15);
|
|
} catch (const std::exception& exc) {
|
|
throw PlexerError("tmux call failed: " + cap300(exc.what()));
|
|
}
|
|
if (done.timed_out) {
|
|
std::string cmd = "[";
|
|
for (size_t i = 0; i < argv.size(); ++i) cmd += (i ? ", " : "") + py::repr(argv[i]);
|
|
cmd += "]";
|
|
throw PlexerError("tmux call failed: " + cap300("Command '" + cmd + "' timed out after 15 seconds"));
|
|
}
|
|
if (done.returncode != 0)
|
|
throw PlexerError("tmux error: " + py::slice(py::strip(universal_newlines(decode_replace(done.err))), 0, 300));
|
|
return universal_newlines(decode_replace(done.out));
|
|
}
|
|
|
|
Json TmuxPlexerController::send_keys(const std::string& text, bool enter) {
|
|
if (!text.empty()) tmux({"send-keys", "-t", pane, "-l", text});
|
|
if (enter) tmux({"send-keys", "-t", pane, "Enter"});
|
|
return Json::boolean(true);
|
|
}
|
|
|
|
Json TmuxPlexerController::read_pane(int lines) {
|
|
return Json::string(tmux({"capture-pane", "-p", "-t", pane, "-S", "-" + std::to_string(std::max(1, lines))}));
|
|
}
|
|
|
|
Json TmuxPlexerController::status() {
|
|
std::string info = tmux({"list-panes", "-t", pane, "-F", "#{session_name}:#{window_index}.#{pane_index} #{pane_current_command}"});
|
|
Json out = Json::object();
|
|
out.set("backend", Json::string("tmux"));
|
|
out.set("pane", Json::string(pane));
|
|
out.set("info", Json::string(py::strip(info)));
|
|
return out;
|
|
}
|
|
|
|
Json TmuxPlexerController::ask(const Json& screen, double timeout) {
|
|
const Json& kind_json = jget(screen, "kind");
|
|
std::string k = kind_json.is_string() ? kind_json.as_string() : "";
|
|
if (kind_json.is_string()) {
|
|
if (k == "choice") return ask_choice(screen, timeout);
|
|
if (k == "text") return ask_text(screen, timeout);
|
|
if (k == "message") return ask_message(screen, timeout);
|
|
}
|
|
throw PlexerError("unknown screen kind " + repr_json(kind_json));
|
|
}
|
|
|
|
std::string TmuxPlexerController::ask_result_path() {
|
|
auto made = make_temp(".answer");
|
|
if (made.first >= 0) ::close(made.first);
|
|
::unlink(made.second.c_str());
|
|
return made.second;
|
|
}
|
|
|
|
std::optional<std::string> TmuxPlexerController::await_answer(const std::string& path, double budget) {
|
|
double deadline = py::monotonic() + std::max(0.0, budget);
|
|
for (;;) {
|
|
std::ifstream in(path, std::ios::binary);
|
|
if (in) {
|
|
std::stringstream ss;
|
|
ss << in.rdbuf();
|
|
return decode_replace(ss.str());
|
|
}
|
|
if (py::monotonic() >= deadline) return std::nullopt;
|
|
::usleep(50000);
|
|
}
|
|
}
|
|
|
|
void TmuxPlexerController::ask_dismiss() {
|
|
try {
|
|
py::run_argv({"tmux", "display-popup", "-C"}, true, true, 5);
|
|
} catch (const std::exception&) {
|
|
}
|
|
}
|
|
|
|
TmuxPlexerController::RunOutcome TmuxPlexerController::ask_run(const std::vector<std::string>& argv, const std::string& path,
|
|
double timeout) {
|
|
std::vector<std::string> full = {"tmux"};
|
|
full.insert(full.end(), argv.begin(), argv.end());
|
|
py::RunResult done;
|
|
try {
|
|
done = py::run_argv(full, true, true, std::max(1.0, timeout));
|
|
} catch (const std::exception& exc) {
|
|
throw PlexerError("tmux call failed: " + cap300(exc.what()));
|
|
}
|
|
RunOutcome out;
|
|
if (done.timed_out) {
|
|
ask_dismiss();
|
|
out.timed_out = true;
|
|
out.content = "timeout";
|
|
return out;
|
|
}
|
|
std::string err = py::strip(universal_newlines(decode_replace(done.err)));
|
|
if (done.returncode != 0 && !err.empty())
|
|
throw PlexerError("tmux " + argv[0] + " failed: " + py::slice(err, 0, 300));
|
|
if (done.returncode != 0) return out;
|
|
out.content = await_answer(path);
|
|
return out;
|
|
}
|
|
|
|
Json TmuxPlexerController::ask_choice(const Json& screen, double timeout) {
|
|
const Json& opts_json = jget(screen, "options");
|
|
std::vector<std::string> options;
|
|
if (opts_json.truthy() && opts_json.is_array())
|
|
for (const auto& o : opts_json.as_array()) options.push_back(str_json(o));
|
|
std::string path = ask_result_path();
|
|
RunOutcome raw;
|
|
try {
|
|
const Json& title = jget(screen, "title");
|
|
std::vector<std::string> argv = {"display-menu", "-T", title.truthy() ? str_json(title) : "tai"};
|
|
static const std::string keys = "123456789abcdefghijklmnopqrstuvwxyz";
|
|
for (size_t index = 0; index < options.size(); ++index) {
|
|
if (index >= keys.size()) throw PlexerError("string index out of range");
|
|
argv.push_back(options[index]);
|
|
argv.push_back(std::string(1, keys[index]));
|
|
argv.push_back("run-shell \"printf '%s' '" + std::to_string(index) + "' > " + py::shlex_quote(path) + "\"");
|
|
}
|
|
raw = ask_run(argv, path, timeout);
|
|
} catch (...) {
|
|
::unlink(path.c_str());
|
|
throw;
|
|
}
|
|
::unlink(path.c_str());
|
|
Json out = Json::object();
|
|
if (raw.content && *raw.content == "timeout") {
|
|
out.set("status", Json::string("timeout"));
|
|
return out;
|
|
}
|
|
std::string stripped = raw.content ? py::strip(*raw.content) : "";
|
|
if (!raw.content || !py::all_digits(to_ustr(stripped))) {
|
|
out.set("status", Json::string("cancelled"));
|
|
return out;
|
|
}
|
|
long long index = py::to_int(stripped);
|
|
if (index >= 0 && index < static_cast<long long>(options.size())) {
|
|
out.set("status", Json::string("ok"));
|
|
out.set("value", Json::integer(index));
|
|
return out;
|
|
}
|
|
out.set("status", Json::string("cancelled"));
|
|
return out;
|
|
}
|
|
|
|
TmuxPlexerController::RunOutcome TmuxPlexerController::ask_popup(const std::string& title, const std::string& body,
|
|
const std::string& reader, double timeout) {
|
|
auto made = make_temp(".prompt");
|
|
if (made.first < 0) throw PlexerError("cannot stage prompt text: " + errno_text(errno));
|
|
std::string prompt_path = made.second;
|
|
{
|
|
size_t done = 0;
|
|
while (done < body.size()) {
|
|
ssize_t n = ::write(made.first, body.data() + done, body.size() - done);
|
|
if (n < 0) {
|
|
if (errno == EINTR) continue;
|
|
std::string text = errno_text(errno);
|
|
::close(made.first);
|
|
throw PlexerError("cannot stage prompt text: " + cap300(text));
|
|
}
|
|
done += static_cast<size_t>(n);
|
|
}
|
|
::close(made.first);
|
|
}
|
|
std::string path = ask_result_path();
|
|
RunOutcome raw;
|
|
try {
|
|
std::string command = "cat " + py::shlex_quote(prompt_path) + "; " + format_s(reader, py::shlex_quote(path));
|
|
raw = ask_run({"display-popup", "-w", "62", "-h", "10", "-T", title.empty() ? "tai" : title, "-E", command}, path, timeout);
|
|
} catch (...) {
|
|
::unlink(prompt_path.c_str());
|
|
::unlink(path.c_str());
|
|
throw;
|
|
}
|
|
::unlink(prompt_path.c_str());
|
|
::unlink(path.c_str());
|
|
return raw;
|
|
}
|
|
|
|
Json TmuxPlexerController::ask_text(const Json& screen, double timeout) {
|
|
const Json& text = jget(screen, "text");
|
|
std::string body = (text.truthy() ? str_json(text) : std::string()) + "\n\n> ";
|
|
const Json& title = jget(screen, "title");
|
|
RunOutcome raw = ask_popup(title.truthy() ? str_json(title) : "", body, "read -r TAI_V && printf '%%s' \"$TAI_V\" > %s", timeout);
|
|
Json out = Json::object();
|
|
if (raw.content && *raw.content == "timeout") {
|
|
out.set("status", Json::string("timeout"));
|
|
return out;
|
|
}
|
|
if (!raw.content) {
|
|
out.set("status", Json::string("cancelled"));
|
|
return out;
|
|
}
|
|
out.set("status", Json::string("ok"));
|
|
out.set("value", Json::string(py::slice(*raw.content, 0, PLEXER_FLOW_ANSWER_MAX)));
|
|
return out;
|
|
}
|
|
|
|
Json TmuxPlexerController::ask_message(const Json& screen, double timeout) {
|
|
const Json& text = jget(screen, "text");
|
|
std::string body = (text.truthy() ? str_json(text) : std::string()) + "\n\n[Enter] ";
|
|
const Json& title = jget(screen, "title");
|
|
RunOutcome raw = ask_popup(title.truthy() ? str_json(title) : "", body, "read -r _ && printf ok > %s", timeout);
|
|
Json out = Json::object();
|
|
if (raw.content && *raw.content == "timeout") {
|
|
out.set("status", Json::string("timeout"));
|
|
return out;
|
|
}
|
|
if (!raw.content) {
|
|
out.set("status", Json::string("cancelled"));
|
|
return out;
|
|
}
|
|
out.set("status", Json::string("ok"));
|
|
out.set("value", Json::boolean(true));
|
|
return out;
|
|
}
|
|
|
|
std::unique_ptr<PlexerController> PlexerControllerFactory::detect() {
|
|
const char* socket_path = std::getenv(PLEXER_SOCKET_ENV);
|
|
if (socket_path && *socket_path) {
|
|
const char* pane = std::getenv(PLEXER_PANE_ENV);
|
|
return std::make_unique<NativePlexerController>(socket_path, pane ? std::optional<std::string>(pane) : std::nullopt);
|
|
}
|
|
const char* pane = std::getenv("TMUX_PANE");
|
|
if (pane && *pane) return std::make_unique<TmuxPlexerController>(pane);
|
|
return nullptr;
|
|
}
|
|
|
|
// ---- validation --------------------------------------------------------------------------------
|
|
|
|
std::pair<std::string, std::string> validate_flow_id(const Json& raw, const std::string& what) {
|
|
std::string text = py::strip(raw.truthy() ? str_json(raw) : std::string());
|
|
if (text.empty()) return {"", what + " id must be a non-empty string"};
|
|
if (py::len(text) > static_cast<size_t>(PLEXER_FLOW_ID_MAX))
|
|
return {"", what + " id " + py::repr(py::slice(text, 0, 72)) + " is longer than " + d(PLEXER_FLOW_ID_MAX) + " chars"};
|
|
for (wchar_t ch : to_ustr(text))
|
|
if (py::isspace(ch)) return {"", what + " id " + py::repr(text) + " must not contain whitespace"};
|
|
return {text, ""};
|
|
}
|
|
|
|
std::pair<std::string, std::string> validate_flow_text(const Json& raw, const std::string& what, size_t limit) {
|
|
if (!raw.is_string()) return {"", what + " must be a string"};
|
|
if (py::len(raw.as_string()) > limit) return {"", what + " is longer than " + d(static_cast<long long>(limit)) + " chars"};
|
|
return {raw.as_string(), ""};
|
|
}
|
|
|
|
std::pair<Json, std::string> validate_flow_options(const Json& raw, const std::string& what) {
|
|
if (!raw.is_array() || raw.as_array().empty())
|
|
return {Json::array(), what + " needs 1 to " + d(PLEXER_FLOW_OPTIONS_MAX) + " options"};
|
|
if (raw.as_array().size() > static_cast<size_t>(PLEXER_FLOW_OPTIONS_MAX))
|
|
return {Json::array(), what + " has " + d(static_cast<long long>(raw.as_array().size())) + " options, at most " +
|
|
d(PLEXER_FLOW_OPTIONS_MAX)};
|
|
Json options = Json::array();
|
|
long long pos = 0;
|
|
for (const auto& item : raw.as_array()) {
|
|
Json label, value;
|
|
if (item.is_string()) {
|
|
label = item;
|
|
value = item;
|
|
} else if (item.is_object()) {
|
|
label = jget(item, "label");
|
|
value = jget_or(item, "value", jget(item, "label"));
|
|
} else {
|
|
return {Json::array(), what + " option " + d(pos + 1) + " must be a string or {label, value}"};
|
|
}
|
|
if (!label.is_string() || py::strip(label.as_string()).empty())
|
|
return {Json::array(), what + " option " + d(pos + 1) + " needs a non-empty label"};
|
|
if (py::len(label.as_string()) > static_cast<size_t>(PLEXER_FLOW_LABEL_MAX))
|
|
return {Json::array(), what + " option " + d(pos + 1) + " label is longer than " + d(PLEXER_FLOW_LABEL_MAX) + " chars"};
|
|
if (!is_scalar(value)) return {Json::array(), what + " option " + d(pos + 1) + " value must be a JSON scalar"};
|
|
Json entry = Json::object();
|
|
entry.set("label", label);
|
|
entry.set("value", value);
|
|
options.push(entry);
|
|
++pos;
|
|
}
|
|
return {options, ""};
|
|
}
|
|
|
|
std::pair<Json, std::string> validate_flow_condition(const Json& raw, const std::string& what,
|
|
const std::vector<std::string>& known) {
|
|
if (raw.is_null()) return {Json::object(), ""};
|
|
if (!raw.is_object() || raw.as_object().empty())
|
|
return {Json::object(), what + " show_if must be a non-empty {field id: expected} object"};
|
|
Json clauses = Json::object();
|
|
for (const auto& entry : raw.as_object()) {
|
|
const std::string& key = entry.first;
|
|
const Json& expected = entry.second;
|
|
if (!in_list(known, key)) return {Json::object(), what + " show_if refers to unknown field " + py::repr(key)};
|
|
Json accepted = expected.is_array() ? expected : Json::array({expected});
|
|
if (accepted.as_array().empty())
|
|
return {Json::object(), what + " show_if for " + py::repr(key) + " needs at least one expected value"};
|
|
for (const auto& value : accepted.as_array())
|
|
if (!is_scalar(value)) return {Json::object(), what + " show_if for " + py::repr(key) + " must hold JSON scalars"};
|
|
clauses.set(key, accepted);
|
|
}
|
|
return {clauses, ""};
|
|
}
|
|
|
|
namespace {
|
|
|
|
using Fail = std::pair<Json, std::string>;
|
|
|
|
Fail fail(const std::string& reason) { return {Json(), reason}; }
|
|
|
|
} // namespace
|
|
|
|
std::pair<Json, std::string> validate_flow_spec(const Json& spec) {
|
|
if (!spec.is_object()) return fail("spec must be a JSON object");
|
|
Json version = jget_or(spec, "v", Json::integer(PLEXER_FLOW_VERSION));
|
|
if (!version.equals(Json::integer(PLEXER_FLOW_VERSION)))
|
|
return fail("spec v must be " + d(PLEXER_FLOW_VERSION) + ", got " + repr_json(version));
|
|
auto [title, error] = validate_flow_text(jget_or(spec, "title", Json::string("")), "title", PLEXER_FLOW_TITLE_MAX);
|
|
if (!error.empty()) return fail(error);
|
|
auto timeout_opt = py_int(jget_or(spec, "timeout", Json::integer(PLEXER_FLOW_TIMEOUT_DEFAULT)));
|
|
if (!timeout_opt) return fail("timeout must be an integer number of seconds");
|
|
long long timeout = *timeout_opt;
|
|
if (!(PLEXER_FLOW_TIMEOUT_MIN <= timeout && timeout <= PLEXER_FLOW_TIMEOUT_MAX))
|
|
return fail("timeout must be " + d(PLEXER_FLOW_TIMEOUT_MIN) + " to " + d(PLEXER_FLOW_TIMEOUT_MAX) + " seconds");
|
|
const Json& raw_screens = jget(spec, "screens");
|
|
if (!raw_screens.is_array() || raw_screens.as_array().empty()) return fail("screens must be a non-empty list");
|
|
if (raw_screens.as_array().size() > static_cast<size_t>(PLEXER_FLOW_SCREENS_MAX))
|
|
return fail(d(static_cast<long long>(raw_screens.as_array().size())) + " screens, at most " + d(PLEXER_FLOW_SCREENS_MAX));
|
|
Json screens = Json::array();
|
|
std::vector<std::string> screen_ids, field_ids;
|
|
long long pos = 0;
|
|
for (const auto& item : raw_screens.as_array()) {
|
|
std::string what = "screen " + d(pos + 1);
|
|
++pos;
|
|
if (!item.is_object()) return fail(what + " must be an object");
|
|
auto [ident, e1] = validate_flow_id(jget(item, "id"), what);
|
|
if (!e1.empty()) return fail(e1);
|
|
if (in_list(screen_ids, ident)) return fail("duplicate screen id " + py::repr(ident));
|
|
screen_ids.push_back(ident);
|
|
auto [title_s, e2] = validate_flow_text(jget_or(item, "title", Json::string("")), ident + " title", PLEXER_FLOW_TITLE_MAX);
|
|
if (!e2.empty()) return fail(e2);
|
|
auto [body, e3] = validate_flow_text(jget_or(item, "body", Json::string("")), ident + " body", PLEXER_FLOW_TEXT_MAX);
|
|
if (!e3.empty()) return fail(e3);
|
|
Json raw_fields = jget_or(item, "fields", Json::array());
|
|
if (!raw_fields.is_array()) return fail(ident + " fields must be a list");
|
|
if (raw_fields.as_array().size() > static_cast<size_t>(PLEXER_FLOW_FIELDS_MAX))
|
|
return fail(ident + " has " + d(static_cast<long long>(raw_fields.as_array().size())) + " fields, at most " +
|
|
d(PLEXER_FLOW_FIELDS_MAX));
|
|
Json fields = Json::array();
|
|
long long fpos = 0;
|
|
for (const auto& field : raw_fields.as_array()) {
|
|
std::string fwhat = ident + " field " + d(fpos + 1);
|
|
++fpos;
|
|
if (!field.is_object()) return fail(fwhat + " must be an object");
|
|
Json kind = jget(field, "type");
|
|
if (!(kind.is_string() && in_list(PLEXER_FLOW_TYPES, kind.as_string()))) {
|
|
std::string listed;
|
|
for (size_t i = 0; i < PLEXER_FLOW_TYPES.size(); ++i) listed += (i ? "/" : "") + PLEXER_FLOW_TYPES[i];
|
|
return fail(fwhat + " type must be one of " + listed + ", got " + repr_json(kind));
|
|
}
|
|
auto [fid, e4] = validate_flow_id(jget(field, "id"), fwhat);
|
|
if (!e4.empty()) return fail(e4);
|
|
if (in_list(field_ids, fid)) return fail("duplicate field id " + py::repr(fid));
|
|
field_ids.push_back(fid);
|
|
auto [label, e5] = validate_flow_text(jget_or(field, "label", Json::string("")), fid + " label", PLEXER_FLOW_LABEL_MAX);
|
|
if (!e5.empty()) return fail(e5);
|
|
Json entry = Json::object();
|
|
entry.set("id", Json::string(fid));
|
|
entry.set("type", kind);
|
|
entry.set("label", Json::string(label));
|
|
entry.set("show_if", jget(field, "show_if"));
|
|
if (kind.as_string() == "choice") {
|
|
auto [options, e6] = validate_flow_options(jget(field, "options"), fid);
|
|
if (!e6.empty()) return fail(e6);
|
|
entry.set("options", options);
|
|
auto def = py_int(jget_or(field, "default", Json::integer(0)));
|
|
if (!def) return fail(fid + " default must be an option index");
|
|
long long count = static_cast<long long>(options.as_array().size());
|
|
if (!(0 <= *def && *def < count))
|
|
return fail(fid + " default " + d(*def) + " is outside its " + d(count) + " options");
|
|
entry.set("default", Json::integer(*def));
|
|
} else if (kind.as_string() == "text") {
|
|
auto [initial, e7] = validate_flow_text(jget_or(field, "initial", Json::string("")), fid + " initial", PLEXER_FLOW_ANSWER_MAX);
|
|
if (!e7.empty()) return fail(e7);
|
|
entry.set("initial", Json::string(initial));
|
|
} else {
|
|
auto [text, e8] = validate_flow_text(jget_or(field, "text", Json::string("")), fid + " text", PLEXER_FLOW_TEXT_MAX);
|
|
if (!e8.empty()) return fail(e8);
|
|
entry.set("text", Json::string(text));
|
|
}
|
|
fields.push(entry);
|
|
}
|
|
Json raw_actions = jget_or(item, "actions", Json::array());
|
|
if (!raw_actions.is_array()) return fail(ident + " actions must be a list");
|
|
if (raw_actions.as_array().size() > static_cast<size_t>(PLEXER_FLOW_ACTIONS_MAX))
|
|
return fail(ident + " has " + d(static_cast<long long>(raw_actions.as_array().size())) + " actions, at most " +
|
|
d(PLEXER_FLOW_ACTIONS_MAX));
|
|
Json actions = Json::array();
|
|
std::vector<std::string> action_ids;
|
|
long long apos = 0;
|
|
for (const auto& action : raw_actions.as_array()) {
|
|
std::string awhat = ident + " action " + d(apos + 1);
|
|
++apos;
|
|
if (!action.is_object()) return fail(awhat + " must be an object");
|
|
auto [aid, e9] = validate_flow_id(jget(action, "id"), awhat);
|
|
if (!e9.empty()) return fail(e9);
|
|
if (in_list(action_ids, aid)) return fail(ident + " has a duplicate action id " + py::repr(aid));
|
|
action_ids.push_back(aid);
|
|
auto [label, e10] = validate_flow_text(jget_or(action, "label", Json::string("")), aid + " label", PLEXER_FLOW_LABEL_MAX);
|
|
if (!e10.empty()) return fail(e10);
|
|
if (py::strip(label).empty()) return fail(aid + " needs a non-empty label");
|
|
Json entry = Json::object();
|
|
entry.set("id", Json::string(aid));
|
|
entry.set("label", Json::string(label));
|
|
actions.push(entry);
|
|
}
|
|
if (py::strip(body).empty() && fields.as_array().empty() && actions.as_array().empty())
|
|
return fail(ident + " has nothing to show (no body, fields or actions)");
|
|
Json screen = Json::object();
|
|
screen.set("id", Json::string(ident));
|
|
screen.set("title", Json::string(title_s));
|
|
screen.set("body", Json::string(body));
|
|
screen.set("fields", fields);
|
|
screen.set("actions", actions);
|
|
screen.set("next", jget(item, "next"));
|
|
screen.set("show_if", jget(item, "show_if"));
|
|
screens.push(screen);
|
|
}
|
|
std::vector<std::string> known_fields;
|
|
for (const auto& screen : screens.as_array())
|
|
for (const auto& field : jget(screen, "fields").as_array()) {
|
|
const std::string& t = jget(field, "type").as_string();
|
|
if (t == "choice" || t == "text") known_fields.push_back(jget(field, "id").as_string());
|
|
}
|
|
for (auto& screen : screens.as_array()) {
|
|
for (auto& field : screen.get("fields")->as_array()) {
|
|
auto [clauses, err] = validate_flow_condition(jget(field, "show_if"), jget(field, "id").as_string(), known_fields);
|
|
if (!err.empty()) return fail(err);
|
|
field.set("show_if", clauses);
|
|
}
|
|
const std::string sid = jget(screen, "id").as_string();
|
|
{
|
|
auto [clauses, err] = validate_flow_condition(jget(screen, "show_if"), sid, known_fields);
|
|
if (!err.empty()) return fail(err);
|
|
screen.set("show_if", clauses);
|
|
}
|
|
Json target = jget(screen, "next");
|
|
auto known_target = [&](const Json& dest) {
|
|
return dest.is_string() && (dest.as_string() == "$done" || dest.as_string() == "$cancel" || in_list(screen_ids, dest.as_string()));
|
|
};
|
|
if (target.is_null()) {
|
|
screen.set("next", Json());
|
|
} else if (target.is_string()) {
|
|
if (!known_target(target)) return fail(sid + " next refers to unknown screen " + repr_json(target));
|
|
screen.set("next", target);
|
|
} else if (target.is_object()) {
|
|
if (jget(screen, "actions").as_array().empty()) return fail(sid + " next is a map but the screen has no actions");
|
|
for (const auto& entry : target.as_object()) {
|
|
bool found = false;
|
|
for (const auto& action : jget(screen, "actions").as_array())
|
|
if (jget(action, "id").as_string() == entry.first) found = true;
|
|
if (!found) return fail(sid + " next maps unknown action " + py::repr(entry.first));
|
|
if (!known_target(entry.second))
|
|
return fail(sid + " next for " + py::repr(entry.first) + " refers to unknown screen " + repr_json(entry.second));
|
|
}
|
|
screen.set("next", target);
|
|
} else {
|
|
return fail(sid + " next must be a screen id or an {action: screen} map");
|
|
}
|
|
}
|
|
Json start = jget_or(spec, "start", jget(screens.as_array()[0], "id"));
|
|
if (!(start.is_string() && in_list(screen_ids, start.as_string())))
|
|
return fail("start refers to unknown screen " + repr_json(start));
|
|
Json app = Json::object();
|
|
app.set("title", Json::string(title));
|
|
app.set("timeout", Json::integer(timeout));
|
|
app.set("start", start);
|
|
app.set("screens", screens);
|
|
return {app, ""};
|
|
}
|
|
|
|
// ---- PlexerFlow -----------------------------------------------------------------------------------
|
|
|
|
PlexerFlow::PlexerFlow(const Json& app_in) : app(app_in) {
|
|
for (const auto& screen : app.get("screens")->as_array()) {
|
|
by_id[jget(screen, "id").as_string()] = screen;
|
|
order.push_back(jget(screen, "id").as_string());
|
|
}
|
|
}
|
|
|
|
bool PlexerFlow::condition_holds(const Json& clauses, const Json& answers) {
|
|
if (!clauses.is_object()) return true;
|
|
for (const auto& entry : clauses.as_object()) {
|
|
const Json* have = answers.get(entry.first);
|
|
if (!have) return false;
|
|
bool hit = false;
|
|
for (const auto& accepted : entry.second.as_array())
|
|
if (have->equals(accepted)) hit = true;
|
|
if (!hit) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
std::string PlexerFlow::follow_up(const Json& screen) {
|
|
const Json& target = jget(screen, "next");
|
|
if (target.is_string()) return target.as_string();
|
|
size_t pos = 0;
|
|
const std::string& id = jget(screen, "id").as_string();
|
|
for (size_t i = 0; i < order.size(); ++i)
|
|
if (order[i] == id) pos = i;
|
|
if (pos + 1 < order.size()) return order[pos + 1];
|
|
return "$done";
|
|
}
|
|
|
|
const Json* PlexerFlow::first_askable(const Json& screen) {
|
|
for (const auto& field : jget(screen, "fields").as_array()) {
|
|
const std::string& t = jget(field, "type").as_string();
|
|
if ((t == "choice" || t == "text") && condition_holds(jget(field, "show_if"), answers)) return &field;
|
|
}
|
|
return nullptr;
|
|
}
|
|
|
|
std::string PlexerFlow::screen_text(const Json& screen, const Json* field) {
|
|
auto or_title = [&](const Json& first) {
|
|
const std::string& s = first.as_string();
|
|
return s.empty() ? jget(app, "title").as_string() : s;
|
|
};
|
|
if (field != nullptr && field != first_askable(screen)) {
|
|
const std::string& label = jget(*field, "label").as_string();
|
|
if (!label.empty()) return label;
|
|
return or_title(jget(screen, "title"));
|
|
}
|
|
std::vector<std::string> parts = {or_title(jget(screen, "title")), jget(screen, "body").as_string()};
|
|
for (const auto& item : jget(screen, "fields").as_array())
|
|
if (jget(item, "type").as_string() == "info" && condition_holds(jget(item, "show_if"), answers))
|
|
parts.push_back(jget(item, "text").as_string());
|
|
if (field != nullptr && !jget(*field, "label").as_string().empty()) parts.push_back(jget(*field, "label").as_string());
|
|
std::string out;
|
|
bool first = true;
|
|
for (const auto& part : parts) {
|
|
if (part.empty() || py::strip(part).empty()) continue;
|
|
if (!first) out += "\n\n";
|
|
first = false;
|
|
out += part;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
double PlexerFlow::remaining() const { return deadline - py::monotonic(); }
|
|
|
|
namespace {
|
|
|
|
Json outcome_for(const std::string& status, const std::string& at, const Json& answers, const std::string& field = "") {
|
|
Json out = Json::object();
|
|
out.set("status", Json::string(status));
|
|
out.set("at", Json::string(at));
|
|
if (!field.empty()) out.set("field", Json::string(field));
|
|
out.set("answers", answers);
|
|
return out;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
Json PlexerFlow::run(PlexerController& controller) {
|
|
deadline = py::monotonic() + static_cast<double>(jget(app, "timeout").as_int());
|
|
std::string current = jget(app, "start").as_string();
|
|
for (;;) {
|
|
++visits;
|
|
if (visits > PLEXER_FLOW_VISITS_MAX) {
|
|
Json out = Json::object();
|
|
out.set("status", Json::string("error"));
|
|
out.set("error", Json::string("flow passed " + d(PLEXER_FLOW_VISITS_MAX) + " screens (loop?), aborting"));
|
|
out.set("at", Json::string(current));
|
|
out.set("answers", answers);
|
|
return out;
|
|
}
|
|
const Json& screen = by_id.at(current);
|
|
std::string target;
|
|
if (condition_holds(jget(screen, "show_if"), answers)) {
|
|
ScreenResult result = run_screen(controller, screen);
|
|
if (result.kind == ScreenResult::Kind::Outcome) return result.outcome;
|
|
target = result.kind == ScreenResult::Kind::Jump ? result.target : follow_up(screen);
|
|
} else {
|
|
target = follow_up(screen);
|
|
}
|
|
if (target == "$done") {
|
|
Json out = Json::object();
|
|
out.set("status", Json::string("done"));
|
|
out.set("answers", answers);
|
|
out.set("action", action);
|
|
return out;
|
|
}
|
|
if (target == "$cancel") return outcome_for("cancelled", current, answers);
|
|
current = target;
|
|
}
|
|
}
|
|
|
|
PlexerFlow::ScreenResult PlexerFlow::run_screen(PlexerController& controller, const Json& screen) {
|
|
auto outcome = [](Json value) {
|
|
ScreenResult r;
|
|
r.kind = ScreenResult::Kind::Outcome;
|
|
r.outcome = std::move(value);
|
|
return r;
|
|
};
|
|
const std::string sid = jget(screen, "id").as_string();
|
|
const std::string screen_title = [&] {
|
|
const std::string& t = jget(screen, "title").as_string();
|
|
return t.empty() ? jget(app, "title").as_string() : t;
|
|
}();
|
|
bool asked = false;
|
|
for (const auto& field : jget(screen, "fields").as_array()) {
|
|
const std::string& type = jget(field, "type").as_string();
|
|
if (type == "info" || !condition_holds(jget(field, "show_if"), answers)) continue;
|
|
if (remaining() <= 0) return outcome(outcome_for("timeout", sid, answers));
|
|
const std::string fid = jget(field, "id").as_string();
|
|
if (type == "choice") {
|
|
Json ask = Json::object();
|
|
ask.set("title", Json::string(screen_title));
|
|
ask.set("text", Json::string(screen_text(screen, &field)));
|
|
ask.set("kind", Json::string("choice"));
|
|
Json labels = Json::array();
|
|
for (const auto& opt : jget(field, "options").as_array()) labels.push(jget(opt, "label"));
|
|
ask.set("options", labels);
|
|
ask.set("default", jget(field, "default"));
|
|
Json reply = controller.ask(ask, remaining());
|
|
if (jget(reply, "status").as_string() != "ok")
|
|
return outcome(outcome_for(jget(reply, "status").as_string(), sid, answers, fid));
|
|
const Json& opts = jget(field, "options");
|
|
long long idx = jget(reply, "value").is_number() ? jget(reply, "value").as_int() : -1;
|
|
if (idx < 0 || idx >= static_cast<long long>(opts.as_array().size())) throw PlexerError("list index out of range");
|
|
answers.set(fid, jget(opts.as_array()[static_cast<size_t>(idx)], "value"));
|
|
} else {
|
|
Json ask = Json::object();
|
|
ask.set("title", Json::string(screen_title));
|
|
ask.set("text", Json::string(screen_text(screen, &field)));
|
|
ask.set("kind", Json::string("text"));
|
|
ask.set("initial", jget(field, "initial"));
|
|
Json reply = controller.ask(ask, remaining());
|
|
if (jget(reply, "status").as_string() != "ok")
|
|
return outcome(outcome_for(jget(reply, "status").as_string(), sid, answers, fid));
|
|
answers.set(fid, jget(reply, "value"));
|
|
}
|
|
asked = true;
|
|
}
|
|
const Json& actions = jget(screen, "actions");
|
|
if (actions.as_array().empty()) {
|
|
if (!asked) {
|
|
if (remaining() <= 0) return outcome(outcome_for("timeout", sid, answers));
|
|
Json ask = Json::object();
|
|
ask.set("title", Json::string(screen_title));
|
|
ask.set("text", Json::string(screen_text(screen)));
|
|
ask.set("kind", Json::string("message"));
|
|
Json reply = controller.ask(ask, remaining());
|
|
if (jget(reply, "status").as_string() != "ok")
|
|
return outcome(outcome_for(jget(reply, "status").as_string(), sid, answers));
|
|
}
|
|
return ScreenResult{};
|
|
}
|
|
if (remaining() <= 0) return outcome(outcome_for("timeout", sid, answers));
|
|
std::string text;
|
|
if (first_askable(screen) == nullptr && py::strip(jget(screen, "body").as_string()).empty())
|
|
text = screen_title;
|
|
else if (first_askable(screen) == nullptr)
|
|
text = screen_text(screen);
|
|
else
|
|
text = screen_title;
|
|
Json ask = Json::object();
|
|
ask.set("title", Json::string(screen_title));
|
|
ask.set("text", Json::string(text));
|
|
ask.set("kind", Json::string("choice"));
|
|
Json labels = Json::array();
|
|
for (const auto& a : actions.as_array()) labels.push(jget(a, "label"));
|
|
ask.set("options", labels);
|
|
ask.set("default", Json::integer(0));
|
|
Json reply = controller.ask(ask, remaining());
|
|
if (jget(reply, "status").as_string() != "ok")
|
|
return outcome(outcome_for(jget(reply, "status").as_string(), sid, answers));
|
|
long long idx = jget(reply, "value").is_number() ? jget(reply, "value").as_int() : -1;
|
|
if (idx < 0 || idx >= static_cast<long long>(actions.as_array().size())) throw PlexerError("list index out of range");
|
|
action = jget(actions.as_array()[static_cast<size_t>(idx)], "id");
|
|
const Json& target = jget(screen, "next");
|
|
if (target.is_object()) {
|
|
const Json* dest = target.get(action.as_string());
|
|
if (dest == nullptr || dest->is_null()) return ScreenResult{};
|
|
if (dest->as_string() == "$done") {
|
|
Json out = Json::object();
|
|
out.set("status", Json::string("done"));
|
|
out.set("answers", answers);
|
|
out.set("action", action);
|
|
return outcome(out);
|
|
}
|
|
if (dest->as_string() == "$cancel") return outcome(outcome_for("cancelled", sid, answers));
|
|
ScreenResult r;
|
|
r.kind = ScreenResult::Kind::Jump;
|
|
r.target = dest->as_string();
|
|
return r;
|
|
}
|
|
return ScreenResult{};
|
|
}
|
|
|
|
} // namespace detz::mplex_flow
|