aboutsummaryrefslogtreecommitdiffstats
path: root/src
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
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')
-rw-r--r--src/app.c357
-rw-r--r--src/app.h26
-rw-r--r--src/cmd.c165
-rw-r--r--src/jsonval.c261
-rw-r--r--src/jsonval.h40
5 files changed, 792 insertions, 57 deletions
diff --git a/src/app.c b/src/app.c
index 19d3459..13c8a6b 100644
--- a/src/app.c
+++ b/src/app.c
@@ -40,6 +40,8 @@ struct node {
pane_t *pane;
uint32_t id;
rect_t content; /* where the pane's cells go */
+ char purpose[64];
+ bool purpose_locked; /* declared by a layout: in-band cannot override */
/* split */
split_dir_t dir;
@@ -47,9 +49,21 @@ struct node {
size_t nkids;
};
-struct app {
+/* A tab is a layout tree and its focus. Panes in every tab keep running; only
+ * the current tab is composed. */
+typedef struct {
node_t *root;
node_t *focus;
+ uint32_t id;
+ char name[64];
+ char purpose[64];
+ bool purpose_locked;
+} tab_t;
+
+struct app {
+ tab_t *tabs;
+ size_t ntabs, tabcap, cur;
+ uint32_t next_tab_id;
uint32_t next_id;
uint16_t cols, rows;
bool prefix;
@@ -61,6 +75,8 @@ struct app {
const screen_t *painted;
};
+static tab_t *cur(app_t *a) { return &a->tabs[a->cur]; }
+
static node_t *leaf_new(app_t *a) {
pane_t *p = pane_new(a->argv, 1, 1, NULL);
if (!p) return NULL;
@@ -82,27 +98,58 @@ static void node_free(node_t *n) {
free(n);
}
+/* Purposes are how tooling finds "the agent pane" (D8). Sanitised on ingest to
+ * the charset sl0ppi settled on, so the format survives verbatim. */
+static void sanitise_purpose(const char *in, char *out, size_t cap) {
+ size_t n = 0;
+ for (const char *p = in; *p && n + 1 < cap; p++) {
+ char c = *p;
+ bool ok = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') ||
+ (c >= '0' && c <= '9') || c == '_' || c == '.' || c == ':' ||
+ c == '/' || c == '-';
+ if (ok) out[n++] = c;
+ }
+ out[n] = 0;
+}
+
+static tab_t *tab_add(app_t *a, const char *name) {
+ if (a->ntabs == a->tabcap) {
+ a->tabcap = a->tabcap ? a->tabcap * 2 : 4;
+ a->tabs = realloc(a->tabs, a->tabcap * sizeof *a->tabs);
+ }
+ tab_t *t = &a->tabs[a->ntabs++];
+ memset(t, 0, sizeof *t);
+ t->id = ++a->next_tab_id;
+ snprintf(t->name, sizeof t->name, "%s", name && *name ? name : "");
+ return t;
+}
+
app_t *app_new(const char *const argv[], uint16_t cols, uint16_t rows) {
app_t *a = calloc(1, sizeof *a);
a->argv = argv;
a->cols = cols;
a->rows = rows;
- a->root = leaf_new(a);
- if (!a->root) {
+ tab_add(a, "");
+ a->cur = 0;
+ node_t *leaf = leaf_new(a);
+ if (!leaf) {
+ free(a->tabs);
free(a);
return NULL;
}
- a->focus = a->root;
+ cur(a)->root = leaf;
+ cur(a)->focus = leaf;
return a;
}
void app_free(app_t *a) {
if (!a) return;
- node_free(a->root);
+ for (size_t i = 0; i < a->ntabs; i++) node_free(a->tabs[i].root);
+ free(a->tabs);
free(a);
}
-bool app_should_quit(const app_t *a) { return a->quit || a->root == NULL; }
+bool app_should_quit(const app_t *a) { return a->quit || a->ntabs == 0; }
bool app_detach_requested(const app_t *a) { return a->detach; }
void app_clear_detach(app_t *a) { a->detach = false; }
@@ -119,11 +166,32 @@ static void walk(node_t *n, leaf_fn fn, void *ud) {
for (size_t i = 0; i < n->nkids; i++) walk(n->kids[i], fn, ud);
}
+static void walk_all(app_t *a, leaf_fn fn, void *ud) {
+ for (size_t i = 0; i < a->ntabs; i++) walk(a->tabs[i].root, fn, ud);
+}
+
+/* Which tab a node lives in: climb to its root and match. */
+static size_t tab_of(app_t *a, node_t *n) {
+ while (n->parent) n = n->parent;
+ for (size_t i = 0; i < a->ntabs; i++)
+ if (a->tabs[i].root == n) return i;
+ return (size_t)-1;
+}
+
+struct byid {
+ uint32_t id;
+ node_t *found;
+};
+static void byid_cb(node_t *n, void *ud) {
+ struct byid *b = ud;
+ if (n->id == b->id) b->found = n;
+}
+
static void count_cb(node_t *n, void *ud) { (*(size_t *)ud)++; }
size_t app_pane_count(const app_t *a) {
size_t n = 0;
- walk(a->root, count_cb, &n);
+ walk_all((app_t *)a, count_cb, &n);
return n;
}
@@ -147,13 +215,13 @@ static void collect_cb(node_t *n, void *ud) {
size_t app_fds(app_t *a, int *out, size_t max) {
struct collect c = {out, 0, max};
- walk(a->root, collect_cb, &c);
+ walk_all(a, collect_cb, &c); /* background tabs keep running */
return c.n;
}
bool app_pump_fd(app_t *a, int fd) {
struct find_fd f = {fd, NULL};
- walk(a->root, find_fd_cb, &f);
+ walk_all(a, find_fd_cb, &f);
if (!f.found) return false;
pane_pump(f.found->pane);
return true;
@@ -198,17 +266,21 @@ static void layout_node(node_t *n, rect_t r) {
}
}
+#define STRIP_ROWS 1 /* M5 turns this into the real status bar */
+
static void layout(app_t *a) {
+ if (!a->ntabs) return;
uint16_t gx = (uint16_t)(CFG.gap * CFG.gap_aspect), gy = CFG.gap;
+ uint16_t top = (uint16_t)(gy + STRIP_ROWS);
rect_t r = {.x = gx,
- .y = gy,
+ .y = top,
.w = (uint16_t)(a->cols > 2 * gx ? a->cols - 2 * gx : a->cols),
- .h = (uint16_t)(a->rows > 2 * gy ? a->rows - 2 * gy : a->rows)};
- if (a->root) layout_node(a->root, r);
+ .h = (uint16_t)(a->rows > top + gy ? a->rows - top - gy : 1)};
+ if (cur(a)->root) layout_node(cur(a)->root, r);
}
void app_write_focused(app_t *a, const void *buf, size_t len) {
- if (a->focus) pane_write(a->focus->pane, buf, len);
+ if (cur(a)->focus) pane_write(cur(a)->focus->pane, buf, len);
}
void app_resize(app_t *a, uint16_t cols, uint16_t rows) {
@@ -229,7 +301,7 @@ static void replace_child(node_t *parent, node_t *old, node_t *new_) {
}
static void split_focus(app_t *a, split_dir_t dir) {
- node_t *leaf = a->focus;
+ node_t *leaf = cur(a)->focus;
if (!leaf) return;
node_t *fresh = leaf_new(a);
if (!fresh) return;
@@ -256,12 +328,12 @@ static void split_focus(app_t *a, split_dir_t dir) {
sp->kids[1] = fresh;
sp->parent = leaf->parent;
if (leaf->parent) replace_child(leaf->parent, leaf, sp);
- else a->root = sp;
+ else cur(a)->root = sp;
leaf->parent = sp;
fresh->parent = sp;
}
- a->focus = fresh;
+ cur(a)->focus = fresh;
layout(a);
}
@@ -270,13 +342,25 @@ static node_t *first_leaf(node_t *n) {
return n;
}
+static void tab_remove(app_t *a, size_t ti) {
+ node_free(a->tabs[ti].root);
+ memmove(&a->tabs[ti], &a->tabs[ti + 1],
+ (a->ntabs - ti - 1) * sizeof *a->tabs);
+ a->ntabs--;
+ if (a->cur >= a->ntabs && a->ntabs) a->cur = a->ntabs - 1;
+}
+
static void close_leaf(app_t *a, node_t *leaf) {
+ size_t ti = tab_of(a, leaf);
+ if (ti == (size_t)-1) return;
+ tab_t *t = &a->tabs[ti];
node_t *p = leaf->parent;
- if (!p) { /* last pane */
+
+ if (!p) { /* the tab's last pane: the tab goes with it */
+ t->root = NULL;
node_free(leaf);
- a->root = NULL;
- a->focus = NULL;
- a->quit = true;
+ tab_remove(a, ti);
+ if (a->ntabs == 0) a->quit = true;
return;
}
@@ -291,12 +375,12 @@ static void close_leaf(app_t *a, node_t *leaf) {
if (p->nkids == 1) { /* a split with one child is just that child */
survivor->parent = p->parent;
if (p->parent) replace_child(p->parent, p, survivor);
- else a->root = survivor;
+ else t->root = survivor;
free(p->kids);
free(p);
}
- if (a->focus == leaf || a->focus == p) a->focus = first_leaf(survivor);
+ if (t->focus == leaf || t->focus == p) t->focus = first_leaf(survivor);
layout(a);
}
@@ -312,13 +396,123 @@ static void reap_cb(node_t *n, void *ud) {
void app_reap(app_t *a) {
for (;;) {
struct reap r = {a, NULL};
- walk(a->root, reap_cb, &r);
+ walk_all(a, reap_cb, &r);
if (!r.dead) break;
close_leaf(a, r.dead);
- if (!a->root) break;
+ if (a->ntabs == 0) break;
+ }
+}
+
+uint32_t app_new_tab(app_t *a, const char *name) {
+ tab_t *t = tab_add(a, name);
+ node_t *leaf = leaf_new(a);
+ if (!leaf) {
+ a->ntabs--;
+ return 0;
}
+ t->root = leaf;
+ t->focus = leaf;
+ a->cur = a->ntabs - 1;
+ layout(a);
+ return t->id;
+}
+
+bool app_select_tab(app_t *a, size_t index) {
+ if (index >= a->ntabs) return false;
+ a->cur = index;
+ layout(a);
+ return true;
+}
+
+bool app_select_tab_id(app_t *a, uint32_t id) {
+ for (size_t i = 0; i < a->ntabs; i++)
+ if (a->tabs[i].id == id) return app_select_tab(a, i);
+ return false;
+}
+
+void app_cycle_tab(app_t *a, int delta) {
+ if (a->ntabs < 2) return;
+ long n = (long)a->ntabs;
+ a->cur = (size_t)(((long)a->cur + delta % n + n) % n);
+ layout(a);
}
+size_t app_tab_count(const app_t *a) { return a->ntabs; }
+
+/* D8's trust model: a purpose declared by a layout outranks an in-band one and
+ * cannot be overridden, so `cat hostile.txt` in a pane cannot relabel a
+ * project tab. `declared` is only ever true on the control path. */
+bool app_set_pane_purpose(app_t *a, uint32_t id, const char *purpose,
+ bool declared) {
+ struct byid b = {id, NULL};
+ walk_all(a, byid_cb, &b);
+ if (!b.found) return false;
+ if (b.found->purpose_locked && !declared) return false;
+ sanitise_purpose(purpose, b.found->purpose, sizeof b.found->purpose);
+ if (declared) b.found->purpose_locked = true;
+ return true;
+}
+
+bool app_set_tab_purpose(app_t *a, uint32_t id, const char *purpose,
+ bool declared) {
+ for (size_t i = 0; i < a->ntabs; i++) {
+ tab_t *t = &a->tabs[i];
+ if (t->id != id) continue;
+ if (t->purpose_locked && !declared) return false;
+ sanitise_purpose(purpose, t->purpose, sizeof t->purpose);
+ if (declared) t->purpose_locked = true;
+ return true;
+ }
+ return false;
+}
+
+bool app_set_tab_name(app_t *a, uint32_t id, const char *name) {
+ for (size_t i = 0; i < a->ntabs; i++)
+ if (a->tabs[i].id == id) {
+ snprintf(a->tabs[i].name, sizeof a->tabs[i].name, "%s", name);
+ return true;
+ }
+ return false;
+}
+
+/* Control-API verbs that address a pane by id, rather than "the focused one".
+ * They select the pane's tab first, so scripting a background tab works. */
+static node_t *pane_by_id(app_t *a, uint32_t id) {
+ struct byid b = {id, NULL};
+ walk_all(a, byid_cb, &b);
+ return b.found;
+}
+
+bool app_focus_pane(app_t *a, uint32_t id) {
+ node_t *n = pane_by_id(a, id);
+ if (!n) return false;
+ size_t ti = tab_of(a, n);
+ if (ti == (size_t)-1) return false;
+ a->cur = ti;
+ a->tabs[ti].focus = n;
+ layout(a);
+ return true;
+}
+
+bool app_split_pane(app_t *a, uint32_t id, bool rows) {
+ if (id && !app_focus_pane(a, id)) return false;
+ split_focus(a, rows ? SPLIT_ROWS : SPLIT_COLS);
+ return true;
+}
+
+bool app_close_pane(app_t *a, uint32_t id) {
+ node_t *n = id ? pane_by_id(a, id) : cur(a)->focus;
+ if (!n) return false;
+ close_leaf(a, n);
+ return true;
+}
+
+uint32_t app_focused_pane_id(app_t *a) {
+ return a->ntabs && cur(a)->focus ? cur(a)->focus->id : 0;
+}
+
+uint32_t app_current_tab_id(app_t *a) { return a->ntabs ? cur(a)->id : 0; }
+
/* ---- focus -------------------------------------------------------------- */
struct dirsearch {
@@ -346,10 +540,10 @@ static void dir_cb(node_t *n, void *ud) {
}
static void focus_dir(app_t *a, int dx, int dy) {
- if (!a->focus) return;
- struct dirsearch d = {a->focus, dx, dy, NULL, 0};
- walk(a->root, dir_cb, &d);
- if (d.best) a->focus = d.best;
+ if (!cur(a)->focus) return;
+ struct dirsearch d = {cur(a)->focus, dx, dy, NULL, 0};
+ walk(cur(a)->root, dir_cb, &d);
+ if (d.best) cur(a)->focus = d.best;
}
struct nextsearch {
@@ -365,9 +559,9 @@ static void next_cb(node_t *n, void *ud) {
}
static void focus_next(app_t *a) {
- struct nextsearch s = {a->focus, NULL, NULL, NULL, false};
- walk(a->root, next_cb, &s);
- a->focus = s.next ? s.next : s.first;
+ struct nextsearch s = {cur(a)->focus, NULL, NULL, NULL, false};
+ walk(cur(a)->root, next_cb, &s);
+ cur(a)->focus = s.next ? s.next : s.first;
}
/* ---- drawing ------------------------------------------------------------ */
@@ -375,7 +569,7 @@ static void focus_next(app_t *a) {
static void draw_frame(app_t *a, screen_t *s, node_t *leaf) {
rect_t r = leaf->rect;
if (r.w < 3 || r.h < 3) return;
- bool focused = leaf == a->focus;
+ bool focused = leaf == cur(a)->focus;
color_t fg = focused ? FRAME_FOCUS : FRAME_IDLE;
uint16_t attrs = 0;
@@ -449,7 +643,27 @@ static void draw_cb(node_t *n, void *ud) {
action);
draw_frame(d->a, d->s, n);
- pane_compose(n->pane, d->s, n->content.x, n->content.y, n == d->a->focus);
+ pane_compose(n->pane, d->s, n->content.x, n->content.y, n == cur(d->a)->focus);
+}
+
+static void draw_tab_strip(app_t *a, screen_t *s) {
+ uint16_t x = (uint16_t)(CFG.gap * CFG.gap_aspect);
+ for (size_t i = 0; i < a->ntabs && x < s->cols; i++) {
+ tab_t *t = &a->tabs[i];
+ char label[80];
+ const char *nm = t->name[0] ? t->name : (t->purpose[0] ? t->purpose : "");
+ if (nm[0]) snprintf(label, sizeof label, " %zu:%s ", i + 1, nm);
+ else snprintf(label, sizeof label, " %zu ", i + 1);
+
+ bool active = i == a->cur;
+ uint16_t w = screen_text(s, x, CFG.gap, label,
+ active ? TITLE_FOCUS : FRAME_IDLE, NO_COLOR,
+ active ? ATTR_BOLD : 0);
+ char action[48];
+ snprintf(action, sizeof action, "tab:%u", t->id);
+ hit_add(&s->hits, x, CFG.gap, w, 1, action);
+ x = (uint16_t)(x + w);
+ }
}
void app_compose(app_t *a, screen_t *s) {
@@ -457,30 +671,27 @@ void app_compose(app_t *a, screen_t *s) {
hit_reset(&s->hits);
s->cursor_visible = false;
a->painted = s;
- if (!a->root) return;
+ if (!a->ntabs || !cur(a)->root) return;
layout(a);
+ draw_tab_strip(a, s);
struct draw d = {a, s};
- walk(a->root, draw_cb, &d);
+ walk(cur(a)->root, draw_cb, &d);
}
/* ---- input -------------------------------------------------------------- */
-struct byid {
- uint32_t id;
- node_t *found;
-};
-static void byid_cb(node_t *n, void *ud) {
- struct byid *b = ud;
- if (n->id == b->id) b->found = n;
-}
-
static node_t *by_id(app_t *a, uint32_t id) {
struct byid b = {id, NULL};
- walk(a->root, byid_cb, &b);
+ walk(cur(a)->root, byid_cb, &b);
return b.found;
}
static void do_action(app_t *a, const char *action, const input_event_t *ev) {
+ if (strncmp(action, "tab:", 4) == 0) {
+ if (ev->maction == MOUSE_PRESS)
+ app_select_tab_id(a, (uint32_t)strtoul(action + 4, NULL, 10));
+ return;
+ }
/* Chrome activates on press. Forwarding to a pane does not: an app wants the
* release and the motion too. Without this, one click of the split button
* splits twice — press and release both landing on the same target. */
@@ -494,19 +705,19 @@ static void do_action(app_t *a, const char *action, const input_event_t *ev) {
if (!n) return;
if (strncmp(action, "split:", 6) == 0) {
- a->focus = n;
+ cur(a)->focus = n;
split_focus(a, SPLIT_COLS);
} else if (strncmp(action, "close:", 6) == 0) {
close_leaf(a, n);
} else if (strncmp(action, "pane:", 5) == 0) {
- a->focus = n;
+ cur(a)->focus = n;
/* translate to pane-local coordinates before forwarding */
input_event_t local = *ev;
local.mx = (uint16_t)(ev->mx - n->content.x);
local.my = (uint16_t)(ev->my - n->content.y);
pane_send_mouse(n->pane, &local);
} else if (strncmp(action, "focus:", 6) == 0) {
- a->focus = n;
+ cur(a)->focus = n;
}
}
@@ -521,25 +732,33 @@ static bool prefix_command(app_t *a, const input_event_t *ev) {
split_focus(a, SPLIT_ROWS);
return true;
case GHOSTTY_KEY_X:
- if (a->focus) close_leaf(a, a->focus);
+ if (cur(a)->focus) close_leaf(a, cur(a)->focus);
return true;
case GHOSTTY_KEY_O: focus_next(a); return true;
+ case GHOSTTY_KEY_C: app_new_tab(a, ""); return true;
+ case GHOSTTY_KEY_N: app_cycle_tab(a, 1); return true;
+ case GHOSTTY_KEY_P: app_cycle_tab(a, -1); return true;
case GHOSTTY_KEY_H: case GHOSTTY_KEY_ARROW_LEFT: focus_dir(a, -1, 0); return true;
case GHOSTTY_KEY_L: case GHOSTTY_KEY_ARROW_RIGHT: focus_dir(a, 1, 0); return true;
case GHOSTTY_KEY_K: case GHOSTTY_KEY_ARROW_UP: focus_dir(a, 0, -1); return true;
case GHOSTTY_KEY_J: case GHOSTTY_KEY_ARROW_DOWN: focus_dir(a, 0, 1); return true;
- default: return false;
+ default:
+ if (ev->key >= GHOSTTY_KEY_DIGIT_1 && ev->key <= GHOSTTY_KEY_DIGIT_9) {
+ app_select_tab(a, (size_t)(ev->key - GHOSTTY_KEY_DIGIT_1));
+ return true;
+ }
+ return false;
}
}
void app_event(app_t *a, const input_event_t *ev) {
- if (!a->focus) return;
+ if (!cur(a)->focus) return;
if (ev->kind == EV_KEY && ev->action != KEY_RELEASE) {
bool ctrl_a = (ev->mods & MOD_CTRL) && ev->unshifted == 'a';
if (a->prefix) {
a->prefix = false;
- if (ctrl_a) { pane_send_key(a->focus->pane, ev); return; }
+ if (ctrl_a) { pane_send_key(cur(a)->focus->pane, ev); return; }
prefix_command(a, ev); /* unbound: swallowed */
return;
}
@@ -551,7 +770,7 @@ void app_event(app_t *a, const input_event_t *ev) {
switch (ev->kind) {
case EV_KEY:
- pane_send_key(a->focus->pane, ev);
+ pane_send_key(cur(a)->focus->pane, ev);
break;
case EV_MOUSE: {
/* Mouse routing is a hit-list lookup, never a re-derivation of geometry:
@@ -562,7 +781,7 @@ void app_event(app_t *a, const input_event_t *ev) {
break;
}
case EV_PASTE:
- pane_send_paste(a->focus->pane, ev->paste, ev->paste_len);
+ pane_send_paste(cur(a)->focus->pane, ev->paste, ev->paste_len);
break;
default:
break;
@@ -588,7 +807,10 @@ static void panes_cb(node_t *n, void *ud) {
json_int(j, "content_y", n->content.y);
json_int(j, "content_w", n->content.w);
json_int(j, "content_h", n->content.h);
- json_bool(j, "focused", n == pj->a->focus);
+ json_bool(j, "focused", n == cur(pj->a)->focus);
+ json_str(j, "purpose", n->purpose, strlen(n->purpose));
+ json_bool(j, "purpose_declared", n->purpose_locked);
+ json_int(j, "tab", (long long)tab_of(pj->a, n) + 1);
const char *t = pane_title(n->pane);
json_str(j, "title", t ? t : "", t ? strlen(t) : 0);
json_obj_close(j);
@@ -598,7 +820,30 @@ char *app_panes_json(app_t *a) {
json_t j;
json_init(&j);
json_arr_open(&j, NULL);
- walk(a->root, panes_cb, &(struct panes_json){a, &j});
+ struct panes_json pj = {a, &j};
+ walk_all(a, panes_cb, &pj); /* every tab, so tooling can find any pane */
+ json_arr_close(&j);
+ return j.buf;
+}
+
+char *app_tabs_json(app_t *a) {
+ json_t j;
+ json_init(&j);
+ json_arr_open(&j, NULL);
+ for (size_t i = 0; i < a->ntabs; i++) {
+ tab_t *t = &a->tabs[i];
+ size_t panes = 0;
+ walk(t->root, count_cb, &panes);
+ json_obj_open(&j, NULL);
+ json_int(&j, "id", t->id);
+ json_int(&j, "index", (long long)i + 1);
+ json_str(&j, "name", t->name, strlen(t->name));
+ json_str(&j, "purpose", t->purpose, strlen(t->purpose));
+ json_bool(&j, "purpose_declared", t->purpose_locked);
+ json_bool(&j, "active", i == a->cur);
+ json_int(&j, "panes", (long long)panes);
+ json_obj_close(&j);
+ }
json_arr_close(&j);
return j.buf;
}
diff --git a/src/app.h b/src/app.h
index f9d8b58..9d38cba 100644
--- a/src/app.h
+++ b/src/app.h
@@ -38,7 +38,31 @@ bool app_detach_requested(const app_t *a);
void app_clear_detach(app_t *a);
size_t app_pane_count(const app_t *a);
-/* Layout as JSON, for the harness: id, rect, focus, title. Caller frees. */
+/* Tabs. A tab is a layout tree; panes in every tab keep running. */
+uint32_t app_new_tab(app_t *a, const char *name);
+bool app_select_tab(app_t *a, size_t index);
+bool app_select_tab_id(app_t *a, uint32_t id);
+void app_cycle_tab(app_t *a, int delta);
+size_t app_tab_count(const app_t *a);
+bool app_set_tab_name(app_t *a, uint32_t id, const char *name);
+
+/* Purposes (D8). `declared` means "from a layout/control API": it outranks an
+ * in-band purpose and locks it, so a pane cannot relabel itself afterwards.
+ * Returns false when a locked purpose refuses an in-band change. */
+bool app_set_pane_purpose(app_t *a, uint32_t id, const char *purpose,
+ bool declared);
+bool app_set_tab_purpose(app_t *a, uint32_t id, const char *purpose,
+ bool declared);
+
+/* Address panes by id (the control API), not "the focused one". */
+bool app_focus_pane(app_t *a, uint32_t id);
+bool app_split_pane(app_t *a, uint32_t id, bool rows);
+bool app_close_pane(app_t *a, uint32_t id);
+uint32_t app_focused_pane_id(app_t *a);
+uint32_t app_current_tab_id(app_t *a);
+
+/* State as JSON, for the control API and the harness. Caller frees. */
char *app_panes_json(app_t *a);
+char *app_tabs_json(app_t *a);
#endif /* SL0PTTY_APP_H */
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");
}
diff --git a/src/jsonval.c b/src/jsonval.c
new file mode 100644
index 0000000..acc3334
--- /dev/null
+++ b/src/jsonval.c
@@ -0,0 +1,261 @@
+#define _GNU_SOURCE
+#include "jsonval.h"
+
+#include <stdlib.h>
+#include <string.h>
+
+typedef struct {
+ const char *p;
+ bool bad;
+} P;
+
+static jv_t *parse_value(P *s);
+
+static void skip_ws(P *s) {
+ while (*s->p == ' ' || *s->p == '\t' || *s->p == '\n' || *s->p == '\r') s->p++;
+}
+
+static jv_t *jv_new(jv_kind_t k) {
+ jv_t *v = calloc(1, sizeof *v);
+ v->kind = k;
+ return v;
+}
+
+static void utf8_put(char **out, unsigned cp) {
+ char *o = *out;
+ if (cp < 0x80) *o++ = (char)cp;
+ else if (cp < 0x800) {
+ *o++ = (char)(0xc0 | (cp >> 6));
+ *o++ = (char)(0x80 | (cp & 0x3f));
+ } else if (cp < 0x10000) {
+ *o++ = (char)(0xe0 | (cp >> 12));
+ *o++ = (char)(0x80 | ((cp >> 6) & 0x3f));
+ *o++ = (char)(0x80 | (cp & 0x3f));
+ } else {
+ *o++ = (char)(0xf0 | (cp >> 18));
+ *o++ = (char)(0x80 | ((cp >> 12) & 0x3f));
+ *o++ = (char)(0x80 | ((cp >> 6) & 0x3f));
+ *o++ = (char)(0x80 | (cp & 0x3f));
+ }
+ *out = o;
+}
+
+static unsigned hex4(const char *p) {
+ unsigned v = 0;
+ for (int i = 0; i < 4; i++) {
+ char c = p[i];
+ v <<= 4;
+ if (c >= '0' && c <= '9') v |= (unsigned)(c - '0');
+ else if (c >= 'a' && c <= 'f') v |= (unsigned)(c - 'a' + 10);
+ else if (c >= 'A' && c <= 'F') v |= (unsigned)(c - 'A' + 10);
+ }
+ return v;
+}
+
+static char *parse_string_raw(P *s, size_t *out_len) {
+ if (*s->p != '"') {
+ s->bad = true;
+ return NULL;
+ }
+ s->p++;
+ const char *start = s->p;
+ size_t max = strlen(start) + 1;
+ char *buf = malloc(max);
+ char *o = buf;
+ while (*s->p && *s->p != '"') {
+ if (*s->p == '\\') {
+ s->p++;
+ switch (*s->p) {
+ case 'n': *o++ = '\n'; break;
+ case 't': *o++ = '\t'; break;
+ case 'r': *o++ = '\r'; break;
+ case 'b': *o++ = '\b'; break;
+ case 'f': *o++ = '\f'; break;
+ case '/': *o++ = '/'; break;
+ case '"': *o++ = '"'; break;
+ case '\\': *o++ = '\\'; break;
+ case 'u': {
+ unsigned cp = hex4(s->p + 1);
+ s->p += 4;
+ if (cp >= 0xd800 && cp < 0xdc00 && s->p[1] == '\\' && s->p[2] == 'u') {
+ unsigned lo = hex4(s->p + 3);
+ s->p += 6;
+ cp = 0x10000 + ((cp - 0xd800) << 10) + (lo - 0xdc00);
+ }
+ utf8_put(&o, cp);
+ break;
+ }
+ default:
+ s->bad = true;
+ free(buf);
+ return NULL;
+ }
+ s->p++;
+ } else {
+ *o++ = *s->p++;
+ }
+ }
+ if (*s->p != '"') {
+ s->bad = true;
+ free(buf);
+ return NULL;
+ }
+ s->p++;
+ *o = 0;
+ if (out_len) *out_len = (size_t)(o - buf);
+ return buf;
+}
+
+static jv_t *parse_value(P *s) {
+ skip_ws(s);
+ char c = *s->p;
+
+ if (c == '{') {
+ s->p++;
+ jv_t *v = jv_new(JV_OBJ);
+ skip_ws(s);
+ if (*s->p == '}') {
+ s->p++;
+ return v;
+ }
+ for (;;) {
+ skip_ws(s);
+ char *key = parse_string_raw(s, NULL);
+ if (s->bad) { jv_free(v); return NULL; }
+ skip_ws(s);
+ if (*s->p != ':') { s->bad = true; free(key); jv_free(v); return NULL; }
+ s->p++;
+ jv_t *val = parse_value(s);
+ if (!val) { free(key); jv_free(v); return NULL; }
+ v->keys = realloc(v->keys, (v->len + 1) * sizeof *v->keys);
+ v->kids = realloc(v->kids, (v->len + 1) * sizeof *v->kids);
+ v->keys[v->len] = key;
+ v->kids[v->len] = val;
+ v->len++;
+ skip_ws(s);
+ if (*s->p == ',') { s->p++; continue; }
+ if (*s->p == '}') { s->p++; return v; }
+ s->bad = true;
+ jv_free(v);
+ return NULL;
+ }
+ }
+
+ if (c == '[') {
+ s->p++;
+ jv_t *v = jv_new(JV_ARR);
+ skip_ws(s);
+ if (*s->p == ']') {
+ s->p++;
+ return v;
+ }
+ for (;;) {
+ jv_t *val = parse_value(s);
+ if (!val) { jv_free(v); return NULL; }
+ v->kids = realloc(v->kids, (v->len + 1) * sizeof *v->kids);
+ v->kids[v->len++] = val;
+ skip_ws(s);
+ if (*s->p == ',') { s->p++; continue; }
+ if (*s->p == ']') { s->p++; return v; }
+ s->bad = true;
+ jv_free(v);
+ return NULL;
+ }
+ }
+
+ if (c == '"') {
+ size_t len = 0;
+ char *str = parse_string_raw(s, &len);
+ if (!str) return NULL;
+ jv_t *v = jv_new(JV_STR);
+ v->str = str;
+ v->len = len;
+ return v;
+ }
+
+ if (strncmp(s->p, "true", 4) == 0) {
+ s->p += 4;
+ jv_t *v = jv_new(JV_BOOL);
+ v->b = true;
+ return v;
+ }
+ if (strncmp(s->p, "false", 5) == 0) {
+ s->p += 5;
+ jv_t *v = jv_new(JV_BOOL);
+ v->b = false;
+ return v;
+ }
+ if (strncmp(s->p, "null", 4) == 0) {
+ s->p += 4;
+ return jv_new(JV_NULL);
+ }
+
+ {
+ char *end = NULL;
+ double d = strtod(s->p, &end);
+ if (end == s->p) {
+ s->bad = true;
+ return NULL;
+ }
+ s->p = end;
+ jv_t *v = jv_new(JV_NUM);
+ v->num = d;
+ return v;
+ }
+}
+
+jv_t *jv_parse(const char *text) {
+ P s = {.p = text};
+ jv_t *v = parse_value(&s);
+ if (!v) return NULL;
+ skip_ws(&s);
+ if (*s.p) { /* trailing garbage */
+ jv_free(v);
+ return NULL;
+ }
+ return v;
+}
+
+void jv_free(jv_t *v) {
+ if (!v) return;
+ for (size_t i = 0; i < v->len; i++) {
+ if (v->kids) jv_free(v->kids[i]);
+ if (v->keys) free(v->keys[i]);
+ }
+ free(v->kids);
+ free(v->keys);
+ free(v->str);
+ free(v);
+}
+
+const jv_t *jv_get(const jv_t *obj, const char *key) {
+ if (!obj || obj->kind != JV_OBJ) return NULL;
+ for (size_t i = obj->len; i-- > 0;) /* last wins */
+ if (strcmp(obj->keys[i], key) == 0) return obj->kids[i];
+ return NULL;
+}
+
+const char *jv_str(const jv_t *v, const char *fallback) {
+ return v && v->kind == JV_STR ? v->str : fallback;
+}
+
+long jv_int(const jv_t *v, long fallback) {
+ if (!v) return fallback;
+ if (v->kind == JV_NUM) return (long)v->num;
+ if (v->kind == JV_STR) return strtol(v->str, NULL, 10);
+ return fallback;
+}
+
+bool jv_bool(const jv_t *v, bool fallback) {
+ return v && v->kind == JV_BOOL ? v->b : fallback;
+}
+
+const char *jv_gets(const jv_t *obj, const char *key, const char *fallback) {
+ return jv_str(jv_get(obj, key), fallback);
+}
+long jv_geti(const jv_t *obj, const char *key, long fallback) {
+ return jv_int(jv_get(obj, key), fallback);
+}
+bool jv_getb(const jv_t *obj, const char *key, bool fallback) {
+ return jv_bool(jv_get(obj, key), fallback);
+}
diff --git a/src/jsonval.h b/src/jsonval.h
new file mode 100644
index 0000000..34cc597
--- /dev/null
+++ b/src/jsonval.h
@@ -0,0 +1,40 @@
+/* A small JSON reader, for the control API (D3). We already emit JSON; this
+ * is the other half. Deliberately minimal: no streaming, no comments, no
+ * duplicate-key policy beyond "last wins". */
+#ifndef SL0PTTY_JSONVAL_H
+#define SL0PTTY_JSONVAL_H
+
+#include <stdbool.h>
+#include <stddef.h>
+
+typedef enum {
+ JV_NULL, JV_BOOL, JV_NUM, JV_STR, JV_ARR, JV_OBJ,
+} jv_kind_t;
+
+typedef struct jv jv_t;
+
+struct jv {
+ jv_kind_t kind;
+ bool b;
+ double num;
+ char *str; /* JV_STR: NUL-terminated, UTF-8 */
+ size_t len; /* JV_STR bytes; JV_ARR/JV_OBJ element count */
+ jv_t **kids; /* JV_ARR/JV_OBJ values */
+ char **keys; /* JV_OBJ keys */
+};
+
+/* NULL on malformed input. */
+jv_t *jv_parse(const char *text);
+void jv_free(jv_t *v);
+
+const jv_t *jv_get(const jv_t *obj, const char *key);
+const char *jv_str(const jv_t *v, const char *fallback);
+long jv_int(const jv_t *v, long fallback);
+bool jv_bool(const jv_t *v, bool fallback);
+
+/* Convenience: obj.key as a string/int/bool, with a fallback. */
+const char *jv_gets(const jv_t *obj, const char *key, const char *fallback);
+long jv_geti(const jv_t *obj, const char *key, long fallback);
+bool jv_getb(const jv_t *obj, const char *key, bool fallback);
+
+#endif /* SL0PTTY_JSONVAL_H */