From a3ef3a59d5b5f9736b7bf859bf2404b9fa6e42db Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 21 Mar 2026 11:34:11 -0700 Subject: [PATCH] xterm --- package.json | 7 +- pnpm-lock.yaml | 71 +- src/renderer/src/assets/main.css | 34 +- src/renderer/src/components/TerminalPane.tsx | 497 ++++++----- .../src/components/TerminalSearch.tsx | 137 +++ .../settings/TerminalThemePreview.tsx | 27 +- src/renderer/src/lib/pane-manager.ts | 615 ++++++++++++++ src/renderer/src/lib/terminal-theme.ts | 51 +- src/renderer/src/lib/terminal-themes-data.ts | 777 ++++++++++++++++++ 9 files changed, 1943 insertions(+), 273 deletions(-) create mode 100644 src/renderer/src/components/TerminalSearch.tsx create mode 100644 src/renderer/src/lib/pane-manager.ts create mode 100644 src/renderer/src/lib/terminal-themes-data.ts diff --git a/package.json b/package.json index b145c7e061b..b1b93b2ac5d 100644 --- a/package.json +++ b/package.json @@ -32,13 +32,18 @@ "@electron-toolkit/preload": "^3.0.2", "@electron-toolkit/utils": "^4.0.0", "@tanstack/react-virtual": "^3.13.23", + "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-search": "^0.16.0", + "@xterm/addon-unicode11": "^0.9.0", + "@xterm/addon-web-links": "^0.12.0", + "@xterm/addon-webgl": "^0.19.0", + "@xterm/xterm": "^6.0.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "electron-updater": "^6.3.9", "lucide-react": "^0.577.0", "node-pty": "^1.1.0", "radix-ui": "^1.4.3", - "restty": "github:nwparker/restty#orca/desktop-word-line-selection", "shadcn": "^4.0.8", "simple-git": "^3.33.0", "tailwind-merge": "^3.5.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e27ec320ad7..6a17d1a1d80 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,24 @@ importers: '@tanstack/react-virtual': specifier: ^3.13.23 version: 3.13.23(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@xterm/addon-fit': + specifier: ^0.11.0 + version: 0.11.0 + '@xterm/addon-search': + specifier: ^0.16.0 + version: 0.16.0 + '@xterm/addon-unicode11': + specifier: ^0.9.0 + version: 0.9.0 + '@xterm/addon-web-links': + specifier: ^0.12.0 + version: 0.12.0 + '@xterm/addon-webgl': + specifier: ^0.19.0 + version: 0.19.0 + '@xterm/xterm': + specifier: ^6.0.0 + version: 6.0.0 class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -44,9 +62,6 @@ importers: radix-ui: specifier: ^1.4.3 version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - restty: - specifier: github:nwparker/restty#orca/desktop-word-line-selection - version: https://codeload.github.com/nwparker/restty/tar.gz/fb8a604be99f570fb437b414790ef36fe85c8f17(typescript@5.9.3) shadcn: specifier: ^4.0.8 version: 4.0.8(@types/node@25.5.0)(typescript@5.9.3) @@ -2064,6 +2079,24 @@ packages: resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==} engines: {node: '>=10.0.0'} + '@xterm/addon-fit@0.11.0': + resolution: {integrity: sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==} + + '@xterm/addon-search@0.16.0': + resolution: {integrity: sha512-9OeuBFu0/uZJPu+9AHKY6g/w0Czyb/Ut0A5t79I4ULoU4IfU5BEpPFVGQxP4zTTMdfZEYkVIRYbHBX1xWwjeSA==} + + '@xterm/addon-unicode11@0.9.0': + resolution: {integrity: sha512-FxDnYcyuXhNl+XSqGZL/t0U9eiNb/q3EWT5rYkQT/zuig8Gz/VagnQANKHdDWFM2lTMk9ly0EFQxxxtZUoRetw==} + + '@xterm/addon-web-links@0.12.0': + resolution: {integrity: sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==} + + '@xterm/addon-webgl@0.19.0': + resolution: {integrity: sha512-b3fMOsyLVuCeNJWxolACEUED0vm7qC0cy4wRvf3oURSzDTYVQiGPhTnhWZwIHdvC48Y+oLhvYXnY4XDXPoJo6A==} + + '@xterm/xterm@6.0.0': + resolution: {integrity: sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==} + abbrev@3.0.1: resolution: {integrity: sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==} engines: {node: ^18.17.0 || >=20.5.0} @@ -3798,11 +3831,6 @@ packages: resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} engines: {node: '>=18'} - restty@https://codeload.github.com/nwparker/restty/tar.gz/fb8a604be99f570fb437b414790ef36fe85c8f17: - resolution: {tarball: https://codeload.github.com/nwparker/restty/tar.gz/fb8a604be99f570fb437b414790ef36fe85c8f17} - version: 0.1.34 - engines: {bun: '>=1.2.0'} - retry@0.12.0: resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} engines: {node: '>= 4'} @@ -4071,11 +4099,6 @@ packages: resolution: {integrity: sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==} engines: {node: '>=6.0.0'} - text-shaper@0.1.18: - resolution: {integrity: sha512-ZfuqKPupphWz6OS3IhlEc5tPmJFmOJVqhp5Q4uQc32zrHwfu6uY/cIsv0i/tirUVSfVDohmVpuGr5lYOVyK13w==} - peerDependencies: - typescript: ^5 - tiny-async-pool@1.3.0: resolution: {integrity: sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA==} @@ -6224,6 +6247,18 @@ snapshots: '@xmldom/xmldom@0.8.11': {} + '@xterm/addon-fit@0.11.0': {} + + '@xterm/addon-search@0.16.0': {} + + '@xterm/addon-unicode11@0.9.0': {} + + '@xterm/addon-web-links@0.12.0': {} + + '@xterm/addon-webgl@0.19.0': {} + + '@xterm/xterm@6.0.0': {} + abbrev@3.0.1: {} accepts@2.0.0: @@ -8147,12 +8182,6 @@ snapshots: onetime: 7.0.0 signal-exit: 4.1.0 - restty@https://codeload.github.com/nwparker/restty/tar.gz/fb8a604be99f570fb437b414790ef36fe85c8f17(typescript@5.9.3): - dependencies: - text-shaper: 0.1.18(typescript@5.9.3) - transitivePeerDependencies: - - typescript - retry@0.12.0: {} rettime@0.10.1: {} @@ -8511,10 +8540,6 @@ snapshots: mkdirp: 0.5.6 rimraf: 2.6.3 - text-shaper@0.1.18(typescript@5.9.3): - dependencies: - typescript: 5.9.3 - tiny-async-pool@1.3.0: dependencies: semver: 5.7.2 diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index f337c6b7f2f..4a508d42e48 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -1,5 +1,6 @@ @import 'tailwindcss'; @import 'tw-animate-css'; +@import '@xterm/xterm/css/xterm.css'; @custom-variant dark (&:is(.dark *)); @@ -397,21 +398,38 @@ position: relative; } -/* Ensure Restty pane root fills its absolutely-positioned container */ -.restty-pane-root { +/* Ensure pane manager root fills its absolutely-positioned container */ +.pane-manager-root { width: 100% !important; height: 100% !important; } -.restty-pane-root .pane-divider.is-vertical, -.restty-pane-root .pane-divider.is-horizontal { +/* Reset the global border/outline base rule inside terminal panes */ +.pane-manager-root *, +.pane-manager-root *::before, +.pane-manager-root *::after { + border-color: transparent; + outline: none; +} + +.pane-manager-root .xterm { + height: 100%; + width: 100%; +} + +.pane-manager-root .xterm-viewport { + overflow-y: auto; +} + +.pane-divider.is-vertical, +.pane-divider.is-horizontal { background: var(--orca-terminal-divider-color, transparent) !important; } -.restty-pane-root .pane-divider.is-vertical:hover, -.restty-pane-root .pane-divider.is-vertical.is-dragging, -.restty-pane-root .pane-divider.is-horizontal:hover, -.restty-pane-root .pane-divider.is-horizontal.is-dragging { +.pane-divider.is-vertical:hover, +.pane-divider.is-vertical.is-dragging, +.pane-divider.is-horizontal:hover, +.pane-divider.is-horizontal.is-dragging { background: var( --orca-terminal-divider-color-strong, var(--orca-terminal-divider-color, transparent) diff --git a/src/renderer/src/components/TerminalPane.tsx b/src/renderer/src/components/TerminalPane.tsx index d58e25f9a3a..affcb038d14 100644 --- a/src/renderer/src/components/TerminalPane.tsx +++ b/src/renderer/src/components/TerminalPane.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from 'react' -import { Restty, getBuiltinTheme } from 'restty' import type { CSSProperties } from 'react' +import type { ITheme } from '@xterm/xterm' import { Clipboard, Copy, @@ -28,13 +28,14 @@ import type { import { useAppStore } from '../store' import { DEFAULT_TERMINAL_DIVIDER_DARK, - buildTerminalFontMatchers, - colorToCss, getCursorStyleSequence, + getBuiltinTheme, normalizeColor, resolvePaneStyleOptions, resolveEffectiveTerminalAppearance } from '@/lib/terminal-theme' +import { PaneManager, type ManagedPane } from '@/lib/pane-manager' +import TerminalSearch from '@/components/TerminalSearch' type PtyTransport = { connect: (options: { @@ -259,25 +260,15 @@ function paneLeafId(paneId: number): string { return `pane:${paneId}` } -function buildTerminalFontSources(fontFamily: string) { - return [ - { - type: 'local' as const, - label: fontFamily || 'Preferred terminal font', - matchers: buildTerminalFontMatchers(fontFamily), - required: true - }, - { - type: 'local' as const, - label: 'Menlo', - matchers: ['menlo', 'menlo regular'] - }, - { - type: 'local' as const, - label: 'Monospace fallback', - matchers: ['dejavu sans mono', 'liberation mono', 'ubuntu mono', 'monospace'] - } - ] +function buildFontFamily(fontFamily: string): string { + const trimmed = fontFamily.trim() + const parts = trimmed ? [`"${trimmed}"`] : [] + // Always include fallbacks + if (!parts.some((p) => p.toLowerCase().includes('sf mono'))) { + parts.push('"SF Mono"') + } + parts.push('Menlo', 'monospace') + return parts.join(', ') } function getLayoutChildNodes(split: HTMLElement): HTMLElement[] { @@ -327,13 +318,13 @@ function serializeTerminalLayout( } function replayTerminalLayout( - restty: Restty, + manager: PaneManager, snapshot: TerminalLayoutSnapshot | null | undefined, focusInitialPane: boolean ): Map { const paneByLeafId = new Map() - const initialPane = restty.createInitialPane({ focus: focusInitialPane }) + const initialPane = manager.createInitialPane({ focus: focusInitialPane }) if (!snapshot?.root) { paneByLeafId.set(paneLeafId(initialPane.id), initialPane.id) return paneByLeafId @@ -345,7 +336,7 @@ function replayTerminalLayout( return } - const createdPane = restty.splitPane(paneId, node.direction as TerminalPaneSplitDirection) + const createdPane = manager.splitPane(paneId, node.direction as TerminalPaneSplitDirection) if (!createdPane) { collectLeafIds(node, paneByLeafId, paneId) return @@ -388,7 +379,7 @@ export default function TerminalPane({ onPtyExit }: TerminalPaneProps): React.JSX.Element { const containerRef = useRef(null) - const resttyRef = useRef(null) + const managerRef = useRef(null) const contextPaneIdRef = useRef(null) const wasActiveRef = useRef(false) const paneFontSizesRef = useRef>(new Map()) @@ -396,9 +387,12 @@ export default function TerminalPane({ const expandedStyleSnapshotRef = useRef>( new Map() ) + // Track transports per pane for PTY communication + const paneTransportsRef = useRef>(new Map()) const [terminalMenuOpen, setTerminalMenuOpen] = useState(false) const [terminalMenuPoint, setTerminalMenuPoint] = useState({ x: 0, y: 0 }) const [expandedPaneId, setExpandedPaneId] = useState(null) + const [searchOpen, setSearchOpen] = useState(false) const setTabPaneExpanded = useAppStore((s) => s.setTabPaneExpanded) const setTabCanExpandPane = useAppStore((s) => s.setTabCanExpandPane) const savedLayout = useAppStore((s) => s.terminalLayoutsByTabId[tabId] ?? EMPTY_LAYOUT) @@ -406,10 +400,10 @@ export default function TerminalPane({ const initialLayoutRef = useRef(savedLayout) const persistLayoutSnapshot = (): void => { - const restty = resttyRef.current + const manager = managerRef.current const container = containerRef.current - if (!restty || !container) return - const activePaneId = restty.getActivePane()?.id ?? restty.getPanes()[0]?.id ?? null + if (!manager || !container) return + const activePaneId = manager.getActivePane()?.id ?? manager.getPanes()[0]?.id ?? null setTabLayout(tabId, serializeTerminalLayout(container, activePaneId, expandedPaneIdRef.current)) } @@ -438,11 +432,11 @@ export default function TerminalPane({ } const applyExpandedLayout = (paneId: number): boolean => { - const restty = resttyRef.current + const manager = managerRef.current const root = containerRef.current - if (!restty || !root) return false + if (!manager || !root) return false - const panes = restty.getPanes() + const panes = manager.getPanes() if (panes.length <= 1) return false const targetPane = panes.find((pane) => pane.id === paneId) if (!targetPane) return false @@ -470,15 +464,19 @@ export default function TerminalPane({ const refreshPaneSizes = (focusActive: boolean): void => { requestAnimationFrame(() => { - const restty = resttyRef.current - if (!restty) return - const panes = restty.getPanes() + const manager = managerRef.current + if (!manager) return + const panes = manager.getPanes() for (const p of panes) { - p.app.updateSize(true) + try { + p.fitAddon.fit() + } catch { + /* container may not have dimensions */ + } } if (focusActive) { - const active = restty.getActivePane() ?? panes[0] - active?.canvas.focus({ preventScroll: true }) + const active = manager.getActivePane() ?? panes[0] + active?.terminal.focus() } }) } @@ -490,9 +488,9 @@ export default function TerminalPane({ return } - const restty = resttyRef.current - if (!restty) return - const panes = restty.getPanes() + const manager = managerRef.current + if (!manager) return + const panes = manager.getPanes() if (panes.length <= 1 || !panes.some((pane) => pane.id === paneId)) { setExpandedPane(null) restoreExpandedLayout() @@ -502,14 +500,14 @@ export default function TerminalPane({ } const syncCanExpandState = (): void => { - const paneCount = resttyRef.current?.getPanes().length ?? 1 + const paneCount = managerRef.current?.getPanes().length ?? 1 setTabCanExpandPane(tabId, paneCount > 1) } const toggleExpandPane = (paneId: number): void => { - const restty = resttyRef.current - if (!restty) return - const panes = restty.getPanes() + const manager = managerRef.current + if (!manager) return + const panes = manager.getPanes() if (panes.length <= 1) return const isAlreadyExpanded = expandedPaneIdRef.current === paneId @@ -528,7 +526,7 @@ export default function TerminalPane({ persistLayoutSnapshot() return } - restty.setActivePane(paneId, { focus: true }) + manager.setActivePane(paneId, { focus: true }) refreshPaneSizes(true) persistLayoutSnapshot() } @@ -552,7 +550,7 @@ export default function TerminalPane({ const settingsRef = useRef(settings) settingsRef.current = settings - // Use a ref so the Restty closure always calls the latest onPtyExit + // Use a ref so the PaneManager closure always calls the latest onPtyExit const onPtyExitRef = useRef(onPtyExit) onPtyExitRef.current = onPtyExit @@ -566,7 +564,7 @@ export default function TerminalPane({ return () => media.removeEventListener('change', handleChange) }, []) - const applyTerminalAppearance = (restty: Restty): void => { + const applyTerminalAppearance = (manager: PaneManager): void => { const currentSettings = settingsRef.current if (!currentSettings) return @@ -576,18 +574,28 @@ export default function TerminalPane({ currentSettings.terminalCursorStyle, currentSettings.terminalCursorBlink ) - const theme = appearance.theme ?? getBuiltinTheme(appearance.themeName) - const paneBackground = colorToCss(theme?.colors.background, '#000000') - for (const pane of restty.getPanes()) { + const theme: ITheme | null = appearance.theme ?? getBuiltinTheme(appearance.themeName) + const paneBackground = theme?.background ?? '#000000' + + for (const pane of manager.getPanes()) { if (theme) { - pane.app.applyTheme(theme, appearance.themeName) + pane.terminal.options.theme = theme } + pane.terminal.options.cursorStyle = currentSettings.terminalCursorStyle + pane.terminal.options.cursorBlink = currentSettings.terminalCursorBlink const paneSize = paneFontSizesRef.current.get(pane.id) - pane.app.setFontSize(paneSize ?? currentSettings.terminalFontSize) - pane.app.sendInput(cursorSequence, 'pty') + pane.terminal.options.fontSize = paneSize ?? currentSettings.terminalFontSize + try { + pane.fitAddon.fit() + } catch { + /* ignore */ + } + // Send cursor style sequence to PTY + const transport = paneTransportsRef.current.get(pane.id) + transport?.sendInput(cursorSequence) } - restty.setPaneStyleOptions({ + manager.setPaneStyleOptions({ splitBackground: paneBackground, paneBackground, inactivePaneOpacity: paneStyles.inactivePaneOpacity, @@ -597,27 +605,16 @@ export default function TerminalPane({ }) } - // Initialize Restty instance once - useEffect(() => { - const container = containerRef.current - if (!container) return - let resizeRaf: number | null = null - - const queueResizeAll = (focusActive: boolean): void => { - if (resizeRaf !== null) cancelAnimationFrame(resizeRaf) - resizeRaf = requestAnimationFrame(() => { - resizeRaf = null - const restty = resttyRef.current - if (!restty) return - const panes = restty.getPanes() - for (const p of panes) { - p.app.updateSize(true) - } - if (focusActive) { - const active = restty.getActivePane() ?? panes[0] - active?.canvas.focus({ preventScroll: true }) - } - }) + // Connect a pane's terminal to a PTY via IPC transport + const connectPanePty = (pane: ManagedPane, manager: PaneManager): void => { + const onExit = (ptyId: string): void => { + const panes = manager.getPanes() + if (panes.length <= 1) { + clearTabPtyId(tabId, ptyId) + onPtyExitRef.current(ptyId) + return + } + manager.closePane(pane.id) } const onTitleChange = (title: string): void => { @@ -627,54 +624,80 @@ export default function TerminalPane({ const onPtySpawn = (ptyId: string): void => updateTabPtyId(tabId, ptyId) const onBell = (): void => markWorktreeUnreadFromBell(worktreeId) + const transport = createIpcPtyTransport(cwd, onExit, onTitleChange, onPtySpawn, onBell) + paneTransportsRef.current.set(pane.id, transport) + + // Wire terminal → PTY + pane.terminal.onData((data) => { + transport.sendInput(data) + }) + + // Wire terminal resize → PTY resize + pane.terminal.onResize(({ cols, rows }) => { + transport.resize(cols, rows) + }) + + // Connect PTY and wire PTY → terminal + const cols = pane.terminal.cols + const rows = pane.terminal.rows + transport.connect({ + url: '', + cols, + rows, + callbacks: { + onData: (data) => { + pane.terminal.write(data) + } + } + }) + } + + // Initialize PaneManager instance once + useEffect(() => { + const container = containerRef.current + if (!container) return + let resizeRaf: number | null = null + + const queueResizeAll = (focusActive: boolean): void => { + if (resizeRaf !== null) cancelAnimationFrame(resizeRaf) + resizeRaf = requestAnimationFrame(() => { + resizeRaf = null + const manager = managerRef.current + if (!manager) return + const panes = manager.getPanes() + for (const p of panes) { + try { + p.fitAddon.fit() + } catch { + /* ignore */ + } + } + if (focusActive) { + const active = manager.getActivePane() ?? panes[0] + active?.terminal.focus() + } + }) + } + let shouldPersistLayout = false - const restty = new Restty({ - root: container, - createInitialPane: false, - autoInit: false, - shortcuts: { enabled: false }, - defaultContextMenu: false, - appOptions: ({ id }) => { - const currentSettings = settingsRef.current - const onExit = (ptyId: string): void => { - // Schedule close via parent - const panes = restty.getPanes() - if (panes.length <= 1) { - clearTabPtyId(tabId, ptyId) - onPtyExitRef.current(ptyId) - return - } - restty.closePane(id) - } - return { - renderer: 'auto', - fontSize: currentSettings?.terminalFontSize ?? 14, - fontSizeMode: 'em', - fontHinting: true, - fontHintTarget: 'normal', - fontThicken: true, - alphaBlending: 'linear-corrected', - maxScrollbackBytes: currentSettings?.terminalScrollbackBytes ?? 10_000_000, - ptyTransport: createIpcPtyTransport( - cwd, - onExit, - onTitleChange, - onPtySpawn, - onBell - ) as never, - fontSources: buildTerminalFontSources(currentSettings?.terminalFontFamily ?? 'SF Mono') - } - }, - onPaneCreated: async (pane) => { - await pane.app.init() - applyTerminalAppearance(restty) - pane.app.updateSize(true) - pane.app.connectPty('') - pane.canvas.focus({ preventScroll: true }) + const manager = new PaneManager(container, { + onPaneCreated: (pane) => { + // Apply appearance before connecting PTY + applyTerminalAppearance(manager) + // Connect PTY + connectPanePty(pane, manager) queueResizeAll(true) }, - onPaneClosed: () => {}, + onPaneClosed: (paneId) => { + // Clean up transport for closed pane + const transport = paneTransportsRef.current.get(paneId) + if (transport) { + transport.destroy?.() + paneTransportsRef.current.delete(paneId) + } + paneFontSizesRef.current.delete(paneId) + }, onActivePaneChange: () => { if (shouldPersistLayout) persistLayoutSnapshot() }, @@ -683,25 +706,41 @@ export default function TerminalPane({ syncCanExpandState() queueResizeAll(false) if (shouldPersistLayout) persistLayoutSnapshot() + }, + terminalOptions: () => { + const currentSettings = settingsRef.current + return { + fontSize: currentSettings?.terminalFontSize ?? 14, + fontFamily: buildFontFamily(currentSettings?.terminalFontFamily ?? 'SF Mono'), + scrollback: Math.max( + 1000, + Math.round((currentSettings?.terminalScrollbackBytes ?? 10_000_000) / 100) + ), + cursorStyle: currentSettings?.terminalCursorStyle ?? 'bar', + cursorBlink: currentSettings?.terminalCursorBlink ?? true + } + }, + onLinkClick: (url) => { + window.api.shell.openExternal(url) } }) - resttyRef.current = restty - const restoredPaneByLeafId = replayTerminalLayout(restty, initialLayoutRef.current, isActive) + managerRef.current = manager + const restoredPaneByLeafId = replayTerminalLayout(manager, initialLayoutRef.current, isActive) const restoredActivePaneId = (initialLayoutRef.current.activeLeafId ? restoredPaneByLeafId.get(initialLayoutRef.current.activeLeafId) : null) ?? - restty.getActivePane()?.id ?? - restty.getPanes()[0]?.id ?? + manager.getActivePane()?.id ?? + manager.getPanes()[0]?.id ?? null if (restoredActivePaneId !== null) { - restty.setActivePane(restoredActivePaneId, { focus: isActive }) + manager.setActivePane(restoredActivePaneId, { focus: isActive }) } const restoredExpandedPaneId = initialLayoutRef.current.expandedLeafId ? (restoredPaneByLeafId.get(initialLayoutRef.current.expandedLeafId) ?? null) : null - if (restoredExpandedPaneId !== null && restty.getPanes().length > 1) { + if (restoredExpandedPaneId !== null && manager.getPanes().length > 1) { setExpandedPane(restoredExpandedPaneId) applyExpandedLayout(restoredExpandedPaneId) } else { @@ -709,15 +748,20 @@ export default function TerminalPane({ } shouldPersistLayout = true syncCanExpandState() - applyTerminalAppearance(restty) + applyTerminalAppearance(manager) queueResizeAll(isActive) persistLayoutSnapshot() return () => { if (resizeRaf !== null) cancelAnimationFrame(resizeRaf) restoreExpandedLayout() - restty.destroy() - resttyRef.current = null + // Destroy all transports + for (const transport of paneTransportsRef.current.values()) { + transport.destroy?.() + } + paneTransportsRef.current.clear() + manager.destroy() + managerRef.current = null setTabPaneExpanded(tabId, false) setTabCanExpandPane(tabId, false) } @@ -725,16 +769,19 @@ export default function TerminalPane({ }, [tabId, cwd]) useEffect(() => { - const restty = resttyRef.current - if (!restty || !settings) return - applyTerminalAppearance(restty) - void Promise.all( - restty - .getPanes() - .map((pane) => - pane.app.setFontSources(buildTerminalFontSources(settings.terminalFontFamily)) - ) - ) + const manager = managerRef.current + if (!manager || !settings) return + applyTerminalAppearance(manager) + // Update font family on all panes + const fontFamily = buildFontFamily(settings.terminalFontFamily) + for (const pane of manager.getPanes()) { + pane.terminal.options.fontFamily = fontFamily + try { + pane.fitAddon.fit() + } catch { + /* ignore */ + } + } }, [settings, systemPrefersDark]) // Per-pane font zoom via Cmd+Plus/Minus/0 @@ -745,9 +792,9 @@ export default function TerminalPane({ const FONT_SIZE_STEP = 1 return window.api.ui.onTerminalZoom((direction) => { - const restty = resttyRef.current - if (!restty) return - const pane = restty.getActivePane() + const manager = managerRef.current + if (!manager) return + const pane = manager.getActivePane() if (!pane) return const globalSize = settingsRef.current?.terminalFontSize ?? 14 @@ -765,25 +812,34 @@ export default function TerminalPane({ paneFontSizesRef.current.set(pane.id, nextSize) } - pane.app.setFontSize(nextSize) + pane.terminal.options.fontSize = nextSize + try { + pane.fitAddon.fit() + } catch { + /* ignore */ + } }) }, [isActive]) // Handle focus and resize when tab becomes active useEffect(() => { - const restty = resttyRef.current - if (!restty) return + const manager = managerRef.current + if (!manager) return if (isActive) { // Ensure size/focus is correct both on initial mount and tab activation. requestAnimationFrame(() => { - const panes = restty.getPanes() + const panes = manager.getPanes() for (const p of panes) { - p.app.updateSize(true) + try { + p.fitAddon.fit() + } catch { + /* ignore */ + } } - const active = restty.getActivePane() ?? panes[0] + const active = manager.getActivePane() ?? panes[0] if (active) { - active.canvas.focus({ preventScroll: true }) + active.terminal.focus() } }) } @@ -794,11 +850,11 @@ export default function TerminalPane({ const onToggleExpand = (event: Event): void => { const detail = (event as CustomEvent<{ tabId?: string }>).detail if (!detail?.tabId || detail.tabId !== tabId) return - const restty = resttyRef.current - if (!restty) return - const panes = restty.getPanes() + const manager = managerRef.current + if (!manager) return + const panes = manager.getPanes() if (panes.length < 2) return - const pane = restty.getActivePane() ?? panes[0] + const pane = manager.getActivePane() ?? panes[0] if (!pane) return toggleExpandPane(pane.id) } @@ -815,11 +871,15 @@ export default function TerminalPane({ if (!container) return const ro = new ResizeObserver(() => { - const restty = resttyRef.current - if (!restty) return - const panes = restty.getPanes() + const manager = managerRef.current + if (!manager) return + const panes = manager.getPanes() for (const p of panes) { - p.app.updateSize(true) + try { + p.fitAddon.fit() + } catch { + /* ignore */ + } } }) ro.observe(container) @@ -835,47 +895,52 @@ export default function TerminalPane({ if (e.repeat) return if (!e.metaKey || e.altKey || e.ctrlKey) return - const restty = resttyRef.current - if (!restty) return + const manager = managerRef.current + if (!manager) return + + // Cmd+F opens search + if (!e.shiftKey && e.key.toLowerCase() === 'f') { + e.preventDefault() + e.stopPropagation() + setSearchOpen((prev) => !prev) + return + } // Cmd+K clears active pane screen + scrollback. if (!e.shiftKey && e.key.toLowerCase() === 'k') { e.preventDefault() e.stopPropagation() - const activePane = restty.activePane?.() - if (activePane) { - activePane.clearScreen() - return + const pane = manager.getActivePane() ?? manager.getPanes()[0] + if (pane) { + pane.terminal.clear() } - const pane = restty.getActivePane() ?? restty.getPanes()[0] - pane?.app.clearScreen() return } // Cmd+[ / Cmd+] cycles active split pane focus. if (!e.shiftKey && (e.code === 'BracketLeft' || e.code === 'BracketRight')) { - const panes = restty.getPanes() + const panes = manager.getPanes() if (panes.length < 2) return e.preventDefault() e.stopPropagation() - const activeId = restty.getActivePane()?.id ?? panes[0].id + const activeId = manager.getActivePane()?.id ?? panes[0].id const currentIdx = panes.findIndex((p) => p.id === activeId) if (currentIdx === -1) return const dir = e.code === 'BracketRight' ? 1 : -1 const nextPane = panes[(currentIdx + dir + panes.length) % panes.length] - restty.setActivePane(nextPane.id, { focus: true }) + manager.setActivePane(nextPane.id, { focus: true }) return } // Cmd+Shift+Enter expands/collapses the active pane to full terminal area. if (e.shiftKey && e.key === 'Enter' && (e.code === 'Enter' || e.code === 'NumpadEnter')) { - const panes = restty.getPanes() + const panes = manager.getPanes() if (panes.length < 2) return e.preventDefault() e.stopPropagation() - const pane = restty.getActivePane() ?? panes[0] + const pane = manager.getActivePane() ?? panes[0] if (!pane) return toggleExpandPane(pane.id) return @@ -884,13 +949,13 @@ export default function TerminalPane({ // Cmd+W closes only the active split pane and prevents the tab-level // handler from closing the entire terminal tab. if (!e.shiftKey && e.key.toLowerCase() === 'w') { - const panes = restty.getPanes() + const panes = manager.getPanes() if (panes.length < 2) return e.preventDefault() e.stopPropagation() - const pane = restty.getActivePane() ?? panes[0] + const pane = manager.getActivePane() ?? panes[0] if (!pane) return - restty.closePane(pane.id) + manager.closePane(pane.id) return } @@ -898,58 +963,58 @@ export default function TerminalPane({ if (e.key.toLowerCase() === 'd') { e.preventDefault() e.stopPropagation() - const pane = restty.getActivePane() ?? restty.getPanes()[0] + const pane = manager.getActivePane() ?? manager.getPanes()[0] if (!pane) return - restty.splitPane(pane.id, e.shiftKey ? 'horizontal' : 'vertical') + manager.splitPane(pane.id, e.shiftKey ? 'horizontal' : 'vertical') } } // Ctrl+Backspace → send \x17 (backward-kill-word) to PTY. - // Most terminal emulators map this shortcut but xterm.js/Restty does not - // by default, so we intercept at the capture phase and forward manually. const onCtrlBackspace = (e: KeyboardEvent): void => { if (!e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) return if (e.key !== 'Backspace') return - const restty = resttyRef.current - if (!restty) return + const manager = managerRef.current + if (!manager) return e.preventDefault() e.stopPropagation() - const pane = restty.getActivePane() ?? restty.getPanes()[0] - pane?.app.sendKeyInput('\x17') + const pane = manager.getActivePane() ?? manager.getPanes()[0] + if (!pane) return + const transport = paneTransportsRef.current.get(pane.id) + transport?.sendInput('\x17') } // Alt+Backspace → send ESC + DEL (\x1b\x7f, backward-kill-word) to PTY. - // Standard macOS terminal behaviour; Restty does not map this by default. const onAltBackspace = (e: KeyboardEvent): void => { if (!e.altKey || e.metaKey || e.ctrlKey || e.shiftKey) return if (e.key !== 'Backspace') return - const restty = resttyRef.current - if (!restty) return + const manager = managerRef.current + if (!manager) return e.preventDefault() e.stopPropagation() - const pane = restty.getActivePane() ?? restty.getPanes()[0] - pane?.app.sendKeyInput('\x1b\x7f') + const pane = manager.getActivePane() ?? manager.getPanes()[0] + if (!pane) return + const transport = paneTransportsRef.current.get(pane.id) + transport?.sendInput('\x1b\x7f') } // Shift+Enter → insert a literal newline into the shell command line. - // Sends Ctrl+V (\x16, quoted-insert) followed by LF (\x0a) so that - // both bash (readline) and zsh (zle) insert a newline character instead - // of executing the command. const onShiftEnter = (e: KeyboardEvent): void => { if (!e.shiftKey || e.metaKey || e.altKey || e.ctrlKey) return if (e.key !== 'Enter') return - const restty = resttyRef.current - if (!restty) return + const manager = managerRef.current + if (!manager) return e.preventDefault() e.stopPropagation() - const pane = restty.getActivePane() ?? restty.getPanes()[0] - pane?.app.sendKeyInput('\x16\x0a') + const pane = manager.getActivePane() ?? manager.getPanes()[0] + if (!pane) return + const transport = paneTransportsRef.current.get(pane.id) + transport?.sendInput('\x16\x0a') } window.addEventListener('keydown', onKeyDown, { capture: true }) @@ -965,53 +1030,60 @@ export default function TerminalPane({ }, [isActive]) const resolveMenuPane = () => { - const restty = resttyRef.current - if (!restty) return null - const panes = restty.getPanes() + const manager = managerRef.current + if (!manager) return null + const panes = manager.getPanes() if (contextPaneIdRef.current !== null) { const clickedPane = panes.find((p) => p.id === contextPaneIdRef.current) ?? null if (clickedPane) return clickedPane } - return restty.getActivePane() ?? panes[0] ?? null + return manager.getActivePane() ?? panes[0] ?? null } const handleCopy = async (): Promise => { const pane = resolveMenuPane() if (!pane) return - await pane.app.copySelectionToClipboard() + const selection = pane.terminal.getSelection() + if (selection) { + await navigator.clipboard.writeText(selection) + } } const handlePaste = async (): Promise => { const pane = resolveMenuPane() if (!pane) return - await pane.app.pasteFromClipboard() + const text = await navigator.clipboard.readText() + if (text) { + const transport = paneTransportsRef.current.get(pane.id) + transport?.sendInput(text) + } } const handleSplitRight = (): void => { const pane = resolveMenuPane() if (!pane) return - resttyRef.current?.splitPane(pane.id, 'vertical') + managerRef.current?.splitPane(pane.id, 'vertical') } const handleSplitDown = (): void => { const pane = resolveMenuPane() if (!pane) return - resttyRef.current?.splitPane(pane.id, 'horizontal') + managerRef.current?.splitPane(pane.id, 'horizontal') } const handleClosePane = (): void => { const pane = resolveMenuPane() if (!pane) return - const panes = resttyRef.current?.getPanes() ?? [] + const panes = managerRef.current?.getPanes() ?? [] if (panes.length <= 1) return - resttyRef.current?.closePane(pane.id) + managerRef.current?.closePane(pane.id) } const handleClearScreen = (): void => { const pane = resolveMenuPane() if (!pane) return - pane.app.clearScreen() + pane.terminal.clear() } const handleToggleExpand = (): void => { @@ -1020,7 +1092,7 @@ export default function TerminalPane({ toggleExpandPane(pane.id) } - const paneCount = resttyRef.current?.getPanes().length ?? 1 + const paneCount = managerRef.current?.getPanes().length ?? 1 const canClosePane = paneCount > 1 const canExpandPane = paneCount > 1 const menuPaneId = resolveMenuPane()?.id ?? null @@ -1038,6 +1110,10 @@ export default function TerminalPane({ ) } + // Get the search addon for the active pane + const activePane = managerRef.current?.getActivePane() + const activeSearchAddon = activePane?.searchAddon ?? null + return ( <>
pane.container.contains(target)) ?? null + manager.getPanes().find((pane) => pane.container.contains(target)) ?? null contextPaneIdRef.current = clickedPane?.id ?? null const bounds = event.currentTarget.getBoundingClientRect() @@ -1068,6 +1144,11 @@ export default function TerminalPane({ setTerminalMenuOpen(true) }} /> + setSearchOpen(false)} + searchAddon={activeSearchAddon} + /> + + + +
+ + + + + +
+ + +
+ ) +} diff --git a/src/renderer/src/components/settings/TerminalThemePreview.tsx b/src/renderer/src/components/settings/TerminalThemePreview.tsx index 857ee79312c..50e05cceed5 100644 --- a/src/renderer/src/components/settings/TerminalThemePreview.tsx +++ b/src/renderer/src/components/settings/TerminalThemePreview.tsx @@ -1,9 +1,5 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' -import { - colorToCss, - terminalPalettePreview, - type EffectiveTerminalAppearance -} from '@/lib/terminal-theme' +import { terminalPalettePreview, type EffectiveTerminalAppearance } from '@/lib/terminal-theme' type TerminalThemePreviewProps = { title: string @@ -22,19 +18,14 @@ export function TerminalThemePreview({ inactivePaneOpacity = 0.9, activePaneOpacity = 1 }: TerminalThemePreviewProps): React.JSX.Element { - const background = colorToCss( - appearance.theme?.colors.background, - appearance.mode === 'light' ? '#f8fafc' : '#09090b' - ) - const foreground = colorToCss( - appearance.theme?.colors.foreground, - appearance.mode === 'light' ? '#111827' : '#f4f4f5' - ) - const cursor = colorToCss(appearance.theme?.colors.cursor, foreground) - const selection = colorToCss( - appearance.theme?.colors.selectionBackground, - appearance.mode === 'light' ? 'rgba(59, 130, 246, 0.18)' : 'rgba(148, 163, 184, 0.22)' - ) + const background = + appearance.theme?.background ?? (appearance.mode === 'light' ? '#f8fafc' : '#09090b') + const foreground = + appearance.theme?.foreground ?? (appearance.mode === 'light' ? '#111827' : '#f4f4f5') + const cursor = appearance.theme?.cursor ?? foreground + const selection = + appearance.theme?.selectionBackground ?? + (appearance.mode === 'light' ? 'rgba(59, 130, 246, 0.18)' : 'rgba(148, 163, 184, 0.22)') const palette = terminalPalettePreview(appearance.theme) return ( diff --git a/src/renderer/src/lib/pane-manager.ts b/src/renderer/src/lib/pane-manager.ts new file mode 100644 index 00000000000..9d3128f1ad9 --- /dev/null +++ b/src/renderer/src/lib/pane-manager.ts @@ -0,0 +1,615 @@ +import { Terminal } from '@xterm/xterm' +import type { ITerminalOptions } from '@xterm/xterm' +import { FitAddon } from '@xterm/addon-fit' +import { SearchAddon } from '@xterm/addon-search' +import { Unicode11Addon } from '@xterm/addon-unicode11' +import { WebLinksAddon } from '@xterm/addon-web-links' +import { WebglAddon } from '@xterm/addon-webgl' + +// --------------------------------------------------------------------------- +// Public interfaces +// --------------------------------------------------------------------------- + +export interface PaneManagerOptions { + onPaneCreated?: (pane: ManagedPane) => void | Promise + onPaneClosed?: (paneId: number) => void + onActivePaneChange?: (pane: ManagedPane) => void + onLayoutChanged?: () => void + terminalOptions?: (paneId: number) => Partial + onLinkClick?: (url: string) => void +} + +export interface PaneStyleOptions { + splitBackground?: string + paneBackground?: string + inactivePaneOpacity?: number + activePaneOpacity?: number + opacityTransitionMs?: number + dividerThicknessPx?: number +} + +export interface ManagedPane { + id: number + terminal: Terminal + container: HTMLElement // the .pane element + fitAddon: FitAddon + searchAddon: SearchAddon +} + +// --------------------------------------------------------------------------- +// Internal types +// --------------------------------------------------------------------------- + +interface ManagedPaneInternal extends ManagedPane { + xtermContainer: HTMLElement + webglAddon: WebglAddon | null + unicode11Addon: Unicode11Addon + webLinksAddon: WebLinksAddon +} + +// --------------------------------------------------------------------------- +// PaneManager +// --------------------------------------------------------------------------- + +export class PaneManager { + private root: HTMLElement + private panes: Map = new Map() + private activePaneId: number | null = null + private nextPaneId = 1 + private options: PaneManagerOptions + private styleOptions: PaneStyleOptions = {} + private destroyed = false + + constructor(root: HTMLElement, options: PaneManagerOptions) { + this.root = root + this.options = options + } + + // ----------------------------------------------------------------------- + // Public API + // ----------------------------------------------------------------------- + + createInitialPane(opts?: { focus?: boolean }): ManagedPane { + const pane = this.createPaneInternal() + + // Place directly into root + this.root.appendChild(pane.container) + + this.openTerminal(pane) + + this.activePaneId = pane.id + this.applyPaneOpacity() + + if (opts?.focus !== false) { + pane.terminal.focus() + } + + void this.options.onPaneCreated?.(this.toPublic(pane)) + return this.toPublic(pane) + } + + splitPane(paneId: number, direction: 'vertical' | 'horizontal'): ManagedPane | null { + const existing = this.panes.get(paneId) + if (!existing) return null + + const newPane = this.createPaneInternal() + + const parent = existing.container.parentElement + if (!parent) return null + + const isVertical = direction === 'vertical' + + // Capture the flex style of the element we're replacing BEFORE modifying it, + // so the new split wrapper inherits its position in the parent flex layout. + const existingFlex = existing.container.style.flex || '' + const existingMinW = existing.container.style.minWidth || '' + const existingMinH = existing.container.style.minHeight || '' + + // Create split container + const split = document.createElement('div') + split.className = `pane-split ${isVertical ? 'is-vertical' : 'is-horizontal'}` + split.style.display = 'flex' + split.style.flexDirection = isVertical ? 'row' : 'column' + + // If the existing pane was inside a flex parent (another split), inherit its + // flex properties so the split wrapper occupies the same slot. Otherwise + // (direct child of root) use full width/height. + if (parent.classList.contains('pane-split')) { + split.style.flex = existingFlex || '1 1 0%' + split.style.minWidth = existingMinW || '0' + split.style.minHeight = existingMinH || '0' + split.style.overflow = 'hidden' + } else { + split.style.width = '100%' + split.style.height = '100%' + } + + // Create divider + const divider = this.createDivider(isVertical) + + // Apply flex styles to existing pane container (child of the new split) + this.applyPaneFlexStyle(existing.container) + + // Apply flex styles to new pane container + this.applyPaneFlexStyle(newPane.container) + + // Replace existing pane with split in the DOM + parent.replaceChild(split, existing.container) + + // Build split: [existing] [divider] [new] + split.appendChild(existing.container) + split.appendChild(divider) + split.appendChild(newPane.container) + + // Open terminal for new pane + this.openTerminal(newPane) + + // Set new pane active + this.activePaneId = newPane.id + this.applyPaneOpacity() + this.applyDividerStyles() + + if (newPane.terminal) { + newPane.terminal.focus() + } + + // Refit existing pane since it now shares space + this.safeFit(existing) + + void this.options.onPaneCreated?.(this.toPublic(newPane)) + this.options.onLayoutChanged?.() + + return this.toPublic(newPane) + } + + closePane(paneId: number): void { + const pane = this.panes.get(paneId) + if (!pane) return + + const paneContainer = pane.container + const parent = paneContainer.parentElement + if (!parent) return + + // Dispose terminal and addons + this.disposePane(pane) + + if (parent.classList.contains('pane-split')) { + // Find sibling (skip divider) + const children = Array.from(parent.children).filter( + (child): child is HTMLElement => + child instanceof HTMLElement && + (child.classList.contains('pane') || child.classList.contains('pane-split')) + ) + + const sibling = children.find((c) => c !== paneContainer) ?? null + + // Remove pane element + paneContainer.remove() + + // Remove divider(s) + const dividers = Array.from(parent.children).filter( + (child): child is HTMLElement => + child instanceof HTMLElement && child.classList.contains('pane-divider') + ) + for (const d of dividers) d.remove() + + if (sibling) { + // Unwrap: replace the split container with the sibling + const grandparent = parent.parentElement + if (grandparent) { + if (grandparent === this.root) { + // Going back to root level — remove flex styles so it fills naturally + sibling.style.flex = '' + sibling.style.minWidth = '' + sibling.style.minHeight = '' + sibling.style.position = '' + sibling.style.overflow = '' + if (sibling.classList.contains('pane-split')) { + sibling.style.width = '100%' + sibling.style.height = '100%' + } + } else if (grandparent.classList.contains('pane-split')) { + // Going into another split — inherit the flex slot from the + // split container we're removing + sibling.style.flex = parent.style.flex || '1 1 0%' + sibling.style.minWidth = parent.style.minWidth || '0' + sibling.style.minHeight = parent.style.minHeight || '0' + sibling.style.overflow = 'hidden' + } + grandparent.replaceChild(sibling, parent) + } + } else { + // No sibling left, just remove the split + parent.remove() + } + } else { + // Direct child of root (only pane) — just remove + paneContainer.remove() + } + + // Activate next pane if needed + if (this.activePaneId === paneId) { + const remaining = Array.from(this.panes.values()) + if (remaining.length > 0) { + this.activePaneId = remaining[0].id + remaining[0].terminal.focus() + } else { + this.activePaneId = null + } + } + + this.applyPaneOpacity() + + // Refit remaining panes + for (const p of this.panes.values()) { + this.safeFit(p) + } + + this.options.onPaneClosed?.(paneId) + this.options.onLayoutChanged?.() + } + + getPanes(): ManagedPane[] { + return Array.from(this.panes.values()).map((p) => this.toPublic(p)) + } + + getActivePane(): ManagedPane | null { + if (this.activePaneId === null) return null + const pane = this.panes.get(this.activePaneId) + return pane ? this.toPublic(pane) : null + } + + setActivePane(paneId: number, opts?: { focus?: boolean }): void { + const pane = this.panes.get(paneId) + if (!pane) return + + const changed = this.activePaneId !== paneId + this.activePaneId = paneId + this.applyPaneOpacity() + + if (opts?.focus !== false) { + pane.terminal.focus() + } + + if (changed) { + this.options.onActivePaneChange?.(this.toPublic(pane)) + } + } + + setPaneStyleOptions(opts: PaneStyleOptions): void { + this.styleOptions = { ...opts } + this.applyPaneOpacity() + this.applyDividerStyles() + this.applyRootBackground() + } + + destroy(): void { + this.destroyed = true + for (const pane of this.panes.values()) { + this.disposePane(pane) + } + this.root.innerHTML = '' + this.activePaneId = null + } + + // ----------------------------------------------------------------------- + // Internal helpers + // ----------------------------------------------------------------------- + + private createPaneInternal(): ManagedPaneInternal { + const id = this.nextPaneId++ + + // Create .pane container + const container = document.createElement('div') + container.className = 'pane' + container.dataset.paneId = String(id) + + // Create .xterm-container + const xtermContainer = document.createElement('div') + xtermContainer.className = 'xterm-container' + xtermContainer.style.width = '100%' + xtermContainer.style.height = '100%' + container.appendChild(xtermContainer) + + // Build terminal options + const userOpts = this.options.terminalOptions?.(id) ?? {} + const terminalOpts: ITerminalOptions = { + allowProposedApi: true, + cursorBlink: true, + cursorStyle: 'bar', + fontSize: 14, + fontFamily: '"SF Mono", Menlo, monospace', + scrollback: 10000, + allowTransparency: false, + macOptionIsMeta: true, + macOptionClickForcesSelection: true, + drawBoldTextInBrightColors: true, + ...userOpts + } + + const terminal = new Terminal(terminalOpts) + const fitAddon = new FitAddon() + const searchAddon = new SearchAddon() + const unicode11Addon = new Unicode11Addon() + const webLinksAddon = new WebLinksAddon( + this.options.onLinkClick ? (_event, uri) => this.options.onLinkClick!(uri) : undefined + ) + + const pane: ManagedPaneInternal = { + id, + terminal, + container, + xtermContainer, + fitAddon, + searchAddon, + unicode11Addon, + webLinksAddon, + webglAddon: null + } + + // Focus handler: clicking a pane makes it active + container.addEventListener('pointerdown', () => { + if (!this.destroyed && this.activePaneId !== id) { + this.setActivePane(id, { focus: false }) + } + }) + + this.panes.set(id, pane) + return pane + } + + /** Open terminal into its container and load addons. Must be called after the container is in the DOM. */ + private openTerminal(pane: ManagedPaneInternal): void { + const { terminal, xtermContainer, fitAddon, searchAddon, unicode11Addon, webLinksAddon } = pane + + // Open terminal into DOM + terminal.open(xtermContainer) + + // Load addons (order matters: WebGL must be after open()) + terminal.loadAddon(fitAddon) + terminal.loadAddon(searchAddon) + terminal.loadAddon(unicode11Addon) + terminal.loadAddon(webLinksAddon) + + // Activate unicode 11 + terminal.unicode.activeVersion = '11' + + // Try WebGL, fall back to canvas + try { + const webglAddon = new WebglAddon() + webglAddon.onContextLoss(() => { + webglAddon.dispose() + pane.webglAddon = null + }) + terminal.loadAddon(webglAddon) + pane.webglAddon = webglAddon + } catch { + // WebGL not available — default canvas renderer is fine + pane.webglAddon = null + } + + // Initial fit (deferred to ensure layout has settled) + requestAnimationFrame(() => { + this.safeFit(pane) + }) + } + + private safeFit(pane: ManagedPaneInternal): void { + try { + pane.fitAddon.fit() + } catch { + // Container may not have dimensions yet + } + } + + private disposePane(pane: ManagedPaneInternal): void { + try { + pane.webglAddon?.dispose() + } catch { + /* ignore */ + } + try { + pane.searchAddon.dispose() + } catch { + /* ignore */ + } + try { + pane.unicode11Addon.dispose() + } catch { + /* ignore */ + } + try { + pane.webLinksAddon.dispose() + } catch { + /* ignore */ + } + try { + pane.fitAddon.dispose() + } catch { + /* ignore */ + } + try { + pane.terminal.dispose() + } catch { + /* ignore */ + } + this.panes.delete(pane.id) + } + + private applyPaneFlexStyle(el: HTMLElement): void { + el.style.flex = '1 1 0%' + el.style.minWidth = '0' + el.style.minHeight = '0' + el.style.position = 'relative' + el.style.overflow = 'hidden' + } + + private createDivider(isVertical: boolean): HTMLElement { + const divider = document.createElement('div') + divider.className = `pane-divider ${isVertical ? 'is-vertical' : 'is-horizontal'}` + + const thickness = this.styleOptions.dividerThicknessPx ?? 4 + if (isVertical) { + divider.style.width = `${thickness}px` + divider.style.cursor = 'col-resize' + } else { + divider.style.height = `${thickness}px` + divider.style.cursor = 'row-resize' + } + divider.style.flex = 'none' + + if (this.styleOptions.splitBackground) { + divider.style.background = this.styleOptions.splitBackground + } + + this.attachDividerDrag(divider, isVertical) + return divider + } + + private attachDividerDrag(divider: HTMLElement, isVertical: boolean): void { + const MIN_PANE_SIZE = 50 + + let dragging = false + let startPos = 0 + let prevFlex = 0 + let nextFlex = 0 + let totalSize = 0 + let prevEl: HTMLElement | null = null + let nextEl: HTMLElement | null = null + + const onPointerDown = (e: PointerEvent): void => { + e.preventDefault() + divider.setPointerCapture(e.pointerId) + divider.classList.add('is-dragging') + dragging = true + + startPos = isVertical ? e.clientX : e.clientY + + // Find previous and next pane/split siblings + prevEl = divider.previousElementSibling as HTMLElement | null + nextEl = divider.nextElementSibling as HTMLElement | null + + if (!prevEl || !nextEl) return + + const prevRect = prevEl.getBoundingClientRect() + const nextRect = nextEl.getBoundingClientRect() + const prevSize = isVertical ? prevRect.width : prevRect.height + const nextSize = isVertical ? nextRect.width : nextRect.height + totalSize = prevSize + nextSize + + // Store current proportions as flex-basis values + prevFlex = prevSize + nextFlex = nextSize + } + + const onPointerMove = (e: PointerEvent): void => { + if (!dragging || !prevEl || !nextEl) return + + const currentPos = isVertical ? e.clientX : e.clientY + const delta = currentPos - startPos + + let newPrev = prevFlex + delta + let newNext = nextFlex - delta + + // Enforce minimum pane size + if (newPrev < MIN_PANE_SIZE) { + newPrev = MIN_PANE_SIZE + newNext = totalSize - MIN_PANE_SIZE + } + if (newNext < MIN_PANE_SIZE) { + newNext = MIN_PANE_SIZE + newPrev = totalSize - MIN_PANE_SIZE + } + + // Use flex-grow proportionally + prevEl.style.flex = `${newPrev} 1 0%` + nextEl.style.flex = `${newNext} 1 0%` + + // Refit terminals in affected panes + this.refitPanesUnder(prevEl) + this.refitPanesUnder(nextEl) + } + + const onPointerUp = (e: PointerEvent): void => { + if (!dragging) return + dragging = false + divider.releasePointerCapture(e.pointerId) + divider.classList.remove('is-dragging') + prevEl = null + nextEl = null + } + + divider.addEventListener('pointerdown', onPointerDown) + divider.addEventListener('pointermove', onPointerMove) + divider.addEventListener('pointerup', onPointerUp) + } + + private refitPanesUnder(el: HTMLElement): void { + // If the element is a pane, refit it + if (el.classList.contains('pane')) { + const paneId = Number(el.dataset.paneId) + const pane = this.panes.get(paneId) + if (pane) this.safeFit(pane) + return + } + + // If it's a split, refit all panes inside it + if (el.classList.contains('pane-split')) { + const paneEls = el.querySelectorAll('.pane[data-pane-id]') + for (const paneEl of paneEls) { + const paneId = Number((paneEl as HTMLElement).dataset.paneId) + const pane = this.panes.get(paneId) + if (pane) this.safeFit(pane) + } + } + } + + private applyPaneOpacity(): void { + const { + activePaneOpacity = 1, + inactivePaneOpacity = 1, + opacityTransitionMs = 0 + } = this.styleOptions + + const transition = opacityTransitionMs > 0 ? `opacity ${opacityTransitionMs}ms ease` : '' + + for (const pane of this.panes.values()) { + const isActive = pane.id === this.activePaneId + pane.container.style.opacity = String(isActive ? activePaneOpacity : inactivePaneOpacity) + pane.container.style.transition = transition + } + } + + private applyDividerStyles(): void { + const thickness = this.styleOptions.dividerThicknessPx ?? 4 + const bg = this.styleOptions.splitBackground ?? '' + + const dividers = this.root.querySelectorAll('.pane-divider') + for (const div of dividers) { + const el = div as HTMLElement + const isVertical = el.classList.contains('is-vertical') + if (isVertical) { + el.style.width = `${thickness}px` + } else { + el.style.height = `${thickness}px` + } + el.style.background = bg + } + } + + private applyRootBackground(): void { + if (this.styleOptions.splitBackground) { + this.root.style.background = this.styleOptions.splitBackground + } + } + + private toPublic(pane: ManagedPaneInternal): ManagedPane { + return { + id: pane.id, + terminal: pane.terminal, + container: pane.container, + fitAddon: pane.fitAddon, + searchAddon: pane.searchAddon + } + } +} diff --git a/src/renderer/src/lib/terminal-theme.ts b/src/renderer/src/lib/terminal-theme.ts index 20fb54b1a3b..a7348ddbd94 100644 --- a/src/renderer/src/lib/terminal-theme.ts +++ b/src/renderer/src/lib/terminal-theme.ts @@ -1,7 +1,8 @@ -import { getBuiltinTheme, listBuiltinThemeNames, type GhosttyTheme } from 'restty' +import type { ITheme } from '@xterm/xterm' +import { getTheme, getThemeNames } from './terminal-themes-data' import type { GlobalSettings } from '../../../shared/types' -export const BUILTIN_TERMINAL_THEME_NAMES = listBuiltinThemeNames() +export const BUILTIN_TERMINAL_THEME_NAMES = getThemeNames() export const DEFAULT_TERMINAL_THEME_DARK = 'Ghostty Default Style Dark' export const DEFAULT_TERMINAL_THEME_LIGHT = 'Builtin Tango Light' @@ -13,7 +14,7 @@ export type EffectiveTerminalAppearance = { sourceTheme: 'system' | 'dark' | 'light' themeName: string dividerColor: string - theme: GhosttyTheme | null + theme: ITheme | null systemPrefersDark: boolean } @@ -22,10 +23,14 @@ export function getSystemPrefersDark(): boolean { return window.matchMedia('(prefers-color-scheme: dark)').matches } -export function getTerminalThemePreview(name: string): GhosttyTheme | null { - const theme = getBuiltinTheme(name) +export function getBuiltinTheme(name: string): ITheme | null { + return getTheme(name) +} + +export function getTerminalThemePreview(name: string): ITheme | null { + const theme = getTheme(name) if (theme) return theme - return getBuiltinTheme(DEFAULT_TERMINAL_THEME_DARK) + return getTheme(DEFAULT_TERMINAL_THEME_DARK) } export function resolveEffectiveTerminalAppearance( @@ -73,12 +78,10 @@ export function buildTerminalFontMatchers(fontFamily: string): string[] { return Array.from( new Set([ ...matchers, - // macOS 'sf mono', 'sfmono-regular', 'menlo', 'menlo regular', - // Linux 'dejavu sans mono', 'liberation mono', 'ubuntu mono', @@ -132,19 +135,37 @@ export function colorToCss( color: { r: number; g: number; b: number; a?: number } | string | undefined, fallback: string ): string { - if (!color || typeof color === 'string') return fallback + if (!color) return fallback + if (typeof color === 'string') return color const alpha = typeof color.a === 'number' ? color.a / 255 : 1 return `rgba(${color.r}, ${color.g}, ${color.b}, ${alpha})` } -export function terminalPalettePreview(theme: GhosttyTheme | null): string[] { +const PALETTE_KEYS = [ + 'black', + 'red', + 'green', + 'yellow', + 'blue', + 'magenta', + 'cyan', + 'white', + 'brightBlack', + 'brightRed', + 'brightGreen', + 'brightYellow', + 'brightBlue', + 'brightMagenta', + 'brightCyan', + 'brightWhite' +] as const + +export function terminalPalettePreview(theme: ITheme | null): string[] { if (!theme) return [] - const colors = theme.colors.palette const swatches: string[] = [] - for (let i = 0; i < 16; i += 1) { - const color = colors[i] - if (!color) continue - swatches.push(colorToCss(color, '#000000')) + for (const key of PALETTE_KEYS) { + const color = theme[key] + if (color) swatches.push(color) } return swatches } diff --git a/src/renderer/src/lib/terminal-themes-data.ts b/src/renderer/src/lib/terminal-themes-data.ts new file mode 100644 index 00000000000..b416bb1b0f4 --- /dev/null +++ b/src/renderer/src/lib/terminal-themes-data.ts @@ -0,0 +1,777 @@ +import type { ITheme } from '@xterm/xterm' + +export const TERMINAL_THEMES: Record = { + // ────────────────────────────────────────────── + // Defaults + // ────────────────────────────────────────────── + + 'Ghostty Default Style Dark': { + background: '#282c34', + foreground: '#c0c5ce', + cursor: '#c0c5ce', + cursorAccent: '#282c34', + selectionBackground: '#3e4451', + selectionForeground: '#c0c5ce', + black: '#1d2021', + red: '#cc241d', + green: '#98971a', + yellow: '#d79921', + blue: '#458588', + magenta: '#b16286', + cyan: '#689d6a', + white: '#a89984', + brightBlack: '#928374', + brightRed: '#fb4934', + brightGreen: '#b8bb26', + brightYellow: '#fabd2f', + brightBlue: '#83a598', + brightMagenta: '#d3869b', + brightCyan: '#8ec07c', + brightWhite: '#ebdbb2' + }, + + 'Builtin Tango Light': { + background: '#ffffff', + foreground: '#2e3434', + cursor: '#2e3434', + cursorAccent: '#ffffff', + selectionBackground: '#accef7', + selectionForeground: '#2e3434', + black: '#2e3436', + red: '#cc0000', + green: '#4e9a06', + yellow: '#c4a000', + blue: '#3465a4', + magenta: '#75507b', + cyan: '#06989a', + white: '#d3d7cf', + brightBlack: '#555753', + brightRed: '#ef2929', + brightGreen: '#8ae234', + brightYellow: '#fce94f', + brightBlue: '#729fcf', + brightMagenta: '#ad7fa8', + brightCyan: '#34e2e2', + brightWhite: '#eeeeec' + }, + + // ────────────────────────────────────────────── + // Popular Dark + // ────────────────────────────────────────────── + + Dracula: { + background: '#282a36', + foreground: '#f8f8f2', + cursor: '#f8f8f2', + cursorAccent: '#282a36', + selectionBackground: '#44475a', + selectionForeground: '#f8f8f2', + black: '#21222c', + red: '#ff5555', + green: '#50fa7b', + yellow: '#f1fa8c', + blue: '#bd93f9', + magenta: '#ff79c6', + cyan: '#8be9fd', + white: '#f8f8f2', + brightBlack: '#6272a4', + brightRed: '#ff6e6e', + brightGreen: '#69ff94', + brightYellow: '#ffffa5', + brightBlue: '#d6acff', + brightMagenta: '#ff92df', + brightCyan: '#a4ffff', + brightWhite: '#ffffff' + }, + + 'One Dark': { + background: '#282c34', + foreground: '#abb2bf', + cursor: '#528bff', + cursorAccent: '#282c34', + selectionBackground: '#3e4451', + selectionForeground: '#abb2bf', + black: '#282c34', + red: '#e06c75', + green: '#98c379', + yellow: '#e5c07b', + blue: '#61afef', + magenta: '#c678dd', + cyan: '#56b6c2', + white: '#abb2bf', + brightBlack: '#5c6370', + brightRed: '#e06c75', + brightGreen: '#98c379', + brightYellow: '#e5c07b', + brightBlue: '#61afef', + brightMagenta: '#c678dd', + brightCyan: '#56b6c2', + brightWhite: '#ffffff' + }, + + Nord: { + background: '#2e3440', + foreground: '#d8dee9', + cursor: '#d8dee9', + cursorAccent: '#2e3440', + selectionBackground: '#434c5e', + selectionForeground: '#d8dee9', + black: '#3b4252', + red: '#bf616a', + green: '#a3be8c', + yellow: '#ebcb8b', + blue: '#81a1c1', + magenta: '#b48ead', + cyan: '#88c0d0', + white: '#e5e9f0', + brightBlack: '#4c566a', + brightRed: '#bf616a', + brightGreen: '#a3be8c', + brightYellow: '#ebcb8b', + brightBlue: '#81a1c1', + brightMagenta: '#b48ead', + brightCyan: '#8fbcbb', + brightWhite: '#eceff4' + }, + + Monokai: { + background: '#272822', + foreground: '#f8f8f2', + cursor: '#f8f8f0', + cursorAccent: '#272822', + selectionBackground: '#49483e', + selectionForeground: '#f8f8f2', + black: '#272822', + red: '#f92672', + green: '#a6e22e', + yellow: '#f4bf75', + blue: '#66d9ef', + magenta: '#ae81ff', + cyan: '#a1efe4', + white: '#f8f8f2', + brightBlack: '#75715e', + brightRed: '#f92672', + brightGreen: '#a6e22e', + brightYellow: '#f4bf75', + brightBlue: '#66d9ef', + brightMagenta: '#ae81ff', + brightCyan: '#a1efe4', + brightWhite: '#f9f8f5' + }, + + 'Tokyo Night': { + background: '#1a1b26', + foreground: '#c0caf5', + cursor: '#c0caf5', + cursorAccent: '#1a1b26', + selectionBackground: '#33467c', + selectionForeground: '#c0caf5', + black: '#15161e', + red: '#f7768e', + green: '#9ece6a', + yellow: '#e0af68', + blue: '#7aa2f7', + magenta: '#bb9af7', + cyan: '#7dcfff', + white: '#a9b1d6', + brightBlack: '#414868', + brightRed: '#f7768e', + brightGreen: '#9ece6a', + brightYellow: '#e0af68', + brightBlue: '#7aa2f7', + brightMagenta: '#bb9af7', + brightCyan: '#7dcfff', + brightWhite: '#c0caf5' + }, + + 'Gruvbox Dark': { + background: '#282828', + foreground: '#ebdbb2', + cursor: '#ebdbb2', + cursorAccent: '#282828', + selectionBackground: '#504945', + selectionForeground: '#ebdbb2', + black: '#282828', + red: '#cc241d', + green: '#98971a', + yellow: '#d79921', + blue: '#458588', + magenta: '#b16286', + cyan: '#689d6a', + white: '#a89984', + brightBlack: '#928374', + brightRed: '#fb4934', + brightGreen: '#b8bb26', + brightYellow: '#fabd2f', + brightBlue: '#83a598', + brightMagenta: '#d3869b', + brightCyan: '#8ec07c', + brightWhite: '#ebdbb2' + }, + + 'Catppuccin Mocha': { + background: '#1e1e2e', + foreground: '#cdd6f4', + cursor: '#f5e0dc', + cursorAccent: '#1e1e2e', + selectionBackground: '#585b70', + selectionForeground: '#cdd6f4', + black: '#45475a', + red: '#f38ba8', + green: '#a6e3a1', + yellow: '#f9e2af', + blue: '#89b4fa', + magenta: '#f5c2e7', + cyan: '#94e2d5', + white: '#bac2de', + brightBlack: '#585b70', + brightRed: '#f38ba8', + brightGreen: '#a6e3a1', + brightYellow: '#f9e2af', + brightBlue: '#89b4fa', + brightMagenta: '#f5c2e7', + brightCyan: '#94e2d5', + brightWhite: '#a6adc8' + }, + + 'Solarized Dark': { + background: '#002b36', + foreground: '#839496', + cursor: '#839496', + cursorAccent: '#002b36', + selectionBackground: '#073642', + selectionForeground: '#93a1a1', + black: '#073642', + red: '#dc322f', + green: '#859900', + yellow: '#b58900', + blue: '#268bd2', + magenta: '#d33682', + cyan: '#2aa198', + white: '#eee8d5', + brightBlack: '#002b36', + brightRed: '#cb4b16', + brightGreen: '#586e75', + brightYellow: '#657b83', + brightBlue: '#839496', + brightMagenta: '#6c71c4', + brightCyan: '#93a1a1', + brightWhite: '#fdf6e3' + }, + + 'Material Dark': { + background: '#263238', + foreground: '#eeffff', + cursor: '#ffcc00', + cursorAccent: '#263238', + selectionBackground: '#546e7a', + selectionForeground: '#eeffff', + black: '#000000', + red: '#f07178', + green: '#c3e88d', + yellow: '#ffcb6b', + blue: '#82aaff', + magenta: '#c792ea', + cyan: '#89ddff', + white: '#eeffff', + brightBlack: '#546e7a', + brightRed: '#f07178', + brightGreen: '#c3e88d', + brightYellow: '#ffcb6b', + brightBlue: '#82aaff', + brightMagenta: '#c792ea', + brightCyan: '#89ddff', + brightWhite: '#ffffff' + }, + + 'Ayu Dark': { + background: '#0a0e14', + foreground: '#b3b1ad', + cursor: '#e6b450', + cursorAccent: '#0a0e14', + selectionBackground: '#273747', + selectionForeground: '#b3b1ad', + black: '#01060e', + red: '#ea6c73', + green: '#91b362', + yellow: '#f9af4f', + blue: '#53bdfa', + magenta: '#fae994', + cyan: '#90e1c6', + white: '#c7c7c7', + brightBlack: '#686868', + brightRed: '#f07178', + brightGreen: '#c2d94c', + brightYellow: '#ffb454', + brightBlue: '#59c2ff', + brightMagenta: '#ffee99', + brightCyan: '#95e6cb', + brightWhite: '#ffffff' + }, + + Nightfox: { + background: '#192330', + foreground: '#cdcecf', + cursor: '#cdcecf', + cursorAccent: '#192330', + selectionBackground: '#2b3b51', + selectionForeground: '#cdcecf', + black: '#393b44', + red: '#c94f6d', + green: '#81b29a', + yellow: '#dbc074', + blue: '#719cd6', + magenta: '#9d79d6', + cyan: '#63cdcf', + white: '#dfdfe0', + brightBlack: '#575860', + brightRed: '#d16983', + brightGreen: '#8ebaa4', + brightYellow: '#e0c989', + brightBlue: '#86abdc', + brightMagenta: '#baa1e2', + brightCyan: '#7ad5d6', + brightWhite: '#e4e4e5' + }, + + Kanagawa: { + background: '#1f1f28', + foreground: '#dcd7ba', + cursor: '#c8c093', + cursorAccent: '#1f1f28', + selectionBackground: '#2d4f67', + selectionForeground: '#dcd7ba', + black: '#090618', + red: '#c34043', + green: '#76946a', + yellow: '#c0a36e', + blue: '#7e9cd8', + magenta: '#957fb8', + cyan: '#6a9589', + white: '#c8c093', + brightBlack: '#727169', + brightRed: '#e82424', + brightGreen: '#98bb6c', + brightYellow: '#e6c384', + brightBlue: '#7fb4ca', + brightMagenta: '#938aa9', + brightCyan: '#7aa89f', + brightWhite: '#dcd7ba' + }, + + 'Rose Pine': { + background: '#191724', + foreground: '#e0def4', + cursor: '#524f67', + cursorAccent: '#e0def4', + selectionBackground: '#403d52', + selectionForeground: '#e0def4', + black: '#26233a', + red: '#eb6f92', + green: '#31748f', + yellow: '#f6c177', + blue: '#9ccfd8', + magenta: '#c4a7e7', + cyan: '#ebbcba', + white: '#e0def4', + brightBlack: '#6e6a86', + brightRed: '#eb6f92', + brightGreen: '#31748f', + brightYellow: '#f6c177', + brightBlue: '#9ccfd8', + brightMagenta: '#c4a7e7', + brightCyan: '#ebbcba', + brightWhite: '#e0def4' + }, + + 'Everforest Dark': { + background: '#2d353b', + foreground: '#d3c6aa', + cursor: '#d3c6aa', + cursorAccent: '#2d353b', + selectionBackground: '#543a48', + selectionForeground: '#d3c6aa', + black: '#475258', + red: '#e67e80', + green: '#a7c080', + yellow: '#dbbc7f', + blue: '#7fbbb3', + magenta: '#d699b6', + cyan: '#83c092', + white: '#d3c6aa', + brightBlack: '#697379', + brightRed: '#e67e80', + brightGreen: '#a7c080', + brightYellow: '#dbbc7f', + brightBlue: '#7fbbb3', + brightMagenta: '#d699b6', + brightCyan: '#83c092', + brightWhite: '#d3c6aa' + }, + + Palenight: { + background: '#292d3e', + foreground: '#a6accd', + cursor: '#ffcc00', + cursorAccent: '#292d3e', + selectionBackground: '#434758', + selectionForeground: '#a6accd', + black: '#292d3e', + red: '#f07178', + green: '#c3e88d', + yellow: '#ffcb6b', + blue: '#82aaff', + magenta: '#c792ea', + cyan: '#89ddff', + white: '#a6accd', + brightBlack: '#676e95', + brightRed: '#f07178', + brightGreen: '#c3e88d', + brightYellow: '#ffcb6b', + brightBlue: '#82aaff', + brightMagenta: '#c792ea', + brightCyan: '#89ddff', + brightWhite: '#ffffff' + }, + + 'Horizon Dark': { + background: '#1c1e26', + foreground: '#e0e0e0', + cursor: '#e95678', + cursorAccent: '#1c1e26', + selectionBackground: '#2e303e', + selectionForeground: '#e0e0e0', + black: '#16161c', + red: '#e95678', + green: '#29d398', + yellow: '#fab795', + blue: '#26bbd9', + magenta: '#ee64ac', + cyan: '#59e1e3', + white: '#d5d8da', + brightBlack: '#6c6f93', + brightRed: '#ec6a88', + brightGreen: '#3fdaa4', + brightYellow: '#fbc3a7', + brightBlue: '#3fc4de', + brightMagenta: '#f075b5', + brightCyan: '#6be4e6', + brightWhite: '#f0f0f0' + }, + + 'Night Owl': { + background: '#011627', + foreground: '#d6deeb', + cursor: '#80a4c2', + cursorAccent: '#011627', + selectionBackground: '#1d3b53', + selectionForeground: '#d6deeb', + black: '#011627', + red: '#ef5350', + green: '#22da6e', + yellow: '#addb67', + blue: '#82aaff', + magenta: '#c792ea', + cyan: '#21c7a8', + white: '#ffffff', + brightBlack: '#575656', + brightRed: '#ef5350', + brightGreen: '#22da6e', + brightYellow: '#ffeb95', + brightBlue: '#82aaff', + brightMagenta: '#c792ea', + brightCyan: '#7fdbca', + brightWhite: '#ffffff' + }, + + // ────────────────────────────────────────────── + // Popular Light + // ────────────────────────────────────────────── + + 'Solarized Light': { + background: '#fdf6e3', + foreground: '#657b83', + cursor: '#657b83', + cursorAccent: '#fdf6e3', + selectionBackground: '#eee8d5', + selectionForeground: '#586e75', + black: '#073642', + red: '#dc322f', + green: '#859900', + yellow: '#b58900', + blue: '#268bd2', + magenta: '#d33682', + cyan: '#2aa198', + white: '#eee8d5', + brightBlack: '#002b36', + brightRed: '#cb4b16', + brightGreen: '#586e75', + brightYellow: '#657b83', + brightBlue: '#839496', + brightMagenta: '#6c71c4', + brightCyan: '#93a1a1', + brightWhite: '#fdf6e3' + }, + + 'One Light': { + background: '#fafafa', + foreground: '#383a42', + cursor: '#526fff', + cursorAccent: '#fafafa', + selectionBackground: '#e5e5e6', + selectionForeground: '#383a42', + black: '#383a42', + red: '#e45649', + green: '#50a14f', + yellow: '#c18401', + blue: '#4078f2', + magenta: '#a626a4', + cyan: '#0184bc', + white: '#a0a1a7', + brightBlack: '#696c77', + brightRed: '#e45649', + brightGreen: '#50a14f', + brightYellow: '#c18401', + brightBlue: '#4078f2', + brightMagenta: '#a626a4', + brightCyan: '#0184bc', + brightWhite: '#fafafa' + }, + + 'Catppuccin Latte': { + background: '#eff1f5', + foreground: '#4c4f69', + cursor: '#dc8a78', + cursorAccent: '#eff1f5', + selectionBackground: '#acb0be', + selectionForeground: '#4c4f69', + black: '#5c5f77', + red: '#d20f39', + green: '#40a02b', + yellow: '#df8e1d', + blue: '#1e66f5', + magenta: '#ea76cb', + cyan: '#179299', + white: '#acb0be', + brightBlack: '#6c6f85', + brightRed: '#d20f39', + brightGreen: '#40a02b', + brightYellow: '#df8e1d', + brightBlue: '#1e66f5', + brightMagenta: '#ea76cb', + brightCyan: '#179299', + brightWhite: '#bcc0cc' + }, + + 'GitHub Light': { + background: '#ffffff', + foreground: '#24292e', + cursor: '#044289', + cursorAccent: '#ffffff', + selectionBackground: '#c8c8fa', + selectionForeground: '#24292e', + black: '#24292e', + red: '#d73a49', + green: '#28a745', + yellow: '#dbab09', + blue: '#0366d6', + magenta: '#5a32a3', + cyan: '#0598bc', + white: '#6a737d', + brightBlack: '#959da5', + brightRed: '#cb2431', + brightGreen: '#22863a', + brightYellow: '#b08800', + brightBlue: '#005cc5', + brightMagenta: '#5a32a3', + brightCyan: '#3192aa', + brightWhite: '#d1d5da' + }, + + 'Rose Pine Dawn': { + background: '#faf4ed', + foreground: '#575279', + cursor: '#9893a5', + cursorAccent: '#faf4ed', + selectionBackground: '#dfdad9', + selectionForeground: '#575279', + black: '#f2e9e1', + red: '#b4637a', + green: '#286983', + yellow: '#ea9d34', + blue: '#56949f', + magenta: '#907aa9', + cyan: '#d7827e', + white: '#575279', + brightBlack: '#9893a5', + brightRed: '#b4637a', + brightGreen: '#286983', + brightYellow: '#ea9d34', + brightBlue: '#56949f', + brightMagenta: '#907aa9', + brightCyan: '#d7827e', + brightWhite: '#575279' + }, + + 'Gruvbox Light': { + background: '#fbf1c7', + foreground: '#3c3836', + cursor: '#3c3836', + cursorAccent: '#fbf1c7', + selectionBackground: '#ebdbb2', + selectionForeground: '#3c3836', + black: '#fbf1c7', + red: '#cc241d', + green: '#98971a', + yellow: '#d79921', + blue: '#458588', + magenta: '#b16286', + cyan: '#689d6a', + white: '#7c6f64', + brightBlack: '#928374', + brightRed: '#9d0006', + brightGreen: '#79740e', + brightYellow: '#b57614', + brightBlue: '#076678', + brightMagenta: '#8f3f71', + brightCyan: '#427b58', + brightWhite: '#3c3836' + }, + + 'Tokyo Night Light': { + background: '#d5d6db', + foreground: '#343b58', + cursor: '#343b58', + cursorAccent: '#d5d6db', + selectionBackground: '#9699a3', + selectionForeground: '#343b58', + black: '#0f0f14', + red: '#8c4351', + green: '#485e30', + yellow: '#8f5e15', + blue: '#34548a', + magenta: '#5a4a78', + cyan: '#0f4b6e', + white: '#343b58', + brightBlack: '#9699a3', + brightRed: '#8c4351', + brightGreen: '#485e30', + brightYellow: '#8f5e15', + brightBlue: '#34548a', + brightMagenta: '#5a4a78', + brightCyan: '#0f4b6e', + brightWhite: '#343b58' + }, + + 'Everforest Light': { + background: '#fdf6e3', + foreground: '#5c6a72', + cursor: '#5c6a72', + cursorAccent: '#fdf6e3', + selectionBackground: '#e6e2cc', + selectionForeground: '#5c6a72', + black: '#5c6a72', + red: '#f85552', + green: '#8da101', + yellow: '#dfa000', + blue: '#3a94c5', + magenta: '#df69ba', + cyan: '#35a77c', + white: '#dfd9a8', + brightBlack: '#939f91', + brightRed: '#f85552', + brightGreen: '#8da101', + brightYellow: '#dfa000', + brightBlue: '#3a94c5', + brightMagenta: '#df69ba', + brightCyan: '#35a77c', + brightWhite: '#fdf6e3' + }, + + // ────────────────────────────────────────────── + // Classic + // ────────────────────────────────────────────── + + 'Tango Dark': { + background: '#000000', + foreground: '#d3d7cf', + cursor: '#d3d7cf', + cursorAccent: '#000000', + selectionBackground: '#555753', + selectionForeground: '#eeeeec', + black: '#2e3436', + red: '#cc0000', + green: '#4e9a06', + yellow: '#c4a000', + blue: '#3465a4', + magenta: '#75507b', + cyan: '#06989a', + white: '#d3d7cf', + brightBlack: '#555753', + brightRed: '#ef2929', + brightGreen: '#8ae234', + brightYellow: '#fce94f', + brightBlue: '#729fcf', + brightMagenta: '#ad7fa8', + brightCyan: '#34e2e2', + brightWhite: '#eeeeec' + }, + + Homebrew: { + background: '#000000', + foreground: '#00ff00', + cursor: '#00ff00', + cursorAccent: '#000000', + selectionBackground: '#005500', + selectionForeground: '#00ff00', + black: '#000000', + red: '#990000', + green: '#00a600', + yellow: '#999900', + blue: '#0000b2', + magenta: '#b200b2', + cyan: '#00a6b2', + white: '#bfbfbf', + brightBlack: '#666666', + brightRed: '#e50000', + brightGreen: '#00d900', + brightYellow: '#e5e500', + brightBlue: '#0000ff', + brightMagenta: '#e500e5', + brightCyan: '#00e5e5', + brightWhite: '#e5e5e5' + }, + + Snazzy: { + background: '#282a36', + foreground: '#eff0eb', + cursor: '#97979b', + cursorAccent: '#282a36', + selectionBackground: '#3e404a', + selectionForeground: '#eff0eb', + black: '#282a36', + red: '#ff5c57', + green: '#5af78e', + yellow: '#f3f99d', + blue: '#57c7ff', + magenta: '#ff6ac1', + cyan: '#9aedfe', + white: '#f1f1f0', + brightBlack: '#686868', + brightRed: '#ff5c57', + brightGreen: '#5af78e', + brightYellow: '#f3f99d', + brightBlue: '#57c7ff', + brightMagenta: '#ff6ac1', + brightCyan: '#9aedfe', + brightWhite: '#f1f1f0' + } +} + +export function getThemeNames(): string[] { + return Object.keys(TERMINAL_THEMES).sort() +} + +export function getTheme(name: string): ITheme | null { + return TERMINAL_THEMES[name] ?? null +}