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:
parent
1febf2e096
commit
7e624a3f96
10 changed files with 938 additions and 5 deletions
77
src/App.tsx
77
src/App.tsx
|
|
@ -77,6 +77,7 @@ import {
|
||||||
changeLabel,
|
changeLabel,
|
||||||
toggleBroadcast as toggleBroadcastInTree,
|
toggleBroadcast as toggleBroadcastInTree,
|
||||||
toggleMcpAllow as toggleMcpAllowInTree,
|
toggleMcpAllow as toggleMcpAllowInTree,
|
||||||
|
setLeafColors as setLeafColorsInTree,
|
||||||
setAllBroadcast,
|
setAllBroadcast,
|
||||||
adjustFontSize,
|
adjustFontSize,
|
||||||
adjustAllFontSizes,
|
adjustAllFontSizes,
|
||||||
|
|
@ -106,6 +107,13 @@ import Palette from "./components/Palette";
|
||||||
import HostManager from "./components/HostManager";
|
import HostManager from "./components/HostManager";
|
||||||
import Help from "./components/Help";
|
import Help from "./components/Help";
|
||||||
import McpPanel from "./components/McpPanel";
|
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 McpConfirm, { type McpConfirmSpec } from "./components/McpConfirm";
|
||||||
import TabStrip from "./components/TabStrip";
|
import TabStrip from "./components/TabStrip";
|
||||||
import "./App.css";
|
import "./App.css";
|
||||||
|
|
@ -239,6 +247,15 @@ export default function App() {
|
||||||
token: null,
|
token: null,
|
||||||
});
|
});
|
||||||
const [mcpPanelOpen, setMcpPanelOpen] = useState(false);
|
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 [ready, setReady] = useState(false);
|
||||||
const [notifications, setNotifications] = useState<Toast[]>([]);
|
const [notifications, setNotifications] = useState<Toast[]>([]);
|
||||||
const [paletteOpen, setPaletteOpen] = useState(false);
|
const [paletteOpen, setPaletteOpen] = useState(false);
|
||||||
|
|
@ -647,6 +664,34 @@ export default function App() {
|
||||||
setTree((t) => toggleMcpAllowInTree(t, leafId));
|
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 ------------------------------------------------
|
// ---- MCP server lifecycle ------------------------------------------------
|
||||||
const refreshMcpStatus = useCallback(async () => {
|
const refreshMcpStatus = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -1265,13 +1310,16 @@ export default function App() {
|
||||||
activeLeafId,
|
activeLeafId,
|
||||||
distros,
|
distros,
|
||||||
hosts,
|
hosts,
|
||||||
|
globalColors,
|
||||||
split,
|
split,
|
||||||
close,
|
close,
|
||||||
setShell,
|
setShell,
|
||||||
setLabel,
|
setLabel,
|
||||||
toggleBroadcast,
|
toggleBroadcast,
|
||||||
toggleMcpAllow,
|
toggleMcpAllow,
|
||||||
|
setLeafColors,
|
||||||
openHostManager,
|
openHostManager,
|
||||||
|
openColorPanel,
|
||||||
setActive,
|
setActive,
|
||||||
navigateTo,
|
navigateTo,
|
||||||
registerPaneId,
|
registerPaneId,
|
||||||
|
|
@ -1290,13 +1338,16 @@ export default function App() {
|
||||||
activeLeafId,
|
activeLeafId,
|
||||||
distros,
|
distros,
|
||||||
hosts,
|
hosts,
|
||||||
|
globalColors,
|
||||||
split,
|
split,
|
||||||
close,
|
close,
|
||||||
setShell,
|
setShell,
|
||||||
setLabel,
|
setLabel,
|
||||||
toggleBroadcast,
|
toggleBroadcast,
|
||||||
toggleMcpAllow,
|
toggleMcpAllow,
|
||||||
|
setLeafColors,
|
||||||
openHostManager,
|
openHostManager,
|
||||||
|
openColorPanel,
|
||||||
setActive,
|
setActive,
|
||||||
navigateTo,
|
navigateTo,
|
||||||
registerPaneId,
|
registerPaneId,
|
||||||
|
|
@ -2086,6 +2137,14 @@ export default function App() {
|
||||||
>
|
>
|
||||||
🤖
|
🤖
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className="palette-btn"
|
||||||
|
onClick={() => openColorPanel()}
|
||||||
|
title="Terminal colours (global theme + per-pane overrides)"
|
||||||
|
aria-label="Terminal colours"
|
||||||
|
>
|
||||||
|
🎨
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
className="palette-btn"
|
className="palette-btn"
|
||||||
onClick={() => setHelpOpen(true)}
|
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 && (
|
{confirmQueue.length > 0 && (
|
||||||
<McpConfirm
|
<McpConfirm
|
||||||
spec={confirmQueue[0]}
|
spec={confirmQueue[0]}
|
||||||
|
|
|
||||||
203
src/components/ColorPanel.css
Normal file
203
src/components/ColorPanel.css
Normal 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; }
|
||||||
258
src/components/ColorPanel.tsx
Normal file
258
src/components/ColorPanel.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -25,6 +25,11 @@ import {
|
||||||
type SpawnSpec,
|
type SpawnSpec,
|
||||||
} from "../ipc";
|
} from "../ipc";
|
||||||
import type { NavigateIntent } from "../lib/layout/orchestration";
|
import type { NavigateIntent } from "../lib/layout/orchestration";
|
||||||
|
import {
|
||||||
|
type PaneColors,
|
||||||
|
DEFAULT_PANE_COLORS,
|
||||||
|
toXtermTheme,
|
||||||
|
} from "../lib/theme";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// base64 helpers (private to this module)
|
// base64 helpers (private to this module)
|
||||||
|
|
@ -76,6 +81,9 @@ interface XtermPaneProps {
|
||||||
focusTrigger?: number;
|
focusTrigger?: number;
|
||||||
/** Absolute font size in px. Changes are applied live (fit + PTY resize). */
|
/** Absolute font size in px. Changes are applied live (fit + PTY resize). */
|
||||||
fontSize?: number;
|
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
|
/** Called when the user presses a tiling-WM navigation chord inside the
|
||||||
* terminal. XtermPane only emits the intent; the parent (LeafPane/App)
|
* terminal. XtermPane only emits the intent; the parent (LeafPane/App)
|
||||||
* resolves the target leaf from the current layout and sets it active.
|
* resolves the target leaf from the current layout and sets it active.
|
||||||
|
|
@ -100,6 +108,7 @@ export default function XtermPane({
|
||||||
onFocus,
|
onFocus,
|
||||||
focusTrigger = 0,
|
focusTrigger = 0,
|
||||||
fontSize,
|
fontSize,
|
||||||
|
colors,
|
||||||
onNavigate,
|
onNavigate,
|
||||||
}: XtermPaneProps) {
|
}: XtermPaneProps) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
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
|
// up the initial value without re-running when it changes (the secondary
|
||||||
// effect below handles dynamic updates).
|
// effect below handles dynamic updates).
|
||||||
const initialFontSizeRef = useRef(fontSize);
|
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
|
// 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.
|
// 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',
|
fontFamily: '"Cascadia Mono", "JetBrains Mono", "Consolas", monospace',
|
||||||
fontSize: initialFontSizeRef.current ?? DEFAULT_XTERM_FONT_SIZE,
|
fontSize: initialFontSizeRef.current ?? DEFAULT_XTERM_FONT_SIZE,
|
||||||
cursorBlink: true,
|
cursorBlink: true,
|
||||||
theme: {
|
// Theme is resolved by the parent (global default merged with any
|
||||||
background: "#0c0c0c",
|
// per-pane override) and applied live by the effect below. The fixed
|
||||||
foreground: "#e6e6e6",
|
// 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,
|
scrollback: 5000,
|
||||||
convertEol: false,
|
convertEol: false,
|
||||||
allowProposedApi: true,
|
allowProposedApi: true,
|
||||||
|
|
@ -537,6 +551,27 @@ export default function XtermPane({
|
||||||
}
|
}
|
||||||
}, [fontSize]);
|
}, [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
|
// Close the search bar and return focus to the xterm textarea so the user
|
||||||
// can resume typing immediately. Queries the well-known xterm helper
|
// can resume typing immediately. Queries the well-known xterm helper
|
||||||
// textarea selector — the same pattern used in the focusTrigger effect.
|
// textarea selector — the same pattern used in the focusTrigger effect.
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import {
|
||||||
} from "react";
|
} from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { type LeafNode, resolveFontSize, type LeafShellSpec } from "./tree";
|
import { type LeafNode, resolveFontSize, type LeafShellSpec } from "./tree";
|
||||||
|
import { resolvePaneColors } from "../theme";
|
||||||
import { useOrchestration } from "./orchestration";
|
import { useOrchestration } from "./orchestration";
|
||||||
import XtermPane from "../../components/XtermPane";
|
import XtermPane from "../../components/XtermPane";
|
||||||
import type { SpawnSpec } from "../../ipc";
|
import type { SpawnSpec } from "../../ipc";
|
||||||
|
|
@ -547,6 +548,22 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
|
||||||
🤖
|
🤖
|
||||||
</button>
|
</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 ? (
|
{isIdle && statusOk ? (
|
||||||
<span className="pane-status idle" title={`No output for ${IDLE_THRESHOLD_MS / 1000}s+`}>
|
<span className="pane-status idle" title={`No output for ${IDLE_THRESHOLD_MS / 1000}s+`}>
|
||||||
idle
|
idle
|
||||||
|
|
@ -604,6 +621,7 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
|
||||||
onNavigate={onPaneNavigate}
|
onNavigate={onPaneNavigate}
|
||||||
focusTrigger={focusTrigger}
|
focusTrigger={focusTrigger}
|
||||||
fontSize={resolveFontSize(leaf.fontSizeOffset)}
|
fontSize={resolveFontSize(leaf.fontSizeOffset)}
|
||||||
|
colors={resolvePaneColors(orch.globalColors, leaf.colorOverride)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="leaf-missing-host">
|
<div className="leaf-missing-host">
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { createContext, useContext, type ReactNode } from "react";
|
import { createContext, useContext, type ReactNode } from "react";
|
||||||
import type { Orientation, NodeId, LeafShellSpec, Direction } from "./tree";
|
import type { Orientation, NodeId, LeafShellSpec, Direction } from "./tree";
|
||||||
import type { PaneId, SshHost } from "../../ipc";
|
import type { PaneId, SshHost } from "../../ipc";
|
||||||
|
import type { PaneColors } from "../theme";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Orchestration context — every piece of shared state and every operation
|
* 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
|
/** Saved SSH hosts loaded from `hosts.json`. Reactive — changes when the
|
||||||
* user edits hosts via {@link openHostManager}. */
|
* user edits hosts via {@link openHostManager}. */
|
||||||
hosts: SshHost[];
|
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
|
// Tree mutations
|
||||||
split: (leafId: NodeId, orientation: Orientation) => void;
|
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
|
/** Flip the per-pane mcpAllow flag. Default-deny; chip in the pane
|
||||||
* toolbar drives this. */
|
* toolbar drives this. */
|
||||||
toggleMcpAllow: (leafId: NodeId) => void;
|
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
|
// SSH host management
|
||||||
openHostManager: () => void;
|
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
|
// Per-pane orchestration
|
||||||
setActive: (leafId: NodeId) => void;
|
setActive: (leafId: NodeId) => void;
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import {
|
||||||
changeLabel,
|
changeLabel,
|
||||||
toggleBroadcast,
|
toggleBroadcast,
|
||||||
toggleMcpAllow,
|
toggleMcpAllow,
|
||||||
|
setLeafColors,
|
||||||
adjustFontSize,
|
adjustFontSize,
|
||||||
adjustAllFontSizes,
|
adjustAllFontSizes,
|
||||||
resolveFontSize,
|
resolveFontSize,
|
||||||
|
|
@ -302,12 +303,13 @@ describe("setLeafShell", () => {
|
||||||
expect(next.id).not.toBe(leaf.id);
|
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({
|
const leaf = newLeaf({
|
||||||
distro: "Ubuntu",
|
distro: "Ubuntu",
|
||||||
label: "my pane",
|
label: "my pane",
|
||||||
broadcast: true,
|
broadcast: true,
|
||||||
fontSizeOffset: 2,
|
fontSizeOffset: 2,
|
||||||
|
colorOverride: { background: "#101010" },
|
||||||
});
|
});
|
||||||
const next = setLeafShell(leaf, leaf.id, {
|
const next = setLeafShell(leaf, leaf.id, {
|
||||||
shellKind: "powershell",
|
shellKind: "powershell",
|
||||||
|
|
@ -315,6 +317,7 @@ describe("setLeafShell", () => {
|
||||||
expect(next.label).toBe("my pane");
|
expect(next.label).toBe("my pane");
|
||||||
expect(next.broadcast).toBe(true);
|
expect(next.broadcast).toBe(true);
|
||||||
expect(next.fontSizeOffset).toBe(2);
|
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", () => {
|
describe("resolveFontSize", () => {
|
||||||
it("returns the default when offset is undefined or 0", () => {
|
it("returns the default when offset is undefined or 0", () => {
|
||||||
expect(resolveFontSize(undefined)).toBe(DEFAULT_FONT_SIZE);
|
expect(resolveFontSize(undefined)).toBe(DEFAULT_FONT_SIZE);
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@
|
||||||
//! tmux / i3 / Zellij use — dragging a gutter mutates one parent ratio,
|
//! tmux / i3 / Zellij use — dragging a gutter mutates one parent ratio,
|
||||||
//! both sibling subtrees reflow automatically.
|
//! both sibling subtrees reflow automatically.
|
||||||
|
|
||||||
|
import type { PaneColors } from "../theme";
|
||||||
|
|
||||||
export type NodeId = string;
|
export type NodeId = string;
|
||||||
|
|
||||||
/** 'h' = side-by-side (a on left, b on right). 'v' = stacked (a on top, b below). */
|
/** '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.
|
* later doesn't require migrating saved workspaces.
|
||||||
*/
|
*/
|
||||||
fontSizeOffset?: number;
|
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,
|
* 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
|
* read its scrollback, etc.). Default-DENY: when undefined or false, the
|
||||||
|
|
@ -111,6 +120,7 @@ export function setLeafShell(
|
||||||
label: node.label,
|
label: node.label,
|
||||||
broadcast: node.broadcast,
|
broadcast: node.broadcast,
|
||||||
fontSizeOffset: node.fontSizeOffset,
|
fontSizeOffset: node.fontSizeOffset,
|
||||||
|
colorOverride: node.colorOverride,
|
||||||
};
|
};
|
||||||
if (spec.shellKind === "wsl") {
|
if (spec.shellKind === "wsl") {
|
||||||
if (spec.distro !== undefined) base.distro = spec.distro;
|
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
|
/** Compute the actual pixel font size from a leaf's offset, clamped to
|
||||||
* [MIN_FONT_SIZE, MAX_FONT_SIZE]. */
|
* [MIN_FONT_SIZE, MAX_FONT_SIZE]. */
|
||||||
export function resolveFontSize(offset: number | undefined): number {
|
export function resolveFontSize(offset: number | undefined): number {
|
||||||
|
|
@ -383,6 +419,7 @@ export function reshapeToPreset(
|
||||||
if (src.label !== undefined) slot.label = src.label;
|
if (src.label !== undefined) slot.label = src.label;
|
||||||
if (src.broadcast !== undefined) slot.broadcast = src.broadcast;
|
if (src.broadcast !== undefined) slot.broadcast = src.broadcast;
|
||||||
if (src.fontSizeOffset !== undefined) slot.fontSizeOffset = src.fontSizeOffset;
|
if (src.fontSizeOffset !== undefined) slot.fontSizeOffset = src.fontSizeOffset;
|
||||||
|
if (src.colorOverride !== undefined) slot.colorOverride = src.colorOverride;
|
||||||
if (src.mcpAllow !== undefined) slot.mcpAllow = src.mcpAllow;
|
if (src.mcpAllow !== undefined) slot.mcpAllow = src.mcpAllow;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
79
src/lib/theme.test.ts
Normal file
79
src/lib/theme.test.ts
Normal 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
160
src/lib/theme.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue