ADR 0013: Pluggable TUI layouts and workflows¶
Status¶
Accepted. The layout/workflow architecture is an internal composition seam.
The shipped agentgrep ui surface is fixed to one layout/workflow pair; alternate
registered components remain available to Python factories, tests, and
embedders.
Context¶
ADR 0012 finished the reusable
leaf-widget layer behind the SearchInvoker engine seam and recorded the
pi/ink → Textual capability mapping. It deliberately declined a layout
abstraction until a second concrete consumer existed. The subsequent HUD,
greplog, search, and browse implementations proved that two internal axes are
useful for separating shell lifecycle, structure, and interaction policy. The
former ExplorerApp had fused those concerns into a single ~2358-line object.
The surface splits along two orthogonal axes:
Layout — structure: which widgets exist and how they are arranged (a results-list + detail split vs. an append-only log).
Workflow — behavior: what the primary input does (run a fresh engine search vs. filter the already-loaded records in-memory).
These are independent: a workflow should drive any layout, and a layout should host any workflow. That orthogonality is valuable for implementation tests and embedding, but it does not require a normal user-facing selector or live switcher. This ADR records the internal architecture so contributors neither re-fuse the App and the view nor turn an implementation seam into product surface without a separate decision.
Decision¶
The TUI is a thin App shell that mounts exactly one layout (a Textual
Screen) driven by exactly one workflow (a plain strategy object), both
resolved by name from an internal registry. The normal CLI always uses the
registry defaults. Python app factories keep keyword injection for tests and
embedding, validate the names, and pass one frozen internal composition to
ExplorerApp. The shell never replaces that composition; the lower-level
LayoutScreen.set_workflow strategy seam remains available to component code.
The following invariants govern the layer (PL for pluggable layout), in the
enumerated style of ADR 0011.
PL-1 — A layout is a
Screeninjected with a shared context. A layout is aLayoutScreen(Screen)subclass receiving a frozenUiContext(home, theSearchInvokerseam, the launch query, the cooperative-cancel control) and the activeWorkflow. It ownscompose, CSS,BINDINGS, and presentation, and reaches the engine only throughcontext.invoker(ADR 0012 RW-1) — neveragentgrep._engine,agentgrep.query, oragentgrep.stores.PL-2 — A workflow is a Textual-free strategy driven through a narrow host.
Workflowis aProtocol:on_attachseeds the initial dispatch andon_queryhandles a submission, both by calling theWorkflowHostsurface (build_query/run_search/filter_loaded/reset_view/record_history/request_cancel). A workflow imports no Textual and touches no widget, so it runs on any layout and is unit-tested against a fake host.PL-3 — The App shell owns initial composition, not switching or presentation.
ExplorerApp(App)owns lifecycle, theme registration, the ADR-0011 pump bind / watchdog / audit hook, theUiContext, and construction of the one typed layout × workflow composition it receives. It registers no Textual modes, exposes no layout or workflow cycling bindings/actions, and does not display the internal pair in chrome. No rendering, matching, or record-detail construction lives on the shell (mirrors RW-6).PL-4 — Layouts and workflows resolve through a frozen, lazy registry.
agentgrep.ui.registryis a Textual-free catalog ofLayoutSpec/WorkflowSpecwhose loaders are function-local imports, so listing names never imports Textual. Programmatically injected names are validated against the registry before launch, resolved before Textual starts its message pump, and paired in one frozen value; the shell never handles lazy loaders, unresolved names, or fallback selection. Layout-specific startup state such as query history is likewise loaded at this pre-pump factory boundary. The CLI does not expose those names. A futureimportlib.metadataentry-point source can feed the same spec shape without changing internal consumers.PL-5 — Each layout carries its own transport over the shared primitives. A layout’s streaming transport reuses
_runtime.make_gated_emitter/@offload/@pump_only/stream_apply(ADR 0011 NB-1…NB-10, unchanged) with a layout-specific present. Everyrun_workerstaysthread=True, exclusive=Trueand grouped (thehistoryappend group excepted), and manual pump-entrypoint review covers everyui/layouts/*.py, not just the HUD. The transport is intentionally not hoisted into the base: a sharedpresent_*base waits for a third consumer, per the defer-until-consumer rule of ADR 0012.PL-6 — Orthogonality is an internal contract and is proven. Any workflow drives any layout. The behavior difference is the workflow’s routing (
SearchWorkflow→run_search,BrowseWorkflow→filter_loaded); the structure difference is the layout’scompose+ present. Direct component tests and injected app construction provesearch×browseoverhud×greplog; normal users do not choose among those combinations.PL-7 — The opaque
Screenbase carries the former App posture.LayoutScreenkeeps thet.Anybase the fused App used, becauseDOMNode.query(the DOM query) collides with view state; the search-query state isself.search_queryprecisely to avoid that. Fully typing the views againstScreenis a follow-up, as it was againstApp.
Internal catalog¶
Kind |
Name |
Class |
Role |
|---|---|---|---|
Layout |
|
|
Search bar, streaming results list, detail pane. |
Layout |
|
|
Append-only |
Workflow |
|
|
Each submission runs a fresh engine search. |
Workflow |
|
|
The input filters the loaded records in-memory. |
agentgrep ui launches the fixed hud × search pair. There are no
--layout / --workflow options, runtime cycling keys, Textual mode stacks, or
active-pair subtitle. Tests and embedders may pass layout= and workflow= to
the Python app factories; direct shell tests inject a validated composition.
Relationship to ADR 0012¶
This ADR builds on, and partially supersedes, ADR 0012. ADR 0012’s reusable widget layer
(RW-1…RW-8) and the ADR 0011
non-blocking catalog are kept intact — layouts compose the same leaf widgets and
honor the same pump rules. What this ADR reverses is ADR 0012’s single-frontend
position: the second consumer it said to wait for has arrived, so the layout
abstraction (LayoutScreen, the Workflow seam, the registry) is now
warranted internally. It does not create multiple shipped frontends or a
user-facing plugin contract. No reconciler, flexbox engine, or kill-ring editor
is adopted; Textual’s Screen supplies the composition boundary directly.
Engine changes¶
None. Layouts and workflows reach the engine only through the existing
SearchInvoker seam and the already-streaming, cooperatively-cancellable engine
of ADR 0004. No
native code, no new engine entry point.
Consequences¶
The former god-object is now one layout behind a thin lifecycle shell, while the shipped explorer keeps one stable interaction model. Removing live switching also removes suspended-screen state, hidden layout workers, cross-layout workflow reattachment, and user-facing key/chrome complexity.
The internal registry and alternate pair injection still need direct coverage so
they do not drift while absent from the CLI. LayoutScreen.set_workflow remains
a programmatic component operation, but ExplorerApp never calls it. The
opaque-base typing (PL-7) remains a debt, and the per-layout transport (PL-5)
carries a little boilerplate over the shared primitives until a third layout
justifies a present_* base.