diff options
| author | blasty <peter@haxx.in> | 2026-09-29 14:31:26 +0200 |
|---|---|---|
| committer | blasty <peter@haxx.in> | 2026-09-29 14:31:26 +0200 |
| commit | 6252b7c6a89043cfee636aa418303c5a86b4d108 (patch) | |
| tree | 87259b99504789bf1b5a0d7a2ee10c532bc6ba9a | |
| parent | Spawn TUI panes in slosh, the multiplexer we actually use (diff) | |
| download | ida-tui-6252b7c6a89043cfee636aa418303c5a86b4d108.tar.gz ida-tui-6252b7c6a89043cfee636aa418303c5a86b4d108.tar.xz ida-tui-6252b7c6a89043cfee636aa418303c5a86b4d108.zip | |
Put a spawned pane beside US, not beside whoever is looking
The slosh backend worked out "where do I live" from `panes[].focused`.
Exactly one pane in a session is focused, it is always in the tab
currently on screen, and it belongs to whoever is looking -- so a TUI
spawned "beside the agent" landed in whatever tab the human had wandered
into, and in a session running several agents it was reliably a
stranger's. `apply-layout` jumping the view to the tab it built made it
self-inflicted too: one spawn moved the focus the next spawn would read.
Identity now comes from four sources, in order, none of which is "whoever
has focus": $IDATUI_SLOSH_PANE (an operator saying so), $SLOSH_PANE
(slosh telling the pane's program which pane it is), our own /proc
ancestry matched against panes[].pid (the answer for an agent's shell-out,
which has neither a scrubbed environment nor a controlling tty), and a
purpose named by $IDATUI_ORIGIN_PURPOSE / $SLOSH_PANE_PURPOSE. If none of
them can answer, the pane stays in the tab the layout built it in --
visible, findable by purpose, and nobody else's tab -- and says so, since
the fix is one variable in the caller's environment.
The id-addressed verbs are probed, not assumed, and the probe is a field
rather than a verb: slosh ignores arguments it does not know, so `raw`
with an `id` on an older build answers ok and writes the bytes into
whatever pane has focus -- silently, into somebody's shell. panes[].pid
arrived with those verbs, so its presence is the capability. Where they
exist: `raw id=` writes without touching focus, `capture id=` reads a pane
in any tab without hopping the view (the old snapshot-and-slice route is
kept as the fallback, and is rude precisely because it moves what other
people are looking at), `apply-layout focus=false` builds the tab without
jumping to it, and `move-pane beside=` names our pane rather than letting
slosh pick that tab's focused one.
Detached is now the default and `--focus` is the opt-in. The other way
round, every spawn ended by pulling the session's focus -- and so the
viewed tab -- to a pane nobody asked to look at: fine when a human typed
the command and is waiting for it, wrong for an agent opening a binary
while somebody works two tabs away. `--detached` still parses, for callers
that say it.
tests/test_pane_origin.py (22 checks, pure stdlib, no IDA and no slosh:
the decisions are made about a `panes` reply, so the reply is the input)
pins the precedence order, the focus-is-not-identity rule, the ancestry
walk and that an unanswerable session leaves the pane where it was built.
| -rw-r--r-- | idatui/pane.py | 201 | ||||
| -rw-r--r-- | tests/test_pane_origin.py | 339 |
2 files changed, 521 insertions, 19 deletions
diff --git a/idatui/pane.py b/idatui/pane.py index bbc783d..851c442 100644 --- a/idatui/pane.py +++ b/idatui/pane.py @@ -185,6 +185,105 @@ def _kdl_str(s: str) -> str: return '"' + s.replace("\\", "\\\\").replace('"', '\\"') + '"' +_SLOSH_CAPS: dict[str, bool] = {} + + +def _slosh_addressable() -> bool: + """Whether this slosh can act on a pane *by id* rather than on the focused one. + + Probed, not assumed, and the probe is a field rather than a verb: slosh + ignores arguments it does not know, so ``raw`` with an ``id`` on an older + build answers ``ok`` and writes the bytes into whatever pane has focus -- + silently, into somebody's shell. There is no way to tell from the reply, so + ask something that *is* visible. ``panes[].pid`` arrived with the + id-addressed verbs (``raw id``, ``capture``, ``move-pane beside``, + ``apply-layout focus``), so its presence is the capability. + """ + if "addressable" not in _SLOSH_CAPS: + rows = _slosh_panes() + _SLOSH_CAPS["addressable"] = bool(rows) and "pid" in rows[0] + return _SLOSH_CAPS["addressable"] + + +def _proc_ancestry() -> list[int]: + """Our own pid and every parent up to init, nearest first. + + For finding which pane we are in when the environment cannot say: the pane's + program is one of our ancestors, and slosh reports each pane's pid. + """ + out: list[int] = [] + pid = os.getpid() + for _ in range(64): # a bound, not an expectation: no cycles in /proc + if pid <= 1: + break + out.append(pid) + try: + with open(f"/proc/{pid}/stat") as fh: + stat = fh.read() + # comm can contain spaces and brackets; everything after the last + # ')' is the fixed-position part, and ppid is its second field. + pid = int(stat[stat.rindex(")") + 2 :].split()[1]) + except (OSError, ValueError): + break + return out + + +def _slosh_origin() -> dict[str, Any] | None: + """The pane *we* are running in, as a `panes` row, or None if unknowable. + + This is the question the whole spawn hangs on, and getting it from the + session's focus -- which is what this used to do -- is wrong in a way that + is invisible until it bites: exactly one pane in a slosh session is + ``focused``, it is always in the tab currently on screen, and it belongs to + whoever is looking. So a TUI spawned "beside the agent" landed in whatever + tab the human had wandered into, and in a session running several agents it + was reliably somebody else's. (Worse, ``apply-layout`` used to jump the view + to the tab it built, so one spawn moved the focus that the *next* spawn + would read.) + + Four answers, in order, none of which is "whoever has focus": + + 1. ``$IDATUI_SLOSH_PANE`` -- an operator or wrapper saying so outright. + 2. ``$SLOSH_PANE`` -- slosh telling the pane's program which pane it is. + The right answer; needs a slosh new enough to export it. + 3. our own pid's ancestry against ``panes[].pid`` -- the answer for a tool + whose environment was scrubbed, or which has no controlling tty (an + agent's shell-out has neither), on a slosh that reports pids. + 4. a purpose named by ``$IDATUI_ORIGIN_PURPOSE`` or ``$SLOSH_PANE_PURPOSE`` + -- for an agent that labelled its own pane + (``printf '\\033]5577;1;purpose;agent:me'``) and exported the label, on a + slosh too old for either of the above. pi's slosh-status extension does + exactly this. + + None of them means "leave the pane wherever it was built", which is a + predictable, harmless place. Guessing does not. + """ + rows = _slosh_panes() + if not rows: + return None + by_id = {str(r.get("id")): r for r in rows} + + for var in ("IDATUI_SLOSH_PANE", "SLOSH_PANE"): + want = os.environ.get(var, "").strip() + if want and want in by_id: + return by_id[want] + + mine = set(_proc_ancestry()) + for r in rows: + pid = r.get("pid") + if isinstance(pid, int) and pid > 0 and pid in mine: + return r + + for var in ("IDATUI_ORIGIN_PURPOSE", "SLOSH_PANE_PURPOSE"): + purpose = os.environ.get(var, "").strip() + if not purpose: + continue + for r in rows: + if r.get("purpose") == purpose: + return r + return None + + def _zellij_panes() -> list[dict[str, Any]]: try: out = subprocess.run( @@ -268,7 +367,7 @@ def _pane_split( # into a shell is the thing not to do: a pane that was *given* a command # is the one with alive/exit_code to poll and `rerun` to repeat. So the # pane comes from a one-tab layout and is then moved in beside us. - origin = next((r for r in _slosh_panes() if r.get("focused")), None) + origin = _slosh_origin() tag = "idatui:" + secrets.token_hex(3) command = f"cd {_q(REPO)} && exec " + " ".join(_q(a) for a in inner) kdl = ( @@ -276,7 +375,14 @@ def _pane_split( f"purpose={_kdl_str(tag)} cwd={_kdl_str(REPO)} command={_kdl_str(command)}" " } }" ) - _slosh("apply-layout", kdl=kdl) + # focus=False builds the tab without moving anybody's view to it. An + # older slosh ignores the argument and jumps to the new tab -- rude, but + # that jump is also what used to *size* the pane there, so asking for it + # is only safe where the sizing does not depend on it. + if _slosh_addressable(): + _slosh("apply-layout", kdl=kdl, focus=False) + else: + _slosh("apply-layout", kdl=kdl) # Find it by the purpose we declared, never by "the newest id": a # declared purpose is locked, so the program in the pane cannot rename # itself out from under us the way a title changes. @@ -288,17 +394,43 @@ def _pane_split( # Into the tab we were called from, so the TUI sits beside the agent # and is on screen for `capture`. The pane keeps its pty/scrollback, # and the layout's now-empty tab goes away by itself. + # + # `beside` names our own pane rather than letting slosh pick that + # tab's focused one -- the tab's focus is a fact about the last + # person who looked at it -- and `focus` says whether to follow the + # pane there. Both are ignored by an older slosh, which lands it + # beside that tab's focus and takes focus with it; the tab is still + # right, which is the part that matters. + move: dict[str, Any] = { + "id": int(pane), + "tab": int(origin.get("tab_id", 0)), + "dir": "rows" if vertical else "cols", + } + if _slosh_addressable(): + move["beside"] = int(origin.get("id", 0)) + move["focus"] = not detached try: - _slosh( - "move-pane", - id=int(pane), - tab=int(origin.get("tab_id", 0)), - dir="rows" if vertical else "cols", - ) + _slosh("move-pane", **move) except (OSError, RuntimeError): pass # no room to split: leave it in the tab of its own - if detached: # slosh focuses what it creates/moves; hop back - _slosh("focus", id=int(origin.get("id", 0))) + if detached: + # Put focus back if this slosh moved it anyway, and check + # `panes` rather than assuming: focusing a pane also selects its + # tab, so a needless call here is itself a view change. + now = next((r for r in _slosh_panes() if r.get("focused")), None) + if now and str(now.get("id")) == pane: + _slosh("focus", id=int(origin.get("id", 0))) + else: + # We could not establish which pane we are in, so there is no "here" + # to put it next to. It stays in the tab the layout made -- visible, + # findable by purpose, and nobody else's tab. Said out loud, because + # the fix is a one-liner in the caller's environment. + print( + f"note: cannot tell which slosh pane this is, so {tag} stays in " + "its own tab (set $IDATUI_SLOSH_PANE, or use a slosh that " + "exports $SLOSH_PANE / reports panes[].pid)", + file=sys.stderr, + ) return pane if mux == "zellij": @@ -348,12 +480,18 @@ def _pane_split( def _slosh_capture(pane: str) -> str: - """One slosh pane's visible screen, cut out of the session snapshot. + """One slosh pane's visible screen. - ``snapshot`` is the whole composited session (every visible pane, borders and - all), and only the active tab is composited at all — so a pane parked in - another tab is brought up and put back. + ``capture`` asks the pane's own terminal, so it works for a pane in any tab + and changes nothing. Where it is missing (an older slosh) fall back to the + old route: cut the pane's rect out of the session ``snapshot``, which + composites only the active tab and therefore has to bring the pane's tab up + and put it back -- visible to anybody watching, and a race against any other + tool doing the same. That is the fallback precisely because it is rude. """ + if _slosh_addressable(): + return _slosh("capture", id=int(pane)).get("text", "").rstrip() + row = _slosh_row(pane) if row is None: raise RuntimeError(f"no such slosh pane: {pane}") @@ -457,11 +595,17 @@ def _pane_keys(pane: str, keys: list[str], mux: str | None = None) -> None: mux = mux or _mux_of_pane(pane) if mux == "slosh": data = "".join(_to_slosh_bytes(k) for k in keys) + # `raw` takes the pane to write into, so no focus moves: focusing a pane + # also selects its tab, which moves the view of whoever is watching and + # loses a race with any other tool doing the same dance. `raw` rather + # than `send` either way: `send` is decoded as session input, where C-a + # is slosh's leader and would be swallowed instead of reaching the TUI. + if _slosh_addressable(): + _slosh("raw", id=int(pane), data=data) + return + # An older slosh ignores `id` and writes to the focused pane, so the + # only safe route is the old one: focus, write, put focus back. prev = next((r for r in _slosh_panes() if r.get("focused")), None) - # Both `raw` and `send` land in the *focused* pane, so focus first and - # put focus back after. `raw` rather than `send`: `send` is decoded as - # session input, where C-a is slosh's leader and would be swallowed - # instead of reaching the TUI. _slosh("focus", id=int(pane)) try: _slosh("raw", data=data) @@ -875,7 +1019,26 @@ def main(argv: list[str]) -> int: help="new pane size (tmux -l value, e.g. 60%% or 120; " "ignored under slosh/zellij)", ) - sp.add_argument("--detached", action="store_true", help="don't focus the new pane") + # Detached is the default, and `--focus` is the opt-in. + # + # The other way round, every spawn ended by pulling the session's focus (and + # so the viewed tab) to a pane nobody asked to look at -- fine when a human + # typed the command and is waiting for it, wrong for an agent opening a + # binary while somebody else works two tabs away. A caller that does want to + # land on it says so. + sp.add_argument( + "--focus", + dest="detached", + action="store_false", + default=True, + help="focus the new pane (default: leave focus and the viewed tab alone)", + ) + sp.add_argument( + "--detached", + dest="detached", + action="store_true", + help="don't focus the new pane (the default; kept for callers that say so)", + ) sp.add_argument( "--mux", choices=MUXES, diff --git a/tests/test_pane_origin.py b/tests/test_pane_origin.py new file mode 100644 index 0000000..4d46e5b --- /dev/null +++ b/tests/test_pane_origin.py @@ -0,0 +1,339 @@ +#!/usr/bin/env python3 +"""Which pane a spawn thinks it is next to (pure stdlib, no IDA, no slosh). + +The bug being pinned: the slosh backend used to work out "where do I live" from +`panes[].focused`. Exactly one pane in a slosh session is focused, it is always +in the tab currently on screen, and it belongs to whoever is looking -- so a +TUI spawned "beside the agent" landed in whatever tab the human had wandered +into, and in a session running several agents it was reliably a stranger's. + +The rule now: identity comes from the environment ($SLOSH_PANE), or from our own +process ancestry against `panes[].pid`, or from a purpose we were told to look +for -- and if none of those can answer, the pane stays in the tab it was built +in rather than being placed on a guess. + +slosh is faked here on purpose: these are decisions the backend makes about a +`panes` reply, so the reply is the input. tests/test_own_pane.py in the slosh +repo covers the other side (that slosh really does export $SLOSH_PANE, report +pids, and honour `beside`/`focus:false`). +""" + +#: origin resolution + spawn placement, pure stdlib. +#: Read by tests/run.py (--fast skips every NEEDS_IDA file). +NEEDS_IDA = False +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from idatui import pane as P # noqa: E402 + +PASS = FAIL = 0 + + +def check(name, cond, detail=""): + global PASS, FAIL + if cond: + PASS += 1 + print(f"ok {name}") + else: + FAIL += 1 + print(f"FAIL {name} <- {detail}") + + +# The session every test below reasons about: the agent is pane 1 in tab 1, the +# human is pane 2 in tab 2 and holds the session's only focus. +AGENT = { + "id": 1, + "tab_id": 1, + "pid": 4242, + "focused": False, + "purpose": "", + "alive": True, + "w": 80, + "h": 24, +} +HUMAN = { + "id": 2, + "tab_id": 2, + "pid": 5252, + "focused": True, + "purpose": "", + "alive": True, + "w": 80, + "h": 24, +} + + +class FakeSlosh: + """Records every call, and answers `panes` from a list we control.""" + + def __init__(self, rows, spawned_id=9): + self.rows = [dict(r) for r in rows] + self.calls = [] + self.spawned_id = spawned_id + + def __call__(self, cmd, **params): + self.calls.append((cmd, params)) + if cmd == "panes": + return {"ok": True, "panes": self.rows} + if cmd == "apply-layout": + # slosh builds the pane in a tab of its own; give it back with the + # purpose the caller declared, which is how the backend finds it. + kdl = params.get("kdl", "") + purpose = kdl.split('purpose="', 1)[-1].split('"', 1)[0] + self.rows.append( + { + "id": self.spawned_id, + "tab_id": 99, + "pid": 6262, + "focused": False, + "purpose": purpose, + "alive": True, + "w": 80, + "h": 24, + } + ) + return {"ok": True} + if cmd == "move-pane": + for r in self.rows: + if r["id"] == params.get("id"): + r["tab_id"] = params.get("tab") + return {"ok": True, "tab": params.get("tab")} + return {"ok": True} + + def of(self, cmd): + return [p for c, p in self.calls if c == cmd] + + +def with_fake(rows, env=None, ancestry=(), spawned_id=9): + """Install a fake slosh + environment; returns (fake, restore callable).""" + fake = FakeSlosh(rows, spawned_id=spawned_id) + saved = { + "slosh": P._slosh, + "ancestry": P._proc_ancestry, + "caps": dict(P._SLOSH_CAPS), + "env": { + k: os.environ.get(k) + for k in ( + "SLOSH_PANE", + "IDATUI_SLOSH_PANE", + "IDATUI_ORIGIN_PURPOSE", + "SLOSH_PANE_PURPOSE", + ) + }, + } + P._slosh = fake + P._proc_ancestry = lambda: list(ancestry) + P._SLOSH_CAPS.clear() + for k in saved["env"]: + os.environ.pop(k, None) + for k, v in (env or {}).items(): + os.environ[k] = v + + def restore(): + P._slosh = saved["slosh"] + P._proc_ancestry = saved["ancestry"] + P._SLOSH_CAPS.clear() + P._SLOSH_CAPS.update(saved["caps"]) + for k, v in saved["env"].items(): + os.environ.pop(k, None) + if v is not None: + os.environ[k] = v + + return fake, restore + + +def origin_with(**kw): + fake, restore = with_fake(**kw) + try: + return P._slosh_origin() + finally: + restore() + + +def test_origin_is_never_the_focused_pane(): + o = origin_with(rows=[AGENT, HUMAN], env={"SLOSH_PANE": "1"}) + check( + "$SLOSH_PANE wins over the focused pane", + o and o["id"] == 1, + f"got {o}", + ) + + o = origin_with(rows=[AGENT, HUMAN], ancestry=(1111, 4242, 1)) + check( + "our own pid's ancestry finds our pane", + o and o["id"] == 1, + f"got {o}", + ) + + o = origin_with( + rows=[AGENT, HUMAN], + env={"IDATUI_ORIGIN_PURPOSE": "agent:me"}, + ) + check("an unset purpose does not match an empty one", o is None, f"got {o}") + + tagged = dict(AGENT, purpose="agent:me") + o = origin_with(rows=[tagged, HUMAN], env={"IDATUI_ORIGIN_PURPOSE": "agent:me"}) + check("a declared purpose is the last resort", o and o["id"] == 1, f"got {o}") + + # What pi's slosh-status extension publishes for its own pane. + tagged = dict(AGENT, purpose="agent:pi.1234") + o = origin_with(rows=[tagged, HUMAN], env={"SLOSH_PANE_PURPOSE": "agent:pi.1234"}) + check("$SLOSH_PANE_PURPOSE works too", o and o["id"] == 1, f"got {o}") + + o = origin_with(rows=[AGENT, HUMAN]) + check( + "with nothing to go on the answer is None, never the focused pane", + o is None, + f"got {o} (the focused pane is the human's)", + ) + + +def test_an_override_beats_everything(): + o = origin_with( + rows=[AGENT, HUMAN], + env={"IDATUI_SLOSH_PANE": "2", "SLOSH_PANE": "1"}, + ancestry=(4242,), + ) + check("$IDATUI_SLOSH_PANE overrides $SLOSH_PANE", o and o["id"] == 2, f"got {o}") + + o = origin_with(rows=[AGENT, HUMAN], env={"SLOSH_PANE": "777"}, ancestry=(4242,)) + check( + "an id that is not in the session falls through to the pid", + o and o["id"] == 1, + f"got {o}", + ) + + +def test_a_spawn_lands_beside_us_and_takes_no_focus(): + fake, restore = with_fake(rows=[AGENT, HUMAN], env={"SLOSH_PANE": "1"}) + try: + got = P._pane_split( + ["/bin/true"], mux="slosh", vertical=False, size=None, detached=True + ) + finally: + restore() + + check("the spawn returns the new pane's id", got == "9", f"got {got!r}") + + layouts = fake.of("apply-layout") + check( + "the layout is applied without taking the view", + layouts and layouts[0].get("focus") is False, + f"{layouts}", + ) + + moves = fake.of("move-pane") + check("the pane is moved once", len(moves) == 1, f"{moves}") + if moves: + check( + "into the tab of the pane we are in, not the focused tab", + moves[0].get("tab") == AGENT["tab_id"], + f"went to tab {moves[0].get('tab')}, human is in {HUMAN['tab_id']}", + ) + check( + "beside our own pane, not that tab's focus", + moves[0].get("beside") == AGENT["id"], + f"{moves[0]}", + ) + check( + "and without taking focus", + moves[0].get("focus") is False, + f"{moves[0]}", + ) + check( + "nothing is focused on the way out", + not fake.of("focus"), + f"{fake.of('focus')}", + ) + + +def test_focus_is_restored_only_when_slosh_moved_it(): + """`--detached` on an older slosh: it ignores `focus`, so we put it back.""" + rows = [AGENT, HUMAN] + fake, restore = with_fake(rows=rows, env={"SLOSH_PANE": "1"}) + # An older slosh: no `pid` in the rows, so the backend must not pass the + # newer arguments -- and must fix up focus itself. + old = [{k: v for k, v in r.items() if k != "pid"} for r in rows] + fake.rows = old + P._slosh = fake + P._SLOSH_CAPS.clear() + try: + P._pane_split( + ["/bin/true"], mux="slosh", vertical=False, size=None, detached=True + ) + moves = fake.of("move-pane") + check( + "an older slosh is not sent arguments it would ignore", + moves and "beside" not in moves[0] and "focus" not in moves[0], + f"{moves}", + ) + check( + "the layout is applied the old way too", + fake.of("apply-layout") and "focus" not in fake.of("apply-layout")[0], + f"{fake.of('apply-layout')}", + ) + check( + "focus is not put back when it never moved", + not fake.of("focus"), + f"{fake.of('focus')}", + ) + finally: + restore() + + # Now the same, with the new pane holding focus afterwards: put it back. + fake2, restore2 = with_fake(rows=rows, env={"SLOSH_PANE": "1"}) + fake2.rows = [{k: v for k, v in r.items() if k != "pid"} for r in rows] + P._slosh = fake2 + P._SLOSH_CAPS.clear() + orig_call = fake2.__call__ + + def stealing(cmd, **params): + out = orig_call(cmd, **params) + if cmd == "move-pane": # slosh focuses what it moves + for r in fake2.rows: + r["focused"] = r["id"] == params.get("id") + return out + + P._slosh = stealing + try: + P._pane_split( + ["/bin/true"], mux="slosh", vertical=False, size=None, detached=True + ) + check( + "focus is put back when slosh did move it", + [p.get("id") for p in fake2.of("focus")] == [AGENT["id"]], + f"{fake2.of('focus')}", + ) + finally: + restore2() + + +def test_without_an_identity_it_stays_put(): + fake, restore = with_fake(rows=[AGENT, HUMAN]) # nothing to go on + try: + got = P._pane_split( + ["/bin/true"], mux="slosh", vertical=False, size=None, detached=True + ) + finally: + restore() + check("the pane is still created", got == "9", f"got {got!r}") + check( + "but never moved into somebody else's tab", + not fake.of("move-pane"), + f"{fake.of('move-pane')}", + ) + check("and focus is left alone", not fake.of("focus"), f"{fake.of('focus')}") + + +def main(): + for name, fn in sorted(globals().items()): + if name.startswith("test_"): + fn() + print(f"\n{PASS} passed, {FAIL} failed") + return 1 if FAIL else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) |
