diff options
| author | sl0p <sl0p@sl0p.foo> | 2026-08-13 22:58:24 +0200 |
|---|---|---|
| committer | sl0p <sl0p@sl0p.foo> | 2026-08-13 22:58:24 +0200 |
| commit | 7d416d95cb49f783f8c4ee6eddd5c2cdcd81bb40 (patch) | |
| tree | 6aaced8bd6927565816ce48ae07fb5f397d6edf7 | |
| parent | sl0ptty: M2 — server/client split, detach/reattach, control socket (diff) | |
| download | slosh-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.
| -rw-r--r-- | DESIGN.md | 6 | ||||
| -rw-r--r-- | Makefile | 2 | ||||
| -rw-r--r-- | src/app.c | 357 | ||||
| -rw-r--r-- | src/app.h | 26 | ||||
| -rw-r--r-- | src/cmd.c | 165 | ||||
| -rw-r--r-- | src/jsonval.c | 261 | ||||
| -rw-r--r-- | src/jsonval.h | 40 | ||||
| -rw-r--r-- | tests/harness.py | 13 | ||||
| -rw-r--r-- | tests/test_screen.py | 5 | ||||
| -rw-r--r-- | tests/test_tabs.py | 180 |
10 files changed, 992 insertions, 63 deletions
@@ -135,12 +135,12 @@ unreadable. |---|---| | **D1** | **OSC 5577 stays byte-compatible** with the sl0ppi fork, so the five existing pi extensions work unmodified. Delivered by a side-channel scanner on the pty stream (see the correction above), with a versioned `hello` handshake added on top — the fork's protocol is unversioned, which is a known risk — and an APC-based v2 once anything new is written against it. | | **D2** | **Config is a hand-rolled KDL subset.** Layouts are trees; KDL reads well as a tree and keeps continuity with sl0ppi muscle memory. ~400 LOC of parser, no dependency. Single-line nodes need `;` terminators — same rule as upstream. | -| **D3** | **Clean JSON control API** (newline-delimited JSON on a unix socket). We do *not* mimic `zellij action`'s surface. The `sl0ppi` CLI is ported on top of it later; `up`'s idempotency and D9's fail-open property carry over. | +| **D3** | **Clean JSON control API** (one JSON object per line on a unix socket): `{"cmd":"new-tab","purpose":"project:x.deadbeef"}` -> `{"ok":true,"id":2}`. We do *not* mimic `zellij action`'s surface. A bare-verb form (`panes`, `snapshot text`) is kept as a human/harness alias and runs the same code, so a script cannot drift from what the API does. The `sl0ppi` CLI is ported on top later; `up`'s idempotency and D9's fail-open property carry over. | | **D4** | **Scrollback yes** (free from lib-vt), **copy-mode UI later.** | | **D5** | Binary, session dir, socket and config are all named **`sl0ptty`**. | | **D6** | **Responsive layout is a pure function** — see below. | | **D7** | **Server renders, client is dumb.** The client forwards raw bytes and paints what it is sent — it does not even decode, so the decoder can change without redeploying clients, and a bug there costs a frame rather than a session. The same socket is the scripting API. Detach/reattach is table stakes because agents keep running. | -| **D8** | **Panes and tabs carry `purpose=`**, sl0ppi's semantics kept verbatim, including the trust model: *layout-declared purposes outrank in-band ones and cannot be overridden*, so `cat hostile.txt` cannot relabel a project tab. | +| **D8** | **Panes and tabs carry `purpose=`**, sl0ppi's semantics kept verbatim, including the trust model: *declared purposes outrank in-band ones and cannot be overridden*, so `cat hostile.txt` cannot relabel a project tab. Declared means "from a layout or an operator", i.e. the control path; the in-band path (OSC, M4) always passes `declared:false` and is refused against a locked slot. Sanitised on ingest to `[A-Za-z0-9_.:/-]`. | | **D9** | **No wasm plugins.** Status bar and pane finder are built in. A "plugin" is a subprocess speaking the control protocol. | | **D10** | **One attached client** in the MVP. The protocol allows N (read-only observers are what sl0p.foo actually wants); the multi-user *rendering* path is not built, because it is where the fork's phantom-client and `MY FOCUS AND:` bugs live. | | **D11** | **No terminfo/ncurses.** We are opinionated about the outer terminal: emit a known-good modern subset, probe with DA/XTGETTCAP where we must. | @@ -212,7 +212,7 @@ Frame pacing: pty reads are coalesced and painted on a timerfd at a cap - **M0.5** — headless driver + screen-assert harness ✅ - **M1** — layout tree, splits, focus, frames (gap/padding/title alignment) ✅ - **M2** — server/client split, detach/reattach, control socket ✅ -- **M3** — tabs, `purpose=`, JSON control API +- **M3** — tabs, `purpose=`, JSON control API ✅ - **M4** — chrome: OSC 5577, buttons, hit-list mouse, drag-to-reorder - **M5** — built-in status bar, pane finder overlay, responsive (D6) @@ -49,7 +49,7 @@ $(TEST_BIN): tests/input_test.c src/input.c $(VT_LIB) | build test: $(BIN) $(TEST_BIN) ## unit + headless checks (fast) ./$(TEST_BIN) @echo - cd tests && python3 test_screen.py && python3 test_layout.py + cd tests && python3 test_screen.py && python3 test_layout.py && python3 test_tabs.py test-live: $(BIN) ## the checks that need a real tty (slow) python3 tests/live_m0.py @@ -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; } @@ -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 */ @@ -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 */ diff --git a/tests/harness.py b/tests/harness.py index c29f4d3..2271dcb 100644 --- a/tests/harness.py +++ b/tests/harness.py @@ -113,6 +113,19 @@ class Session: raise RuntimeError("sl0ptty exited before answering snapshot") return Snapshot(json.loads(line)) + def api(self, cmd, **kw): + """One JSON control request; returns the parsed reply.""" + kw["cmd"] = cmd + self._cmd(json.dumps(kw)) + line = self.proc.stdout.readline() + if not line: + raise RuntimeError("sl0ptty exited before answering " + cmd) + return json.loads(line) + + def tabs(self): + self._cmd("tabs") + return json.loads(self.proc.stdout.readline()) + def panes(self): self._cmd("panes") line = self.proc.stdout.readline() diff --git a/tests/test_screen.py b/tests/test_screen.py index 6b82f1f..0dd24f2 100644 --- a/tests/test_screen.py +++ b/tests/test_screen.py @@ -93,9 +93,10 @@ def test_resize(): check("resize changes the composited size", snap.cols == 50 and snap.rows == 12 and len(snap.line(0)) == 50, f"{snap.cols}x{snap.rows}") - # chrome budget: gap 1 (x2 aspect => 2 cols) each side, plus a border + # chrome budget: gap 1 (x2 aspect => 2 cols) each side, a border, and the + # one-row tab strip along the top check("the pane grows with the screen, minus its chrome", - p["content_w"] == snap.cols - 6 and p["content_h"] == snap.rows - 4, + p["content_w"] == snap.cols - 6 and p["content_h"] == snap.rows - 5, str(p)) check("pane still responds after resize", s.alive()) diff --git a/tests/test_tabs.py b/tests/test_tabs.py new file mode 100644 index 0000000..c45073e --- /dev/null +++ b/tests/test_tabs.py @@ -0,0 +1,180 @@ +#!/usr/bin/env python3 +"""M3: tabs, purposes and the JSON control API.""" +import sys + +from harness import Session, check, report + +SH = ["/bin/sh", "-c", "stty raw -echo; cat"] + + +def test_tabs(): + with Session(SH, cols=60, rows=14) as s: + s.settle() + check("one tab to start", len(s.tabs()) == 1) + + s.raw("in-tab-one") + s.settle() + s.key("c") # new tab + s.settle() + tabs = s.tabs() + check("C-a c makes a tab", len(tabs) == 2, str(tabs)) + check("the new tab is active", tabs[1]["active"] and not tabs[0]["active"]) + check("each tab has its own pane", [t["panes"] for t in tabs] == [1, 1], + str(tabs)) + + snap = s.snapshot() + check("the new tab's pane is empty", "in-tab-one" not in snap.screen()) + check("the tab strip is drawn", "1" in snap.line(1) and "2" in snap.line(1), + repr(snap.line(1))) + + s.key("p") # previous tab + s.settle() + check("C-a p goes back", s.tabs()[0]["active"]) + check("the first tab kept its content", "in-tab-one" in s.snapshot().screen(), + repr(s.snapshot().screen()[:200])) + + s.key("2") + s.settle() + check("C-a 2 selects by number", s.tabs()[1]["active"]) + + +def test_background_tabs_keep_running(): + with Session(SH, cols=60, rows=14) as s: + s.settle() + s.key("c") + s.settle() + first = [p for p in s.panes() if p["tab"] == 1][0] + # drive the *background* tab's pane through the API + s.api("focus", id=first["id"]) + s.settle() + s.raw("still-alive") + s.settle() + s.key("n") # away again + s.settle() + check("a background pane still accepts input", + any(p["tab"] == 1 for p in s.panes()), str(s.panes())) + s.api("select-tab", index=1) + s.settle() + check("its output survived being off-screen", + "still-alive" in s.snapshot().screen(), + repr(s.snapshot().screen()[:200])) + + +def test_tab_click(): + with Session(SH, cols=60, rows=14) as s: + s.settle() + s.key("c") + s.settle() + snap = s.snapshot() + pos = snap.find("1") # the first tab's label, on the strip row + check("tab label is on the strip", pos is not None and pos[1] == 1, str(pos)) + if pos: + x, y = pos + action = snap.hit_at(x, y) + check("the tab label is clickable", action == "tab:1", str(action)) + s.click(x, y) + s.settle() + check("clicking a tab switches to it", s.tabs()[0]["active"], + str(s.tabs())) + + +def test_closing_a_tab(): + with Session(SH, cols=60, rows=14) as s: + s.settle() + s.key("c") + s.settle() + s.key("\\\\") # two panes in tab 2 + s.settle() + check("tab 2 has two panes", s.tabs()[1]["panes"] == 2, str(s.tabs())) + s.key("x") + s.settle() + check("closing one pane keeps the tab", len(s.tabs()) == 2, str(s.tabs())) + s.key("x") + s.settle() + check("closing the last pane closes the tab", len(s.tabs()) == 1, + str(s.tabs())) + check("the surviving tab is active and usable", + s.tabs()[0]["active"] and s.api("alive")["alive"]) + + +def test_purpose_trust_model(): + """D8: a declared purpose outranks an in-band one and cannot be overridden.""" + with Session(SH, cols=60, rows=12) as s: + s.settle() + pane = s.pane() + + r = s.api("set-purpose", target="pane", id=pane["id"], + purpose="agent:main", declared=True) + check("a declared purpose is accepted", r["ok"], str(r)) + p = s.pane() + check("the purpose is reported", p["purpose"] == "agent:main", str(p)) + check("and marked declared", p["purpose_declared"], str(p)) + + r = s.api("set-purpose", target="pane", id=pane["id"], + purpose="evil:overridden", declared=False) + check("an in-band purpose cannot override a declared one", not r["ok"], + str(r)) + check("the declared purpose is intact", + s.pane()["purpose"] == "agent:main", str(s.pane())) + + r = s.api("set-purpose", target="tab", id=s.tabs()[0]["id"], + purpose="project:foo bar/../$(rm -rf)", declared=True) + check("a tab purpose is sanitised on ingest", + s.tabs()[0]["purpose"] == "project:foobar/../rm-rf", + str(s.tabs()[0]["purpose"])) + + with Session(SH, cols=60, rows=12) as s: + s.settle() + pane = s.pane() + r = s.api("set-purpose", target="pane", id=pane["id"], + purpose="guessed:by-the-pane", declared=False) + check("an in-band purpose is allowed when nothing is declared", r["ok"], + str(r)) + check("and does not lock the slot", + not s.pane()["purpose_declared"], str(s.pane())) + + +def test_json_api(): + with Session(SH, cols=60, rows=14) as s: + s.settle() + base = s.pane()["id"] + + r = s.api("split", dir="rows", id=base) + check("split answers with the new pane id", r["ok"] and r["id"] != base, + str(r)) + new = r["id"] + + r = s.api("focus", id=base) + check("focus by id works", r["ok"] and r["id"] == base, str(r)) + check("focus by id is reflected in the layout", + [p for p in s.panes() if p["focused"]][0]["id"] == base) + + r = s.api("new-tab", name="build", purpose="project:x.deadbeef") + check("new-tab answers with an id", r["ok"] and r["id"], str(r)) + t = [t for t in s.tabs() if t["id"] == r["id"]][0] + check("new-tab records name and declared purpose", + t["name"] == "build" and t["purpose"] == "project:x.deadbeef" + and t["purpose_declared"], str(t)) + + r = s.api("snapshot", format="text") + check("snapshot text comes back as JSON", r["ok"] and "text" in r, + str(r)[:80]) + + r = s.api("close", id=new) + check("close by id works", r["ok"], str(r)) + + check("unknown commands are refused, not fatal", + s.api("nope")["error"] == "unknown cmd") + r = s.api("focus", id=9999) + check("bad ids are refused, not fatal", not r["ok"] and s.api("alive")["ok"], + str(r)) + + +if __name__ == "__main__": + test_tabs() + test_background_tabs_keep_running() + test_tab_click() + test_closing_a_tab() + test_purpose_trust_model() + test_json_api() + sys.exit(report()) |
