aboutsummaryrefslogtreecommitdiffstats
path: root/src/cmd.c
diff options
context:
space:
mode:
authorsl0p <sl0p@sl0p.foo>2026-08-13 22:58:24 +0200
committersl0p <sl0p@sl0p.foo>2026-08-13 22:58:24 +0200
commit7d416d95cb49f783f8c4ee6eddd5c2cdcd81bb40 (patch)
tree6aaced8bd6927565816ce48ae07fb5f397d6edf7 /src/cmd.c
parentsl0ptty: M2 — server/client split, detach/reattach, control socket (diff)
downloadslosh-7d416d95cb49f783f8c4ee6eddd5c2cdcd81bb40.tar.gz
slosh-7d416d95cb49f783f8c4ee6eddd5c2cdcd81bb40.tar.xz
slosh-7d416d95cb49f783f8c4ee6eddd5c2cdcd81bb40.zip
sl0ptty: M3 — tabs, purposes, and the JSON control API
Tabs are layout trees, one per tab, and panes in every tab keep running: fds, pumping and reaping span all of them, only the current one is composed. A one-row strip shows them (M5 makes it the real status bar) and each label registers a hit, so clicking a tab switches to it through the same mechanism as every other click. C-a c new tab C-a n / C-a p cycle C-a 1..9 select Purposes on panes and tabs with D8's trust model implemented as a lock: declared (from a layout or an operator) outranks in-band, cannot be overridden, and everything is sanitised to [A-Za-z0-9_.:/-] on ingest — 'project:foo bar/../$(rm -rf)' becomes 'project:foobar/../rm-rf'. The in-band path arrives with the OSC scanner in M4 and always passes declared:false; the refusal is already tested. The control API is now JSON in, JSON out: {"cmd":"new-tab","name":"agent","purpose":"project:x.deadbeef"} {"ok":true,"id":2} with panes/tabs/snapshot/send/raw/resize/split/focus/close/select-tab/ set-name/set-purpose/alive/quit. Panes are addressed by id, not by 'the focused one', so a background tab can be scripted. The bare-verb form stays as a harness alias and runs the same code, so the tests cannot drift from the API. Adds a small JSON reader (jsonval.c) — we could only emit until now. 129 checks green.
Diffstat (limited to 'src/cmd.c')
-rw-r--r--src/cmd.c165
1 files changed, 165 insertions, 0 deletions
diff --git a/src/cmd.c b/src/cmd.c
index bfd1f08..0c6f9c3 100644
--- a/src/cmd.c
+++ b/src/cmd.c
@@ -8,6 +8,9 @@
#include <stdlib.h>
#include <string.h>
+#include "json.h"
+#include "jsonval.h"
+
/* \e \n \r \t \\ \0 \xHH -> bytes */
size_t cmd_unescape(const char *in, uint8_t *out, size_t cap) {
size_t n = 0;
@@ -42,8 +45,166 @@ static void feed_event(const input_event_t *ev, void *ud) {
app_event((app_t *)ud, ev);
}
+/* ---- the JSON control API (D3) ------------------------------------------
+ *
+ * Requests are one JSON object per line, `{"cmd": ...}`; replies are one JSON
+ * object. The bare-verb form below is kept as a human/harness alias, and both
+ * run the same code, so a script cannot drift from what the API does.
+ */
+
+static char *jerr(const char *msg) {
+ json_t j;
+ json_init(&j);
+ json_obj_open(&j, NULL);
+ json_bool(&j, "ok", false);
+ json_str(&j, "error", msg, strlen(msg));
+ json_obj_close(&j);
+ return j.buf;
+}
+
+static char *jok_int(const char *key, long long val) {
+ json_t j;
+ json_init(&j);
+ json_obj_open(&j, NULL);
+ json_bool(&j, "ok", true);
+ if (key) json_int(&j, key, val);
+ json_obj_close(&j);
+ return j.buf;
+}
+
+/* {"ok":true,"<key>":<already-serialised JSON>} */
+static char *jok_raw(const char *key, char *raw) {
+ size_t n = strlen(raw) + strlen(key) + 32;
+ char *out = malloc(n);
+ snprintf(out, n, "{\"ok\":true,\"%s\":%s}", key, raw);
+ free(raw);
+ return out;
+}
+
+static char *cmd_json(app_t *a, screen_t *s, input_parser_t *in,
+ const jv_t *req, bool *quit) {
+ const char *cmd = jv_gets(req, "cmd", NULL);
+ if (!cmd) return jerr("missing cmd");
+
+ if (strcmp(cmd, "panes") == 0) {
+ app_compose(a, s);
+ return jok_raw("panes", app_panes_json(a));
+ }
+ if (strcmp(cmd, "tabs") == 0) {
+ app_compose(a, s);
+ return jok_raw("tabs", app_tabs_json(a));
+ }
+ if (strcmp(cmd, "snapshot") == 0) {
+ app_compose(a, s);
+ const char *fmt = jv_gets(req, "format", "json");
+ if (strcmp(fmt, "text") == 0) {
+ char *txt = screen_dump(s);
+ json_t j;
+ json_init(&j);
+ json_obj_open(&j, NULL);
+ json_bool(&j, "ok", true);
+ json_str(&j, "text", txt, strlen(txt));
+ json_obj_close(&j);
+ free(txt);
+ return j.buf;
+ }
+ return jok_raw("screen", screen_dump_json(s));
+ }
+ if (strcmp(cmd, "send") == 0) {
+ const char *data = jv_gets(req, "data", "");
+ input_feed(in, (const uint8_t *)data, strlen(data), feed_event, a);
+ if (input_pending(in)) input_timeout(in, feed_event, a);
+ return jok_int(NULL, 0);
+ }
+ if (strcmp(cmd, "raw") == 0) {
+ const char *data = jv_gets(req, "data", "");
+ app_write_focused(a, data, strlen(data));
+ return jok_int(NULL, 0);
+ }
+ if (strcmp(cmd, "resize") == 0) {
+ uint16_t c = (uint16_t)jv_geti(req, "cols", s->cols);
+ uint16_t r = (uint16_t)jv_geti(req, "rows", s->rows);
+ if (!c || !r) return jerr("bad size");
+ screen_resize(s, c, r);
+ app_resize(a, c, r);
+ return jok_int(NULL, 0);
+ }
+ if (strcmp(cmd, "split") == 0) {
+ bool rows = strcmp(jv_gets(req, "dir", "cols"), "rows") == 0;
+ if (!app_split_pane(a, (uint32_t)jv_geti(req, "id", 0), rows))
+ return jerr("no such pane");
+ return jok_int("id", app_focused_pane_id(a));
+ }
+ if (strcmp(cmd, "focus") == 0) {
+ if (!app_focus_pane(a, (uint32_t)jv_geti(req, "id", 0)))
+ return jerr("no such pane");
+ return jok_int("id", app_focused_pane_id(a));
+ }
+ if (strcmp(cmd, "close") == 0) {
+ if (!app_close_pane(a, (uint32_t)jv_geti(req, "id", 0)))
+ return jerr("no such pane");
+ return jok_int(NULL, 0);
+ }
+ if (strcmp(cmd, "new-tab") == 0) {
+ uint32_t id = app_new_tab(a, jv_gets(req, "name", ""));
+ if (!id) return jerr("cannot create tab");
+ const char *purpose = jv_gets(req, "purpose", NULL);
+ if (purpose) app_set_tab_purpose(a, id, purpose, true);
+ return jok_int("id", id);
+ }
+ if (strcmp(cmd, "select-tab") == 0) {
+ long id = jv_geti(req, "id", 0);
+ bool ok = id ? app_select_tab_id(a, (uint32_t)id)
+ : app_select_tab(a, (size_t)(jv_geti(req, "index", 1) - 1));
+ return ok ? jok_int("id", app_current_tab_id(a)) : jerr("no such tab");
+ }
+ if (strcmp(cmd, "set-name") == 0) {
+ return app_set_tab_name(a, (uint32_t)jv_geti(req, "id", 0),
+ jv_gets(req, "name", ""))
+ ? jok_int(NULL, 0)
+ : jerr("no such tab");
+ }
+ if (strcmp(cmd, "set-purpose") == 0) {
+ const char *target = jv_gets(req, "target", "pane");
+ uint32_t id = (uint32_t)jv_geti(req, "id", 0);
+ const char *purpose = jv_gets(req, "purpose", "");
+ /* Declared purposes come from a layout or an operator, never from a
+ * pane's own output; the in-band path (M4) always passes false. */
+ bool declared = jv_getb(req, "declared", true);
+ bool ok = strcmp(target, "tab") == 0
+ ? app_set_tab_purpose(a, id, purpose, declared)
+ : app_set_pane_purpose(a, id, purpose, declared);
+ return ok ? jok_int(NULL, 0) : jerr("refused");
+ }
+ if (strcmp(cmd, "alive") == 0) {
+ json_t j;
+ json_init(&j);
+ json_obj_open(&j, NULL);
+ json_bool(&j, "ok", true);
+ json_bool(&j, "alive", !app_should_quit(a));
+ json_int(&j, "panes", (long long)app_pane_count(a));
+ json_int(&j, "tabs", (long long)app_tab_count(a));
+ json_obj_close(&j);
+ return j.buf;
+ }
+ if (strcmp(cmd, "quit") == 0) {
+ if (quit) *quit = true;
+ return jok_int(NULL, 0);
+ }
+ return jerr("unknown cmd");
+}
+
char *cmd_exec(app_t *a, screen_t *s, input_parser_t *in, const char *line,
bool *quit) {
+ while (*line == ' ') line++;
+ if (*line == '{') {
+ jv_t *req = jv_parse(line);
+ if (!req) return jerr("malformed json");
+ char *reply = cmd_json(a, s, in, req, quit);
+ jv_free(req);
+ return reply;
+ }
+
char verb[32] = {0};
const char *arg = "";
const char *sp = strchr(line, ' ');
@@ -80,6 +241,10 @@ char *cmd_exec(app_t *a, screen_t *s, input_parser_t *in, const char *line,
app_compose(a, s); /* layout is a function of the frame: compose first */
return app_panes_json(a);
}
+ if (strcmp(verb, "tabs") == 0) {
+ app_compose(a, s);
+ return app_tabs_json(a);
+ }
if (strcmp(verb, "alive") == 0) {
return strdup(app_should_quit(a) ? "false" : "true");
}