18 KiB
PRODUCT.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 (set warp theme in launch configuration)
Summary
The Warp theme is a single global value today (appearance.themes.theme in
settings.toml); switching it affects every open tab at once. Users have asked
for years (#478, 55+ upvotes; #2618) for tabs to render with different
themes when they represent different contexts — different projects, local vs.
remote machines, production vs. development.
This spec covers a focused first cut of per-tab theme overrides driven by three sources, in priority order:
- A user-visible manual override on a tab (set via the launch configuration YAML or via a right-click menu).
- A directory-pattern auto-match: the user maps directory paths to
themes in
settings.toml, and tabs whose active pane's cwd matches a pattern render with the mapped theme. This is the path most users in the issue thread describe (pyronaur,janderegg,milopersic): "Icdinto project A, my theme should change." - A launch-configuration window-level default that themes every tab a given launch configuration opens unless the tab itself has another override.
The global theme remains the fallback when none of the three sources apply. Window chrome (title bar, sidebar, settings views, the tab strip) continues to follow the global theme so windows holding mixed-theme tabs remain visually coherent at the window level.
This spec deliberately scopes out automatic theming triggered by SSH host,
hostname, runtime escape codes, or shell hooks. Those appear in #478
discussion and are listed as follow-ups that consume the override field this
spec introduces.
Figma: none provided.
Goals / Non-goals
In-scope surfaces:
- A new settings map
appearance.themes.directory_overrideswhose keys are directory paths (tilde-expanded) and whose values are theme identifiers. Matching uses longest-prefix-wins. - The launch-configuration YAML schema gains an optional
theme:field at the tab level and at the window level. - A persisted per-tab override that survives session restore.
- A right-click tab menu entry for Pin theme (manually override the active tab's theme to a chosen theme) and Reset theme (clear a manual override; cwd-pattern matching may then reapply).
Out of scope:
- SSH-host-driven, hostname-driven, or
whoami-driven theming (stevenchanin,pyronaur,zethon,janderegg). - Escape-code or shell-hook protocols for runtime theme switching
(
yatharth, for Claude-Code session signaling). - Per-pane theming. Panes inside a tab continue to share one theme.
- Per-tab wallpaper or graphics (
scottaw66,SheepDomination). - Changes to the global theme storage path
(
appearance.themes.theme), the theme picker UI, or custom-theme loading. Overrides reuse the existing theme identifier type.
Resolution order
A tab's effective theme is determined by walking these layers and returning the first hit:
- Menu pin, if any. Set by the right-click "Pin theme" menu and cleared by the right-click "Reset theme" menu. Persists across sessions.
- Launch-configuration manual pin, if any. Set by a tab-level
theme:in the launch configuration that opened (or restored) the tab. Cleared only by an explicit "Forget launch config theme" menu entry —Reset themedoes not clear this layer (per Zach's v4 review). Persists across sessions. - Directory match, if any. The active pane's current working
directory is matched against
appearance.themes.directory_overrides; if a key is a prefix of the cwd, the longest such key wins. - Launch-configuration window-level default, if the tab was opened
from a launch configuration with a window-level
theme:and no closer override applies. Persists across sessions. - Global theme as derived from
ThemeSettingsand the system theme, exactly as today.
If none of the override sources resolve to a known theme, behavior is bit-for-bit identical to today.
Behavior
Directory-pattern overrides
-
settings.tomlaccepts a new section[appearance.themes.directory_overrides]whose entries map a directory path to a theme identifier. Example:[appearance.themes.directory_overrides] "~/Work/medone" = "Dark City" "~/Work/bondwise" = "Solarized Dark" "~/Work/checkpt" = "Dracula"Theme values are the same string form accepted by the global
appearance.themes.theme(the parser tolerates both display names like"Dark City"and snake-case like"dark_city"; see #14 below). -
Keys are tilde-expanded to absolute paths at match time. Trailing slashes are normalized away. Symlinks are not resolved — the cwd as the shell reports it is what's matched. Path normalization rules, per platform:
- Linux: matching is case-sensitive (matches the filesystem
semantics on standard ext4/xfs). Path separators are
/. - macOS: matching is case-insensitive (matches the default
HFS+/APFS case-insensitive setting). Path separators are
/. - Windows: matching is case-insensitive. Both
/and\are accepted as separators indirectory_overrideskeys and are normalized internally to a single canonical form before comparison. Drive letters are normalized to uppercase (c:\WorkandC:\Workare equivalent keys; the second-written one wins per TOML duplicate-key semantics). Tilde expands to%USERPROFILE%.
Component-boundary matching (the rule in the previous paragraph that prevents
~/Work/medonefrom matching~/Work/medone-archive) uses the platform's path-component definition — on Windows that means the boundary follows either\or/after normalization. - Linux: matching is case-sensitive (matches the filesystem
semantics on standard ext4/xfs). Path separators are
-
Match resolution: a key matches if it is a prefix of the active pane's cwd at a path-component boundary.
~/Work/medonematches both~/Work/medoneand~/Work/medone/apps/admin-api, but does not match~/Work/medone-archive(no component boundary). When multiple keys match, the longest one wins (most specific). -
The cwd evaluated for a tab is the cwd of the focused pane in that tab. A tab whose focused pane is in a non-shell context (notebook, settings view, etc.) has no cwd and falls through directory matching.
-
When a tab's active pane changes cwd (because the user ran
cd, opened a subdirectory, or moved focus to a pane in a different cwd), directory matching re-runs. If the new cwd matches a different key, the tab immediately re-renders with the new theme. If it matches no key, the tab falls through to the next layer in the resolution order. -
Adding, editing, or removing entries in
directory_overrideswhile Warp is running re-evaluates every open tab. Tabs whose effective theme changes redraw; tabs whose effective theme is unchanged do not. -
A theme name in
directory_overridesthat does not resolve to a known theme is treated the same way an unknown launch-configuration theme is (#11): a warning is logged identifying the offending key, the entry is skipped for matching purposes, and the rest of the map continues to work.
7a. directory_overrides is stored locally and never synced to Warp's
cloud. Directory paths can encode employer, customer, and project
names (~/Work/<client>/<engagement>/...); cloud-syncing the keys
would push that organizational context off-machine. Users on
multiple machines who want shared themes today set them per-machine.
An opt-in cloud-sync mode is a candidate follow-up. The global
theme setting (appearance.themes.theme) and per-tab pins set via
the right-click menu remain user-controllable surfaces; only this
map is local-only.
7b. Diagnostic output never contains raw directory_overrides keys.
Local logs are routinely shared with Warp support and copied into
bug reports, so even local diagnostics can leak path keys. The
invariant is: any warning or telemetry emitted by the
directory_overrides machinery refers to an offending entry by a
short non-cryptographic hash of its key plus the offending value
(the theme name, which is non-sensitive). For example, a warning
that today might say directory_overrides: "~/Work/AcmeCorp/2026": unknown theme "Drakula" instead reads
directory_overrides[hash=8a3f9c]: unknown theme "Drakula". The
user can grep their settings.toml for the bad theme name to find
the offending row. Hashes are stable for the same key but contain
no path information.
Launch-configuration overrides
-
A launch configuration YAML may include
theme:on any tab entry. Accepted values are theme identifiers in the form documented in #14. Omitting the field leaves the tab to fall through the resolution order. -
A launch configuration YAML may include
theme:on any window entry. Tabs in that window with no tab-leveltheme:and no directory match inherit this window-level value (per the resolution order: #3 sits below #2). -
Saving a window's current state to a launch configuration preserves every theme override that was not derived from directory matching, so that reopening the saved configuration produces the same effective themes (modulo cwd matching, which re-runs against the current
directory_overrides). Specifically, for each tab the preserved override is the first non-empty value of, in priority order: the tab's menu pin (layer #1), the tab's launch-configuration manual pin (layer #2), or the tab's window-level default (layer #4). Tabs whose effective theme came only from directory matching (layer #3) have no preserved override. The save rule is then:- If every tab in the window has the same preserved override
Some(X)and no tab has a manual pin different from the others, the saved YAML emits a single window-leveltheme: Xand no per-tabtheme:fields. - Otherwise, each tab whose preserved override is
Some(Y)emits a per-tabtheme: Y; tabs with no preserved override emit notheme:field; window-leveltheme:is omitted. - Directory-matched themes never appear in saved YAML — directory matching is settings-level and re-applies on next open.
This means a launch configuration that originally opened with a window-level
theme:round-trips correctly: every tab inherited the value into itswindow_defaultslot, the save rule sees a shared preserved override, and emits the same window-leveltheme:again. - If every tab in the window has the same preserved override
YAML / settings format
-
An unknown theme identifier — anywhere it appears — never causes a file-level load failure. The deserializer accepts any string; the resolver runs at apply time and falls back to the next resolution layer for the affected entry only. Other tabs in the same launch configuration, and other entries in the same
directory_overridesmap, are unaffected. Each unknown name produces exactly one logged warning per load. -
Custom themes referenced by an override behave identically to custom themes used as the global theme — loaded from the user's themes directory, fail-soft to the next layer if the file is missing, same trust/validation rules as the global theme loader.
-
Overrides persist per-tab through session restore, by source:
- Menu pin (layer #1) — kept on relaunch.
- Launch-configuration manual pin (layer #2) — kept on relaunch. The launch configuration that set it is not necessarily reopened on restore, so the value travels with the tab.
- Launch-configuration window-level default (layer #4) — kept on relaunch, same reason as #2.
- Directory match (layer #3) — not stored; recomputed on
relaunch from the current
directory_overridesand the restored cwd. Editingdirectory_overridesbetween sessions therefore takes effect on the next launch.
-
The accepted form for any theme reference (in
directory_overrides, in launch-config tabtheme:, in launch-config windowtheme:) is a single string. Both the human-readable display form ("Dark City","Solarized Dark","Dracula") and the snake-case form ("dark_city","solarized_dark","dracula") are accepted. Matching is case-insensitive on whitespace-stripped input. Custom themes are referenced by their custom-theme name, same as today's global setting.
Rendering scope
-
When a tab has an effective override (from any layer), the override applies to: the terminal cell foreground/background, the ANSI 16-color palette used by the terminal grid, and any in-tab UI surfaces whose colors are derived from the active theme (block backgrounds, command output styling, accent colors). The window chrome (title bar, sidebar, settings views, the tab strip itself) continues to follow the global theme.
-
Switching tabs is instant — no flash, no progressive paint. Only the rendering of the newly-active tab reflects its (possibly different) theme; inactive tabs do not redraw on switch.
-
Changing the global theme updates every tab whose effective theme falls through to the global layer. Tabs with overrides at any higher-priority layer are unaffected.
User affordances
-
The right-click tab context menu gains three entries, alongside the existing per-tab attributes:
- Pin theme... — opens a submenu listing available themes. Choosing one sets a menu pin on the tab (resolution layer #1), which wins over every other layer including a launch-config manual pin. Visible at all times.
- Reset theme — clears only the menu pin (layer #1). The tab falls through to the launch-config manual pin / directory match / window default / global layers in that order. Visible only when the tab has a menu pin.
- Forget launch config theme — clears only the launch-config manual pin (layer #2). The tab falls through to the directory match / window default / global layers. Visible only when the tab has a launch-config manual pin.
Splitting "Reset theme" from "Forget launch config theme" means a user who pinned a different theme via the menu can clear that pin without unintentionally also discarding what their launch configuration originally set. (Per Zach's v4 review.)
-
The existing per-tab
color:field on a tab template (the small colored indicator next to the tab title) is independent of the new theme override. Both can be set; both are honored. -
The feature applies on every supported platform (macOS, Linux, Windows). It is gated behind a feature flag named
appearance.themes.per_tab_overrides(per Zach's v4 review):- Initial release ships with the flag off in stable and on in dev/preview, so the rollout can soak with internal users before reaching the broader install base.
- When the flag is off the feature is invisible: the right-click
menu entries are hidden,
directory_overridesmatching does not run (the settings group is still parseable so users who set up the map under preview do not lose data), and launch-configurationtheme:fields are deserialized but ignored. The user-visible behavior is bit-for-bit identical to today. - The default flips to on in stable in a follow-up release, after telemetry on the preview/dev cohort confirms no regressions in render performance, settings parsing, or session restore.
- An empty
directory_overridesmap (the default) plus no launch-config theme fields plus no pinned themes — even with the flag on — is bit-for-bit identical to current behavior.
Accessibility
- The override does not change any text content, accessible labels, or focus order. Screen readers continue to report tab titles and contents identically. The three new right-click menu entries have accessible labels "Pin theme" (with a submenu of theme names), "Reset theme", and "Forget launch config theme"; each is announced as a menu item, and each follows the menu's existing visibility rules (Reset theme appears only when the tab has a menu pin; Forget launch config theme appears only when the tab has a launch-config manual pin).
User-visible failure modes
- Unknown theme name — anywhere it appears, the entry is skipped at
apply time, a one-line warning is written to the Warp log identifying
the source (launch configuration filename + tab title or index for
launch-config sources; redacted entry identifier for
directory_overridessources, never the raw path key — see #7b), and the rest of the configuration loads normally. - Custom theme file missing — same fallback the global theme uses today: tab opens with the next-layer theme, warning logged.
- Two
directory_overrideskeys are equivalent after tilde expansion — last-write-wins per TOML semantics; a warning is logged identifying the duplicate. - A pane's cwd is unavailable (non-shell pane content) — that pane contributes no cwd to directory matching; if it is the focused pane the tab falls through to the window/global layers.
Open questions
(Resolved in v4 review and folded into the spec — kept here as a record of the design choices made.)
Resolved: prefix matching only (Zach v4: "i think prefix-match is fine to start"). Globs are a follow-up.directory_overrideskeys: glob vs. prefix matching."Reset theme" scope: clear all manual layers, or only menu pins.Resolved: only menu pins (Zach v4: "i don't think it should" clear launch-config pins). A separate "Forget launch config theme" entry exists for the launch-config layer. Reflected in behaviors #18 and in the resolution order.- Should saving a launch configuration emit
directory_overridesentries so themes travel with the launch config? Spec keeps the two surfaces independent — launch configs carry only manual pins; directory matching is settings-level and does not round-trip through saved launch configs. A future "shareable theme bundle" could compose them. Open for product-team input.