From c82bd3ab12ce7ebf3bbd216b02726162d23ec8cd Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sat, 31 Oct 2026 21:23:20 -0400 Subject: [PATCH 01/17] Use one observed private lifetime for physical Toad runs --- docs/performance/physical-run.md | 98 +++++ docs/performance/scroll-series.md | 5 + tools/acceptance.py | 347 +++-------------- tools/performance/capture_live.py | 64 +--- tools/performance/scroll_scenario.py | 62 +++ tools/performance/scroll_scenario.sh | 254 ------------- tools/physical_ui.py | 549 +++++++++++++++++++++++++++ tools/physical_ui_remote.py | 360 ++++++++++++++++++ 8 files changed, 1128 insertions(+), 611 deletions(-) create mode 100644 docs/performance/physical-run.md create mode 100644 tools/performance/scroll_scenario.py delete mode 100755 tools/performance/scroll_scenario.sh create mode 100644 tools/physical_ui.py create mode 100644 tools/physical_ui_remote.py diff --git a/docs/performance/physical-run.md b/docs/performance/physical-run.md new file mode 100644 index 000000000..6df1d4b5a --- /dev/null +++ b/docs/performance/physical-run.md @@ -0,0 +1,98 @@ +# Isolated physical Toad runs + +The shared implementation is `tools/physical_ui.py`: the existing +`XvfbDisplay → OwnedDisplay` family owns Xlib/display allocation and retirement; +`TerminalApp` owns st, the frontend, source observations and all physical input. +`tools/acceptance.py` and the scrolling scenario consume it. There is no public +raw-input method on a display. + +## Entry point + +Run with the **installed source runtime's Python**, with Core's retained +`CheckRoot.create()` and `SuppliedRuntimeHome` APIs available (Core PR #3821). +Do not put the candidate Core checkout on `PYTHONPATH` and call that installed +source qualification. + +```sh +/home/ts/.local/share/agent-comms/current/bin/python \ + tools/performance/scroll_scenario.py \ + --thread YOUR_THREAD --history-thread ANOTHER_THREAD \ + --output /home/ts/.cache/agent-scratch/YOUR_OWNER/fresh-run +``` + +`--runtime /absolute/complete/candidate-prefix` selects only the frontend and +private copy's runtime. The source controller remains installed. The candidate +must contain its own Toad/Core/Textual packages; a dependency `.pth` pointing +those packages outside the selected prefix fails actual module-origin checks. +Supply a new output directory for each distinct run. Do not replay a failed or +UNKNOWN input against an old run. + +The callable entry point is `TerminalApp.copied(source, threads, runtime, +output, thread=..., size=Size(...))`. Its context yields the admitted app and +retains the copied root, frontend-home, operation receipts, screenshots and +recording on exit. The caller still owns retiring those retained files after +acceptance and actual borrower checks. + +## What the owner establishes + +- `CheckRoot` alone selects/imports bounded stopped history; old goals, turns, + tasks, processes and input authority do not travel with it. The installed + source owner creates history; only its disposable copy is published through + `RootRuntime.publish` to a supplied runtime. +- Fresh Toad config/state/data and no copied saved-tab database. Presentation + settings can travel; source identities cannot be repaired by rewriting tabs. +- HOME/XDG inside `st -e env`: st itself resets HOME from passwd before exec. +- Xvfb uses displayfd, not a guessed display number. Xlib checks its actual + local peer UID/process; the run verifies the allocated server incarnation. +- Child exec grants debugging only to the recorded controller PID and its + descendants. It changes no global ptrace policy and needs no sudo. +- Before physical input: the actual frontend incarnation, runtime and loaded + Toad/Core/Textual module locations, HOME/XDG/database, selected/opened source, + every saved entry's source and every borrowed ACP process's creator/session + agent coordination roots. Pending/foreign sources refuse input. +- The read-only observation asks `ToadApp.running_app()` and the actual screen + owner. A window caption is not readiness. `remote_exec` receives an explicit + module filename binding and an adjacent FieldCodec request; a missing or + timed-out result is not a successful observation. +- st text travels with the actually published history viewport geometry. + `ScreenCapture` clips through Textual/Rich's cell geometry, not sidebar + separators that disappear when a sidebar closes or occur inside code. + Textual's installed compiled `Region` rejects subclassing; `CapturedRegion` + adds only the existing `DeclaredWireType` boundary capability and retains the + actual Region object, without copying its coordinate declarations. + +## Evidence limits + +This scenario exercises **bounded stopped history**, not the retired shell +benchmark's active provider response. Historical `scroll-series.md` entries +retain their original source/traffic scopes and old API names. They are not +commands to rerun against the live root. + +A source observation establishes module **locations**, not hashes of already +loaded code. Screen dumps help inspect text; the continuous Xvfb recording owns +visible pixels. Neither an import nor one screenshot proves the whole journey. +A run with no ACP bindings does not demonstrate the bound/pending/foreign ACP +cases. + +Source-observer work is part of the measurement overhead. For example, one +prototype observation took about 97 ms inside the callback before considering +its compilation/dispatch cost. Do not report that overhead as production frame +cost or quietly subtract it from a user-facing latency claim. Correlate complete +controller/call spans and the recording. + +The separate reviewed `capture_history.py` path retains its own explicit +approval, identity, size/time budgets and completion token. This entry point +neither starts that capture nor authorizes heap, GC, repaint, remeasure or +arbitrary trace experiments. The legacy `capture_live.py` diagnostic options do +not become authorized merely because they import the shared display resource. + +## Current qualification + +Core #3821 is an API checkpoint; full acceptance is separate. Prototype7 +completed with exit0: actual copied b34 frontend/source/home/runtime admission, +held PageUp/PageDown, reversal, End and wheel input, screenshots, a continuous +recording and typed copied-root stop. Its source controller still used the +owned Core overlay, and it had no bound ACP. Earlier failed run receipts remain +retained. Candidate admission, installed-source qualification, four isolation +counterexamples, tab/resize/arrival/retirement coverage and final profiling must +be reported individually; none is implied by this bounded scrolling result. diff --git a/docs/performance/scroll-series.md b/docs/performance/scroll-series.md index 97a09e89c..6f00bb0fa 100644 --- a/docs/performance/scroll-series.md +++ b/docs/performance/scroll-series.md @@ -1,5 +1,10 @@ # Scroll series +Historical measurements below retain their original commands, API names and +source/traffic scopes. The unsafe shell controller and raw display-input API +are retired. Use the [shared isolated physical-run owner](physical-run.md) for +new work; its stopped-history scenario is not the old active-response benchmark. + One line per change, from `tools/performance/scroll_scenario.sh` (real `toad` in `st` on display :121, a fork of `perf-base-20261009`, which is a fork of the saved `agent-comms-ux` session; an active response, held PageUp from the input box, wheel bursts with reversal, End, tab return). Frame work is UI-thread time per frame handed to the driver; input-to-paint runs from the driver posting an event to the first frame after its last handler returns. Input-to-paint pairs each input with the first frame after it is handled (fixed 2026-10-09; earlier summaries paired out of order). Raw results are under `~/.cache/agent-scratch/scroll-series//`. | Date | Change | Total frame median (ms) | Total frame p95 (ms) | Input-to-paint median (ms) | Input-to-paint p95 (ms) | Scroll-path lines +/- | What the screen showed | diff --git a/tools/acceptance.py b/tools/acceptance.py index 48b1cdb28..67210103d 100644 --- a/tools/acceptance.py +++ b/tools/acceptance.py @@ -1,284 +1,41 @@ #!/usr/bin/env python3 -"""Toad's half of an installed runtime's check: what a person sees in a terminal. +"""Toad's installed-runtime check in an owned, source-checked physical run. /bin/python -I tools/acceptance.py --runtime RUNTIME_BIN --thread NAME --output DIR -Core's installer runs this with the runtime under check, in a home whose route selects -a rehearsal copy of the bus, on a thread it made there. Everything runs in plain st on -an Xvfb display this tool owns. Plain ``toad`` must start; its ACP entrypoint must -show the thread ready. This tool types one fresh request into that open tab, checks -its final native reply and returns to the tab. It then says SHOWN. Core sends the thread a DM and, -once the thread has answered on the bus, writes the answer here as one JSON string; it -must appear in the thread, in #any and again on return to the thread, and the reader -position must survive leaving and returning with only that DM's category shown. The -tool then says SEEN. The last line printed is the result. +Core supplies a rehearsal root and thread. A fresh frontend home does NOT restore +source-bound saved Toad tabs. Plain st and the real ACP editor share TerminalApp's +post-attachment source/runtime/persistence admission. SHOWN, SEEN and the final +JSON retain Core's existing protocol; failed/UNKNOWN fresh input is never replayed. """ from __future__ import annotations import argparse import json import os -import re +from pathlib import Path import shlex -import subprocess import sys import time -from pathlib import Path +from agent_comms.active_route import ActiveRoute from agent_comms.comms import wire +from agent_comms.launch_root import LaunchRoot +from agent_comms.launch_runtime import LaunchRuntime from agent_comms.ui_model.message_category import InboundCategory, all_categories -from rich.cells import cell_len - from toad.widgets.side_bar import BackTabButton # Isolated Python omits the script directory; this tool owns its sibling imports. sys.path.insert(0, str(Path(__file__).resolve().parent)) -from performance.capture_live import XvfbDisplay - - -def run(*args: str | Path) -> str: - print("+ " + " ".join(map(str, args)), file=sys.stderr, flush=True) - return subprocess.check_output(list(map(str, args)), text=True, timeout=10).strip() - - -class OwnedDisplay(XvfbDisplay): - """The isolated display this check creates and removes.""" - - def __init__(self, output: Path): - output.mkdir(parents=True, exist_ok=True) - self.log = (output / "xvfb.log").open("w") - self.server = subprocess.Popen( - ["Xvfb", "-displayfd", "1", "-screen", "0", "1600x1000x24", "-nolisten", "tcp"], - stdout=subprocess.PIPE, stderr=self.log) - try: - super().__init__(":" + self.server.stdout.readline().decode().strip()) - except BaseException: - self.close_server() - raise - - def close_server(self) -> None: - self.server.terminate() - self.server.wait(timeout=5) - self.log.close() - - def __exit__(self, *exception): - try: - return super().__exit__(*exception) - finally: - self.close_server() - - -class TerminalApp: - """Own one isolated st window, its screen and physical X input.""" - - columns, rows = 140, 40 - - def __init__(self, command, display, output, environment, cwd): - self.command, self.display, self.output = command, display, output - self.environment = dict(environment, DISPLAY=display.name) - self.cwd = cwd - - def __enter__(self): - import psutil - self.output.mkdir(parents=True, exist_ok=True) - self.dump = self.output / "st-screen.txt" - self.log = (self.output / "st.log").open("w") - self.terminal = subprocess.Popen( - ["st", "-f", "DejaVu Sans Mono:pixelsize=16", "-g", f"{self.columns}x{self.rows}", - "-o", str(self.dump), "-e", "env", f"HOME={self.environment['HOME']}", - *map(str, self.command)], - cwd=self.cwd, env=self.environment, stdout=self.log, stderr=self.log) - self.owner = psutil.Process(self.terminal.pid) - self.created = self.owner.create_time() - try: - self.window = int(subprocess.check_output( - ["xdotool", "search", "--sync", "--onlyvisible", "--pid", str(self.terminal.pid)], - env=self.environment, text=True, timeout=10).splitlines()[0]) - self.read_grid() - # -o enables st's raw printer too; Ctrl+Print leaves only explicit dumps. - self.keys("ctrl+Print") - self.wait("terminal", lambda text: bool(text.strip())) - return self - except BaseException: - self.__exit__(*sys.exc_info()) - raise - - def capture(self): - """Read st's own print-screen output; screenshots record the visible window.""" - offset = self.dump.stat().st_size - self.keys("shift+Print") - deadline = time.monotonic() + 2 - while True: - with self.dump.open("rb") as stream: - stream.seek(offset) - data = stream.read() - if data.count(b"\n") >= self.rows: - return data.decode("utf-8") - if self.terminal.poll() is not None or time.monotonic() >= deadline: - raise RuntimeError("st did not finish its screen dump") - time.sleep(.02) - - def message_rows(self, text): - """Each screen row's middle pane, decoded through the sidebar boundaries. - - st's dump drops trailing blanks; padding restores the full row, so a pane's - last columns are its gutter. - """ - for row, line in enumerate(text.splitlines()): - panes = (line + " " * (self.columns - cell_len(line))).split("│") - if len(panes) >= 2: - yield row, "│".join(panes[1:-1] if len(panes) >= 3 else panes[1:]) +from physical_ui import OwnedDisplay, TerminalApp - def messages(self, text): - """Each visible message's body, its wrapped rows joined without whitespace. - A body runs from its ``───`` header to its ``▶`` status row; the last two - columns are the scrollbar gutter. - """ - message = None - for _, cell in self.message_rows(text): - body = cell[:-2].strip() - content = body.lstrip("▌").strip() - if not body.startswith("▌") or content.startswith(("─", "▶")): - if message: - yield message - message = "" if content.startswith("─") else None - elif message is not None: - message += "".join(content.split()) - if message: - yield message - - def reader_position(self, text): - """First visible message and its screen row, excluding the scrollbar gutter.""" - for row, cell in self.message_rows(text): - body = cell[:-2] - if body.startswith("▌") and body.strip("▌ "): - return row, body - return () - - def reader_place(self, name): - """Observe a settled message viewport after one navigation action.""" - previous = () - - def settled(text): - nonlocal previous - rows = self.reader_position(text) - # This connection banner occupies a transcript row until it closes. - same = "Agent Comms connected" not in text and bool(rows) and rows == previous - previous = rows - return same - - return self.reader_position(self.wait(name, settled)) - - def screenshot(self, name): - path = self.output / f"{name}.png" - run("import", "-display", self.display.name, "-window", str(self.window), path) - return path - - def wait(self, name, predicate, seconds=60): - deadline = time.monotonic() + seconds - previous = "" - changes = self.output / f"{name}-changes.txt" - while True: - text = self.capture() - if text != previous: - with changes.open("a") as log: - log.write(f"\n--- {time.monotonic()} ---\n{text}") - previous = text - if predicate(text): - (self.output / f"{name}.txt").write_text(text) - return text - if self.terminal.poll() is not None or time.monotonic() >= deadline: - (self.output / f"{name}-failed.txt").write_text(text) - self.screenshot(f"{name}-failed") - raise RuntimeError(f"Terminal probe {name} failed; see {self.output}") - time.sleep(.2) - - def keys(self, *keys): - XvfbDisplay.input(self.owner, self.created, self.window, "key", "--clearmodifiers", *keys) - - def type_text(self, text): - XvfbDisplay.input(self.owner, self.created, self.window, - "type", "--clearmodifiers", "--delay", "1", text) - - def show_thread(self, thread): - self.wait("ready", lambda text: thread in text - and "▶ Session details · Ready" in "\n".join(text.splitlines()[-8:])) - # The thread sidebar starts collapsed; expand its visible right gutter. - self.click_cell(self.columns - 1, self.rows // 2) - return self.wait("shown", lambda text: "User messages" in text and all( - "Indexing" not in line.split("│", 1)[0] for line in text.splitlines())) - - def click(self, label): - text = self.wait("find-" + label.replace("/", "-"), lambda text: any( - label in line.split("│", 1)[0] for line in text.splitlines())) - row, line = next((row, line) for row, line in enumerate(text.splitlines()) - if label in line.split("│", 1)[0]) - self.click_cell(cell_len(line[:line.index(label)]) + cell_len(label) // 2, row) - - def toggle_group(self, label): - """Click this sidebar group's disclosure, collapsing or expanding its members.""" - def header(line): - return line.split("│", 1)[0].lstrip().startswith(("▾ " + label, "▸ " + label)) - text = self.wait("find-group-" + label, lambda text: any(map(header, text.splitlines()))) - row, line = next((row, line) for row, line in enumerate(text.splitlines()) if header(line)) - glyph = min(index for index in (line.find("▾"), line.find("▸")) if index >= 0) - self.click_cell(cell_len(line[:glyph]), row) - - def hide_category(self, label): - """Click this checkbox in the right sidebar, never matching message text.""" - def control(text): - return next(((row, line) for row, line in enumerate(text.splitlines()) - if "│" in line and label in line.rsplit("│", 1)[-1]), None) - text = self.wait("find-filter-" + label.replace("/", "-"), lambda text: control(text) is not None) - row, line = control(text) - prefix, panel = line.rsplit("│", 1) - self.click_cell(cell_len(prefix + "│" + panel[:panel.index("▐")]) + 1, row) - - def read_grid(self): - """st declares one cell plus its padding as the X minimum window size.""" - geometry = subprocess.check_output( - ["xdotool", "getwindowgeometry", "--shell", str(self.window)], - env=self.environment, text=True) - size = dict(line.split("=", 1) for line in geometry.splitlines() if "=" in line) - hints = subprocess.check_output( - ["xprop", "-display", self.display.name, "-id", str(self.window), "WM_NORMAL_HINTS"], - env=self.environment, text=True) - declared = {name: (int(width), int(height)) for name, width, height in re.findall( - r"program specified (minimum size|base size): (\d+) by (\d+)", hints)} - base_x, base_y = declared["base size"] - minimum_x, minimum_y = declared["minimum size"] - self.cell_width, self.cell_height = minimum_x - base_x, minimum_y - base_y - self.border_x, self.border_y = base_x / 2, base_y / 2 - self.columns = (int(size["WIDTH"]) - base_x) // self.cell_width - self.rows = (int(size["HEIGHT"]) - base_y) // self.cell_height - - def click_cell(self, column, row): - self.read_grid() - x = int(self.border_x + (column + .5) * self.cell_width) - y = int(self.border_y + (row + .5) * self.cell_height) - XvfbDisplay.input(self.owner, self.created, self.window, "mousemove", "--window", - str(self.window), str(x), str(y), "click", "1") - - def __exit__(self, *exception): - try: - if self.terminal.poll() is None: - self.keys("ctrl+q") - try: - self.terminal.wait(timeout=5) - except subprocess.TimeoutExpired: - self.terminal.terminate() - self.terminal.wait(timeout=5) - finally: - self.log.close() - - -def any_channel(text): - return "#any · all comms" in text and "IRC / Markdown" in text +def any_channel(screen): + return "#any · all comms" in screen.text and "IRC / Markdown" in screen.text def check_typed_reply(app: TerminalApp, thread: str, output: Path) -> None: - """A physical submit must finish in the native journal and survive tab return.""" + """A physical submit finishes in the native journal and survives tab return.""" comms = wire() selected = comms.registry.require(thread) session = selected.runtime.history_source(comms.root, selected.incarnation) @@ -287,7 +44,6 @@ def check_typed_reply(app: TerminalApp, thread: str, output: Path) -> None: marker = output / "typed-input.json" app.click_cell(app.columns // 2, app.rows - 4) app.type_text("Reply with " + token + ". Do not use tools.") - # Record before Enter; a failed check must never cause this input to be replayed. marker.write_text(json.dumps(attempt)) started = time.monotonic() app.keys("Return") @@ -298,7 +54,7 @@ def replies(): and entry.message.text.strip() == token) def visible(text): - return any(cell.strip("▌ ") == token for _, cell in app.message_rows(text)) + return any(cell.strip("▌ ") == token for _, cell in text.message_rows()) app.wait("typed-reply", lambda text: bool(replies()) and not comms.registry.require(thread).executing and visible(text), seconds=180) @@ -310,16 +66,15 @@ def visible(text): app.click("#any") app.wait("typed-away", any_channel) app.click(thread) - app.wait("typed-return", lambda text: visible(text) and "User messages" in text) + app.wait("typed-return", lambda screen: visible(screen) and "User messages" in screen.text) app.screenshot("typed-return") def check_answer(app: TerminalApp, thread: str, answer: str) -> None: - """The answer shows in the thread, in #any, and again back in the thread.""" expected = "".join(answer.split()) def visible(text): - return expected in app.messages(text) + return expected in text.messages() app.wait("answer", visible) app.screenshot("answer") @@ -329,20 +84,18 @@ def visible(text): initial = app.reader_place("any-initial") app.click_cell(app.columns // 2, app.rows - 4) app.keys("Page_Up", "Page_Up", "Page_Up") - app.wait("any-page-up", lambda text: bool(app.reader_position(text)) - and app.reader_position(text) != initial) - # #any lists every live agent and can push later channels off screen. + app.wait("any-page-up", lambda screen: bool(screen.reader_position()) + and screen.reader_position() != initial) app.toggle_group("#any") app.click("#comms") - app.wait("comms", lambda text: "#comms" in "\n".join(text.splitlines()[:3]) and "IRC / Markdown" in text) + app.wait("comms", lambda screen: "#comms" in "\n".join(screen.text.splitlines()[:3]) and "IRC / Markdown" in screen.text) app.toggle_group("#any") app.click(thread) - app.wait("answer-return", lambda text: visible(text) and "User messages" in text) + app.wait("answer-return", lambda screen: visible(screen) and "User messages" in screen.text) app.screenshot("answer-return") def check_filtered_return(app: TerminalApp, thread: str) -> None: - """With only the DM into the thread shown, the reader returns to the same rows from #any.""" for category in all_categories() - {InboundCategory}: app.hide_category(category.label) expected = app.reader_place("filtered-before") @@ -350,11 +103,11 @@ def check_filtered_return(app: TerminalApp, thread: str) -> None: app.click("#any") app.wait("filtered-away", any_channel) app.click(BackTabButton.label) - app.wait("filtered-return", lambda text: app.reader_position(text) == expected) + app.wait("filtered-return", lambda screen: screen.reader_position() == expected) app.click("#any") app.wait("filtered-click-away", any_channel) app.click(thread) - app.wait("filtered-click-return", lambda text: app.reader_position(text) == expected) + app.wait("filtered-click-return", lambda screen: screen.reader_position() == expected) app.screenshot("filtered-click-return") @@ -363,39 +116,35 @@ def main() -> None: parser.add_argument("--runtime", type=Path, required=True, help="The runtime's bin directory") parser.add_argument("--thread", required=True) parser.add_argument("--output", type=Path, required=True) - args = parser.parse_args() - # The protocol owns stdout; everything else this tool prints goes to stderr. + arguments = sys.argv[1:] + LaunchRoot.startup(arguments, ActiveRoute.published_launch) + args = parser.parse_args(arguments) protocol = os.fdopen(os.dup(sys.stdout.fileno()), "w", buffering=1) sys.stdout = sys.stderr - home = Path(os.environ["HOME"]) - environment = {key: value for key, value in os.environ.items() - if not key.startswith(("AGENT_COMMS_ROOT", "AGENT_COMMS_THREAD", "XDG_")) - and key not in ("PYTHONPATH", "PYTHONHOME", "NO_COLOR", "TOAD_VALIDATION")} - environment.update(XDG_CONFIG_HOME=str(home / "config"), XDG_STATE_HOME=str(home / "state"), - XDG_DATA_HOME=str(home / "data"), AGENT_COMMS_RUNTIME_ROOT=str(args.runtime)) + comms = wire() + target = TerminalApp.prepare_target(comms.root, LaunchRuntime.at_prefix(args.runtime.parent)) with OwnedDisplay(args.output) as display: - command = [args.runtime / "toad"] - with TerminalApp(command, display, args.output / "plain", environment, home) as app: - app.wait("store", lambda text: "Toad" in text or "agent" in text.lower()) - app.screenshot("store") - # CheckRoot restores stopped declarations. Ask the lifecycle owner to - # start the selected worker before waiting for a connected editor. - wire().owners.start_many((args.thread,))[0] - command = [*command, "acp", - shlex.quote(str(args.runtime / "agent-comms-acp")), - wire().registry.require(args.thread).worktree, - "--session", args.thread, "--title", "Agent Comms"] - with TerminalApp(command, display, args.output / "comms", environment, home) as app: - app.wait("welcome", lambda text: args.thread in text) - app.click(args.thread) - app.show_thread(args.thread) - app.screenshot("shown") - check_typed_reply(app, args.thread, args.output) - print("SHOWN", file=protocol) - answer = json.loads(sys.stdin.readline()) - check_answer(app, args.thread, answer) - check_filtered_return(app, args.thread) - print("SEEN", file=protocol) + command = (str(args.runtime / "toad"),) + plain = TerminalApp(command, display, args.output / "plain", target, target.home) + with display.recording(args.output / "journey.mp4", plain.environment): + with plain as app: + app.wait("store", lambda screen: "Toad" in screen.text or "agent" in screen.text.lower()) + app.screenshot("store") + comms.owners.start_many((args.thread,))[0] + command = (*command, "acp", shlex.quote(str(args.runtime / "agent-comms-acp")), + str(comms.registry.require(args.thread).worktree), + "--session", args.thread, "--title", "Agent Comms") + with TerminalApp(command, display, args.output / "comms", target, target.home) as app: + app.wait("welcome", lambda screen: args.thread in screen.text) + app.click(args.thread) + app.show_thread(args.thread) + app.screenshot("shown") + check_typed_reply(app, args.thread, args.output) + print("SHOWN", file=protocol) + answer = json.loads(sys.stdin.readline()) + check_answer(app, args.thread, answer) + check_filtered_return(app, args.thread) + print("SEEN", file=protocol) print(json.dumps(dict(plain="passed", shown="passed", answer="passed", channels="passed", filtered_return="passed", evidence=str(args.output))), file=protocol) diff --git a/tools/performance/capture_live.py b/tools/performance/capture_live.py index 5aa642309..e0b50273b 100644 --- a/tools/performance/capture_live.py +++ b/tools/performance/capture_live.py @@ -1,78 +1,26 @@ """Measure an explicitly selected live Toad process on required --display. -Input controllers use this same XvfbDisplay.input owner and terminal launch identity. +Physical input belongs to physical_ui.TerminalApp's observed private-run lifetime. --profile-seconds record a py-spy speedscope profile. --frame-meter N time UI work per frame and input-to-paint for N seconds (frame_meter.py). --targets write where sidebar thread rows and session tabs are (frame_meter.targets). """ import argparse -from contextlib import AbstractContextManager, ExitStack -import ctypes +from contextlib import ExitStack import json import os -import socket -import struct from pathlib import Path import subprocess +import sys import time import psutil -class XvfbDisplay(AbstractContextManager): - """Own a connection to a local framebuffer, resolving X11 aliases at Xlib.""" - - def __init__(self, name: str): - if not name: - raise ValueError("An explicit isolated Xvfb display is required") - x11 = ctypes.CDLL('libX11.so.6') - x11.XOpenDisplay.argtypes = [ctypes.c_char_p] - x11.XOpenDisplay.restype = ctypes.c_void_p - x11.XConnectionNumber.argtypes = [ctypes.c_void_p] - x11.XConnectionNumber.restype = ctypes.c_int - x11.XCloseDisplay.argtypes = [ctypes.c_void_p] - x11.XCloseDisplay.restype = ctypes.c_int - connection = x11.XOpenDisplay(name.encode()) - if not connection: - raise ValueError(f'Cannot connect to isolated Xvfb display: {name}') - with ExitStack() as close_display: - close_display.callback(x11.XCloseDisplay, connection) - with socket.fromfd(x11.XConnectionNumber(connection), socket.AF_UNIX, socket.SOCK_STREAM) as peer: - pid, uid, _gid = struct.unpack('3i', peer.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, 12)) - address = Path(os.fsdecode(peer.getpeername()).lstrip('\0')) - if address.name == 'X0' or uid != os.getuid() or Path(psutil.Process(pid).exe()).name != 'Xvfb': - raise ValueError(f'Not an owned isolated Xvfb resource: {name}') - self.name = name - self.close_display = close_display.pop_all() - - @property - def environment(self) -> dict[str, str]: - return {**os.environ, "DISPLAY": self.name} - - @classmethod - def input(cls, frontend: psutil.Process, created: float, window: int, *commands: str) -> None: - """Take the display from the actual st frontend, never the controller environment.""" - if frontend.create_time() != created or not frontend.is_running(): - raise ValueError("Frontend launch identity changed") - with cls(frontend.environ()["DISPLAY"]) as display: - env = display.environment - owner = subprocess.check_output( - ["xprop", "-display", display.name, "-id", str(window), "_NET_WM_PID"], - env=env, text=True, timeout=5, - ) - if int(owner.partition("=")[2].strip()) != frontend.pid: - raise ValueError("Window does not belong to the frontend on its display") - print(json.dumps({"at_ns": time.time_ns(), "monotonic_ns": time.monotonic_ns(), - "input_target": {"display": display.name, "window": window, - "frontend_pid": frontend.pid, "created": created}}), flush=True) - subprocess.run( - ["xdotool", "windowfocus", "--sync", str(window), *commands], - env=env, check=True, - ) - - def __exit__(self, *exception): - return self.close_display.__exit__(*exception) +# The shared physical resource owns Xlib admission; capture never owns raw input. +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from physical_ui import XvfbDisplay def main(): diff --git a/tools/performance/scroll_scenario.py b/tools/performance/scroll_scenario.py new file mode 100644 index 000000000..419e551ca --- /dev/null +++ b/tools/performance/scroll_scenario.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +"""Record real stopped-history scrolling through the common private-run owner. + +Run with the installed SOURCE runtime's Python. --runtime chooses only the +frontend/copy runtime; it never opens the original Comms through that candidate. +No provider input, live-root fork or automatic metadata-capture approval occurs. +This retained-history scenario is NOT comparable to the old active-response +shell benchmark without stating their different source/traffic scopes. +""" +import argparse +from pathlib import Path +import sys + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from agent_comms.active_route import ActiveRoute +from agent_comms.comms import wire +from agent_comms.launch_root import LaunchRoot +from agent_comms.launch_runtime import LaunchRuntime +from physical_ui import TerminalApp +from physical_ui_remote import ObservedResult, PhysicalOperation +from textual.geometry import Size + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--thread", required=True) + parser.add_argument("--history-thread", action="append", default=[]) + parser.add_argument("--runtime", type=Path, default=LaunchRuntime.installed().prefix) + parser.add_argument("--output", type=Path, required=True, help="Fresh owned agent-scratch directory") + parser.add_argument("--columns", type=int, default=140) + parser.add_argument("--rows", type=int, default=40) + arguments = sys.argv[1:] + LaunchRoot.startup(arguments, ActiveRoute.published_launch) + args = parser.parse_args(arguments) + if LaunchRuntime.running().prefix != LaunchRuntime.installed().prefix: + parser.error("History must be copied by the installed source runtime, not the candidate") + if args.columns <= 0 or args.rows <= 0: + parser.error("A terminal needs positive columns/rows") + threads = tuple(dict.fromkeys((args.thread, *args.history_thread))) + with TerminalApp.copied( + wire(), threads, LaunchRuntime.at_prefix(args.runtime), args.output, + thread=args.thread, size=Size(args.columns, args.rows), + ) as app: + app.wait("history", lambda screen: bool(screen.reader_position())) + PhysicalOperation.publish(args.output / "admitted.json", ObservedResult(app.observe())) + app.screenshot("startup") + app.click_cell(app.columns // 2, app.rows // 3) + app.input("key", "--clearmodifiers", "--repeat", "20", "--delay", "75", "Page_Up") + app.screenshot("page-up") + app.input("key", "--clearmodifiers", "--repeat", "8", "--delay", "75", "Page_Down") + app.screenshot("reversal") + app.keys("End") + app.screenshot("end-immediate") + app.input("click", "--repeat", "12", "--delay", "60", "4") + app.screenshot("wheel-up") + app.keys("End") + app.screenshot("end-return") + print(args.output) + + +if __name__ == "__main__": + main() diff --git a/tools/performance/scroll_scenario.sh b/tools/performance/scroll_scenario.sh deleted file mode 100755 index 8fe6394f9..000000000 --- a/tools/performance/scroll_scenario.sh +++ /dev/null @@ -1,254 +0,0 @@ -#!/usr/bin/env bash -# The fixed scroll scenario for docs/performance/scroll-series.md. -# Usage: scroll_scenario.sh NAME [BIN_DIR] -# Forks perf-base-20261009 (a fork of Tristan's saved agent-comms-ux session), opens it -# with the real `toad` in st on explicit SCENARIO_DISPLAY, and drives real X input: -# an active response, held PageUp from the input box, wheel bursts with reversal, -# End, and a return to the open tab. capture_live.py --frame-meter times every frame. -set -euo pipefail -name=$1 -bin=${2:-$(dirname "$(readlink -f "$(command -v toad)")")} -tools=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) -out=/home/ts/.cache/agent-scratch/scroll-series/$name -fork=perf-$name-$(date +%H%M%S) -project=/home/ts/wt/comms-post-feature-debt-audit-20260928 -meter_seconds=${METER_SECONDS:-45} -display=${SCENARIO_DISPLAY:?Set an explicit owned Xvfb display} -export DISPLAY=$display -PYTHONPATH="$tools" python3 -c ' -import sys -from capture_live import XvfbDisplay -with XvfbDisplay(sys.argv[1]): - pass -' "$display" -mkdir -p "$(dirname "$out")" -mkdir "$out" - -agent-comms fork --name "$fork" --parent perf-base-20261009 > "$out/fork.json" -env -u NO_COLOR -u TOAD_VALIDATION st -g145x50 -t "$fork" -e "$bin/toad" acp "$bin/agent-comms-acp" \ - --session "$fork" --project-dir "$project" --title "$fork" > "$out/st.log" 2>&1 & -st_pid=$! -read -r st_created display < <(python3 -c ' -import psutil,sys -frontend = psutil.Process(int(sys.argv[1])) -print(frontend.create_time(), frontend.environ()["DISPLAY"]) -' "$st_pid") -# Recorder and input both take their display from the actual frontend. -xdotool() { - PYTHONPATH="$tools" python3 -c ' -import psutil,sys -from capture_live import XvfbDisplay -frontend = psutil.Process(int(sys.argv[1])) -XvfbDisplay.input(frontend, float(sys.argv[2]), int(sys.argv[3]), *sys.argv[4:]) -' "$st_pid" "$st_created" "$window" "$@" -} -cleanup() { - local status=$? - xdotool key --window "$window" ctrl+q 2>/dev/null || true - # Wait for the app to exit by itself: killing the terminal sends SIGHUP to - # the app and its agents, and a process killed inside a guarded registry - # write leaves the live bus's registry guard pending (fail-closed for all). - for _ in $(seq 60); do kill -0 "$st_pid" 2>/dev/null || break; sleep 1; done - if kill -0 "$st_pid" 2>/dev/null; then - echo "app did not exit within 60 s; leaving it running rather than killing it mid-write" >&2 - fi - if (( status != 0 )); then - echo "Retaining failed run and fork $fork; no stop/delete or replay" >&2 - return "$status" - fi - # A fork is a full copy of the parent's 141 MB history: delete it, which - # removes its registration, per-thread state and session files. - agent-comms stop --name "$fork" > /dev/null 2>&1 || true - agent-comms delete --name "$fork" > "$out/delete.json" 2>&1 || true -} -trap cleanup EXIT - -for _ in $(seq 60); do - app_pid=$(pgrep -P "$st_pid" | head -1 || true) - window=$(DISPLAY="$display" command xdotool search --pid "$st_pid" 2>/dev/null | head -1 || true) - [[ -n "$app_pid" && -n "$window" ]] && break - sleep 1 -done -# Optional: raise the UI thread's scheduling priority (for example UI_NICE=-10) -# to separate the app's own work from waiting for a CPU behind other processes. -if [[ -n "${UI_NICE:-}" ]]; then - sudo -n renice -n "$UI_NICE" -p "$app_pid" > "$out/renice.log" 2>&1 -fi -sleep "${LOAD_SECONDS:-25}" -read -r width height < <(DISPLAY="$display" command xdotool getwindowgeometry --shell "$window" | sed -n 's/^\(WIDTH\|HEIGHT\)=//p' | paste -sd' ') -cell() { xdotool mousemove --window "$window" $(( ($1) * width / 145 + 3 )) $(( ($2) * height / 50 + height / 100 )) click 1; } -regions() { python3 "$tools/capture_live.py" --display "$display" --pid "$app_pid" --output-dir "$out" --name "$1" --targets --sudo > /dev/null; } -# Who asks the app to exit is written as it happens (the app has quit by -# itself when the second tab opened, with no error recorded). -python3 "$tools/capture_live.py" --display "$display" --pid "$app_pid" --output-dir "$out" --name app --trace-exit --sudo > /dev/null 2>&1 & -# Optional: measure from here, so opening the second tab (and the closing of -# the deleted thread's tab at the end) fall inside the measured window. -if [[ -n "${METER_FROM_OPEN:-}" ]]; then - # One attach at a time: the exit trace above must be installed first. - for _ in $(seq 40); do [[ -s "$out/app-exit.json" ]] && break; sleep 0.5; done - # PROFILE_LAYOUT profiles the window's layouts instead of metering it. - if [[ -n "${PROFILE_LAYOUT:-}" ]]; then - measure=(--profile-layout "$PROFILE_LAYOUT" ${PROFILE_TARGET:+--profile-target "$PROFILE_TARGET"} - ${PROFILE_WITHIN:+--profile-within "$PROFILE_WITHIN"} ${PROFILE_MIN_MS:+--profile-min-ms "$PROFILE_MIN_MS"}) - else - measure=(--frame-meter "${METER_SECONDS:-150}") - fi - python3 "$tools/capture_live.py" --display "$display" --pid "$app_pid" --output-dir "$out" --name run "${measure[@]}" \ - ${TRACE_CALLS:+--trace-calls "$TRACE_CALLS"} ${TRACE_REFLOWS:+--trace-reflows} ${TRACE_RULES:+--trace-rules} \ - ${TRACE_GARBAGE:+--trace-garbage} ${HEAP_CENSUS:+--heap-census "$HEAP_CENSUS"} ${COLLECT_ALL:+--collect-all "$COLLECT_ALL"} ${HEAP_COMPONENTS:+--heap-components "$HEAP_COMPONENTS"} ${FREEZE_HEAP:+--freeze-heap} ${TRACE_ABC:+--trace-abc} ${TRACE_POSTS:+--trace-posts} --sudo > "$out/capture.log" 2>&1 & - capture=$! - # Optional: every app thread's CPU and run-queue time, every 10 ms, over - # the same window (THREAD_CPU_SAMPLER names the sampler script). - if [[ -n "${THREAD_CPU_SAMPLER:-}" ]]; then - python3 "$THREAD_CPU_SAMPLER" "$app_pid" "$out/thread-cpu.json" "${METER_SECONDS:-150}" & - fi - sleep 2 -fi -# Open a second tab from the sidebar so the scenario can return to this one. -cell 1 25 -sleep 15 -regions sidebar -# Prefer the usual idle thread; with many agents running it may be scrolled -# out of the sidebar, so take the first thread row on screen instead. -read -r row_x row_y < <(python3 -c " -import json,sys -rows=json.load(open(sys.argv[1]))['threads'] -r=rows.get('parent-real-ui-20261009') or next(v for k, v in rows.items() if not k.startswith('perf-')) -print(r[0]+4, r[1])" "$out/sidebar-targets.json") -cell "$row_x" "$row_y" -for attempt in $(seq 15); do - sleep 2 - regions "tabs-$attempt" - tabs=$(python3 -c "import json,sys; print(len(json.load(open(sys.argv[1]))['tabs']))" "$out/tabs-$attempt-targets.json") - [[ "$tabs" -ge 2 ]] && { cp "$out/tabs-$attempt-targets.json" "$out/tabs-targets.json"; break; } -done -if [[ "$tabs" -lt 2 ]]; then - import -window "$window" "$out/tab-open-failed.png" 2>/dev/null || true - echo "second tab did not open; see $out/tab-open-failed.png" >&2 - exit 1 -fi -read -r first_x second_x tab_y < <(python3 -c "import json,sys; t=json.load(open(sys.argv[1]))['tabs']; print(t[0][0]+8, t[1][0]+8, t[0][1])" "$out/tabs-targets.json") -cell "$first_x" "$tab_y" -sleep 3 -# Close the sidebar (its toggle sits on its right edge while open) and confirm. -for attempt in 1 2 3 4 5; do - regions "closed-$attempt" - open_rows=$(python3 -c "import json,sys; print(len(json.load(open(sys.argv[1]))['threads']))" "$out/closed-$attempt-targets.json") - [[ "$open_rows" == 0 ]] && break - cell 54 25 - sleep 5 -done -[[ "$open_rows" == 0 ]] || { echo "sidebar did not close" >&2; exit 1; } -import -window "$window" "$out/loaded.png" 2>/dev/null || true -regions screen-loaded - -if [[ -n "${METER_FROM_OPEN:-}" ]]; then - : # already measuring since before the second tab opened -elif [[ -n "${PROFILE_SECONDS:-}" ]]; then - measure=(--profile-seconds "$PROFILE_SECONDS" ${PROFILE_IDLE:+--idle} ${PROFILE_GIL:+--gil}) -elif [[ -n "${PROFILE_LAYOUT:-}" ]]; then - measure=(--profile-layout "$PROFILE_LAYOUT" ${PROFILE_TARGET:+--profile-target "$PROFILE_TARGET"} - ${PROFILE_WITHIN:+--profile-within "$PROFILE_WITHIN"} ${PROFILE_MIN_MS:+--profile-min-ms "$PROFILE_MIN_MS"}) -else - measure=(--frame-meter "$meter_seconds") -fi -if [[ -z "${METER_FROM_OPEN:-}" ]]; then -python3 "$tools/capture_live.py" --display "$display" --pid "$app_pid" --output-dir "$out" --name run \ - "${measure[@]}" ${TRACE_CALLS:+--trace-calls "$TRACE_CALLS"} ${TRACE_REFLOWS:+--trace-reflows} ${TRACE_RULES:+--trace-rules} --sudo > "$out/capture.log" 2>&1 & -capture=$! -fi -sleep 2 -# Optional load: a real channel message whose replies arrive during the scenario. -if [[ -n "${CHANNEL:-}" && -n "${CHANNEL_MESSAGE:-}" ]]; then - "$HOME/.local/bin/agent-comms" user-send --to "$CHANNEL" --body "$CHANNEL_MESSAGE" --worktree "$project" > "$out/channel-send.json" -fi - -# Active response: a real configured-provider turn that streams while we scroll. -cell 40 47 -xdotool type --delay 5 "Explain in about 300 words why a terminal chat history should scroll prepared text instead of rebuilding widgets. No tools." -xdotool key Return -sleep 4 -# Held PageUp from the input box: 30 key repeats per second for 3 seconds. -# Screen text is sampled during the hold, not only after it. -( sleep 0.8; regions screen-pageup-hold-1; sleep 0.6; regions screen-pageup-hold-2 ) & -holding=$! -xdotool key --repeat "${PAGEUP_REPEAT:-90}" --delay 33 Prior -wait "$holding" -import -window "$window" "$out/pageup.png" 2>/dev/null || true -regions screen-pageup -# Without input the reader's view holds still: loading earlier history above -# it must not move what is shown (checked by screen_checks.py). -sleep 2 -regions screen-pageup-idle-1 -sleep 2 -regions screen-pageup-idle-2 -# Wheel bursts with reversal over the history. -xdotool mousemove --window "$window" 400 300 -for _ in 1 2 3; do - xdotool click --repeat 25 --delay 10 4 - xdotool click --repeat 25 --delay 10 5 -done -sleep 2 -regions screen-wheel-idle-1 -sleep 2 -regions screen-wheel-idle-2 -# End returns to the tail (End belongs to the history once it has focus). -xdotool click 1 -xdotool key End -sleep 2 -import -window "$window" "$out/end.png" 2>/dev/null || true -regions screen-end -# Tab return: switch to the other open tab and back. -cell "$second_x" "$tab_y" -sleep 2 -cell "$first_x" "$tab_y" -sleep 1 -import -window "$window" "$out/tab-return.png" 2>/dev/null || true -regions screen-tab-return - -# A retired thread closes its open tab: delete this run's fork (cleanup -# would delete it anyway) and the observation service must close the tab. -# Archiving keeps the incarnation current, so it does not retire the view. -"$HOME/.local/bin/agent-comms" stop --name "$fork" > /dev/null 2>&1 || true -# Core refuses while any process still references the session (the agent -# exiting, a read in progress); retry as a person would, recording holders. -for attempt in 1 2 3 4 5; do - "$HOME/.local/bin/agent-comms" delete --name "$fork" > "$out/delete-during-run.json" 2>&1 || true - grep -q '"deleted"' "$out/delete-during-run.json" && break - for pid in $(grep -o 'processes \[[0-9, ]*\]' "$out/delete-during-run.json" | grep -o '[0-9]\+'); do - ps -o pid,ppid,args -p "$pid" >> "$out/delete-referrers.txt" 2>&1 || true - done - sleep 1 -done -for attempt in $(seq 10); do - sleep 1 - regions "retire-$attempt" - tabs=$(python3 -c "import json,sys; print(len(json.load(open(sys.argv[1]))['tabs']))" "$out/retire-$attempt-targets.json") - [[ "$tabs" -lt 2 ]] && break -done -echo "{\"tabs_after_delete\": $tabs}" > "$out/retire-check.json" - -wait "$capture" -[[ -n "${PROFILE_SECONDS:-}${PROFILE_LAYOUT:-}" ]] || python3 "$tools/frame_meter.py" "$out/run-frames.json" | tee "$out/summary.json" -# What the reader saw, from the screen text: no placeholders or blank runs in -# the message window, End at the tail, tab return filled and still at the tail. -python3 "$tools/screen_checks.py" "$out" | tee "$out/screen-checks.json" - -# Optional: the per-build paint figure for this live history viewport (after -# the checks: it repaints the history window in full, cold, warm and scrolled by a page). -if [[ -n "${REPAINT:-}" ]]; then - python3 "$tools/capture_live.py" --display "$display" --pid "$app_pid" --output-dir "$out" --name repaint \ - --repaint "$REPAINT" --sudo > /dev/null - python3 -c "import json,sys; d=json.load(open(sys.argv[1])); print(d.get('error') or {k: d[k] for k in ('cold', 'warm', 'scroll')})" \ - "$out/repaint-repaint.json" | tee "$out/repaint.txt" -fi - -# Optional: the per-build layout figure for this live widget tree (after the -# checks, since it re-measures the screen at widths it is not shown at). -if [[ -n "${REMEASURE:-}" ]]; then - python3 "$tools/capture_live.py" --display "$display" --pid "$app_pid" --output-dir "$out" --name remeasure \ - --remeasure "$REMEASURE" --sudo > /dev/null - python3 -c "import json,sys; d=json.load(open(sys.argv[1])); print({k: d[k] for k in ('median_ms', 'p10_ms', 'p90_ms')})" \ - "$out/remeasure-remeasure.json" | tee "$out/remeasure.txt" -fi - diff --git a/tools/physical_ui.py b/tools/physical_ui.py new file mode 100644 index 000000000..bbfb1ba49 --- /dev/null +++ b/tools/physical_ui.py @@ -0,0 +1,549 @@ +"""One physical Toad run: private source/home, owned Xvfb/st, observed input. + +Acceptance and performance use these same existing display/terminal types. +The installed source runtime creates history; only that copy is rebound to a +candidate. A closed run retains its files, including failed/UNKNOWN evidence. +""" +from __future__ import annotations + +from collections.abc import Iterator, Mapping +from contextlib import AbstractContextManager, ExitStack, contextmanager +import ctypes +from dataclasses import dataclass +import itertools +import os +from pathlib import Path +import re +import select +import socket +import struct +import subprocess +import sys +import time + +from agent_comms.active_route import ActiveRoute, active_route_path, route_new_bus +from agent_comms.bus import Bus +from agent_comms.check_root import CheckRoot +from agent_comms.child_process import DetachedChildStdio, ProcessIdentity, SpawnedProcess +from agent_comms.comms import Comms +from agent_comms.installer.practice import SuppliedRuntimeHome +from agent_comms.launch_root import LaunchRoot +from agent_comms.launch_runtime import LaunchRuntime, ThreadLaunch +from agent_comms.recorded_bus import RecordedBusIdentity +from agent_comms.root_runtime import RootRuntime +from agent_comms.store_files import ExclusiveAtomicFile +from rich.cells import cell_len +from rich.segment import Segment +from textual.geometry import Region, Size +from textual.strip import Strip + +from physical_ui_remote import ( + CapturedRegion, ExecOperation, ObserveOperation, PendingRun, PhysicalOperation, + RunObservation, RunTarget, StopRootOperation, +) + + +class XvfbDisplay(AbstractContextManager): + """Own a connection to a local framebuffer, resolving aliases at Xlib. + + This resource has no raw-input entrypoint. Only TerminalApp may drive its + window after checking the actual application/source on that incarnation. + """ + + def __init__(self, name: str): + if not name: + raise ValueError("An explicit isolated Xvfb display is required") + x11 = ctypes.CDLL("libX11.so.6") + x11.XOpenDisplay.argtypes = [ctypes.c_char_p] + x11.XOpenDisplay.restype = ctypes.c_void_p + x11.XConnectionNumber.argtypes = [ctypes.c_void_p] + x11.XConnectionNumber.restype = ctypes.c_int + x11.XCloseDisplay.argtypes = [ctypes.c_void_p] + x11.XCloseDisplay.restype = ctypes.c_int + x11.XDefaultScreen.argtypes = [ctypes.c_void_p] + x11.XDefaultScreen.restype = ctypes.c_int + x11.XDisplayWidth.argtypes = [ctypes.c_void_p, ctypes.c_int] + x11.XDisplayWidth.restype = ctypes.c_int + x11.XDisplayHeight.argtypes = [ctypes.c_void_p, ctypes.c_int] + x11.XDisplayHeight.restype = ctypes.c_int + connection = x11.XOpenDisplay(name.encode()) + if not connection: + raise ValueError(f"Cannot connect to isolated Xvfb display: {name}") + with ExitStack() as close_display: + close_display.callback(x11.XCloseDisplay, connection) + with socket.fromfd(x11.XConnectionNumber(connection), socket.AF_UNIX, socket.SOCK_STREAM) as peer: + pid, uid, _gid = struct.unpack("3i", peer.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, 12)) + address = Path(os.fsdecode(peer.getpeername()).lstrip("\0")) + process = ProcessIdentity.capture(pid) + if address.name == "X0" or uid != os.getuid() or Path(f"/proc/{pid}/exe").resolve().name != "Xvfb": + raise ValueError(f"Not an owned isolated Xvfb resource: {name}") + self.name, self.process = name, process + self.x11, self.connection = x11, connection + self.close_display = close_display.pop_all() + + @property + def environment(self) -> dict[str, str]: + return {**os.environ, "DISPLAY": self.name} + + @property + def size(self) -> Size: + screen = self.x11.XDefaultScreen(self.connection) + return Size(self.x11.XDisplayWidth(self.connection, screen), + self.x11.XDisplayHeight(self.connection, screen)) + + @contextmanager + def recording(self, path: Path, environment: Mapping[str, str]): + """Record this display from startup through frontend retirement.""" + path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + size = self.size + with path.with_suffix(".log").open("wb") as log: + with SpawnedProcess.launch( + ("ffmpeg", "-hide_banner", "-loglevel", "warning", "-n", "-f", "x11grab", + "-framerate", "25", "-video_size", f"{size.width}x{size.height}", + "-i", self.name, "-c:v", "libx264", "-preset", "ultrafast", + "-pix_fmt", "yuv420p", str(path)), + env=environment, + stdio=DetachedChildStdio(stdin=subprocess.PIPE, stdout=log, stderr=log), + ) as child: + try: + yield path + finally: + child.process.communicate(b"q", timeout=10) + if child.process.returncode: + raise RuntimeError(f"Physical recording failed; see {log.name}") + + def __exit__(self, *exception): + return self.close_display.__exit__(*exception) + + +class OwnedDisplay(XvfbDisplay): + """The isolated display this run creates and retires.""" + + startup_seconds = 10.0 + stop_seconds = 10.0 + + def __init__(self, output: Path): + output.mkdir(parents=True, exist_ok=True) + self.log = (output / "xvfb.log").open("wb") + self.server = SpawnedProcess.launch( + ("Xvfb", "-displayfd", "1", "-screen", "0", "1600x1000x24", "-nolisten", "tcp"), + stdio=DetachedChildStdio(stdout=subprocess.PIPE, stderr=self.log), + ) + try: + stream = self.server.process.stdout + ready, _, _ = select.select((stream,), (), (), self.startup_seconds) + if not ready: + raise TimeoutError("Xvfb did not publish its allocated display") + name = ":" + stream.readline().decode().strip() + super().__init__(name) + if self.process != self.server.identity: + self.close_display.close() + raise ValueError("Xlib connected to a server this run did not create") + except BaseException: + self.close_server() + raise + + def close_server(self) -> None: + try: + self.server.stop_sync(deadline=time.monotonic() + self.stop_seconds) + finally: + self.server.close_streams() + self.log.close() + + def __exit__(self, *exception): + try: + return super().__exit__(*exception) + finally: + self.close_server() + + +@dataclass(frozen=True) +class ScreenCapture: + """Real st text paired with the published viewport geometry observed for it.""" + + text: str + history_regions: tuple[CapturedRegion, ...] + + def message_rows(self): + lines = self.text.splitlines() + for captured in self.history_regions: + region = captured.region + for row in range(max(0, region.y), min(len(lines), region.bottom)): + yield row, Strip([Segment(lines[row])]).crop(region.x, region.right).text + + def messages(self): + message = None + for _, cell in self.message_rows(): + body = cell.rstrip() + content = body.lstrip("▌").strip() + if not body.startswith("▌") or content.startswith(("─", "▶")): + if message: + yield message + message = "" if content.startswith("─") else None + elif message is not None: + message += "".join(content.split()) + if message: + yield message + + def reader_position(self): + for row, cell in self.message_rows(): + if cell.startswith("▌") and cell.strip("▌ "): + return row, cell + return () + + +class TerminalApp: + """Own one isolated st window, actual-source checks and all physical input.""" + + startup_seconds = 60.0 + stop_seconds = 10.0 + + def __init__(self, command: tuple[str, ...], display: XvfbDisplay, output: Path, + target: RunTarget, cwd: Path, *, size: Size = Size(140, 40)): + self.command, self.display, self.output = tuple(map(str, command)), display, output + self.target, self.cwd, self.requested_size = target, cwd, size + environment = LaunchRoot.environment(ThreadLaunch.strip(dict(os.environ))) + for key in tuple(environment): + if key.startswith("XDG_") or key in ("PYTHONPATH", "PYTHONHOME", "NO_COLOR", "TOAD_VALIDATION"): + del environment[key] + environment.update( + HOME=str(target.home), DISPLAY=display.name, + XDG_CONFIG_HOME=str(target.config), XDG_STATE_HOME=str(target.state), + XDG_DATA_HOME=str(target.data), + AGENT_COMMS_RUNTIME_ROOT=str(target.launch_runtime.interpreter.parent), + ) + self.environment = environment + self.operations = itertools.count() + + @property + def columns(self) -> int: + return self.grid_size.width + + @property + def rows(self) -> int: + return self.grid_size.height + + @staticmethod + def prepare_target(root: Path, runtime: LaunchRuntime) -> RunTarget: + """Prepare a fresh frontend home beside an already owned private root. + + Only presentation settings travel. Saved Toad databases/tabs never do. + Existing installer acceptance and copied-history runs share this setup. + """ + from toad.preferences import SavedSettings + + root = root.resolve(strict=True) + home = root.parent / "frontend-home" + # Validate the intended paths before touching the home. RunTarget also + # rejects the real user's home/workspace and an unrelated bus. + if home.exists(): + raise FileExistsError(f"A physical run needs fresh frontend persistence: {home}") + # The source root has already been created by CheckRoot/the installer. + # Its parent is the run workspace, never the user's real home. + import pwd + user_home = Path(pwd.getpwuid(os.getuid()).pw_dir).resolve() + if root.parent == user_home or user_home.is_relative_to(root.parent): + raise ValueError("Refusing frontend setup beside a live user root") + home.mkdir(mode=0o700) + SuppliedRuntimeHome(home, runtime).prepare() + target = RunTarget(root, home.resolve(), runtime.prefix) + for directory in (target.config, target.state, target.data): + directory.mkdir(mode=0o700) + settings = SavedSettings.path() + if settings.is_file(): + destination = target.config / settings.parent.name / settings.name + destination.parent.mkdir(mode=0o700) + ExclusiveAtomicFile(destination).write_bytes(settings.read_bytes()) + route = ActiveRoute(root, RecordedBusIdentity.capture(Bus.path_in(root))) + route_path = home / active_route_path().relative_to(Path.home()) + route_new_bus(route, route_path) + return target + + @classmethod + @contextmanager + def copied(cls, source: Comms, threads: tuple[str, ...], runtime: LaunchRuntime, + output: Path, *, thread: str, size: Size = Size(140, 40)) -> Iterator[TerminalApp]: + """Create under the source owner, bind only its copy, retain run evidence.""" + output = output.expanduser().absolute() + output.mkdir(mode=0o700, parents=True) # Fresh evidence, never overwrite a failed run. + copied = CheckRoot(source, threads, output / "bus").create() + RootRuntime.publish(copied.root, runtime) + target = cls.prepare_target(copied.root, runtime) + try: + with OwnedDisplay(output / "display") as display: + # Keep the real default launcher: its root/runtime succession is + # precisely what the post-attachment observation must establish. + command = (str(Path.home() / ".local/bin/toad-comms"), thread) + app = cls(command, display, output / "frontend", target, target.home, size=size) + with display.recording(output / "journey.mp4", app.environment), app: + yield app + finally: + # Shutdown uses the copy's own runtime, not a foreign source Comms. + # Files remain until the actual acceptance and borrower checks retire them. + cls.stop_copy(target, output / "retirement") + + @staticmethod + def operation_file(operation: PhysicalOperation, directory: Path) -> Path: + directory.mkdir(mode=0o700, parents=True) + source = Path(__file__).with_name("physical_ui_remote.py") + with source.open("rb") as stream: + raw = stream.read(PhysicalOperation.source_bytes + 1) + if len(raw) > PhysicalOperation.source_bytes: + raise ValueError("Physical operation source exceeds its bound") + script = directory / "operation.py" + # remote_exec supplies no module filename. Bind only that external + # datum; the program stays in its reviewed source file and the request + # stays in FieldCodec's adjacent record, as in capture_history. + filename = f"__file__ = {str(script)!r}\n".encode() + ExclusiveAtomicFile(script).write_bytes(filename + raw) + PhysicalOperation.publish(script.with_suffix(".json"), operation) + return script + + @staticmethod + def result(directory: Path): + error = directory / "error.json" + path = error if error.exists() else directory / "result.json" + return PhysicalOperation.read_result(path).require() + + @classmethod + def stop_copy(cls, target: RunTarget, directory: Path) -> None: + script = cls.operation_file(StopRootOperation(target), directory) + with SpawnedProcess.launch( + (str(target.launch_runtime.interpreter), "-I", str(script)), + env=LaunchRoot.environment(ThreadLaunch.strip(dict(os.environ))), + ) as child: + child.collect(timeout=30).check_returncode() + cls.result(directory) + + def _packet(self, operation: PhysicalOperation) -> Path: + return self.operation_file(operation, self.output / "operations" / f"{next(self.operations):06d}") + + def __enter__(self): + self.output.mkdir(mode=0o700, parents=True) + self.dump = self.output / "st-screen.txt" + self.log = (self.output / "st.log").open("wb") + script = self._packet(ExecOperation(self.target, ProcessIdentity.current(), self.command)) + self.terminal = SpawnedProcess.launch( + ("st", "-f", "DejaVu Sans Mono:pixelsize=16", + "-g", f"{self.requested_size.width}x{self.requested_size.height}", "-o", str(self.dump), "-e", "env", + f"HOME={self.target.home}", f"XDG_CONFIG_HOME={self.target.config}", + f"XDG_STATE_HOME={self.target.state}", f"XDG_DATA_HOME={self.target.data}", + str(self.target.launch_runtime.interpreter), "-I", str(script)), + cwd=self.cwd, env=self.environment, + stdio=DetachedChildStdio(stdout=self.log, stderr=self.log), + ) + try: + self.window = int(subprocess.check_output( + ("xdotool", "search", "--sync", "--onlyvisible", "--pid", str(self.terminal.identity.pid)), + env=self.environment, text=True, timeout=10).splitlines()[0]) + deadline = time.monotonic() + self.startup_seconds + self._await_result(script.parent, deadline) + self.frontend = self.result(script.parent).process() + # Wait until the exec wrapper has left and the real interpreter has + # emitted terminal data. Neither is application/source readiness: + # that answer comes from the live Toad owner in observe() below. + while (Path(f"/proc/{self.frontend.pid}/exe").resolve() + != self.target.launch_runtime.interpreter.resolve() + or os.fsencode(script) in Path(f"/proc/{self.frontend.pid}/cmdline").read_bytes() + or not self.dump.stat().st_size): + self._require_starting(deadline) + time.sleep(.05) + self.read_grid() + while True: + try: + self.observe() + break + except PendingRun: + self._require_starting(deadline) + time.sleep(.1) + # -o enables st's raw printer too. Even these printer keys pass the + # same source admission as every later physical key/click. + self.keys("ctrl+Print") + self.wait("terminal", lambda screen: bool(screen.text.strip())) + return self + except BaseException: + self.terminal.stop_sync(deadline=time.monotonic() + self.stop_seconds) + self.terminal.close_streams() + self.log.close() + raise + + def _require_starting(self, deadline: float) -> None: + if self.terminal.process.poll() is not None or time.monotonic() >= deadline: + raise TimeoutError(f"Physical frontend did not become ready; see {self.output}") + + def _await_result(self, directory: Path, deadline: float) -> None: + while not ((directory / "result.json").exists() or (directory / "error.json").exists()): + self._require_starting(deadline) + time.sleep(.02) + + def observe(self) -> RunObservation: + self.terminal.platform.require(self.frontend) + operation = ObserveOperation(self.target, self.frontend, Path(__file__).resolve().parent) + script = self._packet(operation) + # This controller is an ancestor of its st/frontend. No arbitrary PID, + # helper privilege escalation, installed-file edit or source retry. + sys.remote_exec(self.frontend.pid, str(script)) + self._await_result(script.parent, time.monotonic() + PhysicalOperation.timeout) + observed = self.result(script.parent).observation() + if observed.process != self.frontend: + raise ValueError("Observation belongs to another frontend incarnation") + return self.target.require(observed) + + def input(self, *commands: str) -> RunObservation: + observed = self.observe() + self.terminal.platform.require(self.terminal.identity) + self.server_identity() + owner = subprocess.check_output( + ("xprop", "-display", self.display.name, "-id", str(self.window), "_NET_WM_PID"), + env=self.environment, text=True, timeout=5, + ) + if int(owner.partition("=")[2].strip()) != self.terminal.identity.pid: + raise ValueError("Window does not belong to this run's st frontend") + PhysicalOperation.publish( + self.output / "operations" / f"input-{next(self.operations):06d}.json", observed, + ) + subprocess.run( + ("xdotool", "windowfocus", "--sync", str(self.window), *commands), + env=self.environment, check=True, timeout=10, + ) + return observed + + def server_identity(self) -> None: + with XvfbDisplay(self.display.name) as actual: + if actual.process != self.display.process: + raise ValueError("The run's Xvfb incarnation changed") + + def keys(self, *keys: str) -> RunObservation: + return self.input("key", "--clearmodifiers", *keys) + + def type_text(self, text: str) -> None: + self.input("type", "--clearmodifiers", "--delay", "1", text) + + def capture(self): + """Read st's own print-screen output; screenshots record the visible window.""" + offset = self.dump.stat().st_size + observed = self.keys("shift+Print") + deadline = time.monotonic() + 2 + while True: + with self.dump.open("rb") as stream: + stream.seek(offset) + data = stream.read() + if data.count(b"\n") >= self.rows: + return ScreenCapture(data.decode("utf-8"), observed.history_regions) + if self.terminal.process.poll() is not None or time.monotonic() >= deadline: + raise RuntimeError("st did not finish its screen dump") + time.sleep(.02) + + def reader_place(self, name): + previous = () + + def settled(screen): + nonlocal previous + rows = screen.reader_position() + same = "Agent Comms connected" not in screen.text and bool(rows) and rows == previous + previous = rows + return same + + return self.wait(name, settled).reader_position() + + def screenshot(self, name): + path = self.output / f"{name}.png" + subprocess.run(("import", "-display", self.display.name, "-window", str(self.window), str(path)), + check=True, timeout=10) + return path + + def wait(self, name, predicate, seconds=60): + deadline = time.monotonic() + seconds + previous = "" + changes = self.output / f"{name}-changes.txt" + while True: + screen = self.capture() + text = screen.text + if text != previous: + with changes.open("a") as log: + log.write(f"\n--- {time.monotonic()} ---\n{text}") + previous = text + if predicate(screen): + (self.output / f"{name}.txt").write_text(text) + return screen + if self.terminal.process.poll() is not None or time.monotonic() >= deadline: + (self.output / f"{name}-failed.txt").write_text(text) + self.screenshot(f"{name}-failed") + raise RuntimeError(f"Terminal probe {name} failed; see {self.output}") + time.sleep(.2) + + def show_thread(self, thread): + self.wait("ready", lambda screen: thread in screen.text + and "▶ Session details · Ready" in "\n".join(screen.text.splitlines()[-8:])) + self.click_cell(self.columns - 1, self.rows // 2) + return self.wait("shown", lambda screen: "User messages" in screen.text and all( + "Indexing" not in line.split("│", 1)[0] for line in screen.text.splitlines())) + + def click(self, label): + text = self.wait("find-" + label.replace("/", "-"), lambda screen: any( + label in line.split("│", 1)[0] for line in screen.text.splitlines())).text + row, line = next((row, line) for row, line in enumerate(text.splitlines()) + if label in line.split("│", 1)[0]) + self.click_cell(cell_len(line[:line.index(label)]) + cell_len(label) // 2, row) + + def toggle_group(self, label): + def header(line): + return line.split("│", 1)[0].lstrip().startswith(("▾ " + label, "▸ " + label)) + text = self.wait("find-group-" + label, lambda screen: any(map(header, screen.text.splitlines()))).text + row, line = next((row, line) for row, line in enumerate(text.splitlines()) if header(line)) + glyph = min(index for index in (line.find("▾"), line.find("▸")) if index >= 0) + self.click_cell(cell_len(line[:glyph]), row) + + def hide_category(self, label): + def control(text): + return next(((row, line) for row, line in enumerate(text.splitlines()) + if "│" in line and label in line.rsplit("│", 1)[-1]), None) + text = self.wait("find-filter-" + label.replace("/", "-"), lambda screen: control(screen.text) is not None).text + row, line = control(text) + prefix, panel = line.rsplit("│", 1) + self.click_cell(cell_len(prefix + "│" + panel[:panel.index("▐")]) + 1, row) + + def read_grid(self): + """Derive cells/padding from st's own X size hints, including after resize.""" + geometry = subprocess.check_output( + ("xdotool", "getwindowgeometry", "--shell", str(self.window)), + env=self.environment, text=True, timeout=5) + size = dict(line.split("=", 1) for line in geometry.splitlines() if "=" in line) + hints = subprocess.check_output( + ("xprop", "-display", self.display.name, "-id", str(self.window), "WM_NORMAL_HINTS"), + env=self.environment, text=True, timeout=5) + declared = {name: (int(width), int(height)) for name, width, height in re.findall( + r"program specified (minimum size|base size): (\d+) by (\d+)", hints)} + base_x, base_y = declared["base size"] + minimum_x, minimum_y = declared["minimum size"] + self.cell_width, self.cell_height = minimum_x - base_x, minimum_y - base_y + self.border_x, self.border_y = base_x / 2, base_y / 2 + self.window_region = Region(int(size["X"]), int(size["Y"]), int(size["WIDTH"]), int(size["HEIGHT"])) + self.grid_size = Size((self.window_region.width - base_x) // self.cell_width, + (self.window_region.height - base_y) // self.cell_height) + + def click_cell(self, column, row): + self.read_grid() + x = int(self.border_x + (column + .5) * self.cell_width) + y = int(self.border_y + (row + .5) * self.cell_height) + self.input("mousemove", "--window", str(self.window), str(x), str(y), "click", "1") + + def resize(self, columns: int, rows: int) -> None: + self.read_grid() + self.input("windowsize", "--sync", str(self.window), str(int(2 * self.border_x + columns * self.cell_width)), + str(int(2 * self.border_y + rows * self.cell_height))) + self.read_grid() + + def __exit__(self, *exception): + try: + if self.terminal.process.poll() is None: + try: + self.keys("ctrl+q") + self.terminal.process.wait(timeout=5) + finally: + # The process owner retires the whole exact child lifetime, + # including exceptional/half-started UI teardown. + self.terminal.stop_sync(deadline=time.monotonic() + self.stop_seconds) + finally: + self.terminal.close_streams() + self.log.close() diff --git a/tools/physical_ui_remote.py b/tools/physical_ui_remote.py new file mode 100644 index 000000000..c807bd9e9 --- /dev/null +++ b/tools/physical_ui_remote.py @@ -0,0 +1,360 @@ +"""Typed operations of one owned physical run, copied beside their requests. + +The exec operation grants only its recorded controller's descendants debugging +access, then execs the unchanged launcher. Observation reads live source owners; +it never opens a Comms, sends input, repairs a route or traverses the heap. +""" + +from abc import abstractmethod +import ctypes +from dataclasses import dataclass +import json +import os +from pathlib import Path +import pwd +import sys +import time +from typing import ClassVar + +from agent_comms.child_process import ProcessIdentity +from agent_comms.launch_runtime import LaunchRuntime +from agent_comms.nominal.declared_family import DeclaredFamily +from agent_comms.nominal.field_codec import DeclaredWireType, FieldCodec +from textual.geometry import Region +from agent_comms.store_files import ExclusiveAtomicFile + + +class PendingRun(RuntimeError): + """The observed startup has not acquired its source yet; no input is allowed.""" + + +@dataclass(frozen=True) +class RunTarget: + """Intended private resources, distinct from an observation of the app.""" + + root: Path + home: Path + runtime: Path + + def __post_init__(self): + for path in (self.root, self.home, self.runtime): + if path != path.resolve(strict=True): + raise ValueError(f"A physical run needs canonical existing paths: {path}") + workspace = self.home.parent + user_home = Path(pwd.getpwuid(os.getuid()).pw_dir).resolve() + if workspace == user_home or user_home.is_relative_to(workspace): + raise ValueError("A physical run cannot use the real user's home as its workspace") + if not self.root.is_relative_to(workspace) or self.root == workspace: + raise ValueError("The copied root and private home must belong to one run workspace") + if self.root == self.home or self.root.is_relative_to(self.home): + raise ValueError("The copied root is separate from frontend persistence") + + @property + def launch_runtime(self) -> LaunchRuntime: + return LaunchRuntime.at_prefix(self.runtime) + + @property + def config(self) -> Path: + return self.home / "config" + + @property + def state(self) -> Path: + return self.home / "state" + + @property + def data(self) -> Path: + return self.home / "data" + + def require(self, observed): + if observed.home != self.home or observed.runtime != self.runtime: + raise ValueError("Post-attachment HOME/runtime differs from the run target") + if (observed.config, observed.state, observed.data) != (self.config, self.state, self.data): + raise ValueError("Post-attachment XDG persistence differs from the private run") + if not observed.database.is_relative_to(self.state): + raise ValueError("Toad's actual database is outside private state") + if observed.root != self.root: + raise ValueError("Core selected a foreign source") + if not observed.opened: + raise PendingRun("Core has not opened the intended private source") + for root in (*observed.saved_roots, *observed.acp_roots): + if root != self.root: + raise ValueError(f"Foreign saved/ACP source refuses physical input: {root}") + return observed + + +@dataclass(frozen=True) +class CapturedRegion(DeclaredWireType): + """Wire capability for Textual's final compiled Region, without copying its fields. + + The installed Region rejects subclassing; geometry remains on that existing + type. Only this private boundary's representation lives here. + """ + + region: Region + wire_type = tuple[int, ...] + + def to_wire(self): + return list(self.region) + + @classmethod + def from_wire(cls, data): + return cls(Region(*FieldCodec.decode(cls.wire_type, data))) + + +@dataclass(frozen=True) +class RunObservation: + process: ProcessIdentity + started_ns: int + finished_ns: int + runtime: Path + home: Path + config: Path + state: Path + data: Path + database: Path + root: Path + opened: bool + saved_roots: tuple[Path, ...] + acp_roots: tuple[Path, ...] + history_regions: tuple[CapturedRegion, ...] + modules_checked: int + toad_module: Path + core_module: Path + textual_module: Path + + +class PhysicalResult(DeclaredFamily, affix="Result"): + def require(self): + return self + + def observation(self) -> RunObservation: + raise ValueError("This physical operation did not observe an application") + + def process(self) -> ProcessIdentity: + raise ValueError("This physical operation did not exec a frontend") + + +@dataclass(frozen=True) +class ObservedResult(PhysicalResult): + value: RunObservation + + def observation(self) -> RunObservation: + return self.value + + +@dataclass(frozen=True) +class ExecResult(PhysicalResult): + identity: ProcessIdentity + debugger: ProcessIdentity + + def process(self) -> ProcessIdentity: + return self.identity + + +@dataclass(frozen=True) +class StoppedResult(PhysicalResult): + root: Path + + +@dataclass(frozen=True) +class PendingResult(PhysicalResult): + detail: str + + def require(self): + raise PendingRun(self.detail) + + def observation(self) -> RunObservation: + self.require() + + +@dataclass(frozen=True) +class FailedResult(PhysicalResult): + error: str + detail: str + + def require(self): + raise RuntimeError(f"Physical operation failed: {self.error}: {self.detail}") + + def observation(self) -> RunObservation: + self.require() + + def process(self) -> ProcessIdentity: + self.require() + + +class PhysicalOperation(DeclaredFamily, affix="Operation"): + metadata_bytes: ClassVar[int] = 65536 + source_bytes: ClassVar[int] = 256 << 10 + timeout: ClassVar[float] = 10.0 + + @abstractmethod + def execute(self, directory: Path) -> None: ... + + @classmethod + def publish(cls, path: Path, value: object) -> None: + encoded = json.dumps(FieldCodec.encode(value), ensure_ascii=False, allow_nan=False).encode() + if len(encoded) > cls.metadata_bytes: + raise ValueError("Physical metadata exceeds its byte bound") + ExclusiveAtomicFile(path).write_bytes(encoded) + + @classmethod + def read_result(cls, path: Path) -> PhysicalResult: + with path.open("rb") as source: + raw = source.read(cls.metadata_bytes + 1) + if len(raw) > cls.metadata_bytes: + raise ValueError("Oversize physical result") + return FieldCodec.decode(PhysicalResult, FieldCodec.parse(raw.decode())) + + @classmethod + def execute_file(cls, script: Path) -> None: + directory = script.parent + result = directory / "result.json" + try: + with script.with_suffix(".json").open("rb") as source: + raw = source.read(cls.metadata_bytes + 1) + if len(raw) > cls.metadata_bytes: + raise ValueError("Oversize physical request") + operation = FieldCodec.decode(cls, FieldCodec.parse(raw.decode())) + operation.execute(directory) + except PendingRun as error: + cls.publish(result, PendingResult(str(error)[:2048])) + except BaseException as error: + cls.publish(directory / "error.json", FailedResult(type(error).__name__, str(error)[:2048])) + + +@dataclass(frozen=True) +class ExecOperation(PhysicalOperation): + """One new child, not an arbitrary existing trace target or global policy.""" + + target: RunTarget + debugger: ProcessIdentity + arguments: tuple[str, ...] + PR_SET_PTRACER: ClassVar[int] = 0x59616D61 # Linux's declared prctl ABI. + + def execute(self, directory: Path) -> None: + if not self.arguments or not self.debugger.alive(): + raise ValueError("Exec needs its original living controller and command") + if Path.home().resolve() != self.target.home: + raise ValueError("st changed HOME before frontend exec") + prctl = ctypes.CDLL(None, use_errno=True).prctl + prctl.argtypes = (ctypes.c_int, ctypes.c_ulong, ctypes.c_ulong, ctypes.c_ulong, ctypes.c_ulong) + prctl.restype = ctypes.c_int + if prctl(self.PR_SET_PTRACER, self.debugger.pid, 0, 0, 0) != 0: + raise OSError(ctypes.get_errno(), "Cannot admit this controller's debugger descendants") + self.publish(directory / "result.json", ExecResult(ProcessIdentity.current(), self.debugger)) + os.execvpe(self.arguments[0], self.arguments, os.environ) + + +@dataclass(frozen=True) +class ObserveOperation(PhysicalOperation): + target: RunTarget + identity: ProcessIdentity + tools: Path + + def execute(self, directory: Path) -> None: + started = time.monotonic_ns() + if ProcessIdentity.current() != self.identity: + raise ValueError("Post-attachment frontend incarnation changed") + if Path.home().resolve() != self.target.home: + raise ValueError("Post-attachment HOME is not private") + # The existing reviewed validator remains the single module-location policy. + # The temporary tool import path is retired before inspecting application state. + previous = sys.path[:] + try: + sys.path.insert(0, str(self.tools)) + from performance.history_capture_remote import require_modules + finally: + sys.path[:] = previous + import agent_comms + import textual + import toad + from agent_comms.acp_extension import CoordinationChangedUpdate + from metaclass_registry.mro_dispatch import handles + from toad.acp.agent import Agent + from toad.agent_binding import NoAgentConnection + from toad.app import ToadApp + from textual._context import NoActiveAppError + from textual.app import ScreenStackError + from toad.core.projection import MroProjection + from toad.widgets.history_anchor import HistoryWindow + from xdg_base_dirs import xdg_config_home, xdg_data_home, xdg_state_home + + class AcpRoots(MroProjection[tuple[Path, ...]]): + """Decode foreign running-UI connections once, without guessing readiness.""" + + @handles(object) + def unknown(self, value): + raise ValueError(f"Unadmitted ACP metadata type: {type(value).__name__}") + + @handles(type(None)) + def pending(self, value): + raise PendingRun("A bound ACP has not published its coordination root") + + @handles(NoAgentConnection) + def unbound(self, value): + return () + + @handles(Agent) + def bound(self, value): + return self.project_value(value.coordination) + + @handles(CoordinationChangedUpdate) + def coordinated(self, value): + return (Path(value.wire_root).resolve(),) + + try: + app = ToadApp.running_app() + screen = app.screen + except (NoActiveAppError, ScreenStackError) as error: + raise PendingRun("No sole running Toad application with a screen yet") from error + checked = require_modules(self.target.runtime) + access = app.coordination_access + root = access.selection.root + sessions = app.session_navigation + saved = tuple(Path(source).resolve() for entry in sessions.members + for source, _identity in (*entry.original_threads(sessions), *entry.original_channels())) + decoder = AcpRoots() + acp = tuple(source for process in dict.fromkeys(sessions.processes) + for agent in (process.agent, *(session.agent for session in process.sessions)) + for source in decoder.project_value(agent)) + # This is geometry of actually published history windows, not a guess + # from sidebar separators that may also occur inside a user's message. + regions = tuple(CapturedRegion(region) for window in screen.query(HistoryWindow) + for region in (window.published_content_region,) if region is not None) + observed = RunObservation( + self.identity, started, time.monotonic_ns(), Path(sys.prefix).resolve(), + Path.home().resolve(), xdg_config_home().resolve(), xdg_state_home().resolve(), + xdg_data_home().resolve(), app.tab_order._db.path.resolve(), root, + access.observation.observes(root), saved, acp, regions, checked, + Path(toad.__file__).resolve(), Path(agent_comms.__file__).resolve(), + Path(textual.__file__).resolve(), + ) + self.target.require(observed) + self.publish(directory / "result.json", ObservedResult(observed)) + + +@dataclass(frozen=True) +class StopRootOperation(PhysicalOperation): + """Stop only the private copy, in its own declared runtime; retain its files.""" + + target: RunTarget + + def execute(self, directory: Path) -> None: + if LaunchRuntime.running().prefix != self.target.runtime: + raise ValueError("Private-copy shutdown must run in its declared runtime") + from agent_comms.comms import wire + from agent_comms.launch_root import LaunchRoot + from agent_comms.registry_document import RosterVisibility + + with LaunchRoot.from_path(self.target.root).selected(): + copied = wire() + for thread in copied.registry.snapshot().visible_threads(RosterVisibility(archived=True)): + copied.owners.stop(thread.name) + self.publish(directory / "result.json", StoppedResult(self.target.root)) + + +# Normal imports define the family. A controller-bound module filename selects +# only that immutable copy's adjacent request, including under remote_exec. +_source = Path(__file__) +if _source.with_suffix(".json").is_file(): + PhysicalOperation.execute_file(_source) From 6afd1a50f934948cd46edbf92c65566d99ba47af Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sat, 31 Oct 2026 21:52:13 -0400 Subject: [PATCH 02/17] Freeze run operations and retain exact child retirement evidence --- docs/performance/physical-run.md | 38 +++++++++--- tools/physical_ui.py | 103 +++++++++++++++++++++---------- tools/physical_ui_remote.py | 21 ++++++- 3 files changed, 118 insertions(+), 44 deletions(-) diff --git a/docs/performance/physical-run.md b/docs/performance/physical-run.md index 6df1d4b5a..74d518bbd 100644 --- a/docs/performance/physical-run.md +++ b/docs/performance/physical-run.md @@ -40,12 +40,18 @@ acceptance and actual borrower checks. source owner creates history; only its disposable copy is published through `RootRuntime.publish` to a supplied runtime. - Fresh Toad config/state/data and no copied saved-tab database. Presentation - settings can travel; source identities cannot be repaired by rewriting tabs. + settings derive from the declarations on the `INTERFACE` page; session/shell/ + launcher settings and the original anonymous identity do not travel. Collection + is disabled through `SavedSettings`. The optional palette is copied/validated + through `WalFile` and the actual private palette directory is observed. + Source identities cannot be repaired by rewriting tabs. - HOME/XDG inside `st -e env`: st itself resets HOME from passwd before exec. - Xvfb uses displayfd, not a guessed display number. Xlib checks its actual local peer UID/process; the run verifies the allocated server incarnation. - Child exec grants debugging only to the recorded controller PID and its - descendants. It changes no global ptrace policy and needs no sudo. + descendants. It changes no global ptrace policy and needs no sudo. The + controller owns one bounded operation-source snapshot for its entire Python + lifetime; editing the checkout cannot change an already started run's wire. - Before physical input: the actual frontend incarnation, runtime and loaded Toad/Core/Textual module locations, HOME/XDG/database, selected/opened source, every saved entry's source and every borrowed ACP process's creator/session @@ -88,11 +94,23 @@ not become authorized merely because they import the shared display resource. ## Current qualification -Core #3821 is an API checkpoint; full acceptance is separate. Prototype7 -completed with exit0: actual copied b34 frontend/source/home/runtime admission, -held PageUp/PageDown, reversal, End and wheel input, screenshots, a continuous -recording and typed copied-root stop. Its source controller still used the -owned Core overlay, and it had no bound ACP. Earlier failed run receipts remain -retained. Candidate admission, installed-source qualification, four isolation -counterexamples, tab/resize/arrival/retirement coverage and final profiling must -be reported individually; none is implied by this bounded scrolling result. +Core #3821 is installed in 7d0d; the merger reports 23 selected checks passed. +Fresh runs10 and12 used that installed source interpreter with `-I` and no Core +source overlay, and observed all three actual package origins in the supplied +mobile candidate. Run12 completed exit0 through held PageUp/PageDown, reversal, +End and wheel input, with screenshots/recording and typed private-copy stop. +The st, Xvfb and encoder receipts name their exact process births and existing +child-owner outcomes; their PIDs and the frontend were independently absent. +There were **no bound ACPs**. Four isolation counterexamples, the wider tab/ +resize/arrival journey and actual profiling remain separate obligations. + +Run8's foreign candidate-module origin, run9's missing-palette warning and +callback timeout, and run11's first-observation timeout remain retained. A +missing palette was demonstrated, not proved to explain every timeout. Failed +or uncertain operations now go straight to the existing owned-process shutdown; +exit does not attempt another attachment/key. No such attempt is replayed. + +Current helpers are `tools/physical_ui.py` and `tools/physical_ui_remote.py` on +PR #1393. Consumers must start a new controller from one published checkpoint, +not combine a live process with a newly imported wire shape. A completed +stopped-history scenario is not bound-ACP or performance acceptance. diff --git a/tools/physical_ui.py b/tools/physical_ui.py index bbfb1ba49..ad0917fc6 100644 --- a/tools/physical_ui.py +++ b/tools/physical_ui.py @@ -20,6 +20,7 @@ import subprocess import sys import time +from typing import get_args, get_type_hints from agent_comms.active_route import ActiveRoute, active_route_path, route_new_bus from agent_comms.bus import Bus @@ -29,6 +30,7 @@ from agent_comms.installer.practice import SuppliedRuntimeHome from agent_comms.launch_root import LaunchRoot from agent_comms.launch_runtime import LaunchRuntime, ThreadLaunch +from agent_comms.nominal.field_codec import FieldCodec from agent_comms.recorded_bus import RecordedBusIdentity from agent_comms.root_runtime import RootRuntime from agent_comms.store_files import ExclusiveAtomicFile @@ -36,10 +38,11 @@ from rich.segment import Segment from textual.geometry import Region, Size from textual.strip import Strip +from ui_theme.wal import WalFile from physical_ui_remote import ( CapturedRegion, ExecOperation, ObserveOperation, PendingRun, PhysicalOperation, - RunObservation, RunTarget, StopRootOperation, + ProcessRetirement, RunObservation, RunTarget, StopRootOperation, ) @@ -105,12 +108,18 @@ def recording(self, path: Path, environment: Mapping[str, str]): env=environment, stdio=DetachedChildStdio(stdin=subprocess.PIPE, stdout=log, stderr=log), ) as child: + PhysicalOperation.publish(path.with_suffix(".process.json"), child.identity) try: yield path finally: - child.process.communicate(b"q", timeout=10) - if child.process.returncode: - raise RuntimeError(f"Physical recording failed; see {log.name}") + try: + child.process.communicate(b"q", timeout=10) + if child.process.returncode: + raise RuntimeError(f"Physical recording failed; see {log.name}") + finally: + outcome = child.stop_sync(deadline=time.monotonic() + 10) + PhysicalOperation.publish(path.with_suffix(".retired.json"), + ProcessRetirement(child.identity, outcome)) def __exit__(self, *exception): return self.close_display.__exit__(*exception) @@ -123,6 +132,7 @@ class OwnedDisplay(XvfbDisplay): stop_seconds = 10.0 def __init__(self, output: Path): + self.output = output output.mkdir(parents=True, exist_ok=True) self.log = (output / "xvfb.log").open("wb") self.server = SpawnedProcess.launch( @@ -139,13 +149,16 @@ def __init__(self, output: Path): if self.process != self.server.identity: self.close_display.close() raise ValueError("Xlib connected to a server this run did not create") + PhysicalOperation.publish(output / "xvfb-process.json", self.process) except BaseException: self.close_server() raise def close_server(self) -> None: try: - self.server.stop_sync(deadline=time.monotonic() + self.stop_seconds) + outcome = self.server.stop_sync(deadline=time.monotonic() + self.stop_seconds) + PhysicalOperation.publish(self.output / "xvfb-retired.json", + ProcessRetirement(self.server.identity, outcome)) finally: self.server.close_streams() self.log.close() @@ -197,6 +210,12 @@ class TerminalApp: startup_seconds = 60.0 stop_seconds = 10.0 + # One source snapshot belongs to the controller's Python module lifetime, + # not to each callback executing inside a foreign running application. + with Path(__file__).with_name("physical_ui_remote.py").open("rb") as _source: + operation_program = _source.read(PhysicalOperation.source_bytes + 1) + if len(operation_program) > PhysicalOperation.source_bytes: + raise ValueError("Physical operation source exceeds its bound") def __init__(self, command: tuple[str, ...], display: XvfbDisplay, output: Path, target: RunTarget, cwd: Path, *, size: Size = Size(140, 40)): @@ -210,6 +229,7 @@ def __init__(self, command: tuple[str, ...], display: XvfbDisplay, output: Path, HOME=str(target.home), DISPLAY=display.name, XDG_CONFIG_HOME=str(target.config), XDG_STATE_HOME=str(target.state), XDG_DATA_HOME=str(target.data), + PYWAL_CACHE_DIR=str(target.palette), AGENT_COMMS_RUNTIME_ROOT=str(target.launch_runtime.interpreter.parent), ) self.environment = environment @@ -230,7 +250,7 @@ def prepare_target(root: Path, runtime: LaunchRuntime) -> RunTarget: Only presentation settings travel. Saved Toad databases/tabs never do. Existing installer acceptance and copied-history runs share this setup. """ - from toad.preferences import SavedSettings + from toad.preferences import INTERFACE, SETTINGS, SavedSettings, ToadSettings root = root.resolve(strict=True) home = root.parent / "frontend-home" @@ -250,10 +270,24 @@ def prepare_target(root: Path, runtime: LaunchRuntime) -> RunTarget: for directory in (target.config, target.state, target.data): directory.mkdir(mode=0o700) settings = SavedSettings.path() + destination = target.config / settings.parent.name / settings.name + destination.parent.mkdir(mode=0o700) + selected = {} if settings.is_file(): - destination = target.config / settings.parent.name / settings.name - destination.parent.mkdir(mode=0o700) - ExclusiveAtomicFile(destination).write_bytes(settings.read_bytes()) + raw = FieldCodec.decode(dict[str, object], FieldCodec.parse(settings.read_text())) + annotations = get_type_hints(ToadSettings, include_extras=True) + presentation = frozenset(key for field, key in FieldCodec.record_fields(ToadSettings) + if INTERFACE in get_args(annotations[field.name])) + selected = {key: value for key, value in raw.items() if key in presentation} + private_settings = SavedSettings(selected) + private_settings.set(SETTINGS.statistics.allow_collect, False) + ExclusiveAtomicFile(destination).write_bytes(private_settings.json.encode()) + palette = WalFile.directory(os.environ) / WalFile.NAME + if palette.is_file(): + target.palette.mkdir(mode=0o700, parents=True) + destination = target.palette / WalFile.NAME + ExclusiveAtomicFile(destination).write_bytes(palette.read_bytes()) + WalFile.read(destination) # Validate the exact copied bytes through their format owner. route = ActiveRoute(root, RecordedBusIdentity.capture(Bus.path_in(root))) route_path = home / active_route_path().relative_to(Path.home()) route_new_bus(route, route_path) @@ -282,14 +316,12 @@ def copied(cls, source: Comms, threads: tuple[str, ...], runtime: LaunchRuntime, # Files remain until the actual acceptance and borrower checks retire them. cls.stop_copy(target, output / "retirement") - @staticmethod - def operation_file(operation: PhysicalOperation, directory: Path) -> Path: + @classmethod + def operation_file(cls, operation: PhysicalOperation, directory: Path) -> Path: directory.mkdir(mode=0o700, parents=True) - source = Path(__file__).with_name("physical_ui_remote.py") - with source.open("rb") as stream: - raw = stream.read(PhysicalOperation.source_bytes + 1) - if len(raw) > PhysicalOperation.source_bytes: - raise ValueError("Physical operation source exceeds its bound") + # The declaration module owns one bounded program snapshot for this + # controller lifetime. Later source edits cannot change a run's wire. + raw = cls.operation_program script = directory / "operation.py" # remote_exec supplies no module filename. Bind only that external # datum; the program stays in its reviewed source file and the request @@ -333,6 +365,7 @@ def __enter__(self): stdio=DetachedChildStdio(stdout=self.log, stderr=self.log), ) try: + PhysicalOperation.publish(self.output / "st-process.json", self.terminal.identity) self.window = int(subprocess.check_output( ("xdotool", "search", "--sync", "--onlyvisible", "--pid", str(self.terminal.identity.pid)), env=self.environment, text=True, timeout=10).splitlines()[0]) @@ -362,9 +395,7 @@ def __enter__(self): self.wait("terminal", lambda screen: bool(screen.text.strip())) return self except BaseException: - self.terminal.stop_sync(deadline=time.monotonic() + self.stop_seconds) - self.terminal.close_streams() - self.log.close() + self.close() raise def _require_starting(self, deadline: float) -> None: @@ -387,10 +418,7 @@ def observe(self) -> RunObservation: observed = self.result(script.parent).observation() if observed.process != self.frontend: raise ValueError("Observation belongs to another frontend incarnation") - return self.target.require(observed) - - def input(self, *commands: str) -> RunObservation: - observed = self.observe() + self.target.require(observed) self.terminal.platform.require(self.terminal.identity) self.server_identity() owner = subprocess.check_output( @@ -399,6 +427,10 @@ def input(self, *commands: str) -> RunObservation: ) if int(owner.partition("=")[2].strip()) != self.terminal.identity.pid: raise ValueError("Window does not belong to this run's st frontend") + return observed + + def input(self, *commands: str) -> RunObservation: + observed = self.observe() PhysicalOperation.publish( self.output / "operations" / f"input-{next(self.operations):06d}.json", observed, ) @@ -447,6 +479,7 @@ def settled(screen): return self.wait(name, settled).reader_position() def screenshot(self, name): + self.observe() path = self.output / f"{name}.png" subprocess.run(("import", "-display", self.display.name, "-window", str(self.window), str(path)), check=True, timeout=10) @@ -534,16 +567,22 @@ def resize(self, columns: int, rows: int) -> None: str(int(2 * self.border_y + rows * self.cell_height))) self.read_grid() - def __exit__(self, *exception): + def close(self) -> None: + """Retire one child lifetime on normal exit and failed entry alike.""" try: - if self.terminal.process.poll() is None: - try: - self.keys("ctrl+q") - self.terminal.process.wait(timeout=5) - finally: - # The process owner retires the whole exact child lifetime, - # including exceptional/half-started UI teardown. - self.terminal.stop_sync(deadline=time.monotonic() + self.stop_seconds) + outcome = self.terminal.stop_sync(deadline=time.monotonic() + self.stop_seconds) + PhysicalOperation.publish(self.output / "st-retired.json", + ProcessRetirement(self.terminal.identity, outcome)) finally: self.terminal.close_streams() self.log.close() + + def __exit__(self, exception_type, exception, traceback): + try: + # An exceptional/uncertain operation is not permission for another + # attachment or input. Retire only the already owned child instead. + if exception_type is None and self.terminal.process.poll() is None: + self.keys("ctrl+q") + self.terminal.process.wait(timeout=5) + finally: + self.close() diff --git a/tools/physical_ui_remote.py b/tools/physical_ui_remote.py index c807bd9e9..0ed1627e4 100644 --- a/tools/physical_ui_remote.py +++ b/tools/physical_ui_remote.py @@ -16,11 +16,12 @@ import time from typing import ClassVar -from agent_comms.child_process import ProcessIdentity +from agent_comms.child_process import ChildOutcome, ProcessIdentity from agent_comms.launch_runtime import LaunchRuntime from agent_comms.nominal.declared_family import DeclaredFamily from agent_comms.nominal.field_codec import DeclaredWireType, FieldCodec from textual.geometry import Region +from ui_theme.wal import WalFile from agent_comms.store_files import ExclusiveAtomicFile @@ -53,6 +54,10 @@ def __post_init__(self): def launch_runtime(self) -> LaunchRuntime: return LaunchRuntime.at_prefix(self.runtime) + @property + def palette(self) -> Path: + return WalFile.directory({"HOME": str(self.home)}) + @property def config(self) -> Path: return self.home / "config" @@ -70,6 +75,8 @@ def require(self, observed): raise ValueError("Post-attachment HOME/runtime differs from the run target") if (observed.config, observed.state, observed.data) != (self.config, self.state, self.data): raise ValueError("Post-attachment XDG persistence differs from the private run") + if observed.palette_directory != self.palette: + raise ValueError("The actual theme palette is outside the private home") if not observed.database.is_relative_to(self.state): raise ValueError("Toad's actual database is outside private state") if observed.root != self.root: @@ -112,6 +119,7 @@ class RunObservation: state: Path data: Path database: Path + palette_directory: Path root: Path opened: bool saved_roots: tuple[Path, ...] @@ -123,6 +131,14 @@ class RunObservation: textual_module: Path +@dataclass(frozen=True) +class ProcessRetirement: + """The existing child owner's result, qualified by the exact resource birth.""" + + identity: ProcessIdentity + outcome: ChildOutcome + + class PhysicalResult(DeclaredFamily, affix="Result"): def require(self): return self @@ -324,7 +340,8 @@ def coordinated(self, value): observed = RunObservation( self.identity, started, time.monotonic_ns(), Path(sys.prefix).resolve(), Path.home().resolve(), xdg_config_home().resolve(), xdg_state_home().resolve(), - xdg_data_home().resolve(), app.tab_order._db.path.resolve(), root, + xdg_data_home().resolve(), app.tab_order._db.path.resolve(), + app.colours.wal_directory.resolve(), root, access.observation.observes(root), saved, acp, regions, checked, Path(toad.__file__).resolve(), Path(agent_comms.__file__).resolve(), Path(textual.__file__).resolve(), From 7d776bbfeb3921f96a95252e4740ba8123e3e05d Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sat, 31 Oct 2026 21:58:03 -0400 Subject: [PATCH 03/17] Give physical runs an explicit framebuffer and reject clipped windows --- docs/performance/physical-run.md | 11 ++++++++--- tools/performance/scroll_scenario.py | 11 +++++++++-- tools/physical_ui.py | 22 ++++++++++++++++------ 3 files changed, 33 insertions(+), 11 deletions(-) diff --git a/docs/performance/physical-run.md b/docs/performance/physical-run.md index 74d518bbd..5613f9b4b 100644 --- a/docs/performance/physical-run.md +++ b/docs/performance/physical-run.md @@ -25,10 +25,14 @@ private copy's runtime. The source controller remains installed. The candidate must contain its own Toad/Core/Textual packages; a dependency `.pth` pointing those packages outside the selected prefix fails actual module-origin checks. Supply a new output directory for each distinct run. Do not replay a failed or -UNKNOWN input against an old run. +UNKNOWN input against an old run. Terminal dimensions are **cells**; framebuffer +sizes are **pixels**. For the mobile48×48 →140×40 journey, supply +`--columns 48 --rows 48 --framebuffer 1600 1200 --resize 140 40`, or pass +`framebuffer=Size(1600, 1200)` to the callable factory. The owner checks actual +native window bounds and rejects clipping instead of recording half a terminal. The callable entry point is `TerminalApp.copied(source, threads, runtime, -output, thread=..., size=Size(...))`. Its context yields the admitted app and +output, thread=..., size=Size(...), framebuffer=Size(...))`. Its context yields the admitted app and retains the copied root, frontend-home, operation receipts, screenshots and recording on exit. The caller still owns retiring those retained files after acceptance and actual borrower checks. @@ -101,7 +105,8 @@ mobile candidate. Run12 completed exit0 through held PageUp/PageDown, reversal, End and wheel input, with screenshots/recording and typed private-copy stop. The st, Xvfb and encoder receipts name their exact process births and existing child-owner outcomes; their PIDs and the frontend were independently absent. -There were **no bound ACPs**. Four isolation counterexamples, the wider tab/ +Run13 also completed the48×48 →140×40 candidate geometry journey in a1600×1200 +owned framebuffer. There were **no bound ACPs**. Four isolation counterexamples, the wider tab/ resize/arrival journey and actual profiling remain separate obligations. Run8's foreign candidate-module origin, run9's missing-palette warning and diff --git a/tools/performance/scroll_scenario.py b/tools/performance/scroll_scenario.py index 419e551ca..3046f594b 100644 --- a/tools/performance/scroll_scenario.py +++ b/tools/performance/scroll_scenario.py @@ -16,7 +16,7 @@ from agent_comms.comms import wire from agent_comms.launch_root import LaunchRoot from agent_comms.launch_runtime import LaunchRuntime -from physical_ui import TerminalApp +from physical_ui import OwnedDisplay, TerminalApp from physical_ui_remote import ObservedResult, PhysicalOperation from textual.geometry import Size @@ -29,6 +29,10 @@ def main(): parser.add_argument("--output", type=Path, required=True, help="Fresh owned agent-scratch directory") parser.add_argument("--columns", type=int, default=140) parser.add_argument("--rows", type=int, default=40) + parser.add_argument("--framebuffer", nargs=2, type=int, default=OwnedDisplay.default_size, + metavar=("WIDTH", "HEIGHT"), help="Owned display size in pixels, not terminal cells") + parser.add_argument("--resize", nargs=2, type=int, action="append", default=[], + metavar=("COLUMNS", "ROWS"), help="Resize through the same owner after scrolling") arguments = sys.argv[1:] LaunchRoot.startup(arguments, ActiveRoute.published_launch) args = parser.parse_args(arguments) @@ -39,7 +43,7 @@ def main(): threads = tuple(dict.fromkeys((args.thread, *args.history_thread))) with TerminalApp.copied( wire(), threads, LaunchRuntime.at_prefix(args.runtime), args.output, - thread=args.thread, size=Size(args.columns, args.rows), + thread=args.thread, size=Size(args.columns, args.rows), framebuffer=Size(*args.framebuffer), ) as app: app.wait("history", lambda screen: bool(screen.reader_position())) PhysicalOperation.publish(args.output / "admitted.json", ObservedResult(app.observe())) @@ -55,6 +59,9 @@ def main(): app.screenshot("wheel-up") app.keys("End") app.screenshot("end-return") + for columns, rows in args.resize: + app.resize(columns, rows) + app.screenshot(f"resized-{columns}x{rows}") print(args.output) diff --git a/tools/physical_ui.py b/tools/physical_ui.py index ad0917fc6..d64b4ed1f 100644 --- a/tools/physical_ui.py +++ b/tools/physical_ui.py @@ -130,13 +130,14 @@ class OwnedDisplay(XvfbDisplay): startup_seconds = 10.0 stop_seconds = 10.0 + default_size = Size(1600, 1000) - def __init__(self, output: Path): + def __init__(self, output: Path, *, size: Size = default_size): self.output = output output.mkdir(parents=True, exist_ok=True) self.log = (output / "xvfb.log").open("wb") self.server = SpawnedProcess.launch( - ("Xvfb", "-displayfd", "1", "-screen", "0", "1600x1000x24", "-nolisten", "tcp"), + ("Xvfb", "-displayfd", "1", "-screen", "0", f"{size.width}x{size.height}x24", "-nolisten", "tcp"), stdio=DetachedChildStdio(stdout=subprocess.PIPE, stderr=self.log), ) try: @@ -296,7 +297,8 @@ def prepare_target(root: Path, runtime: LaunchRuntime) -> RunTarget: @classmethod @contextmanager def copied(cls, source: Comms, threads: tuple[str, ...], runtime: LaunchRuntime, - output: Path, *, thread: str, size: Size = Size(140, 40)) -> Iterator[TerminalApp]: + output: Path, *, thread: str, size: Size = Size(140, 40), + framebuffer: Size = OwnedDisplay.default_size) -> Iterator[TerminalApp]: """Create under the source owner, bind only its copy, retain run evidence.""" output = output.expanduser().absolute() output.mkdir(mode=0o700, parents=True) # Fresh evidence, never overwrite a failed run. @@ -304,7 +306,7 @@ def copied(cls, source: Comms, threads: tuple[str, ...], runtime: LaunchRuntime, RootRuntime.publish(copied.root, runtime) target = cls.prepare_target(copied.root, runtime) try: - with OwnedDisplay(output / "display") as display: + with OwnedDisplay(output / "display", size=framebuffer) as display: # Keep the real default launcher: its root/runtime succession is # precisely what the post-attachment observation must establish. command = (str(Path.home() / ".local/bin/toad-comms"), thread) @@ -427,6 +429,7 @@ def observe(self) -> RunObservation: ) if int(owner.partition("=")[2].strip()) != self.terminal.identity.pid: raise ValueError("Window does not belong to this run's st frontend") + self.read_grid() return observed def input(self, *commands: str) -> RunObservation: @@ -552,9 +555,14 @@ def read_grid(self): self.cell_width, self.cell_height = minimum_x - base_x, minimum_y - base_y self.border_x, self.border_y = base_x / 2, base_y / 2 self.window_region = Region(int(size["X"]), int(size["Y"]), int(size["WIDTH"]), int(size["HEIGHT"])) + self.require_visible(self.window_region) self.grid_size = Size((self.window_region.width - base_x) // self.cell_width, (self.window_region.height - base_y) // self.cell_height) + def require_visible(self, region: Region) -> None: + if not Region(0, 0, *self.display.size).contains_region(region): + raise ValueError(f"Terminal {region} exceeds the owned framebuffer {self.display.size}") + def click_cell(self, column, row): self.read_grid() x = int(self.border_x + (column + .5) * self.cell_width) @@ -563,8 +571,10 @@ def click_cell(self, column, row): def resize(self, columns: int, rows: int) -> None: self.read_grid() - self.input("windowsize", "--sync", str(self.window), str(int(2 * self.border_x + columns * self.cell_width)), - str(int(2 * self.border_y + rows * self.cell_height))) + width = int(2 * self.border_x + columns * self.cell_width) + height = int(2 * self.border_y + rows * self.cell_height) + self.require_visible(Region(self.window_region.x, self.window_region.y, width, height)) + self.input("windowsize", "--sync", str(self.window), str(width), str(height)) self.read_grid() def close(self) -> None: From f1e689bc69f6d1a7b389849d7d48b965aeb40b07 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 1 Nov 2026 00:04:24 -0400 Subject: [PATCH 04/17] Record the debugger context repair and private functional journey --- docs/performance/cpython-debugger-context.md | 122 ++++++++++++++++++ .../cpython-debugger-context.patch | 79 ++++++++++++ 2 files changed, 201 insertions(+) create mode 100644 docs/performance/cpython-debugger-context.md create mode 100644 docs/performance/cpython-debugger-context.patch diff --git a/docs/performance/cpython-debugger-context.md b/docs/performance/cpython-debugger-context.md new file mode 100644 index 000000000..00dd40776 --- /dev/null +++ b/docs/performance/cpython-debugger-context.md @@ -0,0 +1,122 @@ +# Remote debugger context: functional candidate, not an installed fix + +The accompanying `cpython-debugger-context.patch` belongs to CPython's +`Python/ceval_gil.c`, not Toad's renderer or its runner. It is retained here so +this runner's platform dependency, exact proposed repair and demonstrated scope +are reviewable. It is **not** an alternate launcher, import preload, retry or +permission bypass. No live Python binary/library was replaced. + +## Failure and owner + +On CPython3.14.8, retained physical run11 and independently read mobile06 fail +before opening the helper: + +``` +Can't open debugger script .../operation.py + File "", line 1, in +KeyError: '__import__' +``` + +`run_remote_debugger_script` audits/opens/reads/closes source under the +interrupted frame. Only afterward does `run_remote_debugger_source` create its +fresh namespace. The default `PyFile_OpenCodeObject` imports `_io.open`; +`PyImport_Import` consults the interrupted frame's builtins. A legitimate empty +builtin mapping therefore breaks the debugger itself. The interrupted constructor +is unidentified; run14's separate timeout remains unexplained. + +Exact source: v3.14.8, commit +`8e6e75d9102e39bed2a2b279203a396741180f12`. The debugger owner file is identical +on the checked compatible3.14.9/3.14.10 tags;3.14.10's file-open/import owners are +also unchanged. NRA's Python AST does not parse these C files; the C call chain +was read manually and checked against installed disassembly. + +## Repair and audit difference + +The original remote-debugger audit refusal remains first. The existing loader +body becomes one private C callable, invoked through a real Python frame with +interpreter-owned builtins. The original application frame stays in the normal +frame/GC chain. The embedder's open-code hook is still called; global import +behavior, source reading/closing and payload execution are not replaced. + +**The complete audit trace is not equivalent.** Successful execution now emits: + +1. `cpython.remote_debugger_script(path)`; +2. `compile` / `exec` for the context lambda, filename ``; +3. the existing `compile` / `exec` for payload source, filename ``. + +The initial proposal incorrectly put context preparation ahead of the original +audit. Review caught this; the published patch restores that audit gate first. +The extra context events remain explicit. A policy refusing them prevents source +opening and reports a context-entry error. A close error still reports the error +then executes already-read source, matching the original loader body. + +## Functional build and native controls + +A separately owned3.14.8 build used existing GCC16.1.1/make, no new toolchain, +PGO/LTO/BOLT/JIT/tail-call chase, and at most two make/compileall jobs. Configure, +make and private altinstall completed0 in18.4s/109.6s/13.0s. Built patch SHA256: +`5a679cd8875271a4c7eace7ce088b5f93f261076b929987e1276b6161ef9857d`; +functional binary SHA256: +`e18b6c81a63f9683beca2d20c9e56225da3207b68530bae2eaa26b6bf351e4fe`. +These identify measured artifacts, not a runtime publication. Existing `BoundedRun` +owned process retirement and deadlines; no output for180s failed the stage. +This nonmatching compiler build is **not release-performance evidence**. + +One native batch completed0: + +- one fresh unpatched empty-builtins child reproduced the source-open failure; +- 18 patched controls: nine cases under full and empty interrupted builtins; +- normal execution; refusing request/compile/exec audit hooks; real embedder-hook + acceptance/refusal; read/close failure; payload failure; +- executing cases retained the original frame and cyclic guard through + `gc.collect`; every case preserved its active exception and builtin mapping; +- all19 typed target identities independently absent after retirement. + +These are exercised counterexamples/controls, not a proof over every CPython state. +The guard has other strong references; this is not a sole-root liveness proof. +The fresh control target deliberately runs a busy select loop, not a production +workload. The separate actual Toad run below exercises the unchanged application. +The disposable control extension/script targets never loaded into a user app. + +## Actual private Toad + +The unchanged shared runner then completed one real default-launcher journey0: +saved history, held PageUp/PageDown, reversal, End, wheel scrolling and normal +retirement. It obtained31 observed callbacks,17 explicit startup-pending results +and one exec result. The screenshot shows saved message bodies and STANDARD +mode; no physical timeout or source-open failure occurred in this run. + +Admission checked1013 module locations; HOME/XDG/database/palette, selected and +opened root, and saved-source identities were private. There were **zero ACP +bindings**: this does not qualify bound-ACP behavior, mobile1391 or errors1389. +The controller used the installed source owner to copy history. The target used +the functional interpreter with a byte-compared copy of5295 regular installed +package files, declaration-derived console scripts and an existing Neovim payload +verified through its archive owner. No native download or fabricated build.json. +A setup assertion initially confused Core's package root with site-packages; +that failure is retained, and only script generation was completed afterward. + +st/Xvfb/encoder retired0, all four frontend/resource identities were independently +absent, and the copied root was stopped. Original sessions, input dispositions, +phone/editor processes, desktop/pointer and scientific data were untouched. +The separately approved metadata/profile capture remains unused. + +## Receipts and remaining work + +Owned retained evidence under `/home/ts/.cache/agent-scratch/pi-ui-performance/`: + +- `cpython-functional-build.log`, `cpython-functional-startup.log`; +- `cpython-native-controls.log`, `cpython-native-control-absence.log`; +- `cpython-functional-toad-{prepare,finish}.log` and native-payload log; +- `cpython-functional-toad-01/{admitted.json,frontend/end-return.png}`, + typed retirement/copy-stop records and `cpython-functional-toad-absence.log`. + +Native case inputs/results and the functional source/prefix are retained under +the allocated Core checkout's `platform-source/` pending review. One-shot build +code was retired after its successful result. Input hashes are observations, +not a new runtime stamp. Integration still needs a qualified interpreter/build +selection: `RuntimeStack.build` currently keys reuse by Core commit and lock +hash only. The runner's repository-identity work must use the existing +`RecordedBuildIdentity`/`RuntimeResolution`/`RuntimePackage` owners; a mutable +prefix/module location is not a repository commit. Installed acceptance and +measured production source-lifetime/performance repair remain open. diff --git a/docs/performance/cpython-debugger-context.patch b/docs/performance/cpython-debugger-context.patch new file mode 100644 index 000000000..30078214e --- /dev/null +++ b/docs/performance/cpython-debugger-context.patch @@ -0,0 +1,79 @@ +--- a/Python/ceval_gil.c ++++ b/Python/ceval_gil.c +@@ -1216,22 +1216,16 @@ + return 0; + } + +-// Note that this function is inline to avoid creating a PLT entry +-// that would be an easy target for a ROP gadget. +-static inline void run_remote_debugger_script(PyObject *path) ++// This private callback is invoked only inside the debugger's Python frame. ++// It remains static: there is no new exported or PLT entry point. ++static PyObject * ++run_remote_debugger_script_body(PyObject *Py_UNUSED(self), PyObject *path) + { +- if (0 != PySys_Audit("cpython.remote_debugger_script", "O", path)) { +- PyErr_FormatUnraisable( +- "Audit hook failed for remote debugger script %U", path); +- return; +- } +- +- // Open the debugger script with the open code hook, and reopen the +- // resulting file object to get a C FILE* object. ++ // Preserve the existing open-code hook, read/close ordering and errors. + PyObject* fileobj = PyFile_OpenCodeObject(path); + if (!fileobj) { + PyErr_FormatUnraisable("Can't open debugger script %U", path); +- return; ++ Py_RETURN_NONE; + } + + PyObject* source = PyObject_CallMethodNoArgs(fileobj, &_Py_ID(read)); +@@ -1253,6 +1247,47 @@ + } + Py_DECREF(source); + } ++ Py_RETURN_NONE; ++} ++ ++// Note that this function is inline to avoid creating a PLT entry ++// that would be an easy target for a ROP gadget. ++static inline void run_remote_debugger_script(PyObject *path) ++{ ++ // Keep the existing debugger refusal before context compilation/execution. ++ if (0 != PySys_Audit("cpython.remote_debugger_script", "O", path)) { ++ PyErr_FormatUnraisable( ++ "Audit hook failed for remote debugger script %U", path); ++ return; ++ } ++ static PyMethodDef body_definition = { ++ "_run_remote_debugger_script_body", ++ run_remote_debugger_script_body, ++ METH_O, ++ NULL ++ }; ++ PyObject *ns = PyDict_New(); ++ if (!ns) { ++ PyErr_FormatUnraisable("Can't prepare debugger namespace for %U", path); ++ return; ++ } ++ // PyRun_String initializes this namespace from the interpreter's builtins. ++ // A real Python call frame then owns audit/open/read/close as well as exec; ++ // it neither borrows the interrupted frame's builtins nor detaches its GC roots. ++ PyObject *entry = PyRun_String("lambda load, path: load(path)", Py_eval_input, ns, ns); ++ if (entry) { ++ PyObject *body = PyCFunction_New(&body_definition, NULL); ++ if (body) { ++ PyObject *result = PyObject_CallFunctionObjArgs(entry, body, path, NULL); ++ Py_XDECREF(result); ++ Py_DECREF(body); ++ } ++ Py_DECREF(entry); ++ } ++ Py_DECREF(ns); ++ if (PyErr_Occurred()) { ++ PyErr_FormatUnraisable("Can't enter debugger execution context for %U", path); ++ } + } + + int _PyRunRemoteDebugger(PyThreadState *tstate) From b0e83fe358914d618656ac864c4914b10058fb91 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 1 Nov 2026 01:12:53 -0500 Subject: [PATCH 05/17] Detach pager retirement joins from the cancelled worker context --- docs/performance/physical-run.md | 37 ++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/docs/performance/physical-run.md b/docs/performance/physical-run.md index 5613f9b4b..204036122 100644 --- a/docs/performance/physical-run.md +++ b/docs/performance/physical-run.md @@ -119,3 +119,40 @@ Current helpers are `tools/physical_ui.py` and `tools/physical_ui_remote.py` on PR #1393. Consumers must start a new controller from one published checkpoint, not combine a live process with a newly imported wire shape. A completed stopped-history scenario is not bound-ACP or performance acceptance. + +## Pager failure retirement + +The retained configured-fork attempt failed when multiprocessing spawn re-entered +an unguarded controller. Its `BrokenProcessPool` also exposed a separate pager +cleanup defect; neither is an image verdict and the failed root is not replayed. + +`WorkingTranscript.execute` reports a read failure inside the pager Worker. +`TranscriptSourcePreparation.source_failed` immediately revokes its source and +starts `retire_source`. Textual's `AwaitComplete` eagerly schedules the join in a +new task, copying the caller's `active_worker`. `Worker.wait` then rejects that +inherited identity as a self-wait before awaiting anything. The traceback is not +evidence of a synchronous UI stall. + +The existing shared preparation base now creates that independent `AwaitComplete` +in an empty native `Context`. Its join needs only the cancelled Worker handles; +it must not borrow the caller's worker or message pump. Revocation, generation, +subscription closure, cancellation, awaited outcomes and error propagation stay +with their existing owners. Textual's genuine self-wait guard is unchanged. +All six pager descendants inherit the correction, including saved, wire and +projected history; there is no per-view exception handler or second cleanup path. + +Before editing, the retained NRA parser/syntax/canonical-class query covered +1,533 modules: 9,005 original class declarations, 8,966 canonical projections, +39 unprojected OPEN rows and 190 selected lifecycle sites. The manifest retains +source hashes. This is source evidence, not domain completeness or live binding +proof; external/unresolved bases, dynamic mutation and non-Python behavior stay +OPEN. Actual imported MROs confirm the shared base on the relevant saved-history +classes. The source query uses NRA's existing owners, not another parser. + +The existing lifecycle controls now observe original coverage publications and +saved error artifacts instead of replacing our own methods. They cover caller- +initiated retirement, pager-initiated retirement, resume, real self-wait rejection, +and a failed read that retires once without an unhandled cleanup exception. +Both changed Python files compile and the source imports from this checkout. +The controls have **not yet run**; the combined host-lease run and affected +physical journey remain open. This checkpoint is not a performance acceptance. From c08b219a1ab1bec22948ed1ceab56e46d4283f88 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 1 Nov 2026 01:41:54 -0500 Subject: [PATCH 06/17] Record the qualified pager retirement app controls --- docs/performance/physical-run.md | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/docs/performance/physical-run.md b/docs/performance/physical-run.md index 204036122..ee0dbeb7a 100644 --- a/docs/performance/physical-run.md +++ b/docs/performance/physical-run.md @@ -154,5 +154,17 @@ saved error artifacts instead of replacing our own methods. They cover caller- initiated retirement, pager-initiated retirement, resume, real self-wait rejection, and a failed read that retires once without an unhandled cleanup exception. Both changed Python files compile and the source imports from this checkout. -The controls have **not yet run**; the combined host-lease run and affected -physical journey remain open. This checkpoint is not a performance acceptance. +The exact source head `59d8218a4b70896aa0f8226367202cff035dd411` +passed both app controls: **2 passed in 31.58s, exit 0**, under Core's shared +`TestLease`. The controller asserted the actual Core and Toad source origins +before invoking `tests/textual/test_transcript_lifecycle.py`; the retained log +prints both paths, the interpreter and the acquired lease. Its first attempt +failed before pytest because the controller shadowed an imported module with a +`Path`; that attempt is not a test run. The passing run paired Core's earlier +`a5b` lifecycle source, not its subsequent parallel-reader correction. + +Receipts: `pi-commander/heartbeat-publication/pager-lifetime-controls-2.log` +and `.rc` under `/home/ts/.cache/agent-scratch/`. These are app-level lifecycle +controls, not installed or physical acceptance. The affected physical journey, +full isolation counterexamples and production performance measurement remain +open; no failed configured-fork root was replayed. From 06f307a75193383e0b1cf1630716f476f9485393 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 1 Nov 2026 02:20:53 -0500 Subject: [PATCH 07/17] Compare recorded runtime inputs before physical UI input --- docs/performance/physical-run.md | 30 ++++++++++++++++++++++++++++++ tools/physical_ui.py | 5 ++++- tools/physical_ui_remote.py | 11 ++++++++++- 3 files changed, 44 insertions(+), 2 deletions(-) diff --git a/docs/performance/physical-run.md b/docs/performance/physical-run.md index ee0dbeb7a..3d04cc3b7 100644 --- a/docs/performance/physical-run.md +++ b/docs/performance/physical-run.md @@ -71,6 +71,36 @@ acceptance and actual borrower checks. adds only the existing `DeclaredWireType` boundary capability and retains the actual Region object, without copying its coordinate declarations. +## Runtime input identity (Core #3825 dependency) + +The current source requires a `RuntimeResolution` with a recorded interpreter +observation, not just a prefix and three module locations. `RunTarget` retains +that intended build; `ObserveOperation` obtains the actual process's resolution +through the same target-owned probe before `RunTarget.require` permits input. +An old build record without interpreter evidence is explicitly unrecorded and +refuses admission. It is not retrofitted by reading its intended executable. + +`WheelRuntime` retains copies of its exact inputs under the new prefix's +`inputs/` directory; borrowed originals remain read-only. The package probe +compares every root-wheel member except the installer's rewritten `RECORD` +against installed bytes, then records the verified archive SHA256. Root-layout +wheels are the supported domain; `.data` relocation is refused, not silently +skipped. Remote archive hashes remain metadata, never permission to fetch them. +The deployed donor package probe keeps its existing core-only wire; a target +subclass adds wheel verification without changing old workers' request shape. + +The interpreter observation keeps the invoked environment separate from the +resolved executable and the native image actually named by the loader for +`Py_GetVersion`. Stable file reads use `FileRevision`; their SHA256 values are +native **disk-artifact** evidence, not complete loaded-code or stdlib-content +equivalence. Repository ancestry remains `RecordedBuildIdentity`'s separate +contract: a verified archive still does not acquire a Git revision. + +This implementation is a published source checkpoint, not candidate/carry or +physical qualification. The first prepared candidate remains immutable and +unqualified under the new interpreter requirement. No frontend/native/model +launch follows from the earlier single artifact-preparation approval. + ## Evidence limits This scenario exercises **bounded stopped history**, not the retired shell diff --git a/tools/physical_ui.py b/tools/physical_ui.py index d64b4ed1f..225937989 100644 --- a/tools/physical_ui.py +++ b/tools/physical_ui.py @@ -28,6 +28,7 @@ from agent_comms.child_process import DetachedChildStdio, ProcessIdentity, SpawnedProcess from agent_comms.comms import Comms from agent_comms.installer.practice import SuppliedRuntimeHome +from agent_comms.installer.runtime_resolution import RuntimeResolution from agent_comms.launch_root import LaunchRoot from agent_comms.launch_runtime import LaunchRuntime, ThreadLaunch from agent_comms.nominal.field_codec import FieldCodec @@ -254,6 +255,8 @@ def prepare_target(root: Path, runtime: LaunchRuntime) -> RunTarget: from toad.preferences import INTERFACE, SETTINGS, SavedSettings, ToadSettings root = root.resolve(strict=True) + build = RuntimeResolution.at(runtime.prefix) + build.interpreter.require_recorded() home = root.parent / "frontend-home" # Validate the intended paths before touching the home. RunTarget also # rejects the real user's home/workspace and an unrelated bus. @@ -267,7 +270,7 @@ def prepare_target(root: Path, runtime: LaunchRuntime) -> RunTarget: raise ValueError("Refusing frontend setup beside a live user root") home.mkdir(mode=0o700) SuppliedRuntimeHome(home, runtime).prepare() - target = RunTarget(root, home.resolve(), runtime.prefix) + target = RunTarget(root, home.resolve(), runtime.prefix, build) for directory in (target.config, target.state, target.data): directory.mkdir(mode=0o700) settings = SavedSettings.path() diff --git a/tools/physical_ui_remote.py b/tools/physical_ui_remote.py index 0ed1627e4..af186396e 100644 --- a/tools/physical_ui_remote.py +++ b/tools/physical_ui_remote.py @@ -18,6 +18,8 @@ from agent_comms.child_process import ChildOutcome, ProcessIdentity from agent_comms.launch_runtime import LaunchRuntime +from agent_comms.installer.probes.resolution import RuntimeResolutionProbe +from agent_comms.installer.runtime_resolution import RuntimeResolution from agent_comms.nominal.declared_family import DeclaredFamily from agent_comms.nominal.field_codec import DeclaredWireType, FieldCodec from textual.geometry import Region @@ -36,8 +38,10 @@ class RunTarget: root: Path home: Path runtime: Path + build: RuntimeResolution def __post_init__(self): + self.build.interpreter.require_recorded() for path in (self.root, self.home, self.runtime): if path != path.resolve(strict=True): raise ValueError(f"A physical run needs canonical existing paths: {path}") @@ -71,6 +75,7 @@ def data(self) -> Path: return self.home / "data" def require(self, observed): + self.build.require_runtime(observed.build) if observed.home != self.home or observed.runtime != self.runtime: raise ValueError("Post-attachment HOME/runtime differs from the run target") if (observed.config, observed.state, observed.data) != (self.config, self.state, self.data): @@ -114,6 +119,7 @@ class RunObservation: started_ns: int finished_ns: int runtime: Path + build: RuntimeResolution home: Path config: Path state: Path @@ -337,8 +343,11 @@ def coordinated(self, value): # from sidebar separators that may also occur inside a user's message. regions = tuple(CapturedRegion(region) for window in screen.query(HistoryWindow) for region in (window.published_content_region,) if region is not None) + build = RuntimeResolutionProbe( + self.target.build.core_commit, self.target.build.wheel_inputs() + ).run() observed = RunObservation( - self.identity, started, time.monotonic_ns(), Path(sys.prefix).resolve(), + self.identity, started, time.monotonic_ns(), Path(sys.prefix).resolve(), build, Path.home().resolve(), xdg_config_home().resolve(), xdg_state_home().resolve(), xdg_data_home().resolve(), app.tab_order._db.path.resolve(), app.colours.wal_directory.resolve(), root, From e13d1febdf07e08226719eedb5a4927ac97811b5 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sat, 31 Oct 2026 18:22:53 -0400 Subject: [PATCH 08/17] Keep narrow message headers and receipts readable through shared rows --- src/toad/line_blocks.py | 15 +++---- src/toad/widgets/message_divider.py | 30 +++++++------ src/toad/widgets/message_notifications.py | 5 ++- src/toad/widgets/transcript_fragments.py | 8 +++- src/toad/widgets/transcript_lines.py | 51 +++++++++++++---------- 5 files changed, 64 insertions(+), 45 deletions(-) diff --git a/src/toad/line_blocks.py b/src/toad/line_blocks.py index 2a884daf9..7c97dbab0 100644 --- a/src/toad/line_blocks.py +++ b/src/toad/line_blocks.py @@ -17,7 +17,7 @@ from agent_comms.display_references import DisplayReference from agent_comms.nominal.field_codec import FieldCodec -from typing import ClassVar +from typing import ClassVar, Self from ui_theme.scheme import ( AccentColour, ErrorColour, ForegroundColour, MutedColour, PrimaryColour, SecondaryColour, SuccessColour, @@ -303,6 +303,11 @@ def text(self) -> str: def selection_text(self) -> str: return self.text + @classmethod + def titled(cls, title: str, role: type[LineRole] = MutedRole, + link: LineLink | None = None) -> Self: + return cls((Span(title, role, link),)) + @dataclass(frozen=True) class Spans(SpanText): @@ -348,14 +353,6 @@ class Summary(SpanText): def text(self) -> str: return super().text + "".join(span.text for span in self.kept) - @classmethod - def titled(cls, title: str, role: type[LineRole] = MutedRole, - link: LineLink | None = None) -> Summary: - return cls((Span(title, role, link),)) - - @property - def selection_text(self) -> str: - return self.text @dataclass(frozen=True) diff --git a/src/toad/widgets/message_divider.py b/src/toad/widgets/message_divider.py index 86b60ff52..3ca44fe07 100644 --- a/src/toad/widgets/message_divider.py +++ b/src/toad/widgets/message_divider.py @@ -33,7 +33,7 @@ class LineBlockRow(LinkedRows, Widget): """One semantic line block, drawn exactly as transcript history draws it.""" - DEFAULT_CSS = "LineBlockRow { width: 1fr; height: 1; }" + DEFAULT_CSS = "LineBlockRow { width: 1fr; height: auto; }" def __init__(self, block: LineBlock) -> None: super().__init__() @@ -48,26 +48,32 @@ def line_theme(self) -> LineTheme: def update_block(self, block: LineBlock) -> None: if block != self.block: self.block, self._lines = block, None - self.refresh() + self.refresh(layout=True) def _drawn(self, theme: LineTheme, width: int) -> PreparedLines: return PreparedLines(width, RichLineRenderer(theme, width).rows((self.block,))) - def render_line(self, y: int, paint: PaintState) -> Strip: - width = self.size.width - if y or not width: - return self.blank_row(width, paint) + def prepared_lines(self, width: int) -> PreparedLines: theme = self.line_theme if self._lines is None or self._lines[0] != theme or self._lines[1].width != width: self._lines = theme, self._drawn(theme, width) - return self.on_background(self._lines[1].strips_at(self._link_place)[0], paint, y) + return self._lines[1] + + def render_line(self, y: int, paint: PaintState) -> Strip: + width = self.size.width + if not width: + return self.blank_row(width, paint) + lines = self.prepared_lines(width) + if y >= lines.height: + return self.blank_row(width, paint) + return self.on_background(lines.strips_at(self._link_place)[y], paint, y) @height_dependency(INDEPENDENT_HEIGHT) def get_content_height(self, container: Size, viewport: Size, width: int) -> int: - return 1 + return self.prepared_lines(width).height -class SingleLineBlock(BlockContent, LineBlockRow): +class ConversationLineBlock(BlockContent, LineBlockRow): """A line block in the conversation: navigable and selectable like any block.""" def get_selection(self, selection: Selection) -> tuple[str, str] | None: @@ -94,7 +100,7 @@ def pin(self, headers: tuple[Divider, ...]) -> None: self.display = bool(headers) -class MessageDivider(Animated[Mix], SingleLineBlock): +class MessageDivider(Animated[Mix], ConversationLineBlock): """The rule above a live message: the same `Divider` block saved history draws. A message that has just arrived fades in from the accent colour. @@ -105,7 +111,7 @@ class MessageDivider(Animated[Mix], SingleLineBlock): DEFAULT_CSS = """ MessageDivider { width: 1fr; - height: 1; + height: auto; margin: 1 0 0 0; } """ @@ -139,7 +145,7 @@ def __init__(self, row: MessageRow, *, clock: MessageClock = LiveMessageClock()) self.message_row = row -class AgentActivityDivider(ConversationBlock, CommitParticipant, CategorizedBlock, SingleLineBlock): +class AgentActivityDivider(ConversationBlock, CommitParticipant, CategorizedBlock, ConversationLineBlock): """The shared activity-group row, filtered with the activity that follows it.""" ALLOW_SELECT = False diff --git a/src/toad/widgets/message_notifications.py b/src/toad/widgets/message_notifications.py index bcb8dc28f..344ecd9af 100644 --- a/src/toad/widgets/message_notifications.py +++ b/src/toad/widgets/message_notifications.py @@ -32,7 +32,10 @@ class MessageNotifications(Collapsible): MessageNotifications { height: auto; padding: 0; margin: 0; border: none; background: transparent; color: $text-muted; - CollapsibleTitle { padding: 0; min-height: 1; } + CollapsibleTitle { + padding: 0; width: 1fr; height: auto; min-height: 1; + text-wrap: wrap; text-overflow: fold; + } Contents { padding: 0; } } MessageNotifications.-working > CollapsibleTitle { color: $accent; } diff --git a/src/toad/widgets/transcript_fragments.py b/src/toad/widgets/transcript_fragments.py index a0e1ec5d8..c478fa007 100644 --- a/src/toad/widgets/transcript_fragments.py +++ b/src/toad/widgets/transcript_fragments.py @@ -39,6 +39,8 @@ LineRole, MarkdownBody, MutedRole, + SpanText, + Spans, Summary, ToggleDetails, UserRole, @@ -216,10 +218,11 @@ class Shown: """What a reader chose about one drawn fragment.""" details_open: bool = False + summary_type: ClassVar[type[SpanText]] = Summary - def summary(self, title: str, role: type[LineRole], link: LineLink) -> Summary: + def summary(self, title: str, role: type[LineRole], link: LineLink) -> SpanText: """The disclosure retains the source occurrence supplied by its fragment.""" - return Summary.titled(("▼ " if self.details_open else "▶ ") + title, role, link) + return self.summary_type.titled(("▼ " if self.details_open else "▶ ") + title, role, link) def toggled(self) -> "Shown": return replace(self, details_open=not self.details_open) @@ -229,6 +232,7 @@ def toggled(self) -> "Shown": class MessageShown(Shown): """Style and Core's feedback for one bus message, in any conversation.""" + summary_type: ClassVar[type[SpanText]] = Spans style: WireMessageStyle = field(default_factory=MarkdownMessageStyle) status: NotificationStatus = field(default_factory=UncheckedNotifications) diff --git a/src/toad/widgets/transcript_lines.py b/src/toad/widgets/transcript_lines.py index d584966af..9d11a4ff5 100644 --- a/src/toad/widgets/transcript_lines.py +++ b/src/toad/widgets/transcript_lines.py @@ -457,26 +457,31 @@ def rich_text(self, block: RichTextBody) -> None: @handles(Divider) def divider(self, block: Divider) -> None: - spans = block.spans + groups = (block.spans,) if block.timestamp is not None: - spans = (*spans, Span(f" · {MessageClock.recorded(block.timestamp).display()[0]}")) + timestamp = MessageClock.recorded(block.timestamp).display()[0] + timed = (*block.spans, Span(f" · {timestamp}")) + groups = ((block.spans, (Span(timestamp),)) + if sum(cell_len(span.text) for span in timed) > max(1, self.width - 8) + else (timed,)) rule, bold = self.theme.style(block.role), Style(bold=True) - title, room = [Segment(" ", rule)], max(0, self.width - 8) - for span in spans: - # A title longer than the rule ends in an ellipsis; what follows is not drawn. - text = _fit(span.text, room) - room -= cell_len(text) - title.append( - Segment( - text, (rule if span.link is None else self.theme.style(span.role)) + bold + link_style(span.link) - ) - ) - if room == 0: - break - title.append(Segment(" ", rule)) - length = sum(segment.cell_length for segment in title) - left = max(0, (self.width - length) // 2) - self.output.append((Segment("─" * left, rule), *title, Segment("─" * max(0, self.width - left - length), rule))) + # Fit actual content, not a guessed phone width. Each semantic span and + # the complete recorded time survive wrapping, including long names. + inset = min(1, (self.width - 1) // 2) + for spans in groups: + content = Text.assemble(*( + (span.text, (rule if span.link is None else self.theme.style(span.role)) + + bold + link_style(span.link)) + for span in spans + )) + for row in self.console.render_lines( + content, self.console.options.update(width=self.width - 2 * inset), pad=False + ): + title = (Segment(" " * inset, rule), *row, Segment(" " * inset, rule)) + length = sum(segment.cell_length for segment in title) + left = max(0, (self.width - length) // 2) + self.output.append((Segment("─" * left, rule), *title, + Segment("─" * max(0, self.width - left - length), rule))) @handles(Summary) def summary(self, block: Summary) -> None: @@ -491,10 +496,14 @@ def summary(self, block: Summary) -> None: @handles(Spans) def spans(self, block: Spans) -> None: + content = Text.assemble(*( + (span.text, self.theme.style(span.role) + link_style(span.link)) + for span in block.spans + )) self.output.extend( - (Segment(line[start : start + self.width]),) - for line in _plain_lines(block.text) - for start in _plain_spans(line, self.width) + tuple(row) for row in self.console.render_lines( + content, self.console.options, pad=False + ) ) @handles(PreparedLines) From d5dabdb46fbfd6bd29f07701823b6f2d2befcbde Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sat, 31 Oct 2026 18:40:12 -0400 Subject: [PATCH 09/17] Derive four-edge sidebar geometry and controls from crossing capabilities --- src/toad/application_actions.py | 8 +- src/toad/sidebar_layout.py | 417 +++++++++++++++++----- src/toad/widget_actions.py | 23 ++ src/toad/widgets/side_bar.py | 232 +++++------- src/toad/workspace_chrome.py | 19 +- tests/model/test_sidebar_layout_policy.py | 75 ++-- tests/textual/test_navigation_keys.py | 7 +- tests/textual/test_sidebar_direction.py | 97 +++-- tests/textual/test_sidebar_drag_resize.py | 27 +- 9 files changed, 573 insertions(+), 332 deletions(-) diff --git a/src/toad/application_actions.py b/src/toad/application_actions.py index e1c1f9fc2..bdb9b988a 100644 --- a/src/toad/application_actions.py +++ b/src/toad/application_actions.py @@ -20,7 +20,7 @@ from toad.target_commands import TargetContext from toad.thread_actions import ThreadAction from toad.core.session_requests import SessionNavigate -from toad.sidebar_layout import Side +from toad.sidebar_layout import LeftSidebarEdge, RightSidebarEdge, SidebarEdge from agent_comms.errors import RelationViolationError from agent_comms.cli_verbs.new import NewThreadCliCommand from agent_comms.channel_targets import ProjectTarget @@ -234,7 +234,7 @@ class VimHistoryForwardAction(VimHistoryAction): class VimSidebarAction(VimWorkspaceAction, declared_name=None): - side: Side + side: type[SidebarEdge] async def apply(self, app: ToadApp) -> None: app.workspace_chrome.toggle_sidebar(self.side) @@ -243,13 +243,13 @@ async def apply(self, app: ToadApp) -> None: class VimLeftSidebarAction(VimSidebarAction): """Toggle the sidebars on the left edge.""" keys = {VimMode: ("ctrl+shift+h",)} - side = "left" + side = LeftSidebarEdge class VimRightSidebarAction(VimSidebarAction): """Toggle the sidebars on the right edge.""" keys = {VimMode: ("ctrl+shift+l",)} - side = "right" + side = RightSidebarEdge class VimMoveTabAction(VimWorkspaceAction, declared_name=None): diff --git a/src/toad/sidebar_layout.py b/src/toad/sidebar_layout.py index 3f8f73e01..e187a987d 100644 --- a/src/toad/sidebar_layout.py +++ b/src/toad/sidebar_layout.py @@ -1,24 +1,223 @@ -"""One app-owned placement policy for the two independently movable sidebars.""" +"""One app-owned placement policy for independently movable sidebar surfaces.""" from __future__ import annotations -from dataclasses import dataclass, replace -from typing import Literal from abc import abstractmethod +from collections.abc import Iterator +from dataclasses import dataclass, replace +from typing import ClassVar from agent_comms.nominal.declared_family import DeclaredFamily +from textual.css.types import DockEdge +from textual.geometry import Offset, Region, Size, Spacing + + +class SidebarEdge(DeclaredFamily, affix="SidebarEdge"): + """An edge owns its native geometry, resize axis and control placement.""" + + dock: ClassVar[DockEdge] + symbol: ClassVar[str] + sign: ClassVar[int] + minimum: ClassVar[int] + conversation_minimum: ClassVar[int] + collapsed_extent: ClassVar[int] + + @classmethod + @abstractmethod + def axis(cls) -> type[SidebarEdge]: ... + + @classmethod + @abstractmethod + def extent(cls, size: Size) -> int: ... + + @classmethod + @abstractmethod + def coordinate(cls, point: Offset) -> int: ... + + @classmethod + @abstractmethod + def origin(cls, viewport: int, offset: int, extent: int) -> int: ... + + @classmethod + @abstractmethod + def region(cls, size: Size, span: SidebarGeometry, occupied: Spacing) -> Region: ... + + @classmethod + @abstractmethod + def gutter(cls, extent: int) -> Spacing: ... + + @classmethod + @abstractmethod + def size_toggle(cls, toggle) -> None: ... + + @classmethod + @abstractmethod + def size_handle(cls, handle) -> None: ... + + @classmethod + @abstractmethod + def handle_text(cls, size: Size) -> str: ... + + @classmethod + @abstractmethod + def control_offset(cls, distance: int) -> Offset: ... + + @classmethod + def opposite(cls) -> type[SidebarEdge]: + return next(edge for edge in SidebarEdge.members_with(cls.axis()) if edge is not cls) + + @classmethod + def controls_margin(cls) -> Spacing: + return cls.opposite().gutter(cls.collapsed_extent + 1) + + @classmethod + def toggle_symbol(cls, collapsed: bool) -> str: + return (cls.opposite() if collapsed else cls).symbol + + @classmethod + def order_key(cls, order: int) -> int: + return cls.sign * order + + +class LeadingSidebarEdge: + sign = 1 + + @classmethod + def origin(cls, viewport: int, offset: int, extent: int) -> int: + return offset + + +class TrailingSidebarEdge: + sign = -1 + + @classmethod + def origin(cls, viewport: int, offset: int, extent: int) -> int: + return viewport - offset - extent + + +class WidthSidebarEdge(SidebarEdge): + minimum = 18 + conversation_minimum = 24 + collapsed_extent = 3 + + @classmethod + def axis(cls) -> type[SidebarEdge]: + return WidthSidebarEdge + + @classmethod + def extent(cls, size: Size) -> int: + return size.width + + @classmethod + def coordinate(cls, point: Offset) -> int: + return point.x + + @classmethod + def region(cls, size: Size, span: SidebarGeometry, occupied: Spacing) -> Region: + return Region(cls.origin(size.width, span.offset, span.extent), occupied.top, + span.extent, max(0, size.height - occupied.height)) -type Side = Literal["left", "right"] + @classmethod + def size_toggle(cls, toggle) -> None: + toggle.styles.width, toggle.styles.height = cls.collapsed_extent, "1fr" + + @classmethod + def size_handle(cls, handle) -> None: + handle.styles.width, handle.styles.height = 1, "1fr" + handle.styles.pointer = "ew-resize" + + @classmethod + def handle_text(cls, size: Size) -> str: + return "\n".join("│" for _ in range(size.height)) + + @classmethod + def control_offset(cls, distance: int) -> Offset: + return Offset(-cls.sign * distance, 0) + + +class HeightSidebarEdge(SidebarEdge): + minimum = 9 + conversation_minimum = 8 + collapsed_extent = 1 + + @classmethod + def axis(cls) -> type[SidebarEdge]: + return HeightSidebarEdge + + @classmethod + def extent(cls, size: Size) -> int: + return size.height + + @classmethod + def coordinate(cls, point: Offset) -> int: + return point.y + + @classmethod + def region(cls, size: Size, span: SidebarGeometry, occupied: Spacing) -> Region: + return Region(0, cls.origin(size.height, span.offset, span.extent), size.width, span.extent) + + @classmethod + def size_toggle(cls, toggle) -> None: + toggle.styles.width, toggle.styles.height = "1fr", cls.collapsed_extent + + @classmethod + def size_handle(cls, handle) -> None: + handle.styles.width, handle.styles.height = "1fr", 1 + handle.styles.pointer = "ns-resize" + + @classmethod + def handle_text(cls, size: Size) -> str: + return "─" * size.width + + @classmethod + def control_offset(cls, distance: int) -> Offset: + return Offset(0, -cls.sign * distance) + + +class LeftSidebarEdge(LeadingSidebarEdge, WidthSidebarEdge): + dock = "left" + symbol = "<" + + @classmethod + def gutter(cls, extent: int) -> Spacing: + return Spacing(0, 0, 0, extent) + + +class RightSidebarEdge(TrailingSidebarEdge, WidthSidebarEdge): + dock = "right" + symbol = ">" + + @classmethod + def gutter(cls, extent: int) -> Spacing: + return Spacing(0, extent, 0, 0) + + +class TopSidebarEdge(LeadingSidebarEdge, HeightSidebarEdge): + dock = "top" + symbol = "↑" + + @classmethod + def gutter(cls, extent: int) -> Spacing: + return Spacing(extent, 0, 0, 0) + + +class BottomSidebarEdge(TrailingSidebarEdge, HeightSidebarEdge): + dock = "bottom" + symbol = "↓" + + @classmethod + def gutter(cls, extent: int) -> Spacing: + return Spacing(0, 0, extent, 0) class SidebarShift(DeclaredFamily, affix="SidebarShift"): available = True @abstractmethod - def apply(self, layout: "SidebarLayout", identity: str, direction: Side) -> bool: ... + def apply(self, layout: SidebarLayout, identity: str, direction: type[SidebarEdge]) -> bool: ... @abstractmethod - def tooltip(self, direction: Side) -> str: ... + def tooltip(self, direction: type[SidebarEdge]) -> str: ... class BlockedSidebarShift(SidebarShift): @@ -36,7 +235,7 @@ def apply(self, layout, identity, direction): return layout.swap(identity) def tooltip(self, direction): - return f"Swap with the sidebar to the {direction}" + return f"Swap with the sidebar toward {direction.declared_name}" class MoveSidebarShift(SidebarShift): @@ -44,36 +243,54 @@ def apply(self, layout, identity, direction): return layout.move(identity, direction) def tooltip(self, direction): - return f"Move sidebar to the {direction}" + return f"Move sidebar to {direction.declared_name}" class SidebarPlacementAction(DeclaredFamily, affix="SidebarPlacementAction"): - directional = False + classes = "" label: str + @property + def name(self) -> str: + return self.declared_name + + @classmethod + def offered(cls) -> Iterator[SidebarPlacementAction]: + yield cls() + + @abstractmethod + def apply(self, layout: SidebarLayout, identity: str) -> None: ... + @abstractmethod - def apply(self, layout: "SidebarLayout", identity: str) -> None: ... + def present(self, button, placement: SidebarPlacement, + directions: dict[type[SidebarEdge], SidebarShift]) -> None: ... +@dataclass(frozen=True) class DirectionalSidebarPlacementAction(SidebarPlacementAction): - directional = True + direction: type[SidebarEdge] + classes = "-direction" @property - @abstractmethod - def direction(self) -> Side: ... - - def apply(self, layout, identity): - layout.shift(identity, self.direction) + def name(self) -> str: + return self.direction.declared_name + @property + def label(self) -> str: + return self.direction.symbol -class LeftSidebarPlacementAction(DirectionalSidebarPlacementAction): - direction = "left" - label = "<──" + @classmethod + def offered(cls) -> Iterator[SidebarPlacementAction]: + for edge in SidebarEdge.members_with(SidebarEdge): + yield cls(edge) + def apply(self, layout, identity): + layout.shift(identity, self.direction) -class RightSidebarPlacementAction(DirectionalSidebarPlacementAction): - direction = "right" - label = "──>" + def present(self, button, placement, directions): + action = directions[self.direction] + button.display = action.available + button.tooltip = action.tooltip(self.direction) class FloatSidebarPlacementAction(SidebarPlacementAction): @@ -82,48 +299,53 @@ class FloatSidebarPlacementAction(SidebarPlacementAction): def apply(self, layout, identity): layout.float_mode(identity) + def present(self, button, placement, directions): + button.update("Push" if placement.floating else "Float", layout=False) + button.tooltip = ("Push conversation text" if placement.floating + else "Float over conversation text") + @dataclass(frozen=True, slots=True) class SidebarPlacement: - side: Side - order: int # Zero is the outside edge; a second bar goes on its inside. - width_percent: int + side: type[SidebarEdge] + order: int + extent_percent: int floating: bool = False @dataclass(frozen=True, slots=True) class SidebarGeometry: - x: int - width: int + offset: int + extent: int @dataclass(frozen=True, slots=True) class ResolvedSidebarLayout: - bars: dict[str, SidebarGeometry] - left_gutter: int - right_gutter: int + bars: dict[str, Region] + gutter: Spacing class SidebarLayout: """Placement only; sidebar content and thread navigation retain their owners.""" + minimum_percent = 15 + maximum_percent = 50 + def __init__(self) -> None: self.placements: dict[str, SidebarPlacement] = { - "channels-sidebar": SidebarPlacement("left", 0, 40), - "thread-sidebar": SidebarPlacement("right", 0, 34), + "channels-sidebar": SidebarPlacement(LeftSidebarEdge, 0, 40), + "thread-sidebar": SidebarPlacement(RightSidebarEdge, 0, 34), } def get(self, identity: str) -> SidebarPlacement: return self.placements[identity] - def move(self, identity: str, side: Side) -> bool: - if side not in {"left", "right"}: - raise ValueError("Unknown sidebar edge.") + def move(self, identity: str, side: type[SidebarEdge]) -> bool: current = self.get(identity) - if current.side == side: + if current.side is side: return False peers = [key for key, placement in self.placements.items() - if key != identity and placement.side == side] + if key != identity and placement.side is side] self.placements[identity] = replace(current, side=side, order=len(peers)) self._renumber(current.side) return True @@ -131,7 +353,7 @@ def move(self, identity: str, side: Side) -> bool: def swap(self, identity: str) -> bool: current = self.get(identity) peers = [key for key, placement in self.placements.items() - if key != identity and placement.side == current.side] + if key != identity and placement.side is current.side] if not peers: return False other = peers[0] @@ -139,32 +361,34 @@ def swap(self, identity: str) -> bool: self.placements[other] = replace(self.placements[other], order=current.order) return True - def directions(self, identity: str) -> dict[Side, SidebarShift]: - """Resolve spatial arrows against the current neighbors and outside wall.""" + def directions(self, identity: str) -> dict[type[SidebarEdge], SidebarShift]: + """Each spatial arrow swaps a neighbor, crosses the center or stops at a wall.""" current = self.get(identity) - actions: dict[Side, SidebarShift] = {"left": BlockedSidebarShift(), "right": BlockedSidebarShift()} - for direction in actions: - inward = direction != current.side + actions: dict[type[SidebarEdge], SidebarShift] = {} + for direction in SidebarEdge.members_with(SidebarEdge): + if direction.axis() is not current.side.axis(): + actions[direction] = MoveSidebarShift() + continue + inward = direction is not current.side neighbor_order = current.order + (1 if inward else -1) - if any(key != identity and peer.side == current.side and peer.order == neighbor_order + if any(key != identity and peer.side is current.side and peer.order == neighbor_order for key, peer in self.placements.items()): actions[direction] = SwapSidebarShift() elif inward: actions[direction] = MoveSidebarShift() + else: + actions[direction] = BlockedSidebarShift() return actions - def shift(self, identity: str, direction: Side) -> bool: - """One arrow step: swap with a neighbor, cross the center, or stop at a wall.""" + def shift(self, identity: str, direction: type[SidebarEdge]) -> bool: return self.directions(identity)[direction].apply(self, identity, direction) - def width(self, identity: str, percentage: int) -> bool: - if type(percentage) is not int: - raise ValueError("Sidebar width must be an integer percentage.") + def resize(self, identity: str, percentage: int) -> bool: current = self.get(identity) - percentage = max(15, min(50, percentage)) - if current.width_percent == percentage: + percentage = max(self.minimum_percent, min(self.maximum_percent, percentage)) + if current.extent_percent == percentage: return False - self.placements[identity] = replace(current, width_percent=percentage) + self.placements[identity] = replace(current, extent_percent=percentage) return True def float_mode(self, identity: str) -> bool: @@ -172,51 +396,60 @@ def float_mode(self, identity: str) -> bool: self.placements[identity] = replace(current, floating=not current.floating) return self.placements[identity].floating - def resolve(self, viewport: int, collapsed: dict[str, bool]) -> ResolvedSidebarLayout: - """Pack actual bar widths; Float changes only the conversation's gutter. + def resolve(self, viewport: Size, collapsed: dict[str, bool]) -> ResolvedSidebarLayout: + """Pack each axis once; top/bottom surfaces own the corners. - An inner pushed bar reserves space through its inside edge, including - any outer floating peer. Otherwise conversation text would overlap the - pushed bar. Collapsed handles always reserve their small edge extent. + Float changes only conversation clearance. A pushed inner peer clears + its floating outer peer too; collapsed handles always reserve space. """ - viewport = max(0, viewport) - widths = {key: 3 if collapsed[key] else max(18, viewport * self.get(key).width_percent // 100) - for key in self.placements if key in collapsed} - handles = sum(width for key, width in widths.items() if collapsed[key]) - expanded = [key for key in widths if not collapsed[key]] - budget = max(viewport - 24, min(viewport, handles + 18 * len(expanded))) - wanted = sum(widths[key] for key in expanded) - available = max(0, budget - handles) - if wanted > available: - for key in expanded: - widths[key] = available * widths[key] // wanted - for key in expanded[:available - sum(widths[key] for key in expanded)]: - widths[key] += 1 - if handles > viewport: - for key in widths: - widths[key] = viewport * widths[key] // handles - bars: dict[str, SidebarGeometry] = {} - gutters = {"left": 0, "right": 0} - for side in ("left", "right"): - offset = 0 - for key in sorted((key for key in widths if self.get(key).side == side), - key=lambda key: self.get(key).order): - width = widths[key] - bars[key] = SidebarGeometry(offset if side == "left" else viewport - offset - width, - width) - offset += width - if collapsed[key] or not self.get(key).floating: - gutters[side] = offset - return ResolvedSidebarLayout(bars, gutters["left"], gutters["right"]) - - def _renumber(self, side: Side) -> None: + spans: dict[str, SidebarGeometry] = {} + occupied = gutter = Spacing() + edges = SidebarEdge.members_with(SidebarEdge) + for axis in dict.fromkeys(edge.axis() for edge in edges): + total = max(0, axis.extent(viewport)) + extents = { + key: axis.collapsed_extent if collapsed[key] else max(axis.minimum, total * placement.extent_percent // 100) + for key, placement in self.placements.items() + if key in collapsed and placement.side.axis() is axis + } + handles = sum(extent for key, extent in extents.items() if collapsed[key]) + expanded = [key for key in extents if not collapsed[key]] + budget = max(total - axis.conversation_minimum, + min(total, handles + axis.minimum * len(expanded))) + wanted = sum(extents[key] for key in expanded) + available = max(0, budget - handles) + if wanted > available: + for key in expanded: + extents[key] = available * extents[key] // wanted + for key in expanded[:available - sum(extents[key] for key in expanded)]: + extents[key] += 1 + if handles > total: + for key in extents: + extents[key] = total * extents[key] // handles + for side in SidebarEdge.members_with(axis): + offset = clearance = 0 + for key in sorted((key for key in extents if self.get(key).side is side), + key=lambda key: self.get(key).order): + extent = extents[key] + spans[key] = SidebarGeometry(offset, extent) + offset += extent + if collapsed[key] or not self.get(key).floating: + clearance = offset + occupied += side.gutter(offset) + gutter += side.gutter(clearance) + return ResolvedSidebarLayout( + {key: self.get(key).side.region(viewport, span, occupied) for key, span in spans.items()}, + gutter, + ) + + def _renumber(self, side: type[SidebarEdge]) -> None: names = sorted((key for key, placement in self.placements.items() - if placement.side == side), key=lambda key: self.placements[key].order) + if placement.side is side), key=lambda key: self.placements[key].order) for index, key in enumerate(names): self.placements[key] = replace(self.placements[key], order=index) def ordered(self) -> tuple[str, ...]: + edges = SidebarEdge.members_with(SidebarEdge) return tuple(sorted(self.placements, key=lambda key: - (self.placements[key].side != "left", - self.placements[key].order if self.placements[key].side == "left" - else -self.placements[key].order))) + (edges.index(self.get(key).side), + self.get(key).side.order_key(self.get(key).order)))) diff --git a/src/toad/widget_actions.py b/src/toad/widget_actions.py index a50384069..9bda01d21 100644 --- a/src/toad/widget_actions.py +++ b/src/toad/widget_actions.py @@ -313,6 +313,24 @@ class CursorRightAction(HorizontalCursorAction): direction = +1 +class VerticalCursorAction(VisibleAction, declared_name=None): + group = HorizontalCursorAction.group + direction: ClassVar[Literal[-1, 1]] + + async def apply(self, context: MovesVerticalCursor) -> None: + context.cursor_vertical(self.direction) + + +class CursorUpAction(VerticalCursorAction): + keys = {EditingMode: ("up",)} + direction = -1 + + +class CursorDownAction(VerticalCursorAction): + keys = {EditingMode: ("down",)} + direction = +1 + + class SubmitAction(VisibleAction): keys = {EditingMode: ("enter",)} @@ -411,6 +429,11 @@ class MovesHorizontalCursor(DeclaredWidgetActions, actions=(HorizontalCursorActi def cursor_horizontal(self, direction: Literal[-1, 1]) -> None: ... +class MovesVerticalCursor(DeclaredWidgetActions, actions=(VerticalCursorAction,)): + @abstractmethod + def cursor_vertical(self, direction: Literal[-1, 1]) -> None: ... + + class Submits(DeclaredWidgetActions, actions=(SubmitAction,)): @abstractmethod diff --git a/src/toad/widgets/side_bar.py b/src/toad/widgets/side_bar.py index f76639d9e..deb6f8644 100644 --- a/src/toad/widgets/side_bar.py +++ b/src/toad/widgets/side_bar.py @@ -6,7 +6,7 @@ from toad.core_event_carrier import CoreEventReceiver from toad.core import events as core_events from textual.content import Content -from toad.widget_actions import ApplicationAction, ActivationKeys, Activates, DismissibleControl, WidgetAction, DeclaredWidgetActions, MovesHorizontalCursor +from toad.widget_actions import ApplicationAction, ActivationKeys, Activates, DismissibleControl, WidgetAction, DeclaredWidgetActions, MovesHorizontalCursor, MovesVerticalCursor from textual.document._editing import EditingMode from toad.widgets.comms_menu import ContextMenu from toad.core.preference_events import PreferenceChanged @@ -18,7 +18,7 @@ from textual import containers, events, on, widgets from textual.app import ComposeResult from textual.events import Click -from textual.geometry import Offset +from textual.geometry import Offset, Region from textual.message import Message from textual.reactive import reactive from textual._activation import PrimaryActivation @@ -31,8 +31,8 @@ from toad.widgets.selection import HoverSelection, ColourSelection from toad.session_tracker import SidebarState from toad.sidebar_layout import ( - Side, - SidebarGeometry, + SidebarEdge, + SidebarLayout, SidebarPlacement, SidebarPlacementAction, SidebarShift, @@ -46,12 +46,11 @@ @dataclass(frozen=True) class BarPresentation: - side: Side + side: type[SidebarEdge] order: int floating: bool collapsed: bool directions: tuple[type[SidebarShift], ...] - compact: bool @classmethod def from_placement( @@ -59,8 +58,7 @@ def from_placement( placement: SidebarPlacement, *, collapsed: bool, - directions: dict[Side, SidebarShift], - width: int, + directions: dict[type[SidebarEdge], SidebarShift], ) -> BarPresentation: return cls( placement.side, @@ -68,15 +66,14 @@ def from_placement( placement.floating, collapsed, tuple(type(action) for action in directions.values()), - all(action.available for action in directions.values()) and width - 4 < 21, ) @dataclass(frozen=True) class PresentedBar: presentation: BarPresentation - geometry: SidebarGeometry - width_percent: int + geometry: Region + extent_percent: int class SidebarPanelWidget(Widget): @@ -197,9 +194,6 @@ def paint_highlighted(self): DEFAULT_CSS = """ SideBarToggle { - dock: right; - width: 3; - height: 1fr; content-align: center middle; color: $text-muted; background: $primary 8%; @@ -214,17 +208,15 @@ def __init__(self, *, focus: bool = True) -> None: super().__init__() self.focus = focus - def __init__(self, collapsed: bool = False, *, right: bool = False) -> None: + def __init__(self, collapsed: bool, *, edge: type[SidebarEdge]) -> None: super().__init__() - self.right = right - self.set_collapsed(collapsed) - - def set_collapsed(self, collapsed: bool) -> None: - self.styles.dock = "left" if self.right else "right" - self.offset = (0 if collapsed else 1 if self.right else -1, 0) - text = ( - ("<" if collapsed else ">") if self.right else (">" if collapsed else "<") - ) + self.set_collapsed(collapsed, edge) + + def set_collapsed(self, collapsed: bool, edge: type[SidebarEdge]) -> None: + edge.size_toggle(self) + self.styles.dock = edge.opposite().dock + self.offset = edge.control_offset(0 if collapsed else 1) + text = edge.toggle_symbol(collapsed) if self.content != text: self.update(text, layout=False) tooltip = "Expand sidebar" if collapsed else "Collapse sidebar" @@ -389,7 +381,7 @@ def __init__(self, *args, **kwargs) -> None: def drag_start(self, event: events.MouseDown) -> None: raise NotImplementedError - def drag_to(self, screen_x: int) -> None: + def drag_to(self, position: Offset) -> None: raise NotImplementedError def on_mouse_down(self, event: events.MouseDown) -> None: @@ -402,7 +394,7 @@ def on_mouse_down(self, event: events.MouseDown) -> None: def on_mouse_move(self, event: events.MouseMove) -> None: if self._dragging: event.stop() - self.drag_to(event.screen_x) + self.drag_to(event.screen_offset) def can_replace_mouse_move( self, event: events.MouseMove, pending: events.MouseMove @@ -413,7 +405,7 @@ def can_replace_mouse_move( def on_mouse_up(self, event: events.MouseUp) -> None: if event.button == 1 and self._dragging: event.stop() - self.drag_to(event.screen_x) + self.drag_to(event.screen_offset) self._dragging = False self.release_mouse() @@ -441,7 +433,7 @@ def __init__(self, minimum: int, maximum: int, value: int) -> None: self.maximum = maximum self.value = value self.reversed = False - self.tooltip = "Drag or use ←/→ to resize (15–50% of screen)" + self.tooltip = f"Drag or use ←/→ to resize ({minimum}–{maximum}% of the placement axis)" def set_range(self, minimum: int, maximum: int, value: int) -> None: self.minimum, self.maximum = minimum, max(minimum, maximum) @@ -458,8 +450,8 @@ def render(self) -> str: line = "".join("●" if index == thumb else "─" for index in range(track)) return f"{line} {label}" if self.reversed else f"{label} {line}" - def drag_to(self, screen_x: int) -> None: - x = screen_x - self._drag_origin_x + def drag_to(self, position: Offset) -> None: + x = position.x - self._drag_origin_x width = self._drag_width reverse = self._drag_reversed label_width = 4 @@ -489,7 +481,7 @@ def drag_start(self, event: events.MouseDown) -> None: self._drag_origin_x = self.region.x self._drag_width = self.size.width self._drag_reversed = self.reversed - self.drag_to(event.screen_x) + self.drag_to(event.screen_offset) class SidebarAction(Activates, HoverSelection, widgets.Static, can_focus=True): @@ -497,7 +489,7 @@ class SidebarAction(Activates, HoverSelection, widgets.Static, can_focus=True): DEFAULT_CSS = """ SidebarAction { height: 1; width: auto; padding: 0 1; color: $text-muted; pointer: pointer; } - SidebarAction.-direction { width: 7; content-align: center middle; text-style: bold; } + SidebarAction.-direction { width: 1fr; content-align: center middle; text-style: bold; } """ @dataclass @@ -505,15 +497,14 @@ class Pressed(Message): action: SidebarPlacementAction def __init__(self, action: SidebarPlacementAction) -> None: - super().__init__(action.label, id=f"sidebar-{action.declared_name}") + super().__init__(action.label, id=f"sidebar-{action.name}", classes=action.classes) self.action = action - self.set_class(action.directional, "-direction") def activate(self) -> None: self.post_message(self.Pressed(self.action)) class SidebarResizeHandle( - MovesHorizontalCursor, CapturedDrag, ColourSelection, widgets.Static, ToadNode, can_focus=True + MovesHorizontalCursor, MovesVerticalCursor, CapturedDrag, ColourSelection, widgets.Static, ToadNode, can_focus=True ): """Drag the conversation-facing edge; the placement model owns the width.""" @@ -521,37 +512,43 @@ class SidebarResizeHandle( DEFAULT_CSS = """ SidebarResizeHandle { - dock: right; width: 1; height: 1fr; padding: 0; - color: $success; pointer: ew-resize; + padding: 0; + color: $success; } SidebarResizeHandle:ansi { color: ansi_green; } """ - def __init__(self) -> None: + def __init__(self, edge: type[SidebarEdge]) -> None: super().__init__(id="sidebar-resize-handle") - self.tooltip = "Drag toward the center to widen the sidebar" + edge.size_handle(self) + self.styles.dock = edge.opposite().dock + self.tooltip = "Drag toward the center to expand the sidebar" def render(self) -> str: - return "\n".join("│" for _ in range(self.size.height)) + return self.query_ancestor(SideBar).placement.side.handle_text(self.size) + + def drag_to(self, position: Offset) -> None: + edge = self._drag_edge + extent = self._start_extent + edge.sign * edge.coordinate(position - self._start_point) + self.query_ancestor(SideBar).resize_to(round(100 * extent / max(1, self._drag_viewport))) - def drag_to(self, screen_x: int) -> None: + def resize_by(self, direction: Offset) -> None: bar = self.query_ancestor(SideBar) - if bar.id is None or bar.parent is None: - return - app = self.app - direction = -1 if app.sidebar_layout.get(bar.id).side == "right" else 1 - width = self._start_width + direction * (screen_x - self._start_x) - percent = round(100 * width / max(1, bar.screen.size.width)) - if app.sidebar_layout.width(bar.id, percent): - app.workspace_chrome.refresh_layout() + placement = bar.placement + bar.resize_to(placement.extent_percent + placement.side.sign * placement.side.coordinate(direction)) def cursor_horizontal(self, direction: int) -> None: - bar = self.query_ancestor(SideBar) - bar.width_slider.cursor_horizontal(direction) + self.resize_by(Offset(direction, 0)) + + def cursor_vertical(self, direction: int) -> None: + self.resize_by(Offset(0, direction)) def drag_start(self, event: events.MouseDown) -> None: - self._start_x = event.screen_x - self._start_width = self.query_ancestor(SideBar).size.width + bar = self.query_ancestor(SideBar) + self._drag_edge = bar.placement.side + self._start_point = event.screen_offset + self._start_extent = self._drag_edge.extent(bar.size) + self._drag_viewport = self._drag_edge.extent(bar.screen.size) class ClearSidebarSearchAction(WidgetAction): @@ -596,7 +593,6 @@ class SideBar(DismissibleControl, WorkspaceHosted, TonePaint, CoreEventReceiver, SideBar > SidebarViewport { width: 1fr; height: 1fr; - margin: 0 4 0 0; layer: sidebar-content; overflow: auto auto; scrollbar-size: 1 1; @@ -605,21 +601,13 @@ class SideBar(DismissibleControl, WorkspaceHosted, TonePaint, CoreEventReceiver, SideBar > #sidebar-controls { width: 1fr; height: auto; - margin: 0 4 0 0; layer: sidebar-content; } - SideBar #sidebar-layout-actions { height: 1; width: 1fr; overflow: hidden hidden; grid-size: 3; grid-columns: 7 7 auto; } - SideBar #sidebar-layout-actions.-compact { - grid-size: 2; grid-columns: 1fr 1fr; height: 2; + SideBar #sidebar-layout-actions { + height: auto; width: 1fr; overflow: hidden hidden; + grid-size: 2; grid-columns: 1fr 1fr; grid-rows: 1; } - SideBar #sidebar-layout-actions.-compact > #sidebar-float { column-span: 2; width: 1fr; } - SideBar.-right { - border-right: none; - border-left: none; - } - SideBar.-right > SideBarToggle { dock: left; } - SideBar.-right > SidebarResizeHandle { dock: left; } - SideBar.-right > SidebarViewport, SideBar.-right > #sidebar-controls { margin: 0 0 0 4; } + SideBar #sidebar-float { column-span: 2; width: 1fr; } """ collapsed = reactive(False) @@ -646,7 +634,6 @@ def __init__( classes: str | None = None, disabled: bool = False, hide: bool = False, - right: bool = False, defer_mount: bool = False, ) -> None: super().__init__(name=name, id=id, classes=classes, disabled=disabled) @@ -655,14 +642,19 @@ def __init__( self.search_input.styles.layer = "sidebar-content" self.panels_viewport: SidebarViewport = SidebarViewport() self.hide = hide - self.right = right - self.width_slider = SidebarSlider(15, 50, 40 if not right else 34) + self.extent_slider = SidebarSlider( + SidebarLayout.minimum_percent, SidebarLayout.maximum_percent, + SidebarLayout.minimum_percent, + ) self._panels_loaded = not defer_mount self._panels_loading = False self._panels_ready = asyncio.Event() self._presented_collapsed: bool | None = None self._presented_layout: PresentedBar | None = None - self.set_class(right, "-right") + + @property + def placement(self) -> SidebarPlacement: + return self.app.sidebar_layout.get(self.id) @property def panels_loaded(self) -> bool: @@ -745,8 +737,8 @@ def on_unmount(self) -> None: self._panels_ready.set() def compose(self) -> ComposeResult: - yield SideBarToggle(self.collapsed, right=self.right) - yield SidebarResizeHandle() + yield SideBarToggle(self.collapsed, edge=self.placement.side) + yield SidebarResizeHandle(self.placement.side) yield self.search_input with self.panels_viewport: if self._panels_loaded: @@ -774,11 +766,12 @@ def _compose_panels(self) -> ComposeResult: ) def _compose_controls(self) -> ComposeResult: - yield self.width_slider + yield self.extent_slider yield containers.Grid( *( - SidebarAction(kind()) + SidebarAction(action) for kind in SidebarPlacementAction.members_with(SidebarPlacementAction) + for action in kind.offered() ), id="sidebar-layout-actions", ) @@ -790,88 +783,59 @@ def on_show(self) -> None: self._present_controls() def _present_controls(self) -> bool: - app = self.app if not self.is_mounted: return False - placement = app.sidebar_layout.get(self.id) + placement = self.placement if self.parent is None or not self.presentation_visible: self._presented_layout = None return False - geometry = SidebarGeometry(self.region.x, self.size.width) - width = geometry.width - directions = app.sidebar_layout.directions(self.id) + directions = self.app.sidebar_layout.directions(self.id) current = PresentedBar( presentation=BarPresentation.from_placement( - placement, collapsed=self.collapsed, directions=directions, width=width + placement, collapsed=self.collapsed, directions=directions ), - geometry=geometry, - width_percent=placement.width_percent, + geometry=self.region, + extent_percent=placement.extent_percent, ) previous = self._presented_layout if current == previous: return False self._presented_layout = current - controls = ( - self.query_one_optional("#sidebar-controls") - if self._panels_loaded - else None - ) - slider = self.width_slider + controls = self.query_one_optional("#sidebar-controls") if self._panels_loaded else None + slider = self.extent_slider if previous is None or previous.presentation != current.presentation: - self.right = placement.side == "right" - self.set_class(self.right, "-right") - self.search_input.styles.margin = ( - (0, 0, 0, 4) if self.right else (0, 4, 0, 0) - ) + edge = placement.side + margin = edge.controls_margin() + self.search_input.styles.margin = margin + self.panels_viewport.styles.margin = margin if toggle := self.query_one_optional(SideBarToggle): - toggle.right = self.right - toggle.set_collapsed(self.collapsed) + toggle.set_collapsed(self.collapsed, edge) if handle := self.query_one_optional(SidebarResizeHandle): + edge.size_handle(handle) + handle.styles.dock = edge.opposite().dock handle.display = not self.collapsed if controls is not None: + controls.styles.margin = margin controls.display = not self.collapsed - actions = controls.query_one_optional("#sidebar-layout-actions") - buttons = { - direction: controls.query_one_optional( - f"#sidebar-{direction}", SidebarAction - ) - for direction in (*directions, "float") - } - if actions is None or any( - button is None for button in buttons.values() - ): - # Mounted parents can precede their composed action rows. - self._presented_layout = None - else: - slider.reversed = self.right - slider.set_range(15, 50, placement.width_percent) - for direction, action in directions.items(): - button = buttons[direction] - button.display = action.available - button.tooltip = action.tooltip(direction) - toggle_mode = buttons["float"] - toggle_mode.update( - "Push" if placement.floating else "Float", layout=False - ) - toggle_mode.tooltip = ( - "Push conversation text" - if placement.floating - else "Float over conversation text" - ) - actions.set_class(current.presentation.compact, "-compact") - if slider.value != placement.width_percent: - slider.value = placement.width_percent + slider.reversed = edge.sign < 0 + slider.set_range(SidebarLayout.minimum_percent, SidebarLayout.maximum_percent, + placement.extent_percent) + for button in controls.query(SidebarAction): + button.action.present(button, placement, directions) + if slider.value != placement.extent_percent: + slider.value = placement.extent_percent slider.refresh() return True + def resize_to(self, percentage: int) -> None: + if self.app.sidebar_layout.resize(self.id, percentage): + self._present_controls() + self.app.workspace_chrome.refresh_layout() + @on(SidebarSlider.Changed) def on_sidebar_slider(self, event: SidebarSlider.Changed) -> None: event.stop() - if self.id is not None: - app = self.app - if app.sidebar_layout.width(self.id, event.value): - self._present_controls() - app.workspace_chrome.refresh_layout() + self.resize_to(event.value) @on(SidebarAction.Pressed) def on_sidebar_action(self, event: SidebarAction.Pressed) -> None: @@ -917,7 +881,7 @@ def watch_collapsed(self, collapsed: bool) -> None: self.tooltip = "Expand sidebar" if collapsed else None toggle = self.query_one_optional(SideBarToggle) if toggle is not None: - toggle.set_collapsed(collapsed) + toggle.set_collapsed(collapsed, self.placement.side) toggle.refresh_paint() for panel in self.panels: panel.widget.sidebar_visibility_changed() @@ -927,7 +891,7 @@ def watch_collapsed(self, collapsed: bool) -> None: self.app.workspace_chrome.refresh_layout() def render(self) -> str: - return ("<" if self.right else ">") if self.collapsed else "" + return self.placement.side.toggle_symbol(self.collapsed) if self.collapsed else "" @handles(PreferenceChanged) async def _settings_changed(self, event: PreferenceChanged) -> None: @@ -980,7 +944,7 @@ def dismiss_control(self) -> None: class ThreadSidebar(SideBar): def __init__(self, *panels, state: SidebarState, **kwargs): self.state = state - super().__init__(*panels, id="thread-sidebar", right=True, hide=True, **kwargs) + super().__init__(*panels, id="thread-sidebar", hide=True, **kwargs) self.collapsed = self.hide @property diff --git a/src/toad/workspace_chrome.py b/src/toad/workspace_chrome.py index 87991f935..826bf3a69 100644 --- a/src/toad/workspace_chrome.py +++ b/src/toad/workspace_chrome.py @@ -15,7 +15,7 @@ from toad.widget_view import AUTO, FILL, WidgetView from toad.session_presentation import NativeSessionSurface from toad.session_scope import SessionScope -from toad.sidebar_layout import Side +from toad.sidebar_layout import SidebarEdge if TYPE_CHECKING: from toad.app import ToadApp @@ -49,7 +49,7 @@ def presented_sidebars(self): return tuple(bar for bar in (self.channels, *source.sidebars()) if bar.presentation_visible and bar.id in layout.placements) - def toggle_sidebar(self, side: Side) -> None: + def toggle_sidebar(self, side: type[SidebarEdge]) -> None: layout = self.app.sidebar_layout bars = tuple(bar for bar in self.presented_sidebars() if layout.get(bar.id).side == side) @@ -83,23 +83,22 @@ def arrange( layout = self.app.sidebar_layout own = tuple(source.sidebars()) bars = self.presented_sidebars() - resolved = layout.resolve(size.width, {bar.id: bar.collapsed for bar in bars}) + resolved = layout.resolve(size, {bar.id: bar.collapsed for bar in bars}) # The outer host stays full width. Its session's own native layout # clears both gutters, including the shared channel surface. nested = parent is self.channels.parent and any( bar.parent.layout is self for bar in own ) - left = 0 if nested else resolved.left_gutter - right = 0 if nested else resolved.right_gutter + gutter = Spacing() if nested else resolved.gutter placements = [ placement._replace( - region=placement.region + Offset(left, 0), - margin=placement.margin + Spacing(0, right, 0, left), + region=placement.region + Offset(gutter.left, gutter.top), + margin=placement.margin + gutter, ) for placement in super().arrange( parent, [child for child in children if child not in bars], - Size(max(0, size.width - left - right), size.height), + Size(max(0, size.width - gutter.width), max(0, size.height - gutter.height)), greedy, ) ] @@ -109,8 +108,8 @@ def arrange( geometry = resolved.bars[bar.id] placements.append( WidgetPlacement( - Region(0, 0, geometry.width, size.height), - Offset(geometry.x, 0), + geometry.reset_offset, + geometry.offset, Spacing(), bar, order[bar.id], diff --git a/tests/model/test_sidebar_layout_policy.py b/tests/model/test_sidebar_layout_policy.py index c0da18821..5b737eb8a 100644 --- a/tests/model/test_sidebar_layout_policy.py +++ b/tests/model/test_sidebar_layout_policy.py @@ -1,71 +1,92 @@ -"""The placement model cannot duplicate, discard or over-expand a sidebar.""" +"""Placement preserves both sidebars, peer order, axis bounds and conversation clearance.""" from itertools import product import pytest +from textual.geometry import Size -from toad.sidebar_layout import SidebarLayout +from toad.sidebar_layout import LeftSidebarEdge, RightSidebarEdge, SidebarEdge, SidebarLayout -def test_moving_swapping_and_resizing_keep_both_sidebars_and_clamp_width(): +EDGES = SidebarEdge.members_with(SidebarEdge) + + +def test_moving_swapping_and_resizing_keep_both_sidebars_and_clamp_extent(): layout = SidebarLayout() assert layout.ordered() == ("channels-sidebar", "thread-sidebar") - assert layout.move("channels-sidebar", "right") + assert layout.move("channels-sidebar", RightSidebarEdge) assert layout.get("thread-sidebar").order == 0 assert layout.get("channels-sidebar").order == 1 assert layout.swap("channels-sidebar") assert layout.get("channels-sidebar").order == 0 assert layout.get("thread-sidebar").order == 1 assert layout.ordered() == ("thread-sidebar", "channels-sidebar") - assert layout.move("thread-sidebar", "left") + assert layout.move("thread-sidebar", LeftSidebarEdge) assert layout.ordered() == ("thread-sidebar", "channels-sidebar") assert layout.get("thread-sidebar").order == 0 - assert layout.width("channels-sidebar", 70) - assert layout.get("channels-sidebar").width_percent == 50 - assert layout.width("channels-sidebar", 1) - assert layout.get("channels-sidebar").width_percent == 15 + assert layout.resize("channels-sidebar", 70) + assert layout.get("channels-sidebar").extent_percent == layout.maximum_percent + assert layout.resize("channels-sidebar", 1) + assert layout.get("channels-sidebar").extent_percent == layout.minimum_percent assert layout.float_mode("thread-sidebar") is True assert layout.float_mode("thread-sidebar") is False assert set(layout.ordered()) == {"thread-sidebar", "channels-sidebar"} -@pytest.mark.parametrize("side", ["left", "right"]) -def test_two_sidebars_on_one_side_swap_inward_then_cross(side): +@pytest.mark.parametrize("side", EDGES) +def test_two_sidebars_on_one_edge_swap_inward_then_cross(side): pair = SidebarLayout() pair.move("channels-sidebar", side) pair.move("thread-sidebar", side) outer, inner = sorted(pair.placements, key=lambda key: pair.get(key).order) - inward = "right" if side == "left" else "left" + inward = side.opposite() before = dict(pair.placements) assert not pair.shift(outer, side) and pair.placements == before assert pair.shift(outer, inward) - assert pair.get(outer).side == pair.get(inner).side == side + assert pair.get(outer).side is pair.get(inner).side is side assert pair.get(outer).order == 1 and pair.get(inner).order == 0 - assert pair.shift(outer, inward) and pair.get(outer).side == inward + assert pair.shift(outer, inward) and pair.get(outer).side is inward assert not pair.shift(outer, inward) @pytest.mark.parametrize( - "side, width, hide_left, hide_right", list(product(("left", "right"), (76, 120), (False, True), (False, True)))) -def test_float_keeps_order_and_swap_keeps_widths(side, width, hide_left, hide_right): + "side, viewport, hide_channels, hide_thread", + list(product(EDGES, (Size(76, 40), Size(120, 70)), (False, True), (False, True))), +) +def test_float_keeps_order_and_swap_keeps_extents(side, viewport, hide_channels, hide_thread): pair = SidebarLayout() pair.move("channels-sidebar", side) pair.move("thread-sidebar", side) - collapsed = {"channels-sidebar": hide_left, "thread-sidebar": hide_right} - before = pair.resolve(width, collapsed) + collapsed = {"channels-sidebar": hide_channels, "thread-sidebar": hide_thread} + before = pair.resolve(viewport, collapsed) order = pair.ordered() for name in order: pair.float_mode(name) assert pair.ordered() == order, "Float must not reorder sidebars" - assert pair.resolve(width, collapsed).bars == before.bars - physical = sorted(before.bars.values(), key=lambda bar: bar.x) - assert physical[0].x + physical[0].width == physical[1].x - assert physical[0].x >= 0 and physical[1].x + physical[1].width <= width + assert pair.resolve(viewport, collapsed).bars == before.bars + physical = sorted(before.bars.values(), key=lambda bar: side.coordinate(bar.offset)) + assert side.coordinate(physical[0].offset) + side.extent(physical[0].size) == side.coordinate(physical[1].offset) + assert side.coordinate(physical[0].offset) >= 0 + assert side.coordinate(physical[1].offset) + side.extent(physical[1].size) <= side.extent(viewport) for name, hidden in collapsed.items(): if hidden: - assert before.bars[name].width == 3 + assert side.extent(before.bars[name].size) == side.collapsed_extent pair.swap("channels-sidebar") - after = pair.resolve(width, dict(reversed(tuple(collapsed.items())))) - assert {key: bar.width for key, bar in after.bars.items()} == { - key: bar.width for key, bar in before.bars.items() - }, "Swap must not change widths through order-dependent rounding" + after = pair.resolve(viewport, dict(reversed(tuple(collapsed.items())))) + assert {key: side.extent(bar.size) for key, bar in after.bars.items()} == { + key: side.extent(bar.size) for key, bar in before.bars.items() + }, "Swap must not change extents through order-dependent rounding" + + +@pytest.mark.parametrize("channels_edge, thread_edge", list(product(EDGES, repeat=2))) +def test_every_edge_combination_stays_inside_native_viewport(channels_edge, thread_edge): + layout = SidebarLayout() + layout.move("channels-sidebar", channels_edge) + layout.move("thread-sidebar", thread_edge) + viewport = Size(52, 70) + resolved = layout.resolve(viewport, {key: False for key in layout.placements}) + bars = tuple(resolved.bars.values()) + assert all(viewport.region.contains_region(bar) for bar in bars) + assert not bars[0].overlaps(bars[1]) + assert resolved.gutter.width < viewport.width + assert resolved.gutter.height < viewport.height diff --git a/tests/textual/test_navigation_keys.py b/tests/textual/test_navigation_keys.py index fefaa1c5e..132195c27 100644 --- a/tests/textual/test_navigation_keys.py +++ b/tests/textual/test_navigation_keys.py @@ -137,11 +137,12 @@ async def test_sidebar_keys_follow_actual_edges_after_a_pointer_move(workspace): field.value = '' # Use the existing spatial control to move the right bar to the left. - button = next(button for button in thread.query(SidebarAction) - if button.display and button.action.directional and button.action.direction == 'left') + from toad.sidebar_layout import LeftSidebarEdge + + button = thread.query_one('#sidebar-left', SidebarAction) assert await pilot.click(button) await ui.step() - assert not thread.right and not channels.right + assert thread.placement.side is channels.placement.side is LeftSidebarEdge ui.conversation.prompt.focus() assert ui.conversation.prompt.prompt_text_area.editing_mode.allows_application_commands await ui.press('ctrl+shift+l') diff --git a/tests/textual/test_sidebar_direction.py b/tests/textual/test_sidebar_direction.py index c73c3dae5..2ae1c791c 100644 --- a/tests/textual/test_sidebar_direction.py +++ b/tests/textual/test_sidebar_direction.py @@ -1,67 +1,66 @@ -"""Textual-facing: sidebar arrow buttons follow spatial neighbours (walls, inward swaps, outward swaps, cross-centre moves) as laid out by the compositor.""" - -from toad.widgets.side_bar import SideBar, SidebarAction +"""Native sidebar controls reflect the placement owner at every declared edge.""" +import pytest -def arrows(bar: SideBar) -> list[str]: - return [button.render().plain for button in bar.query(SidebarAction) - if button.display and button.action.directional] +from toad.sidebar_layout import SidebarEdge +from toad.widgets.side_bar import SideBar, SidebarAction -async def test_arrow_clicks_follow_spatial_neighbours(workspace): - async with workspace.app(size=(120, 40)) as ui: +@pytest.mark.parametrize("side", SidebarEdge.members_with(SidebarEdge)) +async def test_arrow_clicks_follow_spatial_neighbours(workspace, side): + async with workspace.app(size=(120, 60)) as ui: app, pilot = ui.textual channels = app.screen.query_one("#channels-sidebar", SideBar) thread = app.selected_session.query_one("#thread-sidebar", SideBar) + channels.reveal() thread.reveal() + app.sidebar_layout.move(channels.id, side) + app.sidebar_layout.move(thread.id, side) + app.workspace_chrome.refresh_layout() await ui.step() - async def click(bar: SideBar, label: str) -> None: - button = next(button for button in bar.query(SidebarAction) - if button.display and button.render().plain == label) - assert await pilot.click(button), ( - bar.id, label, button.region, bar.region, - tuple((item.id, item.region, item.collapsed) for item in app.screen.query(SideBar)), - button in app.screen._compositor.visible_widgets) + async def click(bar: SideBar, edge) -> None: + button = bar.query_one(f"#sidebar-{edge.declared_name}", SidebarAction) + assert button.display and await pilot.click(button), (bar.id, edge, button.region, bar.region) await ui.step() - assert arrows(channels) == ["──>"] and arrows(thread) == ["<──"], ( - arrows(channels), arrows(thread), ui.visible_text()) - await click(thread, "<──") - assert not channels.right and not thread.right - assert channels.region.right == thread.region.x - assert arrows(channels) == ["──>"], "Wall-adjacent bar must have only the inward arrow" - assert arrows(thread) == ["<──", "──>"] + def check_controls(bar: SideBar) -> None: + for edge, shift in app.sidebar_layout.directions(bar.id).items(): + button = bar.query_one(f"#sidebar-{edge.declared_name}", SidebarAction) + assert button.display == shift.available - await click(channels, "──>") - assert not channels.right and not thread.right, "Inward click must swap, not cross the conversation" - assert thread.region.right == channels.region.x - assert arrows(thread) == ["──>"] and arrows(channels) == ["<──", "──>"] - await click(channels, "<──") - assert channels.region.right == thread.region.x - await click(thread, "──>") - assert thread.right and not channels.right + def check_adjacent() -> None: + regions = sorted((channels.region, thread.region), key=lambda region: side.coordinate(region.offset)) + assert side.coordinate(regions[0].offset) + side.extent(regions[0].size) == side.coordinate(regions[1].offset) - await click(channels, "──>") - assert channels.right and thread.right - assert channels.region.right == thread.region.x - assert arrows(thread) == ["<──"] and arrows(channels) == ["<──", "──>"] - await click(thread, "<──") - assert channels.right and thread.right - assert thread.region.right == channels.region.x - assert arrows(channels) == ["<──"] and arrows(thread) == ["<──", "──>"] - await click(thread, "──>") - assert channels.region.right == thread.region.x + # The wall is blocked, the inward arrow swaps, then crosses the centre. + assert channels.placement.order == 0 and thread.placement.order == 1 + check_controls(channels) + check_controls(thread) + check_adjacent() + inward = side.opposite() + await click(channels, inward) + assert channels.placement.side is thread.placement.side is side + assert thread.placement.order == 0 and channels.placement.order == 1 + check_adjacent() + await click(channels, side) + assert channels.placement.order == 0 and thread.placement.order == 1 + await click(thread, inward) + assert thread.placement.side is inward and channels.placement.side is side + await click(channels, inward) + assert channels.placement.side is thread.placement.side is inward + check_controls(channels) + check_controls(thread) + check_adjacent() - # Float and a collapsed neighbour do not change the arrow policy. - await click(thread, "Float") - channels.toggle() + # Float and collapse keep the same canonical peer/action policy. + assert await pilot.click(thread.query_one("#sidebar-float", SidebarAction)) + channels.toggle(focus=False) await ui.step() - assert arrows(thread) == ["<──"] - await click(thread, "<──") - assert thread.right and channels.right - assert thread.region.right == channels.region.x - assert arrows(thread) == ["<──", "──>"] + check_controls(thread) + await click(thread, side) + assert thread.placement.side is channels.placement.side is inward channels.reveal() await ui.step() - assert arrows(channels) == ["<──"] + check_controls(channels) + check_controls(thread) diff --git a/tests/textual/test_sidebar_drag_resize.py b/tests/textual/test_sidebar_drag_resize.py index ad64d7b58..9a2dfa5a3 100644 --- a/tests/textual/test_sidebar_drag_resize.py +++ b/tests/textual/test_sidebar_drag_resize.py @@ -6,27 +6,28 @@ from toad.keymap import KeymapRow from toad.preferences import SETTINGS from toad.widget_actions import CursorRightAction +from toad.sidebar_layout import SidebarEdge, WidthSidebarEdge from toad.widgets.side_bar import ( SideBar, SideBarToggle, SidebarAction, SidebarResizeHandle, SidebarSlider, TabHistoryControls, ) -@pytest.mark.parametrize("side", ["left", "right"]) +@pytest.mark.parametrize("side", SidebarEdge.members_with(WidthSidebarEdge)) async def test_resize_by_edge_drag_slider_and_keys_follows_the_side(workspace, side): async with workspace.app(size=(120, 40)) as ui: app, pilot = ui.textual bar = app.screen.query_one("#channels-sidebar", SideBar) app.sidebar_layout.move("channels-sidebar", side) - app.sidebar_layout.width("channels-sidebar", 30) + app.sidebar_layout.resize("channels-sidebar", 30) app.workspace_chrome.refresh_layout() await ui.step() - slider = bar.width_slider + slider = bar.extent_slider handle = bar.query_one(SidebarResizeHandle) toggle = bar.query_one(SideBarToggle) - assert (toggle.region.right == handle.region.x if side == "left" + assert (toggle.region.right == handle.region.x if side.sign > 0 else toggle.region.x == handle.region.right), (side, toggle.region, handle.region) - direction = 1 if side == "left" else -1 - assert slider.reversed == (side == "right") + direction = side.sign + assert slider.reversed == (side.sign < 0) for theme in ("ansi-dark", "textual-dark"): app.theme = theme ui.conversation.prompt.focus() @@ -36,7 +37,7 @@ async def test_resize_by_edge_drag_slider_and_keys_follows_the_side(workspace, s assert await pilot.hover(handle, offset=(0, 3)) await ui.step() assert handle._paint_state.foreground != normal and "hover" in handle.pseudo_classes - for selector in (f"#sidebar-{'right' if side == 'left' else 'left'}", "#sidebar-float"): + for selector in (f"#sidebar-{side.opposite().declared_name}", "#sidebar-float"): button = bar.query_one(selector, SidebarAction) normal = button._paint_state.background assert await pilot.hover(button) @@ -53,14 +54,14 @@ async def test_resize_by_edge_drag_slider_and_keys_follows_the_side(workspace, s await pilot.mouse_up(offset=(start + direction * 12, y)) await ui.step() assert bar.size.width == old_width + 12, (side, bar.size, old_width) - assert slider.value == app.sidebar_layout.get(bar.id).width_percent == 40 + assert slider.value == app.sidebar_layout.get(bar.id).extent_percent == 40 assert not handle._dragging and app.mouse_captured is None # The physical arrow keys and slider endpoints mirror as well. slider.focus() - await ui.press("right" if side == "left" else "left") + await ui.press("right" if side.sign > 0 else "left") assert slider.value == 41 - await ui.press("left" if side == "left" else "right") + await ui.press("left" if side.sign > 0 else "right") assert slider.value == 40 remap = app.settings.ui.keymap.changed(KeymapRow.of(CursorRightAction(), Standard), ("f6",)) app.saved_settings.set(SETTINGS.ui.keymap.bindings, remap.bindings) @@ -72,20 +73,20 @@ async def test_resize_by_edge_drag_slider_and_keys_follows_the_side(workspace, s assert slider.value == 40 + direction await ui.press("left") assert slider.value == 40 - inward = slider.size.width - 1 if side == "left" else 0 + inward = slider.size.width - 1 if side.sign > 0 else 0 assert await pilot.mouse_down(slider, offset=(inward, 0)) assert not app.screen._selecting, "Width slider started a text selection" assert await pilot.mouse_up(slider, offset=(inward, 0)) await ui.step() assert slider.value == 50 and bar.size.width == 60, (side, slider.value, bar.region) - outward = 4 if side == "left" else slider.size.width - 1 + outward = 4 if side.sign > 0 else slider.size.width - 1 # Width changes on press, then the capture owner gets the release. # A final Click may land outside the track it has just resized. assert await pilot.mouse_down(slider, offset=(outward, 0)) await pilot.mouse_up(offset=(slider._drag_origin_x + outward, slider.region.y)) await ui.step() assert slider.value == 15 and bar.size.width == 18 - assert handle.region.x == (bar.region.right - 1 if side == "left" else bar.region.x) + assert handle.region.x == (bar.region.right - 1 if side.sign > 0 else bar.region.x) bar.toggle() await ui.step() assert not handle.display From 16a60860299b87319ab028ffa85b659fdb7c9780 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sat, 31 Oct 2026 18:51:03 -0400 Subject: [PATCH 10/17] Migrate the tool header to the shared variable-height line block --- src/toad/widgets/tool_call.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/toad/widgets/tool_call.py b/src/toad/widgets/tool_call.py index e966e2392..963860d2d 100644 --- a/src/toad/widgets/tool_call.py +++ b/src/toad/widgets/tool_call.py @@ -21,14 +21,14 @@ from toad.widgets.agent_activity import AgentActivityPresentation from toad.widgets.line_markdown import LineMarkdown -from toad.widgets.message_divider import SingleLineBlock +from toad.widgets.message_divider import ConversationLineBlock from toad.widgets.tool_content import ToolContent from toad.app_node import ToadNode -class ToolCallHeader(SingleLineBlock): +class ToolCallHeader(ConversationLineBlock): ALLOW_SELECT = False DEFAULT_CSS = """ ToolCallHeader { From 9672035414964e1b1c38f77fe94355bb01bf163f Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sat, 31 Oct 2026 19:31:39 -0400 Subject: [PATCH 11/17] Inherit the cursor grouping and direction contract once --- src/toad/widget_actions.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/toad/widget_actions.py b/src/toad/widget_actions.py index 9bda01d21..9e1e28a39 100644 --- a/src/toad/widget_actions.py +++ b/src/toad/widget_actions.py @@ -295,10 +295,14 @@ async def apply(self, context: DismissibleControl) -> None: context.dismiss_control() -class HorizontalCursorAction(VisibleAction, declared_name=None): +class CursorAction(VisibleAction, declared_name=None): + """Select along the widget's cursor axis.""" + group = Binding.Group("Select") direction: ClassVar[Literal[-1, 1]] + +class HorizontalCursorAction(CursorAction, declared_name=None): async def apply(self, context: MovesHorizontalCursor) -> None: context.cursor_horizontal(self.direction) @@ -313,10 +317,7 @@ class CursorRightAction(HorizontalCursorAction): direction = +1 -class VerticalCursorAction(VisibleAction, declared_name=None): - group = HorizontalCursorAction.group - direction: ClassVar[Literal[-1, 1]] - +class VerticalCursorAction(CursorAction, declared_name=None): async def apply(self, context: MovesVerticalCursor) -> None: context.cursor_vertical(self.direction) From a834afabd933595be7b0fb6912527eb0333a7b9b Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sat, 31 Oct 2026 19:43:04 -0400 Subject: [PATCH 12/17] Own sidebar content clearance once and preserve resize minimums --- src/toad/sidebar_layout.py | 7 +- src/toad/widgets/side_bar.py | 81 ++++++++++++----------- tests/model/test_sidebar_layout_policy.py | 11 +++ tests/textual/test_sidebar_projection.py | 2 +- 4 files changed, 60 insertions(+), 41 deletions(-) diff --git a/src/toad/sidebar_layout.py b/src/toad/sidebar_layout.py index e187a987d..e2b2c6f09 100644 --- a/src/toad/sidebar_layout.py +++ b/src/toad/sidebar_layout.py @@ -67,7 +67,7 @@ def opposite(cls) -> type[SidebarEdge]: return next(edge for edge in SidebarEdge.members_with(cls.axis()) if edge is not cls) @classmethod - def controls_margin(cls) -> Spacing: + def content_padding(cls) -> Spacing: return cls.opposite().gutter(cls.collapsed_extent + 1) @classmethod @@ -419,8 +419,11 @@ def resolve(self, viewport: Size, collapsed: dict[str, bool]) -> ResolvedSidebar wanted = sum(extents[key] for key in expanded) available = max(0, budget - handles) if wanted > available: + minimum = min(axis.minimum, available // len(expanded)) + surplus = available - minimum * len(expanded) + demand = wanted - minimum * len(expanded) for key in expanded: - extents[key] = available * extents[key] // wanted + extents[key] = minimum + surplus * (extents[key] - minimum) // demand for key in expanded[:available - sum(extents[key] for key in expanded)]: extents[key] += 1 if handles > total: diff --git a/src/toad/widgets/side_bar.py b/src/toad/widgets/side_bar.py index deb6f8644..b645b04ad 100644 --- a/src/toad/widgets/side_bar.py +++ b/src/toad/widgets/side_bar.py @@ -183,7 +183,7 @@ def apply(self, context: SideBarToggle) -> None: class SideBarToggle(HoverSelection, DeclaredWidgetActions, PrimaryActivation, widgets.Static, actions=(ToggleSidebarAction,)): - """Full-height collapse target beside the conversation-facing resize edge.""" + """Collapse target spanning the conversation-facing resize edge.""" @property def paint_highlighted(self): @@ -506,7 +506,7 @@ def activate(self) -> None: class SidebarResizeHandle( MovesHorizontalCursor, MovesVerticalCursor, CapturedDrag, ColourSelection, widgets.Static, ToadNode, can_focus=True ): - """Drag the conversation-facing edge; the placement model owns the width.""" + """Drag the conversation-facing edge; the placement model owns the extent.""" highlight_foreground = ForegroundColour @@ -575,6 +575,35 @@ async def apply(self, sidebar: SideBar) -> None: sidebar.search_input.focus() +class SidebarContentStack(containers.Vertical): + """One padded flow for content; padding never intercepts the outer handles.""" + + hit_test_transparent = True + + DEFAULT_CSS = """ + SidebarContentStack { + width: 1fr; height: 1fr; layer: sidebar-content; + overflow: hidden hidden; + } + SidebarContentStack > SidebarViewport { + width: 1fr; + height: 1fr; + overflow: auto auto; + scrollbar-size: 1 1; + scrollbar-gutter: stable; + } + SidebarContentStack > #sidebar-controls { + width: 1fr; + height: auto; + } + SidebarContentStack #sidebar-layout-actions { + height: auto; width: 1fr; overflow: hidden hidden; + grid-size: 2; grid-columns: 1fr 1fr; grid-rows: 1; + } + SidebarContentStack #sidebar-float { column-span: 2; width: 1fr; } + """ + + class SideBar(DismissibleControl, WorkspaceHosted, TonePaint, CoreEventReceiver, containers.Vertical, ToadNode, actions=(SearchSidebarAction,)): tone = SidebarToneColour @@ -590,24 +619,6 @@ class SideBar(DismissibleControl, WorkspaceHosted, TonePaint, CoreEventReceiver, width: auto; border: none; } - SideBar > SidebarViewport { - width: 1fr; - height: 1fr; - layer: sidebar-content; - overflow: auto auto; - scrollbar-size: 1 1; - scrollbar-gutter: stable; - } - SideBar > #sidebar-controls { - width: 1fr; - height: auto; - layer: sidebar-content; - } - SideBar #sidebar-layout-actions { - height: auto; width: 1fr; overflow: hidden hidden; - grid-size: 2; grid-columns: 1fr 1fr; grid-rows: 1; - } - SideBar #sidebar-float { column-span: 2; width: 1fr; } """ collapsed = reactive(False) @@ -638,8 +649,8 @@ def __init__( ) -> None: super().__init__(name=name, id=id, classes=classes, disabled=disabled) self.panels: list[SideBar.Panel] = [*panels] + self.content_stack = SidebarContentStack() self.search_input = SidebarSearch(placeholder="Search", compact=True) - self.search_input.styles.layer = "sidebar-content" self.panels_viewport: SidebarViewport = SidebarViewport() self.hide = hide self.extent_slider = SidebarSlider( @@ -739,13 +750,14 @@ def on_unmount(self) -> None: def compose(self) -> ComposeResult: yield SideBarToggle(self.collapsed, edge=self.placement.side) yield SidebarResizeHandle(self.placement.side) - yield self.search_input - with self.panels_viewport: - if self._panels_loaded: - yield from self._compose_panels() - with containers.Vertical(id="sidebar-controls"): - if self._panels_loaded: - yield from self._compose_controls() + with self.content_stack: + yield self.search_input + with self.panels_viewport: + if self._panels_loaded: + yield from self._compose_panels() + with containers.Vertical(id="sidebar-controls"): + if self._panels_loaded: + yield from self._compose_controls() @on(widgets.Input.Changed) def search_changed(self, event: widgets.Input.Changed) -> None: @@ -805,9 +817,7 @@ def _present_controls(self) -> bool: slider = self.extent_slider if previous is None or previous.presentation != current.presentation: edge = placement.side - margin = edge.controls_margin() - self.search_input.styles.margin = margin - self.panels_viewport.styles.margin = margin + self.content_stack.styles.padding = edge.content_padding() if toggle := self.query_one_optional(SideBarToggle): toggle.set_collapsed(self.collapsed, edge) if handle := self.query_one_optional(SidebarResizeHandle): @@ -815,8 +825,6 @@ def _present_controls(self) -> bool: handle.styles.dock = edge.opposite().dock handle.display = not self.collapsed if controls is not None: - controls.styles.margin = margin - controls.display = not self.collapsed slider.reversed = edge.sign < 0 slider.set_range(SidebarLayout.minimum_percent, SidebarLayout.maximum_percent, placement.extent_percent) @@ -873,11 +881,8 @@ def watch_collapsed(self, collapsed: bool) -> None: if not collapsed and self.is_mounted: self.schedule_hydration() self._presented_collapsed = collapsed - # Panel visibility is independent of its native placement. - self.panels_viewport.display = not collapsed - self.search_input.display = not collapsed - if controls := self.query_one_optional("#sidebar-controls"): - controls.display = not collapsed + # One content flow hides without retiring its retained children. + self.content_stack.display = not collapsed self.tooltip = "Expand sidebar" if collapsed else None toggle = self.query_one_optional(SideBarToggle) if toggle is not None: diff --git a/tests/model/test_sidebar_layout_policy.py b/tests/model/test_sidebar_layout_policy.py index 5b737eb8a..e35bbd1b8 100644 --- a/tests/model/test_sidebar_layout_policy.py +++ b/tests/model/test_sidebar_layout_policy.py @@ -78,6 +78,17 @@ def test_float_keeps_order_and_swap_keeps_extents(side, viewport, hide_channels, }, "Swap must not change extents through order-dependent rounding" +@pytest.mark.parametrize("side, viewport", list(product(EDGES, (Size(51, 27), Size(36, 18))))) +def test_shrinking_preserves_each_axis_minimum_when_both_controls_fit(side, viewport): + layout = SidebarLayout() + layout.move("channels-sidebar", side) + layout.move("thread-sidebar", side.opposite()) + layout.resize("channels-sidebar", layout.maximum_percent) + layout.resize("thread-sidebar", layout.minimum_percent) + resolved = layout.resolve(viewport, {key: False for key in layout.placements}) + assert all(side.extent(bar.size) >= side.minimum for bar in resolved.bars.values()) + + @pytest.mark.parametrize("channels_edge, thread_edge", list(product(EDGES, repeat=2))) def test_every_edge_combination_stays_inside_native_viewport(channels_edge, thread_edge): layout = SidebarLayout() diff --git a/tests/textual/test_sidebar_projection.py b/tests/textual/test_sidebar_projection.py index 158c730cf..88efa66ac 100644 --- a/tests/textual/test_sidebar_projection.py +++ b/tests/textual/test_sidebar_projection.py @@ -24,7 +24,7 @@ def _display(self, screen, renderable): sidebar = screen.query_one_optional("#channels-sidebar", SideBar) if sidebar is not None: self.frames.append((self.selected_mode, sidebar.collapsed, sidebar.size.width, - sidebar.panels_viewport.display)) + sidebar.content_stack.display)) return result From 971a1011d782bc5d2468d34e325003daa28bbf8d Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sat, 31 Oct 2026 19:55:04 -0400 Subject: [PATCH 13/17] Resize from native parent geometry without percentage round trips --- src/toad/widgets/side_bar.py | 10 ++++++---- tests/textual/test_sidebar_drag_resize.py | 21 ++++++++++++++++++++- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/src/toad/widgets/side_bar.py b/src/toad/widgets/side_bar.py index b645b04ad..99d999890 100644 --- a/src/toad/widgets/side_bar.py +++ b/src/toad/widgets/side_bar.py @@ -529,8 +529,10 @@ def render(self) -> str: def drag_to(self, position: Offset) -> None: edge = self._drag_edge - extent = self._start_extent + edge.sign * edge.coordinate(position - self._start_point) - self.query_ancestor(SideBar).resize_to(round(100 * extent / max(1, self._drag_viewport))) + delta = edge.sign * edge.coordinate(position - self._start_point) + self.query_ancestor(SideBar).resize_to( + self._start_percent + round(100 * delta / max(1, self._drag_viewport)) + ) def resize_by(self, direction: Offset) -> None: bar = self.query_ancestor(SideBar) @@ -547,8 +549,8 @@ def drag_start(self, event: events.MouseDown) -> None: bar = self.query_ancestor(SideBar) self._drag_edge = bar.placement.side self._start_point = event.screen_offset - self._start_extent = self._drag_edge.extent(bar.size) - self._drag_viewport = self._drag_edge.extent(bar.screen.size) + self._start_percent = bar.placement.extent_percent + self._drag_viewport = self._drag_edge.extent(bar.query_ancestor(Widget).content_size) class ClearSidebarSearchAction(WidgetAction): diff --git a/tests/textual/test_sidebar_drag_resize.py b/tests/textual/test_sidebar_drag_resize.py index 9a2dfa5a3..93ed4eefb 100644 --- a/tests/textual/test_sidebar_drag_resize.py +++ b/tests/textual/test_sidebar_drag_resize.py @@ -1,4 +1,4 @@ -"""Textual-facing: pointer capture, drag, hover styling and keyboard resize of a sidebar from either edge, with the width slider mirrored.""" +"""Textual-facing: captured resize, hover and keys; zero-motion release preserves intent on every edge.""" import pytest @@ -94,3 +94,22 @@ async def test_resize_by_edge_drag_slider_and_keys_follows_the_side(workspace, s bar.reveal() await ui.step() assert handle.display + + +@pytest.mark.parametrize("side", SidebarEdge.members_with(SidebarEdge)) +async def test_zero_motion_resize_keeps_requested_percentage_on_every_edge(workspace, side): + async with workspace.app(size=(51, 27)) as ui: + app, pilot = ui.textual + bar = app.screen.query_one("#channels-sidebar", SideBar) + app.sidebar_layout.move(bar.id, side) + app.sidebar_layout.resize(bar.id, 40) + bar.reveal() + app.workspace_chrome.refresh_layout() + await ui.step() + handle = bar.query_one(SidebarResizeHandle) + before = bar.region + assert await pilot.click(handle) + await ui.step() + assert bar.placement.extent_percent == 40 + assert bar.region == before + assert not handle._dragging and app.mouse_captured is None From f0cd49a4679bf3fc559e91dcb4411b088632ddb2 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sat, 31 Oct 2026 22:11:32 -0400 Subject: [PATCH 14/17] Own session sidebars before native composition and through retirement --- src/toad/screens/comms.py | 39 +++++++++----------- src/toad/screens/main.py | 14 +++---- src/toad/screens/session_view.py | 27 ++++++++++++++ src/toad/workspace_chrome.py | 6 +-- tests/textual/test_initial_prompt_handoff.py | 5 +++ 5 files changed, 58 insertions(+), 33 deletions(-) diff --git a/src/toad/screens/comms.py b/src/toad/screens/comms.py index 2a21c160b..c000c54f2 100644 --- a/src/toad/screens/comms.py +++ b/src/toad/screens/comms.py @@ -2,7 +2,7 @@ from toad.core_event_carrier import CoreEventMessage from toad.core import events as core_events from toad.core import session_requests -from toad.screens.session_view import SessionView +from toad.screens.session_view import SidebarSessionView from toad.project_path_owner import CheckoutRoot import asyncio from functools import partial @@ -24,7 +24,6 @@ from toad.widgets.comms_chat import CommsChatView from toad.core.input_events import SelectHistoricalIdentity from toad.widgets.comms_sidebar import CoordinationStatus, CommsSidebar -from toad.session_tracker import SidebarState from toad.navigation_context import NavigationContext from toad.navigation_target import HistoryTarget, NavigationTarget, SelectTarget from toad.widgets.session_tabs import SessionsTabs @@ -78,7 +77,7 @@ def apply(self, context: CommsScreen) -> None: chat.prepare_prompt() -class CommsScreen(TogglesIrc, CyclesSessions, CheckoutRoot, SessionView, ToadNode, can_focus=False, +class CommsScreen(TogglesIrc, CyclesSessions, CheckoutRoot, SidebarSessionView[ThreadSidebar], ToadNode, can_focus=False, actions=(ViewAction, ReturnToViewAction, CloseViewAction)): """A channel or DM represented as a native concurrent Toad session.""" @@ -102,7 +101,6 @@ def __init__( self._identity = SessionIdentity.for_thread(me, owner_root) self.conversation = conversation self.wire_root = wire_root - self._thread_sidebar_state = SidebarState() self._content_ready = asyncio.Event() self._content_error: BaseException | None = None self._content_loaded = False @@ -143,9 +141,21 @@ def rebind_project(self, project: Checkout) -> None: chat.project_path = project chat.working_directory = str(project) - def sidebars(self): - sidebar = self.query_one_optional(ThreadSidebar) - return (sidebar,) if sidebar is not None else () + def make_thread_sidebar(self) -> ThreadSidebar: + return ThreadSidebar( + SideBar.Panel( + "Connection", + CoordinationStatus(), + id="coordination-panel", + ), + SideBar.Panel( + "Comms", + ThreadCommsSidebar(), + id="thread-comms-panel", + header_control=RelationshipSort(), + ), + state=self._thread_sidebar_state, + ) def compose(self) -> ComposeResult: content = ( @@ -156,20 +166,7 @@ def compose(self) -> ComposeResult: ) ) yield containers.Center( - ThreadSidebar( - SideBar.Panel( - "Connection", - CoordinationStatus(), - id="coordination-panel", - ), - SideBar.Panel( - "Comms", - ThreadCommsSidebar(), - id="thread-comms-panel", - header_control=RelationshipSort(), - ), - state=self._thread_sidebar_state, - ), + self.thread_sidebar, containers.Vertical(*content, id="comms-content"), ).set_styles(layout=self.app.workspace_chrome) diff --git a/src/toad/screens/main.py b/src/toad/screens/main.py index 85b263577..18853344c 100644 --- a/src/toad/screens/main.py +++ b/src/toad/screens/main.py @@ -46,10 +46,10 @@ ThreadTarget, ) from toad.screens.mcp_inventory import MCPInventoryScreen -from toad.screens.session_view import SessionView +from toad.screens.session_view import SidebarSessionView from toad.session_presentation import OperationalSessionPresentation from toad.session_scope import AgentBinding, NoAgent, ViewSessionOrigin -from toad.session_tracker import SessionDetails, SidebarState +from toad.session_tracker import SessionDetails from toad.tab_identity.history_origin import SessionHistoryOrigin from toad.thread_identity import NoThreadIdentity, ThreadIdentity from toad.widgets.channels_sidebar import ChannelsSidebar @@ -294,7 +294,7 @@ async def attached(self, screen: "MainScreen") -> None: await screen.prepare_presentation() -class MainScreen(NativeMethodKeys, HiddenActions, TogglesIrc, CyclesSessions, ViewSessionOrigin, SessionView, ToadNode, +class MainScreen(NativeMethodKeys, HiddenActions, TogglesIrc, CyclesSessions, ViewSessionOrigin, SidebarSessionView[SessionThreadSidebar], ToadNode, scopes={HiddenActions: (SessionNavigationAction,)}, can_focus=False): footer_compact = True @@ -310,7 +310,6 @@ class MainScreen(NativeMethodKeys, HiddenActions, TogglesIrc, CyclesSessions, Vi def keyboard_scopes(self) -> tuple[DeclaredWidgetActions, ...]: return self.content.keyboard_scopes(self) - thread_sidebar = getters.query_one(SessionThreadSidebar) @property def side_bar(self): @@ -332,7 +331,6 @@ def __init__( self._identity = binding.initial_identity(NoThreadIdentity()) self._session_pk = session_pk self._initial_prompt = initial_prompt - self._thread_sidebar_state = SidebarState() self._project_panel: ProjectPanel | None = None self.content: ScreenContent = OpeningContent() self.presentation = OperationalSessionPresentation() @@ -436,12 +434,12 @@ def activate_session(self) -> None: if self._project_panel is not None: self.call_after_refresh(self._project_panel.refresh_if_visible) - def sidebars(self): - return (self.thread_sidebar,) + def make_thread_sidebar(self) -> SessionThreadSidebar: + return SessionThreadSidebar(self) def compose(self) -> ComposeResult: with containers.Center().set_styles(layout=self.app.workspace_chrome): - yield SessionThreadSidebar(self) + yield self.thread_sidebar with containers.Vertical(id="session-content"): yield self.content.render(self) yield self.presentation.compose_content(self) diff --git a/src/toad/screens/session_view.py b/src/toad/screens/session_view.py index af91b4bda..04a94593c 100644 --- a/src/toad/screens/session_view.py +++ b/src/toad/screens/session_view.py @@ -1,5 +1,7 @@ """Logical session surfaces hosted by the one native WorkspaceScreen.""" +from abc import abstractmethod +from functools import cached_property from textual.containers import Vertical from textual.reactive import reactive from contextlib import contextmanager @@ -10,6 +12,8 @@ from toad.thread_identity import SessionIdentity, NoThreadIdentity from toad.session_scope import NoSessionOrigin, SessionOrigin, SessionScope from toad.navigation_target import NavigationTarget, NoTarget +from toad.session_tracker import SidebarState +from toad.widgets.side_bar import ThreadSidebar from toad.widget_actions import DeclaredWidgetActions from toad.widgets.message_notifications import ( NothingToRead, @@ -158,3 +162,26 @@ async def close_presentation(self) -> None: def retained_native_presentations(self): """Declare accepted native trees; non-native views own no such trees.""" return () + + +class SidebarSessionView[SidebarT: ThreadSidebar](SessionView): + """One session-owned sidebar survives construction, mounting and retirement. + + Composition mounts this resource; it does not create its identity. Chrome + may ask for membership before the native tree exists, and close still owns + the same resource after it has left that tree. + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._thread_sidebar_state = SidebarState() + + @abstractmethod + def make_thread_sidebar(self) -> SidebarT: ... + + @cached_property + def thread_sidebar(self) -> SidebarT: + return self.make_thread_sidebar() + + def sidebars(self) -> tuple[SidebarT, ...]: + return (self.thread_sidebar,) diff --git a/src/toad/workspace_chrome.py b/src/toad/workspace_chrome.py index 826bf3a69..3516d7b79 100644 --- a/src/toad/workspace_chrome.py +++ b/src/toad/workspace_chrome.py @@ -47,7 +47,7 @@ def presented_sidebars(self): source = self.app.workspace_sessions.source layout = self.app.sidebar_layout return tuple(bar for bar in (self.channels, *source.sidebars()) - if bar.presentation_visible and bar.id in layout.placements) + if bar.is_mounted and bar.presentation_visible and bar.id in layout.placements) def toggle_sidebar(self, side: type[SidebarEdge]) -> None: layout = self.app.sidebar_layout @@ -79,15 +79,13 @@ def arrange( self, parent: Widget, children: list[Widget], size: Size, greedy: bool = True ) -> ArrangeResult: """Place chrome and conversation directly in the native layout pass.""" - source = self.app.workspace_sessions.source layout = self.app.sidebar_layout - own = tuple(source.sidebars()) bars = self.presented_sidebars() resolved = layout.resolve(size, {bar.id: bar.collapsed for bar in bars}) # The outer host stays full width. Its session's own native layout # clears both gutters, including the shared channel surface. nested = parent is self.channels.parent and any( - bar.parent.layout is self for bar in own + bar.parent is not parent and bar.parent.layout is self for bar in bars ) gutter = Spacing() if nested else resolved.gutter placements = [ diff --git a/tests/textual/test_initial_prompt_handoff.py b/tests/textual/test_initial_prompt_handoff.py index 9f1151b00..fd8ce95d4 100644 --- a/tests/textual/test_initial_prompt_handoff.py +++ b/tests/textual/test_initial_prompt_handoff.py @@ -42,7 +42,10 @@ async def test_a_pending_initial_prompt_is_published_once_across_eviction_and_th async with workspace.app(size=(110, 35)) as ui: app, _pilot = ui.textual owner = ObservedLaunchScreen(workspace.project, initial_prompt="INITIAL_LOCAL_UI_EVENT") + sidebar = owner.thread_sidebar + assert owner.sidebars() == (sidebar,) and not sidebar.is_mounted await app.session_navigation.new(await saved_tab(app), lambda: owner) + assert owner.thread_sidebar is sidebar await owner.wait_content_ready() await asyncio.wait_for(owner.publication.wait(), 12) await ui.step() @@ -61,6 +64,7 @@ async def test_a_pending_initial_prompt_is_published_once_across_eviction_and_th await app.session_navigation.new(await saved_tab(app), lambda: MainScreen(app.project_dir)) await app.selected_session.wait_content_ready() await owner.presentation.evict() + assert owner.thread_sidebar is sidebar assert view._initial_prompt is None assert owner.presentation.state.initial_prompt == "NEWEST_PENDING_LOCAL_UI_EVENT" assert owner.presentation.widget is None @@ -91,6 +95,7 @@ async def test_a_pending_initial_prompt_is_published_once_across_eviction_and_th assert owner.inputs == {"INITIAL_LOCAL_UI_EVENT": 1, "NEWEST_PENDING_LOCAL_UI_EVENT": 1} assert isinstance(owner.conversation.agent_binding, NoAgentConnection) and not owner.conversation.submissions.active await app.session_navigation.close(owner.id) + assert owner.thread_sidebar is sidebar assert owner.presentation.widget is None and owner.presentation.state is None assert owner.id not in app.workspace_sessions.views assert ui.failure() is None From 3de4ec3cae302cb63e1b73116e5a1f3202efe827 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sat, 31 Oct 2026 22:31:51 -0400 Subject: [PATCH 15/17] Derive sidebar presentation from current native attachment --- src/toad/widgets/side_bar.py | 41 ++++++++++---------- src/toad/workspace_chrome.py | 7 +--- tests/textual/test_initial_prompt_handoff.py | 2 + 3 files changed, 24 insertions(+), 26 deletions(-) diff --git a/src/toad/widgets/side_bar.py b/src/toad/widgets/side_bar.py index 99d999890..695820116 100644 --- a/src/toad/widgets/side_bar.py +++ b/src/toad/widgets/side_bar.py @@ -697,13 +697,16 @@ def on_mount(self) -> None: self.schedule_hydration() def schedule_hydration(self) -> None: - if not self._panels_loaded and not self._panels_loading: + if not self._panels_loaded and not self._panels_loading and self.presentation_visible: self.screen.frame_presentation.defer(self, self._start_hydration) @property def presentation_visible(self) -> bool: + """Native attachment owns whether this retained resource may present.""" return ( - self.screen.is_current + self.is_attached + and not self.is_leaving + and self.screen.is_current and self.display and all( ancestor.display @@ -716,9 +719,7 @@ def _start_hydration(self) -> None: if ( self._panels_loaded or self._panels_loading - or not self.is_attached or not self.presentation_visible - or self.is_leaving ): return self._panels_loading = True @@ -727,12 +728,12 @@ def _start_hydration(self) -> None: async def _hydrate_panels(self) -> None: try: await self.panels_viewport.mount_all(self._compose_panels()) - if not self.is_attached or self.is_leaving or not self.presentation_visible: + if not self.presentation_visible: return controls = self.query_one_optional("#sidebar-controls") if controls is not None: await controls.mount_all(self._compose_controls()) - if self.is_attached and not self.is_leaving and self.presentation_visible: + if self.presentation_visible: self._panels_loaded = True self._presented_layout = None await self.panels_hydrated() @@ -794,15 +795,13 @@ def on_resize(self, event: events.Resize) -> None: self._present_controls() def on_show(self) -> None: - self._present_controls() + self.restore_navigation() def _present_controls(self) -> bool: - if not self.is_mounted: - return False - placement = self.placement - if self.parent is None or not self.presentation_visible: + if not self.presentation_visible: self._presented_layout = None return False + placement = self.placement directions = self.app.sidebar_layout.directions(self.id) current = PresentedBar( presentation=BarPresentation.from_placement( @@ -875,12 +874,12 @@ def restore_navigation(self) -> bool: return changed def watch_collapsed(self, collapsed: bool) -> None: - if self.is_mounted and not self.presentation_visible: - # Keep shared intent current without resizing every hidden transcript. - # Activation applies the latest value inside its render transaction; - # an open/close round trip can therefore keep unchanged geometry. + if not self.presentation_visible: + # Keep shared intent current without native work before attachment, + # while hidden or during retirement. Activation applies the latest + # value inside its render transaction. return - if not collapsed and self.is_mounted: + if not collapsed: self.schedule_hydration() self._presented_collapsed = collapsed # One content flow hides without retiring its retained children. @@ -893,9 +892,8 @@ def watch_collapsed(self, collapsed: bool) -> None: for panel in self.panels: panel.widget.sidebar_visibility_changed() self._presented_layout = None - if self.is_mounted: - self._present_controls() - self.app.workspace_chrome.refresh_layout() + self._present_controls() + self.app.workspace_chrome.refresh_layout() def render(self) -> str: return self.placement.side.toggle_symbol(self.collapsed) if self.collapsed else "" @@ -918,12 +916,13 @@ def on_click(self, event: Click) -> None: @on(events.Enter) @on(events.Leave) def gutter_hover_changed(self) -> None: - self.query_one(SideBarToggle).refresh_paint() + if self.presentation_visible: + self.query_one(SideBarToggle).refresh_paint() def toggle(self, *, focus: bool = True) -> None: collapsed = not self.collapsed return_focus = collapsed and (focus or self.has_focus_within) - if return_focus and self.is_mounted and self.screen.is_current: + if return_focus and self.presentation_visible: # Return input focus before native Hide reconstructs its focus chain. self.screen.focus_sidebar_return() self.store_collapsed(collapsed) diff --git a/src/toad/workspace_chrome.py b/src/toad/workspace_chrome.py index 3516d7b79..a168070b4 100644 --- a/src/toad/workspace_chrome.py +++ b/src/toad/workspace_chrome.py @@ -47,7 +47,7 @@ def presented_sidebars(self): source = self.app.workspace_sessions.source layout = self.app.sidebar_layout return tuple(bar for bar in (self.channels, *source.sidebars()) - if bar.is_mounted and bar.presentation_visible and bar.id in layout.placements) + if bar.presentation_visible and bar.id in layout.placements) def toggle_sidebar(self, side: type[SidebarEdge]) -> None: layout = self.app.sidebar_layout @@ -69,10 +69,7 @@ async def prepare_navigation(self, screen) -> bool: def refresh_layout(self) -> None: """Placement changes retire both native parent arrangements.""" - source = self.app.workspace_sessions.source - for parent in { - bar.parent for bar in (self.channels, *source.sidebars()) if bar.is_mounted - }: + for parent in {bar.parent for bar in self.presented_sidebars()}: parent.refresh(repaint=False, layout=True) def arrange( diff --git a/tests/textual/test_initial_prompt_handoff.py b/tests/textual/test_initial_prompt_handoff.py index fd8ce95d4..5840bcf69 100644 --- a/tests/textual/test_initial_prompt_handoff.py +++ b/tests/textual/test_initial_prompt_handoff.py @@ -44,6 +44,7 @@ async def test_a_pending_initial_prompt_is_published_once_across_eviction_and_th owner = ObservedLaunchScreen(workspace.project, initial_prompt="INITIAL_LOCAL_UI_EVENT") sidebar = owner.thread_sidebar assert owner.sidebars() == (sidebar,) and not sidebar.is_mounted + assert not sidebar.presentation_visible await app.session_navigation.new(await saved_tab(app), lambda: owner) assert owner.thread_sidebar is sidebar await owner.wait_content_ready() @@ -96,6 +97,7 @@ async def test_a_pending_initial_prompt_is_published_once_across_eviction_and_th assert isinstance(owner.conversation.agent_binding, NoAgentConnection) and not owner.conversation.submissions.active await app.session_navigation.close(owner.id) assert owner.thread_sidebar is sidebar + assert not sidebar.presentation_visible assert owner.presentation.widget is None and owner.presentation.state is None assert owner.id not in app.workspace_sessions.views assert ui.failure() is None From 620955b46b143afb7c35c0f8266d1b4796a212dd Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 1 Nov 2026 03:25:18 -0500 Subject: [PATCH 16/17] Verify physical runtime builds at the frontend resource lifetime --- docs/performance/physical-run.md | 44 +++++++++++- tests/model/test_physical_run.py | 118 +++++++++++++++++++++++++++++++ tools/physical_ui.py | 26 ++++--- tools/physical_ui_remote.py | 81 ++++++++++++++++----- 4 files changed, 242 insertions(+), 27 deletions(-) create mode 100644 tests/model/test_physical_run.py diff --git a/docs/performance/physical-run.md b/docs/performance/physical-run.md index 3d04cc3b7..2fe37a344 100644 --- a/docs/performance/physical-run.md +++ b/docs/performance/physical-run.md @@ -75,11 +75,45 @@ acceptance and actual borrower checks. The current source requires a `RuntimeResolution` with a recorded interpreter observation, not just a prefix and three module locations. `RunTarget` retains -that intended build; `ObserveOperation` obtains the actual process's resolution -through the same target-owned probe before `RunTarget.require` permits input. +that intended build. `AdmitOperation(ObserveOperation)` obtains the actual +process's resolution once at frontend admission through the target-owned probe. +Its `AdmittedResult` belongs to that exact frontend birth; a plain +`ObservedResult` cannot substitute for it. `RunObservation` describes current UI +resources, not a freshly hashed build. An old build record without interpreter evidence is explicitly unrecorded and refuses admission. It is not retrofitted by reading its intended executable. +`TerminalApp` owns the admission result with its frontend resource. Every later +observation still checks the actual process, runtime, module origins, HOME/XDG, +database, palette, selected/opened source and all saved/ACP roots before keys. +The declared `FrontendIdentity` groups process birth and primary module-file +paths; equality is derived from that record rather than restating its fields in +two comparison tuples. It must agree with the admission. +Only full wheel/native hashing leaves that repeated callback; there is no +permission flag or product-widget cache replacing those current checks. + +A normal exit performs full build verification again and requires equality with +the admitted snapshot before quitting. `frontend/build-admitted.json` and +`frontend/build-final.json` retain the two build observations; ordinary operation +results and `admitted.json` remain current UI/source observations. An exceptional +or uncertain operation still retires without another attachment or input. + +The selected candidate must remain immutable during the run. Entry/final disk +checks do not prove that arbitrary intermediate disk mutations were impossible, +and a final refusal cannot undo earlier input. This does not claim continuous +content hashing or complete loaded-code equivalence. Process/root/module-origin +and saved/ACP-root invalidation remain current before every key. + +Current observations also carry the actual typed `StorageRule` from the already +observing service's `Registration`. The rule must equal its own `within(root)` +projection; observation neither constructs a Comms nor writes a policy. This +checks declared allocation/default scope, not private HOME alone. Canonical +filesystem containment, registered allocations, persisted reopen and future-child +qualification remain separate prerequisites owned by the isolation work. In +particular, a `checkouts` symlink can defeat equality-only projection in the +current Core owner; that counterexample is handed to the isolation owner, not +silently counted as qualified. + `WheelRuntime` retains copies of its exact inputs under the new prefix's `inputs/` directory; borrowed originals remain read-only. The package probe compares every root-wheel member except the installer's rewritten `RECORD` @@ -114,6 +148,12 @@ visible pixels. Neither an import nor one screenshot proves the whole journey. A run with no ACP bindings does not demonstrate the bound/pending/foreign ACP cases. +The new admission/resource-lifetime split has written model controls for build +admission versus ordinary observation, changed build/process/resources/module +paths, new saved/ACP roots, actual storage policy and source readiness after admission. These controls +are not yet executed. The source/codec checks and eventual candidate preparation +must not be reported as physical or performance acceptance. + Source-observer work is part of the measurement overhead. For example, one prototype observation took about 97 ms inside the callback before considering its compilation/dispatch cost. Do not report that overhead as production frame diff --git a/tests/model/test_physical_run.py b/tests/model/test_physical_run.py new file mode 100644 index 000000000..186ac34cc --- /dev/null +++ b/tests/model/test_physical_run.py @@ -0,0 +1,118 @@ +"""Admission and current-resource contracts; no frontend, provider or private bus.""" +from dataclasses import replace +from pathlib import Path + +import pytest + +from agent_comms.child_process import ProcessIdentity +from agent_comms.installer.probes.interpreter import InterpreterBuildProbe +from agent_comms.installer.runtime_resolution import RuntimeResolution +from agent_comms.storage_rules import StorageRule +from tools.physical_ui_remote import ( + AdmittedResult, FrontendIdentity, ObservedResult, PendingRun, RunObservation, RunTarget, +) + + +@pytest.fixture +def physical_target(tmp_path: Path) -> RunTarget: + """Synthetic resource paths and metadata, not an application admission proof.""" + root, home, runtime = (tmp_path / name for name in ("bus", "frontend-home", "runtime")) + for path in (root, home, runtime): + path.mkdir() + build = RuntimeResolution( + core_commit="a" * 40, packages={}, interpreter=InterpreterBuildProbe().run(), + ) + return RunTarget(root, home, runtime, build) + + +@pytest.fixture +def physical_observation(physical_target: RunTarget) -> RunObservation: + target = physical_target + return RunObservation( + frontend=FrontendIdentity( + ProcessIdentity.current(), target.runtime / "toad/__init__.py", + target.runtime / "agent_comms/__init__.py", target.runtime / "textual/__init__.py", + ), + started_ns=1, finished_ns=2, runtime=target.runtime, home=target.home, + config=target.config, state=target.state, data=target.data, + database=target.state / "toad.db", palette_directory=target.palette, + root=target.root, opened=True, storage_rule=StorageRule().within(target.root), + saved_roots=(target.root,), + acp_roots=(target.root,), history_regions=(), modules_checked=3, + ) + + +@pytest.fixture +def physical_admission( + physical_target: RunTarget, physical_observation: RunObservation, +) -> AdmittedResult: + return AdmittedResult(physical_observation, physical_target.build).admitted(physical_target) + + +def test_current_observation_cannot_substitute_for_build_admission( + physical_target: RunTarget, physical_observation: RunObservation, physical_admission: AdmittedResult, +) -> None: + with pytest.raises(ValueError, match="did not verify the runtime build"): + ObservedResult(physical_observation).admitted(physical_target) + assert physical_admission.observe(physical_target, physical_observation) == physical_observation + + +def test_admission_refuses_different_build_and_frontend_birth( + physical_target: RunTarget, physical_observation: RunObservation, physical_admission: AdmittedResult, +) -> None: + different = physical_target.build.model_copy(update={"core_commit": "b" * 40}) + with pytest.raises(ValueError): + AdmittedResult(physical_observation, different).admitted(physical_target) + original = physical_observation.frontend + replacement = replace(original, process=replace(original.process, pid=original.process.pid + 1)) + with pytest.raises(ValueError, match="frontend process or module-file locations changed"): + physical_admission.observe(physical_target, replace(physical_observation, frontend=replacement)) + + +@pytest.mark.parametrize("field", ( + "runtime", "home", "config", "state", "data", "database", "palette_directory", "root", +)) +def test_admission_does_not_cache_permission_for_changed_resources( + field: str, tmp_path: Path, physical_target: RunTarget, + physical_observation: RunObservation, physical_admission: AdmittedResult, +) -> None: + current = replace(physical_observation, **{field: tmp_path / "foreign"}) + with pytest.raises(ValueError): + physical_admission.observe(physical_target, current) + + +@pytest.mark.parametrize("field", ("toad_module", "core_module", "textual_module")) +def test_changed_primary_module_location_invalidates_admission( + field: str, tmp_path: Path, physical_target: RunTarget, + physical_observation: RunObservation, physical_admission: AdmittedResult, +) -> None: + changed = replace(physical_observation.frontend, **{field: tmp_path / "foreign"}) + with pytest.raises(ValueError, match="frontend process or module-file locations changed"): + physical_admission.observe(physical_target, replace(physical_observation, frontend=changed)) + + +@pytest.mark.parametrize("field", ("saved_roots", "acp_roots")) +def test_new_saved_and_acp_roots_are_checked_after_admission( + field: str, tmp_path: Path, physical_target: RunTarget, + physical_observation: RunObservation, physical_admission: AdmittedResult, +) -> None: + current = replace(physical_observation, **{field: (physical_target.root, tmp_path / "foreign")}) + with pytest.raises(ValueError, match="Foreign saved/ACP source"): + physical_admission.observe(physical_target, current) + + +def test_actual_source_policy_is_checked_after_admission( + tmp_path: Path, physical_target: RunTarget, + physical_observation: RunObservation, physical_admission: AdmittedResult, +) -> None: + foreign = physical_observation.storage_rule.within(tmp_path / "foreign") + current = replace(physical_observation, storage_rule=foreign) + with pytest.raises(ValueError, match="actual source storage policy"): + physical_admission.observe(physical_target, current) + + +def test_source_readiness_is_current_not_remembered_admission( + physical_target: RunTarget, physical_observation: RunObservation, physical_admission: AdmittedResult, +) -> None: + with pytest.raises(PendingRun): + physical_admission.observe(physical_target, replace(physical_observation, opened=False)) diff --git a/tools/physical_ui.py b/tools/physical_ui.py index 225937989..c4fe7bf3e 100644 --- a/tools/physical_ui.py +++ b/tools/physical_ui.py @@ -42,7 +42,7 @@ from ui_theme.wal import WalFile from physical_ui_remote import ( - CapturedRegion, ExecOperation, ObserveOperation, PendingRun, PhysicalOperation, + AdmitOperation, CapturedRegion, ExecOperation, ObserveOperation, PendingRun, PhysicalOperation, ProcessRetirement, RunObservation, RunTarget, StopRootOperation, ) @@ -389,7 +389,8 @@ def __enter__(self): self.read_grid() while True: try: - self.observe() + self.admission = self._observe(AdmitOperation).admitted(self.target) + PhysicalOperation.publish(self.output / "build-admitted.json", self.admission) break except PendingRun: self._require_starting(deadline) @@ -412,18 +413,16 @@ def _await_result(self, directory: Path, deadline: float) -> None: self._require_starting(deadline) time.sleep(.02) - def observe(self) -> RunObservation: + def _observe(self, operation: type[ObserveOperation]): self.terminal.platform.require(self.frontend) - operation = ObserveOperation(self.target, self.frontend, Path(__file__).resolve().parent) - script = self._packet(operation) + script = self._packet(operation(self.target, self.frontend, Path(__file__).resolve().parent)) # This controller is an ancestor of its st/frontend. No arbitrary PID, # helper privilege escalation, installed-file edit or source retry. sys.remote_exec(self.frontend.pid, str(script)) self._await_result(script.parent, time.monotonic() + PhysicalOperation.timeout) - observed = self.result(script.parent).observation() - if observed.process != self.frontend: + result = self.result(script.parent) + if result.observation().frontend.process != self.frontend: raise ValueError("Observation belongs to another frontend incarnation") - self.target.require(observed) self.terminal.platform.require(self.terminal.identity) self.server_identity() owner = subprocess.check_output( @@ -433,7 +432,11 @@ def observe(self) -> RunObservation: if int(owner.partition("=")[2].strip()) != self.terminal.identity.pid: raise ValueError("Window does not belong to this run's st frontend") self.read_grid() - return observed + return result + + def observe(self) -> RunObservation: + current = self._observe(ObserveOperation).observation() + return self.admission.observe(self.target, current) def input(self, *commands: str) -> RunObservation: observed = self.observe() @@ -595,6 +598,11 @@ def __exit__(self, exception_type, exception, traceback): # An exceptional/uncertain operation is not permission for another # attachment or input. Retire only the already owned child instead. if exception_type is None and self.terminal.process.poll() is None: + final = self._observe(AdmitOperation).admitted(self.target) + self.admission.observe(self.target, final.observation()) + if final.build != self.admission.build: + raise ValueError("The verified runtime build changed during the physical run") + PhysicalOperation.publish(self.output / "build-final.json", final) self.keys("ctrl+q") self.terminal.process.wait(timeout=5) finally: diff --git a/tools/physical_ui_remote.py b/tools/physical_ui_remote.py index af186396e..b8166e0b0 100644 --- a/tools/physical_ui_remote.py +++ b/tools/physical_ui_remote.py @@ -20,6 +20,7 @@ from agent_comms.launch_runtime import LaunchRuntime from agent_comms.installer.probes.resolution import RuntimeResolutionProbe from agent_comms.installer.runtime_resolution import RuntimeResolution +from agent_comms.storage_rules import StorageRule from agent_comms.nominal.declared_family import DeclaredFamily from agent_comms.nominal.field_codec import DeclaredWireType, FieldCodec from textual.geometry import Region @@ -74,8 +75,8 @@ def state(self) -> Path: def data(self) -> Path: return self.home / "data" - def require(self, observed): - self.build.require_runtime(observed.build) + def require_resources(self, observed): + """Current UI/source resources; build evidence belongs to AdmittedResult.""" if observed.home != self.home or observed.runtime != self.runtime: raise ValueError("Post-attachment HOME/runtime differs from the run target") if (observed.config, observed.state, observed.data) != (self.config, self.state, self.data): @@ -88,6 +89,8 @@ def require(self, observed): raise ValueError("Core selected a foreign source") if not observed.opened: raise PendingRun("Core has not opened the intended private source") + if observed.storage_rule != observed.storage_rule.within(self.root): + raise ValueError("The actual source storage policy escapes the private root") for root in (*observed.saved_roots, *observed.acp_roots): if root != self.root: raise ValueError(f"Foreign saved/ACP source refuses physical input: {root}") @@ -114,12 +117,21 @@ def from_wire(cls, data): @dataclass(frozen=True) -class RunObservation: +class FrontendIdentity: + """One process birth and its primary module-file locations.""" + process: ProcessIdentity + toad_module: Path + core_module: Path + textual_module: Path + + +@dataclass(frozen=True) +class RunObservation: + frontend: FrontendIdentity started_ns: int finished_ns: int runtime: Path - build: RuntimeResolution home: Path config: Path state: Path @@ -128,13 +140,11 @@ class RunObservation: palette_directory: Path root: Path opened: bool + storage_rule: StorageRule saved_roots: tuple[Path, ...] acp_roots: tuple[Path, ...] history_regions: tuple[CapturedRegion, ...] modules_checked: int - toad_module: Path - core_module: Path - textual_module: Path @dataclass(frozen=True) @@ -149,6 +159,9 @@ class PhysicalResult(DeclaredFamily, affix="Result"): def require(self): return self + def admitted(self, target: RunTarget) -> AdmittedResult: + raise ValueError("This physical operation did not verify the runtime build") + def observation(self) -> RunObservation: raise ValueError("This physical operation did not observe an application") @@ -164,6 +177,24 @@ def observation(self) -> RunObservation: return self.value +@dataclass(frozen=True) +class AdmittedResult(ObservedResult): + """Build evidence for one frontend birth, not a fresh hash on every UI read.""" + + build: RuntimeResolution + + def admitted(self, target: RunTarget) -> AdmittedResult: + target.build.require_runtime(self.build) + target.require_resources(self.value) + return self + + def observe(self, target: RunTarget, current: RunObservation) -> RunObservation: + if current.frontend != self.value.frontend: + raise ValueError("The admitted frontend process or module-file locations changed") + # Dynamic persistence and every saved/ACP root still precede each input. + return target.require_resources(current) + + @dataclass(frozen=True) class ExecResult(PhysicalResult): identity: ProcessIdentity @@ -273,6 +304,9 @@ class ObserveOperation(PhysicalOperation): identity: ProcessIdentity tools: Path + def observed_result(self, observed: RunObservation) -> PhysicalResult: + return ObservedResult(observed) + def execute(self, directory: Path) -> None: started = time.monotonic_ns() if ProcessIdentity.current() != self.identity: @@ -332,6 +366,11 @@ def coordinated(self, value): checked = require_modules(self.target.runtime) access = app.coordination_access root = access.selection.root + observation = access.observation + opened = observation.observes(root) + if not opened: + raise PendingRun("Core has not opened the intended private source") + storage_rule = observation.service.registry.storage_rule sessions = app.session_navigation saved = tuple(Path(source).resolve() for entry in sessions.members for source, _identity in (*entry.original_threads(sessions), *entry.original_channels())) @@ -343,20 +382,30 @@ def coordinated(self, value): # from sidebar separators that may also occur inside a user's message. regions = tuple(CapturedRegion(region) for window in screen.query(HistoryWindow) for region in (window.published_content_region,) if region is not None) - build = RuntimeResolutionProbe( - self.target.build.core_commit, self.target.build.wheel_inputs() - ).run() + frontend = FrontendIdentity( + self.identity, Path(toad.__file__).resolve(), Path(agent_comms.__file__).resolve(), + Path(textual.__file__).resolve(), + ) observed = RunObservation( - self.identity, started, time.monotonic_ns(), Path(sys.prefix).resolve(), build, + frontend, started, time.monotonic_ns(), Path(sys.prefix).resolve(), Path.home().resolve(), xdg_config_home().resolve(), xdg_state_home().resolve(), xdg_data_home().resolve(), app.tab_order._db.path.resolve(), app.colours.wal_directory.resolve(), root, - access.observation.observes(root), saved, acp, regions, checked, - Path(toad.__file__).resolve(), Path(agent_comms.__file__).resolve(), - Path(textual.__file__).resolve(), + opened, storage_rule, saved, acp, regions, checked, ) - self.target.require(observed) - self.publish(directory / "result.json", ObservedResult(observed)) + self.target.require_resources(observed) + self.publish(directory / "result.json", self.observed_result(observed)) + + +@dataclass(frozen=True) +class AdmitOperation(ObserveOperation): + """Full disk-artifact verification at entry and successful retirement only.""" + + def observed_result(self, observed: RunObservation) -> PhysicalResult: + build = RuntimeResolutionProbe( + self.target.build.core_commit, self.target.build.wheel_inputs() + ).run() + return AdmittedResult(observed, build).admitted(self.target) @dataclass(frozen=True) From 0264972385fb663cd37e7a9f70caf762aa7ae0e9 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 1 Nov 2026 04:14:30 -0500 Subject: [PATCH 17/17] Ask the storage rule to verify physical-run isolation --- docs/performance/physical-run.md | 26 ++++++++++++++++---------- tests/model/test_physical_run.py | 2 +- tools/physical_ui_remote.py | 3 +-- 3 files changed, 18 insertions(+), 13 deletions(-) diff --git a/docs/performance/physical-run.md b/docs/performance/physical-run.md index 2fe37a344..24f3fce0b 100644 --- a/docs/performance/physical-run.md +++ b/docs/performance/physical-run.md @@ -105,14 +105,18 @@ content hashing or complete loaded-code equivalence. Process/root/module-origin and saved/ACP-root invalidation remain current before every key. Current observations also carry the actual typed `StorageRule` from the already -observing service's `Registration`. The rule must equal its own `within(root)` -projection; observation neither constructs a Comms nor writes a policy. This -checks declared allocation/default scope, not private HOME alone. Canonical -filesystem containment, registered allocations, persisted reopen and future-child -qualification remain separate prerequisites owned by the isolation work. In -particular, a `checkouts` symlink can defeat equality-only projection in the -current Core owner; that counterexample is handed to the isolation owner, not -silently counted as qualified. +observing service's `Registration`. `RunTarget` calls that rule's pure +`require_isolated(root)` observation contract; it no longer repeats the owner's +projection/equality decision. Observation neither constructs a Comms nor writes +a policy. The paired Core must include this API: its `ScopedDirectory` field +declarations own canonical directory checks and future default allocations, +including outward-symlink refusal. There is no fallback to an older API. + +Filesystem behavior, registered allocations, persisted reopen and future-child +qualification remain separate prerequisites owned by the isolation work. The +owner's eleven new filesystem cases are written, not run; a source implementation +is not isolation acceptance. Candidate02's Core7e4/Toad620955 artifact pairing +predates this API and cannot qualify the current consumer; preserve it unchanged. `WheelRuntime` retains copies of its exact inputs under the new prefix's `inputs/` directory; borrowed originals remain read-only. The package probe @@ -120,8 +124,10 @@ compares every root-wheel member except the installer's rewritten `RECORD` against installed bytes, then records the verified archive SHA256. Root-layout wheels are the supported domain; `.data` relocation is refused, not silently skipped. Remote archive hashes remain metadata, never permission to fetch them. -The deployed donor package probe keeps its existing core-only wire; a target -subclass adds wheel verification without changing old workers' request shape. +The deployed donor package probe keeps its existing core-only wire. Updated +built workers derive retained inputs from their own `RuntimeResolution`; the +target subclass supplies explicit inputs before its first resolution exists. +Both use the same verification loop, without changing old workers' request shape. The interpreter observation keeps the invoked environment separate from the resolved executable and the native image actually named by the loader for diff --git a/tests/model/test_physical_run.py b/tests/model/test_physical_run.py index 186ac34cc..0aa458580 100644 --- a/tests/model/test_physical_run.py +++ b/tests/model/test_physical_run.py @@ -107,7 +107,7 @@ def test_actual_source_policy_is_checked_after_admission( ) -> None: foreign = physical_observation.storage_rule.within(tmp_path / "foreign") current = replace(physical_observation, storage_rule=foreign) - with pytest.raises(ValueError, match="actual source storage policy"): + with pytest.raises(ValueError): physical_admission.observe(physical_target, current) diff --git a/tools/physical_ui_remote.py b/tools/physical_ui_remote.py index b8166e0b0..6802f31cb 100644 --- a/tools/physical_ui_remote.py +++ b/tools/physical_ui_remote.py @@ -89,8 +89,7 @@ def require_resources(self, observed): raise ValueError("Core selected a foreign source") if not observed.opened: raise PendingRun("Core has not opened the intended private source") - if observed.storage_rule != observed.storage_rule.within(self.root): - raise ValueError("The actual source storage policy escapes the private root") + observed.storage_rule.require_isolated(self.root) for root in (*observed.saved_roots, *observed.acp_roots): if root != self.root: raise ValueError(f"Foreign saved/ACP source refuses physical input: {root}")