diff options
| author | sl0p <sl0p@sl0p.foo> | 2026-08-13 23:27:47 +0200 |
|---|---|---|
| committer | sl0p <sl0p@sl0p.foo> | 2026-08-13 23:27:47 +0200 |
| commit | 58afd137373fb395fc0479d091c4da9ffa60324a (patch) | |
| tree | 93839e3c20ff722819444ed22deaef07dc6e6041 /src | |
| parent | sl0ptty: M6 — the config file, and a README (diff) | |
| download | slosh-58afd137373fb395fc0479d091c4da9ffa60324a.tar.gz slosh-58afd137373fb395fc0479d091c4da9ffa60324a.tar.xz slosh-58afd137373fb395fc0479d091c4da9ffa60324a.zip | |
sl0ptty: M7 — layout files and suspended panes
A session can be a file:
layout {
tab name="api" purpose="project:api.a1b2c3" cwd="~/dev/api" {
pane purpose="agent:main" command="pi"
pane split="rows" {
pane purpose="service:web" command="npm run dev" suspended=true
pane purpose="shell:scratch"
}
}
}
built by --layout at startup or applied to a running session with
{"cmd":"apply-layout", path|kdl, replace?}. Idempotency stays with the caller,
which is where sl0ppi's CLI design already puts it: diff list-tabs against the
projects on disk, then apply what is missing. close-tab completes that loop.
Purposes a layout declares are locked, so identity comes from the file rather
than from whatever the program inside prints — tested by having a pane try to
take one in-band and be refused.
Suspended panes: a pane that exists, is laid out, and has run nothing, showing
what it would run and starting on its first keystroke (which is consumed, not
forwarded). This is sl0ppi's D6 — a root with twelve projects must not become
twelve running servers — and it is the reason can be cheap.
Also fixes a test-isolation bug latent since M2: the live tests attached to the
session called "main", which is whatever happens to exist on the box rather
than one they created. They now use a unique session per run, which is how a
run that had somebody else's htop in it became reproducible.
284 checks green (257 headless + 27 live).
Diffstat (limited to 'src')
| -rw-r--r-- | src/app.c | 199 | ||||
| -rw-r--r-- | src/app.h | 8 | ||||
| -rw-r--r-- | src/cmd.c | 18 | ||||
| -rw-r--r-- | src/headless.c | 7 | ||||
| -rw-r--r-- | src/main.c | 13 | ||||
| -rw-r--r-- | src/pane.c | 59 | ||||
| -rw-r--r-- | src/server.c | 11 | ||||
| -rw-r--r-- | src/server.h | 4 | ||||
| -rw-r--r-- | src/sl0ptty.h | 7 |
9 files changed, 314 insertions, 12 deletions
@@ -10,6 +10,7 @@ #include <string.h> #include "json.h" +#include "kdl.h" /* ---- config ------------------------------------------------------------- */ @@ -142,6 +143,18 @@ static void on_pane_osc(pane_t *p, const char *verb, const char *payload, if (b.found) app_set_pane_purpose(a, b.found->id, payload, false); } +static node_t *leaf_new_ex(app_t *a, const char *const argv[], const char *cwd, + bool suspended, const char *label) { + pane_t *p = pane_new_ex(argv, 1, 1, cwd, suspended, label); + if (!p) return NULL; + node_t *n = calloc(1, sizeof *n); + n->kind = NODE_LEAF; + n->pane = p; + n->id = ++a->next_id; + pane_set_osc_handler(p, on_pane_osc, a); + return n; +} + static node_t *leaf_new(app_t *a) { pane_t *p = pane_new(a->argv, 1, 1, NULL); if (!p) return NULL; @@ -593,6 +606,17 @@ void app_cycle_tab(app_t *a, int delta) { size_t app_tab_count(const app_t *a) { return a->ntabs; } +bool app_close_tab(app_t *a, uint32_t id) { + for (size_t i = 0; i < a->ntabs; i++) { + if (a->tabs[i].id != id) continue; + tab_remove(a, i); + if (a->ntabs == 0) a->quit = true; + else layout(a); + return true; + } + return false; +} + /* 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. */ @@ -667,6 +691,150 @@ uint32_t app_focused_pane_id(app_t *a) { uint32_t app_current_tab_id(app_t *a) { return a->ntabs ? cur(a)->id : 0; } +/* ---- layouts ------------------------------------------------------------ * + * + * layout { + * tab name="api" purpose="project:api.a1b2" cwd="/home/user/dev/api" { + * pane purpose="agent:main" command="pi" suspended=true + * pane split="rows" { + * pane command="npm run dev" suspended=true + * pane + * } + * } + * } + * + * A `pane` with children is a split; `split` on it picks the direction. Every + * purpose a layout declares is locked, which is the point: identity comes from + * the layout, not from whatever the program inside decides to print (D8). + */ + +static node_t *build_pane(app_t *a, const kdl_node_t *node, const char *cwd) { + const char *node_cwd = kdl_prop(node, "cwd", cwd); + + /* a split: children, in order, in one direction */ + size_t kids = 0; + for (size_t i = 0; i < node->nkids; i++) + if (strcmp(node->kids[i]->name, "pane") == 0) kids++; + + if (kids) { + node_t *sp = calloc(1, sizeof *sp); + sp->kind = NODE_SPLIT; + sp->dir = strcmp(kdl_prop(node, "split", "cols"), "rows") == 0 ? SPLIT_ROWS + : SPLIT_COLS; + for (size_t i = 0; i < node->nkids; i++) { + if (strcmp(node->kids[i]->name, "pane") != 0) continue; + node_t *kid = build_pane(a, node->kids[i], node_cwd); + if (!kid) continue; + sp->kids = realloc(sp->kids, (sp->nkids + 1) * sizeof *sp->kids); + sp->kids[sp->nkids++] = kid; + kid->parent = sp; + } + if (sp->nkids == 0) { + free(sp); + return NULL; + } + if (sp->nkids == 1) { /* a split of one is just the pane */ + node_t *only = sp->kids[0]; + only->parent = NULL; + free(sp->kids); + free(sp); + return only; + } + return sp; + } + + const char *command = kdl_prop(node, "command", NULL); + bool suspended = kdl_prop_bool(node, "suspended", false); + const char *argv[4]; + if (command) { + argv[0] = "/bin/sh"; + argv[1] = "-c"; + argv[2] = command; + argv[3] = NULL; + } else { + argv[0] = a->argv[0]; + argv[1] = NULL; + for (size_t i = 1; a->argv[i] && i < 3; i++) argv[i] = a->argv[i]; + } + + node_t *leaf = leaf_new_ex(a, command ? argv : a->argv, node_cwd, suspended, + command ? command : ""); + if (!leaf) return NULL; + const char *purpose = kdl_prop(node, "purpose", NULL); + if (purpose) { + sanitise_purpose(purpose, leaf->purpose, sizeof leaf->purpose); + leaf->purpose_locked = true; /* declared by a layout: in-band cannot win */ + } + return leaf; +} + +bool app_apply_layout(app_t *a, const kdl_node_t *root, bool replace, char *err, + size_t errcap) { + const kdl_node_t *lay = kdl_child(root, "layout"); + if (!lay) lay = root; /* allow a bare list of tabs */ + + size_t before = a->ntabs; + size_t made = 0; + for (size_t i = 0; i < lay->nkids; i++) { + const kdl_node_t *t = lay->kids[i]; + if (strcmp(t->name, "tab") != 0) continue; + + tab_t *tab = tab_add(a, kdl_prop(t, "name", "")); + const char *purpose = kdl_prop(t, "purpose", NULL); + if (purpose) { + sanitise_purpose(purpose, tab->purpose, sizeof tab->purpose); + tab->purpose_locked = true; + } + + /* the tab body is a split of its pane children */ + kdl_node_t body = {.name = (char *)"pane", .kids = t->kids, + .nkids = t->nkids, .props = t->props, + .nprops = t->nprops}; + node_t *tree = build_pane(a, &body, kdl_prop(t, "cwd", NULL)); + if (!tree) tree = leaf_new(a); + if (!tree) { + a->ntabs--; + if (err) snprintf(err, errcap, "cannot create panes for tab %zu", i + 1); + return false; + } + tab->root = tree; + tab->focus = first_leaf_of(tree); + made++; + } + + if (!made) { + if (err) snprintf(err, errcap, "layout declares no tabs"); + return false; + } + + if (replace) { /* drop the tabs that existed before this layout */ + for (size_t i = 0; i < before; i++) node_free(a->tabs[i].root); + memmove(&a->tabs[0], &a->tabs[before], made * sizeof *a->tabs); + a->ntabs = made; + } + a->cur = replace ? 0 : before; + layout(a); + return true; +} + +bool app_apply_layout_text(app_t *a, const char *text, bool replace, char *err, + size_t errcap) { + kdl_node_t *root = kdl_parse(text, err, errcap); + if (!root) return false; + bool ok = app_apply_layout(a, root, replace, err, errcap); + kdl_free(root); + return ok; +} + +bool app_apply_layout_file(app_t *a, const char *path, bool replace, char *err, + size_t errcap) { + kdl_node_t *root = kdl_parse_file(path, err, errcap); + if (!root) return false; + bool ok = app_apply_layout(a, root, replace, err, errcap); + kdl_free(root); + return ok; +} + /* ---- focus -------------------------------------------------------------- */ struct dirsearch { @@ -869,6 +1037,31 @@ static void draw_cb(node_t *n, void *ud) { action); draw_frame(d->a, d->s, n); + if (pane_suspended(n->pane)) { + /* The pane exists, is laid out, and has run nothing. Say what it would. */ + const char *label = pane_label(n->pane); + char line[256]; + snprintf(line, sizeof line, "press a key to run: %s", + label && *label ? label : "shell"); + /* Truncate rather than vanish: a hint that only appears on wide panes is + * worse than a clipped one, and the pane may be narrow precisely because + * there are twelve of them. */ + size_t w = strlen(line); + if (w > n->content.w) { + w = n->content.w; + if (w > 1) { + line[w - 1] = 0; + line[w - 2] = 0xe2; /* fall back to a plain dot rather than a cut UTF-8 */ + line[w - 2] = '.'; + } else { + line[w] = 0; + } + } + screen_text(d->s, (uint16_t)(n->content.x + (n->content.w - w) / 2), + (uint16_t)(n->content.y + n->content.h / 2), line, FRAME_IDLE, + NO_COLOR, 0); + return; + } pane_compose(n->pane, d->s, n->content.x, n->content.y, n == cur(d->a)->focus); } @@ -1200,6 +1393,11 @@ void app_event(app_t *a, const input_event_t *ev) { switch (ev->kind) { case EV_KEY: + /* the first keystroke starts a suspended pane, and is not forwarded */ + if (pane_suspended(cur(a)->focus->pane)) { + pane_start(cur(a)->focus->pane); + break; + } pane_send_key(cur(a)->focus->pane, ev); break; case EV_MOUSE: { @@ -1239,6 +1437,7 @@ static void panes_cb(node_t *n, void *ud) { json_int(j, "content_h", n->content.h); json_bool(j, "focused", n == cur(pj->a)->focus); json_bool(j, "hidden", n->hidden); + json_bool(j, "suspended", pane_suspended(n->pane)); 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); @@ -46,6 +46,7 @@ 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_close_tab(app_t *a, uint32_t id); 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 @@ -56,6 +57,13 @@ bool app_set_pane_purpose(app_t *a, uint32_t id, const char *purpose, bool app_set_tab_purpose(app_t *a, uint32_t id, const char *purpose, bool declared); +/* Build tabs and panes from a KDL layout. `replace` drops what was there. + * Purposes a layout declares are locked (D8). */ +bool app_apply_layout_text(app_t *a, const char *text, bool replace, char *err, + size_t errcap); +bool app_apply_layout_file(app_t *a, const char *path, bool replace, char *err, + size_t errcap); + /* 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); @@ -158,6 +158,11 @@ static char *cmd_json(app_t *a, screen_t *s, input_parser_t *in, : 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, "close-tab") == 0) { + return app_close_tab(a, (uint32_t)jv_geti(req, "id", 0)) + ? jok_int(NULL, 0) + : 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", "")) @@ -176,6 +181,19 @@ static char *cmd_json(app_t *a, screen_t *s, input_parser_t *in, : app_set_pane_purpose(a, id, purpose, declared); return ok ? jok_int(NULL, 0) : jerr("refused"); } + if (strcmp(cmd, "apply-layout") == 0) { + const char *path = jv_gets(req, "path", NULL); + const char *text = jv_gets(req, "kdl", NULL); + bool replace = jv_getb(req, "replace", false); + char err[256] = {0}; + bool ok = text ? app_apply_layout_text(a, text, replace, err, sizeof err) + : path ? app_apply_layout_file(a, path, replace, err, sizeof err) + : false; + if (!ok) return jerr(err[0] ? err : "need a path or kdl"); + app_resize(a, s->cols, s->rows); + s->force_full = true; + return jok_raw("tabs", app_tabs_json(a)); + } if (strcmp(cmd, "reload") == 0) { char err[256] = {0}; if (!app_reload_config(err, sizeof err)) return jerr(err[0] ? err : "reload failed"); diff --git a/src/headless.c b/src/headless.c index 8be9f87..8fafd05 100644 --- a/src/headless.c +++ b/src/headless.c @@ -51,12 +51,17 @@ static void settle(app_t *a, int quiet) { } int run_headless(const char *const argv[], uint16_t cols, uint16_t rows, - int idle_ms, bool script) { + int idle_ms, bool script, const char *layout) { app_t *a = app_new(argv, cols, rows); if (!a) { fprintf(stderr, "sl0ptty: cannot spawn pane\n"); return 1; } + if (layout) { + char err[256] = {0}; + if (!app_apply_layout_file(a, layout, true, err, sizeof err)) + fprintf(stderr, "sl0ptty: %s: %s\n", layout, err[0] ? err : "bad layout"); + } app_resize(a, cols, rows); screen_t s; screen_init(&s, cols, rows); @@ -22,7 +22,7 @@ #include "server.h" int run_headless(const char *const argv[], uint16_t cols, uint16_t rows, - int idle_ms, bool script); + int idle_ms, bool script, const char *layout); static void term_size(uint16_t *cols, uint16_t *rows) { struct winsize ws; @@ -36,7 +36,8 @@ static void term_size(uint16_t *cols, uint16_t *rows) { } static void usage(void) { - fputs("usage: sl0ptty [-s NAME] [ls | cmd LINE | -- CMD...]\n", stderr); + fputs("usage: sl0ptty [-s NAME] [--layout FILE] [ls | cmd LINE | -- CMD...]\n", + stderr); } int main(int argc, char **argv) { @@ -45,6 +46,7 @@ int main(int argc, char **argv) { int idle_ms = 300; const char *name = "main"; const char *cmd_line = NULL; + const char *layout = NULL; bool list = false; const char *cmd_argv[64]; int cmd_n = 0; @@ -55,6 +57,7 @@ int main(int argc, char **argv) { else if (strcmp(a, "--script") == 0) headless = script = true; else if (strcmp(a, "--server") == 0) server = true; else if (strcmp(a, "-s") == 0 && i + 1 < argc) name = argv[++i]; + else if (strcmp(a, "--layout") == 0 && i + 1 < argc) layout = argv[++i]; else if (strcmp(a, "ls") == 0) list = true; else if (strcmp(a, "cmd") == 0 && i + 1 < argc) cmd_line = argv[++i]; else if (strcmp(a, "--cols") == 0 && i + 1 < argc) cols = (uint16_t)atoi(argv[++i]); @@ -101,14 +104,14 @@ int main(int argc, char **argv) { return client_control(fd, cmd_line); } - if (headless) return run_headless(cmd_argv, cols, rows, idle_ms, script); + if (headless) return run_headless(cmd_argv, cols, rows, idle_ms, script, layout); if (isatty(STDOUT_FILENO)) term_size(&cols, &rows); - if (server) return server_run(name, cmd_argv, cols, rows); + if (server) return server_run(name, cmd_argv, cols, rows, layout); /* attach, creating the session if nobody is home */ int fd = server_connect(name); - if (fd < 0) fd = server_spawn(name, cmd_argv, cols, rows); + if (fd < 0) fd = server_spawn(name, cmd_argv, cols, rows, layout); if (fd < 0) { fprintf(stderr, "sl0ptty: cannot start session %s\n", name); return 1; @@ -29,6 +29,14 @@ struct pane { char title[256]; /* OSC 5577 state: what this pane asked us to draw in its frame */ + /* A suspended pane is real, sized and laid out, but has not run anything: + * a root with twelve projects must not become twelve running servers. It + * starts on the first keystroke it is given. */ + bool suspended; + char **argv; + char *cwd; + char label[128]; + osc_scan_t scan; char status[256]; pane_button_t buttons[8]; @@ -132,6 +140,49 @@ static void on_title_changed(GhosttyTerminal t, void *ud) { p->dirty = true; } +bool pane_suspended(const pane_t *p) { return p->suspended; } +const char *pane_label(const pane_t *p) { return p->label; } + +/* Spawn what a suspended pane was created to run. */ +bool pane_start(pane_t *p) { + if (!p->suspended) return false; + p->suspended = false; + if (pty_spawn(&p->pty, (const char *const *)p->argv, p->cols, p->rows_n, + p->cwd) != 0) { + p->alive = false; + return false; + } + p->alive = true; + p->dirty = true; + return true; +} + +static char **argv_dup(const char *const argv[]) { + size_t n = 0; + while (argv[n]) n++; + char **out = calloc(n + 1, sizeof *out); + for (size_t i = 0; i < n; i++) out[i] = strdup(argv[i]); + return out; +} + +pane_t *pane_new_ex(const char *const argv[], uint16_t cols, uint16_t rows, + const char *cwd, bool suspended, const char *label) { + pane_t *p = pane_new(argv, cols, rows, cwd); + if (!p) return NULL; + snprintf(p->label, sizeof p->label, "%s", label ? label : ""); + if (suspended) { + /* pane_new already spawned; a suspended pane must not have. Close it and + * keep the arguments for later, rather than duplicating the constructor. */ + pty_close(&p->pty); + p->pty.fd = -1; + p->suspended = true; + p->alive = true; + p->argv = argv_dup(argv); + p->cwd = cwd ? strdup(cwd) : NULL; + } + return p; +} + pane_t *pane_new(const char *const argv[], uint16_t cols, uint16_t rows, const char *cwd) { pane_t *p = calloc(1, sizeof *p); @@ -190,6 +241,11 @@ fail: void pane_free(pane_t *p) { if (!p) return; + if (p->argv) { + for (size_t i = 0; p->argv[i]; i++) free(p->argv[i]); + free(p->argv); + } + free(p->cwd); if (p->pty.fd >= 0) pty_close(&p->pty); if (p->mev) ghostty_mouse_event_free(p->mev); if (p->menc) ghostty_mouse_encoder_free(p->menc); @@ -208,6 +264,7 @@ bool pane_dirty(pane_t *p) { return p->dirty; } const char *pane_title(const pane_t *p) { return p->title; } ssize_t pane_pump(pane_t *p) { + if (p->pty.fd < 0) return 0; uint8_t buf[65536]; ssize_t total = 0; for (;;) { @@ -315,7 +372,7 @@ void pane_resize(pane_t *p, uint16_t cols, uint16_t rows) { p->cols = cols; p->rows_n = rows; ghostty_terminal_resize(p->term, cols, rows, 0, 0); - pty_resize(&p->pty, cols, rows); + if (p->pty.fd >= 0) pty_resize(&p->pty, cols, rows); p->dirty = true; } diff --git a/src/server.c b/src/server.c index 4326fb2..55696d1 100644 --- a/src/server.c +++ b/src/server.c @@ -121,7 +121,7 @@ static void drop_display(server_t *s, uint8_t reason) { } int server_run(const char *name, const char *const argv[], uint16_t cols, - uint16_t rows) { + uint16_t rows, const char *layout) { char path[512]; if (session_socket_path(name, path, sizeof path) != 0) return 1; int lfd = listen_socket(path); @@ -139,6 +139,11 @@ int server_run(const char *name, const char *const argv[], uint16_t cols, server_t s = {0}; s.app = app_new(argv, cols, rows); if (!s.app) return 1; + if (layout) { + char err[256] = {0}; + if (!app_apply_layout_file(s.app, layout, true, err, sizeof err)) + fprintf(stderr, "sl0ptty: %s: %s\n", layout, err[0] ? err : "bad layout"); + } app_resize(s.app, cols, rows); screen_init(&s.screen, cols, rows); s.in = input_new(); @@ -299,7 +304,7 @@ int server_run(const char *name, const char *const argv[], uint16_t cols, /* Fork a server into the background and wait for its socket to answer. */ int server_spawn(const char *name, const char *const argv[], uint16_t cols, - uint16_t rows) { + uint16_t rows, const char *layout) { char path[512], logp[512]; if (session_socket_path(name, path, sizeof path) != 0) return -1; session_log_path(name, logp, sizeof logp); @@ -316,7 +321,7 @@ int server_spawn(const char *name, const char *const argv[], uint16_t cols, dup2(log >= 0 ? log : null, STDERR_FILENO); if (null > 2) close(null); if (log > 2) close(log); - _exit(server_run(name, argv, cols, rows)); + _exit(server_run(name, argv, cols, rows, layout)); } waitpid(pid, NULL, 0); /* the intermediate exits immediately */ diff --git a/src/server.h b/src/server.h index c81dd33..e2b9224 100644 --- a/src/server.h +++ b/src/server.h @@ -9,10 +9,10 @@ /* Run the session in this process (the daemonised half). */ int server_run(const char *name, const char *const argv[], uint16_t cols, - uint16_t rows); + uint16_t rows, const char *layout); /* Fork a server, then wait for its socket to answer. Returns a connected fd. */ int server_spawn(const char *name, const char *const argv[], uint16_t cols, - uint16_t rows); + uint16_t rows, const char *layout); /* Connect to an existing session, or -1. */ int server_connect(const char *name); diff --git a/src/sl0ptty.h b/src/sl0ptty.h index 828de0e..eab209c 100644 --- a/src/sl0ptty.h +++ b/src/sl0ptty.h @@ -123,6 +123,13 @@ typedef void (*pane_osc_fn)(pane_t *p, const char *verb, const char *payload, pane_t *pane_new(const char *const argv[], uint16_t cols, uint16_t rows, const char *cwd); +/* `suspended`: create the pane but run nothing until pane_start(). `label` is + * what to show in the meantime (usually the command line). */ +pane_t *pane_new_ex(const char *const argv[], uint16_t cols, uint16_t rows, + const char *cwd, bool suspended, const char *label); +bool pane_suspended(const pane_t *p); +const char *pane_label(const pane_t *p); +bool pane_start(pane_t *p); void pane_free(pane_t *p); int pane_fd(const pane_t *p); bool pane_alive(const pane_t *p); |
