26 KiB
APP-3909: Tech Spec — Layout-aware binding descriptions for tab actions
Problem
PR #24105 updates the right-click tab context menu to show "Close tabs below" instead of "Close tabs to the right" when vertical tabs are enabled. The tab-layout-dependent label is computed inline in app/src/tab.rs:close_tab_menu_items, but the underlying EditableBinding for workspace:close_tabs_right_active_tab is still registered with a hardcoded "Close tabs to the right" description. That description is the source of truth for several other surfaces that all still show the horizontal-axis wording even when vertical tabs are enabled:
- the macOS menu bar (Tab > Close Tabs to the Right)
- the command palette (Cmd-P search for "close tabs")
- the settings keybindings page
- the resource center keybindings panel
The same inconsistency already exists for workspace:move_tab_left / workspace:move_tab_right: the tab context menu swaps them to "Move tab up" / "Move tab down" when vertical tabs are enabled, but every other surface continues to show "Move tab left" / "Move tab right". The scope of this spec is all three bindings, not just the one that triggered the review comment.
The goal is to give tab-layout-dependent bindings a single source of truth for their label that every consumer honors, and to make it compile-time impossible for a future consumer to miss the dynamic resolution.
Relevant code
app/src/workspace/mod.rs:772-797— registration ofworkspace:move_tab_left/workspace:move_tab_rightwith static descriptions.app/src/workspace/mod.rs:892-899— registration ofworkspace:close_tabs_right_active_tabwith a static description.app/src/tab.rs:266-356—modify_tab_menu_itemsandclose_tab_menu_items, which already branch onuses_vertical_tabsinline.crates/warpui_core/src/keymap.rs:64-116—BindingDescriptiondefinition and thein_contextlookup API.crates/warpui_core/src/keymap.rs:145-221—BindingLens,EditableBinding,EditableBindingLens.crates/warpui_core/src/core/app.rs:1718-1726—AppContext::description_for_custom_action, used by the menu bar to resolve a custom action's description.app/src/app_menus.rs:486-511—make_new_tab_menu, whereCustomAction::CloseTabsRight,MoveTabLeft, andMoveTabRightare wired.app/src/app_menus.rs:1163-1186—custom_action_updater, the per-menu-item update callback that pullsdescription.in_context(MAC_MENUS_CONTEXT)intoMenuItemPropertyChanges.nameon every menu open.app/src/util/bindings.rs:714-782—CommandBindingand itsfrom_binding/From<BindingLens<'_>>/From<EditableBindingLens<'_>>constructors. These are the cache-population entry points that cloneBindingDescriptioninto a reusable value type.app/src/search/action/data_source.rs:68-94—CommandBindingDataSource::on_binding_source_changed, the command-palette cache-population site.app/src/search/action/data_source.rs:142-182—FuzzyActionSearcher::search, which fuzzy-matches against cached descriptions without access toAppContext.app/src/search/action/data_source.rs:261-298—FullTextActionSearcher::rebuild_search_index, which builds a Tantivy index from cached descriptions without access toAppContext.app/src/search/action/search_item.rs:71-156—MatchedBinding::render_labelandaccessibility_label, which readbinding.descriptionwithout access toAppContext.app/src/settings_view/keybindings.rs:761-800—on_page_selected, the settings keybindings cache-population site.app/src/resource_center/keybindings_page.rs:81-100—KeybindingsView::new, the resource center cache-population site. Built once per panel lifetime.app/src/workspace/tab_settings.rs:352-360—TabSettings::use_vertical_tabsdefinition.
Current state
Binding registration
EditableBinding::new(name, description, action) stores a BindingDescription by value on the binding. BindingDescription holds an immutable default String and an optional map of DescriptionContext-keyed overrides (for example, MAC_MENUS_CONTEXT lets a binding provide a shorter label for the macOS menu bar). in_context(context) returns an &str; there is no way to compute the description at lookup time.
How each surface reads the description
Menu bar. make_new_tab_menu calls updateable_custom_item_without_checkmark(CustomAction::CloseTabsRight, ctx), which constructs a CustomMenuItem whose updater is custom_action_updater. The updater runs every time the menu is opened, calls ctx.description_for_custom_action(action, MAC_MENUS_CONTEXT) via update_custom_action_binding, and copies the result into MenuItemPropertyChanges.name. Because the updater re-runs on each open, the menu bar is the one surface that could already pick up a dynamic label — the only static piece is the binding's own description.
Command palette. When the palette opens, set_command_palette_binding_source emits a change on the BindingSource model, which triggers CommandBindingDataSource::on_binding_source_changed. That method iterates ctx.key_bindings_for_view(window_id, view_id) and converts each BindingLens to a CommandBinding via CommandBinding::from_binding. The conversion clones the BindingDescription into the CommandBinding. The fuzzy/full-text searcher then builds its index from the cached descriptions — those downstream consumers run without AppContext and can only see the concrete cached string.
Settings keybindings page. on_page_selected calls ctx.editable_bindings().map(CommandBinding::from), using From<EditableBindingLens<'_>> for CommandBinding to clone descriptions into the cache.
Resource center keybindings panel. KeybindingsView::new calls ctx.get_key_bindings().map(CommandBinding::from) once during view construction. The cached descriptions live for the lifetime of the panel.
Key constraint: downstream consumers without &AppContext
Several CommandBinding consumers read the description from a path that does not have AppContext:
search_item::MatchedBinding::accessibility_label(no context parameter)FuzzyActionSearcher::search(operates on cached descriptions)FullTextActionSearcher::rebuild_search_index(builds a Tantivy index from cached descriptions)util/bindings::filter_bindings_including_keystroke(no context parameter)settings_view/keybindings::render_summary(only hasAppearance)
Any solution has to materialize dynamic descriptions into concrete Strings at cache-population time (when AppContext is available) so these downstream consumers continue to see ordinary Strings. This rules out "resolve at render time" designs that don't plumb AppContext all the way down.
Why this is the moment to fix the framework, not each surface
The immediate bug is small. The structural risk is that there are four cache-population sites today and the set will grow. A purely per-surface fix relies on every future author remembering to call an override helper after constructing a CommandBinding, and nothing in the type system catches a miss. That is exactly the class of bug that prompted this spec.
Proposed changes
Add first-class support for dynamic description overrides in warpui_core::keymap. Make CommandBinding::from_lens(lens, ctx) the only way to materialize a CommandBinding from a lens, so the compiler forces every cache-population site to pass &AppContext and resolve dynamic description overrides at construction time. Define the three tab-layout overrides next to the binding registrations, and teach the menu-bar updater to resolve dynamically too.
1. Framework: optional dynamic override on BindingDescription
In crates/warpui_core/src/keymap.rs:
- Add a new private field
dynamic_override: Option<Arc<dyn Fn(&AppContext) -> Option<String> + Send + Sync>>toBindingDescription. Using a boxed closure (viaArcsoClonestays cheap) lets registrations define resolvers inline with captured state, rather than forcing every dynamic binding to have a free function. - Replace the
#[derive(PartialEq, Eq, Debug)]onBindingDescriptionwith manual impls. The derived impls don't work becauseArc<dyn Fn>is neitherPartialEqnorDebug. The manualPartialEq/Eqcompares the staticdescription+customoverrides and ignoresdynamic_override; this is safe because the only consumers of description equality (the dedup loops insettings_view/keybindings.rsandresource_center/keybindings_page.rs) operate on post-materializationCommandBindings whosedynamic_overrideis alwaysNone. The manualDebugimpl printsdynamic_override: "<dynamic>"when present. - Add
BindingDescription::with_dynamic_override(self, impl Fn(&AppContext) -> Option<String> + Send + Sync + 'static) -> Self. - Add
BindingDescription::resolve(&self, ctx: &AppContext, context: DescriptionContext) -> Cow<'_, str>that returns a title-casedCow::Owned(override)whendynamic_overridereturnsSome, and otherwise falls back toCow::Borrowed(self.in_context(context)). - Add
BindingDescription::has_dynamic_override(&self) -> boolfor cache-population code that only needs to know whether to materialize. - Keep
in_contextunchanged. It still returns&strand still returns the static default even for bindings with a dynamic override. That keeps the non-context read paths compiling during migration and gives downstream consumers a safe static fallback if a cache was somehow populated without resolution.
BindingDescription is defined in the same crate as AppContext (crates/warpui_core/src/core/app.rs), and AppContext already owns the keystroke_matcher: Matcher that holds bindings. There is no layering or crate-graph concern here.
2. App layer: inline override closures at registration
Add a small uses_vertical_tabs(ctx: &AppContext) -> bool helper in app/src/workspace/tab_settings.rs (or tab.rs) and reuse it from tab.rs:close_tab_menu_items and tab.rs:modify_tab_menu_items so there is exactly one definition of the predicate.
Then update the three EditableBinding::new calls in app/src/workspace/mod.rs with inline override closures. Each closure returns Some(...) only when vertical tabs need a label different from the static fallback. resolve applies the same titlecase normalization used by BindingDescription::new, so the tab context menu, menu bar, command palette, and keybindings pages all agree:
EditableBinding::new(
"workspace:close_tabs_right_active_tab",
BindingDescription::new("Close tabs to the right").with_dynamic_override(|ctx| {
uses_vertical_tabs(ctx).then(|| "close tabs below".into())
}),
WorkspaceAction::CloseTabsRightActiveTab,
)
The same pattern is applied to workspace:move_tab_left (swaps to "Move Tab Up") and workspace:move_tab_right (swaps to "Move Tab Down"). The tab context-menu literals in tab.rs:modify_tab_menu_items / close_tab_menu_items are updated to match the same Title Case so every surface is consistent.
The static "Close tabs to the right" is retained as the non-context fallback so downstream read paths without AppContext remain sensible, and so titlecase normalization still runs once at registration.
3. App layer: compile-enforced cache-population API
In app/src/util/bindings.rs:
- Remove
impl From<BindingLens<'_>> for CommandBindingandimpl From<EditableBindingLens<'_>> for CommandBinding. They cannot express an&AppContextdependency, which is exactly what we want the type system to enforce. - Replace them with:
impl CommandBinding {
pub fn from_lens(lens: BindingLens<'_>, ctx: &AppContext) -> Option<Self> { ... }
pub fn from_editable_lens(lens: EditableBindingLens<'_>, ctx: &AppContext) -> Self { ... }
}
- Each constructor inspects the source
BindingDescription. Ifhas_dynamic_override()is true, it storeslens.description.materialized(ctx)on theCommandBinding. If there is no dynamic override, the existingclone()path is preserved. CommandBinding::from_bindingbecomes a thin wrapper overfrom_lensfor callers that currently pass aBindingLenswithout constructing one themselves (search for the three remaining call sites and migrate them).
4. Call-site migration (four sites)
All four sites already have &AppContext available, so the migration is mechanical.
app/src/search/action/data_source.rs:on_binding_source_changed— changeCommandBinding::from_binding(binding)toCommandBinding::from_lens(binding, ctx).app/src/settings_view/keybindings.rs:on_page_selected— changectx.editable_bindings().map(CommandBinding::from)toctx.editable_bindings().map(|lens| CommandBinding::from_editable_lens(lens, ctx)).app/src/resource_center/keybindings_page.rs:KeybindingsView::new— same treatment:ctx.get_key_bindings().map(|lens| CommandBinding::from_lens(lens, ctx)).- Any remaining callers of the deleted
Fromimpls thatcargo buildsurfaces.
Additionally, KeybindingsView::new subscribes to TabSettings via ctx.observe (pattern already used in settings_view/appearance_page.rs) and rebuilds self.bindings / self.binding_results when use_vertical_tabs flips. This is the one surface whose cache lifetime is longer than a single open and therefore needs explicit invalidation.
5. Menu bar: delegate to resolve in the updater
In app/src/app_menus.rs:custom_action_updater, replace description.in_context(bindings::MAC_MENUS_CONTEXT).to_string() with description.resolve(ctx, bindings::MAC_MENUS_CONTEXT).into_owned(). This single change gives the macOS menu bar dynamic labels for every binding that opts in, without per-CustomAction special-casing. No changes to make_new_tab_menu are required.
The menu bar is the only surface that can safely call resolve at render time, because custom_action_updater already takes &mut AppContext and re-runs on every menu open.
6. Tests
Add unit tests in crates/warpui_core/src/keymap_test.rs exercising:
BindingDescription::new("static").resolve(ctx, Default)returnsCow::Borrowed("Static")(preserves title-casing).BindingDescription::new("static").with_dynamic_override(|_| Some("dynamic".into())).resolve(ctx, Default)returnsCow::Owned("Dynamic").has_dynamic_override()reports correctly.in_contextstill returns the static fallback on a description that also has a dynamic override.
Add an integration-ish test alongside CommandBinding::from_lens in app/src/util/bindings.rs (or a new test file) that constructs an EditableBinding with .with_dynamic_override(...), builds a lens, and asserts the materialized CommandBinding.description.in_context(Default) returns the dynamic value.
End-to-end flow
Command palette open
- User presses Cmd-P.
Workspace::open_command_palette→set_command_palette_binding_source. - The binding source model notifies, waking
CommandBindingDataSource::on_binding_source_changed. - For each
BindingLensinctx.key_bindings_for_view(...), the data source callsCommandBinding::from_lens(lens, ctx). - Inside
from_lens,lens.description.has_dynamic_override()is true forworkspace:close_tabs_right_active_tab. The constructor callslens.description.materialized(ctx)→ the inline override closure → readsTabSettings::use_vertical_tabsand returnsSome("close tabs below"). from_lensstores a freshBindingDescription::new_preserve_case("Close Tabs Below")in the cachedCommandBinding.- The fuzzy/full-text searcher rebuilds its index from the materialized strings. The user typing "below" now matches.
macOS menu bar open
- User opens the Tab menu. macOS sends
menuNeedsUpdate. - Cocoa calls each menu item's updater, landing in
custom_action_updater. - The updater resolves the active binding for the
CustomActionviactx.update_custom_action_binding, readsbinding.description, and callsdescription.resolve(ctx, MAC_MENUS_CONTEXT). resolveseesdynamic_overrideis set and returnsCow::Owned("Close Tabs Below".into()).MenuItemPropertyChanges.nameis set to the dynamic value; Cocoa updates the menu item label.
TabSettings toggle while the resource center panel is open
- User toggles "Use vertical tabs" in settings.
TabSettingsmodel emits a change. KeybindingsViewis subscribed viactx.observe, reacts by rebuildingself.bindingsandself.binding_results.- The rebuilt cache re-invokes
CommandBinding::from_lens(..., ctx), re-resolving dynamic description overrides. ctx.notify()schedules a redraw. The panel now shows "Close Tabs Below".
flowchart TD
R[EditableBinding registration in workspace/mod.rs<br/>static + with_dynamic_override override]
KM[warpui_core Matcher / Keymap]
R --> KM
subgraph Cache-population surfaces have AppContext
CP[Command palette<br/>on_binding_source_changed]
SK[Settings keybindings<br/>on_page_selected]
RC[Resource center<br/>KeybindingsView::new + TabSettings observer]
MB[Menu bar<br/>custom_action_updater]
end
KM -->|BindingLens| CP
KM -->|EditableBindingLens| SK
KM -->|BindingLens| RC
KM -->|BindingLens| MB
CP -->|from_lens lens ctx| CB1[CommandBinding with materialized description]
SK -->|from_editable_lens lens ctx| CB2[CommandBinding with materialized description]
RC -->|from_lens lens ctx| CB3[CommandBinding with materialized description]
MB -->|description.resolve ctx MAC_MENUS_CONTEXT| MN[MenuItemPropertyChanges.name]
CB1 --> FZ[FuzzyActionSearcher / FullTextActionSearcher]
CB1 --> AL[MatchedBinding::accessibility_label]
CB2 --> KR[KeybindingRow::render_summary]
CB3 --> RR[Resource center render_section]
Alternatives considered
A. Per-surface overrides via a shared helper
Add apply_tab_layout_overrides(&mut CommandBinding, &AppContext) in app/src/util/bindings.rs with a match statement over binding name, and call it from every cache-population site (plus a custom updater in the menu bar).
Pros: smallest diff, contained to the app crate.
Cons: nothing in the type system forces a future cache-population site to call it. The "list of layout-aware bindings" lives in a centralized match statement rather than alongside the binding registrations. The menu bar has to duplicate the label-resolution logic in a custom updater. This is the primary risk we are trying to eliminate, so it's a non-starter.
B. Layout-agnostic wording
Rename the labels to be layout-neutral (for example, "Close tabs after this one", "Move tab toward end"). Remove the context-menu branching.
Pros: zero dynamic code; a single source of truth.
Cons: regresses UX for horizontal-tabs users who are used to the directional wording. Reverts the cleaner context-menu wording shipped with the vertical-tabs effort. Does not match the reviewer's intent.
D. Register two bindings with enabled_predicate
Register both a "close tabs to the right" and a "close tabs below" binding and gate them with enabled_predicate.
Cons: EnabledPredicate is fn() -> bool with no AppContext; the vertical-tabs setting is a user preference, not a feature flag. Two different binding names break custom keystroke persistence (user-defined keystrokes stick to one name). Does not generalize without four bindings for the MoveTab{Left,Right} pair.
E. Menu bar only, file follow-up for everything else
Pros: smallest possible diff.
Cons: leaves the command palette and keybindings pages inconsistent. Only a marginal improvement over the reviewer's stated minimum bar.
Conclusion
Approach C (the one specified above) is the only option that makes the cache-population invariant compile-time-enforced while keeping per-binding logic co-located with the registration. The framework change is bounded (≈ 40 lines in warpui_core plus a four-site app migration) and the resulting API is reusable for any future dynamic binding label.
Risks and mitigations
Stale cache in the resource center panel
Risk: KeybindingsView::new materializes dynamic descriptions once, so toggling vertical tabs while the panel is open leaves the strings stale.
Mitigation: subscribe to TabSettings via ctx.observe and rebuild self.bindings / self.binding_results on change. Pattern already used in settings_view/appearance_page.rs.
Accidental use of in_context instead of resolve
Risk: a consumer with AppContext calls binding.description.in_context(...) and silently gets the static fallback instead of the dynamic value.
Mitigation: this is intentional for downstream consumers without AppContext, but for consumers that do have it we want them to go through resolve. The main offender would be a new surface added in the future. Mitigated structurally by routing all CommandBinding construction through from_lens(..., ctx) — consumers should read binding.description.in_context(...) on a CommandBinding, never on a BindingLens directly, and the materialization inside from_lens means the CommandBinding's description is already the resolved string. Add a rustdoc comment on BindingDescription::in_context explaining the two modes and pointing to resolve.
Closure Send + Sync + 'static bound
Risk: closures passed to with_dynamic_override must be Send + Sync + 'static. A future dynamic override that tries to capture a non-Send handle would fail to compile.
Mitigation: the bound is correct for our use case (reading global app state through &AppContext) and is the same bound we'd hand-write if we cared about thread safety anyway. The error message is a standard trait-bound complaint, not unusual for Rust.
Manual PartialEq/Eq/Debug impls
Risk: Arc<dyn Fn> isn't PartialEq or Debug, so the derived impls on BindingDescription break. A hand-written impl that ignores dynamic_override could be subtly wrong.
Mitigation: equality is only consumed by the dedup loops in settings_view/keybindings.rs and resource_center/keybindings_page.rs, both of which run against post-materialization CommandBindings (whose dynamic_override is always None). The manual PartialEq compares the static string + custom overrides and drops dynamic_override from the comparison — safe by construction. The Debug impl prints dynamic_override: "<dynamic>" when present, which is sufficient for diagnostics.
Migration churn
Risk: removing the From<BindingLens<'_>> / From<EditableBindingLens<'_>> impls breaks any downstream code that depended on them.
Mitigation: grep results show exactly four call sites in the app crate, all with AppContext in scope. There are no external consumers. The compile error is the feature, not a bug.
Title-casing inconsistency
Risk: BindingDescription::new runs the static description through titlecase, but resolve returns the raw result of the dynamic override.
Mitigation: resolve title-cases any dynamic override before returning it, matching the static normalization performed by BindingDescription::new. Dynamic override closures can therefore return sentence-case labels such as "close tabs below" without duplicating title-cased fallback strings.
Testing and validation
Unit tests
crates/warpui_core/src/keymap_test.rs:BindingDescription::new("foo").resolve(&ctx, Default)returnsCow::Borrowed("Foo").BindingDescription::new("foo").with_dynamic_override(|_| Some("bar".into())).resolve(&ctx, Default)returnsCow::Owned("Bar").has_dynamic_override()returns true afterwith_dynamic_override.- A description with both
with_custom_description(MAC_MENUS_CONTEXT, ...)and a dynamic override that returnsNonefalls back to the custom description for that context. in_contextstill returns the static default when a dynamic override is present (no regressions on the non-context read path).- Two
BindingDescriptions with the same static string but different dynamic overrides compareeq(equality ignoresdynamic_override).
Targeted test run
cargo nextest run -p warp_app
cargo nextest run -p warpui_core
cargo test --doc -p warpui_core
Manual validation
Toggle vertical tabs on and off with the appearance settings while observing the four surfaces:
- Tab context menu (should already work, unchanged by this spec).
- Tab > Close Tabs menu bar.
- Cmd-P and search for "close tabs".
- Settings > Keyboard Shortcuts, search for "close tabs".
- Help > Keyboard Shortcuts, search for "close tabs".
Repeat for "move tab" to verify MoveTabLeft / MoveTabRight as well. Verify that:
- With horizontal tabs: labels read "Close Tabs to the Right", "Move Tab Left", "Move Tab Right".
- With vertical tabs: labels read "Close Tabs Below", "Move Tab Up", "Move Tab Down".
- Toggling the setting with the resource center keybindings panel already open updates the labels without reopening the panel.
Presubmit
Run ./script/presubmit before pushing the PR; specifically ensure cargo fmt and cargo clippy --workspace --all-targets --all-features --tests -- -D warnings pass.
Follow-ups
- Deprecate
BindingDescription::in_contexton consumers that do haveAppContext. Likely requires walking the remaining call sites and deciding whether to add a lint or a#[deprecated]marker. - Evaluate extending dynamic descriptions to other user-preference-dependent labels that may exist (for example, feature-flag-gated actions whose names change as flags flip). Out of scope for this change.
- Consider whether
FixedBindingshould also expose awith_dynamic_overridebuilder for parity. Not required today; all layout-aware bindings are editable. - If we later migrate
EnabledPredicateto take&AppContext, the dynamic-description mechanism and the enabled-predicate mechanism should share the same resolver pattern.