[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled

This commit is contained in:
2026-09-13 18:24:01 +08:00
commit 3c72efcb7f
3255 changed files with 907009 additions and 0 deletions

View File

@@ -0,0 +1,156 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { getContrastRatio, getHslTokenRelativeLuminance } from '../../domain/colorContrast';
import { TERMINAL_THEMES } from '../config/terminalThemes';
import {
buildSidePanelChromeThemeFromTerminalTheme,
buildTerminalAppearanceCssVars,
buildTerminalSidePanelCssVars,
} from './terminalAppearanceTokens';
test('buildTerminalAppearanceCssVars maps core terminal colors', () => {
const vars = buildTerminalAppearanceCssVars({
id: 'test',
name: 'Test',
type: 'light',
colors: {
background: '#f7f7f7',
foreground: '#100f0f',
cursor: '#24837b',
selection: '#24837b44',
black: '#000',
red: '#000',
green: '#000',
yellow: '#000',
blue: '#000',
magenta: '#000',
cyan: '#000',
white: '#fff',
brightBlack: '#000',
brightRed: '#000',
brightGreen: '#000',
brightYellow: '#000',
brightBlue: '#000',
brightMagenta: '#000',
brightCyan: '#000',
brightWhite: '#fff',
},
});
assert.equal(vars['--nc-term-bg'], '#f7f7f7');
assert.equal(vars['--nc-term-fg'], '#100f0f');
assert.equal(vars['--nc-term-panel-bg'], '#f7f7f7');
assert.equal(vars['--nc-term-host-tree-bg'], '#f7f7f7');
});
test('buildSidePanelChromeThemeFromTerminalTheme uses resolved terminal colors', () => {
const theme = buildSidePanelChromeThemeFromTerminalTheme({
id: 'test',
name: 'Test',
type: 'light',
colors: {
background: '#f7f7f7',
foreground: '#100f0f',
cursor: '#24837b',
selection: '#24837b44',
black: '#000',
red: '#000',
green: '#000',
yellow: '#000',
blue: '#000',
magenta: '#000',
cyan: '#000',
white: '#fff',
brightBlack: '#000',
brightRed: '#000',
brightGreen: '#000',
brightYellow: '#000',
brightBlue: '#000',
brightMagenta: '#000',
brightCyan: '#000',
brightWhite: '#fff',
},
});
assert.equal(theme.termBg, '#f7f7f7');
assert.equal(theme.termFg, '#100f0f');
assert.equal(theme.accent, '#24837b');
assert.match(theme.separator, /^color-mix\(/);
});
test('terminal side panel variables follow the terminal palette with readable selected text', () => {
const vars = buildTerminalSidePanelCssVars({
id: 'high-contrast-mismatch',
name: 'High contrast mismatch',
type: 'dark',
colors: {
background: '#000000',
foreground: '#ffffff',
cursor: '#ffffff',
selection: '#ffffff44',
black: '#000000',
red: '#ff0000',
green: '#00ff00',
yellow: '#ffff00',
blue: '#0000ff',
magenta: '#ff00ff',
cyan: '#00ffff',
white: '#ffffff',
brightBlack: '#000000',
brightRed: '#ff0000',
brightGreen: '#00ff00',
brightYellow: '#ffff00',
brightBlue: '#0000ff',
brightMagenta: '#ff00ff',
brightCyan: '#00ffff',
brightWhite: '#ffffff',
},
});
assert.equal(vars['--background'], '0 0% 0%');
assert.equal(vars['--foreground'], '0 0% 100%');
assert.notEqual(vars['--accent'], vars['--accent-foreground']);
assert.equal(vars['--accent-foreground'], vars['--foreground']);
});
test('every built-in terminal theme keeps side panel text readable', () => {
const pairs = [
['--foreground', '--background'],
['--muted-foreground', '--background'],
['--muted-foreground', '--muted'],
['--secondary-foreground', '--secondary'],
['--primary', '--background'],
['--accent-foreground', '--accent'],
['--primary-foreground', '--primary'],
['--destructive', '--background'],
['--destructive-foreground', '--destructive'],
] as const;
for (const theme of TERMINAL_THEMES) {
const vars = buildTerminalSidePanelCssVars(theme);
for (const [foregroundKey, backgroundKey] of pairs) {
const foregroundLuminance = getHslTokenRelativeLuminance(vars[foregroundKey]);
const backgroundLuminance = getHslTokenRelativeLuminance(vars[backgroundKey]);
assert.notEqual(foregroundLuminance, null, `${theme.id} ${foregroundKey}`);
assert.notEqual(backgroundLuminance, null, `${theme.id} ${backgroundKey}`);
assert.ok(
getContrastRatio(foregroundLuminance as number, backgroundLuminance as number) >= 4.5,
`${theme.id} ${foregroundKey}/${backgroundKey}`,
);
}
}
});
test('Netcatty Light side panel tokens are light so color-scheme can match the composer', () => {
const theme = TERMINAL_THEMES.find((entry) => entry.id === 'netcatty-light');
assert.ok(theme);
assert.equal(theme.type, 'light');
const vars = buildTerminalSidePanelCssVars(theme);
const backgroundLuminance = getHslTokenRelativeLuminance(vars['--background']);
assert.notEqual(backgroundLuminance, null);
assert.ok(
(backgroundLuminance as number) > 0.5,
'Netcatty Light --background must stay light for the AI composer surface',
);
});

View File

@@ -0,0 +1,376 @@
import type { TerminalTheme } from '../../domain/models';
export const TERMINAL_APPEARANCE_VAR_KEYS = [
'--nc-term-bg',
'--nc-term-fg',
'--nc-term-cursor',
'--nc-term-border',
'--nc-term-muted',
'--nc-term-hover',
'--nc-term-active',
'--nc-term-panel-bg',
'--nc-term-panel-fg',
'--nc-term-panel-muted',
'--nc-term-panel-border',
'--nc-term-panel-hover',
'--nc-term-panel-active',
'--nc-term-host-tree-bg',
'--nc-term-host-tree-fg',
'--nc-term-host-tree-muted',
'--nc-term-host-tree-separator',
'--nc-term-host-tree-hover-bg',
'--nc-term-host-tree-active-bg',
'--nc-term-host-tree-drop-bg',
'--nc-term-host-tree-folder-fg',
'--nc-term-tabs-bg',
'--nc-term-tabs-fg',
'--nc-term-tabs-muted',
'--nc-term-tabs-active-bg',
'--nc-term-tabs-accent',
'--nc-term-toolbar-btn',
'--nc-term-toolbar-btn-hover',
'--nc-term-toolbar-btn-active',
] as const;
export type TerminalAppearanceCssVarKey = (typeof TERMINAL_APPEARANCE_VAR_KEYS)[number];
export type TerminalAppearanceCssVars = Record<TerminalAppearanceCssVarKey, string>;
function mix(fg: string, bg: string, fgPercent: number): string {
return `color-mix(in srgb, ${fg} ${fgPercent}%, ${bg} ${100 - fgPercent}%)`;
}
type RgbColor = { red: number; green: number; blue: number };
const parseHexColor = (value: string): RgbColor | null => {
const normalized = value.trim().replace(/^#/, '');
const expanded = normalized.length === 3
? normalized.split('').map((part) => `${part}${part}`).join('')
: normalized;
if (!/^[0-9a-f]{6}$/i.test(expanded)) return null;
return {
red: Number.parseInt(expanded.slice(0, 2), 16),
green: Number.parseInt(expanded.slice(2, 4), 16),
blue: Number.parseInt(expanded.slice(4, 6), 16),
};
};
const mixRgb = (foreground: RgbColor, background: RgbColor, foregroundRatio: number): RgbColor => ({
red: foreground.red * foregroundRatio + background.red * (1 - foregroundRatio),
green: foreground.green * foregroundRatio + background.green * (1 - foregroundRatio),
blue: foreground.blue * foregroundRatio + background.blue * (1 - foregroundRatio),
});
const rgbToHslToken = ({ red, green, blue }: RgbColor): string => {
const r = red / 255;
const g = green / 255;
const b = blue / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const delta = max - min;
const lightness = (max + min) / 2;
let hue = 0;
let saturation = 0;
if (delta > 0) {
saturation = delta / (1 - Math.abs(2 * lightness - 1));
if (max === r) hue = 60 * (((g - b) / delta) % 6);
else if (max === g) hue = 60 * (((b - r) / delta) + 2);
else hue = 60 * (((r - g) / delta) + 4);
}
if (hue < 0) hue += 360;
const format = (value: number) => Number(value.toFixed(2));
return `${format(hue)} ${format(saturation * 100)}% ${format(lightness * 100)}%`;
};
const relativeLuminance = ({ red, green, blue }: RgbColor): number => {
const linearize = (channel: number) => {
const normalized = channel / 255;
return normalized <= 0.03928
? normalized / 12.92
: ((normalized + 0.055) / 1.055) ** 2.4;
};
return 0.2126 * linearize(red) + 0.7152 * linearize(green) + 0.0722 * linearize(blue);
};
const contrastRatio = (first: RgbColor, second: RgbColor): number => {
const firstLuminance = relativeLuminance(first);
const secondLuminance = relativeLuminance(second);
const lighter = Math.max(firstLuminance, secondLuminance);
const darker = Math.min(firstLuminance, secondLuminance);
return (lighter + 0.05) / (darker + 0.05);
};
const BLACK_RGB: RgbColor = { red: 0, green: 0, blue: 0 };
const WHITE_RGB: RgbColor = { red: 255, green: 255, blue: 255 };
const SAFE_TEXT_CONTRAST = 4.55;
const readableForegroundRgb = (background: RgbColor, preferred?: RgbColor): RgbColor => {
if (preferred && contrastRatio(preferred, background) >= SAFE_TEXT_CONTRAST) return preferred;
return contrastRatio(WHITE_RGB, background) >= contrastRatio(BLACK_RGB, background)
? WHITE_RGB
: BLACK_RGB;
};
const minimumContrastRatio = (foreground: RgbColor, backgrounds: RgbColor[]): number => (
Math.min(...backgrounds.map((background) => contrastRatio(foreground, background)))
);
const ensureReadableRgb = (
preferred: RgbColor,
backgrounds: RgbColor[],
fallbackCandidates: RgbColor[] = [BLACK_RGB, WHITE_RGB],
): RgbColor => {
if (minimumContrastRatio(preferred, backgrounds) >= SAFE_TEXT_CONTRAST) return preferred;
const fallback = fallbackCandidates.reduce((best, candidate) => (
minimumContrastRatio(candidate, backgrounds) > minimumContrastRatio(best, backgrounds)
? candidate
: best
));
for (let step = 1; step <= 100; step += 1) {
const candidate = mixRgb(fallback, preferred, step / 100);
if (minimumContrastRatio(candidate, backgrounds) >= SAFE_TEXT_CONTRAST) return candidate;
}
return fallback;
};
const keepSurfaceReadableBy = (
preferredSurface: RgbColor,
background: RgbColor,
foreground: RgbColor,
): RgbColor => {
if (contrastRatio(foreground, preferredSurface) >= SAFE_TEXT_CONTRAST) return preferredSurface;
for (let step = 1; step <= 100; step += 1) {
const candidate = mixRgb(background, preferredSurface, step / 100);
if (contrastRatio(foreground, candidate) >= SAFE_TEXT_CONTRAST) return candidate;
}
return background;
};
export type TerminalSidePanelCssVars = Record<
| '--background'
| '--foreground'
| '--card'
| '--card-foreground'
| '--popover'
| '--popover-foreground'
| '--primary'
| '--primary-foreground'
| '--secondary'
| '--secondary-foreground'
| '--muted'
| '--muted-foreground'
| '--accent'
| '--accent-foreground'
| '--destructive'
| '--destructive-foreground'
| '--border'
| '--input'
| '--ring',
string
>;
export function buildTerminalSidePanelCssVars(theme: TerminalTheme): TerminalSidePanelCssVars {
const backgroundRgb = parseHexColor(theme.colors.background) ?? BLACK_RGB;
const preferredForegroundRgb = parseHexColor(theme.colors.foreground) ?? WHITE_RGB;
const foregroundRgb = readableForegroundRgb(backgroundRgb, preferredForegroundRgb);
const preferredCursorRgb = parseHexColor(theme.colors.cursor) ?? foregroundRgb;
const preferredRedRgb = parseHexColor(theme.colors.red) ?? { red: 220, green: 38, blue: 38 };
const cursorRgb = ensureReadableRgb(preferredCursorRgb, [backgroundRgb], [foregroundRgb, BLACK_RGB, WHITE_RGB]);
const redRgb = ensureReadableRgb(preferredRedRgb, [backgroundRgb], [foregroundRgb, BLACK_RGB, WHITE_RGB]);
const secondaryRgb = keepSurfaceReadableBy(
mixRgb(foregroundRgb, backgroundRgb, 0.12),
backgroundRgb,
foregroundRgb,
);
const mutedRgb = keepSurfaceReadableBy(
mixRgb(foregroundRgb, backgroundRgb, 0.08),
backgroundRgb,
foregroundRgb,
);
const preferredMutedForegroundRgb = mixRgb(foregroundRgb, backgroundRgb, 0.58);
const mutedForegroundRgb = ensureReadableRgb(
preferredMutedForegroundRgb,
[backgroundRgb, mutedRgb],
[foregroundRgb, BLACK_RGB, WHITE_RGB],
);
const secondaryForegroundRgb = ensureReadableRgb(
foregroundRgb,
[secondaryRgb],
[BLACK_RGB, WHITE_RGB],
);
const accentRgb = mixRgb(foregroundRgb, backgroundRgb, 0.16);
const borderRgb = mixRgb(foregroundRgb, backgroundRgb, 0.12);
const background = rgbToHslToken(backgroundRgb);
const foreground = rgbToHslToken(foregroundRgb);
const primary = rgbToHslToken(cursorRgb);
const secondary = rgbToHslToken(secondaryRgb);
const muted = rgbToHslToken(mutedRgb);
const mutedForeground = rgbToHslToken(mutedForegroundRgb);
const accent = rgbToHslToken(accentRgb);
const accentForeground = rgbToHslToken(readableForegroundRgb(accentRgb, foregroundRgb));
const border = rgbToHslToken(borderRgb);
const destructive = rgbToHslToken(redRgb);
return {
'--background': background,
'--foreground': foreground,
'--card': background,
'--card-foreground': foreground,
'--popover': background,
'--popover-foreground': foreground,
'--primary': primary,
'--primary-foreground': rgbToHslToken(readableForegroundRgb(cursorRgb)),
'--secondary': secondary,
'--secondary-foreground': rgbToHslToken(secondaryForegroundRgb),
'--muted': muted,
'--muted-foreground': mutedForeground,
'--accent': accent,
'--accent-foreground': accentForeground,
'--destructive': destructive,
'--destructive-foreground': rgbToHslToken(readableForegroundRgb(redRgb)),
'--border': border,
'--input': border,
'--ring': primary,
};
}
export function buildTerminalAppearanceCssVars(theme: TerminalTheme): TerminalAppearanceCssVars {
const bg = theme.colors.background;
const fg = theme.colors.foreground;
const cursor = theme.colors.cursor;
const muted = mix(fg, bg, 58);
const hover = mix(fg, bg, 12);
const active = mix(fg, bg, 16);
const border = mix(fg, bg, 12);
const panelMuted = mix(fg, bg, 58);
const panelHover = mix(fg, bg, 12);
const panelActive = mix(fg, bg, 16);
const panelBorder = mix(fg, bg, 12);
const hostMuted = mix(fg, bg, 55);
const hostSeparator = mix(fg, bg, 10);
const hostHover = mix(fg, bg, 8);
const hostActive = mix(fg, bg, 14);
const hostDrop = mix(fg, bg, 20);
const hostFolder = mix(fg, bg, 75);
const toolbarBtn = mix(bg, fg, 12);
const toolbarBtnHover = mix(bg, fg, 22);
const toolbarBtnActive = mix(cursor, bg, 22);
return {
'--nc-term-bg': bg,
'--nc-term-fg': fg,
'--nc-term-cursor': cursor,
'--nc-term-border': border,
'--nc-term-muted': muted,
'--nc-term-hover': hover,
'--nc-term-active': active,
'--nc-term-panel-bg': bg,
'--nc-term-panel-fg': fg,
'--nc-term-panel-muted': panelMuted,
'--nc-term-panel-border': panelBorder,
'--nc-term-panel-hover': panelHover,
'--nc-term-panel-active': panelActive,
'--nc-term-host-tree-bg': bg,
'--nc-term-host-tree-fg': fg,
'--nc-term-host-tree-muted': hostMuted,
'--nc-term-host-tree-separator': hostSeparator,
'--nc-term-host-tree-hover-bg': hostHover,
'--nc-term-host-tree-active-bg': hostActive,
'--nc-term-host-tree-drop-bg': hostDrop,
'--nc-term-host-tree-folder-fg': hostFolder,
'--nc-term-tabs-bg': hover,
'--nc-term-tabs-fg': fg,
'--nc-term-tabs-muted': muted,
'--nc-term-tabs-active-bg': bg,
'--nc-term-tabs-accent': cursor,
'--nc-term-toolbar-btn': toolbarBtn,
'--nc-term-toolbar-btn-hover': toolbarBtnHover,
'--nc-term-toolbar-btn-active': toolbarBtnActive,
};
}
export type HostTreeThemeColors = {
termBg: string;
termFg: string;
mutedFg: string;
separator: string;
rowHoverBg: string;
rowActiveBg: string;
rowDropBg: string;
folderFg: string;
};
export type SidePanelChromeTheme = {
termBg: string;
termFg: string;
mutedFg: string;
separator: string;
accent: string;
};
export function buildSidePanelChromeThemeFromTerminalTheme(theme: TerminalTheme): SidePanelChromeTheme {
const bg = theme.colors.background;
const fg = theme.colors.foreground;
return {
termBg: bg,
termFg: fg,
mutedFg: mix(fg, bg, 58),
separator: mix(fg, bg, 12),
accent: theme.colors.cursor,
};
}
export function buildHostTreeThemeFromTerminalTheme(theme: TerminalTheme): HostTreeThemeColors {
const bg = theme.colors.background;
const fg = theme.colors.foreground;
return {
termBg: bg,
termFg: fg,
mutedFg: mix(fg, bg, 55),
separator: mix(fg, bg, 10),
rowHoverBg: mix(fg, bg, 8),
rowActiveBg: mix(fg, bg, 14),
rowDropBg: mix(fg, bg, 20),
folderFg: mix(fg, bg, 75),
};
}
export const terminalAppearancePanelStyle = {
backgroundColor: 'var(--nc-term-panel-bg, var(--background))',
color: 'var(--nc-term-panel-fg, var(--foreground))',
borderColor: 'var(--nc-term-panel-border, var(--border))',
} as const;
export const terminalAppearanceSidePanelStyle = {
['--terminal-sidepanel-bg' as const]: 'var(--nc-term-panel-bg, var(--background))',
['--terminal-sidepanel-fg' as const]: 'var(--nc-term-panel-fg, var(--foreground))',
['--terminal-sidepanel-accent' as const]: 'var(--nc-term-cursor, var(--accent))',
['--terminal-sidepanel-muted' as const]: 'var(--nc-term-panel-muted, var(--muted-foreground))',
['--terminal-sidepanel-border' as const]: 'var(--nc-term-panel-border, var(--border))',
backgroundColor: 'var(--nc-term-panel-bg, var(--background))',
color: 'var(--nc-term-panel-fg, var(--foreground))',
borderColor: 'var(--nc-term-panel-border, var(--border))',
} as const;
export const terminalAppearanceThemePanelVars = {
['--terminal-panel-bg' as const]: 'var(--nc-term-panel-bg, var(--background))',
['--terminal-panel-fg' as const]: 'var(--nc-term-panel-fg, var(--foreground))',
['--terminal-panel-muted' as const]: 'var(--nc-term-panel-muted, var(--muted-foreground))',
['--terminal-panel-border' as const]: 'var(--nc-term-panel-border, var(--border))',
['--terminal-panel-hover' as const]: 'var(--nc-term-panel-hover, var(--accent))',
['--terminal-panel-active' as const]: 'var(--nc-term-panel-active, var(--accent))',
} as const;
export const terminalAppearanceHostTreeTheme = {
termBg: 'var(--nc-term-host-tree-bg, var(--nc-term-bg, var(--background)))',
termFg: 'var(--nc-term-host-tree-fg, var(--nc-term-fg, var(--foreground)))',
mutedFg: 'var(--nc-term-host-tree-muted, var(--nc-term-muted, var(--muted-foreground)))',
separator: 'var(--nc-term-host-tree-separator, var(--nc-term-border, var(--border)))',
rowHoverBg: 'var(--nc-term-host-tree-hover-bg, var(--nc-term-hover, transparent))',
rowActiveBg: 'var(--nc-term-host-tree-active-bg, var(--nc-term-active, transparent))',
rowDropBg: 'var(--nc-term-host-tree-drop-bg, var(--nc-term-active, transparent))',
folderFg: 'var(--nc-term-host-tree-folder-fg, var(--nc-term-fg, var(--foreground)))',
} as const;

View File

@@ -0,0 +1,160 @@
import type { TerminalTheme } from '../../domain/models';
import {
buildTerminalAppearanceCssVars,
TERMINAL_APPEARANCE_VAR_KEYS,
} from './terminalAppearanceTokens';
export const TERMINAL_APPEARANCE_ROOT_ATTR = 'data-terminal-appearance-root';
export const TERMINAL_APPEARANCE_ROOT_SELECTOR = `[${TERMINAL_APPEARANCE_ROOT_ATTR}]`;
const setStylePropertyIfChanged = (element: HTMLElement, property: string, value: string) => {
if (element.style.getPropertyValue(property) === value) return;
element.style.setProperty(property, value);
};
const removeStylePropertyIfSet = (element: HTMLElement, property: string) => {
if (!element.style.getPropertyValue(property)) return;
element.style.removeProperty(property);
};
export function findTerminalAppearanceRoot(root?: ParentNode): HTMLElement | null {
if (typeof document === 'undefined') return null;
const scope = root ?? document;
return scope.querySelector<HTMLElement>(TERMINAL_APPEARANCE_ROOT_SELECTOR);
}
const TERMINAL_CHROME_SURFACE_SELECTOR = [
'[data-section="app-host-tree-layer"]',
'[data-section="terminal-host-tree-sidebar"]',
'[data-section="terminal-side-panel"]',
].join(', ');
const TERMINAL_LAYER_CHROME_SURFACE_SELECTOR = [
'[data-section="terminal-host-tree-sidebar"]',
'[data-section="terminal-side-panel"]',
].join(', ');
function collectTerminalChromeSurfaceElements(): HTMLElement[] {
if (typeof document === 'undefined') return [];
return Array.from(document.querySelectorAll<HTMLElement>(TERMINAL_CHROME_SURFACE_SELECTOR));
}
function collectTerminalLayerChromeSurfaceElements(): HTMLElement[] {
if (typeof document === 'undefined') return [];
return Array.from(document.querySelectorAll<HTMLElement>(TERMINAL_LAYER_CHROME_SURFACE_SELECTOR));
}
function applyTerminalAppearanceVarsToTargets(targets: HTMLElement[], theme: TerminalTheme): void {
if (targets.length === 0) return;
const vars = buildTerminalAppearanceCssVars(theme);
for (const target of targets) {
for (const key of TERMINAL_APPEARANCE_VAR_KEYS) {
setStylePropertyIfChanged(target, key, vars[key]);
}
target.dataset.terminalAppearanceId = theme.id;
}
}
export function injectTerminalChromeSurfaceVars(theme: TerminalTheme): void {
applyTerminalAppearanceVarsToTargets(collectTerminalChromeSurfaceElements(), theme);
}
export function injectTerminalLayerChromeSurfaceVars(theme: TerminalTheme): void {
applyTerminalAppearanceVarsToTargets(collectTerminalLayerChromeSurfaceElements(), theme);
}
function clearTerminalAppearanceVarsOnTargets(targets: HTMLElement[]): void {
for (const target of targets) {
for (const key of TERMINAL_APPEARANCE_VAR_KEYS) {
removeStylePropertyIfSet(target, key);
}
delete target.dataset.terminalAppearanceId;
}
}
export function clearTerminalChromeSurfaceVars(): void {
if (typeof document === 'undefined') return;
clearTerminalAppearanceVarsOnTargets(collectTerminalChromeSurfaceElements());
}
export function clearTerminalLayerChromeSurfaceVars(): void {
if (typeof document === 'undefined') return;
clearTerminalAppearanceVarsOnTargets(collectTerminalLayerChromeSurfaceElements());
}
export type InjectTerminalAppearanceVarsOptions = {
root?: HTMLElement | null;
includeChromeSurfaces?: boolean;
};
export function injectTerminalAppearanceVars(
theme: TerminalTheme,
options?: InjectTerminalAppearanceVarsOptions | HTMLElement | null,
): void {
if (typeof document === 'undefined') return;
const normalizedOptions: InjectTerminalAppearanceVarsOptions = options && typeof options === 'object' && 'includeChromeSurfaces' in options
? options
: { root: options instanceof HTMLElement || options === null ? options : undefined };
const includeChromeSurfaces = normalizedOptions.includeChromeSurfaces ?? true;
const targets: HTMLElement[] = [];
const appearanceRoot = normalizedOptions.root ?? findTerminalAppearanceRoot();
if (appearanceRoot) targets.push(appearanceRoot);
const tabsRoot = document.querySelector<HTMLElement>('[data-top-tabs-root]');
if (tabsRoot && tabsRoot !== appearanceRoot) targets.push(tabsRoot);
if (includeChromeSurfaces) {
for (const chromeSurface of collectTerminalChromeSurfaceElements()) {
if (!targets.includes(chromeSurface)) targets.push(chromeSurface);
}
}
applyTerminalAppearanceVarsToTargets(targets, theme);
}
export function clearTerminalAppearanceVars(root?: HTMLElement | null): void {
if (typeof document === 'undefined') return;
const targets: HTMLElement[] = [];
const appearanceRoot = root ?? findTerminalAppearanceRoot();
if (appearanceRoot) targets.push(appearanceRoot);
const tabsRoot = document.querySelector<HTMLElement>('[data-top-tabs-root]');
if (tabsRoot && tabsRoot !== appearanceRoot) targets.push(tabsRoot);
for (const hostTreeRoot of document.querySelectorAll<HTMLElement>(
'[data-section="app-host-tree-layer"], [data-section="terminal-host-tree-sidebar"], [data-section="terminal-side-panel"]',
)) {
if (!targets.includes(hostTreeRoot)) targets.push(hostTreeRoot);
}
clearTerminalAppearanceVarsOnTargets(targets);
}
export function injectTerminalPaneAppearanceVars(sessionId: string, theme: TerminalTheme): void {
if (typeof document === 'undefined') return;
const pane = document.querySelector<HTMLElement>(`[data-session-id="${sessionId}"]`);
if (!pane) return;
const vars = buildTerminalAppearanceCssVars(theme);
setStylePropertyIfChanged(pane, '--nc-term-bg', vars['--nc-term-bg']);
setStylePropertyIfChanged(pane, '--nc-term-fg', vars['--nc-term-fg']);
setStylePropertyIfChanged(pane, '--nc-term-border', vars['--nc-term-border']);
setStylePropertyIfChanged(pane, '--nc-term-toolbar-btn', vars['--nc-term-toolbar-btn']);
setStylePropertyIfChanged(pane, '--nc-term-toolbar-btn-hover', vars['--nc-term-toolbar-btn-hover']);
setStylePropertyIfChanged(pane, '--nc-term-toolbar-btn-active', vars['--nc-term-toolbar-btn-active']);
// Legacy aliases consumed by Terminal.tsx toolbar styles.
setStylePropertyIfChanged(pane, '--terminal-preview-bg', vars['--nc-term-bg']);
setStylePropertyIfChanged(pane, '--terminal-preview-fg', vars['--nc-term-fg']);
setStylePropertyIfChanged(pane, '--terminal-preview-border', vars['--nc-term-border']);
setStylePropertyIfChanged(pane, '--terminal-preview-toolbar-btn', vars['--nc-term-toolbar-btn']);
setStylePropertyIfChanged(pane, '--terminal-preview-toolbar-btn-hover', vars['--nc-term-toolbar-btn-hover']);
setStylePropertyIfChanged(pane, '--terminal-preview-toolbar-btn-active', vars['--nc-term-toolbar-btn-active']);
}
export function clearTerminalPaneAppearanceVars(sessionId: string): void {
if (typeof document === 'undefined') return;
const pane = document.querySelector<HTMLElement>(`[data-session-id="${sessionId}"]`);
if (!pane) return;
const keys = [
'--nc-term-bg', '--nc-term-fg', '--nc-term-border',
'--nc-term-toolbar-btn', '--nc-term-toolbar-btn-hover', '--nc-term-toolbar-btn-active',
'--terminal-preview-bg', '--terminal-preview-fg', '--terminal-preview-border',
'--terminal-preview-toolbar-btn', '--terminal-preview-toolbar-btn-hover', '--terminal-preview-toolbar-btn-active',
];
for (const key of keys) {
removeStylePropertyIfSet(pane, key);
}
}