This commit is contained in:
Neil
2026-03-21 11:34:11 -07:00
parent 76d884a835
commit a3ef3a59d5
9 changed files with 1943 additions and 273 deletions
+6 -1
View File
@@ -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",
+48 -23
View File
@@ -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
+26 -8
View File
@@ -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)
+289 -208
View File
@@ -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<string, number> {
const paneByLeafId = new Map<string, number>()
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<HTMLDivElement>(null)
const resttyRef = useRef<Restty | null>(null)
const managerRef = useRef<PaneManager | null>(null)
const contextPaneIdRef = useRef<number | null>(null)
const wasActiveRef = useRef(false)
const paneFontSizesRef = useRef<Map<number, number>>(new Map())
@@ -396,9 +387,12 @@ export default function TerminalPane({
const expandedStyleSnapshotRef = useRef<Map<HTMLElement, { display: string; flex: string }>>(
new Map()
)
// Track transports per pane for PTY communication
const paneTransportsRef = useRef<Map<number, PtyTransport>>(new Map())
const [terminalMenuOpen, setTerminalMenuOpen] = useState(false)
const [terminalMenuPoint, setTerminalMenuPoint] = useState({ x: 0, y: 0 })
const [expandedPaneId, setExpandedPaneId] = useState<number | null>(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<void> => {
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<void> => {
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 (
<>
<div
@@ -1048,8 +1124,8 @@ export default function TerminalPane({
event.preventDefault()
window.dispatchEvent(new Event(CLOSE_ALL_CONTEXT_MENUS_EVENT))
const restty = resttyRef.current
if (!restty) {
const manager = managerRef.current
if (!manager) {
contextPaneIdRef.current = null
return
}
@@ -1060,7 +1136,7 @@ export default function TerminalPane({
return
}
const clickedPane =
restty.getPanes().find((pane) => 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)
}}
/>
<TerminalSearch
isOpen={searchOpen}
onClose={() => setSearchOpen(false)}
searchAddon={activeSearchAddon}
/>
<DropdownMenu open={terminalMenuOpen} onOpenChange={setTerminalMenuOpen} modal={false}>
<DropdownMenuTrigger asChild>
<button
@@ -0,0 +1,137 @@
import { useEffect, useRef, useState, useCallback } from 'react'
import { ChevronUp, ChevronDown, X, CaseSensitive, Regex } from 'lucide-react'
import type { SearchAddon } from '@xterm/addon-search'
interface TerminalSearchProps {
isOpen: boolean
onClose: () => void
searchAddon: SearchAddon | null
}
export default function TerminalSearch({
isOpen,
onClose,
searchAddon
}: TerminalSearchProps): React.JSX.Element | null {
const inputRef = useRef<HTMLInputElement>(null)
const [query, setQuery] = useState('')
const [caseSensitive, setCaseSensitive] = useState(false)
const [regex, setRegex] = useState(false)
const searchOptions = useCallback(
(incremental = false) => ({ caseSensitive, regex, incremental }),
[caseSensitive, regex]
)
const findNext = useCallback(() => {
if (searchAddon && query) {
searchAddon.findNext(query, searchOptions())
}
}, [searchAddon, query, searchOptions])
const findPrevious = useCallback(() => {
if (searchAddon && query) {
searchAddon.findPrevious(query, searchOptions())
}
}, [searchAddon, query, searchOptions])
useEffect(() => {
if (isOpen) {
inputRef.current?.focus()
} else {
searchAddon?.clearDecorations()
}
}, [isOpen, searchAddon])
useEffect(() => {
if (!query) {
searchAddon?.clearDecorations()
return
}
if (searchAddon && isOpen) {
searchAddon.findNext(query, searchOptions(true))
}
}, [query, searchAddon, isOpen, searchOptions])
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
e.stopPropagation()
if (e.key === 'Escape') {
onClose()
} else if (e.key === 'Enter' && e.shiftKey) {
findPrevious()
} else if (e.key === 'Enter') {
findNext()
}
},
[onClose, findNext, findPrevious]
)
if (!isOpen) return null
return (
<div
className="absolute top-2 right-2 z-50 flex items-center gap-1 rounded-lg border border-zinc-700 bg-zinc-800/95 px-2 py-1 shadow-lg backdrop-blur-sm"
style={{ width: 300 }}
onKeyDown={handleKeyDown}
>
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search..."
className="min-w-0 flex-1 border-none bg-transparent text-sm text-white outline-none placeholder:text-zinc-500"
/>
<button
onClick={() => setCaseSensitive((v) => !v)}
className={`flex size-6 shrink-0 items-center justify-center rounded ${
caseSensitive ? 'bg-zinc-700/50 text-blue-400' : 'text-zinc-400 hover:text-zinc-200'
}`}
title="Case sensitive"
>
<CaseSensitive size={14} />
</button>
<button
onClick={() => setRegex((v) => !v)}
className={`flex size-6 shrink-0 items-center justify-center rounded ${
regex ? 'bg-zinc-700/50 text-blue-400' : 'text-zinc-400 hover:text-zinc-200'
}`}
title="Regex"
>
<Regex size={14} />
</button>
<div className="mx-0.5 h-4 w-px bg-zinc-700" />
<button
onClick={findPrevious}
className="flex size-6 shrink-0 items-center justify-center rounded text-zinc-400 hover:text-zinc-200"
title="Previous match"
>
<ChevronUp size={14} />
</button>
<button
onClick={findNext}
className="flex size-6 shrink-0 items-center justify-center rounded text-zinc-400 hover:text-zinc-200"
title="Next match"
>
<ChevronDown size={14} />
</button>
<div className="mx-0.5 h-4 w-px bg-zinc-700" />
<button
onClick={onClose}
className="flex size-6 shrink-0 items-center justify-center rounded text-zinc-400 hover:text-zinc-200"
title="Close"
>
<X size={14} />
</button>
</div>
)
}
@@ -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 (
+615
View File
@@ -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<void>
onPaneClosed?: (paneId: number) => void
onActivePaneChange?: (pane: ManagedPane) => void
onLayoutChanged?: () => void
terminalOptions?: (paneId: number) => Partial<ITerminalOptions>
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<number, ManagedPaneInternal> = 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
}
}
}
+36 -15
View File
@@ -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
}
@@ -0,0 +1,777 @@
import type { ITheme } from '@xterm/xterm'
export const TERMINAL_THEMES: Record<string, ITheme> = {
// ──────────────────────────────────────────────
// 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
}