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:
Bohdan Triapitsyn
2026-09-07 12:18:22 +03:00
parent 3132d1361a
commit 39fa8c1917
61 changed files with 6678 additions and 990 deletions
@@ -0,0 +1,45 @@
# libghostty-vt Terminal Adapter
## Ownership
This directory is OpenChamber's browser adapter for the official `libghostty-vt` C ABI, adapted from T3 Code (see `LICENSE-T3CODE`). It replaces the `ghostty-web` npm package: the emulator, the renderer and the input layer are all owned here, so a terminal bug is fixed in this directory rather than patched around in `node_modules`.
- `runtime.ts` owns the single WebAssembly instance per page, the C struct layouts read from `ghostty_type_json`, allocation helpers, and the PTY write callback trampoline (embedded bytes compiled from `../../../scripts/ghostty-write-pty.zig`).
- `core.ts` owns one terminal's Ghostty handles: VT writes, resize, theme and 256-color palette, selection, key/mouse/paste encoding, and the render-state snapshot (`GhosttySnapshot`) the renderer draws.
- `renderer.ts` paints a snapshot into a Canvas 2D context: background runs, text runs, decorations, cursor. It measures the cell from the faces that will render.
- `surface.ts` owns the DOM: canvas, hidden textarea (keyboard, IME, clipboard), scrollbar, pointer selection, link hover/activation, mouse reporting, wheel scrolling, cursor blink, DPR changes, and the fit/notify cycle toward the PTY.
- `boxDrawing.ts` draws Box Drawing (U+2500U+257F), Block Elements (U+2580U+259F) and Powerline arrows (U+E0B0U+E0B3) procedurally to the exact cell; the renderer never sends those to the font.
- `keyCodes.ts` mirrors the `GhosttyKey` enum of the pinned revision. `terminalLinks.ts` matches URLs across soft-wrapped rows. `fonts.ts` normalizes family lists for the canvas font shorthand and probes for monospace advances.
- `vendor/` holds the reproducible artifact (`ghostty-vt.wasm`), the pinned upstream revision (`VERSION`) and Ghostty's license. `fonts/` vendors the symbols-only Nerd Font (MIT) so prompt glyphs render without a locally installed Nerd Font and without a CDN.
`components/terminal/TerminalViewport.tsx` is the only React consumer. React stays out of the render loop: the surface schedules its own frames.
## Invariants
- The grid is measured after the faces that will render are loaded (`document.fonts.load` for every style plus the bundled symbols font). A face that finishes loading later triggers a re-measure through `loadingdone`. Never size the grid from a fallback face on purpose.
- Generic keywords Chromium's canvas parser rejects (`ui-monospace`, `system-ui`) are stripped before any `context.font` assignment; an invalid shorthand silently no-ops and the grid would be measured with the previous font.
- The canvas context is created with `willReadFrequently: true`, which pins it to the software rasterizer. Gecko otherwise picks acceleration per canvas, and its GPU text path on macOS skips CoreText smoothing: a terminal created after page load drew thin, pencil-like glyphs while the first one stayed on the software path (confirmed: `gfx.canvas.accelerated=false` in Zen removed the symptom). The backing store is also sized to the mount at DPR before the first paint, so the compositor never sees the default 300×150 store stretched.
- Cell-filling symbols (borders, bars, block logos) are drawn by `boxDrawing.ts`, snapped to whole CSS pixels so neighbouring cells meet without seams. Fonts draw these only as tall as their em box, so at the 1.35 em line height every TUI border showed a strip of background between rows.
- The PTY hears about a resize only after the grid settles (150 ms) and at most once per fit; `onResize` is the sole resize channel. The first successful fit always notifies, even at the construction size.
- History replay (`resetAndWrite`) detaches the PTY writer so historical device queries never reach the live shell, and runs at the PTY size the history was drawn for when the caller passes one, so Ghostty reflows lines where the shell wrapped them.
- A hidden surface (`setVisible(false)`) keeps parsing output and answering VT queries but schedules no frames, no cursor timer and no scrollbar work. Reveal repaints in full.
- Touch hosts (`handleTouchPointer: false`) own scroll and long-press gestures through `scrollLines`, `selectWordAt` and `extendSelectionTo`; the surface ignores touch pointers and the compatibility mouse events that follow them so a tap does not summon the soft keyboard.
- Every terminal frees its own handles on `dispose()`; the WebAssembly instance is shared and never torn down.
## Updating libghostty-vt
1. Put the new upstream commit hash in `vendor/VERSION`.
2. Run `bun run --cwd packages/ui build:ghostty-wasm`. It downloads Zig 0.15.2 into `~/.cache/openchamber-ghostty`, clones Ghostty at the pin, builds `wasm32-freestanding`, replaces `vendor/ghostty-vt.wasm`, and prints the trampoline bytes for `runtime.ts` (they only change when the Zig source changes).
3. Reconcile the ABI numbers in `core.ts` (`RENDER_DATA`, `ROW_DATA`, `CELL_DATA`, option ids in `setTheme`, `ghostty_terminal_get` ids) and `keyCodes.ts` against the headers of the new revision.
4. `runtime.test.ts` fails when the artifact's embedded build metadata disagrees with `VERSION`.
On macOS 27 with Xcode 26+ SDKs the script works around a Zig 0.15.2 limitation: the SDK's `libSystem.tbd` lists only `arm64e-macos`, so the script builds a patched SDK root and shims `xcrun` for the duration of the build.
## Verification
```sh
bun test packages/ui/src/lib/ghostty packages/ui/src/components/terminal
bun run --cwd packages/ui type-check
```
The `core` and `runtime` tests run the real WebAssembly under bun; they cover reflow, palette, replay isolation and recycled-row cleanliness.
@@ -0,0 +1,25 @@
The libghostty-vt browser adapter in this directory (runtime.ts, core.ts,
renderer.ts, surface.ts, keyCodes.ts and their tests) is adapted from T3 Code,
https://github.com/pingdotgg/t3code, and carries its license:
MIT License
Copyright (c) 2026 T3 Tools Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,162 @@
import { describe, expect, test } from 'bun:test';
import { drawBoxDrawingGlyph, isBoxDrawingText, type BoxDrawingContext } from './boxDrawing';
type Call = readonly [string, ...number[]];
interface RecordingContext {
readonly context: BoxDrawingContext;
readonly calls: Call[];
readonly fills: string[];
}
function recordingContext(): RecordingContext {
const calls: Call[] = [];
const fills: string[] = [];
const context: BoxDrawingContext = {
fillStyle: '',
strokeStyle: '',
lineWidth: 1,
lineCap: 'butt',
fillRect: (x, y, w, h) => {
calls.push(['fillRect', x, y, w, h]);
fills.push(String(context.fillStyle));
},
beginPath: () => calls.push(['beginPath']),
moveTo: (x, y) => calls.push(['moveTo', x, y]),
lineTo: (x, y) => calls.push(['lineTo', x, y]),
quadraticCurveTo: (cpx, cpy, x, y) => calls.push(['quadraticCurveTo', cpx, cpy, x, y]),
closePath: () => calls.push(['closePath']),
fill: () => calls.push(['fill']),
stroke: () => calls.push(['stroke']),
};
return { context, calls, fills };
}
const white = { r: 255, g: 255, b: 255 };
// A 7.8 x 18 cell at a fractional x, like the real grid produces.
const cell = { x: 4 + 7.8 * 3, y: 4 + 18 * 2, width: 7.8, height: 18 };
describe('isBoxDrawingText', () => {
test('owns box drawing, block elements and powerline arrows only', () => {
expect(isBoxDrawingText('─')).toBe(true);
expect(isBoxDrawingText('╬')).toBe(true);
expect(isBoxDrawingText('▀')).toBe(true);
expect(isBoxDrawingText('░')).toBe(true);
expect(isBoxDrawingText('')).toBe(true);
expect(isBoxDrawingText('a')).toBe(false);
expect(isBoxDrawingText('')).toBe(false);
expect(isBoxDrawingText('')).toBe(false);
expect(isBoxDrawingText('──')).toBe(false);
});
});
describe('drawBoxDrawingGlyph', () => {
test('fills a full block over the whole rounded cell so stacked rows touch', () => {
const { context, calls } = recordingContext();
expect(drawBoxDrawingGlyph(context, '█', cell, white)).toBe(true);
// x: 27.4 -> 27, right: 35.2 -> 35; y: 40, bottom: 58.
expect(calls).toEqual([['fillRect', 27, 40, 8, 18]]);
});
test('splits the upper and lower half blocks at the shared middle pixel', () => {
const upper = recordingContext();
const lower = recordingContext();
drawBoxDrawingGlyph(upper.context, '▀', cell, white);
drawBoxDrawingGlyph(lower.context, '▄', cell, white);
expect(upper.calls).toEqual([['fillRect', 27, 40, 8, 9]]);
expect(lower.calls).toEqual([['fillRect', 27, 49, 8, 9]]);
});
test('shades with the foreground at partial alpha', () => {
const { context, fills } = recordingContext();
drawBoxDrawingGlyph(context, '▒', cell, white);
expect(fills).toEqual(['rgba(255, 255, 255, 0.5)']);
});
test('draws light lines edge to edge so neighbouring cells join without seams', () => {
const { context, calls } = recordingContext();
drawBoxDrawingGlyph(context, '─', cell, white);
// Each arm reaches the far edge of the one-pixel center band.
expect(calls).toEqual([
['fillRect', 27, 49, 5, 1],
['fillRect', 31, 49, 4, 1],
]);
const next = recordingContext();
drawBoxDrawingGlyph(next.context, '─', { ...cell, x: cell.x + cell.width }, white);
expect(next.calls[0]).toEqual(['fillRect', 35, 49, 5, 1]);
});
test('closes a light corner at the junction square without a stub', () => {
const { context, calls } = recordingContext();
drawBoxDrawingGlyph(context, '┌', cell, white);
expect(calls).toEqual([
['fillRect', 31, 49, 4, 1],
['fillRect', 31, 49, 1, 9],
]);
});
test('draws heavy arms three strokes thick', () => {
const { context, calls } = recordingContext();
drawBoxDrawingGlyph(context, '━', cell, white);
expect(calls).toEqual([
['fillRect', 27, 48, 5, 3],
['fillRect', 31, 48, 4, 3],
]);
});
test('nests the two lines of a double corner', () => {
const { context, calls } = recordingContext();
drawBoxDrawingGlyph(context, '╔', cell, white);
// Right arm: outer (top) line from the outer vertical line, inner (bottom)
// line from the inner vertical line. Down arm mirrors it.
expect(calls).toEqual([
['fillRect', 29, 47, 6, 1],
['fillRect', 33, 51, 2, 1],
['fillRect', 29, 47, 1, 11],
['fillRect', 33, 51, 1, 7],
]);
});
test('keeps a double cross open in the middle', () => {
const { context, calls } = recordingContext();
drawBoxDrawingGlyph(context, '╬', cell, white);
expect(calls).toEqual([
['fillRect', 27, 47, 2, 1],
['fillRect', 27, 51, 2, 1],
['fillRect', 33, 47, 2, 1],
['fillRect', 33, 51, 2, 1],
['fillRect', 29, 40, 1, 7],
['fillRect', 33, 40, 1, 7],
['fillRect', 29, 51, 1, 7],
['fillRect', 33, 51, 1, 7],
]);
});
test('strokes arcs, diagonals and outline arrows and fills solid arrows', () => {
const arc = recordingContext();
drawBoxDrawingGlyph(arc.context, '╭', cell, white);
expect(arc.calls.map(([name]) => name)).toEqual(['beginPath', 'moveTo', 'lineTo', 'quadraticCurveTo', 'lineTo', 'stroke']);
const diagonal = recordingContext();
drawBoxDrawingGlyph(diagonal.context, '', cell, white);
expect(diagonal.calls.filter(([name]) => name === 'moveTo')).toHaveLength(2);
const solid = recordingContext();
drawBoxDrawingGlyph(solid.context, '', cell, white);
expect(solid.calls.at(-1)).toEqual(['fill']);
const outline = recordingContext();
drawBoxDrawingGlyph(outline.context, '', cell, white);
expect(outline.calls.at(-1)).toEqual(['stroke']);
});
test('splits dashed lines into their dash count', () => {
const { context, calls } = recordingContext();
drawBoxDrawingGlyph(context, '┈', cell, white);
expect(calls).toHaveLength(4);
});
test('leaves other text to the font', () => {
const { context, calls } = recordingContext();
expect(drawBoxDrawingGlyph(context, 'a', cell, white)).toBe(false);
expect(calls).toEqual([]);
});
});
+367
View File
@@ -0,0 +1,367 @@
import type { GhosttyColor } from './core';
/**
* Procedural glyphs for the cell-filling symbols TUI apps draw borders and
* bars with: Box Drawing (U+2500U+257F), Block Elements (U+2580U+259F) and
* the Powerline arrows (U+E0B0U+E0B3). A font draws these only as tall as
* its own em box, so at the terminal's 1.35 em line height every border and
* every logo built from block characters shows a strip of background between
* rows. Native terminals draw them to the exact cell instead; so does this.
*/
export interface BoxDrawingContext {
fillStyle: string | CanvasGradient | CanvasPattern;
strokeStyle: string | CanvasGradient | CanvasPattern;
lineWidth: number;
lineCap: CanvasLineCap;
fillRect(x: number, y: number, w: number, h: number): void;
beginPath(): void;
moveTo(x: number, y: number): void;
lineTo(x: number, y: number): void;
quadraticCurveTo(cpx: number, cpy: number, x: number, y: number): void;
closePath(): void;
fill(): void;
stroke(): void;
}
export interface BoxDrawingCell {
readonly x: number;
readonly y: number;
readonly width: number;
readonly height: number;
}
const BOX_DRAWING_FIRST = 0x2500;
const BOX_DRAWING_LAST = 0x257f;
const BLOCK_FIRST = 0x2580;
const BLOCK_LAST = 0x259f;
const POWERLINE_FIRST = 0xe0b0;
const POWERLINE_LAST = 0xe0b3;
/** Arm weights, one digit each for up, down, left, right; 0 is no arm. */
const LIGHT = 1;
const HEAVY = 2;
const DOUBLE = 3;
// Up/Down/Left/Right weights for U+2500..U+257F. Dashed, arc and diagonal
// forms carry their solid equivalent here and are special-cased when drawn.
const BOX_ARMS =
'0011 0022 1100 2200 0011 0022 1100 2200 0011 0022 1100 2200' + // 2500-250B lines and dashes
' 0101 0102 0201 0202 0110 0120 0210 0220 1001 1002 2001 2002 1010 1020 2010 2020' + // 250C-251B corners
' 1101 1102 2101 1201 2201 2102 1202 2202' + // 251C-2523 left tees
' 1110 1120 2110 1210 2210 2120 1220 2220' + // 2524-252B right tees
' 0111 0121 0112 0122 0211 0221 0212 0222' + // 252C-2533 top tees
' 1011 1021 1012 1022 2011 2021 2012 2022' + // 2534-253B bottom tees
' 1111 1121 1112 1122 2111 1211 2211 2121 2112 1221 1212 2122 1222 2221 2212 2222' + // 253C-254B crosses
' 0011 0022 1100 2200' + // 254C-254F double dashes
' 0033 3300' + // 2550-2551 double lines
' 0103 0301 0303 0130 0310 0330 1003 3001 3003 1030 3010 3030' + // 2552-255D double corners
' 1103 3301 3303 1130 3310 3330 0133 0311 0333 1033 3011 3033 1133 3311 3333' + // 255E-256C double tees and cross
' 0101 0110 1010 1001' + // 256D-2570 arcs (drawn as curves)
' 0000 0000 0000' + // 2571-2573 diagonals (drawn as lines)
' 0010 1000 0001 0100 0020 2000 0002 0200' + // 2574-257B half lines
' 0012 1200 0021 2100'; // 257C-257F mixed half lines
const BOX_ARM_TABLE = BOX_ARMS.split(' ');
const TRIPLE_DASH = new Set([0x2504, 0x2505, 0x2506, 0x2507]);
const QUAD_DASH = new Set([0x2508, 0x2509, 0x250a, 0x250b]);
const DOUBLE_DASH = new Set([0x254c, 0x254d, 0x254e, 0x254f]);
// Block elements as unit rectangles (left, top, width, height) of the cell.
const BLOCK_RECTS = new Map<number, readonly (readonly [number, number, number, number])[]>([
[0x2580, [[0, 0, 1, 1 / 2]]],
[0x2581, [[0, 7 / 8, 1, 1 / 8]]],
[0x2582, [[0, 6 / 8, 1, 2 / 8]]],
[0x2583, [[0, 5 / 8, 1, 3 / 8]]],
[0x2584, [[0, 1 / 2, 1, 1 / 2]]],
[0x2585, [[0, 3 / 8, 1, 5 / 8]]],
[0x2586, [[0, 2 / 8, 1, 6 / 8]]],
[0x2587, [[0, 1 / 8, 1, 7 / 8]]],
[0x2588, [[0, 0, 1, 1]]],
[0x2589, [[0, 0, 7 / 8, 1]]],
[0x258a, [[0, 0, 6 / 8, 1]]],
[0x258b, [[0, 0, 5 / 8, 1]]],
[0x258c, [[0, 0, 1 / 2, 1]]],
[0x258d, [[0, 0, 3 / 8, 1]]],
[0x258e, [[0, 0, 2 / 8, 1]]],
[0x258f, [[0, 0, 1 / 8, 1]]],
[0x2590, [[1 / 2, 0, 1 / 2, 1]]],
[0x2594, [[0, 0, 1, 1 / 8]]],
[0x2595, [[7 / 8, 0, 1 / 8, 1]]],
[0x2596, [[0, 1 / 2, 1 / 2, 1 / 2]]],
[0x2597, [[1 / 2, 1 / 2, 1 / 2, 1 / 2]]],
[0x2598, [[0, 0, 1 / 2, 1 / 2]]],
[0x2599, [[0, 0, 1 / 2, 1], [1 / 2, 1 / 2, 1 / 2, 1 / 2]]],
[0x259a, [[0, 0, 1 / 2, 1 / 2], [1 / 2, 1 / 2, 1 / 2, 1 / 2]]],
[0x259b, [[0, 0, 1, 1 / 2], [0, 1 / 2, 1 / 2, 1 / 2]]],
[0x259c, [[0, 0, 1, 1 / 2], [1 / 2, 1 / 2, 1 / 2, 1 / 2]]],
[0x259d, [[1 / 2, 0, 1 / 2, 1 / 2]]],
[0x259e, [[1 / 2, 0, 1 / 2, 1 / 2], [0, 1 / 2, 1 / 2, 1 / 2]]],
[0x259f, [[1 / 2, 0, 1 / 2, 1 / 2], [0, 1 / 2, 1, 1 / 2]]],
]);
const SHADE_ALPHA = new Map([[0x2591, 0.25], [0x2592, 0.5], [0x2593, 0.75]]);
/** Whether a cell's text is a single symbol this module draws instead of the font. */
export function isBoxDrawingText(text: string): boolean {
if (text.length === 0 || text.length > 2) return false;
const code = text.codePointAt(0);
if (code === undefined || String.fromCodePoint(code) !== text) return false;
return (
(code >= BOX_DRAWING_FIRST && code <= BOX_DRAWING_LAST) ||
(code >= BLOCK_FIRST && code <= BLOCK_LAST) ||
(code >= POWERLINE_FIRST && code <= POWERLINE_LAST)
);
}
function rgba(color: GhosttyColor, alpha: number): string {
return `rgba(${color.r}, ${color.g}, ${color.b}, ${alpha})`;
}
interface CellGeometry {
readonly left: number;
readonly right: number;
readonly top: number;
readonly bottom: number;
readonly centerX: number;
readonly centerY: number;
/** Light stroke thickness. */
readonly stroke: number;
/** Half-distance between the two lines of a double stroke. */
readonly gap: number;
}
// Everything snaps to whole CSS pixels. Neighbouring cells round the same
// shared edge to the same value, so borders meet without seams, and a whole
// pixel stays crisp at every integer device pixel ratio.
function cellGeometry(cell: BoxDrawingCell): CellGeometry {
const left = Math.round(cell.x);
const right = Math.round(cell.x + cell.width);
const top = Math.round(cell.y);
const bottom = Math.round(cell.y + cell.height);
const stroke = Math.max(1, Math.round(cell.width / 8));
return {
left,
right,
top,
bottom,
centerX: Math.round(cell.x + cell.width / 2),
centerY: Math.round(cell.y + cell.height / 2),
stroke,
gap: stroke + 1,
};
}
function armThickness(weight: number, stroke: number): number {
return weight === HEAVY ? stroke * 3 : stroke;
}
/** Fill a horizontal band [x0, x1) centered on y with the given thickness. */
function hBand(context: BoxDrawingContext, x0: number, x1: number, y: number, thickness: number): void {
if (x1 <= x0) return;
context.fillRect(x0, y - Math.floor(thickness / 2), x1 - x0, thickness);
}
function vBand(context: BoxDrawingContext, y0: number, y1: number, x: number, thickness: number): void {
if (y1 <= y0) return;
context.fillRect(x - Math.floor(thickness / 2), y0, thickness, y1 - y0);
}
function drawDashes(
context: BoxDrawingContext,
geometry: CellGeometry,
horizontal: boolean,
weight: number,
count: number,
): void {
const thickness = armThickness(weight, geometry.stroke);
const start = horizontal ? geometry.left : geometry.top;
const end = horizontal ? geometry.right : geometry.bottom;
const gapSize = geometry.stroke;
const span = end - start;
const dash = Math.max(1, Math.floor((span - gapSize * (count - 1)) / count));
for (let index = 0; index < count; index += 1) {
const from = start + index * (dash + gapSize);
const to = index === count - 1 ? end : from + dash;
if (horizontal) hBand(context, from, to, geometry.centerY, thickness);
else vBand(context, from, to, geometry.centerX, thickness);
}
}
function drawArms(context: BoxDrawingContext, geometry: CellGeometry, arms: string): void {
const up = Number(arms[0]);
const down = Number(arms[1]);
const left = Number(arms[2]);
const right = Number(arms[3]);
const { centerX, centerY, stroke, gap } = geometry;
const single = (weight: number) => weight === LIGHT || weight === HEAVY;
// A single-weight arm runs to the far edge of the thickest crossing arm's
// band, so corners and tees fill their junction square exactly, without a
// hole and without a stub past the perpendicular line.
const crossV = Math.max(armThickness(up, stroke), armThickness(down, stroke), stroke);
const crossH = Math.max(armThickness(left, stroke), armThickness(right, stroke), stroke);
const bandLeft = centerX - Math.floor(crossV / 2);
const bandRight = bandLeft + crossV;
const bandTop = centerY - Math.floor(crossH / 2);
const bandBottom = bandTop + crossH;
const verticalDouble = up === DOUBLE || down === DOUBLE;
const horizontalDouble = left === DOUBLE || right === DOUBLE;
if (single(left)) {
hBand(context, geometry.left, verticalDouble ? centerX - gap : bandRight, centerY, armThickness(left, stroke));
}
if (single(right)) {
hBand(context, verticalDouble ? centerX + gap : bandLeft, geometry.right, centerY, armThickness(right, stroke));
}
if (single(up)) {
vBand(context, geometry.top, horizontalDouble ? centerY - gap : bandBottom, centerX, armThickness(up, stroke));
}
if (single(down)) {
vBand(context, horizontalDouble ? centerY + gap : bandTop, geometry.bottom, centerX, armThickness(down, stroke));
}
// A double arm is two light lines. Where a line meets the perpendicular arm
// on its own side it stops at that arm's matching line (a double arm), at
// the center (a single arm), or crosses to the far line to close a corner
// or run straight through (no arm). `sign` is +1 toward the far side.
const stopX = (perpendicular: number, sign: 1 | -1) =>
perpendicular === DOUBLE ? centerX - sign * gap : single(perpendicular) ? centerX : centerX + sign * gap;
const stopY = (perpendicular: number, sign: 1 | -1) =>
perpendicular === DOUBLE ? centerY - sign * gap : single(perpendicular) ? centerY : centerY + sign * gap;
if (left === DOUBLE) {
hBand(context, geometry.left, stopX(up, 1), centerY - gap, stroke);
hBand(context, geometry.left, stopX(down, 1), centerY + gap, stroke);
}
if (right === DOUBLE) {
hBand(context, stopX(up, -1), geometry.right, centerY - gap, stroke);
hBand(context, stopX(down, -1), geometry.right, centerY + gap, stroke);
}
if (up === DOUBLE) {
vBand(context, geometry.top, stopY(left, 1), centerX - gap, stroke);
vBand(context, geometry.top, stopY(right, 1), centerX + gap, stroke);
}
if (down === DOUBLE) {
vBand(context, stopY(left, -1), geometry.bottom, centerX - gap, stroke);
vBand(context, stopY(right, -1), geometry.bottom, centerX + gap, stroke);
}
}
function drawArc(context: BoxDrawingContext, geometry: CellGeometry, code: number): void {
const { centerX, centerY, stroke } = geometry;
// 256D ╭ down+right, 256E ╮ down+left, 256F ╯ up+left, 2570 ╰ up+right
const toRight = code === 0x256d || code === 0x2570;
const toDown = code === 0x256d || code === 0x256e;
const endX = toRight ? geometry.right : geometry.left;
const endY = toDown ? geometry.bottom : geometry.top;
const radius = Math.min(Math.abs(endX - centerX), Math.abs(endY - centerY));
const dirX = toRight ? 1 : -1;
const dirY = toDown ? 1 : -1;
// An odd stroke sits on pixel centers, matching the fillRect bands of the
// straight forms so a curve continues a line without a half-pixel step.
const align = (stroke % 2) / 2;
const cx = centerX + align;
const cy = centerY + align;
context.beginPath();
context.moveTo(endX, cy);
context.lineTo(cx + dirX * radius, cy);
context.quadraticCurveTo(cx, cy, cx, cy + dirY * radius);
context.lineTo(cx, endY);
context.lineWidth = stroke;
context.lineCap = 'butt';
context.stroke();
}
function drawDiagonal(context: BoxDrawingContext, geometry: CellGeometry, code: number): void {
context.lineWidth = geometry.stroke;
context.lineCap = 'butt';
context.beginPath();
if (code === 0x2571 || code === 0x2573) {
context.moveTo(geometry.right, geometry.top);
context.lineTo(geometry.left, geometry.bottom);
}
if (code === 0x2572 || code === 0x2573) {
context.moveTo(geometry.left, geometry.top);
context.lineTo(geometry.right, geometry.bottom);
}
context.stroke();
}
function drawPowerline(context: BoxDrawingContext, geometry: CellGeometry, code: number): void {
const { left, right, top, bottom, centerY } = geometry;
const pointsRight = code === 0xe0b0 || code === 0xe0b1;
const tip = pointsRight ? right : left;
const base = pointsRight ? left : right;
context.beginPath();
context.moveTo(base, top);
context.lineTo(tip, centerY);
context.lineTo(base, bottom);
if (code === 0xe0b0 || code === 0xe0b2) {
context.closePath();
context.fill();
return;
}
context.lineWidth = geometry.stroke;
context.lineCap = 'butt';
context.stroke();
}
/**
* Draw one symbol into its cell. Returns false when the code point is not a
* symbol this module owns, so the caller falls back to the font.
*/
export function drawBoxDrawingGlyph(
context: BoxDrawingContext,
text: string,
cell: BoxDrawingCell,
color: GhosttyColor,
): boolean {
if (!isBoxDrawingText(text)) return false;
const code = text.codePointAt(0) ?? 0;
const geometry = cellGeometry(cell);
const solid = rgba(color, 1);
context.fillStyle = solid;
context.strokeStyle = solid;
if (code >= BLOCK_FIRST && code <= BLOCK_LAST) {
const shade = SHADE_ALPHA.get(code);
if (shade !== undefined) {
context.fillStyle = rgba(color, shade);
context.fillRect(geometry.left, geometry.top, geometry.right - geometry.left, geometry.bottom - geometry.top);
return true;
}
const width = geometry.right - geometry.left;
const height = geometry.bottom - geometry.top;
for (const [x, y, w, h] of BLOCK_RECTS.get(code) ?? []) {
// Edges of eighths snap independently so stacked bars still tile.
const x0 = geometry.left + Math.round(x * width);
const x1 = geometry.left + Math.round((x + w) * width);
const y0 = geometry.top + Math.round(y * height);
const y1 = geometry.top + Math.round((y + h) * height);
context.fillRect(x0, y0, Math.max(1, x1 - x0), Math.max(1, y1 - y0));
}
return true;
}
if (code >= POWERLINE_FIRST && code <= POWERLINE_LAST) {
drawPowerline(context, geometry, code);
return true;
}
if (code >= 0x256d && code <= 0x2570) {
drawArc(context, geometry, code);
return true;
}
if (code >= 0x2571 && code <= 0x2573) {
drawDiagonal(context, geometry, code);
return true;
}
const arms = BOX_ARM_TABLE[code - BOX_DRAWING_FIRST] ?? '0000';
const dashCount = TRIPLE_DASH.has(code) ? 3 : QUAD_DASH.has(code) ? 4 : DOUBLE_DASH.has(code) ? 2 : 0;
if (dashCount > 0) {
const horizontal = arms[2] !== '0';
drawDashes(context, geometry, horizontal, Number(horizontal ? arms[2] : arms[0]), dashCount);
return true;
}
drawArms(context, geometry, arms);
return true;
}
+158
View File
@@ -0,0 +1,158 @@
// Adapted from T3 Code's libghostty-vt browser adapter tests (MIT, T3 Tools Inc.).
// See LICENSE-T3CODE in this directory.
import { afterEach, describe, expect, test } from 'bun:test';
import { GHOSTTY_CELL_WIDE, GhosttyTerminalCore, ghosttyCellText, ghosttyPaletteBytes, type GhosttyColor } from './core';
import { loadGhosttyRuntime } from './runtime';
const WHITE: GhosttyColor = { r: 255, g: 255, b: 255 };
const BLACK: GhosttyColor = { r: 0, g: 0, b: 0 };
function codepointView(codepoints: ReadonlyArray<number>): DataView {
const view = new DataView(new ArrayBuffer(codepoints.length * 4));
codepoints.forEach((codepoint, index) => view.setUint32(index * 4, codepoint, true));
return view;
}
describe('ghosttyCellText', () => {
test('converts oversized grapheme clusters without hitting engine spread limits', () => {
const graphemeLength = 130_000;
const view = new DataView(new ArrayBuffer(graphemeLength * 4));
for (let index = 0; index < graphemeLength; index += 1) {
view.setUint32(index * 4, index === 0 ? 'a'.codePointAt(0)! : 0x301, true);
}
const text = ghosttyCellText(view, graphemeLength);
expect(text.length).toBe(graphemeLength);
expect(text.codePointAt(0)).toBe('a'.codePointAt(0));
expect(text.codePointAt(graphemeLength - 1)).toBe(0x301);
});
test('converts small clusters including astral codepoints', () => {
expect([...ghosttyCellText(codepointView([0x1f642, 0x20e3]), 2)]).toEqual(['\u{1F642}', '\u{20E3}']);
expect(ghosttyCellText(codepointView([0x1f642]), 1)).toBe('🙂');
expect(ghosttyCellText(codepointView([]), 0)).toBe('');
});
});
describe('ghosttyPaletteBytes', () => {
test('places the theme ANSI colors first and keeps the xterm cube and gray ramp', () => {
const ansi = Array.from({ length: 16 }, (_, index) => ({ r: index, g: index * 2, b: index * 3 }));
const bytes = ghosttyPaletteBytes(ansi);
expect(bytes.length).toBe(768);
expect([...bytes.subarray(15 * 3, 16 * 3)]).toEqual([15, 30, 45]);
// Index 196 is pure red in the 6x6x6 cube; 232 is the darkest gray.
expect([...bytes.subarray(196 * 3, 197 * 3)]).toEqual([255, 0, 0]);
expect([...bytes.subarray(232 * 3, 233 * 3)]).toEqual([8, 8, 8]);
expect([...bytes.subarray(255 * 3, 256 * 3)]).toEqual([238, 238, 238]);
});
});
describe('GhosttyTerminalCore', () => {
const cores = new Set<GhosttyTerminalCore>();
async function createCore(onData: (data: string) => void = () => {}, palette?: GhosttyColor[]) {
const core = await GhosttyTerminalCore.create(12, 3, 8, 16, {
foreground: WHITE,
background: BLACK,
cursor: WHITE,
palette,
}, onData);
cores.add(core);
return core;
}
afterEach(() => {
for (const core of cores) core.dispose();
cores.clear();
});
test('preserves styles, wide cells, and selection after shared memory grows', async () => {
const core = await createCore();
const runtime = await loadGhosttyRuntime();
const grapheme = `e${'́'.repeat(64)}`;
core.write(`\x1b[1;3;4;8;9;53;38;2;123;45;67;48;2;9;8;7m${grapheme}\x1b[0m界🙂`);
const cells = core.snapshot().rowData[0]!.cells;
expect(cells[0]).toEqual({
text: grapheme,
wide: 0,
foreground: { r: 123, g: 45, b: 67 },
background: { r: 9, g: 8, b: 7 },
bold: true,
italic: true,
invisible: true,
strikethrough: true,
overline: true,
underline: true,
selected: false,
});
expect(cells.slice(1, 5).map(({ text, wide }) => ({ text, wide }))).toEqual([
{ text: '界', wide: 0 },
{ text: '', wide: GHOSTTY_CELL_WIDE.spacerTail },
{ text: '🙂', wide: 0 },
{ text: '', wide: GHOSTTY_CELL_WIDE.spacerTail },
]);
runtime.memory.grow(1);
core.setSelection({ x: 0, y: 0 }, { x: 2, y: 0 });
expect(core.snapshot().rowData[0]!.cells[0]).toEqual({ ...cells[0]!, selected: true });
core.clearSelection();
expect(core.snapshot().rowData[0]!.cells[0]).toEqual(cells[0]!);
});
test('renders ANSI colors from the theme palette', async () => {
const palette = Array.from({ length: 16 }, (_, index) => ({ r: 10 + index, g: 20, b: 30 }));
const core = await createCore(() => {}, palette);
core.write('\x1b[31mred\x1b[0m \x1b[94mblue');
const cells = core.snapshot().rowData[0]!.cells;
expect(cells[0]!.foreground).toEqual({ r: 11, g: 20, b: 30 });
expect(cells[4]!.foreground).toEqual({ r: 22, g: 20, b: 30 });
// Indices past the theme keep the standard table.
core.write('\x1b[38;5;196mX');
expect(core.snapshot().rowData[0]!.cells[8]!.foreground).toEqual({ r: 255, g: 0, b: 0 });
});
test('answers device queries through the PTY writer but not during history replay', async () => {
const replies: string[] = [];
const core = await createCore((data) => replies.push(data));
core.write('\x1b[5n');
expect(replies).toEqual(['\x1b[0n']);
replies.length = 0;
core.resetAndWrite('history\x1b[5n');
expect(replies).toEqual([]);
expect(core.snapshot().rowData[0]!.text).toBe('history');
core.write('\x1b[5n');
expect(replies).toEqual(['\x1b[0n']);
});
test('a fresh terminal after disposing a scrolled one shows none of its rows', async () => {
const first = await createCore();
first.write(Array.from({ length: 200 }, (_, index) => `leak-${index}\r\n`).join(''));
first.snapshot();
first.dispose();
cores.delete(first);
const second = await createCore();
second.write('fresh\r\n'.repeat(4));
const rows = second.snapshot().rowData.map((row) => row.text);
expect(rows.some((text) => text.includes('leak-'))).toBe(false);
expect(rows[0]).toBe('fresh');
});
test('reflows history written at a wider size back to the fitted grid', async () => {
const core = await createCore();
core.resize(40, 3, 8, 16);
core.resetAndWrite(`${'x'.repeat(30)}\r\nprompt> `);
core.resize(12, 3, 8, 16);
// 30 columns wrap into 12 + 12 + 6; the first wrapped row scrolls out of a 3-row viewport.
expect(core.snapshot().rowData.map((row) => row.text)).toEqual(['x'.repeat(12), 'x'.repeat(6), 'prompt>']);
});
test('encodes bracketed paste only when the terminal asked for it', async () => {
const core = await createCore();
expect(core.encodePaste('hello')).toBe('hello');
core.write('\x1b[?2004h');
expect(core.encodePaste('hello')).toBe('\x1b[200~hello\x1b[201~');
});
});
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, test } from 'bun:test';
import { areFontAdvancesMonospace, canvasFontFamilies, quoteFontFamilyName } from './fonts';
describe('canvasFontFamilies', () => {
test('quotes names the canvas shorthand would reject and drops engine-specific generics', () => {
expect(canvasFontFamilies('ui-monospace, JetBrains Mono, "Fira Code", Menlo, monospace'))
.toBe('"JetBrains Mono", "Fira Code", Menlo, monospace');
expect(canvasFontFamilies('ui-monospace')).toBeNull();
expect(canvasFontFamilies('')).toBeNull();
});
test('keeps already quoted and single-ident names as they are', () => {
expect(quoteFontFamilyName('"3270 Nerd Font"')).toBe('"3270 Nerd Font"');
expect(quoteFontFamilyName('Menlo')).toBe('Menlo');
expect(quoteFontFamilyName('M+ 1m')).toBe('"M+ 1m"');
});
});
describe('areFontAdvancesMonospace', () => {
test('accepts equal advances and treats unmeasurable input as monospace', () => {
expect(areFontAdvancesMonospace([7.2, 7.2, 7.2])).toBe(true);
expect(areFontAdvancesMonospace([7.2, 9.1, 7.2])).toBe(false);
expect(areFontAdvancesMonospace([])).toBe(true);
expect(areFontAdvancesMonospace([0, 0])).toBe(true);
});
});
+73
View File
@@ -0,0 +1,73 @@
// Adapted from T3 Code's libghostty-vt browser adapter (MIT, T3 Tools Inc.).
// See LICENSE-T3CODE in this directory.
const MONOSPACE_PROBE_VARIANTS = ['normal 400', 'normal 700', 'italic 400', 'italic 700'] as const;
const MONOSPACE_PROBE_GLYPHS = ['i', 'M', 'W', '0', '@', '#', '.', ' '] as const;
const MONOSPACE_ADVANCE_TOLERANCE = 0.01;
// Generic keywords the canvas font shorthand parser does not accept in every
// engine (Chromium rejects ui-monospace outright, which silently voids the
// whole assignment). The concrete platform faces cover the same intent.
const UNSUPPORTED_CANVAS_GENERICS = /^(ui-monospace|ui-sans-serif|ui-serif|system-ui)$/i;
export function quoteFontFamilyName(name: string): string {
const bare = name.trim();
if (bare.length === 0) return '';
// Already quoted, or a single ident that needs no quoting.
if (/^(['"]).*\1$/.test(bare)) return bare;
if (/^[a-zA-Z][a-zA-Z0-9-]*$/.test(bare)) return bare;
return `"${bare.replaceAll('"', '')}"`;
}
/**
* Normalize a family list into a canvas-safe CSS font-family list, or null
* when nothing usable remains. Quotes names the shorthand would reject and
* drops generics that only some engines know.
*/
export function canvasFontFamilies(input: string): string | null {
const families = input
.split(',')
.map(quoteFontFamilyName)
.filter((name) => name.length > 0 && !UNSUPPORTED_CANVAS_GENERICS.test(name));
return families.length > 0 ? families.join(', ') : null;
}
export function areFontAdvancesMonospace(advances: readonly number[]): boolean {
const reference = advances[0];
if (
reference === undefined ||
reference <= 0 ||
advances.some((advance) => !Number.isFinite(advance) || advance <= 0)
) {
return true;
}
return advances.every((advance) => Math.abs(advance - reference) < MONOSPACE_ADVANCE_TOLERANCE);
}
let fontProbeContext: CanvasRenderingContext2D | null | undefined;
/**
* Whether a family renders every character on the same advance. The cell grid
* requires this: a proportional face draws its text narrower than its own
* cells and strands the cursor.
*/
export function isMonospaceFamily(family: string): boolean {
const families = canvasFontFamilies(family);
if (families === null) return true;
try {
if (fontProbeContext === undefined) {
fontProbeContext = document.createElement('canvas').getContext('2d');
}
if (fontProbeContext === null) return true;
const context = fontProbeContext;
// Fall back to a generic mono so an absent face measures as monospace and
// is left for the normal fallback chain to resolve.
for (const variant of MONOSPACE_PROBE_VARIANTS) {
context.font = `${variant} 32px ${families}, monospace`;
const advances = MONOSPACE_PROBE_GLYPHS.map((glyph) => context.measureText(glyph).width);
if (!areFontAdvancesMonospace(advances)) return false;
}
return true;
} catch {
return true;
}
}
+21
View File
@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2014 Ryan L McIntyre
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,67 @@
// Adapted from T3 Code's libghostty-vt browser adapter tests (MIT, T3 Tools Inc.).
// See LICENSE-T3CODE in this directory.
import { describe, expect, test } from 'bun:test';
import { ghosttyConsumedMods, ghosttyKeyForCode, ghosttyUnshiftedCodepoint } from './keyCodes';
describe('ghosttyKeyForCode', () => {
test('keeps the tail of the pinned Ghostty key enum in order', () => {
expect(ghosttyKeyForCode('F25')).toBe(ghosttyKeyForCode('F24') + 1);
expect(ghosttyKeyForCode('PrintScreen')).toBe(ghosttyKeyForCode('FnLock') + 1);
expect(ghosttyKeyForCode('Pause')).toBe(ghosttyKeyForCode('ScrollLock') + 1);
expect(ghosttyKeyForCode('Paste')).toBe(ghosttyKeyForCode('Cut') + 1);
});
});
describe('ghosttyConsumedMods', () => {
const shifted = { altKey: false, ctrlKey: false, key: '@', metaKey: false, shiftKey: true };
test('only consumes a lone Shift producing a character', () => {
expect(ghosttyConsumedMods(shifted)).toBe(1);
expect(ghosttyConsumedMods({ ...shifted, ctrlKey: true })).toBe(0);
expect(ghosttyConsumedMods({ ...shifted, key: 'Tab' })).toBe(0);
// Deliberate: Shift+Space collapses to Space so it still types one.
expect(ghosttyConsumedMods({ ...shifted, key: ' ' })).toBe(1);
});
});
describe('ghosttyUnshiftedCodepoint', () => {
test('provides the logical base character for Kitty keyboard encoding', () => {
expect(ghosttyUnshiftedCodepoint({ code: 'KeyC', key: 'c', shiftKey: false })).toBe(
'c'.codePointAt(0),
);
expect(ghosttyUnshiftedCodepoint({ code: 'KeyC', key: 'C', shiftKey: true })).toBe(
'c'.codePointAt(0),
);
expect(ghosttyUnshiftedCodepoint({ code: 'Digit1', key: '!', shiftKey: true })).toBe(
'1'.codePointAt(0),
);
expect(ghosttyUnshiftedCodepoint({ code: 'Slash', key: '?', shiftKey: true })).toBe(
'/'.codePointAt(0),
);
expect(ghosttyUnshiftedCodepoint({ code: 'Digit1', key: '&', shiftKey: false })).toBe(
'&'.codePointAt(0),
);
expect(ghosttyUnshiftedCodepoint({ code: 'Enter', key: 'Enter', shiftKey: false })).toBe(0);
});
test('reports unknown instead of the shifted character without layout data', () => {
expect(ghosttyUnshiftedCodepoint({ code: 'Digit7', key: '/', shiftKey: true })).toBe(0);
expect(ghosttyUnshiftedCodepoint({ code: 'KeyD', key: 'Д', shiftKey: true })).toBe(
'д'.codePointAt(0),
);
});
test('prefers the active browser layout over US physical key positions', () => {
const layoutMap = new Map([
['Digit1', '&'],
['KeyC', 'j'],
]);
expect(ghosttyUnshiftedCodepoint({ code: 'Digit1', key: '1', shiftKey: true }, layoutMap)).toBe(
'&'.codePointAt(0),
);
expect(ghosttyUnshiftedCodepoint({ code: 'KeyC', key: 'J', shiftKey: true }, layoutMap)).toBe(
'j'.codePointAt(0),
);
});
});
+269
View File
@@ -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;
}
@@ -0,0 +1,324 @@
// Adapted from T3 Code's libghostty-vt browser adapter tests (MIT, T3 Tools Inc.).
// See LICENSE-T3CODE in this directory.
import { describe, expect, test } from 'bun:test';
import { GHOSTTY_CELL_WIDE, type GhosttyCell, type GhosttySnapshot } from './core';
import {
ghosttyTextRunEnd,
measureGhosttyCell,
renderGhosttySnapshot,
terminalGridSize,
} from './renderer';
const cell = (text: string, wide = 0): GhosttyCell => ({
text,
wide,
foreground: { r: 255, g: 255, b: 255 },
background: { r: 0, g: 0, b: 0 },
bold: false,
italic: false,
invisible: false,
strikethrough: false,
overline: false,
underline: false,
selected: false,
});
describe('terminalGridSize', () => {
test("matches the mobile renderer's cell-and-padding sizing model", () => {
expect(terminalGridSize(808, 408, { width: 10, height: 20, baseline: 15 }, 4)).toEqual({
cols: 80,
rows: 20,
});
});
test('never sends an invalid zero-sized terminal to libghostty', () => {
expect(terminalGridSize(0, 0, { width: 10, height: 20, baseline: 15 }, 4)).toEqual({
cols: 1,
rows: 1,
});
});
});
describe('measureGhosttyCell', () => {
test('uses descender-aware metrics and the mobile terminal line-height', () => {
const measureText = (text: string) =>
text === 'M'
? { width: 7.2, actualBoundingBoxAscent: 9, actualBoundingBoxDescent: 0 }
: { width: 14.4, actualBoundingBoxAscent: 9, actualBoundingBoxDescent: 3 };
const context = { font: '', measureText };
expect(measureGhosttyCell(context, 12, 'monospace')).toEqual({
width: 7.2,
height: 16,
baseline: 11,
});
});
});
describe('ghosttyTextRunEnd', () => {
test('includes wide spacer tails in the visual clip without rendering spaces', () => {
const cells = [
cell('界', GHOSTTY_CELL_WIDE.wide),
cell('', GHOSTTY_CELL_WIDE.spacerTail),
cell('🙂', GHOSTTY_CELL_WIDE.wide),
cell('', GHOSTTY_CELL_WIDE.spacerTail),
cell(''),
];
expect(ghosttyTextRunEnd(cells, 0, () => true)).toBe(4);
});
});
describe('renderGhosttySnapshot', () => {
test('underlines every cell in a hovered wrapped link', () => {
const fillRectCalls: number[][] = [];
const context = {
canvas: { width: 200, height: 80 },
beginPath: () => {},
clip: () => {},
fillRect: (...args: number[]) => fillRectCalls.push(args),
fillText: () => {},
rect: () => {},
resetTransform: () => {},
restore: () => {},
save: () => {},
fillStyle: '',
strokeStyle: '',
font: '',
textBaseline: 'alphabetic' as const,
strokeRect: () => {},
lineWidth: 1,
lineCap: 'butt' as const,
moveTo: () => {},
lineTo: () => {},
quadraticCurveTo: () => {},
closePath: () => {},
fill: () => {},
stroke: () => {},
};
const snapshot: GhosttySnapshot = {
cols: 4,
rows: 2,
foreground: { r: 255, g: 255, b: 255 },
background: { r: 0, g: 0, b: 0 },
cursor: { r: 255, g: 255, b: 255 },
cursorX: -1,
cursorY: -1,
cursorVisible: false,
cursorBlinking: false,
cursorStyle: 1,
dirtyRows: new Set([0, 1]),
rowData: [0, 1].map(() => ({
cells: [cell('a'), cell('b'), cell('c'), cell('d')],
text: 'abcd',
isWrapContinuation: false,
wrapsToNext: false,
})),
};
renderGhosttySnapshot({
context,
snapshot,
metrics: { width: 10, height: 20, baseline: 15 },
fontSize: 12,
fontFamily: 'monospace',
padding: 4,
forceFull: false,
cursorOn: false,
hoveredLinkRange: { start: { x: 2, y: 0 }, end: { x: 1, y: 1 } },
});
expect(fillRectCalls.filter(([, , , height]) => height === 1)).toEqual([
[24, 22, 10, 1],
[34, 22, 10, 1],
[4, 42, 10, 1],
[14, 42, 10, 1],
]);
});
test('constrains text runs and cursor glyphs to their terminal cells', () => {
const fillTextCalls: unknown[][] = [];
const context = {
canvas: { width: 200, height: 40 },
beginPath: () => {},
clip: () => {},
fillRect: () => {},
fillText: (...args: unknown[]) => fillTextCalls.push(args),
rect: () => {},
resetTransform: () => {},
restore: () => {},
save: () => {},
fillStyle: '',
strokeStyle: '',
font: '',
textBaseline: 'alphabetic' as const,
strokeRect: () => {},
lineWidth: 1,
lineCap: 'butt' as const,
moveTo: () => {},
lineTo: () => {},
quadraticCurveTo: () => {},
closePath: () => {},
fill: () => {},
stroke: () => {},
};
const cells = [cell('a'), cell('b'), cell('x')];
const snapshot: GhosttySnapshot = {
cols: 3,
rows: 1,
foreground: { r: 255, g: 255, b: 255 },
background: { r: 0, g: 0, b: 0 },
cursor: { r: 255, g: 255, b: 255 },
cursorX: 2,
cursorY: 0,
cursorVisible: true,
cursorBlinking: false,
cursorStyle: 1,
dirtyRows: new Set([0]),
rowData: [{ cells, text: 'abx', isWrapContinuation: false, wrapsToNext: false }],
};
renderGhosttySnapshot({
context,
snapshot,
metrics: { width: 7.2, height: 16, baseline: 11 },
fontSize: 12,
fontFamily: 'monospace',
padding: 4,
forceFull: false,
cursorOn: true,
});
expect(fillTextCalls).toEqual([
['abx', 4, 15, 21.6],
['x', 18.4, 15, 7.2],
]);
});
test('repaints the cell without an overlay during the blink off phase', () => {
const fillTextCalls: unknown[][] = [];
const context = {
canvas: { width: 200, height: 40 },
beginPath: () => {},
clip: () => {},
fillRect: () => {},
fillText: (...args: unknown[]) => fillTextCalls.push(args),
rect: () => {},
resetTransform: () => {},
restore: () => {},
save: () => {},
fillStyle: '',
strokeStyle: '',
font: '',
textBaseline: 'alphabetic' as const,
strokeRect: () => {},
lineWidth: 1,
lineCap: 'butt' as const,
moveTo: () => {},
lineTo: () => {},
quadraticCurveTo: () => {},
closePath: () => {},
fill: () => {},
stroke: () => {},
};
const snapshot: GhosttySnapshot = {
cols: 3,
rows: 1,
foreground: { r: 255, g: 255, b: 255 },
background: { r: 0, g: 0, b: 0 },
cursor: { r: 255, g: 255, b: 255 },
cursorX: 2,
cursorY: 0,
cursorVisible: true,
cursorBlinking: true,
cursorStyle: 1,
dirtyRows: new Set(),
rowData: [
{
cells: [cell('a'), cell('b'), cell('x')],
text: 'abx',
isWrapContinuation: false,
wrapsToNext: false,
},
],
};
renderGhosttySnapshot({
context,
snapshot,
metrics: { width: 7.2, height: 16, baseline: 11 },
fontSize: 12,
fontFamily: 'monospace',
padding: 4,
forceFull: false,
cursorOn: false,
});
// The cursor row still repaints so the block disappears, but the inverted
// glyph the on phase draws over the cell is gone.
expect(fillTextCalls).toEqual([['abx', 4, 15, 21.6]]);
});
test('repaints the previous cursor row after the cursor moves', () => {
const clearedRows: number[] = [];
const context = {
canvas: { width: 200, height: 80 },
beginPath: () => {},
clip: () => {},
fillRect: (_left: number, top: number, _width: number, height: number) => {
if (height === 16) clearedRows.push(top);
},
fillText: () => {},
rect: () => {},
resetTransform: () => {},
restore: () => {},
save: () => {},
fillStyle: '',
strokeStyle: '',
font: '',
textBaseline: 'alphabetic' as const,
strokeRect: () => {},
lineWidth: 1,
lineCap: 'butt' as const,
moveTo: () => {},
lineTo: () => {},
quadraticCurveTo: () => {},
closePath: () => {},
fill: () => {},
stroke: () => {},
};
const snapshot: GhosttySnapshot = {
cols: 1,
rows: 3,
foreground: { r: 255, g: 255, b: 255 },
background: { r: 0, g: 0, b: 0 },
cursor: { r: 255, g: 255, b: 255 },
cursorX: 0,
cursorY: 2,
cursorVisible: true,
cursorBlinking: false,
cursorStyle: 1,
dirtyRows: new Set(),
rowData: [0, 1, 2].map(() => ({
cells: [cell('')],
text: '',
isWrapContinuation: false,
wrapsToNext: false,
})),
};
renderGhosttySnapshot({
context,
snapshot,
metrics: { width: 7.2, height: 16, baseline: 11 },
fontSize: 12,
fontFamily: 'monospace',
padding: 4,
forceFull: false,
cursorOn: true,
previousCursorY: 0,
});
expect(clearedRows).toEqual([4, 36, 36]);
});
});
+331
View File
@@ -0,0 +1,331 @@
// Adapted from T3 Code's libghostty-vt browser adapter (MIT, T3 Tools Inc.).
// See LICENSE-T3CODE in this directory.
import {
GHOSTTY_CELL_WIDE,
ghosttyColorsEqual,
type GhosttyCell,
type GhosttyColor,
type GhosttySnapshot,
} from './core';
import { drawBoxDrawingGlyph, isBoxDrawingText, type BoxDrawingContext } from './boxDrawing';
/** The canvas operations the renderer uses; a CanvasRenderingContext2D satisfies it structurally. */
export interface GhosttyRenderContext extends BoxDrawingContext {
readonly canvas: { readonly width: number; readonly height: number };
font: string;
textBaseline: CanvasTextBaseline;
fillRect(x: number, y: number, w: number, h: number): void;
strokeRect(x: number, y: number, w: number, h: number): void;
fillText(text: string, x: number, y: number, maxWidth?: number): void;
save(): void;
restore(): void;
beginPath(): void;
rect(x: number, y: number, w: number, h: number): void;
clip(): void;
resetTransform(): void;
}
export interface GhosttyMeasureContext {
font: string;
measureText(text: string): {
readonly width: number;
readonly actualBoundingBoxAscent: number;
readonly actualBoundingBoxDescent: number;
};
}
export interface GhosttyCellMetrics {
readonly width: number;
readonly height: number;
readonly baseline: number;
}
export interface GhosttyCellRange {
readonly start: { readonly x: number; readonly y: number };
readonly end: { readonly x: number; readonly y: number };
}
const DEFAULT_SELECTION_BACKGROUND = 'rgba(72, 122, 191, 0.35)';
function cssColor(color: GhosttyColor): string {
return `rgb(${color.r}, ${color.g}, ${color.b})`;
}
function sameTextStyle(left: GhosttyCell, right: GhosttyCell): boolean {
// Selection deliberately does not participate: it only tints the background
// overlay, and splitting a text run at a selection boundary visibly shifts
// glyph spacing whenever the face's true advance differs from the cell width.
return (
ghosttyColorsEqual(left.foreground, right.foreground) &&
left.bold === right.bold &&
left.italic === right.italic &&
left.invisible === right.invisible
);
}
export function ghosttyTextRunEnd(
cells: readonly GhosttyCell[],
start: number,
sameStyle: (cell: GhosttyCell) => boolean,
): number {
let end = start + 1;
while (end < cells.length) {
const next = cells[end];
if (!next) break;
if (next.wide === GHOSTTY_CELL_WIDE.spacerTail) {
end += 1;
continue;
}
if (next.text.length === 0 || !sameStyle(next)) break;
end += 1;
}
return end;
}
function fontForCell(cell: GhosttyCell, fontSize: number, fontFamily: string): string {
const style = cell.italic ? 'italic' : 'normal';
const weight = cell.bold ? '700' : '400';
return `${style} ${weight} ${fontSize}px ${fontFamily}`;
}
export function measureGhosttyCell(
context: GhosttyMeasureContext,
fontSize: number,
fontFamily: string,
): GhosttyCellMetrics {
context.font = `normal 400 ${fontSize}px ${fontFamily}`;
const widthMeasurement = context.measureText('M');
const verticalMeasurement = context.measureText('Mg');
const ascent = verticalMeasurement.actualBoundingBoxAscent || fontSize;
const descent = verticalMeasurement.actualBoundingBoxDescent;
const glyphHeight = ascent + descent;
const height = Math.max(1, Math.round(fontSize * 1.35), Math.ceil(glyphHeight));
return {
width: Math.max(1, widthMeasurement.width),
height,
baseline: Math.round((height - glyphHeight) / 2 + ascent),
};
}
export interface GhosttyGridSize {
readonly cols: number;
readonly rows: number;
}
export function terminalGridSize(
width: number,
height: number,
metrics: GhosttyCellMetrics,
padding: number,
): GhosttyGridSize {
return {
cols: Math.max(1, Math.floor((width - padding * 2) / metrics.width)),
rows: Math.max(1, Math.floor((height - padding * 2) / metrics.height)),
};
}
export function renderGhosttySnapshot(options: {
readonly context: GhosttyRenderContext;
readonly snapshot: GhosttySnapshot;
readonly metrics: GhosttyCellMetrics;
readonly fontSize: number;
readonly fontFamily: string;
readonly padding: number;
readonly forceFull: boolean;
readonly cursorOn: boolean;
readonly previousCursorY?: number | null;
readonly focused?: boolean;
readonly selectionBackground?: string;
readonly hoveredLinkRange?: GhosttyCellRange | null;
/** Vertical origin of row 0; defaults to the horizontal padding. */
readonly originY?: number;
}): void {
const {
context,
snapshot,
metrics,
fontSize,
fontFamily,
padding,
forceFull,
cursorOn,
previousCursorY,
} = options;
const focused = options.focused ?? true;
const selectionBackground = options.selectionBackground ?? DEFAULT_SELECTION_BACKGROUND;
const hoveredLinkRange = options.hoveredLinkRange ?? null;
const originY = options.originY ?? padding;
const rowsToDraw = forceFull
? Array.from({ length: snapshot.rows }, (_, index) => index)
: [...snapshot.dirtyRows];
if (
previousCursorY !== null &&
previousCursorY !== undefined &&
previousCursorY >= 0 &&
!rowsToDraw.includes(previousCursorY)
) {
rowsToDraw.push(previousCursorY);
}
if (snapshot.cursorVisible && snapshot.cursorY >= 0 && !rowsToDraw.includes(snapshot.cursorY)) {
rowsToDraw.push(snapshot.cursorY);
}
if (forceFull) {
context.save();
context.resetTransform();
context.fillStyle = cssColor(snapshot.background);
context.fillRect(0, 0, context.canvas.width, context.canvas.height);
context.restore();
}
context.textBaseline = 'alphabetic';
for (const rowIndex of rowsToDraw) {
const row = snapshot.rowData[rowIndex];
if (!row) continue;
const top = originY + rowIndex * metrics.height;
context.fillStyle = cssColor(snapshot.background);
context.fillRect(padding, top, snapshot.cols * metrics.width, metrics.height);
let backgroundStart = 0;
while (backgroundStart < row.cells.length) {
const first = row.cells[backgroundStart];
if (!first) break;
let backgroundEnd = backgroundStart + 1;
while (backgroundEnd < row.cells.length) {
const next = row.cells[backgroundEnd];
if (
!next ||
next.selected !== first.selected ||
!ghosttyColorsEqual(next.background, first.background)
) {
break;
}
backgroundEnd += 1;
}
if (first.selected || !ghosttyColorsEqual(first.background, snapshot.background)) {
const left = padding + backgroundStart * metrics.width;
const width = (backgroundEnd - backgroundStart) * metrics.width;
if (!ghosttyColorsEqual(first.background, snapshot.background)) {
context.fillStyle = cssColor(first.background);
context.fillRect(left, top, width, metrics.height);
}
if (first.selected) {
context.fillStyle = selectionBackground;
context.fillRect(left, top, width, metrics.height);
}
}
backgroundStart = backgroundEnd;
}
let runStart = 0;
while (runStart < row.cells.length) {
const first = row.cells[runStart];
if (!first) break;
if (first.text.length === 0) {
runStart += 1;
continue;
}
// Borders, bars and block logos are drawn to the exact cell instead of
// through the font, whose glyphs leave a gap at the terminal line height.
if (isBoxDrawingText(first.text)) {
if (!first.invisible) {
drawBoxDrawingGlyph(
context,
first.text,
{ x: padding + runStart * metrics.width, y: top, width: metrics.width, height: metrics.height },
first.foreground,
);
}
runStart += 1;
continue;
}
const runEnd = ghosttyTextRunEnd(
row.cells,
runStart,
(cell) => sameTextStyle(cell, first) && !isBoxDrawingText(cell.text),
);
const text = row.cells
.slice(runStart, runEnd)
.map((cell) => cell.text)
.join('');
if (!first.invisible && text.trim().length > 0) {
context.save();
context.beginPath();
context.rect(
padding + runStart * metrics.width,
top,
(runEnd - runStart) * metrics.width,
metrics.height,
);
context.clip();
context.font = fontForCell(first, fontSize, fontFamily);
context.fillStyle = cssColor(first.foreground);
context.fillText(
text,
padding + runStart * metrics.width,
top + metrics.baseline,
(runEnd - runStart) * metrics.width,
);
context.restore();
}
runStart = runEnd;
}
for (let column = 0; column < row.cells.length; column += 1) {
const cell = row.cells[column];
const hoveredLink =
hoveredLinkRange !== null &&
rowIndex >= hoveredLinkRange.start.y &&
rowIndex <= hoveredLinkRange.end.y &&
(rowIndex > hoveredLinkRange.start.y || column >= hoveredLinkRange.start.x) &&
(rowIndex < hoveredLinkRange.end.y || column <= hoveredLinkRange.end.x);
if (!cell || (!cell.underline && !cell.strikethrough && !cell.overline && !hoveredLink)) {
continue;
}
context.fillStyle = cssColor(cell.foreground);
const left = padding + column * metrics.width;
if (cell.underline || hoveredLink) {
context.fillRect(left, top + metrics.height - 2, metrics.width, 1);
}
if (cell.strikethrough) {
context.fillRect(left, top + Math.floor(metrics.height * 0.55), metrics.width, 1);
}
if (cell.overline) context.fillRect(left, top + 1, metrics.width, 1);
}
}
if (cursorOn && snapshot.cursorVisible && snapshot.cursorX >= 0 && snapshot.cursorY >= 0) {
const left = padding + snapshot.cursorX * metrics.width;
const top = originY + snapshot.cursorY * metrics.height;
context.fillStyle = cssColor(snapshot.cursor);
if (!focused) {
// An unfocused terminal draws a hollow cursor so the active pane is obvious.
context.strokeStyle = cssColor(snapshot.cursor);
context.strokeRect(left + 0.5, top + 0.5, metrics.width - 1, metrics.height - 1);
} else if (snapshot.cursorStyle === 0) {
context.fillRect(left, top, 2, metrics.height);
} else if (snapshot.cursorStyle === 2) {
context.fillRect(left, top + metrics.height - 2, metrics.width, 2);
} else if (snapshot.cursorStyle === 3) {
context.strokeStyle = cssColor(snapshot.cursor);
context.strokeRect(left + 0.5, top + 0.5, metrics.width - 1, metrics.height - 1);
} else {
context.fillRect(left, top, metrics.width, metrics.height);
const cell = snapshot.rowData[snapshot.cursorY]?.cells[snapshot.cursorX];
if (cell?.text && isBoxDrawingText(cell.text)) {
drawBoxDrawingGlyph(
context,
cell.text,
{ x: left, y: top, width: metrics.width, height: metrics.height },
snapshot.background,
);
} else if (cell?.text) {
context.font = fontForCell(cell, fontSize, fontFamily);
context.fillStyle = cssColor(snapshot.background);
context.fillText(cell.text, left, top + metrics.baseline, metrics.width);
}
}
}
}
@@ -0,0 +1,52 @@
import { describe, expect, test } from 'bun:test';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { loadGhosttyRuntime } from './runtime';
const vendorDir = join(dirname(fileURLToPath(import.meta.url)), 'vendor');
describe('vendored libghostty-vt WebAssembly', () => {
test('stays pinned to VERSION and inside the size budget', async () => {
const wasm = readFileSync(join(vendorDir, 'ghostty-vt.wasm'));
expect(wasm.byteLength).toBeLessThan(750_000);
// The build embeds the pinned revision as semver build metadata, so VERSION
// is the single source of truth and drift between the two is caught here.
const runtime = await loadGhosttyRuntime();
const out = runtime.alloc(8);
expect(runtime.call('ghostty_build_info', 10, out)).toBe(0);
const view = runtime.view(out, 8);
const embeddedRevision = new TextDecoder().decode(
runtime.bytes(view.getUint32(0, true), view.getUint32(4, true)),
);
runtime.free(out, 8);
expect(embeddedRevision).toBe(readFileSync(join(vendorDir, 'VERSION'), 'utf8').trim());
});
test('routes terminal replies through the embedded trampoline to the attached writer', async () => {
const runtime = await loadGhosttyRuntime();
const optionsSize = runtime.layout('GhosttyTerminalOptions').size;
const options = runtime.alloc(optionsSize);
runtime.setField(options, 'GhosttyTerminalOptions', 'cols', 20);
runtime.setField(options, 'GhosttyTerminalOptions', 'rows', 4);
const slot = runtime.allocOpaque();
expect(runtime.call('ghostty_terminal_new', 0, slot, options)).toBe(0);
runtime.free(options, optionsSize);
const terminal = runtime.readPointer(slot);
const replies: string[] = [];
const writerId = runtime.attachPtyWriter(terminal, (data) => replies.push(data));
const input = new TextEncoder().encode('\x1b[6n');
const pointer = runtime.alloc(input.length);
runtime.bytes(pointer, input.length).set(input);
runtime.call('ghostty_terminal_vt_write', terminal, pointer, input.length);
runtime.free(pointer, input.length);
expect(replies).toEqual(['\x1b[1;1R']);
runtime.detachPtyWriter(terminal, writerId);
runtime.call('ghostty_terminal_free', terminal);
runtime.freeOpaque(slot);
});
});
+260
View File
@@ -0,0 +1,260 @@
// Adapted from T3 Code's libghostty-vt browser adapter (MIT, T3 Tools Inc.).
// See LICENSE-T3CODE in this directory.
type WasmFunction = (...args: Array<number | bigint>) => number;
interface TypeField {
readonly offset: number;
readonly size: number;
readonly type: string;
}
interface TypeLayout {
readonly size: number;
readonly align: number;
readonly fields: Readonly<Record<string, TypeField>>;
}
type TypeLayouts = Readonly<Record<string, TypeLayout>>;
const textDecoder = new TextDecoder();
// The vendored artifact is fetched at runtime; `new URL` keeps this a plain
// static asset for Vite in every surface (web, VS Code webview, Electron's
// openchamber-ui:// protocol) and a readable file URL under bun's test runner.
const ghosttyWasmUrl = new URL('./vendor/ghostty-vt.wasm', import.meta.url);
/**
* Compiled from scripts/ghostty-write-pty.zig: one exported function that
* forwards libghostty-vt's write-PTY callback to the `openchamber_write_pty`
* import. Embedding the 121 bytes avoids a second network fetch and any CSP
* question about data: URLs.
*/
const WRITE_PTY_TRAMPOLINE = Uint8Array.from([
0, 97, 115, 109, 1, 0, 0, 0, 1, 8, 1, 96, 4, 127, 127, 127, 127, 0, 2, 29, 1, 3, 101, 110, 118,
21, 111, 112, 101, 110, 99, 104, 97, 109, 98, 101, 114, 95, 119, 114, 105, 116, 101, 95, 112,
116, 121, 0, 0, 3, 2, 1, 0, 5, 3, 1, 0, 16, 6, 9, 1, 127, 1, 65, 128, 128, 192, 0, 11, 7, 30, 2,
6, 109, 101, 109, 111, 114, 121, 2, 0, 17, 103, 104, 111, 115, 116, 116, 121, 95, 119, 114,
105, 116, 101, 95, 112, 116, 121, 0, 1, 10, 18, 1, 16, 0, 32, 0, 32, 1, 32, 2, 32, 3, 16, 128,
128, 128, 128, 0, 11,
]);
export class GhosttyRuntime {
readonly memory: WebAssembly.Memory;
readonly layouts: TypeLayouts;
private readonly exports: WebAssembly.Exports;
private memoryView: DataView;
private readonly ptyWriters = new Map<number, (data: string) => void>();
private nextPtyWriterId = 1;
private writePtyFunctionIndex = 0;
private constructor(instance: WebAssembly.Instance) {
this.exports = instance.exports;
const memory = instance.exports.memory;
if (!(memory instanceof WebAssembly.Memory)) {
throw new Error('libghostty-vt did not export WebAssembly memory');
}
this.memory = memory;
this.memoryView = new DataView(memory.buffer);
const jsonPointer = this.call('ghostty_type_json');
const bytes = new Uint8Array(memory.buffer);
let end = jsonPointer;
while (end < bytes.length && bytes[end] !== 0) end += 1;
// SAFETY: ghostty_type_json is generated by the pinned libghostty-vt build
// from its own C ABI structs; runtime.test.ts verifies the artifact matches
// VERSION, so the document has exactly this shape.
this.layouts = JSON.parse(textDecoder.decode(bytes.subarray(jsonPointer, end))) as TypeLayouts;
}
static async load(bytes?: ArrayBuffer): Promise<GhosttyRuntime> {
const wasmBytes = bytes ?? (await fetchGhosttyWasm());
// The log import needs the instance's memory, which only exists once
// instantiation returns; the holder closes that loop.
const instanceHolder: { current: WebAssembly.Instance | null } = { current: null };
const imports = {
env: {
log: (pointer: number, length: number) => {
const memory = instanceHolder.current?.exports.memory;
if (!(memory instanceof WebAssembly.Memory)) return;
const message = textDecoder.decode(new Uint8Array(memory.buffer, pointer, length));
console.debug('[libghostty-vt]', message);
},
},
};
const result = await WebAssembly.instantiate(wasmBytes, imports);
instanceHolder.current = result.instance;
const runtime = new GhosttyRuntime(result.instance);
await runtime.installWritePtyTrampoline();
return runtime;
}
call(name: string, ...args: Array<number | bigint>): number {
const fn = this.exports[name];
if (!(fn instanceof Function)) {
throw new Error(`libghostty-vt export is unavailable: ${name}`);
}
// SAFETY: every libghostty-vt export takes and returns wasm32 scalars (i32/i64).
return (fn as WasmFunction)(...args);
}
layout(name: string): TypeLayout {
const layout = this.layouts[name];
if (!layout) throw new Error(`libghostty-vt type layout is unavailable: ${name}`);
return layout;
}
alloc(size: number): number {
const pointer = this.call('ghostty_wasm_alloc_u8_array', size);
if (pointer === 0) throw new Error(`libghostty-vt failed to allocate ${size} bytes`);
new Uint8Array(this.memory.buffer, pointer, size).fill(0);
return pointer;
}
free(pointer: number, size: number): void {
if (pointer !== 0) this.call('ghostty_wasm_free_u8_array', pointer, size);
}
allocOpaque(): number {
const pointer = this.call('ghostty_wasm_alloc_opaque');
if (pointer === 0) throw new Error('libghostty-vt failed to allocate an opaque pointer');
// The slot is uninitialized until a *_new call writes it; zero it so dispose
// paths that run after a partial initialization never free a garbage pointer.
new DataView(this.memory.buffer).setUint32(pointer, 0, true);
return pointer;
}
freeOpaque(pointer: number): void {
if (pointer !== 0) this.call('ghostty_wasm_free_opaque', pointer);
}
readPointer(slot: number): number {
return this.currentMemoryView().getUint32(slot, true);
}
attachPtyWriter(terminal: number, writer: (data: string) => void): number {
if (this.writePtyFunctionIndex === 0) {
throw new Error('libghostty-vt PTY callback trampoline is unavailable');
}
const id = this.nextPtyWriterId++;
this.ptyWriters.set(id, writer);
this.call('ghostty_terminal_set', terminal, 0, id);
this.call('ghostty_terminal_set', terminal, 1, this.writePtyFunctionIndex);
return id;
}
detachPtyWriter(terminal: number, id: number): void {
this.call('ghostty_terminal_set', terminal, 1, 0);
this.call('ghostty_terminal_set', terminal, 0, 0);
this.ptyWriters.delete(id);
}
view(pointer: number, size?: number): DataView {
return new DataView(this.memory.buffer, pointer, size);
}
bytes(pointer: number, size: number): Uint8Array {
return new Uint8Array(this.memory.buffer, pointer, size);
}
/** Reuse scalar reads across cells, refreshing after any terminal grows shared WASM memory. */
private currentMemoryView(): DataView {
if (this.memoryView.buffer !== this.memory.buffer) {
this.memoryView = new DataView(this.memory.buffer);
}
return this.memoryView;
}
setField(pointer: number, structName: string, fieldName: string, value: number): void {
const field = this.layout(structName).fields[fieldName];
if (!field) throw new Error(`libghostty-vt field is unavailable: ${structName}.${fieldName}`);
const view = this.currentMemoryView();
const offset = pointer + field.offset;
switch (field.type) {
case 'bool':
case 'u8':
view.setUint8(offset, value);
return;
case 'u16':
view.setUint16(offset, value, true);
return;
case 'i32':
view.setInt32(offset, value, true);
return;
case 'u32':
case 'enum':
view.setUint32(offset, value, true);
return;
case 'u64':
view.setBigUint64(offset, BigInt(value), true);
return;
default:
throw new Error(`Unsupported libghostty-vt field type: ${field.type}`);
}
}
readField(pointer: number, structName: string, fieldName: string): number {
const field = this.layout(structName).fields[fieldName];
if (!field) throw new Error(`libghostty-vt field is unavailable: ${structName}.${fieldName}`);
const view = this.currentMemoryView();
const offset = pointer + field.offset;
switch (field.type) {
case 'bool':
case 'u8':
return view.getUint8(offset);
case 'u16':
return view.getUint16(offset, true);
case 'i32':
return view.getInt32(offset, true);
case 'u32':
case 'enum':
return view.getUint32(offset, true);
case 'u64':
return Number(view.getBigUint64(offset, true));
default:
throw new Error(`Unsupported libghostty-vt field type: ${field.type}`);
}
}
private async installWritePtyTrampoline(): Promise<void> {
const result = await WebAssembly.instantiate(WRITE_PTY_TRAMPOLINE, {
env: {
openchamber_write_pty: (_terminal: number, userdata: number, pointer: number, length: number) => {
const writer = this.ptyWriters.get(userdata);
if (!writer || length === 0) return;
writer(textDecoder.decode(new Uint8Array(this.memory.buffer, pointer, length)));
},
},
});
const trampoline = result.instance.exports.ghostty_write_pty;
const table = this.exports.__indirect_function_table;
if (!(trampoline instanceof Function) || !(table instanceof WebAssembly.Table)) {
throw new Error('libghostty-vt did not expose its callback table');
}
const index = table.length;
// grow-then-set instead of grow(1, fn): WebKit stores a grow init value
// with broken type information and every later call_indirect through the
// entry traps with a signature mismatch. table.set canonicalizes correctly.
table.grow(1);
table.set(index, trampoline);
this.writePtyFunctionIndex = index;
}
}
async function fetchGhosttyWasm(): Promise<ArrayBuffer> {
const response = await fetch(ghosttyWasmUrl);
if (!response.ok) {
throw new Error(`Unable to load libghostty-vt (${response.status})`);
}
return response.arrayBuffer();
}
let runtimePromise: Promise<GhosttyRuntime> | null = null;
/** One WebAssembly instance per page; every terminal owns and frees its own handles inside it. */
export function loadGhosttyRuntime(): Promise<GhosttyRuntime> {
runtimePromise ??= GhosttyRuntime.load().catch((error) => {
runtimePromise = null;
throw error;
});
return runtimePromise;
}
+179
View File
@@ -0,0 +1,179 @@
// Adapted from T3 Code's libghostty-vt browser adapter tests (MIT, T3 Tools Inc.).
// See LICENSE-T3CODE in this directory.
import { describe, expect, test } from 'bun:test';
import type { GhosttyCell, GhosttyRow } from './core';
import {
DEFAULT_TERMINAL_FONT_FAMILY,
advanceTerminalSelectionClickSequence,
isTerminalCopyShortcut,
isTerminalLinkPointerGesture,
isTerminalPasteShortcut,
resolveTerminalMouseData,
shouldBlinkTerminalCursor,
terminalContentOriginY,
terminalFontSize,
terminalGridCellAt,
terminalLinkAtPositionWithRange,
terminalScrollbarGeometry,
terminalScrollbarOffsetAtPointer,
terminalWheelArrowData,
terminalWheelDeltaRows,
loadTerminalFontFamily,
} from './surface';
const cell = (text: string): GhosttyCell => ({
text,
wide: 0,
foreground: { r: 255, g: 255, b: 255 },
background: { r: 0, g: 0, b: 0 },
bold: false,
italic: false,
invisible: false,
strikethrough: false,
overline: false,
underline: false,
selected: false,
});
const row = (text: string, cols: number, flags: Partial<Pick<GhosttyRow, 'isWrapContinuation' | 'wrapsToNext'>> = {}): GhosttyRow => ({
cells: Array.from({ length: cols }, (_, index) => cell([...text][index] ?? '')),
text: text.trimEnd(),
isWrapContinuation: flags.isWrapContinuation ?? false,
wrapsToNext: flags.wrapsToNext ?? false,
});
describe('terminalLinkAtPositionWithRange', () => {
test('reconstructs a URL soft-wrapped across two rows', () => {
const rows = [
row('see https://open', 16, { wrapsToNext: true }),
row('chamber.dev/docs', 16, { isWrapContinuation: true }),
row('done', 16),
];
const link = terminalLinkAtPositionWithRange(rows, 1, 3);
expect(link).toEqual({
text: 'https://openchamber.dev/docs',
range: { start: { x: 4, y: 0 }, end: { x: 15, y: 1 } },
});
});
test('refuses a link whose head scrolled above the viewport', () => {
const rows = [row('chamber.dev/docs', 16, { isWrapContinuation: true }), row('', 16)];
expect(terminalLinkAtPositionWithRange(rows, 0, 2)).toBeNull();
});
test('ignores plain text', () => {
expect(terminalLinkAtPositionWithRange([row('hello world', 16)], 0, 2)).toBeNull();
});
});
describe('terminal font resolution', () => {
test('keeps the glyph fallbacks behind a custom text face and drops canvas-hostile generics', async () => {
const loads: string[] = [];
const family = await loadTerminalFontFamily('ui-monospace, "JetBrains Mono", monospace', 13, {
load: (font) => {
loads.push(font);
return Promise.resolve();
},
resolve: (value) => `resolved:${value}`,
});
expect(family).toBe('resolved:ui-monospace, "JetBrains Mono", monospace');
expect(loads).toHaveLength(4);
expect(loads[0]?.startsWith('normal 400 13px "JetBrains Mono", monospace, "SF Mono"')).toBe(true);
expect(loads[0]).not.toContain('ui-monospace');
});
test('clamps requested font sizes to the supported range', () => {
expect(terminalFontSize(undefined)).toBe(13);
expect(terminalFontSize(2)).toBe(6);
expect(terminalFontSize(99)).toBe(32);
expect(terminalFontSize(14.4)).toBe(14);
});
test('the default stack names only concrete faces plus the bundled symbols', () => {
expect(DEFAULT_TERMINAL_FONT_FAMILY).toContain('"Symbols Nerd Font Mono"');
expect(DEFAULT_TERMINAL_FONT_FAMILY).not.toContain('ui-monospace');
});
});
describe('shortcuts and gestures', () => {
test('copy uses Cmd on macOS and Ctrl elsewhere, keeping Ctrl+C for SIGINT on macOS', () => {
expect(isTerminalCopyShortcut({ key: 'c', ctrlKey: true, metaKey: false, shiftKey: false }, 'MacIntel')).toBe(false);
expect(isTerminalCopyShortcut({ key: 'c', ctrlKey: false, metaKey: true, shiftKey: false }, 'MacIntel')).toBe(true);
expect(isTerminalCopyShortcut({ key: 'C', ctrlKey: true, metaKey: false, shiftKey: true }, 'Linux x86_64')).toBe(true);
});
test('paste uses Cmd+V on macOS, Ctrl+Shift+V or Shift+Insert elsewhere', () => {
expect(isTerminalPasteShortcut({ key: 'v', ctrlKey: false, metaKey: true, shiftKey: false }, 'MacIntel')).toBe(true);
expect(isTerminalPasteShortcut({ key: 'v', ctrlKey: true, metaKey: false, shiftKey: false }, 'Win32')).toBe(false);
expect(isTerminalPasteShortcut({ key: 'v', ctrlKey: true, metaKey: false, shiftKey: true }, 'Win32')).toBe(true);
expect(isTerminalPasteShortcut({ key: 'Insert', ctrlKey: false, metaKey: false, shiftKey: true }, 'Win32')).toBe(true);
});
test('link activation uses Command on macOS and Control elsewhere', () => {
expect(isTerminalLinkPointerGesture({ ctrlKey: false, metaKey: true }, 'MacIntel')).toBe(true);
expect(isTerminalLinkPointerGesture({ ctrlKey: true, metaKey: false }, 'MacIntel')).toBe(false);
expect(isTerminalLinkPointerGesture({ ctrlKey: true, metaKey: false }, 'Linux x86_64')).toBe(true);
});
test('recognizes stationary double and triple presses and restarts after movement', () => {
const first = advanceTerminalSelectionClickSequence(null, { clientX: 10, clientY: 10, timeStamp: 0 });
const second = advanceTerminalSelectionClickSequence(first, { clientX: 11, clientY: 10, timeStamp: 200 });
const third = advanceTerminalSelectionClickSequence(second, { clientX: 11, clientY: 11, timeStamp: 400 });
expect([first.count, second.count, third.count]).toEqual([1, 2, 3]);
expect(advanceTerminalSelectionClickSequence(third, { clientX: 11, clientY: 11, timeStamp: 600 }).count).toBe(1);
expect(advanceTerminalSelectionClickSequence(second, { clientX: 40, clientY: 10, timeStamp: 500 }).count).toBe(1);
});
test('drops repeated motion reports until another action resets the cell', () => {
const motion = resolveTerminalMouseData('motion', '\x1b[<35;3;4M', '');
expect(motion.send).toBe(true);
expect(resolveTerminalMouseData('motion', '\x1b[<35;3;4M', motion.nextMotionData).send).toBe(false);
const press = resolveTerminalMouseData('press', '\x1b[<0;3;4M', motion.nextMotionData);
expect(press).toEqual({ send: true, nextMotionData: '' });
});
});
describe('wheel scrolling', () => {
test('converts line and page deltas into rows and accumulates fractional pixels', () => {
expect(terminalWheelDeltaRows({ deltaY: 3, deltaMode: 1 }, 16, 24, 0)).toEqual({ rows: 3, remainder: 0 });
expect(terminalWheelDeltaRows({ deltaY: -1, deltaMode: 2 }, 16, 24, 0)).toEqual({ rows: -24, remainder: 0 });
const partial = terminalWheelDeltaRows({ deltaY: 10, deltaMode: 0 }, 16, 24, 0);
expect(partial.rows).toBe(0);
expect(terminalWheelDeltaRows({ deltaY: 10, deltaMode: 0 }, 16, 24, partial.remainder).rows).toBe(1);
});
test('emits one arrow per row honoring application cursor keys', () => {
expect(terminalWheelArrowData(-2, false)).toBe('\x1b[A\x1b[A');
expect(terminalWheelArrowData(1, true)).toBe('\x1bOB');
expect(terminalWheelArrowData(0, false)).toBe('');
});
});
describe('layout helpers', () => {
test('anchors the grid to the bottom only once scrollback exists', () => {
expect(terminalContentOriginY(100, 4, 5, 16, false)).toBe(4);
expect(terminalContentOriginY(100, 4, 5, 16, true)).toBe(16);
});
test('maps points inside the rendered grid without clamping its padding', () => {
const options = { bounds: { left: 10, top: 20 }, cols: 10, rows: 5, metrics: { width: 8, height: 16 }, padding: 4, originY: 4 };
expect(terminalGridCellAt({ ...options, clientX: 14, clientY: 24 })).toEqual({ x: 0, y: 0 });
expect(terminalGridCellAt({ ...options, clientX: 93, clientY: 103 })).toEqual({ x: 9, y: 4 });
expect(terminalGridCellAt({ ...options, clientX: 12, clientY: 24 })).toBeNull();
});
test('maps Ghostty scrollbar state to a proportional thumb and back to rows', () => {
const state = { total: 1000, offset: 500, len: 100 };
const geometry = terminalScrollbarGeometry(state, 200);
expect(geometry).toEqual({ thumbHeight: 20, thumbTop: 100, maxOffset: 900 });
expect(terminalScrollbarOffsetAtPointer(state, 200, 190, 10)).toBe(900);
expect(terminalScrollbarGeometry({ total: 24, offset: 0, len: 24 }, 200)).toBeNull();
});
test('blinks only a focused visible cursor the terminal asked to blink', () => {
expect(shouldBlinkTerminalCursor({ focused: true, cursorBlinking: true, cursorVisible: true, reducedMotion: false })).toBe(true);
expect(shouldBlinkTerminalCursor({ focused: false, cursorBlinking: true, cursorVisible: true, reducedMotion: false })).toBe(false);
expect(shouldBlinkTerminalCursor({ focused: true, cursorBlinking: true, cursorVisible: true, reducedMotion: true })).toBe(false);
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,41 @@
import { describe, expect, test } from 'bun:test';
import { collectWrappedTerminalLinkLine, extractTerminalLinks } from './terminalLinks';
describe('extractTerminalLinks', () => {
test('finds http(s) URLs and trims trailing punctuation and unbalanced brackets', () => {
expect(extractTerminalLinks('see https://example.com/a?b=1). and http://x.y/z,')).toEqual([
{ text: 'https://example.com/a?b=1', start: 4, end: 29 },
{ text: 'http://x.y/z', start: 36, end: 48 },
]);
expect(extractTerminalLinks('(https://example.com/(a))')).toEqual([
{ text: 'https://example.com/(a)', start: 1, end: 24 },
]);
});
test('ignores bare paths and other schemes', () => {
expect(extractTerminalLinks('src/lib/x.ts:12 ftp://host/file')).toEqual([]);
});
});
describe('collectWrappedTerminalLinkLine', () => {
test('joins wrapped rows and records each segment offset', () => {
const lines = [
{ isWrapped: false, translateToString: () => 'abc' },
{ isWrapped: true, translateToString: () => 'def' },
{ isWrapped: false, translateToString: () => 'ghi' },
];
expect(collectWrappedTerminalLinkLine(2, (index) => lines[index])).toEqual({
text: 'abcdef',
segments: [
{ bufferLineNumber: 1, text: 'abc', startIndex: 0, endIndex: 3 },
{ bufferLineNumber: 2, text: 'def', startIndex: 3, endIndex: 6 },
],
});
});
test('returns null when the wrapped head is unavailable', () => {
const lines = [undefined, { isWrapped: true, translateToString: () => 'x' }];
expect(collectWrappedTerminalLinkLine(2, (index) => lines[index])).toBeNull();
});
});
@@ -0,0 +1,113 @@
// Adapted from T3 Code's libghostty-vt browser adapter (MIT, T3 Tools Inc.).
// See LICENSE-T3CODE in this directory.
export interface TerminalLinkMatch {
readonly text: string;
readonly start: number;
readonly end: number;
}
export interface TerminalBufferLineLike {
readonly isWrapped?: boolean;
translateToString(trimRight?: boolean): string;
}
export interface WrappedTerminalLinkLineSegment {
readonly bufferLineNumber: number;
readonly text: string;
readonly startIndex: number;
readonly endIndex: number;
}
export interface WrappedTerminalLinkLine {
readonly text: string;
readonly segments: ReadonlyArray<WrappedTerminalLinkLineSegment>;
}
const URL_PATTERN = /https?:\/\/[^\s"'`<>]+/giu;
const TRAILING_PUNCTUATION_PATTERN = /[.,;!?]+$/;
function trimClosingDelimiters(value: string): string {
let output = value.replace(TRAILING_PUNCTUATION_PATTERN, '');
if (output.length === 0) return output;
const trimUnbalanced = (open: string, close: string) => {
while (output.endsWith(close)) {
const opens = output.split(open).length - 1;
const closes = output.split(close).length - 1;
if (opens >= closes) return;
output = output.slice(0, -1);
}
};
trimUnbalanced('(', ')');
trimUnbalanced('[', ']');
trimUnbalanced('{', '}');
return output;
}
/** http(s) URLs in one logical line, with trailing punctuation and unbalanced brackets trimmed. */
export function extractTerminalLinks(line: string): TerminalLinkMatch[] {
const matches: TerminalLinkMatch[] = [];
URL_PATTERN.lastIndex = 0;
for (const rawMatch of line.matchAll(URL_PATTERN)) {
const raw = rawMatch[0];
const start = rawMatch.index ?? -1;
if (start < 0 || raw.length === 0) continue;
const trimmed = trimClosingDelimiters(raw);
if (trimmed.length === 0) continue;
matches.push({ text: trimmed, start, end: start + trimmed.length });
}
return matches;
}
/**
* Joins a soft-wrapped line back together so a URL that the terminal broke
* across rows matches as one string, remembering where each row's text sits.
*/
export function collectWrappedTerminalLinkLine(
bufferLineNumber: number,
getLine: (bufferLineIndex: number) => TerminalBufferLineLike | null | undefined,
): WrappedTerminalLinkLine | null {
const anchorLine = getLine(bufferLineNumber - 1);
if (!anchorLine) return null;
let startBufferLineNumber = bufferLineNumber;
let startLine = anchorLine;
while (startBufferLineNumber > 1 && startLine.isWrapped) {
const previousLine = getLine(startBufferLineNumber - 2);
if (!previousLine) return null;
startBufferLineNumber -= 1;
startLine = previousLine;
}
const segments: WrappedTerminalLinkLineSegment[] = [];
let nextStartIndex = 0;
let currentBufferLineNumber = startBufferLineNumber;
while (true) {
const currentLine = getLine(currentBufferLineNumber - 1);
if (!currentLine) break;
const nextLine = getLine(currentBufferLineNumber);
const hasWrappedContinuation = nextLine?.isWrapped === true;
const text = currentLine.translateToString(!hasWrappedContinuation);
segments.push({
bufferLineNumber: currentBufferLineNumber,
text,
startIndex: nextStartIndex,
endIndex: nextStartIndex + text.length,
});
nextStartIndex += text.length;
if (!hasWrappedContinuation) break;
currentBufferLineNumber += 1;
}
return {
text: segments.map((segment) => segment.text).join(''),
segments,
};
}
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 Mitchell Hashimoto, Ghostty contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+1
View File
@@ -0,0 +1 @@
9f62873bf195e4d8a762d768a1405a5f2f7b1697
Binary file not shown.
+4
View File
@@ -1646,6 +1646,7 @@ export const dict = {
'terminalView.tabs.closeTabTitle': 'Registerkarte schließen',
'terminalView.tabs.newTabTitle': 'Neue Registerkarte',
'terminalView.viewport.inputAria': 'Terminal-Eingabe',
'terminalView.viewport.scrollbarAria': 'Terminal-Verlauf',
'directoryExplorerDialog.title': 'Projektverzeichnis hinzufügen',
'directoryExplorerDialog.description': 'Wählen Sie einen Ordner aus, der als Projekt hinzugefügt werden soll.',
'directoryExplorerDialog.toggle.showHidden': 'Versteckte anzeigen',
@@ -2979,6 +2980,9 @@ export const dict = {
'quota.window.completions': 'Vervollständigungen',
'quota.window.premiumInteractions': 'KI-Guthaben',
'terminalView.actions.attachSelection': 'Ausgewählte Ausgabe anhängen',
'terminalView.actions.copySelection': 'Ausgewählte Ausgabe kopieren',
'terminalView.toast.selectionCopied': 'Ausgabe kopiert',
'terminalView.toast.copyFailed': 'Kopieren fehlgeschlagen',
'terminalView.actions.restart': 'Terminal neu starten',
'chat.message.terminalContext': '{terminal}, Zeilen {start}-{end}',
'chat.message.context.codeComment': 'Kommentar zu {file}, Zeilen {start}-{end}',
+4
View File
@@ -8,6 +8,9 @@ export const dict = {
...linearIssuePickerI18n.en,
...linearPanelI18n.en,
'terminalView.actions.attachSelection': 'Attach selected output',
'terminalView.actions.copySelection': 'Copy selected output',
'terminalView.toast.selectionCopied': 'Output copied',
'terminalView.toast.copyFailed': 'Copy failed',
'terminalView.actions.restart': 'Restart terminal',
'chat.message.terminalContext': '{terminal}, lines {start}-{end}',
'chat.message.context.codeComment': 'Comment on {file}, lines {start}-{end}',
@@ -1847,6 +1850,7 @@ export const dict = {
'terminalView.tabs.closeTabTitle': 'Close tab',
'terminalView.tabs.newTabTitle': 'New tab',
'terminalView.viewport.inputAria': 'Terminal input',
'terminalView.viewport.scrollbarAria': 'Terminal scrollback',
'directoryExplorerDialog.title': 'Add project directory',
'directoryExplorerDialog.description': 'Choose a folder to add as a project.',
'directoryExplorerDialog.toggle.showHidden': 'Show hidden',
+4
View File
@@ -9,6 +9,9 @@ export const dict: Record<I18nKey, string> = {
...linearIssuePickerI18n.es,
...linearPanelI18n.es,
'terminalView.actions.attachSelection': 'Adjuntar salida seleccionada',
'terminalView.actions.copySelection': 'Copiar salida seleccionada',
'terminalView.toast.selectionCopied': 'Salida copiada',
'terminalView.toast.copyFailed': 'Error al copiar',
'terminalView.actions.restart': 'Reiniciar terminal',
'chat.message.terminalContext': '{terminal}, líneas {start}-{end}',
'chat.message.context.codeComment': 'Comentario en {file}, líneas {start}-{end}',
@@ -1825,6 +1828,7 @@ export const dict: Record<I18nKey, string> = {
"terminalView.tabs.closeTabTitle": "Cerrar pestaña",
"terminalView.tabs.newTabTitle": "Nueva pestaña",
"terminalView.viewport.inputAria": "Entrada de terminal",
"terminalView.viewport.scrollbarAria": "Historial del terminal",
"directoryExplorerDialog.title": "Añadir directorio de proyecto",
"directoryExplorerDialog.description": "Elige una carpeta para añadir como proyecto.",
"directoryExplorerDialog.toggle.showHidden": "Mostrar ocultos",
+4
View File
@@ -8,6 +8,9 @@ export const dict = {
...linearIssuePickerI18n.fr,
...linearPanelI18n.fr,
'terminalView.actions.attachSelection': 'Joindre la sortie sélectionnée',
'terminalView.actions.copySelection': 'Copier la sortie sélectionnée',
'terminalView.toast.selectionCopied': 'Sortie copiée',
'terminalView.toast.copyFailed': 'Échec de la copie',
'terminalView.actions.restart': 'Redémarrer le terminal',
'chat.message.terminalContext': '{terminal}, lignes {start}-{end}',
'chat.message.context.codeComment': 'Commentaire sur {file}, lignes {start}-{end}',
@@ -1604,6 +1607,7 @@ export const dict = {
'terminalView.tabs.closeTabTitle': 'Fermer l\'onglet',
'terminalView.tabs.newTabTitle': 'Nouvel onglet',
'terminalView.viewport.inputAria': 'Entrée de borne',
'terminalView.viewport.scrollbarAria': 'Historique du terminal',
'directoryExplorerDialog.title': 'Ajouter un répertoire de projet',
'directoryExplorerDialog.description': 'Choisissez un dossier à ajouter en tant que projet.',
'directoryExplorerDialog.toggle.showHidden': 'Afficher masqué',
+4
View File
@@ -9,6 +9,9 @@ export const dict: Record<I18nKey, string> = {
...linearIssuePickerI18n.ja,
...linearPanelI18n.ja,
'terminalView.actions.attachSelection': '選択した出力を添付',
'terminalView.actions.copySelection': '選択した出力をコピー',
'terminalView.toast.selectionCopied': '出力をコピーしました',
'terminalView.toast.copyFailed': 'コピーに失敗しました',
'terminalView.actions.restart': 'ターミナルを再起動',
'chat.message.terminalContext': '{terminal}、{start}〜{end}行',
'chat.message.context.codeComment': '{file} の {start}〜{end} 行へのコメント',
@@ -1843,6 +1846,7 @@ export const dict: Record<I18nKey, string> = {
'terminalView.tabs.closeTabTitle': 'タブを閉じる',
'terminalView.tabs.newTabTitle': '新しいタブ',
'terminalView.viewport.inputAria': 'ターミナル入力',
'terminalView.viewport.scrollbarAria': 'ターミナルのスクロールバック',
'directoryExplorerDialog.title': 'プロジェクトディレクトリを追加',
'directoryExplorerDialog.description': 'プロジェクトとして追加するフォルダを選択してください。',
'directoryExplorerDialog.toggle.showHidden': '隠しファイルを表示',
+4
View File
@@ -9,6 +9,9 @@ export const dict: Record<I18nKey, string> = {
...linearIssuePickerI18n.ko,
...linearPanelI18n.ko,
'terminalView.actions.attachSelection': '선택한 출력 첨부',
'terminalView.actions.copySelection': '선택한 출력 복사',
'terminalView.toast.selectionCopied': '출력을 복사했습니다',
'terminalView.toast.copyFailed': '복사 실패',
'terminalView.actions.restart': '터미널 다시 시작',
'chat.message.terminalContext': '{terminal}, {start}-{end}행',
'chat.message.context.codeComment': '{file} {start}-{end}행에 대한 댓글',
@@ -1849,6 +1852,7 @@ export const dict: Record<I18nKey, string> = {
'terminalView.tabs.closeTabTitle': '탭 닫기',
'terminalView.tabs.newTabTitle': '새 탭',
'terminalView.viewport.inputAria': '터미널 입력',
'terminalView.viewport.scrollbarAria': '터미널 스크롤백',
'directoryExplorerDialog.title': '프로젝트 디렉터리 추가',
'directoryExplorerDialog.description': '프로젝트로 추가할 폴더를 선택하세요.',
'directoryExplorerDialog.toggle.showHidden': '숨김 항목 표시',
+4
View File
@@ -9,6 +9,9 @@ export const dict: Record<I18nKey, string> = {
...linearIssuePickerI18n.pl,
...linearPanelI18n.pl,
'terminalView.actions.attachSelection': 'Dołącz zaznaczone dane wyjściowe',
'terminalView.actions.copySelection': 'Kopiuj zaznaczone dane wyjściowe',
'terminalView.toast.selectionCopied': 'Skopiowano dane wyjściowe',
'terminalView.toast.copyFailed': 'Kopiowanie nie powiodło się',
'terminalView.actions.restart': 'Uruchom terminal ponownie',
'chat.message.terminalContext': '{terminal}, wiersze {start}-{end}',
'chat.message.context.codeComment': 'Komentarz do {file}, wiersze {start}-{end}',
@@ -3043,6 +3046,7 @@ export const dict: Record<I18nKey, string> = {
'terminalView.tabs.closeTabTitle': 'Close tab',
'terminalView.tabs.newTabTitle': 'New tab',
'terminalView.viewport.inputAria': 'Terminal input',
'terminalView.viewport.scrollbarAria': 'Historia terminala',
'textarea.resizeHandleAria': 'Resize textarea',
'updateDialog.actions.copied': 'Skopiowano!',
'updateDialog.actions.copyCommand': 'Kopiuj polecenie',
@@ -9,6 +9,9 @@ export const dict: Record<I18nKey, string> = {
...linearIssuePickerI18n['pt-BR'],
...linearPanelI18n['pt-BR'],
'terminalView.actions.attachSelection': 'Anexar saída selecionada',
'terminalView.actions.copySelection': 'Copiar saída selecionada',
'terminalView.toast.selectionCopied': 'Saída copiada',
'terminalView.toast.copyFailed': 'Falha ao copiar',
'terminalView.actions.restart': 'Reiniciar terminal',
'chat.message.terminalContext': '{terminal}, linhas {start}-{end}',
'chat.message.context.codeComment': 'Comentário em {file}, linhas {start}-{end}',
@@ -1825,6 +1828,7 @@ export const dict: Record<I18nKey, string> = {
"terminalView.tabs.closeTabTitle": "Fechar aba",
"terminalView.tabs.newTabTitle": "Nova aba",
"terminalView.viewport.inputAria": "Entrada de terminal",
"terminalView.viewport.scrollbarAria": "Histórico do terminal",
"directoryExplorerDialog.title": "Adicionar diretório de projeto",
"directoryExplorerDialog.description": "Escolha uma pasta para adicionar como projeto.",
"directoryExplorerDialog.toggle.showHidden": "Mostrar ocultos",
+4
View File
@@ -8,6 +8,9 @@ export const dict = {
...linearIssuePickerI18n.tr,
...linearPanelI18n.tr,
'terminalView.actions.attachSelection': 'Seçili çıktıyı ekle',
'terminalView.actions.copySelection': 'Seçili çıktıyı kopyala',
'terminalView.toast.selectionCopied': 'Çıktı kopyalandı',
'terminalView.toast.copyFailed': 'Kopyalama başarısız',
'terminalView.actions.restart': 'Terminali yeniden başlat',
'chat.message.terminalContext': '{terminal}, {start}-{end}. satırlar',
'chat.chatInput.terminalContext': '{terminal}, {start}-{end}. satırlar',
@@ -1809,6 +1812,7 @@ export const dict = {
'terminalView.tabs.closeTabTitle': 'Sekmeyi kapat',
'terminalView.tabs.newTabTitle': 'Yeni sekme',
'terminalView.viewport.inputAria': 'Terminal girişi',
'terminalView.viewport.scrollbarAria': 'Terminal geçmişi',
'directoryExplorerDialog.title': 'Proje dizini ekle',
'directoryExplorerDialog.description': 'Proje olarak eklemek için bir klasör seçin.',
'directoryExplorerDialog.toggle.showHidden': 'Gizli dosyaları göster',
+4
View File
@@ -9,6 +9,9 @@ export const dict: Record<I18nKey, string> = {
...linearIssuePickerI18n.uk,
...linearPanelI18n.uk,
'terminalView.actions.attachSelection': 'Прикріпити вибраний вивід',
'terminalView.actions.copySelection': 'Скопіювати вибраний вивід',
'terminalView.toast.selectionCopied': 'Вивід скопійовано',
'terminalView.toast.copyFailed': 'Не вдалося скопіювати',
'terminalView.actions.restart': 'Перезапустити термінал',
'chat.message.terminalContext': '{terminal}, рядки {start}-{end}',
'chat.message.context.codeComment': 'Коментар до {file}, рядки {start}-{end}',
@@ -1825,6 +1828,7 @@ export const dict: Record<I18nKey, string> = {
"terminalView.tabs.closeTabTitle": "Закрити вкладку",
"terminalView.tabs.newTabTitle": "Нова вкладка",
"terminalView.viewport.inputAria": "Ввід терміналу",
"terminalView.viewport.scrollbarAria": "Історія термінала",
"directoryExplorerDialog.title": "Додати каталог проєкту",
"directoryExplorerDialog.description": "Виберіть папку, щоб додати її як проєкт.",
"directoryExplorerDialog.toggle.showHidden": "Показати приховані",
@@ -9,6 +9,9 @@ export const dict: Record<I18nKey, string> = {
...linearIssuePickerI18n['zh-CN'],
...linearPanelI18n['zh-CN'],
'terminalView.actions.attachSelection': '附加所选输出',
'terminalView.actions.copySelection': '复制所选输出',
'terminalView.toast.selectionCopied': '已复制输出',
'terminalView.toast.copyFailed': '复制失败',
'terminalView.actions.restart': '重启终端',
'chat.message.terminalContext': '{terminal},第 {start}-{end} 行',
'chat.message.context.codeComment': '对 {file} 第 {start}-{end} 行的评论',
@@ -1813,6 +1816,7 @@ export const dict: Record<I18nKey, string> = {
'terminalView.tabs.closeTabTitle': '关闭标签页',
'terminalView.tabs.newTabTitle': '新建标签页',
'terminalView.viewport.inputAria': '终端输入',
'terminalView.viewport.scrollbarAria': '终端回滚历史',
'directoryExplorerDialog.title': '添加项目目录',
'directoryExplorerDialog.description': '选择一个文件夹添加为项目。',
'directoryExplorerDialog.toggle.showHidden': '显示隐藏项',
@@ -9,6 +9,9 @@ export const dict: Record<I18nKey, string> = {
...linearIssuePickerI18n['zh-TW'],
...linearPanelI18n['zh-TW'],
'terminalView.actions.attachSelection': '附加所選輸出',
'terminalView.actions.copySelection': '複製所選輸出',
'terminalView.toast.selectionCopied': '已複製輸出',
'terminalView.toast.copyFailed': '複製失敗',
'terminalView.actions.restart': '重新啟動終端',
'chat.message.terminalContext': '{terminal},第 {start}-{end} 行',
'chat.message.context.codeComment': '對 {file} 第 {start}-{end} 行的評論',
@@ -1817,6 +1820,7 @@ export const dict: Record<I18nKey, string> = {
'terminalView.tabs.closeTabTitle': '關閉分頁',
'terminalView.tabs.newTabTitle': '新增分頁',
'terminalView.viewport.inputAria': '終端機輸入',
'terminalView.viewport.scrollbarAria': '終端機回捲歷史',
'directoryExplorerDialog.title': '新增專案目錄',
'directoryExplorerDialog.description': '選擇一個資料夾新增為專案。',
'directoryExplorerDialog.toggle.showHidden': '顯示隱藏項目',
@@ -1,30 +0,0 @@
import { describe, expect, test } from 'bun:test';
import {
getGhosttySafeResetSequence,
rewriteGhosttyDefaultBackgroundResets,
} from './terminalOutput';
describe('terminal output compatibility', () => {
test('builds an explicit default-background reset from supported CSS colors', () => {
expect(getGhosttySafeResetSequence('#f8f7f0')).toBe('\u001b[0;48;2;248;247;240m');
expect(getGhosttySafeResetSequence('#abc')).toBe('\u001b[0;48;2;170;187;204m');
expect(getGhosttySafeResetSequence('rgb(12, 34, 56)')).toBe('\u001b[0;48;2;12;34;56m');
expect(getGhosttySafeResetSequence('var(--surface-background)')).toBeNull();
});
test('rewrites default resets even when escape sequences span chunks', () => {
const safeReset = '\u001b[0;48;2;10;20;30m';
const first = rewriteGhosttyDefaultBackgroundResets('before\u001b[', '', safeReset);
const second = rewriteGhosttyDefaultBackgroundResets('0mafter\u001b[m', first.carry, safeReset);
expect(first).toEqual({ data: 'before', carry: '\u001b[' });
expect(second).toEqual({ data: `${safeReset}after${safeReset}`, carry: '' });
});
test('preserves output when the background cannot be resolved', () => {
expect(rewriteGhosttyDefaultBackgroundResets('0m', '\u001b[', null)).toEqual({
data: '\u001b[0m',
carry: '',
});
});
});
-52
View File
@@ -1,52 +0,0 @@
// ghostty-web 0.4.0 leaves recycled rows dirty after default SGR resets (#138).
// Keep the theme background explicit until a stable release includes the upstream WASM fix.
const DEFAULT_BACKGROUND_RESETS = ['\u001b[0m', '\u001b[m'] as const;
const parseCssRgb = (color: string): [number, number, number] | null => {
const value = color.trim();
const hex = /^#([0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.exec(value)?.[1];
if (hex) {
const expanded = hex.length <= 4
? hex.slice(0, 3).split('').map((part) => part + part).join('')
: hex.slice(0, 6);
return [
Number.parseInt(expanded.slice(0, 2), 16),
Number.parseInt(expanded.slice(2, 4), 16),
Number.parseInt(expanded.slice(4, 6), 16),
];
}
const rgb = /^rgba?\(\s*(\d{1,3})\s*[, ]\s*(\d{1,3})\s*[, ]\s*(\d{1,3})(?:\s*[,/]\s*[\d.]+)?\s*\)$/i.exec(value);
if (!rgb) return null;
const channels = rgb.slice(1, 4).map(Number);
if (channels.some((channel) => channel < 0 || channel > 255)) return null;
return channels as [number, number, number];
};
export const getGhosttySafeResetSequence = (background: string): string | null => {
const rgb = parseCssRgb(background);
return rgb ? `\u001b[0;48;2;${rgb[0]};${rgb[1]};${rgb[2]}m` : null;
};
export const rewriteGhosttyDefaultBackgroundResets = (
data: string,
carry: string,
safeReset: string | null,
): { data: string; carry: string } => {
const combined = carry + data;
if (!safeReset) return { data: combined, carry: '' };
let carryLength = 0;
const maxPrefixLength = Math.max(...DEFAULT_BACKGROUND_RESETS.map((reset) => reset.length)) - 1;
for (let length = 1; length <= Math.min(maxPrefixLength, combined.length); length += 1) {
const suffix = combined.slice(-length);
if (DEFAULT_BACKGROUND_RESETS.some((reset) => reset.length > suffix.length && reset.startsWith(suffix))) {
carryLength = length;
}
}
const nextCarry = carryLength > 0 ? combined.slice(-carryLength) : '';
let output = carryLength > 0 ? combined.slice(0, -carryLength) : combined;
for (const reset of DEFAULT_BACKGROUND_RESETS) output = output.replaceAll(reset, safeReset);
return { data: output, carry: nextCarry };
};
+40 -47
View File
@@ -1,5 +1,5 @@
import type { Ghostty } from 'ghostty-web';
import type { Theme } from '@/types/theme';
import type { GhosttyColor, GhosttyTheme } from '@/lib/ghostty/core';
export interface TerminalTheme {
background: string;
@@ -62,53 +62,46 @@ export function convertThemeToXterm(theme: Theme): TerminalTheme {
};
}
/**
* Get terminal options for Ghostty Web terminal
*/
export function getGhosttyTerminalOptions(
fontFamily: string,
fontSize: number,
theme: TerminalTheme,
ghostty: Ghostty,
disableStdin = false
) {
const powerlineFallbacks =
'"JetBrainsMonoNL Nerd Font", "FiraCode Nerd Font", "Cascadia Code PL", "Fira Code", "JetBrains Mono", "SFMono-Regular", Menlo, Consolas, "Liberation Mono", "Courier New", monospace';
const augmentedFontFamily = `${fontFamily}, ${powerlineFallbacks}`;
const ANSI_ORDER = [
'black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white',
'brightBlack', 'brightRed', 'brightGreen', 'brightYellow', 'brightBlue', 'brightMagenta', 'brightCyan', 'brightWhite',
] as const;
/** Parses #rgb, #rrggbb (alpha digits ignored) or rgb()/rgba() into channels. */
const parseTerminalColor = (color: string): GhosttyColor | null => {
const value = color.trim();
const hex = /^#([0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.exec(value)?.[1];
if (hex) {
const expanded = hex.length <= 4
? hex.slice(0, 3).split('').map((part) => part + part).join('')
: hex.slice(0, 6);
return {
r: Number.parseInt(expanded.slice(0, 2), 16),
g: Number.parseInt(expanded.slice(2, 4), 16),
b: Number.parseInt(expanded.slice(4, 6), 16),
};
}
const rgb = /^rgba?\(\s*(\d{1,3})\s*[, ]\s*(\d{1,3})\s*[, ]\s*(\d{1,3})(?:\s*[,/]\s*[\d.]+)?\s*\)$/i.exec(value);
if (!rgb) return null;
const [r, g, b] = rgb.slice(1, 4).map(Number);
if ([r, g, b].some((channel) => channel === undefined || channel < 0 || channel > 255)) return null;
return { r: r ?? 0, g: g ?? 0, b: b ?? 0 };
};
/**
* Theme colors as libghostty-vt takes them. Theme JSON values are hex, so a
* parse failure means a broken theme file: fall back to plain white on black
* for that entry rather than sending Ghostty garbage.
*/
export function toGhosttyTheme(theme: TerminalTheme): GhosttyTheme {
const background = parseTerminalColor(theme.background) ?? { r: 0, g: 0, b: 0 };
const foreground = parseTerminalColor(theme.foreground) ?? { r: 255, g: 255, b: 255 };
return {
// TerminalViewport enables blinking only while its input owns focus.
cursorBlink: false,
cursorStyle: 'bar' as const,
fontSize,
fontFamily: augmentedFontFamily,
allowTransparency: false,
theme: {
background: theme.background,
foreground: theme.foreground,
cursor: theme.cursor,
cursorAccent: theme.cursorAccent,
selectionBackground: theme.selectionBackground,
selectionForeground: theme.selectionForeground,
black: theme.black,
red: theme.red,
green: theme.green,
yellow: theme.yellow,
blue: theme.blue,
magenta: theme.magenta,
cyan: theme.cyan,
white: theme.white,
brightBlack: theme.brightBlack,
brightRed: theme.brightRed,
brightGreen: theme.brightGreen,
brightYellow: theme.brightYellow,
brightBlue: theme.brightBlue,
brightMagenta: theme.brightMagenta,
brightCyan: theme.brightCyan,
brightWhite: theme.brightWhite,
},
scrollback: 10_000,
ghostty,
disableStdin,
background,
foreground,
cursor: parseTerminalColor(theme.cursor) ?? foreground,
palette: ANSI_ORDER.map((name) => parseTerminalColor(theme[name]) ?? foreground),
selectionBackground: theme.selectionBackground,
};
}
@@ -1,23 +0,0 @@
import { describe, expect, test } from 'bun:test';
import {
getTerminalCellFromPoint,
getTerminalWordRange,
} from './terminalTouchSelection';
describe('terminal touch selection', () => {
test('maps touch points to clamped terminal cells', () => {
const bounds = { left: 20, top: 40, width: 800, height: 240 };
expect(getTerminalCellFromPoint(425, 165, bounds, 80, 24)).toEqual({ column: 40, row: 12 });
expect(getTerminalCellFromPoint(-100, 500, bounds, 80, 24)).toEqual({ column: 0, row: 23 });
expect(getTerminalCellFromPoint(20, 40, { ...bounds, width: 0 }, 80, 24)).toBeNull();
});
test('selects the non-whitespace token around a long press', () => {
expect(getTerminalWordRange(Array.from(' /projects/openchamber '), 10)).toEqual({
startColumn: 2,
endColumn: 22,
});
expect(getTerminalWordRange(Array.from('foo bar'), 3)).toEqual({ startColumn: 3, endColumn: 3 });
});
});
@@ -1,48 +0,0 @@
export type TerminalCellPosition = {
column: number;
row: number;
};
type TerminalViewportRect = {
left: number;
top: number;
width: number;
height: number;
};
export const getTerminalCellFromPoint = (
clientX: number,
clientY: number,
bounds: TerminalViewportRect,
columns: number,
rows: number,
): TerminalCellPosition | null => {
if (bounds.width <= 0 || bounds.height <= 0 || columns <= 0 || rows <= 0) return null;
const column = Math.floor(((clientX - bounds.left) / bounds.width) * columns);
const row = Math.floor(((clientY - bounds.top) / bounds.height) * rows);
return {
column: Math.max(0, Math.min(columns - 1, column)),
row: Math.max(0, Math.min(rows - 1, row)),
};
};
export const getTerminalWordRange = (
cells: string[],
column: number,
): { startColumn: number; endColumn: number } => {
const clampedColumn = Math.max(0, Math.min(cells.length - 1, column));
const isWordCell = (value: string | undefined) => Boolean(value && !/^\s+$/u.test(value));
if (!isWordCell(cells[clampedColumn])) {
return { startColumn: clampedColumn, endColumn: clampedColumn };
}
let startColumn = clampedColumn;
let endColumn = clampedColumn;
while (startColumn > 0 && isWordCell(cells[startColumn - 1])) startColumn -= 1;
while (endColumn < cells.length - 1 && isWordCell(cells[endColumn + 1])) endColumn += 1;
return { startColumn, endColumn };
};