Files
rkw6086 7c106eecd5 feat: expand Galaxy agent and remote tooling
Add Wormhole remote helpers, provider and agent improvements, filesystem diagnostics, model metadata support, and schema-aware settings IntelliSense.
2026-08-23 13:55:47 -05:00

52 KiB
Raw Permalink Blame History

TECH.md — Per-tab theme overrides driven by directory and launch configurations

Issue: https://github.com/warpdotdev/warp/issues/478 Related: https://github.com/warpdotdev/warp/issues/2618 Product spec: specs/GH478/product.md

Context

The Warp theme is currently a single global value derived from ThemeSettings. The renderer reads it through the global Appearance singleton, which today owns exactly one resolved WarpTheme. Per-tab overrides therefore require changes in four layers — the launch configuration schema, a new directory-pattern settings group, the persisted tab snapshot (with override-source semantics), and the renderer's theme lookup (with a theme catalog so non-active themes can be borrowed by reference). None of this requires changing the theme storage format or adding new theme types.

All file references are at HEAD on master.

Launch configuration schema

app/src/launch_configs/launch_config.rs defines the YAML data model:

  • LaunchConfig (lines 1420) — name, active_window_index, windows.
  • WindowTemplate (lines 3641) — active_tab_index, tabs. No theme field today.
  • TabTemplate (lines 180187) — title, layout, color: Option<AnsiColorIdentifier>. The color field (line 186) is the closest precedent for a per-tab theme field.
  • The file already imports crate::themes::theme::AnsiColorIdentifier (line 7).

Theme model and global theme settings

  • app/src/themes/theme.rs:42100ThemeKind enum, the canonical theme identifier. Built-in variants plus Custom(CustomTheme) and CustomBase16(CustomTheme). Derives Serialize, Deserialize, JsonSchema, settings_value::SettingsValue.
  • app/src/themes/theme.rs:112+Display for ThemeKind returns the canonical user-facing string ("Dark City", "Solarized Dark", etc.).
  • app/src/settings/theme.rs:1448ThemeSettings group; theme_kind field with toml_path: "appearance.themes.theme" (line 23).
  • app/src/settings/theme.rs:6978derived_theme_kind, applies system-light/dark logic. Wrapped by per-tab resolution.
  • app/src/settings/theme.rs:8183active_theme_kind, current entry point for "what theme should we show?".

Renderer entry point

  • app/src/appearance.rs:34 re-exports Appearance from warp_core::ui::appearance.
  • app/src/appearance.rs:4047AppearanceManager; subscribes to ThemeSettings (line 51) and pushes changes into Appearance.
  • Appearance today owns one WarpTheme. Per-tab overrides require it to own a catalog (see Proposed changes §5).

Tab and window state

  • app/src/app_state.rs:4359WindowSnapshot. No theme field.
  • app/src/app_state.rs:6169TabSnapshot. Carries default_directory_color and selected_color: SelectedTabColor (the per-tab indicator color).
  • app/src/app_state.rs:7175TabSnapshot::color(), the resolution helper for the indicator.
  • app/src/tab.rs:134143TabData (runtime), holds selected_color.

Existing precedent — per-tab color

The path that loads TabTemplate.color into a runtime tab is the closest analog. Today:

  1. TabTemplate.color: Option<AnsiColorIdentifier> parsed from YAML (launch_config.rs:186).
  2. On launch-config open, app/src/workspace/view.rs:35173519 writes tab_template.color into self.tabs[…].selected_color via SelectedTabColor::Color.
  3. TabSnapshot.selected_color persists this through session save/restore.

The new override follows the same path but with richer types (an override-source enum, see §3) and an apply-time resolver (§2) instead of direct serde (which is what Oz's first-pass review correctly flagged).

Existing pane cwd tracking

The active pane's cwd is already tracked for tab title generation and breadcrumbs. The directory-pattern feature (§4) hooks into the existing "focused pane cwd changed" event rather than introducing new shell-side plumbing.

Proposed changes

Six steps in dependency order. Each compiles standalone.

1. Schema: theme: Option<String> on TabTemplate and WindowTemplate

File: app/src/launch_configs/launch_config.rs.

Add a new field to both structs:

#[serde(skip_serializing_if = "Option::is_none", default)]
pub theme: Option<String>,

The field is Option<String> rather than Option<ThemeKind> for two reasons that directly address Oz's first-pass concerns:

  • Serde-compat (Oz concern 1). ThemeKind derives Deserialize, but its accepted string form (variant names / snake-case) is not guaranteed to match the human-readable display strings the product spec promises ("Dark City", "Solarized Dark"). Going through String decouples the YAML surface from the enum's internal representation.
  • Field-level fallback (Oz concern 2). A direct ThemeKind deserialization on an unknown string would fail the entire YAML load and reject every tab in the file. With Option<String> the load always succeeds; resolution and fallback happen at apply time per the product spec's behavior #11.

The From<WindowSnapshot> for WindowTemplate (lines 4370) and TryFrom<TabSnapshot> for TabTemplate (lines 189200) impls implement the save rule from product behavior #10. The "preserved override" for a tab is computed as:

fn preserved_override(state: &TabThemeState) -> Option<&ThemeKind> {
    // menu_pin and launch_config_pin both round-trip through saved
    // launch configs as tab-level `theme:`. menu_pin wins on
    // resolution (layer #1 > #2), so we save the higher-priority slot
    // when both are set; on reopen the field reseeds launch_config_pin
    // and the user's prior menu_pin (if any) is restored from session
    // state independently.
    state.menu_pin.as_ref()
        .or(state.launch_config_pin.as_ref())
        .or(state.window_default.as_ref())
}

(Directory-matched themes are deliberately not part of preserved_override — they round-trip through directory_overrides, not through the saved launch configuration.)

From<WindowSnapshot> for WindowTemplate then:

  1. Computes the preserved override for every tab.
  2. If every tab's preserved override is the same Some(X) and no tab has a manual pin different from the others, emits the saved WindowTemplate with theme: Some(X.to_string()) and clears the per-tab theme: on every TabTemplate. This is the common case — a window opened from a launch configuration with a window-level theme: and no per-tab pinning round-trips losslessly.
  3. Otherwise, emits no window-level theme: and writes each tab's preserved override (if any) into the corresponding TabTemplate.theme.
  4. Tabs whose preserved override is None (all sources empty, or only cwd_resolved populated) emit no theme: field. Their effective theme on reopen will be either a fresh directory match or the global theme.

This is what Oz's v3 review correctly flagged: a window opened with a window-level theme: would otherwise drop the theme on save, because v2's "only emit manual" rule treated window_default as not preserved. The preserved_override helper makes the logic symmetric with resolution and adds an integration test (in §6) that exercises the full round-trip.

Round-trip and serde tests live in launch_configs/launch_config_tests.rs (referenced by the #[path] attribute on line 11) and gain coverage in §6.

2. Theme-name resolver

A new helper, alongside ThemeKind in app/src/themes/theme.rs:

/// Resolve a free-form theme reference (from a launch configuration or
/// from `directory_overrides`) to a `ThemeKind`.
///
/// Accepts:
///   * Display strings: "Dark City", "Solarized Dark", "Dracula"
///   * Snake-case strings: "dark_city", "solarized_dark", "dracula"
///   * Custom theme names registered via the theme loader
///
/// Matching is case-insensitive on whitespace-stripped input.
/// Returns `None` for unknown names; callers log a warning and fall
/// through to the next resolution layer.
pub fn resolve_theme_ref(raw: &str) -> Option<ThemeKind> { ... }

Implementation:

  1. Trim and case-fold the input.
  2. Walk built-in ThemeKind variants; return the first whose Display string or snake-case form matches.
  3. Consult the custom-theme registry the existing loader maintains (the same registry that powers the theme picker).
  4. Return None on no match.

This satisfies Oz concerns 1 and 2 by making YAML-format coupling explicit and keeping unknown-theme handling field-level.

3. TabThemeState on TabSnapshot and TabData

File: app/src/app_state.rs and app/src/tab.rs.

A tab can have up to four independent theme inputs at once — a menu pin, a launch-config manual pin, a cwd-pattern match, and a launch-config window-level default — and the product spec defines a strict priority order between them (menu > launch-config-manual > cwd > window default > global). A single Option<enum> cannot represent the state "menu pin cleared, launch-config-manual still applies, cwd also exists as a deeper fallback" because the sources are not mutually exclusive at storage time, only at render time. Replace the single field with a struct holding all four slots, plus a resolver:

/// Per-tab theme state. Each slot is independent storage for one
/// resolution layer; the resolver in `effective()` enforces the
/// priority order from `product.md` §Resolution order.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct TabThemeState {
    /// Layer #1. Set by the right-click "Pin theme" menu; cleared by
    /// the right-click "Reset theme" menu.
    /// **Persisted across sessions.**
    pub menu_pin: Option<ThemeKind>,

    /// Layer #2. Set by a tab-level `theme:` in the launch
    /// configuration that opened (or restored) the tab. Cleared only
    /// by the right-click "Forget launch config theme" menu, never by
    /// "Reset theme" — see Zach's v4 review.
    /// **Persisted across sessions.**
    pub launch_config_pin: Option<ThemeKind>,

    /// Layer #3. Computed by directory-pattern matching against the
    /// focused pane's cwd. Recomputed on tab creation, on focused-pane
    /// cwd change, and on `directory_overrides` settings change.
    /// **Not persisted** — recomputed on startup from current settings
    /// + restored cwd.
    pub cwd_resolved: Option<ThemeKind>,

    /// Layer #4. Set at open time when the tab was created from a
    /// launch configuration whose window had a window-level `theme:`.
    /// **Persisted across sessions** (the launch config that opened
    /// the tab is not necessarily reopened on restore).
    pub window_default: Option<ThemeKind>,
}

impl TabThemeState {
    /// Resolution order: menu_pin > launch_config_pin > cwd_resolved
    /// > window_default > caller's global fallback. Mirrors
    /// product.md §"Resolution order" exactly.
    pub fn effective<'a>(&'a self, global: &'a ThemeKind) -> &'a ThemeKind {
        self.menu_pin.as_ref()
            .or(self.launch_config_pin.as_ref())
            .or(self.cwd_resolved.as_ref())
            .or(self.window_default.as_ref())
            .unwrap_or(global)
    }

    pub fn has_any_override(&self) -> bool {
        self.menu_pin.is_some()
            || self.launch_config_pin.is_some()
            || self.cwd_resolved.is_some()
            || self.window_default.is_some()
    }
}

Add pub theme_state: TabThemeState to:

  • TabSnapshot (app_state.rs:6169), placed next to selected_color.
  • TabData (tab.rs:134), same.

Session serialization writes menu_pin, launch_config_pin, and window_default; cwd_resolved is recomputed on startup. The serializer omits the field entirely when all four slots are None, so existing sessions with no overrides round-trip with no schema cost.

WindowSnapshot does not gain a theme field; the window-level launch-config theme lives only on the tabs it opened (in their window_default slot). The dedicated slot is what makes effective()'s priority order honor the product spec without collapsing into an enum that could mis-rank window defaults — the finding Oz raised in the v2 review.

Menu actions (§6) target individual slots:

  • Reset theme clears menu_pin only. launch_config_pin, cwd_resolved, and window_default are unaffected — the tab falls through to the next non-empty layer per effective().
  • Forget launch config theme clears launch_config_pin only.
  • "Pin theme..." sets menu_pin.

A tab can have a menu pin AND a launch-config pin simultaneously: the menu pin wins for rendering (layer #1 > layer #2), and clearing the menu pin reveals the launch-config pin underneath. This is exactly the behavior Zach's v4 review asked for.

4. Directory-pattern overrides settings group

A new settings group, defined in a new file app/src/settings/directory_overrides.rs and exported from app/src/settings/mod.rs:

define_settings_group!(DirectoryThemeOverrides, settings: [
    overrides: Map {
        // Stored as TOML table: keys = directory paths, values = theme refs.
        type: BTreeMap<String, String>,
        default: BTreeMap::new(),
        supported_platforms: SupportedPlatforms::ALL,
        // **Local-only.** Directory paths can encode employer, customer,
        // and project names (e.g. `~/Work/<client>/<engagement>/...`).
        // Cloud-syncing the keys would leak that organizational context
        // off-machine; cloud-syncing the values without the keys is
        // useless. The setting is therefore not synced. Per-tab themes
        // remain user-controllable through the right-click "Pin theme"
        // menu, which writes to the (non-synced) tab snapshot, not to
        // this map. See *Privacy model* below.
        // (Per Zach's v4 review: `Never` is the correct variant here.
        // The settings system uses `Globally`, `Never`, `PerPlatform`;
        // there is no `Locally` variant.)
        sync_to_cloud: SyncToCloud::Never,
        private: true,
        toml_path: "appearance.themes.directory_overrides",
        max_table_depth: 1,
        description: "Local map of directory paths to theme names. The \
                      active pane's cwd is matched against keys (longest \
                      prefix wins); the matched theme overrides the global \
                      theme for that tab. Stored locally; never synced to \
                      Warp's cloud because path keys can leak employer or \
                      project names.",
    },
]);

Privacy model (responding to Oz's [SECURITY] finding on v2)

Directory paths are not just configuration — they encode information about the user's employer, clients, and projects. A key like ~/Work/AcmeCorp/redesign-2026 reveals all three. The settings system already distinguishes synced from local settings via sync_to_cloud and private; the design rule applied here is:

  • Keys are locally-stored, never synced. sync_to_cloud: Never and private: true. The map is written only to the user's local settings.toml and is not transmitted off-machine by the settings sync path.
  • No telemetry on the contents. The match function emits at most a count metric ("a directory match applied to N tabs this minute"); it never logs path keys or theme names to remote telemetry pipelines.
  • Local logs are also redacted. Diagnostic output is routinely shared in bug reports and support sessions, so even the local Warp log must not contain raw directory_overrides keys. The redaction rule and helper are specced in Diagnostic redaction below.
  • No round-tripping into shareable artifacts. A saved launch configuration does not emit directory_overrides entries (product spec #10). Launch configurations are explicitly designed to be shared between machines and users; the directory map is not.
  • Opt-in cloud sync is a follow-up. A future cloud_sync_directory_overrides setting could let users with enterprise sync needs share the map to themselves across machines. That requires a separate spec covering opt-in UI, encryption-at-rest of keys, and admin-policy controls; it is deliberately not pre-paid for here.

Diagnostic redaction

Helper, alongside the settings group:

Per Oz's v5 [SECURITY] review: a stable unsalted 24-bit FxHash over the raw key is dictionary-guessable — an adversary with a shared log can hash a list of plausible candidate paths and recover the key by collision. The fix is a per-installation keyed hash: a 32-byte random salt is generated on first launch, stored in a local-only, non-synced file (~/.warp/redaction_salt, mode 0600), and never leaves the machine.

/// Stable, **non-reversible** short identifier for a
/// `directory_overrides` key. Suitable for inclusion in user-facing
/// warnings and locally-shared diagnostic logs.
///
/// Implementation: HMAC-SHA256(installation_salt, raw_key), truncated
/// to 6 hex chars (24 bits). The installation salt is read from
/// `~/.warp/redaction_salt` (generated on first launch, mode 0600,
/// never synced). Because the salt is per-installation and never
/// shared, an identifier leaked in a log file cannot be reversed by
/// a dictionary attack against candidate paths — the attacker would
/// need the salt, which only ever exists on the originating machine.
///
/// Truncation to 24 bits keeps the identifier short enough for
/// readable log lines; with O(10) entries per typical user the
/// collision probability inside a single installation is negligible,
/// and cross-installation collisions are irrelevant because each
/// installation has its own salt.
pub fn redacted_key_id(raw_key: &str, salt: &InstallationSalt) -> String {
    let tag = hmac_sha256::HMAC::mac(raw_key.as_bytes(), salt.as_bytes());
    let truncated = u32::from_be_bytes([tag[0], tag[1], tag[2], tag[3]])
        & 0x00ff_ffff;
    format!("{truncated:06x}")
}

InstallationSalt is a thin newtype around [u8; 32] whose constructor reads the salt file, generating it (with rand::OsRng, mode 0600 write) if missing. It is loaded once into the settings subsystem at startup and passed into redacted_key_id as a borrow; no global static is required.

If the salt file is missing or unreadable at runtime — for example the user deleted it, or Warp is running in a container without write access — the redaction helper emits directory_overrides[unsalted]: ... with no derived identifier from the path at all, which is the safest fallback and surfaces the configuration error to the user.

Even-stronger alternative considered and rejected: storing an opaque local ID alongside each entry (e.g. a sidecar file mapping key → uuid). That removes the hash entirely but requires every settings edit to round-trip through Warp's runtime to allocate IDs for new keys; users editing settings.toml directly in a text editor would not get an ID until next launch, so the diagnostic for a freshly-typed bad theme value would be directory_overrides[unidentified]. The salted-HMAC scheme is deterministic from the key alone and preserves "edit settings.toml, get an immediate identifying warning" — the property that makes the diagnostics actually useful for the user.

Usage rule (enforced by code review and by the tests in Privacy invariant tests): every log / warn / error line emitted by the directory_overrides matcher, settings subscriber, or value validator must reference offending entries by redacted_key_id only.

Warnings include the offending value (the theme name) verbatim because theme names are not sensitive and are needed for the user to locate the entry in their settings.toml. Example warning text:

directory_overrides[id=8a3f9c]: unknown theme "Drakula" — matching this entry will be skipped until the value is corrected

The identifier is stable for a given key on a given machine, so the same warning recurs with the same id across Warp restarts and helps the user correlate multiple diagnostics about the same entry. The identifier does not match between machines (different salts), so correlation across users in a shared bug-report channel is impossible — which is the point.

Resolution helper, alongside the group:

/// Returns the theme to use for `cwd`, walking the directory-overrides
/// map by longest-prefix match. Returns `None` when no key matches.
/// Tilde expansion and trailing-slash normalization happen here.
pub fn directory_theme_for(
    overrides: &BTreeMap<String, String>,
    cwd: &Path,
) -> Option<ThemeKind> { ... }

Match semantics (per product spec #2, #3):

  • Keys are tilde-expanded once at evaluation time. On Linux/macOS tilde expands to $HOME; on Windows to %USERPROFILE%.
  • Both / and \ are accepted as separators in keys; normalization rewrites them to the platform's canonical form via Path::components() before comparison.
  • On Windows, drive-letter prefixes are normalized to uppercase (c:\…C:\…).
  • A key matches a cwd if it is a prefix at a path-component boundary. Implementation: walk both paths' Path::components() after normalization and require the key's components to be an exact prefix of the cwd's. Using str::starts_with would let ~/Work/medone spuriously match ~/Work/medone-archive and is forbidden.
  • Case sensitivity follows the platform's filesystem default: case-sensitive on Linux, case-insensitive on macOS, case-insensitive on Windows. Implementation uses unicase::eq(key_component, cwd_component) on Windows and macOS, key_component == cwd_component on Linux. Tests cover each platform.
  • The longest matching key wins (most components after normalization, not most bytes — ~/Work is shorter than ~/Work/medone regardless of how the user typed them).
  • Each value is resolved via resolve_theme_ref; unresolved values are skipped and a warning is logged once at settings-change time, not per-match. Warnings refer to the offending entry by a short hash of its key, never by the key itself, per Diagnostic redaction below.

Matching is invoked from three places (responding to Oz's v2 finding that settings edits were not handled):

  • Tab creation — when a tab is added (from a launch configuration or from "new tab"), the focused pane's cwd is matched and tab.theme_state.cwd_resolved is set to the result (None on no match). The launch_config_pin slot is set independently from the launch config (tab_template.theme resolved through resolve_theme_ref); the window_default slot is set similarly from the window-level theme:. None of these is gated on cwd_resolved being absent — every applicable slot is filled, and effective() chooses the winner.
  • Focused-pane cwd-change events — the existing pane-cwd tracker emits a "focused pane cwd changed" event used today for tab title updates. A new subscriber re-runs directory_theme_for for the affected tab when the cwd differs from the last-matched key. The runtime tab retains the last-matched key so re-matching is O(1) in the no-change case. Result is written to cwd_resolved; if the effective theme (per TabThemeState::effective) changes, the tab re-renders.
  • Settings-change events on DirectoryThemeOverrides — the define_settings_group! macro emits change events the same way ThemeSettings does (app/src/appearance.rs:51 is the existing pattern). A new subscription on DirectoryThemeOverrides::handle(ctx) walks every open tab in every window, recomputes cwd_resolved from the new map and the tab's current cwd, and re-renders each tab whose effective theme changed. Implements product behavior #6.

The same subscription handles validation: any value in the new map that fails resolve_theme_ref is logged once via the redacted_key_id helper (the raw key is never included in the warning, per Diagnostic redaction below) and treated as absent for matching purposes. Subsequent edits that fix the value re-trigger this validation.

5. Renderer: Appearance gains a theme catalog

This addresses Oz concern 3 (Appearance::theme_for cannot return &WarpTheme for arbitrary overrides because Appearance only owns the global WarpTheme today).

Appearance is extended in warp_core::ui::appearance:

pub struct Appearance {
    // existing fields...

    /// Currently-active global theme. Always present in the catalog
    /// below, but tracked separately so `theme()` is O(1).
    global_theme: Arc<WarpTheme>,

    /// Lazy cache of all themes referenced by per-tab overrides.
    /// Keyed by `ThemeKind` so custom themes get their own entries.
    /// Wrapped in `RwLock` because lookup is on the hot rendering path
    /// while population happens on the (much rarer) settings-change path.
    theme_cache: parking_lot::RwLock<HashMap<ThemeKind, Arc<WarpTheme>>>,
}

impl Appearance {
    /// Active global theme. Unchanged contract.
    pub fn theme(&self) -> Arc<WarpTheme> {
        self.global_theme.clone()
    }

    /// Per-tab theme lookup. The caller passes
    /// `tab.theme_state.effective(global_kind)` (or `None` for the
    /// global theme).
    /// Returns the global theme when `override` is `None`.
    pub fn theme_for(&self, override_kind: Option<&ThemeKind>) -> Arc<WarpTheme> {
        match override_kind {
            None => self.global_theme.clone(),
            Some(kind) if *kind == self.global_theme.kind() => {
                self.global_theme.clone()
            }
            Some(kind) => {
                if let Some(t) = self.theme_cache.read().get(kind).cloned() {
                    return t;
                }
                self.load_and_cache(kind)
            }
        }
    }

    fn load_and_cache(&self, kind: &ThemeKind) -> Arc<WarpTheme> {
        // Load via the same path the global theme loader uses.
        // On failure, log warning and return self.global_theme.clone()
        // (callers see a fall-through to global theme; per behavior #11).
    }
}

Cache-invalidation rules:

  • AppearanceManager already subscribes to ThemeSettings change events (app/src/appearance.rs:51). On every change it (a) updates global_theme and (b) calls a new theme_cache.clear() so any previously-cached entries are reloaded next time they are looked up. The hit-rate cost is bounded by the number of distinct overridden themes open at once (typically ≤ 5).
  • Custom-theme file changes (which already invalidate the global theme via the existing watcher) trigger the same cache clear.

Renderer call-site migration

Appearance::as_ref(ctx).theme() is consumed in 862 call sites across the workspace as of HEAD. An exhaustive line-by-line enumeration in this spec would be unreviewable and would also rot the moment any caller moves; the design instead defines a categorical rule plus a custom lint that mechanically enforces it.

Categorical rule (matches product spec #15):

A call to Appearance::*::theme() becomes Appearance::as_ref(ctx).theme_for(Some(tab.theme_state.effective(&global_kind))) if and only if the call is reached during the rendering of content that lives inside a single tab. All other calls keep the existing global theme() form. Concretely:

Area Per-tab? Notes
app/src/terminal/view/** per-tab terminal grid, blocks, in-tab modals over the grid
app/src/terminal/input/** per-tab input bar, inline menus, slash commands inside the tab
app/src/terminal/{view.rs, block_filter.rs, rich_history.rs, terminal_manager.rs, universal_developer_input.rs, ssh/**, wormhole/**, shared_session/**} per-tab terminal-pane content
app/src/terminal/{share_block_modal.rs, profile_model_selector.rs} per-tab scoped to a specific tab's content
app/src/{tab.rs, root_view.rs, voltron.rs, modal.rs, menu.rs, search_bar.rs, input_suggestions.rs} global window chrome, command palette, top-level modals
app/src/settings/** global settings UI
app/src/reward_view.rs, wasm_nux_dialog.rs, word_block_editor.rs global full-window modals/views
crates/onboarding/** global onboarding flow runs outside a tab
crates/ui_components/** global generic widgets — they do not know about tabs
crates/editor/** global by default; per-tab when invoked from terminal/view/** (the call site, not the crate, decides) editor is reusable
crates/integration/** tests; updated to assert both forms

Migration enforcement — concrete tooling

Per Oz's v5 suggestion, the spec commits to a specific mechanism rather than leaving it open between tooling families. The implementation adds the lint as a dylint library named appearance_theme_in_tab_path under a new top-level tools/lints/ directory. dylint is the standard for project-specific Rust lints (used by rust-fuzz, solana, and others), runs against a normal cargo build, and does not require forking clippy or modifying the toolchain. The lint:

  • Flags any call to Appearance::*::theme() (the global accessor) whose enclosing module is under a per-tab path per the table above. Path classification is hard-coded in the lint source.
  • Fails CI when triggered.
  • Is silenced at a call site only by replacing the call with theme_for(...), or — for a deliberate exception — by #[allow(appearance_theme_in_tab_path = "...")] with a written rationale (e.g. "this surface intentionally renders in window chrome even though it lives under terminal/view/").

CI hook: ./script/presubmit gains cargo dylint --lib appearance_theme_in_tab_path -- -D warnings after the existing cargo clippy step. The lint runs in parallel with clippy and adds well under a minute to presubmit time.

Fallback if the Warp team would rather not introduce dylint as a new dependency: replace the lint library with a ripgrep check in ./script/presubmit that matches Appearance::*::theme() calls under per-tab paths and exits non-zero on hit. This gives 90% of the guarantee with no new tooling; the trade-off is no per-call-site #[allow] syntax (deliberate exceptions need a known-marker comment that the script greps and skips). The implementation PR opens with dylint; if review prefers ripgrep, that switch is one file change and one CI-script change.

The lint is the deliverable that gives this spec a feasibility guarantee: the migration is correct by construction across all 862 sites, and any future call site added in a per-tab path is caught by CI before merge. The PR opens with the lint enabled and zero warnings; subsequent reviewers can read the diff knowing every flagged site was either migrated or explicitly justified.

Window-chrome consumers (per the table) keep Appearance::as_ref(ctx).theme() unchanged. The custom lint is the single source of truth for which sites are which; reviewers verify the table by reading the lint config, not by re-walking 862 call sites.

6. Right-click menu: Pin theme / Reset theme / Forget launch config theme

The existing tab context menu gains three entries:

  • Pin theme... — opens a submenu listing built-in themes plus any loaded custom themes (the same list the theme picker shows). On click, sets tab.theme_state.menu_pin = Some(chosen). Always visible (when the feature flag is on, see §7).
  • Reset theme — sets tab.theme_state.menu_pin = None. The other three slots (launch_config_pin, cwd_resolved, window_default) are unaffected; if any still has a value the tab immediately re-renders with the next-priority theme per effective(). Visible only when theme_state.menu_pin.is_some().
  • Forget launch config theme — sets tab.theme_state.launch_config_pin = None. Visible only when theme_state.launch_config_pin.is_some(). This entry exists per Zach's v4 review: a user pinning a different theme via the menu should not also discard what their launch configuration originally set.

All three entries trigger the existing theme-changed redraw path used today when the global theme changes (no new render-invalidation work).

Telemetry: one counter per click for each of the three entries (using existing tab-menu telemetry conventions). No new event schema.

7. Feature flag wiring

Per Zach's v4 review, the entire feature ships behind a feature flag appearance.themes.per_tab_overrides. The flag is added through the existing feature-flag system (the same mechanism that gates other in-development features like OpenWarpNewSettingsModes).

Default values:

  • dev and preview channels: flag defaults to on.
  • stable channel: flag defaults to off in the initial release. A follow-up changelog entry flips it on once preview telemetry confirms no regressions.

Surfaces gated by the flag (when off):

Surface Behavior with flag off
directory_overrides matching Skipped entirely. The settings group is still parsed (so users who set up the map under preview do not lose data on stable) but directory_theme_for is never invoked.
Launch-config theme: fields Deserialized as today (Option<String>) but ignored — launch_config_pin and window_default are not populated when applying templates.
Right-click menu entries "Pin theme...", "Reset theme", and "Forget launch config theme" are hidden.
Theme catalog (§5) theme_for(None) always returns the global theme. theme_for(Some(_)) is reachable only if a render path bypasses the flag check — see Flag check at the resolver below — and falls back to the global theme via the catalog's existing missing-theme path. The catalog cache stays empty in normal flag-off operation.
Settings-change subscription on DirectoryThemeOverrides Subscribed but the handler short-circuits when the flag is off.
appearance_theme_in_tab_path lint (§5) Still enforced. Migrated call sites pass not theme_state.effective(&global) directly but the wrapper described below, so the flag toggle is the single place that controls runtime behavior.

Flag check at the resolver (single source of truth)

Per Oz's v5 [IMPORTANT] finding: the table above implied populated slots could not exist when the flag is off, but the persistence section preserves them so flipping the flag back on is non-lossy. Both must be true. The resolution is to put the flag check inside the resolver itself, so populated slots on disk are independent from populated slots at render time:

/// The single function the renderer should call. When the flag is
/// off, `effective()`'s slot walk is skipped entirely and the global
/// theme is returned. When on, `effective()` runs normally.
///
/// Putting the check here (not at the call site) means:
///   * Flipping the flag has no per-frame cost: still one branch.
///   * Persisted slot data is never consulted at render time when
///     off, so "did the migration miss a call site?" defects can't
///     leak themed pixels into a flag-off install.
///   * The 862 migrated call sites all look identical and don't
///     each need their own flag check.
pub fn theme_state_effective<'a>(
    state: &'a TabThemeState,
    global: &'a ThemeKind,
    ctx: &AppContext,
) -> &'a ThemeKind {
    if !per_tab_overrides_enabled(ctx) {
        return global;
    }
    state.effective(global)
}

The renderer-call-site migration in §5 calls theme_state_effective, not state.effective directly. The lint flags both the bare Appearance::*::theme() form and any direct state.effective() call inside a per-tab module — both are migration mistakes.

Persisted state with flag off:

The theme_state field is still serialized and deserialized through session restore. A user who pinned themes under preview, then opens stable with the flag off, has their pins preserved on disk; flipping the flag back on (or upgrading to a stable release that turns it on) restores the visible behavior exactly as they left it. This avoids any "I lost my themes when I downgraded" regression.

The settings-system feature-flag check is one call:

fn per_tab_overrides_enabled(ctx: &AppContext) -> bool {
    FeatureFlags::as_ref(ctx).per_tab_overrides.value()
}

Each gated surface above (matching, launch-config application, menu visibility, settings-change subscription) guards entry with this single call. The implementation PR introduces a constant for the flag name to avoid typos.

Testing and validation

Unit tests

app/src/themes/theme.rs (or sibling test module):

  • resolve_theme_ref("Dark City")Some(ThemeKind::DarkCity).
  • resolve_theme_ref("dark_city")Some(ThemeKind::DarkCity).
  • resolve_theme_ref(" DARK CITY ")Some(ThemeKind::DarkCity) (case-insensitive, whitespace-tolerant).
  • resolve_theme_ref("My Custom Theme")Some(ThemeKind::Custom(...)) when registered; None otherwise.
  • resolve_theme_ref("Definitely Not A Theme")None.

app/src/launch_configs/launch_config_tests.rs:

  • YAML round-trip of TabTemplate { theme: Some("Solarized Dark") }.
  • A YAML file with one valid and one unknown theme: value loads successfully; resolution applied at apply time, only the unknown tab falls through, the file is not rejected.
  • WindowTemplate.theme set with all tabs un-themed: round-trip preserves the window-level value.
  • Saving a snapshot whose tabs have only Cwd(_) overrides emits no theme: fields in the resulting YAML.

app/src/settings/directory_overrides.rs (new):

  • directory_theme_for(map, "~/Work/medone/apps/admin-api") returns the theme mapped to "~/Work/medone".
  • directory_theme_for(map, "~/Work/medone-archive") returns None when only "~/Work/medone" is mapped (component-boundary match).
  • Two overlapping keys "~/Work" and "~/Work/medone": longer wins for paths under medone, shorter wins elsewhere under ~/Work.
  • Unresolved theme value: that entry is skipped, directory_theme_for on a path that would have matched it returns None (or the next match), and one warning is logged.

app/src/app_state.rs (or test module):

  • Each persisted slot round-trips through session-restore serialization: menu_pin, launch_config_pin, window_default.
  • cwd_resolved does not persist; on deserialization the slot is None regardless of what was written. (Pins product behavior #13.)
  • TabThemeState::effective() walks the priority order exactly:
    • menu=A launch=B cwd=C window=DA.
    • menu=None launch=B cwd=C window=DB.
    • menu=None launch=None cwd=C window=DC.
    • menu=None launch=None cwd=None window=DD.
    • All None → global fallback. Pins the 5-layer resolution order from product.md and addresses the CRITICAL finding from Oz's v2 review (window defaults must not outrank cwd) plus Zach's v4 split (menu pin must outrank launch-config pin).
  • "Reset theme" semantics: a tab with menu=A launch=B resolves to A. After clearing menu_pin, it resolves to B. A tab with menu=A only resolves to A; after clear, falls through to global. Pins product behavior #18.
  • "Forget launch config theme" semantics: a tab with menu=A launch=B resolves to A. After clearing launch_config_pin, it still resolves to A (menu wins). After also clearing menu_pin, it falls through to global.
  • TabSnapshot::color() returns the same value with and without theme_state populated (color and theme are independent — #19).

app/src/appearance.rs (or warp_core tests):

  • Appearance::theme_for(None) returns the same Arc as Appearance::theme().
  • Appearance::theme_for(Some(&ThemeKind::Light)) returns a WarpTheme whose kind is Light regardless of the active global theme.
  • Appearance::theme_for(Some(&ThemeKind::Custom(missing))) falls back to the global theme and increments the existing missing-custom-theme warning counter.
  • After ThemeSettings changes, the cache is cleared: a subsequent theme_for(Some(&kind)) reloads from disk rather than returning stale custom-theme content.

Integration tests

crates/integration/:

  • Open a launch configuration with three tabs — theme: "Dracula", theme: "Solarized Light", no theme:. Assert each tab renders the expected theme and that switching tabs does not redraw inactive tabs.
  • Configure [appearance.themes.directory_overrides] with two entries. Open one tab in each matched directory and one in an unmatched directory. Assert each tab renders the expected theme. cd the unmatched tab into a matched directory; assert the tab redraws with the matched theme. cd it back out; assert it redraws with the global theme.
  • Tab with launch_config_pin = Some(Dracula) (set via launch-config tab-level theme:) sits in a directory that maps to Solarized Dark. Assert Dracula wins (launch_config_pin > cwd). Right-click → "Forget launch config theme". Assert the tab redraws with Solarized Dark.
  • Tab with both menu_pin = Some(Light) and launch_config_pin = Some(Dracula). Assert Light wins (menu_pin > launch_config_pin). Right-click → "Reset theme" (clears menu_pin only). Assert the tab redraws with Dracula. Right-click → "Forget launch config theme". Assert the tab redraws with the next-priority theme. (Addresses Zach's v4 review — menu and launch-config pins must be independently clearable.)
  • Settings-change recompute. With three tabs open in three different cwds, edit directory_overrides to add a new key that matches one of them. Assert that one tab redraws with the new theme; the other two are unchanged. Then edit the same key's value to a different theme name. Assert the matched tab redraws again. Then delete the key. Assert the tab falls through to its next-priority theme. (Pins product behavior #6 and addresses Oz's v2 IMPORTANT finding that this path was missing from the design.)
  • Save-from-snapshot for window-level theme. Open a launch config whose window has theme: "Dark City" and three tabs (no per-tab pins, no directory matches). Save the resulting layout to a new launch config. Assert the saved YAML contains a window-level theme: "Dark City" and no per-tab theme: fields. Reopen it. Assert all three tabs render Dark City. (Addresses Oz v3 IMPORTANT finding on save-rule dropping window defaults.)
  • Save-from-snapshot for mixed pinning. Open a layout with a mix of manually pinned tabs (different themes), tabs with only a window default, and tabs whose effective theme came from cwd. Save. Assert: window-level theme: is omitted (mixed manual pins make coalescing impossible); each manually pinned tab emits its own theme:; tabs with only window_default emit their default; tabs themed only by cwd emit no theme:. Reopen and assert the effective theme of every tab matches what it was pre-save.
  • Windows path normalization. Cross-platform integration test matrix:
    • Linux: keys ~/Work/medone (case-sensitive) — assert ~/Work/MEDONE does not match.
    • macOS: same key — assert ~/Work/MEDONE does match (case-insensitive default).
    • Windows: keys C:\Work\medone and c:\Work\medone collapse to one entry (drive-letter normalization). Cwd C:/Work/medone/app matches (separator normalization). Cwd C:\Work\medone-archive does not match (component boundary).
  • Diagnostic redaction. Configure directory_overrides with a key ~/Work/AcmeCorp/2026 mapped to a bad theme name "Drakula". Trigger validation. Capture the warning string. Assert it contains "Drakula" and redacted_key_id("~/Work/AcmeCorp/2026", &salt), and does not contain "AcmeCorp", "Work", "2026", or any substring of the raw key.
  • Per-installation salt. Two test runs with different generated salts on the same key produce different identifiers (asserts the salt is actually keyed in). Two runs with the same salt produce the same identifier (asserts determinism within an installation).
  • Salt file mode. On a Unix platform, after first-launch salt generation, stat the file and assert mode bits == 0600.
  • Salt missing fallback. Delete the salt file mid-test, trigger a fresh validation. Assert the warning string contains [unsalted] and does not contain any derived identifier.
  • Open a launch configuration with a window-level theme: and three tabs, one of which sits in a directory_overrides-matched cwd. Assert the matched tab uses the cwd theme (cwd > window default); the other two use the window default. (Pins the priority order and addresses Oz's v2 CRITICAL finding.)
  • Quit and relaunch. Assert manually-pinned tabs restore their pin; tabs that had only a cwd_resolved theme have cwd_resolved set to None after deserialization but are recomputed on startup from current settings + cwd.
  • Unknown theme name in a launch configuration: the file opens, exactly that one tab falls through, the warning appears in the log, other tabs render correctly.

Feature-flag tests

  • With appearance.themes.per_tab_overrides off, open a launch configuration containing both window-level and tab-level theme: fields. Assert no tab carries any populated slot in theme_state (effective() falls through to global) and no warning is logged for the launch-config theme references.
  • With the flag off, configure directory_overrides and cd into a matched directory. Assert no theme change occurs.
  • With the flag off, the right-click tab menu does not include "Pin theme...", "Reset theme", or "Forget launch config theme".
  • Toggle the flag from off to on while a session is restored with persisted slots from a prior preview run. Assert the persisted menu_pin, launch_config_pin, and window_default slots resume their effects without losing data, and cwd_resolved is recomputed from current settings.
  • Toggle the flag from on to off mid-session. Assert all themed tabs redraw with the global theme (because theme_state_effective short-circuits when the flag is off, even though slot data remains populated in memory and on disk). Persisted slot data is not cleared. Toggle back on: assert every tab redraws with its prior effective theme without any reload.

Privacy invariant tests

  • DirectoryThemeOverrides settings group has private == true and sync_to_cloud == SyncToCloud::Never. Pinned by a settings-system test analogous to the existing tests that gate which settings sync.
  • The redaction-salt file (~/.warp/redaction_salt) is created with mode 0600 on first launch and is not included in any cloud-synced settings payload (verified by the same serializer test that asserts no path keys leak).
  • The settings sync serializer, when run over a populated DirectoryThemeOverrides, emits no entry containing a path key in the cloud-sync payload. Test by populating the map, running the serializer, and asserting the resulting payload does not contain the literal key string.
  • Saving a window's state as a launch configuration with a populated directory_overrides map produces YAML with no directory_overrides field.

Manual verification

  • macOS, Linux, Windows: visually confirm terminal background and ANSI palette match the expected theme for each tab in the test scenarios.
  • Confirm window chrome (title bar, sidebar, tab strip) follows the global theme in all scenarios.
  • Toggle system light/dark while a mix of themed/unthemed tabs is open; confirm only unthemed tabs follow the system.
  • Save layout as launch configuration; confirm tabs with manual pins emit theme:, tabs themed only by directory matching do not.
  • After implementation, invoke the verify-ui-change-in-cloud skill per the repository rule for user-facing client changes.

Tooling

  • cargo fmt and cargo clippy --workspace --all-targets --all-features --tests -- -D warnings must pass.
  • cargo nextest run for unit tests; crates/integration for end-to-end.
  • ./script/presubmit before pushing.

Risks and mitigations

  • Render hot-path cost of the per-tab lookup. The added work per frame is one Option deref, one Arc clone in the common case (no override or override == global), or one RwLock::read + HashMap hit when the override resolves to a non-global cached theme. The existing per-tab indicator color path runs on the same frame and has not been a bottleneck. If profiling flags it post-merge, cache the Arc<WarpTheme> directly on TabData and invalidate on override-change; the change is local.

  • Cache stampedes when the user changes the global theme with many overridden tabs open. theme_cache.clear() followed by lazy reload means each unique overridden theme reloads once. Bounded by open-tab cardinality.

  • Mismatch between directory_overrides keys and how the OS reports paths. Tilde expansion happens in the resolver; symlinks are not followed (matching whatever the shell's pwd reports). This is documented in the product spec; integration tests cover the common cases.

  • cwd_resolved mistakenly persists across sessions. The slot's serde derives skip it on serialization and default it to None on deserialization; the integration test for restart behavior pins this.

  • Path keys leaking off-machine. Addressed in Privacy model (§4): private: true, sync_to_cloud: Locally, no telemetry on contents, no roundtrip into shareable launch-config YAML. Privacy invariant tests pin each rule.

  • Window-default outranking cwd matches. Addressed by giving window-default its own slot in TabThemeState (§3) rather than expanding it into the manual slot. effective() walks slots in the product spec's resolution order; the integration test for the launch-config-with-window-default scenario pins the priority.

  • Save layout dropping window-level theme. Addressed by the preserved_override helper in §1. Both save tests above pin the round-trip.

  • Windows path matching ambiguity. Component-boundary matching uses Path::components() rather than string operations, separator and drive-letter normalization happens before comparison, and case semantics are platform-conditional. Pinned by the cross-platform test matrix above. The lint appearance_theme_in_tab_path does not cover this — these are runtime correctness tests.

  • Path keys leaking through diagnostics. Addressed by redacted_key_id and the Diagnostic redaction contract in §4. Pinned by the redaction test above plus a code-review checklist item; we considered a custom lint but the surface is small enough (one helper function, one logger call site) that a unit-test enforcement is sufficient.

  • Renderer call-site drift. The appearance_theme_in_tab_path lint catches both the initial migration and any future call site added in a per-tab path. This replaces a one-time enumeration that would have rotted on the next PR. The trade-off is the small cost of maintaining the lint config (one entry per top-level area).

  • Concern 1 / Concern 2 / Concern 3 from Oz's first-pass review. Addressed in §1 (Option + apply-time resolver), §2 (resolve_theme_ref returns Option<ThemeKind>), and §5 (theme catalog, Arc<WarpTheme> ownership) respectively. Each concern has a corresponding unit test above.

  • Per-pane theme creep. Override slots live on the tab, not the pane; the resolver consults the focused pane's cwd. A future per-pane theming feature would need its own data path; this spec does not lock that out but does not pre-pay for it either.

  • Persistence schema collision with future per-window theming. The field is named theme_state (a struct of slots) rather than theme, so a separate resolved theme can live elsewhere if added later.

Follow-ups (deliberately not in this PR)

  • Glob pattern support in directory_overrides (open question in product.md) — extends key matching from prefix to glob.
  • Auto-theme by SSH host or hostname (stevenchanin, pyronaur, zethon, janderegg in #478). Requires detection inside the tab's shell; would add a fourth slot (ssh_resolved) to TabThemeState and a corresponding rung in effective()'s priority order.
  • Runtime escape-code or shell-hook protocol for setting a tab's theme (yatharth, for Claude-Code session signaling). Defines a wire format and a security model; consumes the override field.
  • In-tab "Pin theme" command in the command palette (so users can pin without right-clicking). Strictly additive.
  • Wallpaper-per-tab (scottaw66, SheepDomination). Different surface area entirely; does not conflict.
  • Closing #2618 as a duplicate of #478 once this spec lands.