aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authoruser <user@clank>2026-09-29 14:26:06 +0200
committeruser <user@clank>2026-09-29 14:26:06 +0200
commite779c1fafa556330752b2c5f43c56415f655ff24 (patch)
treeb87d335587fbfb68696569c5a41d912435d67140
parentmouse: encode pixel reports against the cell size we advertise (diff)
downloadslosh-e779c1fafa556330752b2c5f43c56415f655ff24.tar.gz
slosh-e779c1fafa556330752b2c5f43c56415f655ff24.tar.xz
slosh-e779c1fafa556330752b2c5f43c56415f655ff24.zip
panes: $SLOSH_PANE, so a program knows which pane it is in
A program in a pane could name every pane but its own. `panes` says which one is *focused*, and focus belongs to the human, so anything that inferred "where do I live" from it did its work in whichever tab somebody else happened to be looking at -- and lost a race with anybody else asking the same question. So export the id into the child, the way tmux has $TMUX_PANE and zellij $ZELLIJ_PANE_ID. The id is taken before the pane is made, because pane_new spawns the program and assigning it afterwards is one instruction too late; it lives on the pane rather than the layout, so a rerun keeps it -- an identity that changed under a rerun would be no identity at all. Unset, not zero, when there is none: a stale value inherited from an outer session would read as this one's. `panes` also reports each pane's pid now, which is the same question answered for anything that cannot read its own environment: walk your parents up until one of them is on that list.
-rw-r--r--include/slosh.h18
-rw-r--r--src/app.c15
-rw-r--r--src/pane.c19
-rw-r--r--src/pty.c15
-rw-r--r--src/pty_win.c15
-rw-r--r--tests/test_own_pane.py256
6 files changed, 325 insertions, 13 deletions
diff --git a/include/slosh.h b/include/slosh.h
index b8c8849..78972b8 100644
--- a/include/slosh.h
+++ b/include/slosh.h
@@ -169,8 +169,16 @@ typedef struct {
* success). Panes do this on their own when the entry is missing
* everywhere; `slosh --install-terminfo` is the by-hand spelling. */
int pty_terminfo_install(void);
+/* `pane_id` is the id the session knows this pane by, exported into the child
+ * as $SLOSH_PANE. A program in a pane has to be able to name the pane it is
+ * *in* -- `panes` says which pane is focused, and focus is the human's, not the
+ * caller's, so anything that inferred "where do I live" from it spawned its
+ * work in whichever tab somebody else happened to be looking at. tmux answers
+ * this with $TMUX_PANE and zellij with $ZELLIJ_PANE_ID; this is ours. 0 means
+ * "no id", and then the variable is left unset rather than set to a lie. */
int pty_spawn(pty_t *p, const char *const argv[], uint16_t cols, uint16_t rows,
- const char *cwd, uint16_t cell_w, uint16_t cell_h);
+ const char *cwd, uint16_t cell_w, uint16_t cell_h,
+ uint32_t pane_id);
int pty_resize(pty_t *p, uint16_t cols, uint16_t rows, uint16_t cell_w,
uint16_t cell_h);
void pty_close(pty_t *p);
@@ -194,12 +202,16 @@ typedef void (*pane_clip_fn)(pane_t *p, char *text, void *ud);
typedef void (*pane_notify_fn)(pane_t *p, const char *title, const char *body,
void *ud);
+/* `id` is what the session will know this pane by: it reaches the program as
+ * $SLOSH_PANE, and every respawn of this pane (start/rerun) carries the same
+ * one, so a pane's identity outlives the process that had it. */
pane_t *pane_new(const char *const argv[], uint16_t cols, uint16_t rows,
- const char *cwd);
+ const char *cwd, uint32_t id);
/* `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);
+ const char *cwd, bool suspended, const char *label,
+ uint32_t id);
bool pane_suspended(const pane_t *p);
/* A pane opened to do one thing, rather than one the session is made of: an
* editor you popped open, not the dev server a layout declared. It runs a
diff --git a/src/app.c b/src/app.c
index 385c5f9..2031e09 100644
--- a/src/app.c
+++ b/src/app.c
@@ -830,13 +830,18 @@ const char *app_clipboard(const app_t *a) { return a->clipboard; }
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);
+ /* The id is taken *before* the pane is made, because the pane's program is
+ * spawned by pane_new and has to be told which pane it is ($SLOSH_PANE).
+ * Assigning it afterwards, as this used to, is one instruction too late:
+ * the child is already running without it. */
+ uint32_t id = ++a->next_id;
+ pane_t *p = pane_new_ex(argv, 1, 1, cwd, suspended, label, id);
if (!p) return NULL;
node_t *n = calloc(1, sizeof *n);
n->kind = NODE_LEAF;
n->weight = WEIGHT_UNIT;
n->pane = p;
- n->id = ++a->next_id;
+ n->id = id;
pane_set_osc_handler(p, on_pane_osc, a);
pane_set_clipboard_handler(p, on_pane_clipboard, a);
pane_set_notify_handler(p, on_pane_notify, a);
@@ -2137,6 +2142,12 @@ static void panes_cb(node_t *n, void *ud) {
* false with a status is one whose program is over and which is waiting to
* be re-run or closed. `exit_code` is -1 when there is no status to give. */
json_bool(j, "alive", pane_alive(n->pane));
+ /* The pid of the program in the pane, so a process can work out which pane
+ * it is in by walking its own parents up to one of these -- the answer for
+ * anything that cannot read $SLOSH_PANE from its environment, which is
+ * every tool run with a scrubbed env and every process with no controlling
+ * tty. -1 when there is nothing running (a suspended or dead pane). */
+ json_int(j, "pid", (long long)pane_pid(n->pane));
{
int code = 0;
bool sig = false;
diff --git a/src/pane.c b/src/pane.c
index 5252f14..4c5da85 100644
--- a/src/pane.c
+++ b/src/pane.c
@@ -30,6 +30,11 @@ struct pane {
GhosttyMouseEncoder menc;
GhosttyMouseEvent mev;
uint16_t cols, rows_n;
+ /* The id the session knows this pane by, handed to every spawn of it as
+ * $SLOSH_PANE. Kept on the pane rather than looked up from the layout
+ * because `rerun` respawns from here, and an identity that changed under a
+ * rerun would be no identity at all. */
+ uint32_t id;
/* The client's cell size in pixels. A default rather than a zero, because
* zero is what makes an image vanish: a placement given no explicit cell
* count is sized from the image's pixels and this, and 0 covers no cells.
@@ -321,7 +326,7 @@ 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, p->cell_w, p->cell_h) != 0) {
+ p->cwd, p->cell_w, p->cell_h, p->id) != 0) {
p->alive = false;
return false;
}
@@ -339,8 +344,9 @@ static char **argv_dup(const char *const argv[]) {
}
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);
+ const char *cwd, bool suspended, const char *label,
+ uint32_t id) {
+ pane_t *p = pane_new(argv, cols, rows, cwd, id);
if (!p) return NULL;
snprintf(p->label, sizeof p->label, "%s", label ? label : "");
if (suspended) {
@@ -355,8 +361,9 @@ pane_t *pane_new_ex(const char *const argv[], uint16_t cols, uint16_t rows,
}
pane_t *pane_new(const char *const argv[], uint16_t cols, uint16_t rows,
- const char *cwd) {
+ const char *cwd, uint32_t id) {
pane_t *p = calloc(1, sizeof *p);
+ p->id = id;
p->cols = cols;
p->rows_n = rows;
p->cell_w = 8;
@@ -414,7 +421,7 @@ pane_t *pane_new(const char *const argv[], uint16_t cols, uint16_t rows,
p->argv = argv_dup(argv);
p->cwd = cwd ? strdup(cwd) : NULL;
- if (pty_spawn(&p->pty, argv, cols, rows, cwd, p->cell_w, p->cell_h) != 0)
+ if (pty_spawn(&p->pty, argv, cols, rows, cwd, p->cell_w, p->cell_h, id) != 0)
goto fail;
p->alive = true;
p->dirty = true;
@@ -715,7 +722,7 @@ bool pane_restart(pane_t *p) {
p->exit_code = 0;
if (pty_spawn(&p->pty, (const char *const *)p->argv, p->cols, p->rows_n,
- p->cwd, p->cell_w, p->cell_h) != 0)
+ p->cwd, p->cell_w, p->cell_h, p->id) != 0)
return false;
/* The terminal is deliberately not cleared: the run that ended, and the
* line saying it ended, stay above this one in the scrollback. That is the
diff --git a/src/pty.c b/src/pty.c
index 00fa190..deb67c0 100644
--- a/src/pty.c
+++ b/src/pty.c
@@ -124,7 +124,8 @@ static const char *pty_term(void) {
}
int pty_spawn(pty_t *p, const char *const argv[], uint16_t cols, uint16_t rows,
- const char *cwd, uint16_t cell_w, uint16_t cell_h) {
+ const char *cwd, uint16_t cell_w, uint16_t cell_h,
+ uint32_t pane_id) {
int master = posix_openpt(O_RDWR | O_NOCTTY);
if (master < 0) return -1;
if (grantpt(master) < 0 || unlockpt(master) < 0) {
@@ -206,6 +207,18 @@ int pty_spawn(pty_t *p, const char *const argv[], uint16_t cols, uint16_t rows,
* same contract inward. xterm-ghostty is what libghostty-vt implements. */
setenv("TERM", pty_term(), 1);
setenv("SLOSH", "1", 1);
+ /* Which pane this is, so a program can act on its own pane instead of on
+ * whichever one has focus. Set in the child, after the fork, because it is
+ * per-pane: setting it in the parent would leak one pane's id into the
+ * next spawn. Unset when there is no id, so a stale inherited value from
+ * an outer session cannot be read as this one's. */
+ if (pane_id) {
+ char idbuf[16];
+ snprintf(idbuf, sizeof idbuf, "%u", pane_id);
+ setenv("SLOSH_PANE", idbuf, 1);
+ } else {
+ unsetenv("SLOSH_PANE");
+ }
unsetenv("ZELLIJ");
signal(SIGPIPE, SIG_DFL);
diff --git a/src/pty_win.c b/src/pty_win.c
index d4386ed..b33c7fb 100644
--- a/src/pty_win.c
+++ b/src/pty_win.c
@@ -179,7 +179,8 @@ static void build_cmdline(const char *const argv[], char *out, size_t cap) {
}
int pty_spawn(pty_t *p, const char *const argv[], uint16_t cols, uint16_t rows,
- const char *cwd, uint16_t cell_w, uint16_t cell_h) {
+ const char *cwd, uint16_t cell_w, uint16_t cell_h,
+ uint32_t pane_id) {
(void)cell_w;
(void)cell_h; /* ConPTY carries no pixel geometry */
if (!p || !argv || !argv[0]) return -1;
@@ -235,6 +236,18 @@ int pty_spawn(pty_t *p, const char *const argv[], uint16_t cols, uint16_t rows,
/* Same contract as the POSIX child: we say what terminal this is. */
SetEnvironmentVariableA("TERM", "xterm-ghostty");
SetEnvironmentVariableA("SLOSH", "1");
+ {
+ /* Same contract as the POSIX child: which pane this is. See pty_spawn in
+ * slosh.h -- a program must be able to name its own pane rather than
+ * infer it from whoever has focus. */
+ char idbuf[16];
+ if (pane_id) {
+ snprintf(idbuf, sizeof idbuf, "%u", pane_id);
+ SetEnvironmentVariableA("SLOSH_PANE", idbuf);
+ } else {
+ SetEnvironmentVariableA("SLOSH_PANE", NULL);
+ }
+ }
/* A child attached to a pseudoconsole is supposed to take its stdio from
* that console -- but CreateProcess copies the *parent's* standard handles
diff --git a/tests/test_own_pane.py b/tests/test_own_pane.py
new file mode 100644
index 0000000..31e7faa
--- /dev/null
+++ b/tests/test_own_pane.py
@@ -0,0 +1,256 @@
+#!/usr/bin/env python3
+"""A program in a pane can act on *its own* pane, without using focus.
+
+The bug this pins: everything a script could ask about a pane was answered
+relative to focus, and focus is the human's. A tool spawning work "beside
+itself" had nothing else to go on, so it read `panes` for whichever pane was
+focused, and landed its work in whatever tab somebody else happened to be
+looking at -- in a session with several agents in it, reliably the wrong one.
+Worse, applying a layout jumped the view to the tab it built, so the *next*
+caller read a focus that the previous spawn had moved.
+
+So four things are checked here, which together are "background work lands where
+the caller lives and nobody's view moves":
+
+ * $SLOSH_PANE tells a program which pane it is in (and survives a rerun).
+ * `panes` reports each pane's `pid`, so a process with no environment to read
+ -- no controlling tty, a scrubbed env -- can still find itself by walking
+ its own parents.
+ * `apply-layout focus:false` builds tabs without going to them, and the panes
+ it builds are still given a real size (a pane nobody looks at used to stay
+ 1x1, which was invisible only because applying a layout always jumped).
+ * `move-pane beside:<id> focus:false` lands a pane next to a named pane and
+ leaves focus, and the view, alone.
+ * `raw id:<id>` and `capture id:<id>` drive and read a pane in another tab
+ with no focus change and no select-tab.
+"""
+
+import os
+import sys
+
+from harness import Session, check, report
+
+# A pane that reports its own identity into a file, then waits. This is the
+# question under test: what does a program in a pane know about its own pane?
+WHOAMI = "/tmp/slosh-test-whoami.txt"
+SHELL = ["/bin/sh", "-c", f'echo "pane=$SLOSH_PANE pid=$$" > {WHOAMI}; read x']
+# Something with visible output, for the capture checks.
+TALKER = ["/bin/sh", "-c", "echo MARKER-ONE; read x"]
+
+
+def layout(kdl):
+ import tempfile
+
+ f = tempfile.NamedTemporaryFile("w", suffix=".layout", delete=False)
+ f.write(kdl)
+ f.close()
+ return f.name
+
+
+def test_a_pane_is_told_which_pane_it_is():
+ if os.path.exists(WHOAMI):
+ os.unlink(WHOAMI)
+ with Session(SHELL, cols=60, rows=12) as s:
+ s.settle(200)
+ pane = s.pane(0)
+ said = open(WHOAMI).read().strip() if os.path.exists(WHOAMI) else "<nothing>"
+ check(
+ "$SLOSH_PANE is the pane's own id",
+ f"pane={pane['id']} " in said + " ",
+ f"pane id {pane['id']}, program said {said!r}",
+ )
+ check(
+ "panes reports the pid of the program in the pane",
+ f"pid={pane['pid']}" in said,
+ f"panes says pid {pane['pid']}, program said {said!r}",
+ )
+
+
+def test_the_id_survives_a_rerun():
+ """A pane's identity is the pane's, not the process's: `rerun` keeps it.
+
+ Otherwise a tool that recorded "my pane is 3" would be talking about
+ somebody else after the pane it meant was run again."""
+ log = "/tmp/slosh-test-rerun.txt"
+ if os.path.exists(log):
+ os.unlink(log)
+ # A *commanded* pane, because only those keep a husk to rerun -- a pane
+ # that is just a shell goes when its shell does (see test_dead.py).
+ lay = layout(
+ 'layout {\n tab {\n pane command="echo pane=$SLOSH_PANE >> %s"\n pane\n }\n}\n'
+ % log
+ )
+ with Session(["/bin/sh", "-c", "read x"], cols=60, rows=12, layout=lay) as s:
+ s.until(lambda sn: not s.pane(0)["alive"])
+ pane_id = s.pane(0)["id"]
+ s.api("rerun", id=pane_id)
+ s.until(lambda sn: len(open(log).read().splitlines()) >= 2)
+ said = open(log).read().split()
+ check(
+ "the same pane reports the same id after a rerun",
+ said == [f"pane={pane_id}", f"pane={pane_id}"],
+ f"pane {pane_id} logged {said}",
+ )
+
+
+def test_a_layout_can_be_applied_without_stealing_the_view():
+ with Session(TALKER, cols=60, rows=12) as s:
+ s.settle(100)
+ s.api("new-tab", name="human-tab")
+ s.api("select-tab", index=2)
+ s.settle(50)
+ before = [t["id"] for t in s.tabs() if t["active"]]
+
+ s.api(
+ "apply-layout",
+ focus=False,
+ kdl='layout { tab name="bg" { pane purpose="task:bg" command="sh -c \'echo MARKER-BG; read x\'" } }',
+ )
+ s.settle(200)
+ after = [t["id"] for t in s.tabs() if t["active"]]
+ check(
+ "the view stays on the tab it was on",
+ before == after,
+ f"was viewing {before}, now viewing {after}",
+ )
+
+ built = [p for p in s.panes() if p["purpose"] == "task:bg"]
+ check("the layout still built its pane", len(built) == 1, str(s.panes()))
+ if built:
+ # The whole reason background panes were never seen to be broken:
+ # the view used to jump to them, and the jump is what sized them.
+ check(
+ "a pane in a tab nobody looked at still has a real size",
+ built[0]["w"] > 2 and built[0]["h"] > 2,
+ f"{built[0]['w']}x{built[0]['h']}",
+ )
+ check(
+ "and its program ran at that size, so its output is readable",
+ "MARKER-BG" in s.api("capture", id=built[0]["id"])["text"],
+ repr(s.api("capture", id=built[0]["id"])["text"][:120]),
+ )
+
+
+def test_a_pane_lands_beside_the_pane_that_asked_not_beside_focus():
+ """The reported bug, in one test.
+
+ Pane 1 is "the agent". The human is in another tab, so the only focused
+ pane in the session is theirs. The agent asks for a pane beside *itself*
+ by id, and it has to arrive in the agent's tab with nobody's view moving.
+ """
+ with Session(TALKER, cols=80, rows=24) as s:
+ s.settle(100)
+ agent = s.pane(0)["id"]
+ agent_tab = s.pane(0)["tab_id"]
+ s.api("new-tab", name="human-tab")
+ s.api("select-tab", index=2)
+ s.settle(50)
+ human = s.focused()
+
+ s.api(
+ "apply-layout",
+ focus=False,
+ kdl='layout { tab name="work" { pane purpose="task:x" command="sh -c \'echo MARKER-TWO; read x\'" } }',
+ )
+ s.settle(150)
+ new = [p for p in s.panes() if p["purpose"] == "task:x"][0]["id"]
+
+ reply = s.api("move-pane", id=new, tab=agent_tab, beside=agent, focus=False)
+ check("the move is accepted", reply.get("ok"), str(reply))
+ s.settle(50)
+
+ rows = {p["id"]: p for p in s.panes()}
+ check(
+ "the pane lands in the tab of the pane that asked for it",
+ rows[new]["tab_id"] == agent_tab,
+ f"wanted tab {agent_tab}, got {rows[new]['tab_id']}",
+ )
+ check(
+ "focus does not move to it",
+ not rows[new]["focused"],
+ "the new pane took focus",
+ )
+ check(
+ "the human keeps focus",
+ rows[human["id"]]["focused"],
+ f"focus went to {[p['id'] for p in s.panes() if p['focused']]}",
+ )
+ check(
+ "and keeps the tab they were looking at",
+ [t["id"] for t in s.tabs() if t["active"]] == [human["tab_id"]],
+ str(s.tabs()),
+ )
+
+ # `beside` has to be in the destination tab, or the caller has the
+ # wrong idea of where things are and should hear so.
+ bad = s.api("move-pane", id=new, tab=human["tab_id"], beside=agent)
+ check("a `beside` in the wrong tab is refused", not bad.get("ok"), str(bad))
+
+
+def test_a_pane_elsewhere_can_be_read_and_driven_without_focus():
+ with Session(TALKER, cols=80, rows=24) as s:
+ s.settle(100)
+ mine = s.pane(0)["id"]
+ s.api("new-tab", name="elsewhere")
+ s.api("select-tab", index=2)
+ s.settle(50)
+ viewing = [t["id"] for t in s.tabs() if t["active"]]
+
+ cap = s.api("capture", id=mine)
+ check(
+ "capture reads a pane in a tab that is not on screen",
+ "MARKER-ONE" in cap["text"],
+ repr(cap["text"][:120]),
+ )
+ check(
+ "capture does not change which tab is on screen",
+ [t["id"] for t in s.tabs() if t["active"]] == viewing,
+ str(s.tabs()),
+ )
+ check(
+ "capture does not move focus",
+ s.focused()["tab_id"] != s.pane(0)["tab_id"] or len(s.panes()) == 1,
+ str(s.focused()),
+ )
+
+ # raw into a named pane: the pane's program is `read x`, so a line ends
+ # it -- observable as the pane no longer being alive.
+ # raw into a named pane: its program is `read x`, so a line ends it.
+ # A real newline in `data`: the JSON form carries bytes, only the
+ # bare-verb form unescapes (`raw q\n`).
+ s.api("raw", id=mine, data="q\n")
+
+ def ended(_):
+ rows = [p for p in s.panes() if p["id"] == mine]
+ # The session's own shell pane leaves no husk when it ends, so
+ # "gone" and "not alive" are both the line having been delivered.
+ return not rows or not rows[0]["alive"]
+
+ s.until(ended)
+ check(
+ "raw id:<pane> reaches a pane that is not focused",
+ ended(None),
+ str([p for p in s.panes() if p["id"] == mine]),
+ )
+ check(
+ "and still did not move the view",
+ [t["id"] for t in s.tabs() if t["active"]] == viewing,
+ str(s.tabs()),
+ )
+
+ check(
+ "raw at an id that does not exist is refused",
+ not s.api("raw", id=99999, data="x").get("ok"),
+ "a write to a dead id was accepted",
+ )
+ check(
+ "capture at an id that does not exist is refused",
+ not s.api("capture", id=99999).get("ok"),
+ "a read of a dead id was accepted",
+ )
+
+
+for name, fn in sorted(list(globals().items())):
+ if name.startswith("test_"):
+ fn()
+sys.exit(report())