Feed panes in the multiplexer (vpane_* control commands, main-vertical layout, header row), src/orch (board, panel, formatter, transport, CAP adapter), Agent observer and cancel flag, supervision (idle warn/cancel, user close cancels, soft then hard stop), CAP answers collected through the spill path. Research archived under background-work-limits. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
206 lines
8.3 KiB
C++
206 lines
8.3 KiB
C++
#include "mplex_vpane.h"
|
|
|
|
#include <algorithm>
|
|
|
|
#include "mplex_py.h"
|
|
#include "mplex_server.h"
|
|
|
|
namespace detz::mplex {
|
|
|
|
namespace {
|
|
|
|
std::string clip(const std::string& text, size_t limit) { return py::slice(text, 0, static_cast<long>(limit)); }
|
|
|
|
long long to_id(const Json& ident) {
|
|
if (ident.is_number()) return static_cast<long long>(ident.as_float());
|
|
if (ident.is_string()) return py::to_int(py::lstrip(ident.as_string(), "%"));
|
|
throw ValueError("pane must be a pane id");
|
|
}
|
|
|
|
} // namespace
|
|
|
|
std::string MplexVpanes::style_of(const std::string& state) {
|
|
if (state == "done") return "bg=colour28,fg=white,bold";
|
|
if (state == "failed") return "bg=colour124,fg=white,bold";
|
|
if (state == "idle" || state == "timeout") return "bg=colour130,fg=white,bold";
|
|
if (state == "cancelled") return "bg=colour240,fg=white";
|
|
return "bg=colour24,fg=white,bold"; // running, queued, anything else
|
|
}
|
|
|
|
MplexVpanes::Entry* MplexVpanes::find(const Json& ident) {
|
|
auto it = entries_.find(static_cast<int>(to_id(ident)));
|
|
return it == entries_.end() ? nullptr : &it->second;
|
|
}
|
|
|
|
bool MplexVpanes::only_virtual(const std::shared_ptr<MplexWindow>& window) {
|
|
auto all = window->panes();
|
|
if (all.empty()) return false;
|
|
for (const auto& pane : all)
|
|
if (!pane->virtual_pane) return false;
|
|
return true;
|
|
}
|
|
|
|
void MplexVpanes::arrange(const std::shared_ptr<MplexWindow>& window, int main_percent) {
|
|
std::vector<std::shared_ptr<MplexPane>> real, feed;
|
|
for (const auto& pane : window->panes()) (pane->virtual_pane ? feed : real).push_back(pane);
|
|
if (real.empty()) return;
|
|
std::sort(feed.begin(), feed.end(), [](const auto& a, const auto& b) { return a->id < b->id; });
|
|
std::vector<std::shared_ptr<MplexPane>> ordered = real;
|
|
ordered.insert(ordered.end(), feed.begin(), feed.end());
|
|
const int width = window->width, height = window->height;
|
|
int main_w = std::max(1, std::min(width - 2, width * std::max(10, std::min(90, main_percent)) / 100));
|
|
if (width >= 2 * MPLEX_VPANE_MIN_COLS + 1 && width - main_w - 1 < MPLEX_VPANE_MIN_COLS) main_w = width - MPLEX_VPANE_MIN_COLS - 1;
|
|
window->root = MplexLayout::preset("main-vertical", ordered, width, height, main_w, 24);
|
|
window->layout_index = -1;
|
|
window->relayout();
|
|
s_.mark_dirty(window);
|
|
}
|
|
|
|
Json MplexVpanes::open(const Json& request) {
|
|
auto origin = s_.control_pane(request);
|
|
auto window = origin->window();
|
|
if (!window) throw MplexError("pane has no window");
|
|
if (entries_.size() >= static_cast<size_t>(MPLEX_VPANE_MAX))
|
|
throw MplexError("too many feed panes (limit " + std::to_string(MPLEX_VPANE_MAX) + ")");
|
|
if (s_.panes.size() >= static_cast<size_t>(MPLEX_MAX_PANES))
|
|
throw MplexError("too many panes (limit " + std::to_string(MPLEX_MAX_PANES) + ")");
|
|
size_t in_window = 0;
|
|
for (const auto& pane : window->panes())
|
|
if (pane->virtual_pane) ++in_window;
|
|
if (window->height / static_cast<int>(in_window + 1) < MPLEX_VPANE_MIN_ROWS)
|
|
throw MplexError("no space for another feed pane");
|
|
if (window->width < 2 * MPLEX_VPANE_MIN_COLS + 1) throw MplexError("window too narrow for a feed pane");
|
|
if (window->zoomed) {
|
|
window->zoomed = nullptr;
|
|
window->relayout();
|
|
}
|
|
const Json* label_json = request.get("label");
|
|
std::string label = label_json && label_json->is_string() ? clip(label_json->as_string(), MPLEX_VPANE_HEADER_MAX) : "agent";
|
|
const Json* header_json = request.get("header");
|
|
std::string header = header_json && header_json->is_string() ? clip(header_json->as_string(), MPLEX_VPANE_HEADER_MAX) : label;
|
|
const Json* state_json = request.get("state");
|
|
std::string state = state_json && state_json->is_string() ? state_json->as_string() : "running";
|
|
int percent = MPLEX_VPANE_MAIN_PERCENT;
|
|
if (const Json* p = request.get("main_percent")) percent = static_cast<int>(to_id(*p));
|
|
|
|
auto pane = std::make_shared<MplexPane>(s_.next_id("pane"), 1, 1, static_cast<int>(window->options.get("history-limit").i), s_.dir, s_.stamp);
|
|
pane->virtual_pane = true;
|
|
pane->header = header;
|
|
pane->header_style = style_of(state);
|
|
pane->screen.title = label;
|
|
pane->set_window(window);
|
|
pane->open_log();
|
|
s_.panes.set(pane->id, pane);
|
|
Entry entry;
|
|
entry.pane = pane;
|
|
entry.state = state;
|
|
entry.touched = py::monotonic();
|
|
entries_[pane->id] = entry;
|
|
// The new pane joins the window tree through the preset: arrange() rebuilds from window->panes(),
|
|
// so it has to be a leaf first; split the main pane to give it one.
|
|
auto leaf = window->leaf_of(origin->virtual_pane ? window->panes().front() : origin);
|
|
auto result = MplexLayout::split(window->root, leaf, "lr", false, pane, 0);
|
|
window->root = result.first;
|
|
arrange(window, percent);
|
|
Json data = Json::object();
|
|
data.set("id", Json::integer(pane->id));
|
|
data.set("cols", Json::integer(pane->w));
|
|
data.set("rows", Json::integer(pane->screen.rows));
|
|
return data;
|
|
}
|
|
|
|
Json MplexVpanes::write(const Json& request) {
|
|
const Json* ops = request.get("ops");
|
|
if (!ops || !ops->is_array()) throw ValueError("ops must be a list");
|
|
if (ops->as_array().size() > static_cast<size_t>(MPLEX_VPANE_OPS_MAX)) throw ValueError("too many ops in one write");
|
|
const double now = py::monotonic();
|
|
Json closed = Json::array();
|
|
Json cols = Json::object(); // current text width of every pane written to, for client side word wrap
|
|
for (const Json& op : ops->as_array()) {
|
|
if (!op.is_object()) throw ValueError("each op must be an object");
|
|
const Json* ident = op.get("pane");
|
|
if (!ident) throw ValueError("op needs a pane");
|
|
Entry* entry = find(*ident);
|
|
if (!entry) {
|
|
closed.push(Json::integer(to_id(*ident)));
|
|
continue;
|
|
}
|
|
entry->touched = now;
|
|
cols.set(std::to_string(entry->pane->id), Json::integer(entry->pane->w));
|
|
auto window = entry->pane->window();
|
|
const Json* data = op.get("data");
|
|
if (data && data->is_string() && !data->as_string().empty()) {
|
|
if (data->as_string().size() > MPLEX_VPANE_WRITE_MAX) throw ValueError("op data too large");
|
|
entry->pane->feed(to_utf8(to_ustr(data->as_string())));
|
|
}
|
|
if (const Json* header = op.get("header"); header && header->is_string()) entry->pane->header = clip(header->as_string(), MPLEX_VPANE_HEADER_MAX);
|
|
if (const Json* state = op.get("state"); state && state->is_string()) {
|
|
entry->state = state->as_string();
|
|
entry->pane->header_style = style_of(entry->state);
|
|
}
|
|
if (const Json* done = op.get("done"); done && done->truthy()) {
|
|
double linger = 20.0;
|
|
if (const Json* l = op.get("linger"); l && l->is_number()) linger = l->as_float();
|
|
entry->close_at = now + std::max(0.0, std::min(linger, MPLEX_VPANE_LINGER_MAX));
|
|
}
|
|
s_.mark_dirty(window);
|
|
}
|
|
Json reply = Json::object();
|
|
reply.set("closed", closed);
|
|
reply.set("cols", cols);
|
|
return reply;
|
|
}
|
|
|
|
void MplexVpanes::remove(int id) {
|
|
auto it = entries_.find(id);
|
|
if (it == entries_.end()) return;
|
|
auto pane = it->second.pane;
|
|
auto window = pane->window();
|
|
entries_.erase(it);
|
|
s_.kill_pane(pane);
|
|
if (window && s_.windows.contains(window->id) && !only_virtual(window)) arrange(window, MPLEX_VPANE_MAIN_PERCENT);
|
|
}
|
|
|
|
Json MplexVpanes::close(const Json& request) {
|
|
const Json* ident = request.get("pane");
|
|
if (!ident) throw ValueError("pane is required");
|
|
remove(static_cast<int>(to_id(*ident)));
|
|
return Json::boolean(true);
|
|
}
|
|
|
|
Json MplexVpanes::list() const {
|
|
Json out = Json::array();
|
|
for (const auto& item : entries_) {
|
|
Json row = Json::object();
|
|
row.set("id", Json::integer(item.first));
|
|
row.set("state", Json::string(item.second.state));
|
|
row.set("header", Json::string(item.second.pane->header));
|
|
row.set("cols", Json::integer(item.second.pane->w));
|
|
row.set("rows", Json::integer(item.second.pane->screen.rows));
|
|
row.set("closing", Json::boolean(item.second.close_at.has_value()));
|
|
out.push(row);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
void MplexVpanes::sweep(double now) {
|
|
std::vector<int> due;
|
|
for (auto& item : entries_) {
|
|
Entry& e = item.second;
|
|
if (e.close_at) {
|
|
if (now >= *e.close_at) due.push_back(item.first);
|
|
} else if (now - e.touched > MPLEX_VPANE_LEASE) {
|
|
e.state = "failed";
|
|
e.pane->header = "owner gone: " + e.pane->header;
|
|
e.pane->header_style = style_of("failed");
|
|
e.close_at = now + MPLEX_VPANE_ORPHAN_LINGER;
|
|
s_.mark_dirty(e.pane->window());
|
|
}
|
|
}
|
|
for (int id : due) remove(id);
|
|
}
|
|
|
|
void MplexVpanes::forget(int pane_id) { entries_.erase(pane_id); }
|
|
|
|
} // namespace detz::mplex
|