Files
tai/taicpp/src/mplex/mplex_vpane.cpp
T
retoorandClaude Sonnet 5.5 ec9c471780 orchestration: sub agents render in live panels on the multiplexer screen
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>
2026-10-25 18:58:22 +01:00

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