aboutsummaryrefslogtreecommitdiffstats
path: root/idatui/pane.py
diff options
context:
space:
mode:
authorblasty <peter@haxx.in>2026-09-29 14:31:26 +0200
committerblasty <peter@haxx.in>2026-09-29 14:31:26 +0200
commit6252b7c6a89043cfee636aa418303c5a86b4d108 (patch)
tree87259b99504789bf1b5a0d7a2ee10c532bc6ba9a /idatui/pane.py
parentSpawn TUI panes in slosh, the multiplexer we actually use (diff)
downloadida-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.
Diffstat (limited to 'idatui/pane.py')
-rw-r--r--idatui/pane.py201
1 files changed, 182 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,