ADR 0013: Pluggable TUI layouts and workflows¶
Status¶
Accepted. Shipped as a strangler-fig sequence (relocate the HUD into a layout → add the workflow seam → add a second workflow → add a second layout → add the registry, CLI, and switching), each step landing behind the completion gate.
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, on the grounds that “agentgrep ships exactly one frontend” and a
plugin base class with no second consumer is speculation. That premise no longer
holds: the goal now is to launch into — and switch between — different TUI
surfaces over the same engine and the same normalized records. The former
ExplorerApp fused the App lifecycle and one fixed heads-up display into a
single ~2358-line object, so it could be neither swapped nor re-targeted.
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. Textual already supplies the mechanisms (Screen, App.MODES
/ switch_mode, reactive state) — so this is an extraction and seam exercise,
not a new framework. This ADR records the architecture so contributors neither
re-fuse the App and the view nor invent a parallel plugin system.
Decision¶
The TUI is a thin App shell that mounts one layout (a Textual Screen)
driven by one workflow (a plain strategy object), both resolved by name from
a registry and switchable at runtime. 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 selection and switching, not presentation.
ExplorerApp(App)owns lifecycle, theme registration, the ADR-0011 pump bind / watchdog / audit hook, theUiContext, and the choice of layout × workflow. Layouts switch viaApp.MODES/switch_mode(F2, suspend-not-destroy); a layout’s workflow swaps viaLayoutScreen.set_workflow(F3). 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 andagentgrep --helpstays cold. A name is validated against the registry before launch;--layout/--workflowconsume the names as argparsechoices. A futureimportlib.metadataentry-point source can feed the same spec shape without changing 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 the static guard scans 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 real and 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. The product is proven bysearch×browseoverhud×greplog.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.
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 --layout greplog --workflow browse launches a pair; F2 cycles
the layout and F3 cycles the workflow at runtime, with the active pair shown in
the title bar.
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. No reconciler, flexbox engine, or kill-ring editor is adopted;
Textual’s Screen / MODES supply the switching primitive 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 explorer gains two orthogonal, registry-selected, runtime-switchable axes behind a thin shell; the former god-object is now a layout among layouts. Each step is independently revertable, and the non-blocking guards run at every gate.
The chief risks: switch_mode suspends rather than destroys the previous
layout, so a hidden layout’s in-flight worker keeps running against the warm
SearchRuntime cache — accepted for now (cheap warm-resume); a cancel-on-suspend
policy is a follow-up. 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.