mirror of
https://github.com/stablyai/orca.git
synced 2026-10-27 00:02:48 +00:00
xterm.js has no bold-color theme slot (upstream xtermjs/xterm.js#6032), so the Bold Text override was silently dropped — a false affordance. Makes the Settings copy and the Ghostty importer honest that it isn't applied yet, while preserving any persisted value for a future real implementation. No terminal-rendering change. Closes #8595
342 lines
9.6 KiB
TypeScript
342 lines
9.6 KiB
TypeScript
import type {
|
|
GlobalSettings,
|
|
TerminalColorOverrides,
|
|
GhosttyImportPreview
|
|
} from '../../shared/types'
|
|
import { HEX_COLOR_RE } from '../../shared/color-validation'
|
|
import { parsePaddingValue, parseStrictInt } from './numeric-config-values'
|
|
|
|
const PALETTE_INDEX_MAP: Record<number, keyof TerminalColorOverrides> = {
|
|
0: 'black',
|
|
1: 'red',
|
|
2: 'green',
|
|
3: 'yellow',
|
|
4: 'blue',
|
|
5: 'magenta',
|
|
6: 'cyan',
|
|
7: 'white',
|
|
8: 'brightBlack',
|
|
9: 'brightRed',
|
|
10: 'brightGreen',
|
|
11: 'brightYellow',
|
|
12: 'brightBlue',
|
|
13: 'brightMagenta',
|
|
14: 'brightCyan',
|
|
15: 'brightWhite'
|
|
}
|
|
|
|
type FieldAssignment = { key: keyof GlobalSettings; value: unknown }
|
|
|
|
type FieldResult =
|
|
| FieldAssignment
|
|
| FieldAssignment[]
|
|
| { colorOverrides: Partial<TerminalColorOverrides> }
|
|
| null
|
|
|
|
const normalizeHex = (v: string): string => (v.startsWith('#') ? v : `#${v}`)
|
|
|
|
type FieldParser = (value: string, rawValue: string | string[]) => FieldResult
|
|
|
|
export function mapGhosttyToOrca(
|
|
parsed: Record<string, string | string[]>,
|
|
isMacOS = process.platform === 'darwin'
|
|
): {
|
|
diff: Partial<GlobalSettings>
|
|
unsupportedKeys: string[]
|
|
} {
|
|
const diff: Partial<GlobalSettings> = {}
|
|
const unsupportedKeys: string[] = []
|
|
const colorOverrides: TerminalColorOverrides = {}
|
|
|
|
const FIELD_PARSERS: Record<string, FieldParser> = {
|
|
'macos-option-as-alt': (v) => {
|
|
if (!isMacOS) {
|
|
return null
|
|
}
|
|
if (v === 'true' || v === 'on') {
|
|
return { key: 'terminalMacOptionAsAlt', value: 'true' }
|
|
}
|
|
if (v === 'false' || v === 'off') {
|
|
return { key: 'terminalMacOptionAsAlt', value: 'false' }
|
|
}
|
|
if (v === 'left' || v === 'right') {
|
|
return { key: 'terminalMacOptionAsAlt', value: v }
|
|
}
|
|
return null
|
|
},
|
|
|
|
'background-opacity': (v) => {
|
|
const num = Number(v)
|
|
if (!Number.isFinite(num) || num < 0 || num > 1) {
|
|
return null
|
|
}
|
|
return { key: 'terminalBackgroundOpacity', value: num }
|
|
},
|
|
|
|
background: (v) => {
|
|
if (!HEX_COLOR_RE.test(v)) {
|
|
return null
|
|
}
|
|
return { colorOverrides: { background: normalizeHex(v) } }
|
|
},
|
|
|
|
foreground: (v) => {
|
|
if (!HEX_COLOR_RE.test(v)) {
|
|
return null
|
|
}
|
|
return { colorOverrides: { foreground: normalizeHex(v) } }
|
|
},
|
|
|
|
'cursor-color': (v) => {
|
|
if (!HEX_COLOR_RE.test(v)) {
|
|
return null
|
|
}
|
|
return { colorOverrides: { cursor: normalizeHex(v) } }
|
|
},
|
|
|
|
'selection-background': (v) => {
|
|
if (!HEX_COLOR_RE.test(v)) {
|
|
return null
|
|
}
|
|
return { colorOverrides: { selectionBackground: normalizeHex(v) } }
|
|
},
|
|
|
|
'selection-foreground': (v) => {
|
|
if (!HEX_COLOR_RE.test(v)) {
|
|
return null
|
|
}
|
|
return { colorOverrides: { selectionForeground: normalizeHex(v) } }
|
|
},
|
|
|
|
palette: (_v, rawValue) => {
|
|
const entries = Array.isArray(rawValue) ? rawValue : [rawValue]
|
|
const overrides: Partial<TerminalColorOverrides> = {}
|
|
for (const entry of entries) {
|
|
const eqIdx = entry.indexOf('=')
|
|
if (eqIdx === -1) {
|
|
continue
|
|
}
|
|
const idxStr = entry.slice(0, eqIdx).trim()
|
|
const color = entry.slice(eqIdx + 1).trim()
|
|
const index = Number.parseInt(idxStr, 10)
|
|
if (Number.isNaN(index) || !HEX_COLOR_RE.test(color)) {
|
|
continue
|
|
}
|
|
const mapped = PALETTE_INDEX_MAP[index]
|
|
if (mapped) {
|
|
overrides[mapped] = normalizeHex(color)
|
|
}
|
|
}
|
|
if (Object.keys(overrides).length === 0 && entries.length > 0) {
|
|
return null
|
|
}
|
|
return { colorOverrides: overrides }
|
|
},
|
|
|
|
'background-blur-radius': (v) => {
|
|
const num = parseStrictInt(v)
|
|
if (num === null || num < 0) {
|
|
return null
|
|
}
|
|
return { key: 'windowBackgroundBlur', value: num > 0 }
|
|
},
|
|
|
|
'split-divider-color': (v) => {
|
|
if (!HEX_COLOR_RE.test(v)) {
|
|
return null
|
|
}
|
|
return [
|
|
{ key: 'terminalDividerColorDark', value: normalizeHex(v) },
|
|
{ key: 'terminalDividerColorLight', value: normalizeHex(v) }
|
|
]
|
|
},
|
|
|
|
'unfocused-split-opacity': (v) => {
|
|
const num = Number(v)
|
|
if (!Number.isFinite(num) || num < 0 || num > 1) {
|
|
return null
|
|
}
|
|
return { key: 'terminalInactivePaneOpacity', value: num }
|
|
},
|
|
|
|
'window-padding-x': (v) => {
|
|
const num = parsePaddingValue(v)
|
|
if (num === null) {
|
|
return null
|
|
}
|
|
return { key: 'terminalPaddingX', value: num }
|
|
},
|
|
|
|
'window-padding-y': (v) => {
|
|
const num = parsePaddingValue(v)
|
|
if (num === null) {
|
|
return null
|
|
}
|
|
return { key: 'terminalPaddingY', value: num }
|
|
},
|
|
|
|
// Why: only percentages translate to xterm's line-height ratio; Ghostty's
|
|
// pixel form depends on the rendered cell height, which we can't know here.
|
|
// The settings UI clamps terminalLineHeight to [1, 3], so reject outside it.
|
|
'adjust-cell-height': (v) => {
|
|
const match = /^\+?(\d+(?:\.\d+)?)%$/.exec(v.trim())
|
|
if (!match) {
|
|
return null
|
|
}
|
|
const percent = Number(match[1])
|
|
const rawLineHeight = 1 + percent / 100
|
|
if (rawLineHeight > 3) {
|
|
return null
|
|
}
|
|
const lineHeight = Math.round(100 + percent) / 100
|
|
return { key: 'terminalLineHeight', value: lineHeight }
|
|
},
|
|
|
|
'cursor-text': (v) => {
|
|
if (!HEX_COLOR_RE.test(v)) {
|
|
return null
|
|
}
|
|
return { colorOverrides: { cursorAccent: normalizeHex(v) } }
|
|
},
|
|
|
|
'mouse-hide-while-typing': (v) => {
|
|
if (v !== 'true' && v !== 'false') {
|
|
return null
|
|
}
|
|
return { key: 'terminalMouseHideWhileTyping', value: v === 'true' }
|
|
},
|
|
|
|
'cursor-opacity': (v) => {
|
|
const num = Number(v)
|
|
if (!Number.isFinite(num) || num < 0 || num > 1) {
|
|
return null
|
|
}
|
|
return { key: 'terminalCursorOpacity', value: num }
|
|
},
|
|
|
|
'font-family': (v) => {
|
|
if (typeof v !== 'string' || v.trim().length === 0) {
|
|
return null
|
|
}
|
|
return { key: 'terminalFontFamily', value: v }
|
|
},
|
|
|
|
'font-size': (v) => {
|
|
const num = Number(v)
|
|
if (!Number.isFinite(num) || num <= 0) {
|
|
return null
|
|
}
|
|
return { key: 'terminalFontSize', value: num }
|
|
},
|
|
|
|
'font-weight': (v) => {
|
|
const num = Number(v)
|
|
if (!Number.isFinite(num) || num < 100 || num > 900) {
|
|
return null
|
|
}
|
|
return { key: 'terminalFontWeight', value: num }
|
|
},
|
|
|
|
'cursor-style': (v) => {
|
|
if (v !== 'bar' && v !== 'block' && v !== 'underline') {
|
|
return null
|
|
}
|
|
return { key: 'terminalCursorStyle', value: v }
|
|
},
|
|
|
|
'cursor-style-blink': (v) => {
|
|
// Why: Ghostty uses 'true'/'false' strings for booleans; anything else
|
|
// is treated as unsupported rather than silently coerced.
|
|
if (v !== 'true' && v !== 'false') {
|
|
return null
|
|
}
|
|
return { key: 'terminalCursorBlink', value: v === 'true' }
|
|
},
|
|
|
|
'focus-follows-mouse': (v) => {
|
|
// Why: Ghostty's focus-follows-mouse is semantically identical to Orca's
|
|
// terminalFocusFollowsMouse — both control pointer-hover focus transfer.
|
|
if (v !== 'true' && v !== 'false') {
|
|
return null
|
|
}
|
|
return { key: 'terminalFocusFollowsMouse', value: v === 'true' }
|
|
},
|
|
|
|
'middle-click-action': (v) => {
|
|
if (v !== 'primary-paste' && v !== 'ignore') {
|
|
return null
|
|
}
|
|
return { key: 'primarySelectionMiddleClickPaste', value: v === 'primary-paste' }
|
|
}
|
|
}
|
|
|
|
for (const [key, rawValue] of Object.entries(parsed)) {
|
|
const value = Array.isArray(rawValue) ? (rawValue.at(-1) ?? '') : rawValue
|
|
|
|
// Why: Ghostty's selection-word-chars defines characters that ARE part of a
|
|
// word, while xterm.js wordSeparator defines characters that BREAK words.
|
|
// Passing the same string inverts the semantics, and correctly inverting a
|
|
// character set is non-trivial. Treat as unsupported to avoid silent misbehavior.
|
|
if (key === 'selection-word-chars') {
|
|
unsupportedKeys.push(key)
|
|
continue
|
|
}
|
|
|
|
// Why: xterm.js ITheme has no bold color slot (xtermjs/xterm.js#6032), so importing
|
|
// bold-color would render as a no-op; report it as unsupported instead of silently dropping.
|
|
if (key === 'bold-color') {
|
|
unsupportedKeys.push(key)
|
|
continue
|
|
}
|
|
|
|
if (!value.trim()) {
|
|
unsupportedKeys.push(key)
|
|
continue
|
|
}
|
|
|
|
const parser = FIELD_PARSERS[key]
|
|
if (!parser) {
|
|
unsupportedKeys.push(key)
|
|
continue
|
|
}
|
|
|
|
const result = parser(value, rawValue)
|
|
if (result === null) {
|
|
unsupportedKeys.push(key)
|
|
continue
|
|
}
|
|
|
|
// Why: Orca's windowBackgroundBlur is a boolean; the numeric radius is lost.
|
|
// Only note the drop when blur is actually being turned on — a `0` cleanly
|
|
// maps to `false` and there is no radius to lose.
|
|
if (
|
|
key === 'background-blur-radius' &&
|
|
!Array.isArray(result) &&
|
|
'key' in result &&
|
|
result.value === true
|
|
) {
|
|
unsupportedKeys.push('background-blur-radius (radius value not preserved)')
|
|
}
|
|
|
|
if (Array.isArray(result)) {
|
|
for (const entry of result) {
|
|
// Why: TypeScript's strict assignment checking for Partial<T>[K] requires
|
|
// a cast because GlobalSettings has no index signature.
|
|
diff[entry.key] = entry.value as never
|
|
}
|
|
} else if ('colorOverrides' in result) {
|
|
Object.assign(colorOverrides, result.colorOverrides)
|
|
} else {
|
|
diff[result.key] = result.value as never
|
|
}
|
|
}
|
|
|
|
if (Object.keys(colorOverrides).length > 0) {
|
|
diff.terminalColorOverrides = colorOverrides
|
|
}
|
|
|
|
return { diff, unsupportedKeys }
|
|
}
|
|
|
|
export type { GhosttyImportPreview }
|