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

1100 lines
52 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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:42100` — `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:1448` — `ThemeSettings` group; `theme_kind`
field with `toml_path: "appearance.themes.theme"` (line 23).
- `app/src/settings/theme.rs:6978` — `derived_theme_kind`, applies
system-light/dark logic. Wrapped by per-tab resolution.
- `app/src/settings/theme.rs:8183` — `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:4047` — `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:4359` — `WindowSnapshot`. **No theme field.**
- `app/src/app_state.rs:6169` — `TabSnapshot`. Carries
`default_directory_color` and `selected_color: SelectedTabColor` (the
per-tab indicator color).
- `app/src/app_state.rs:7175` — `TabSnapshot::color()`, the resolution
helper for the indicator.
- `app/src/tab.rs:134143` — `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<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:
```rust
#[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:
```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<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`:
```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<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:
```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<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`:
```rust
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.
```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<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`:
```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<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**:
```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<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<String> + 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.