App Shell
Use lc.composition.app.shell(...) when an app has multiple panels or tabs.
The app-shell declaration keeps the common screen shape in one place:
- panel and tab specs
- content slots and widget factories
- focused-panel navigation
- shell actions such as focus and tab cycling
- contextual keybars and keybinding help
- runtime wiring for Textual widgets
Lower-level shell state, shell declarations, and widget adapters are still available, but app guides should start from the app declaration layer.
Declare Panels
Section titled “Declare Panels”Panels define stable ids, visible labels, indexes, tabs, and optional content kinds.
import lazy_cuh as lc
panels = ( lc.composition.shell.panel( "files", index=1, key="1", content_kind=lc.widgets.ContentKind.TREE, ), lc.composition.shell.panel( "work", index=2, key="2", tabs=( lc.composition.shell.tab( "list", "List", content_id="work-list", content_kind=lc.widgets.ContentKind.LIST, key="l", ), lc.composition.shell.tab( "details", "Details", content_kind=lc.widgets.ContentKind.TEXT, key="d", ), ), ),)A panel with one tab still uses tab metadata internally. That keeps single-tab and multi-tab panels on the same composition model.
index is structural metadata used for ordering and title numbering. key is
the optional shortcut metadata. Panel keys always focus from the shell. Tab keys
default to shell-level jump bindings:
lc.composition.shell.panel("work", index=2, key="w")lc.composition.shell.tab("details", "Details", key="d")With that declaration, w focuses the Work panel from anywhere, while d
focuses the Work panel and selects the Details tab from anywhere. Use
key_scope=lc.composition.shell.KeyScope.PANEL when a tab key should only work
while its panel is the active input context. Omit either key when an app wants
to provide bindings through presets or manual action declarations instead.
Declare Actions
Section titled “Declare Actions”Shell presets provide common panel focus, directional focus, help, and quit bindings. App-specific actions, such as opening a selected item or saving data, stay explicit.
from enum import Enum, auto
class AppAction(Enum): OPEN = auto() FOCUS_FILES = auto() FOCUS_WORK = auto() FOCUS_LEFT = auto() FOCUS_RIGHT = auto() HELP = auto() QUIT = auto()
actions = ( lc.composition.shell.binding( "enter", AppAction.OPEN, label="Open", visibility=lc.inputs.HintVisibility.COMPACT, group="Primary", order=10, ), *lc.presets.shell.lazygit( panels=panels, focus_actions={ "files": AppAction.FOCUS_FILES, "work": AppAction.FOCUS_WORK, }, direction_actions={ lc.view.Direction.LEFT: AppAction.FOCUS_LEFT, lc.view.Direction.RIGHT: AppAction.FOCUS_RIGHT, }, help=AppAction.HELP, quit=AppAction.QUIT, ),)Use lc.presets.shell.lazygit(...) when panel numbers should be bare keys such
as 1 and 2. Use lc.presets.shell.lazyvim(...) when panel numbers should
stay behind the configured leader so bare digits remain available for count
prefixes.
Use lc.presets.shell.bindings(...) when you want to provide key templates
directly.
Declare The App Shell
Section titled “Declare The App Shell”The app-shell declaration validates setup errors early: panel ids, panel
indexes, panel keys, tab ids, tab keys, content slots, shell actions, and focus
graph edges must all reference known objects or be unique in their owning
scope. Binding validation also includes content-owned navigation keys derived
from each tab’s ContentKind, so shell-level keys cannot silently shadow
widget navigation keys.
aliases = lc.presets.keys.leader("z")
app_spec = lc.composition.app.shell( panels=panels, content=( lc.builders.content.tree("files", model_factory=build_file_tree), lc.builders.content.list( "work", "list", "work-list", model_factory=build_work_items, ), lc.builders.content.text("work", "details", model_factory=build_details), ), actions=lambda panels: lc.presets.shell.lazygit( panels=panels, focus_actions={ "files": AppAction.FOCUS_FILES, "work": AppAction.FOCUS_WORK, }, direction_actions={ lc.view.Direction.LEFT: AppAction.FOCUS_LEFT, lc.view.Direction.RIGHT: AppAction.FOCUS_RIGHT, }, help=AppAction.HELP, quit=AppAction.QUIT, ), navigation=lc.composition.shell.navigation( *lc.composition.shell.connect( "files", lc.view.Direction.RIGHT, "work", ), ), aliases=aliases, key_display=lc.presets.keys.display(aliases), keybar_widget_id="keybar", view=lc.builders.view.relative_lines(), action_help_view=lc.inputs.HintViewSpec( visibility=( lc.inputs.HintVisibility.COMPACT, lc.inputs.HintVisibility.HELP, ), groups=("Primary", "Pane", "Tabs", "System"), ),)lc.composition.app.shell(...) owns the shell/content composition. The
lc.presets.shell.lazygit(...) call only contributes keybindings. Use
lc.presets.shell.lazyvim(...) when panel numbers should stay behind the
leader so bare digits remain available for count prefixes.
The view argument sets shell-level presentation defaults for navigable
content. panel(..., view=...) can override those defaults for a panel,
tab(..., view=...) can override them for one tab, and content declarations may
pass their own view= to override the final slot.
action_help_view tells the shell how to project its own action map into
expanded help for the focused panel. Content help for list, tree, and options
views is derived separately from ContentKind.
If a shell uses custom item navigation for list, tree, or option content, pass
the same presets through content_navigation. Shell validation reserves those
content-owned navigation keys so a shell-scoped panel or tab shortcut cannot
silently steal keys from focused content.
navigation = lc.presets.navigation.lazygit(include_select=True)
app_spec = lc.composition.app.shell( panels=panels, content=content, actions=lambda panels: lc.presets.shell.lazygit( panels=panels, focus_actions=focus_actions, ), content_navigation={ lc.widgets.ContentKind.LIST: navigation, lc.widgets.ContentKind.OPTIONS: navigation, lc.widgets.ContentKind.TREE: navigation, },)Declare App Content
Section titled “Declare App Content”When a slot is backed by one of lazy-cuh’s view models, use
lc.builders.content to create the content slot and widget factory together:
content = ( lc.builders.content.tree("files", model_factory=build_file_tree), lc.builders.content.list( "work", "list", "work-list", model_factory=build_work_items, ), lc.builders.content.text("work", "details", model_factory=build_details),)The builders return ordinary ContentSlot values. They keep the pure view
model explicit while hiding the repetitive Textual widget factory.
Use lc.composition.app directly when an app needs a custom widget factory:
app_spec = lc.composition.app.shell( panels=panels, content=( lc.composition.app.tree("files"), lc.composition.app.list("work", "list", "work-list"), lc.composition.app.text("work", "details"), ), actions=actions,)The app declaration validates that each content slot references a known panel/tab pair. It can then feed the explicit content specs into the existing shell runtime:
composition = app_spec.compose(app)This layer is intentionally above lc.composition.shell. Shell declarations
still own panel state, tab state, actions, keybars, and focus graph behavior;
app declarations add content-slot ownership and preset wiring without moving
widget factories into ShellSpec.
Compose Runtime Objects
Section titled “Compose Runtime Objects”Attach app-owned handlers where side effects live, then compose the runtime inside the Textual app.
app_runtime = app_spec.with_handlers( { AppAction.HELP: open_help_modal, AppAction.QUIT: app.exit, }, set_controller=apply_controller,).runtime(app)
runtime = app_runtime.composition.runtimedispatcher = app_runtime.composition.dispatcherThe runtime owns Textual-boundary concerns:
- applying panel titles and tab visibility
- focusing the active content widget
- keeping input context aligned with the focused panel
- refreshing the keybar and expanded keybinding help
The app still owns domain state and command handlers.
Textual Bindings
Section titled “Textual Bindings”Use the shell declaration to project Textual bindings for single-key handled
actions, such as ?, q, or tab keys.
class MyApp(App): BINDINGS = app_spec.shell.textual_bindings()For ordinary key presses inside on_key, route through shell dispatch:
def on_key(self, event) -> None: if app_runtime.dispatch_key(event.key): event.prevent_default() event.stop()Runtime Updates
Section titled “Runtime Updates”Most state changes should produce a new ShellController and pass it back to
the runtime:
runtime.set_controller(next_controller, focus=True)If an app changes its active input profile at runtime, rebuild the shell declaration and update the runtime input/keybar projection:
runtime.set_input_bindings( registry=next_shell.registry(), keybars=next_shell.keybars(),)The demo uses this pattern to switch between lazygit and lazyvim shell modes.
Escape Hatches
Section titled “Escape Hatches”Use lower-level APIs when the declaration layer is too high-level:
ShellStateandShellControllerfor pure shell state transitionsShellViewAdapterfor manual Textual widget coordinationShellRuntime.from_widgets(...)for custom runtime compositionfocus_binding(...),move_binding(...), andcycle_tab_binding(...)for hand-written shell bindings
These APIs are useful for framework work and unusual apps, but
lc.composition.shell.declare(...) should be the first path for normal panel
and tab screens.