2026 03 08_sector_b_input_dispatch_plan - mark-ik/graphshell GitHub Wiki
Doc role: Implementation plan for the input and dispatch registry sector
Status: Active / planning
Date: 2026-03-08
Parent: 2026-03-08_registry_development_plan.md
Registries covered: InputRegistry, ActionRegistry, RendererRegistry (new)
Specs: input_registry_spec.md, action_registry_spec.md
Execution note: RendererRegistry Phase B1 is landed as part of the archived servoshell debt-clear plan Phases 1–2. Sector B2/B3 remain follow-on registry work, with initial typed/context-aware keyboard resolution now landed for the current toolbar and graph-view enter path.
Sector B owns the complete dispatch pipeline from raw user input to authoritative state mutation:
InputEvent (key / gamepad / mouse)
└─► InputRegistry binding × context → ActionId
└─► ActionRegistry ActionId × payload → Vec<GraphIntent> | WorkbenchIntent
└─► Reducer / Workbench Authority
NodeKey + PaneId
└─► RendererRegistry pane attachment table → accept/reject
└─► reconcile_webview_lifecycle() renderer created only after acceptance
RendererRegistry is the most urgent new registry in the codebase: it is the boundary object
required by debtclear Phase 1 and blocks servoshell structural inversion.
For sequencing, treat only Phase B1 as a debt-clear prerequisite. Completing
InputRegistry and ActionRegistry modernization is important, but it is not a
reason to pause debt-clear once the renderer boundary is landing through the
debt-clear plan itself.
| Registry | Struct | Completeness | Key gaps |
|---|---|---|---|
InputRegistry |
✅ | Partial | Typed key bindings and context-aware resolution are landed for the current keyboard path; missing chords, runtime rebind depth, and wider surface coverage. The inherited servoshell gamepad path has been removed and is not standing debt. |
ActionRegistry |
✅ | Partial | Only 7 actions; no namespace enforcement; no capability guard; sync-only handlers |
RendererRegistry |
✅ | Phase B1 landed | Follow-through is mostly validation and broader authority-path cleanup under debtclear, not missing registry scaffolding |
Unlocks: Servoshell debtclear Phase 1 done gate; creation inversion (Phase 2).
Sequencing note: This phase is intentionally duplicated with the debt-clear plan's Phase 1B / Phase 2 work because it is the shared boundary slice. When in doubt, implement it under the debt-clear plan and treat this section as the registry-specific acceptance detail for the same work.
The RendererRegistry maintains a bijective map: each PaneId has at most one active
RendererId; each RendererId is attached to exactly one PaneId.
pub struct PaneAttachment {
pub pane_id: PaneId,
pub renderer_id: RendererId,
pub attached_at: Instant,
pub node_key: Option<NodeKey>, // which node this renderer is serving
}
pub struct RendererRegistry {
by_pane: HashMap<PaneId, PaneAttachment>,
by_renderer: HashMap<RendererId, PaneId>,
}
impl RendererRegistry {
/// Accept a renderer for a pane. Returns Err if pane already has a renderer.
pub fn accept(&mut self, pane: PaneId, renderer: RendererId, node: Option<NodeKey>)
-> Result<(), RendererRegistryError>;
/// Detach renderer from its pane. Must be called before the renderer is destroyed.
pub fn detach(&mut self, renderer: RendererId) -> Option<PaneAttachment>;
/// Look up which renderer is currently serving a pane.
pub fn renderer_for_pane(&self, pane: &PaneId) -> Option<&PaneAttachment>;
/// Look up which pane a renderer is attached to.
pub fn pane_for_renderer(&self, renderer: &RendererId) -> Option<PaneId>;
}Done gates:
-
RendererRegistrystruct inshell/desktop/runtime/registries/renderer.rs. -
accept(),detach(),renderer_for_pane(),pane_for_renderer()implemented. - Added to
RegistryRuntime. -
DIAG_RENDERER_ATTACHandDIAG_RENDERER_DETACHchannels registered (Severity::Info). - Unit tests: accept, detach, double-accept error, lookup consistency.
Per debtclear plan Phase 2A, no renderer may be created in reconcile_webview_lifecycle() unless
the registry has accepted the pane attachment first. The acceptance must come from the workbench
authority's handling of an open request, not from shell code.
Flow:
- Workbench authority receives
WorkbenchIntent::OpenPane { node_key }. - It calls
registries.renderer.accept(pane_id, renderer_id, Some(node_key)). - Only after
accept()succeeds doesreconcile_webview_lifecycle()create the renderer. - If
accept()fails (duplicate),reconcile_webview_lifecycle()does not create and emitsDIAG_RENDERER_ATTACHatWarnseverity.
Done gates:
-
reconcile_webview_lifecycle()checksRendererRegistry::renderer_for_pane()before any renderer creation. - Debtclear Phase 1 acceptance criterion: no shell code creates renderers outside
reconcile_webview_lifecycle(). -
log::warn!emitted on any attempt to create without prior acceptance. - Scenario test: Ctrl+T open flow creates renderer only after registry accepts.
When a pane is closed:
-
WorkbenchIntent::ClosePanetriggersRendererRegistry::detach(). -
reconcile_webview_lifecycle()destroys the renderer. -
DIAG_RENDERER_DETACHemits.
Done gates:
- Detach called from workbench close path before reconcile destroys renderer.
- No orphaned
PaneAttachmententries remain after all panes are closed. - Test: open + close round-trip leaves registry empty.
Unlocks: full typed/context-aware input parity for Graphshell-owned keyboard and pointer paths.
Execution note (2026-03-09): the current keyboard path now uses typed InputBinding values plus InputContext-aware resolution for the existing toolbar submit/navigation bindings and a graph-view Enter mapping. Chord variants remain scaffolded-but-unwired follow-on work. The inherited gamepad route has since been removed; reintroduce gamepad only through a fresh Graphshell-native input design.
Replace the flat string key with a typed binding:
pub enum InputBinding {
Key { modifiers: ModifierMask, keycode: Keycode },
Chord(Vec<InputBinding>), // sequential input sequence
}
pub struct ModifierMask(u8); // bit flags for Ctrl/Shift/Alt/Super
pub enum Keycode {
Named(NamedKey), // Enter, Escape, Arrow*, F1–F12, etc.
Char(char),
}Existing 4 bindings (TOOLBAR_SUBMIT, NAV_BACK, NAV_FORWARD, NAV_RELOAD) are re-expressed
as InputBinding::Key values with appropriate modifier masks.
Done gates:
-
InputBindingenum defined. - Existing bindings migrated; no regressions in unit tests.
-
register_binding(binding: InputBinding, action_id: ActionId, context: InputContext).
Execution note (2026-03-09): deterministic context-aware resolution is now present in the registry for the landed keyboard bindings, including Enter resolving differently in OmnibarOpen and GraphView. Conflict diagnostics are emitted when the same (binding, context) pair is registered to multiple actions.
The input_registry_spec.md's context-resolution policy requires that the same physical input
can resolve to different actions depending on active context.
pub enum InputContext {
GraphView,
DetailView,
OmnibarOpen,
RadialMenuOpen,
DialogOpen,
}
pub fn resolve(&self, binding: &InputBinding, context: InputContext)
-> InputBindingResolutionDone gates:
-
resolve()signature updated to includeInputContext. -
EnterinOmnibarOpen→ omnibar submit;EnterinGraphView→ graph node confirm. - Conflict detection: two actions bound to same (binding, context) pair emits
Warndiagnostic. - Unit tests for each context variant.
The servoshell-inherited gamepad/radial-menu control path has been removed from the active implementation track. It should not be treated as missing work for Sector B completion.
Future gamepad support is allowed only as a deliberate Graphshell-native input design with its own capability model, user-visible settings, and tests. It must not resurrect the old path where controller buttons implicitly drove browser or workbench commands because servoshell did.
Done gate: no active Sector B acceptance criterion depends on gamepad.
The spec requires that bindings can be remapped at runtime (user preferences, mod-provided profiles).
pub fn remap_binding(
&mut self,
old: InputBinding,
new: InputBinding,
context: InputContext,
) -> Result<(), InputConflict>Persisted to user preferences via a GraphIntent carrier after rebind.
Current status (2026-03-10): the runtime now supports conflict-aware remap_binding() replayed
from defaults through the singleton RegistryRuntime, emits an Info diagnostic on successful
rebind application, and persists serialized remap specs through workspace-layout settings so
rebinds restore deterministically on restart.
Done gates:
-
remap_binding()implemented with conflict detection. - Rebind emits
DIAG_INPUT_BINDINGat Info severity. - User preferences round-trip: rebind → persist → restore on restart.
Unlocks: Correct namespace:name key discipline (CLAUDE.md); undoable actions; graph and
workbench action families.
Per CLAUDE.md: "New registry keys must follow the namespace:name pattern."
pub fn register_action(&mut self, id: &str, handler: ActionHandler) {
if !id.contains(':') {
log::warn!("action_registry: key {:?} does not follow namespace:name format", id);
}
// ...
}Existing action IDs are already namespace:name format but should be validated consistently.
Current status (2026-03-09): the current enum-backed ActionRegistry now exposes canonical
namespace:name keys for every ActionId, audits the catalog once at runtime with log::warn!
for any non-conforming key, and has a test gate that fails if an action key drifts off-format.
Done gates:
-
register_action()emitslog::warn!for non-conforming keys. - All existing action ID constants conform (audit + fix).
The action_registry_spec.md's intent-emission policy: actions return Vec<GraphIntent> or
WorkbenchIntent, they do not mutate state directly.
Current execute_graph_view_submit_action() and similar handlers call into graph_app state
directly in some paths. Refactor all handlers to return intents:
pub fn execute(&self, action_id: &str, payload: ActionPayload, context: &ActionContext)
-> ActionOutcome
pub enum ActionOutcome {
Intents(Vec<GraphIntent>),
WorkbenchIntent(WorkbenchIntent),
SignalEmit(SignalEnvelope),
Failure(ActionFailure), // never silent noop
}Current status (2026-03-10): the runtime ActionRegistry now returns explicit ActionOutcome
values, existing handlers fail explicitly instead of silently no-oping on invalid or rejected
input, and Verse pair/share actions now emit reducer-handled intents instead of mutating Verse
state directly inside the action layer.
Done gates:
- All 7 existing handlers refactored to return
ActionOutcome. - No handler directly mutates
GraphBrowserAppfields. -
ActionOutcome::FailureemitsDIAG_ACTION_EXECUTEatErrorseverity. - Unit tests confirm handler return shapes.
Dependency note (2026-03-10): graph:edge_create payload semantics depend on the
subsystem_history/2026-02-20_edge_traversal_impl_plan.md carrier model. If
label-bearing edge metadata is not available on the active GraphIntent /
GraphMutation / GraphDelta / persistence WAL path, this step is not
complete and must remain blocked until the carrier path is upgraded.
Register canonical graph actions:
| Action ID | Payload | Emits |
|---|---|---|
graph:node_open |
{ node_key, pane_id? } |
GraphIntent::ActivateNode |
graph:node_close |
{ node_key } |
GraphIntent::DeactivateNode |
graph:edge_create |
{ from, to, label? } |
GraphIntent::AddEdge |
graph:navigate_back |
— | GraphIntent::TraverseBack |
graph:navigate_forward |
— | GraphIntent::TraverseForward |
graph:select_node |
{ node_key } |
GraphIntent::SelectNode |
graph:deselect_all |
— | GraphIntent::DeselectAll |
Done gates:
- All 7 graph actions registered with intent-returning handlers.
-
graph:navigate_back/forwardreplace any hardcoded navigation calls. -
graph:edge_createsupports{ from, to, label? }end-to-end through the reducer and persistence carrier path (no label drop or silent rejection).
Register canonical workbench actions:
| Action ID | Payload | Emits |
|---|---|---|
workbench:split_horizontal |
{ pane_id } |
WorkbenchIntent::SplitPane(Horizontal) |
workbench:split_vertical |
{ pane_id } |
WorkbenchIntent::SplitPane(Vertical) |
workbench:close_pane |
{ pane_id } |
WorkbenchIntent::ClosePane { pane, restore_previous_focus: true } |
workbench:command_palette_open |
— | WorkbenchIntent::OpenCommandPalette |
workbench:settings_pane_open |
— | WorkbenchIntent::OpenToolPane(Settings) |
workbench:settings_overlay_open |
— | WorkbenchIntent::OpenSettingsUrl(verso://settings/general) |
workbench:settings_open |
— | Legacy alias for workbench:settings_pane_open
|
Done gates:
- All 7 workbench actions registered.
- Workbench intents are routed to the workbench authority, not the graph reducer.
-
log::warn!emitted if a workbench intent is mistakenly sent toapply_reducer_intents(). (This is the SYSTEM_REGISTER "silent no-op" gap fix.)
Implementation note (2026-03-10):
- In the current codebase,
WorkbenchIntentitself does not type-flow intoapply_reducer_intents(). The practical misroute seam isGraphIntentvariants that are actually workbench-authority bridge carriers (currentlyRouteGraphViewToWorkbench). - Therefore B3.4 must be read as requiring:
- a central classifier for reducer-received workbench-authority bridge intents,
- a reducer-ingress
log::warn!when such a bridge intent reachesapply_reducer_intents(), and - forwarding from that bridge seam into the pending workbench-intent queue, not direct tile-tree mutation in reducer logic.
- This is sufficient to make the routing boundary explicit before
WorkbenchSurfaceRegistryexists, but it is not yet the full Sector E authority object.
Implementation note (2026-03-10, pane-id refactor):
- B3.4's generic pane actions were previously blocked by graph/tool panes lacking
stable
PaneId. That prerequisite is now part of the lane's architectural groundwork: graph, node, and tool panes all carryPaneId, and generic split/close operations must target those stable pane identities rather than graph-view IDs or tool kinds. - Command palette is not modeled as
ToolPaneState::CommandPalettein the current architecture. The honest workbench-authority action isWorkbenchIntent::OpenCommandPalette; do not reintroduce a fake tool-pane variant just to satisfy the registry table.
Preconditions / non-blocking groundwork:
-
apply_reducer_intents()andapply_reducer_intent_internal()remain owned bygraph_app.rsafter the decomposition push; B3.4 boundary hardening belongs there unless/until reducer ingress is extracted. -
app/workbench_commands.rsqueue helpers and the desktop frame-loop drain ingui_orchestration.rsare prerequisite seams for B3.4 and should be treated as intentional groundwork, not temporary accidents. - If additional graph-carrier variants begin acting as workbench bridges, they must be added to the central classifier immediately; do not let new bridge variants silently piggyback on reducer traffic.
Each action descriptor carries a capability requirement. execute() checks the caller's capability
token before dispatching.
pub struct ActionDescriptor {
pub id: String,
pub required_capability: Option<ActionCapability>,
pub handler: ActionHandler,
}
pub enum ActionCapability {
AlwaysAvailable,
RequiresActiveNode,
RequiresSelection,
RequiresWritableWorkspace,
}describe_action(id) -> ActionCapability is exposed through RegistryRuntime.
Implementation note (2026-03-10):
-
RequiresWritableWorkspacecurrently exists as an action-contract boundary and UI-availability descriptor, but the codebase does not yet have a first-class read-only workspace mode. Until that lands, writable capability checks can only reject on richer predicates (selection, active-node, etc.) and must not pretend a persistence lock bit exists if it does not.
Done gates:
-
ActionDescriptordefined; all existing actions annotated with capability. -
execute()checks capability; unavailable action returnsActionOutcome::Failure. -
describe_action()exposed onRegistryRuntime.
-
RendererRegistryenforces the creation boundary: debtclear Phase 1 acceptance criterion met. -
InputRegistryresolves active keyboard/pointer bindings through the typed context-aware contract. - Any future gamepad/radial-sector path is covered by a new Graphshell-native input design before implementation.
- All action IDs follow
namespace:nameformat. - Actions return intents, not direct mutations; workbench intents route to workbench authority.
-
log::warn!fires when workbench-authority intents reachapply_reducer_intents(). - Graph and workbench action families are registered and tested.
- Runtime rebinding works and persists through user preferences.
- input_registry_spec.md
- action_registry_spec.md
- SYSTEM_REGISTER.md — two-authority model
- archived servoshell debt-clear plan — RendererRegistry requirement
- ../2026-02-24_control_ui_ux_plan.md — historical control UI plan; inherited gamepad assumptions are not active debt
- 2026-03-08_registry_development_plan.md — master index