feat(terminal): replace ghostty-web with an in-repo libghostty-vt adapter
The terminal ran on the ghostty-web npm package plus a hand-written patch, and every rendering bug (recycled rows, duplicated reflow fragments, prompt artifacts) had to be worked around from outside. The emulator now is the official libghostty-vt C ABI compiled to WebAssembly, driven by a browser adapter ported from T3 Code (MIT, notice in LICENSE-T3CODE) and owned in packages/ui/src/lib/ghostty. The artifact is reproducible with scripts/build-libghostty-wasm.sh, including a workaround for Zig 0.15.2 on macOS 27 SDKs. On top of the port: one WASM instance per page with every tab kept mounted and hidden tabs paused; history replayed at the PTY size it was drawn for; shells spawned only after the first fitted grid so zsh never prints the PROMPT_SP marker; box drawing, block elements and Powerline arrows drawn procedurally to the exact cell so TUI borders and block logos have no gaps between rows; a software-rasterized canvas so Gecko renders every tab's text with the same smoothing; the symbols-only Nerd Font bundled instead of a CDN fetch; touch selection and scrolling driven through the surface API; a copy button in the tab strip for touch hosts; localized aria labels. Testing: bun tests run the real WASM (reflow, palette, replay isolation, recycled rows, box glyph geometry); viewport and view tests use a surface double; verified in Chromium and Zen (windowed and headless) for crisp text, new tabs, panel reopen, resize and box glyph rendering; package type-check, oxlint/eslint on new files, web build.
This commit is contained in:
@@ -0,0 +1,269 @@
|
||||
// Adapted from T3 Code's libghostty-vt browser adapter (MIT, T3 Tools Inc.).
|
||||
// See LICENSE-T3CODE in this directory.
|
||||
|
||||
// This order mirrors GhosttyKey in ghostty/vt/key/event.h. The values are
|
||||
// intentionally derived from the official W3C-aligned enum instead of
|
||||
// maintaining a second keyboard protocol.
|
||||
const ghosttyKeyboardCodes = [
|
||||
'Unidentified',
|
||||
'Backquote',
|
||||
'Backslash',
|
||||
'BracketLeft',
|
||||
'BracketRight',
|
||||
'Comma',
|
||||
'Digit0',
|
||||
'Digit1',
|
||||
'Digit2',
|
||||
'Digit3',
|
||||
'Digit4',
|
||||
'Digit5',
|
||||
'Digit6',
|
||||
'Digit7',
|
||||
'Digit8',
|
||||
'Digit9',
|
||||
'Equal',
|
||||
'IntlBackslash',
|
||||
'IntlRo',
|
||||
'IntlYen',
|
||||
'KeyA',
|
||||
'KeyB',
|
||||
'KeyC',
|
||||
'KeyD',
|
||||
'KeyE',
|
||||
'KeyF',
|
||||
'KeyG',
|
||||
'KeyH',
|
||||
'KeyI',
|
||||
'KeyJ',
|
||||
'KeyK',
|
||||
'KeyL',
|
||||
'KeyM',
|
||||
'KeyN',
|
||||
'KeyO',
|
||||
'KeyP',
|
||||
'KeyQ',
|
||||
'KeyR',
|
||||
'KeyS',
|
||||
'KeyT',
|
||||
'KeyU',
|
||||
'KeyV',
|
||||
'KeyW',
|
||||
'KeyX',
|
||||
'KeyY',
|
||||
'KeyZ',
|
||||
'Minus',
|
||||
'Period',
|
||||
'Quote',
|
||||
'Semicolon',
|
||||
'Slash',
|
||||
'AltLeft',
|
||||
'AltRight',
|
||||
'Backspace',
|
||||
'CapsLock',
|
||||
'ContextMenu',
|
||||
'ControlLeft',
|
||||
'ControlRight',
|
||||
'Enter',
|
||||
'MetaLeft',
|
||||
'MetaRight',
|
||||
'ShiftLeft',
|
||||
'ShiftRight',
|
||||
'Space',
|
||||
'Tab',
|
||||
'Convert',
|
||||
'KanaMode',
|
||||
'NonConvert',
|
||||
'Delete',
|
||||
'End',
|
||||
'Help',
|
||||
'Home',
|
||||
'Insert',
|
||||
'PageDown',
|
||||
'PageUp',
|
||||
'ArrowDown',
|
||||
'ArrowLeft',
|
||||
'ArrowRight',
|
||||
'ArrowUp',
|
||||
'NumLock',
|
||||
'Numpad0',
|
||||
'Numpad1',
|
||||
'Numpad2',
|
||||
'Numpad3',
|
||||
'Numpad4',
|
||||
'Numpad5',
|
||||
'Numpad6',
|
||||
'Numpad7',
|
||||
'Numpad8',
|
||||
'Numpad9',
|
||||
'NumpadAdd',
|
||||
'NumpadBackspace',
|
||||
'NumpadClear',
|
||||
'NumpadClearEntry',
|
||||
'NumpadComma',
|
||||
'NumpadDecimal',
|
||||
'NumpadDivide',
|
||||
'NumpadEnter',
|
||||
'NumpadEqual',
|
||||
'NumpadMemoryAdd',
|
||||
'NumpadMemoryClear',
|
||||
'NumpadMemoryRecall',
|
||||
'NumpadMemoryStore',
|
||||
'NumpadMemorySubtract',
|
||||
'NumpadMultiply',
|
||||
'NumpadParenLeft',
|
||||
'NumpadParenRight',
|
||||
'NumpadSubtract',
|
||||
'NumpadSeparator',
|
||||
'NumpadArrowUp',
|
||||
'NumpadArrowDown',
|
||||
'NumpadArrowRight',
|
||||
'NumpadArrowLeft',
|
||||
'NumpadBegin',
|
||||
'NumpadHome',
|
||||
'NumpadEnd',
|
||||
'NumpadInsert',
|
||||
'NumpadDelete',
|
||||
'NumpadPageUp',
|
||||
'NumpadPageDown',
|
||||
'Escape',
|
||||
'F1',
|
||||
'F2',
|
||||
'F3',
|
||||
'F4',
|
||||
'F5',
|
||||
'F6',
|
||||
'F7',
|
||||
'F8',
|
||||
'F9',
|
||||
'F10',
|
||||
'F11',
|
||||
'F12',
|
||||
'F13',
|
||||
'F14',
|
||||
'F15',
|
||||
'F16',
|
||||
'F17',
|
||||
'F18',
|
||||
'F19',
|
||||
'F20',
|
||||
'F21',
|
||||
'F22',
|
||||
'F23',
|
||||
'F24',
|
||||
'F25',
|
||||
'Fn',
|
||||
'FnLock',
|
||||
'PrintScreen',
|
||||
'ScrollLock',
|
||||
'Pause',
|
||||
'BrowserBack',
|
||||
'BrowserFavorites',
|
||||
'BrowserForward',
|
||||
'BrowserHome',
|
||||
'BrowserRefresh',
|
||||
'BrowserSearch',
|
||||
'BrowserStop',
|
||||
'Eject',
|
||||
'LaunchApp1',
|
||||
'LaunchApp2',
|
||||
'LaunchMail',
|
||||
'MediaPlayPause',
|
||||
'MediaSelect',
|
||||
'MediaStop',
|
||||
'MediaTrackNext',
|
||||
'MediaTrackPrevious',
|
||||
'Power',
|
||||
'Sleep',
|
||||
'AudioVolumeDown',
|
||||
'AudioVolumeMute',
|
||||
'AudioVolumeUp',
|
||||
'WakeUp',
|
||||
'Copy',
|
||||
'Cut',
|
||||
'Paste',
|
||||
] as const;
|
||||
|
||||
const codeToGhosttyKey = new Map<string, number>(
|
||||
ghosttyKeyboardCodes.map((code, index) => [code, index]),
|
||||
);
|
||||
|
||||
export function ghosttyKeyForCode(code: string): number {
|
||||
return codeToGhosttyKey.get(code) ?? 0;
|
||||
}
|
||||
|
||||
export interface GhosttyKeyboardLayoutMap {
|
||||
get(code: string): string | undefined;
|
||||
}
|
||||
|
||||
const shiftedToUnshiftedCharacter = new Map<string, string>([
|
||||
['!', '1'],
|
||||
['@', '2'],
|
||||
['#', '3'],
|
||||
['$', '4'],
|
||||
['%', '5'],
|
||||
['^', '6'],
|
||||
['&', '7'],
|
||||
['*', '8'],
|
||||
['(', '9'],
|
||||
[')', '0'],
|
||||
['~', '`'],
|
||||
['_', '-'],
|
||||
['+', '='],
|
||||
['{', '['],
|
||||
['}', ']'],
|
||||
['|', '\\'],
|
||||
[':', ';'],
|
||||
['"', "'"],
|
||||
['<', ','],
|
||||
['>', '.'],
|
||||
['?', '/'],
|
||||
]);
|
||||
|
||||
let keyboardLayoutMapPromise: Promise<GhosttyKeyboardLayoutMap | undefined> | undefined;
|
||||
|
||||
export function loadGhosttyKeyboardLayoutMap(): Promise<GhosttyKeyboardLayoutMap | undefined> {
|
||||
if (keyboardLayoutMapPromise) return keyboardLayoutMapPromise;
|
||||
// SAFETY: navigator.keyboard is Chromium-only and absent from lib.dom; the
|
||||
// optional field keeps every other engine on the undefined branch.
|
||||
const keyboard = (
|
||||
globalThis.navigator as Navigator & {
|
||||
readonly keyboard?: { getLayoutMap(): Promise<GhosttyKeyboardLayoutMap> };
|
||||
} | undefined
|
||||
)?.keyboard;
|
||||
const promise = keyboard?.getLayoutMap().catch(() => undefined) ?? Promise.resolve(undefined);
|
||||
keyboardLayoutMapPromise = promise;
|
||||
return promise;
|
||||
}
|
||||
|
||||
// Browsers do not expose consumed modifiers; treat Shift as consumed for
|
||||
// unchorded character input.
|
||||
export function ghosttyConsumedMods(
|
||||
event: Pick<KeyboardEvent, 'altKey' | 'ctrlKey' | 'key' | 'metaKey' | 'shiftKey'>,
|
||||
): number {
|
||||
if (!event.shiftKey || event.ctrlKey || event.altKey || event.metaKey) return 0;
|
||||
return [...event.key].length === 1 ? 1 : 0;
|
||||
}
|
||||
|
||||
export function ghosttyUnshiftedCodepoint(
|
||||
event: Pick<KeyboardEvent, 'code' | 'key' | 'shiftKey'>,
|
||||
layoutMap?: GhosttyKeyboardLayoutMap,
|
||||
): number {
|
||||
if ([...event.key].length !== 1) return 0;
|
||||
const layoutCharacter = layoutMap?.get(event.code);
|
||||
if (layoutCharacter && [...layoutCharacter].length === 1) {
|
||||
return layoutCharacter.codePointAt(0) ?? 0;
|
||||
}
|
||||
if (/^[A-Z]$/u.test(event.key)) return event.key.charCodeAt(0) + 32;
|
||||
if (event.shiftKey) {
|
||||
const unshiftedCharacter = shiftedToUnshiftedCharacter.get(event.key);
|
||||
if (unshiftedCharacter) return unshiftedCharacter.codePointAt(0) ?? 0;
|
||||
const lowercase = event.key.toLowerCase();
|
||||
if (lowercase !== event.key && [...lowercase].length === 1) {
|
||||
return lowercase.codePointAt(0) ?? 0;
|
||||
}
|
||||
// Without layout data the unshifted form of a shifted key is unknowable;
|
||||
// reporting the shifted character as unshifted corrupts Kitty alternate keys.
|
||||
return 0;
|
||||
}
|
||||
return event.key.codePointAt(0) ?? 0;
|
||||
}
|
||||
Reference in New Issue
Block a user