Plugin Architecture
Status: Implemented
Plugins are functions (app) => enhancedApp that compose via pipe(). The .Root component pattern lets plugins wrap the React element tree with providers.
Core Concepts
Plugin Shape
A plugin is a function that takes an app and returns an enhanced app:
type Plugin<T, U> = (app: T) => T & UPlugins set app.Root — a React component that wraps children with providers. They compose by preserving the previous Root:
const PrevRoot = app.Root ?? Fragment
const MyRoot = ({ children }) => (
<MyProvider>
<PrevRoot>{children}</PrevRoot>
</MyProvider>
)pipe() Composition
Plugins compose left-to-right via pipe():
const app = pipe(
createApp(store),
withReact(<App />),
withTerminal(process),
withInk(),
)
await app.run()Later plugins wrap earlier ones — withInk() wraps withTerminal() which wraps withReact().
Built-in Error Boundary
SilveryErrorBoundary is silvery's default error boundary, applied as the outermost wrapper in createApp() and run(). All apps get error catching for free — plugins don't need their own error boundaries.
Built-in Plugins
Core (silvery)
| Plugin | What | Package |
|---|---|---|
withReact(<Element />) | Mounts React element tree | @silvery/create |
withTerminal(process) | Terminal I/O (stdin/stdout, raw mode, alternate screen) | @silvery/create |
withFocus() | Tree-based focus management (scopes, spatial nav) | @silvery/create |
withDomEvents() | DOM-style event dispatch (capture/target/bubble) | @silvery/create |
withCommands(opts) | Named commands with keybindings and introspection | @silvery/create |
withKeybindings(opts) | Configurable keybinding resolution | @silvery/create |
withDiagnostics() | Render invariant checking | @silvery/create |
Ink Compatibility (@silvery/ink)
The Ink compat layer is decomposed into composable plugins:
| Plugin | What | Lines |
|---|---|---|
withInkCursor() | Bridges Ink's useCursor to silvery's CursorStore | ~50 |
withInkFocus() | Provides Ink's flat-list focus (useFocus/useFocusManager) | ~45 |
withInk() | Composes withInkCursor() + withInkFocus() | ~10 |
withInk() is the convenience plugin — it applies both adapters in one call. For fine-grained control, use the individual plugins:
// All-in-one (most apps)
const app = pipe(createApp(store), withReact(<App />), withTerminal(process), withInk())
// Fine-grained (pick what you need)
const app = pipe(createApp(store), withReact(<App />), withTerminal(process), withInkCursor())Why decomposed? Ink's useCursor and useFocus are independent APIs. An app using only useCursor shouldn't pay for the focus system. Decomposition also makes the mapping clearer: each thin adapter bridges one Ink API to its silvery-native equivalent.
Design Principles
- Plugins are just React providers — no custom API, no registration
- Composition order = nesting order — later plugins wrap earlier ones
- Core providers always present — plugins add on top of silvery's base stack
.Rootis the plugin extension point — composable viaPrevRootpattern- Error boundary is built-in —
SilveryErrorBoundarywraps everything increateApp()
Alternatives Considered
1. Provider Registry Pattern
Register providers globally: silvery.use(InkPlugin). Rejected because:
- Global state causes cross-test contamination
- Order-dependent registration is error-prone
- Can't have different provider stacks for different render instances
2. Middleware Pattern (Redux-style)
Each plugin wraps the render function itself. Rejected because:
- Over-engineered for wrapping React context providers
- The problem is just "add providers to the tree", not "intercept render pipeline"
3. Config Object Pattern
Pass a config describing desired features: { focus: true, cursor: true, theme: 'nord' }. Rejected because:
- Limited to pre-defined options
- Can't support arbitrary third-party providers
- Requires silvery to know about all possible plugins at compile time