# 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 14–20) — `name`, `active_window_index`, `windows`. - `WindowTemplate` (lines 36–41) — `active_tab_index`, `tabs`. **No theme field today.** - `TabTemplate` (lines 180–187) — `title`, `layout`, `color: Option`. 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:42–100` — `ThemeKind` 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:14–48` — `ThemeSettings` group; `theme_kind` field with `toml_path: "appearance.themes.theme"` (line 23). - `app/src/settings/theme.rs:69–78` — `derived_theme_kind`, applies system-light/dark logic. Wrapped by per-tab resolution. - `app/src/settings/theme.rs:81–83` — `active_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:40–47` — `AppearanceManager`; 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:43–59` — `WindowSnapshot`. **No theme field.** - `app/src/app_state.rs:61–69` — `TabSnapshot`. Carries `default_directory_color` and `selected_color: SelectedTabColor` (the per-tab indicator color). - `app/src/app_state.rs:71–75` — `TabSnapshot::color()`, the resolution helper for the indicator. - `app/src/tab.rs:134–143` — `TabData` (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` parsed from YAML (`launch_config.rs:186`). 2. On launch-config open, `app/src/workspace/view.rs:3517–3519` 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` on `TabTemplate` and `WindowTemplate` File: `app/src/launch_configs/launch_config.rs`. Add a new field to both structs: ```rust #[serde(skip_serializing_if = "Option::is_none", default)] pub theme: Option, ``` The field is `Option` rather than `Option` 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` the load always succeeds; resolution and fallback happen at apply time per the product spec's behavior #11. The `From for WindowTemplate` (lines 43–70) and `TryFrom for TabTemplate` (lines 189–200) impls implement the save rule from product behavior #10. The "preserved override" for a tab is computed as: ```rust 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 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`: ```rust /// 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 { ... } ``` 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` 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: ```rust /// 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, /// 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, /// 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, /// 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, } 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:61–69`), 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`: ```rust define_settings_group!(DirectoryThemeOverrides, settings: [ overrides: Map { // Stored as TOML table: keys = directory paths, values = theme refs. type: BTreeMap, default: BTreeMap::new(), supported_platforms: SupportedPlatforms::ALL, // **Local-only.** Directory paths can encode employer, customer, // and project names (e.g. `~/Work///...`). // 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. ```rust /// 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: ```rust /// 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, cwd: &Path, ) -> Option { ... } ``` 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`: ```rust 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, /// 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>>, } impl Appearance { /// Active global theme. Unchanged contract. pub fn theme(&self) -> Arc { 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 { 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 { // 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/**, warpify/**, 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`) 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**: ```rust /// 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: ```rust 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=D` → `A`. - `menu=None launch=B cwd=C window=D` → `B`. - `menu=None launch=None cwd=C window=D` → `C`. - `menu=None launch=None cwd=None window=D` → `D`. - 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` 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`), and §5 (theme catalog, `Arc` 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.