aboutsummaryrefslogtreecommitdiffstats
path: root/src/app.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/app.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/app.c')
-rw-r--r--src/app.c357
1 files changed, 301 insertions, 56 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;
}