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) diff --git a/docs/performance/physical-run.md b/docs/performance/physical-run.md new file mode 100644 index 000000000..24f3fce0b --- /dev/null +++ b/docs/performance/physical-run.md @@ -0,0 +1,246 @@ +# 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. 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(...), 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. + +## 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 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. 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 + 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. + +## 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. `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`. `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 +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. 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 +`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 +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. + +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 +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 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. +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 +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. + +## 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 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. 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/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/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/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/sidebar_layout.py b/src/toad/sidebar_layout.py index 3f8f73e01..e2b2c6f09 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 content_padding(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,63 @@ 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: + minimum = min(axis.minimum, available // len(expanded)) + surplus = available - minimum * len(expanded) + demand = wanted - minimum * len(expanded) + for key in expanded: + 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: + 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..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,6 +317,21 @@ class CursorRightAction(HorizontalCursorAction): direction = +1 +class VerticalCursorAction(CursorAction, declared_name=None): + 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 +430,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/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/side_bar.py b/src/toad/widgets/side_bar.py index f76639d9e..695820116 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): @@ -186,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): @@ -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,53 +497,60 @@ 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.""" + """Drag the conversation-facing edge; the placement model owns the extent.""" highlight_foreground = ForegroundColour 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 + 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 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_percent = bar.placement.extent_percent + self._drag_viewport = self._drag_edge.extent(bar.query_ancestor(Widget).content_size) class ClearSidebarSearchAction(WidgetAction): @@ -578,6 +577,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 @@ -593,33 +621,6 @@ class SideBar(DismissibleControl, WorkspaceHosted, TonePaint, CoreEventReceiver, width: auto; border: none; } - SideBar > SidebarViewport { - width: 1fr; - height: 1fr; - margin: 0 4 0 0; - layer: sidebar-content; - overflow: auto auto; - scrollbar-size: 1 1; - scrollbar-gutter: stable; - } - 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.-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; } """ collapsed = reactive(False) @@ -646,23 +647,27 @@ 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) 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.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: @@ -692,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 @@ -711,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 @@ -722,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() @@ -745,15 +751,16 @@ def on_unmount(self) -> None: self._panels_ready.set() def compose(self) -> ComposeResult: - yield SideBarToggle(self.collapsed, right=self.right) - yield SidebarResizeHandle() - 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() + yield SideBarToggle(self.collapsed, edge=self.placement.side) + yield SidebarResizeHandle(self.placement.side) + 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: @@ -774,11 +781,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", ) @@ -787,91 +795,56 @@ 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: - app = self.app - if not self.is_mounted: - return False - placement = app.sidebar_layout.get(self.id) - if self.parent is None or not self.presentation_visible: + if 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) + placement = self.placement + 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 + self.content_stack.styles.padding = edge.content_padding() 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.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: @@ -901,33 +874,29 @@ 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 - # 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: - toggle.set_collapsed(collapsed) + toggle.set_collapsed(collapsed, self.placement.side) toggle.refresh_paint() 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 ("<" 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: @@ -947,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) @@ -980,7 +950,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/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 { 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) diff --git a/src/toad/workspace_chrome.py b/src/toad/workspace_chrome.py index 87991f935..a168070b4 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) @@ -69,37 +69,31 @@ 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( 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.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 + bar.parent is not parent and bar.parent.layout is self for bar in bars ) - 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 +103,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_physical_run.py b/tests/model/test_physical_run.py new file mode 100644 index 000000000..0aa458580 --- /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): + 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/tests/model/test_sidebar_layout_policy.py b/tests/model/test_sidebar_layout_policy.py index c0da18821..e35bbd1b8 100644 --- a/tests/model/test_sidebar_layout_policy.py +++ b/tests/model/test_sidebar_layout_policy.py @@ -1,71 +1,103 @@ -"""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("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() + 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_initial_prompt_handoff.py b/tests/textual/test_initial_prompt_handoff.py index 9f1151b00..5840bcf69 100644 --- a/tests/textual/test_initial_prompt_handoff.py +++ b/tests/textual/test_initial_prompt_handoff.py @@ -42,7 +42,11 @@ 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 + 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() await asyncio.wait_for(owner.publication.wait(), 12) await ui.step() @@ -61,6 +65,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 +96,8 @@ 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 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 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..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 @@ -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 @@ -93,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 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 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..3046f594b --- /dev/null +++ b/tools/performance/scroll_scenario.py @@ -0,0 +1,69 @@ +#!/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 OwnedDisplay, 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) + 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) + 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), framebuffer=Size(*args.framebuffer), + ) 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") + for columns, rows in args.resize: + app.resize(columns, rows) + app.screenshot(f"resized-{columns}x{rows}") + 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..c4fe7bf3e --- /dev/null +++ b/tools/physical_ui.py @@ -0,0 +1,609 @@ +"""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 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 +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.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 +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 ui_theme.wal import WalFile + +from physical_ui_remote import ( + AdmitOperation, CapturedRegion, ExecOperation, ObserveOperation, PendingRun, PhysicalOperation, + ProcessRetirement, 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: + PhysicalOperation.publish(path.with_suffix(".process.json"), child.identity) + try: + yield path + finally: + 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) + + +class OwnedDisplay(XvfbDisplay): + """The isolated display this run creates and retires.""" + + startup_seconds = 10.0 + stop_seconds = 10.0 + default_size = Size(1600, 1000) + + 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", f"{size.width}x{size.height}x24", "-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") + PhysicalOperation.publish(output / "xvfb-process.json", self.process) + except BaseException: + self.close_server() + raise + + def close_server(self) -> None: + try: + 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() + + 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 + # 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)): + 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), + PYWAL_CACHE_DIR=str(target.palette), + 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 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. + 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, build) + 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(): + 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) + return target + + @classmethod + @contextmanager + def copied(cls, source: Comms, threads: tuple[str, ...], runtime: LaunchRuntime, + 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. + copied = CheckRoot(source, threads, output / "bus").create() + RootRuntime.publish(copied.root, runtime) + target = cls.prepare_target(copied.root, runtime) + try: + 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) + 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") + + @classmethod + def operation_file(cls, operation: PhysicalOperation, directory: Path) -> Path: + directory.mkdir(mode=0o700, parents=True) + # 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 + # 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: + 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]) + 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.admission = self._observe(AdmitOperation).admitted(self.target) + PhysicalOperation.publish(self.output / "build-admitted.json", self.admission) + 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.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, operation: type[ObserveOperation]): + self.terminal.platform.require(self.frontend) + 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) + result = self.result(script.parent) + if result.observation().frontend.process != self.frontend: + raise ValueError("Observation belongs to another frontend incarnation") + 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") + self.read_grid() + 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() + 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): + 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) + 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.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) + 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() + 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: + """Retire one child lifetime on normal exit and failed entry alike.""" + try: + 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: + 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: + self.close() diff --git a/tools/physical_ui_remote.py b/tools/physical_ui_remote.py new file mode 100644 index 000000000..6802f31cb --- /dev/null +++ b/tools/physical_ui_remote.py @@ -0,0 +1,434 @@ +"""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 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.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 +from ui_theme.wal import WalFile +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 + 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}") + 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 palette(self) -> Path: + return WalFile.directory({"HOME": str(self.home)}) + + @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_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): + 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: + raise ValueError("Core selected a foreign source") + if not observed.opened: + raise PendingRun("Core has not opened the intended private source") + 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}") + 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 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 + home: Path + config: Path + state: Path + data: Path + database: Path + 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 + + +@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 + + 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") + + 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 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 + 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 observed_result(self, observed: RunObservation) -> PhysicalResult: + return ObservedResult(observed) + + 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 + 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())) + 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) + frontend = FrontendIdentity( + self.identity, Path(toad.__file__).resolve(), Path(agent_comms.__file__).resolve(), + Path(textual.__file__).resolve(), + ) + observed = RunObservation( + 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, + opened, storage_rule, saved, acp, regions, checked, + ) + 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) +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)