Add customizable terminal colors (global theme + per-pane overrides)

Four editable colors (background/foreground/cursor/selection) via a new
ColorPanel modal with built-in presets and live preview. Global default
persists to localStorage and syncs across windows; per-pane overrides ride
on LeafNode.colorOverride in the workspace tree. Titlebar 🎨 button edits
the global theme; per-pane 🎨 chip overrides a single pane. Subsumes the
prior uncommitted softened-foreground tweak into lib/theme.ts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
megaproxy 2026-06-01 23:41:19 +01:00
parent 1febf2e096
commit 7e624a3f96
10 changed files with 938 additions and 5 deletions

View file

@ -77,6 +77,7 @@ import {
changeLabel,
toggleBroadcast as toggleBroadcastInTree,
toggleMcpAllow as toggleMcpAllowInTree,
setLeafColors as setLeafColorsInTree,
setAllBroadcast,
adjustFontSize,
adjustAllFontSizes,
@ -106,6 +107,13 @@ import Palette from "./components/Palette";
import HostManager from "./components/HostManager";
import Help from "./components/Help";
import McpPanel from "./components/McpPanel";
import ColorPanel from "./components/ColorPanel";
import {
type PaneColors,
GLOBAL_COLORS_STORAGE_KEY,
loadGlobalColors,
saveGlobalColors,
} from "./lib/theme";
import McpConfirm, { type McpConfirmSpec } from "./components/McpConfirm";
import TabStrip from "./components/TabStrip";
import "./App.css";
@ -239,6 +247,15 @@ export default function App() {
token: null,
});
const [mcpPanelOpen, setMcpPanelOpen] = useState(false);
// App-wide default terminal colours, loaded from localStorage. Per-pane
// overrides live on the LeafNode (colorOverride); this is the fallback.
const [globalColors, setGlobalColors] = useState<PaneColors>(() =>
loadGlobalColors(),
);
const [colorPanelOpen, setColorPanelOpen] = useState(false);
const [colorPanelMode, setColorPanelMode] = useState<"global" | "pane">(
"global",
);
const [ready, setReady] = useState(false);
const [notifications, setNotifications] = useState<Toast[]>([]);
const [paletteOpen, setPaletteOpen] = useState(false);
@ -647,6 +664,34 @@ export default function App() {
setTree((t) => toggleMcpAllowInTree(t, leafId));
}, []);
const setLeafColors = useCallback(
(leafId: NodeId, colors: PaneColors | undefined) => {
setTree((t) => setLeafColorsInTree(t, leafId, colors));
},
[],
);
const openColorPanel = useCallback((leafId?: NodeId) => {
if (leafId) setActiveLeafId(leafId);
setColorPanelMode(leafId ? "pane" : "global");
setColorPanelOpen(true);
}, [setActiveLeafId]);
// Persist the global theme on every change, and pick up edits made in
// OTHER windows. localStorage is shared per-origin: the `storage` event
// fires only in windows that did NOT make the write, so this can't loop.
useEffect(() => {
saveGlobalColors(globalColors);
}, [globalColors]);
useEffect(() => {
function onStorage(e: StorageEvent) {
if (e.key !== GLOBAL_COLORS_STORAGE_KEY) return;
setGlobalColors(loadGlobalColors());
}
window.addEventListener("storage", onStorage);
return () => window.removeEventListener("storage", onStorage);
}, []);
// ---- MCP server lifecycle ------------------------------------------------
const refreshMcpStatus = useCallback(async () => {
try {
@ -1265,13 +1310,16 @@ export default function App() {
activeLeafId,
distros,
hosts,
globalColors,
split,
close,
setShell,
setLabel,
toggleBroadcast,
toggleMcpAllow,
setLeafColors,
openHostManager,
openColorPanel,
setActive,
navigateTo,
registerPaneId,
@ -1290,13 +1338,16 @@ export default function App() {
activeLeafId,
distros,
hosts,
globalColors,
split,
close,
setShell,
setLabel,
toggleBroadcast,
toggleMcpAllow,
setLeafColors,
openHostManager,
openColorPanel,
setActive,
navigateTo,
registerPaneId,
@ -2086,6 +2137,14 @@ export default function App() {
>
🤖
</button>
<button
className="palette-btn"
onClick={() => openColorPanel()}
title="Terminal colours (global theme + per-pane overrides)"
aria-label="Terminal colours"
>
🎨
</button>
<button
className="palette-btn"
onClick={() => setHelpOpen(true)}
@ -2206,6 +2265,24 @@ export default function App() {
/>
)}
{colorPanelOpen && (() => {
const activeLeaf = activeLeafId ? findLeaf(tree, activeLeafId) : null;
return (
<ColorPanel
globalColors={globalColors}
onChangeGlobal={setGlobalColors}
activeLeafId={activeLeaf ? activeLeafId : null}
activeLeafLabel={activeLeaf?.label}
activeOverride={activeLeaf?.colorOverride}
onChangeActive={(colors) => {
if (activeLeafId) setLeafColors(activeLeafId, colors);
}}
initialMode={colorPanelMode}
onClose={() => setColorPanelOpen(false)}
/>
);
})()}
{confirmQueue.length > 0 && (
<McpConfirm
spec={confirmQueue[0]}

View file

@ -0,0 +1,203 @@
.color-panel {
position: fixed;
top: 8vh;
left: 50%;
transform: translateX(-50%);
width: min(520px, 92vw);
max-height: 84vh;
background: #161616;
color: #ccc;
border: 1px solid #2a2a2a;
border-radius: 8px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6);
z-index: 100;
display: flex;
flex-direction: column;
overflow: hidden;
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
}
.color-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
border-bottom: 1px solid #2a2a2a;
}
.color-title { font-weight: 600; font-size: 13px; }
.color-close {
background: transparent; border: none; color: #888;
font-size: 18px; line-height: 1; padding: 2px 8px;
cursor: pointer; border-radius: 3px;
}
.color-close:hover { background: #2a2a2a; color: #ddd; }
/* ---- Mode toggle -------------------------------------------------------- */
.color-modes {
display: flex;
gap: 0;
border-bottom: 1px solid #2a2a2a;
padding: 0 10px;
}
.color-mode {
position: relative;
font: inherit;
font-size: 11px;
font-weight: 500;
letter-spacing: 0.04em;
background: transparent;
color: #777;
border: none;
border-bottom: 2px solid transparent;
padding: 7px 12px 5px;
cursor: pointer;
transition: color 0.1s, border-color 0.1s;
}
.color-mode:hover:not(:disabled) { color: #bbb; }
.color-mode:disabled { color: #555; cursor: default; }
.color-mode--active {
color: #cce6ff;
border-bottom-color: #4488cc;
}
/* ---- Body --------------------------------------------------------------- */
.color-body { padding: 14px 18px; overflow-y: auto; }
.color-blurb { margin: 0 0 14px; font-size: 11px; line-height: 1.5; color: #999; }
/* ---- Colour rows -------------------------------------------------------- */
.color-rows { display: flex; flex-direction: column; gap: 8px; }
.color-row {
display: flex;
align-items: center;
gap: 10px;
}
.color-row-label {
flex: 0 0 90px;
font-size: 12px;
color: #bbb;
}
.color-swatch {
flex: 0 0 auto;
width: 34px;
height: 26px;
padding: 0;
border: 1px solid #3a3a3a;
border-radius: 4px;
background: transparent;
cursor: pointer;
}
.color-swatch::-webkit-color-swatch-wrapper { padding: 2px; }
.color-swatch::-webkit-color-swatch { border: none; border-radius: 2px; }
.color-hex {
flex: 0 0 96px;
font-family: inherit;
font-size: 12px;
background: #0e0e0e;
color: #ddd;
border: 1px solid #333;
border-radius: 4px;
padding: 5px 8px;
}
.color-hex:focus { outline: none; border-color: #4488cc; }
.color-inherit-tag {
font-size: 10px;
color: #666;
font-style: italic;
}
.color-clear-field {
background: transparent;
border: 1px solid #333;
color: #888;
border-radius: 4px;
width: 24px;
height: 24px;
font-size: 13px;
line-height: 1;
cursor: pointer;
}
.color-clear-field:hover { background: #2a2a2a; color: #ddd; }
/* ---- Live preview ------------------------------------------------------- */
.color-preview {
margin: 16px 0;
border: 1px solid #2a2a2a;
border-radius: 6px;
padding: 10px 12px;
font-size: 12px;
line-height: 1.7;
overflow: hidden;
}
.color-preview-line { white-space: pre; }
.color-preview-prompt { font-weight: 600; opacity: 0.85; }
.color-preview-cursor {
display: inline-block;
width: 8px;
height: 14px;
margin-left: 2px;
vertical-align: text-bottom;
border-radius: 1px;
}
/* ---- Presets ------------------------------------------------------------ */
.color-presets { margin-top: 4px; }
.color-presets-label {
display: block;
font-size: 11px;
color: #888;
margin-bottom: 8px;
letter-spacing: 0.04em;
}
.color-presets-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.color-preset {
display: inline-flex;
align-items: center;
gap: 7px;
font: inherit;
font-size: 11px;
color: #bbb;
background: #1d1d1d;
border: 1px solid #333;
border-radius: 5px;
padding: 5px 9px 5px 5px;
cursor: pointer;
}
.color-preset:hover { border-color: #4488cc; color: #eee; }
.color-preset-swatch {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 20px;
border: 1px solid;
border-radius: 3px;
font-size: 10px;
font-weight: 600;
}
/* ---- Actions ------------------------------------------------------------ */
.color-actions {
margin-top: 18px;
display: flex;
justify-content: flex-end;
}
.color-reset {
font: inherit;
font-size: 11px;
color: #cbb;
background: transparent;
border: 1px solid #443;
border-radius: 5px;
padding: 6px 12px;
cursor: pointer;
}
.color-reset:hover { background: #2a2420; color: #eed; border-color: #665; }

View file

@ -0,0 +1,258 @@
import { useEffect, useState } from "react";
import type { NodeId } from "../lib/layout/tree";
import {
type PaneColors,
COLOR_PRESETS,
resolvePaneColors,
} from "../lib/theme";
import "./ColorPanel.css";
interface ColorPanelProps {
/** App-wide default theme. */
globalColors: PaneColors;
/** Persist a new global theme (pass {} to reset to built-in defaults). */
onChangeGlobal: (colors: PaneColors) => void;
/** Active pane being targeted in per-pane mode (null only global mode
* is available). */
activeLeafId: NodeId | null;
/** Human label for the active pane, shown in the mode toggle. */
activeLeafLabel?: string;
/** The active pane's current override (undefined → fully inherits global). */
activeOverride: PaneColors | undefined;
/** Persist the active pane's override (undefined → clear it). */
onChangeActive: (colors: PaneColors | undefined) => void;
/** Which target the panel opens on. */
initialMode: "global" | "pane";
onClose: () => void;
}
type Mode = "global" | "pane";
const FIELDS: { key: keyof PaneColors; label: string }[] = [
{ key: "background", label: "Background" },
{ key: "foreground", label: "Foreground" },
{ key: "cursor", label: "Cursor" },
{ key: "selection", label: "Selection" },
];
const HEX_RE = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
/** Expand #rgb #rrggbb so `<input type="color">` (which only accepts the
* 6-digit form) always gets a valid value. */
function expandHex(hex: string): string {
if (/^#[0-9a-fA-F]{3}$/.test(hex)) {
return "#" + hex.slice(1).split("").map((c) => c + c).join("");
}
return hex;
}
export default function ColorPanel({
globalColors,
onChangeGlobal,
activeLeafId,
activeLeafLabel,
activeOverride,
onChangeActive,
initialMode,
onClose,
}: ColorPanelProps) {
// Fall back to global mode if asked for per-pane with no active pane.
const [mode, setMode] = useState<Mode>(
initialMode === "pane" && activeLeafId ? "pane" : "global",
);
const paneMode = mode === "pane" && !!activeLeafId;
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") {
e.preventDefault();
onClose();
}
}
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
// The override layer we're editing: the leaf's override in pane mode, or
// the global theme itself in global mode. `resolved` fills every field so
// the swatches/preview always show a concrete colour.
const editLayer: PaneColors = paneMode ? (activeOverride ?? {}) : globalColors;
const resolved = paneMode
? resolvePaneColors(globalColors, activeOverride)
: resolvePaneColors(globalColors, undefined);
/** Whether a field is explicitly set on the layer we're editing (vs.
* inherited). Only meaningful in pane mode for the "inherited" hint. */
const isSet = (key: keyof PaneColors) => editLayer[key] !== undefined;
function setField(key: keyof PaneColors, value: string) {
const next: PaneColors = { ...editLayer, [key]: value };
if (paneMode) onChangeActive(next);
else onChangeGlobal(next);
}
/** Pane mode only: drop one field's override so it re-inherits the global. */
function clearField(key: keyof PaneColors) {
if (!paneMode) return;
const next: PaneColors = { ...editLayer };
delete next[key];
onChangeActive(next);
}
function applyPreset(colors: PaneColors) {
if (paneMode) onChangeActive({ ...colors });
else onChangeGlobal({ ...colors });
}
function resetAll() {
if (paneMode) onChangeActive(undefined);
else onChangeGlobal({});
}
return (
<>
<button className="backdrop" onClick={onClose} aria-label="Close" />
<div className="color-panel" role="dialog" aria-label="Terminal colours">
<header className="color-header">
<span className="color-title">Terminal colours</span>
<button className="color-close" onClick={onClose} aria-label="Close">
×
</button>
</header>
{/* Target toggle: edit the global default or just the active pane. */}
<div className="color-modes" role="tablist">
<button
className={`color-mode${mode === "global" ? " color-mode--active" : ""}`}
role="tab"
aria-selected={mode === "global"}
onClick={() => setMode("global")}
>
Global default
</button>
<button
className={`color-mode${paneMode ? " color-mode--active" : ""}`}
role="tab"
aria-selected={paneMode}
disabled={!activeLeafId}
onClick={() => setMode("pane")}
title={
activeLeafId
? "Override colours for the active pane only"
: "Select a pane first to override it"
}
>
{activeLeafId
? `This pane (${activeLeafLabel || "active"})`
: "This pane"}
</button>
</div>
<div className="color-body">
<p className="color-blurb">
{paneMode
? "These colours override the global theme for the active pane only. Unset rows inherit the global default."
: "These colours apply to every pane that doesn't have its own override. Saved across restarts and shared with new windows."}
</p>
{/* Editable colour rows */}
<div className="color-rows">
{FIELDS.map(({ key, label }) => {
const value = resolved[key]!;
const inherited = paneMode && !isSet(key);
return (
<div className="color-row" key={key}>
<span className="color-row-label">{label}</span>
<input
type="color"
className="color-swatch"
value={expandHex(value)}
onChange={(e) => setField(key, e.target.value)}
aria-label={label}
/>
<input
type="text"
className="color-hex"
value={value}
spellCheck={false}
onChange={(e) => {
const v = e.target.value.trim();
if (HEX_RE.test(v)) setField(key, v);
}}
/>
{paneMode &&
(inherited ? (
<span className="color-inherit-tag" title="Inheriting the global default">
inherited
</span>
) : (
<button
className="color-clear-field"
onClick={() => clearField(key)}
title="Revert this colour to the global default"
aria-label={`Revert ${label} to global`}
>
</button>
))}
</div>
);
})}
</div>
{/* Live preview */}
<div
className="color-preview"
style={{ background: resolved.background, color: resolved.foreground }}
aria-hidden="true"
>
<div className="color-preview-line">
<span className="color-preview-prompt">user@tiletopia</span>:~$ ls -la
</div>
<div className="color-preview-line">
<span style={{ background: resolved.selection }}>selected text</span>{" "}
normal output
<span
className="color-preview-cursor"
style={{ background: resolved.cursor }}
/>
</div>
</div>
{/* Presets */}
<div className="color-presets">
<span className="color-presets-label">Presets</span>
<div className="color-presets-row">
{COLOR_PRESETS.map((p) => (
<button
key={p.name}
className="color-preset"
onClick={() => applyPreset(p.colors)}
title={`Apply ${p.name}`}
>
<span
className="color-preset-swatch"
style={{
background: p.colors.background,
color: p.colors.foreground,
borderColor: p.colors.selection,
}}
>
Ab
</span>
{p.name}
</button>
))}
</div>
</div>
<div className="color-actions">
<button className="color-reset" onClick={resetAll}>
{paneMode ? "Reset pane to global" : "Reset to defaults"}
</button>
</div>
</div>
</div>
</>
);
}

View file

@ -25,6 +25,11 @@ import {
type SpawnSpec,
} from "../ipc";
import type { NavigateIntent } from "../lib/layout/orchestration";
import {
type PaneColors,
DEFAULT_PANE_COLORS,
toXtermTheme,
} from "../lib/theme";
// ---------------------------------------------------------------------------
// base64 helpers (private to this module)
@ -76,6 +81,9 @@ interface XtermPaneProps {
focusTrigger?: number;
/** Absolute font size in px. Changes are applied live (fit + PTY resize). */
fontSize?: number;
/** Fully-resolved terminal colours (global theme merged with any per-pane
* override). Changes are applied live to the running terminal. */
colors?: Required<PaneColors>;
/** Called when the user presses a tiling-WM navigation chord inside the
* terminal. XtermPane only emits the intent; the parent (LeafPane/App)
* resolves the target leaf from the current layout and sets it active.
@ -100,6 +108,7 @@ export default function XtermPane({
onFocus,
focusTrigger = 0,
fontSize,
colors,
onNavigate,
}: XtermPaneProps) {
const containerRef = useRef<HTMLDivElement>(null);
@ -112,6 +121,9 @@ export default function XtermPane({
// up the initial value without re-running when it changes (the secondary
// effect below handles dynamic updates).
const initialFontSizeRef = useRef(fontSize);
// Same trick for the initial theme — the mount effect reads this once; the
// secondary effect below applies later changes live.
const initialColorsRef = useRef(colors);
// Stable refs for callbacks so the mount effect doesn't need to re-run when
// parents pass new inline functions, while still always calling the latest version.
@ -144,10 +156,12 @@ export default function XtermPane({
fontFamily: '"Cascadia Mono", "JetBrains Mono", "Consolas", monospace',
fontSize: initialFontSizeRef.current ?? DEFAULT_XTERM_FONT_SIZE,
cursorBlink: true,
theme: {
background: "#0c0c0c",
foreground: "#e6e6e6",
},
// Theme is resolved by the parent (global default merged with any
// per-pane override) and applied live by the effect below. The fixed
// slice — softened white/brightWhite that tame the Claude TUI's
// emphasis slots so nothing hits glaring pure white — lives in
// toXtermTheme / BASE_XTERM_THEME (see lib/theme.ts).
theme: toXtermTheme(initialColorsRef.current ?? DEFAULT_PANE_COLORS),
scrollback: 5000,
convertEol: false,
allowProposedApi: true,
@ -537,6 +551,27 @@ export default function XtermPane({
}
}, [fontSize]);
// -------------------------------------------------------------------------
// Live colour-theme changes (global theme edit, per-pane override, preset).
//
// Setting term.options.theme re-tints the renderer immediately; a refresh
// forces the canvas surface to repaint already-drawn cells with the new
// palette (xterm only re-tints on the next write otherwise). Cell geometry
// is unaffected, so no fit()/resize is needed — unlike the font-size path.
// -------------------------------------------------------------------------
useEffect(() => {
const term = termRef.current;
if (!term || !colors) return;
try {
term.options.theme = toXtermTheme(colors);
term.refresh(0, term.rows - 1);
} catch (e) {
console.warn("theme apply failed", e);
}
// Depend on the individual fields rather than the object identity so a
// parent that rebuilds an equal colours object each render doesn't churn.
}, [colors?.background, colors?.foreground, colors?.cursor, colors?.selection]);
// Close the search bar and return focus to the xterm textarea so the user
// can resume typing immediately. Queries the well-known xterm helper
// textarea selector — the same pattern used in the focusTrigger effect.

View file

@ -9,6 +9,7 @@ import {
} from "react";
import { createPortal } from "react-dom";
import { type LeafNode, resolveFontSize, type LeafShellSpec } from "./tree";
import { resolvePaneColors } from "../theme";
import { useOrchestration } from "./orchestration";
import XtermPane from "../../components/XtermPane";
import type { SpawnSpec } from "../../ipc";
@ -547,6 +548,22 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
🤖
</button>
<button
className={`bcast-chip color-chip${leaf.colorOverride ? " on" : ""}`}
onClick={(e) => {
e.stopPropagation();
orch.openColorPanel(leaf.id);
}}
title={
leaf.colorOverride
? "This pane has custom colours — click to edit"
: "Set custom colours for this pane"
}
aria-pressed={leaf.colorOverride ? "true" : "false"}
>
🎨
</button>
{isIdle && statusOk ? (
<span className="pane-status idle" title={`No output for ${IDLE_THRESHOLD_MS / 1000}s+`}>
idle
@ -604,6 +621,7 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
onNavigate={onPaneNavigate}
focusTrigger={focusTrigger}
fontSize={resolveFontSize(leaf.fontSizeOffset)}
colors={resolvePaneColors(orch.globalColors, leaf.colorOverride)}
/>
) : (
<div className="leaf-missing-host">

View file

@ -1,6 +1,7 @@
import { createContext, useContext, type ReactNode } from "react";
import type { Orientation, NodeId, LeafShellSpec, Direction } from "./tree";
import type { PaneId, SshHost } from "../../ipc";
import type { PaneColors } from "../theme";
/**
* Orchestration context every piece of shared state and every operation
@ -21,6 +22,10 @@ export interface Orchestration {
/** Saved SSH hosts loaded from `hosts.json`. Reactive changes when the
* user edits hosts via {@link openHostManager}. */
hosts: SshHost[];
/** App-wide default terminal colours. Reactive edited via the colour
* panel. Each leaf resolves its effective theme from this plus its own
* {@link LeafNode.colorOverride}. */
globalColors: PaneColors;
// Tree mutations
split: (leafId: NodeId, orientation: Orientation) => void;
@ -34,9 +39,15 @@ export interface Orchestration {
/** Flip the per-pane mcpAllow flag. Default-deny; chip in the pane
* toolbar drives this. */
toggleMcpAllow: (leafId: NodeId) => void;
/** Set or clear a leaf's per-pane colour override (undefined fall back
* to the global theme). */
setLeafColors: (leafId: NodeId, colors: PaneColors | undefined) => void;
// SSH host management
openHostManager: () => void;
/** Open the colour panel. When `leafId` is given the panel starts in
* per-pane mode targeting that leaf; otherwise it edits the global theme. */
openColorPanel: (leafId?: NodeId) => void;
// Per-pane orchestration
setActive: (leafId: NodeId) => void;

View file

@ -13,6 +13,7 @@ import {
changeLabel,
toggleBroadcast,
toggleMcpAllow,
setLeafColors,
adjustFontSize,
adjustAllFontSizes,
resolveFontSize,
@ -302,12 +303,13 @@ describe("setLeafShell", () => {
expect(next.id).not.toBe(leaf.id);
});
it("preserves label / broadcast / fontSizeOffset across the shell change", () => {
it("preserves label / broadcast / fontSizeOffset / colorOverride across the shell change", () => {
const leaf = newLeaf({
distro: "Ubuntu",
label: "my pane",
broadcast: true,
fontSizeOffset: 2,
colorOverride: { background: "#101010" },
});
const next = setLeafShell(leaf, leaf.id, {
shellKind: "powershell",
@ -315,6 +317,7 @@ describe("setLeafShell", () => {
expect(next.label).toBe("my pane");
expect(next.broadcast).toBe(true);
expect(next.fontSizeOffset).toBe(2);
expect(next.colorOverride).toEqual({ background: "#101010" });
});
});
@ -389,6 +392,58 @@ describe("toggleMcpAllow", () => {
});
});
describe("setLeafColors", () => {
it("sets an override on a leaf with none", () => {
const leaf = newLeaf();
expect(leaf.colorOverride).toBeUndefined();
const next = setLeafColors(leaf, leaf.id, {
background: "#001122",
foreground: "#ddeeff",
}) as LeafNode;
expect(next.colorOverride).toEqual({
background: "#001122",
foreground: "#ddeeff",
});
});
it("replaces an existing override wholesale", () => {
const leaf = newLeaf({ colorOverride: { background: "#000000" } });
const next = setLeafColors(leaf, leaf.id, { cursor: "#ff0000" }) as LeafNode;
expect(next.colorOverride).toEqual({ cursor: "#ff0000" });
});
it("clears the override when passed undefined", () => {
const leaf = newLeaf({ colorOverride: { background: "#000000" } });
const next = setLeafColors(leaf, leaf.id, undefined) as LeafNode;
expect(next.colorOverride).toBeUndefined();
expect("colorOverride" in next).toBe(false);
});
it("clears the override when passed an all-undefined object", () => {
const leaf = newLeaf({ colorOverride: { background: "#000000" } });
const next = setLeafColors(leaf, leaf.id, {
background: undefined,
foreground: undefined,
cursor: undefined,
selection: undefined,
}) as LeafNode;
expect(next.colorOverride).toBeUndefined();
expect("colorOverride" in next).toBe(false);
});
it("returns the same reference when clearing an already-unset override", () => {
const leaf = newLeaf();
const next = setLeafColors(leaf, leaf.id, undefined);
expect(next).toBe(leaf);
});
it("MUST NOT swap the leaf id (metadata-only, no PTY respawn)", () => {
const leaf = newLeaf();
const next = setLeafColors(leaf, leaf.id, { background: "#123456" }) as LeafNode;
expect(next.id).toBe(leaf.id);
});
});
describe("resolveFontSize", () => {
it("returns the default when offset is undefined or 0", () => {
expect(resolveFontSize(undefined)).toBe(DEFAULT_FONT_SIZE);

View file

@ -5,6 +5,8 @@
//! tmux / i3 / Zellij use — dragging a gutter mutates one parent ratio,
//! both sibling subtrees reflow automatically.
import type { PaneColors } from "../theme";
export type NodeId = string;
/** 'h' = side-by-side (a on left, b on right). 'v' = stacked (a on top, b below). */
@ -44,6 +46,13 @@ export interface LeafNode {
* later doesn't require migrating saved workspaces.
*/
fontSizeOffset?: number;
/**
* Per-pane colour override. Any field set here wins over the app-wide
* global theme (see {@link resolvePaneColors}); unset fields fall through.
* Undefined / empty means "use the global theme". Metadata-only changing
* it never respawns the PTY.
*/
colorOverride?: PaneColors;
/**
* If true, this pane is visible to the MCP server (Claude can list it,
* read its scrollback, etc.). Default-DENY: when undefined or false, the
@ -111,6 +120,7 @@ export function setLeafShell(
label: node.label,
broadcast: node.broadcast,
fontSizeOffset: node.fontSizeOffset,
colorOverride: node.colorOverride,
};
if (spec.shellKind === "wsl") {
if (spec.distro !== undefined) base.distro = spec.distro;
@ -294,6 +304,32 @@ export function toggleMcpAllow(root: TreeNode, leafId: NodeId): TreeNode {
});
}
/** Set (or clear) a leaf's per-pane colour override. Pass `undefined` or an
* empty object to drop the override so the pane falls back to the global
* theme. Metadata-only does NOT swap the id, so the PTY keeps running. */
export function setLeafColors(
root: TreeNode,
leafId: NodeId,
colors: PaneColors | undefined,
): TreeNode {
return replaceById(root, leafId, (node) => {
if (node.kind !== "leaf") return node;
const empty =
!colors ||
(colors.background === undefined &&
colors.foreground === undefined &&
colors.cursor === undefined &&
colors.selection === undefined);
if (empty) {
if (node.colorOverride === undefined) return node;
const next: LeafNode = { ...node };
delete next.colorOverride;
return next;
}
return { ...node, colorOverride: colors };
});
}
/** Compute the actual pixel font size from a leaf's offset, clamped to
* [MIN_FONT_SIZE, MAX_FONT_SIZE]. */
export function resolveFontSize(offset: number | undefined): number {
@ -383,6 +419,7 @@ export function reshapeToPreset(
if (src.label !== undefined) slot.label = src.label;
if (src.broadcast !== undefined) slot.broadcast = src.broadcast;
if (src.fontSizeOffset !== undefined) slot.fontSizeOffset = src.fontSizeOffset;
if (src.colorOverride !== undefined) slot.colorOverride = src.colorOverride;
if (src.mcpAllow !== undefined) slot.mcpAllow = src.mcpAllow;
}

79
src/lib/theme.test.ts Normal file
View file

@ -0,0 +1,79 @@
import { describe, it, expect } from "vitest";
import {
resolvePaneColors,
toXtermTheme,
DEFAULT_PANE_COLORS,
COLOR_PRESETS,
type PaneColors,
} from "./theme";
describe("resolvePaneColors", () => {
it("falls back to defaults when nothing is set", () => {
expect(resolvePaneColors(undefined, undefined)).toEqual(DEFAULT_PANE_COLORS);
});
it("uses global values over defaults", () => {
const global: PaneColors = { background: "#111111", cursor: "#abcdef" };
const r = resolvePaneColors(global, undefined);
expect(r.background).toBe("#111111");
expect(r.cursor).toBe("#abcdef");
// Unset fields still come from defaults.
expect(r.foreground).toBe(DEFAULT_PANE_COLORS.foreground);
expect(r.selection).toBe(DEFAULT_PANE_COLORS.selection);
});
it("per-pane override wins over global, field by field", () => {
const global: PaneColors = { background: "#111111", foreground: "#222222" };
const override: PaneColors = { background: "#999999" };
const r = resolvePaneColors(global, override);
expect(r.background).toBe("#999999"); // override wins
expect(r.foreground).toBe("#222222"); // inherits global
expect(r.cursor).toBe(DEFAULT_PANE_COLORS.cursor); // inherits default
});
it("always returns all four fields defined", () => {
const r = resolvePaneColors({}, {});
expect(Object.keys(r).sort()).toEqual([
"background",
"cursor",
"foreground",
"selection",
]);
});
});
describe("toXtermTheme", () => {
it("maps resolved colours onto the xterm ITheme shape", () => {
const theme = toXtermTheme({
background: "#0c0c0c",
foreground: "#c5c8c6",
cursor: "#ffffff",
selection: "#3a3a3a",
});
expect(theme.background).toBe("#0c0c0c");
expect(theme.foreground).toBe("#c5c8c6");
expect(theme.cursor).toBe("#ffffff");
// selection maps to xterm 5.x's renamed property.
expect(theme.selectionBackground).toBe("#3a3a3a");
// cursorAccent is pinned to the background for block-cursor legibility.
expect(theme.cursorAccent).toBe("#0c0c0c");
});
it("keeps the fixed softened white/brightWhite slice", () => {
const theme = toXtermTheme(DEFAULT_PANE_COLORS);
expect(theme.white).toBe("#c5c8c6");
expect(theme.brightWhite).toBe("#e0e0e0");
});
});
describe("COLOR_PRESETS", () => {
it("starts with the tiletopia default and every preset is fully specified", () => {
expect(COLOR_PRESETS[0].name).toBe("Tiletopia Dark");
expect(COLOR_PRESETS[0].colors).toEqual(DEFAULT_PANE_COLORS);
for (const p of COLOR_PRESETS) {
for (const key of ["background", "foreground", "cursor", "selection"] as const) {
expect(p.colors[key]).toMatch(/^#[0-9a-fA-F]{6}$/);
}
}
});
});

160
src/lib/theme.ts Normal file
View file

@ -0,0 +1,160 @@
//! Terminal colour theming.
//!
//! tiletopia ships one hard-coded dark palette historically baked into
//! XtermPane. This module turns that into a customisable model:
//!
//! - a GLOBAL default theme (persisted to localStorage, app-wide), and
//! - optional PER-PANE overrides (stored on the LeafNode, persisted with the
//! workspace tree).
//!
//! Only four colours are user-editable — background, foreground, cursor, and
//! selection — the ones that actually move the needle on readability. The
//! rest of xterm's ITheme (the 16-colour ANSI palette, etc.) stays fixed in
//! {@link BASE_XTERM_THEME}: notably `white`/`brightWhite` keep the softened
//! values that tame the Claude TUI's emphasis slots (see XtermPane history).
import type { ITheme } from "@xterm/xterm";
/** The four user-editable colours. All optional: an undefined field on a
* per-pane override falls through to the global default; an undefined field
* on the global default falls through to {@link DEFAULT_PANE_COLORS}. */
export interface PaneColors {
/** Terminal background. */
background?: string;
/** Default text colour. */
foreground?: string;
/** Cursor block colour. */
cursor?: string;
/** Selection highlight background. */
selection?: string;
}
/** Fixed slice of the xterm theme that is NOT user-editable. The softened
* white/brightWhite values date back to the original hard-coded theme they
* keep the Claude TUI's emphasis text from hitting glaring pure white. */
const BASE_XTERM_THEME: ITheme = {
white: "#c5c8c6",
brightWhite: "#e0e0e0",
};
/** Ground-truth defaults the historical tiletopia palette. Every editable
* field resolves to one of these when nothing overrides it. Also exposed as
* the first preset ("Tiletopia Dark"). */
export const DEFAULT_PANE_COLORS: Required<PaneColors> = {
background: "#0c0c0c",
foreground: "#c5c8c6",
cursor: "#ffffff",
selection: "#3a3a3a",
};
/** A named, ready-to-apply colour set shown as a one-click starting point in
* the colour panel. */
export interface ColorPreset {
name: string;
colors: Required<PaneColors>;
}
/** Built-in presets. The first is the tiletopia default; the rest are
* well-known community palettes (background/foreground/cursor/selection
* only the ANSI ramp is left to {@link BASE_XTERM_THEME}). */
export const COLOR_PRESETS: ColorPreset[] = [
{ name: "Tiletopia Dark", colors: DEFAULT_PANE_COLORS },
{
name: "Solarized Dark",
colors: { background: "#002b36", foreground: "#839496", cursor: "#93a1a1", selection: "#073642" },
},
{
name: "Gruvbox Dark",
colors: { background: "#282828", foreground: "#ebdbb2", cursor: "#ebdbb2", selection: "#504945" },
},
{
name: "Dracula",
colors: { background: "#282a36", foreground: "#f8f8f2", cursor: "#f8f8f2", selection: "#44475a" },
},
{
name: "Nord",
colors: { background: "#2e3440", foreground: "#d8dee9", cursor: "#d8dee9", selection: "#434c5e" },
},
{
name: "Light",
colors: { background: "#fafafa", foreground: "#1c1c1c", cursor: "#1c1c1c", selection: "#cfe0ff" },
},
];
/** Merge a per-pane override on top of the global default, then fill any
* still-missing field from {@link DEFAULT_PANE_COLORS}. The result always
* has all four fields defined. */
export function resolvePaneColors(
global: PaneColors | undefined,
override: PaneColors | undefined,
): Required<PaneColors> {
return {
background:
override?.background ?? global?.background ?? DEFAULT_PANE_COLORS.background,
foreground:
override?.foreground ?? global?.foreground ?? DEFAULT_PANE_COLORS.foreground,
cursor: override?.cursor ?? global?.cursor ?? DEFAULT_PANE_COLORS.cursor,
selection:
override?.selection ?? global?.selection ?? DEFAULT_PANE_COLORS.selection,
};
}
/** Build a full xterm ITheme from resolved colours. cursorAccent is pinned to
* the background so a block cursor's glyph stays readable. */
export function toXtermTheme(colors: Required<PaneColors>): ITheme {
return {
...BASE_XTERM_THEME,
background: colors.background,
foreground: colors.foreground,
cursor: colors.cursor,
cursorAccent: colors.background,
selectionBackground: colors.selection,
};
}
// ---------------------------------------------------------------------------
// Global-default persistence (localStorage; frontend-only, no backend hop).
// localStorage is shared across all windows of the same origin, so a new
// window picks up the saved theme at startup, and the `storage` event lets
// open windows react live (see App's listener).
// ---------------------------------------------------------------------------
export const GLOBAL_COLORS_STORAGE_KEY = "tiletopia.globalColors.v1";
/** #rgb / #rrggbb hex validator what `<input type="color">` emits and what
* xterm accepts. We reject anything else so a corrupt localStorage value
* can't poison the theme. */
const HEX_RE = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
function sanitizeColors(raw: unknown): PaneColors {
if (typeof raw !== "object" || raw === null) return {};
const o = raw as Record<string, unknown>;
const out: PaneColors = {};
for (const key of ["background", "foreground", "cursor", "selection"] as const) {
const v = o[key];
if (typeof v === "string" && HEX_RE.test(v)) out[key] = v;
}
return out;
}
/** Read the saved global theme. Returns {} ( all defaults) when absent or
* unparseable. */
export function loadGlobalColors(): PaneColors {
try {
const raw = localStorage.getItem(GLOBAL_COLORS_STORAGE_KEY);
if (!raw) return {};
return sanitizeColors(JSON.parse(raw));
} catch {
return {};
}
}
/** Persist the global theme. Empty object is stored as-is (means "all
* defaults"), keeping the round-trip lossless. */
export function saveGlobalColors(colors: PaneColors): void {
try {
localStorage.setItem(GLOBAL_COLORS_STORAGE_KEY, JSON.stringify(colors));
} catch (e) {
console.warn("saveGlobalColors failed:", e);
}
}