Adding a New Service¶
The current tree ships
s3,emr-serverless,glue, andathena; Glue and Athena are linked by immutable table-navigation messages, and EMR includes the read-only browser, job-run logs, and clone-job-run modal. This doc is the pattern for the next ones (EC2, IAM, Lambda, ...). For richer references than S3 (dedicated domain client + per-service VM subtree + per-service UI widget tree + service-specific modal), readsrc/aws_tui/services/emr_serverless/,src/aws_tui/services/glue/, andsrc/aws_tui/services/athena/alongsides3/.
aws-tui's service-plugin spine keeps service construction additive: a
new folder under src/aws_tui/services/<name>/ and one registration
call in composition.py. Today the app shell still owns the view
factory, so non-DualPane services also need an explicit route in
AwsTuiApp._mount_initial_service_view / _mount_service_view until
the planned service-owned view-factory contract lands.
1. The Service protocol¶
Declared in src/aws_tui/vm/services_protocol.py, re-exported from
src/aws_tui/services/__init__.py:
from typing import Any, Protocol
from aws_tui.infra.connection_resolver import Connection
class Service(Protocol):
descriptor: ServiceDescriptor # id, label, icon
def supports(self, connection: Connection) -> bool:
"""True if this service can run against the given connection."""
def build_vm(self, connection: Connection) -> Any:
"""Construct the service's content VM tree for this connection."""
The descriptor is a ClassVar so the registry can introspect it
without instantiating. build_vm is structurally typed as
-> Any on the protocol so the vm/ layer never has to import
vmx.ComponentVM just to spell the bound; concrete services return
whatever VMx VM they actually host (S3Service.build_vm returns
DualPaneVM, see the §2 template below). ContentHostVM only
needs a construct → destruct → dispose surface.
2. Steps¶
-
Create the folder.
src/aws_tui/services/<name>/ __init__.py service.py # implements the Service protocol -
Implement
Serviceinservice.py.from typing import ClassVar from aws_tui.vm.services_protocol import ServiceDescriptor from aws_tui.infra.connection_resolver import Connection from aws_tui.infra.aws_session import AwsSession class EC2Service: descriptor: ClassVar[ServiceDescriptor] = ServiceDescriptor( id="ec2", label="EC2", icon="EC2", ) def __init__(self, *, aws_session: AwsSession, ...) -> None: self._aws_session = aws_session def supports(self, connection: Connection) -> bool: return connection.kind == "aws" # EC2 isn't S3-compat def build_vm(self, connection: Connection) -> ComponentVM: return InstancesPaneVM(self._aws_session, connection)The service rail renders
descriptor.labelfor service rows. Theiconfield remains reserved descriptor metadata; Settings is the current exception and renders its gear icon to keep the rail narrow. Do not choose an emoji or assume terminal cell width as part of a service's navigation contract. -
Register in
src/aws_tui/composition.py(near the existings3_service = S3Service(...)block):ec2_service = EC2Service(aws_session=aws_session, ...) registry.register(cast("Service", ec2_service)) -
Add app-shell view routing if the service does not return a
DualPaneVM.build_service_view(...)currently mapsemr-serverlesstoEmrServerlessPage,gluetoGluePage,athenatoAthenaPage, ands3toDualPane. Add the matching widget factory branch and pass it from both app mount paths. -
Reuse existing VM families where possible:
- For storage-like services (lists with hierarchy): the file-
manager VMs in
vm/file_manager/work as-is — write a newFileSystemProvider(seesrc/aws_tui/domain/filesystem.pyfor the protocol, and §3 / §4 of the design spec for the architectural shape) and reusePaneVM+DualPaneVM. - For flat resource lists (EC2 instances, IAM users): write a new
ListPaneVMundervm/<service>/and a corresponding widget family underui/widgets/<service>/. - Reuse
ServiceSourceHeaderandContextPickerfor shared source and context controls, andServiceTabStripfor a one-stop segmented view selector.ContextPickerand specialized wrappers such as EMR'sApplicationPickermust composeOverlayOptionList, retain their compact trigger footprint while open, and leave parent and sibling regions unchanged. Keep that transient overlay geometry in the Textual view. ExtendFocusCoordinatorVMinstead of creating another focus authority. Add an enclosing context frame only when multiple dependent controls form one coherent region; a standalone source control does not require a second frame.
- For storage-like services (lists with hierarchy): the file-
manager VMs in
-
Layer rules. Services live one layer above domain, so they may import from
domain/,infra/, and the public VM surface (vm/services_protocol.py,vm/messages.py, and the file-manager VMs invm/file_manager/for storage-like services that reusePaneVM/DualPaneVM). The only hard ban isui/(no Textual widget imports) and Textual itself — enforced byscripts/check-layers.sh. See §3 below for the full cheat-sheet. -
Tests. Add unit tests under
tests/unit/services/<name>/and, if your service touches AWS, integration tests undertests/integration/test_<name>_*.py. Use a modeled fake for deterministic behavior and a vendor container only where an independent implementation materially strengthens the contract. -
Update docs. Add any vendor / API quirks to
docs/connections.md. Update the README's features list. -
Commands, palette, and cross-service state. Declare service-specific action IDs in
HintLegendVM, including compact labels, complete shortcut / effect / execution / prerequisite tooltip metadata, and deterministic fitting priorities. The Commands pane remains one content row; reserve the protected[:] morecommand-palette hint and[q] quitat narrow widths, while hidden actions remain bound and palette-visible. Publish immutable typed requests for app-level cross-service state; the composition root owns delivery and Textual integration. Buttons, keys, and palette entries must dispatch the same registered action rather than calling a VM through a separate path.
For cross-service links, publish an immutable VM message carrying plain
identifiers and source identity. The app composition root resolves the
destination connection and owns RootVM switching plus Textual mounting.
OpenS3LocationRequest carries connection, region, URI, pane, and reveal
intent. OpenAthenaTableRequest and OpenGlueTableRequest carry a shared
TableRef; the Athena request may add a non-negative snapshot ID. app.py
rejects missing connections or region mismatches, serializes table handoffs,
and reuses registered service factories. Do not pass clients, VMs, widgets,
raw SDK responses, or credentials in cross-service messages. A navigation
handoff may prefill destination state, but it must document whether it executes
or mutates anything; Glue's Shift+Q and Shift+V actions prefill bounded
Athena SQL and do not execute it.
3. Layer rules cheat-sheet for services¶
A service module may import from:
aws_tui.infra.*(aws_session, config_store, log_sink, …)aws_tui.domain.*(filesystem, journal, …)aws_tui.vm.services_protocol(Service, ServiceDescriptor, ServiceRegistry) — re-exported asaws_tui.services.*aws_tui.vm.messages(for pushing on the hub)aws_tui.vm.file_manager.*(the public VMs —PaneVM,DualPaneVM, etc. — for storage-like services that reuse the file-manager scaffolding; seeservices/s3/service.pyfor the pattern that composesDualPaneVM(left=PaneVM(S3FS), right=PaneVM(LocalFS))).vmx.*
A service module may not import from:
aws_tui.ui.*(no Textual widget code)textual.*directly
These bans are enforced by scripts/check-layers.sh.
4. Future: entry-point discovery¶
v1.1 promotes the registry to
importlib.metadata.entry_points(group="aws_tui.services") so third-
party packages can ship services without forking. The same Service
protocol applies.
5. Reference: the shipped services¶
5.1. S3¶
src/aws_tui/services/s3/service.py is the first concrete service. Read its
S3Service implementation end to end:
descriptordeclaresid = "s3", label"S3", and retained icon metadata. The service rail renders the label, not the icon.supports()accepts bothawsands3-compatibleconnections.build_vm(connection)composesDualPaneVM(left=PaneVM(S3FS), right=PaneVM(LocalFS))each call.- An optional
s3_fs_factorytest hook lets unit tests swap S3FS forInMemoryFSso no AWS calls leak in CI. bind_hub(hub)late-wires the hub since the service is registered beforeRootVMhas its hub.
5.2. EMR Serverless¶
src/aws_tui/services/emr_serverless/service.py is the second
shipped service and demonstrates the richer per-service pattern:
descriptordeclaresid = "emr-serverless", label"EMR", and retained fire-icon metadata. The service rail renders"EMR"; the icon does not define service-row width or alignment.supports()is AWS-only (connection.kind == "aws").- Domain client lives at
domain/emr_serverless.py(asyncEmrServerlessClientfacade overaioboto3, with read-only verbslist_applications/list_job_runs/get_job_runplus the write-sidestart_job_run(added PR #83 for the clone flow), dedicated_map_boto_erroradapter). - VM subtree at
vm/emr_serverless/(EmrServerlessPageVMorchestratesApplicationsVM+JobRunsVM+JobRunDetailVM+JobRunLogsVM;JobRunCloneVMsits alongside, instantiated per modal-mount). - UI widget tree at
ui/widgets/emr_serverless/(ApplicationPicker+JobRunsPane+JobRunDetailPane+JobRunLogsPane+EmrServerlessPagecomposer +JobRunCloneModal+LogFilterModal).ApplicationPickeruses the shared screen-overlaid option list, so opening or closing it leaves the runs, detail, and logs regions unchanged. - Three independent production
set_intervalpollers (apps 60 s / runs 60 s with 6:1 cadence decay while no run is active / detail 30 s with terminal-state suppression). Demo mode uses shorter 30 s / 30 s / 5 s cadences so sample data feels live. - Service-specific modal pattern.
JobRunCloneModalis pushed viaapp.push_screen. Theapp.py::action_copypriority binding short-circuits to the EMR clone path when EMR is mounted (parallel to the dual-pane hijack pattern forTab/ arrow keys). Service-specific keymap + chip wiring:KeymapStore.DEFAULT_BINDINGSadds the action id ("emr.clone": ("c",));HintLegendVM._SERVICE_ACTIONS["emr-serverless"]lists the action id;_ACTION_LABELS["emr.clone"] = "clone"gives it a human label in the Commands strip. Future services with their own actions follow the same three-touch-point pattern: default binding + service-actions tuple + action label.
5.3. AWS Glue¶
src/aws_tui/services/glue/service.py is the third shipped service and
the compact read-only page reference:
descriptordeclaresid = "glue"andsupports()accepts only AWS connections.- Every
build_vm(connection)returns a freshGluePageVM; the long-lived service retains onlyServiceSelectionStore, keyed by service id, connection name, and region. domain/glue.pyowns boto response mapping and pagination for databases/tables/partitions/statistics, jobs/runs, and crawlers. Raw boto responses never enter the VM or view layers.vm/glue/separates Catalog, Jobs, and Crawlers behavior; the Textual tree lives underui/widgets/glue/.GlueCatalogVMadditionally ownsGlueIcebergVM; it binds only whenTableDetail.table_formatisICEBERG.glue_client_factoryinjects profile-keyed in-memory clients for tests and demo mode without changing production composition._ContextualIcebergInspectorrevalidates a remembered enabled Athena workgroup in the same connection and region, then constructsIcebergInspectorwith a matchingQueryContext. Each metadata tab is loaded independently, so one denied or malformed metadata table does not erase successful siblings.- The selected table's S3 handoff is an immutable message, not a
service-to-service import. Its app subscriber resolves the exact
connection name, verifies the region, rebuilds S3 through
RootVM, navigates the requested pane, and focuses it. Shift+Qopens a visible selected table in Athena with exact quotedSELECT * ... LIMIT 5SQL.Shift+V, the corresponding palette entry, and the Iceberg arrow button open a visible selected snapshot withFOR VERSION AS OF <snapshot-id>. Both actions use the same registered action and typed request path, preserve exact source identity, and never execute the generated query.
5.4. Amazon Athena¶
AthenaService in src/aws_tui/services/athena/service.py is the
query-service reference:
descriptordeclaresid = "athena";supports()accepts only AWS connections. Abuild_vm(connection)call makes a freshAthenaPageVM, whileServiceSelectionStoreretains only connection- and region-scoped UI selections.domain/athena.pyperforms one paginated AWS request at a time and maps workgroups, catalogs, databases, tables, query history/detail, runtime statistics, result pages, named queries, and prepared statements. It owns thestart_query_execution/stop_query_executionboundary; views and VMs never receive raw boto responses.domain/sql_policy.pyparses one Athena-dialect statement before dispatch. It acceptsSELECTroots and set operations (includingVALUESoperands), the implemented boundedSHOWandDESCRIBEforms, and non-ANALYZEEXPLAINof an allowed statement. StandaloneVALUESis rejected. The parser is defense in depth, not an authorization system.vm/athena/separates Query, History, Results, and Saved behavior; the Textual widget tree lives inui/widgets/athena/. A result-to-S3 handoff is anOpenS3LocationRequestcarrying the execution's exact connection and region, not an Athena-to-S3 import.AthenaPageVM.open_table(...)discovers the exact catalog/database in bounded pages, updates context, and inserts a quotedSELECT * ... LIMIT 5statement. A snapshot request addsFOR VERSION AS OF <id>. It never executes the generated SQL.open_table_in_glue()publishesOpenGlueTableRequestonly when the current read-only SQL exposes one unambiguous table reference.