docs: update README, AGENTS.md, Makefile, and architecture docs
CI / check (push) Has been cancelled
CI / check (push) Has been cancelled
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
## Project Structure & Module Organization
|
||||
|
||||
`cmd/webterm/` contains the CLI entrypoint. Core server, session, Docker, replay, screenshot, and static-serving code lives in `webterm/`. Shared internal helpers live in `internal/`. Frontend terminal code is in `webterm/static/js/terminal.ts`, with the bundled output committed as `webterm/static/js/terminal.js`. Static assets such as fonts, icons, and WASM files live under `webterm/static/`. Documentation and reference media live in `docs/`.
|
||||
`cmd/webterm/` contains the CLI entrypoint. Core server, session, Docker, replay, screenshot, and static-serving code lives in `webterm/`. Shared internal helpers live in `internal/`. Frontend terminal code is in `webterm/static/js/terminal.ts`, which bundles to generated output at `webterm/static/js/terminal.js`. Static assets such as fonts, icons, and WASM files live under `webterm/static/`. Documentation and reference media live in `docs/`.
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
## Coding Style & Naming Conventions
|
||||
|
||||
Use `gofmt` for Go formatting; run `make format` before submitting Go-heavy changes. Follow existing Go naming: exported identifiers use `CamelCase`, internal helpers use `camelCase`, and tests live beside source files. TypeScript in `webterm/static/js/` uses strict mode, 2-space indentation, and direct DOM-oriented code rather than framework abstractions. Keep generated bundles in sync with source changes.
|
||||
Use `gofmt` for Go formatting; run `make format` before submitting Go-heavy changes. Follow existing Go naming: exported identifiers use `CamelCase`, internal helpers use `camelCase`, and tests live beside source files. TypeScript in `webterm/static/js/` uses strict mode, 2-space indentation, and direct DOM-oriented code rather than framework abstractions. Rebuild generated bundles when frontend source changes.
|
||||
|
||||
## Testing Guidelines
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ bundle-watch: node_modules ## Watch mode for frontend development
|
||||
@test -f $(GHOSTTY_WASM) || bun run copy-wasm
|
||||
bun run watch
|
||||
|
||||
build-go: ## Build Go CLI binary
|
||||
build-go: build-fast ## Build Go CLI binary
|
||||
cd $(GO_DIR) && mkdir -p bin && go build -ldflags "$(GO_VERSION_LDFLAGS)" -o ./bin/webterm ./cmd/webterm
|
||||
|
||||
clean: ## Remove coverage artifacts
|
||||
|
||||
@@ -35,23 +35,24 @@ go install github.com/rcarmo/webterm/cmd/webterm@latest
|
||||
```bash
|
||||
git clone https://github.com/rcarmo/webterm.git
|
||||
cd webterm
|
||||
mkdir -p bin
|
||||
go build -o ./bin/webterm ./cmd/webterm
|
||||
make build-go
|
||||
```
|
||||
|
||||
The command above produces `bin/webterm`; you can also build it from repo root with `make build-go`.
|
||||
That produces `bin/webterm` and rebuilds generated frontend assets first.
|
||||
|
||||
## Quick start
|
||||
|
||||
Run a default shell session:
|
||||
|
||||
```bash
|
||||
make build-fast
|
||||
go run ./cmd/webterm
|
||||
```
|
||||
|
||||
Run a specific command:
|
||||
|
||||
```bash
|
||||
make build-fast
|
||||
go run ./cmd/webterm -- htop
|
||||
```
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
`webterm` is a Go HTTP/WebSocket server that hosts one or more terminal sessions and renders screenshot/telemetry surfaces for a dashboard UI.
|
||||
|
||||
```
|
||||
Browser (terminal.js + ghostty-vt.wasm)
|
||||
Browser (generated terminal.js + ghostty-vt.wasm)
|
||||
│
|
||||
│ WS / HTTP / SSE
|
||||
▼
|
||||
@@ -42,7 +42,7 @@ webterm/server.go (LocalServer)
|
||||
Assets live in `webterm/static`:
|
||||
|
||||
- `js/terminal.ts` source
|
||||
- `js/terminal.js` bundled client
|
||||
- `js/terminal.js` generated bundled client
|
||||
- `js/ghostty-vt.wasm`
|
||||
- `monospace.css`, icons, `manifest.json`
|
||||
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
declare const __WEBTERM_BUILD_VERSION__: string;
|
||||
|
||||
export type FrontendLogLevel = "debug" | "info" | "warn" | "error";
|
||||
|
||||
export interface WebtermScopedLogger {
|
||||
debug: (text: string, extra?: Record<string, unknown>) => void;
|
||||
info: (text: string, extra?: Record<string, unknown>) => void;
|
||||
warn: (text: string, extra?: Record<string, unknown>) => void;
|
||||
error: (text: string, extra?: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
webtermClientLog?: (text: string, extra?: Record<string, unknown>) => Promise<void>;
|
||||
webtermClientLogSessionID?: string;
|
||||
webtermClientBuildVersion?: string;
|
||||
webtermLogs?: {
|
||||
bug: WebtermScopedLogger;
|
||||
ui: WebtermScopedLogger;
|
||||
voice: WebtermScopedLogger;
|
||||
ws: WebtermScopedLogger;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const FRONTEND_BUILD_VERSION = __WEBTERM_BUILD_VERSION__;
|
||||
|
||||
function createFrontendLogSessionID(): string {
|
||||
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
|
||||
let out = "";
|
||||
for (let i = 0; i < 24; i += 1) {
|
||||
out += chars[Math.floor(Math.random() * chars.length)];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export const frontendLogSessionID = createFrontendLogSessionID();
|
||||
|
||||
function normalizeFrontendLogValue(value: unknown): unknown {
|
||||
if (value instanceof Error) {
|
||||
return {
|
||||
name: value.name,
|
||||
message: value.message,
|
||||
stack: value.stack,
|
||||
};
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => normalizeFrontendLogValue(item));
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value as Record<string, unknown>).map(([key, item]) => [key, normalizeFrontendLogValue(item)])
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function serializeFrontendLogContext(extra: Record<string, unknown> = {}): string {
|
||||
const entries = Object.entries(extra);
|
||||
if (entries.length === 0) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(Object.fromEntries(entries.map(([key, value]) => [key, normalizeFrontendLogValue(value)])));
|
||||
} catch {
|
||||
return String(extra);
|
||||
}
|
||||
}
|
||||
|
||||
export async function postFrontendLog(
|
||||
level: FrontendLogLevel,
|
||||
text: string,
|
||||
extra: Record<string, unknown> = {}
|
||||
): Promise<void> {
|
||||
const message = typeof text === "string" ? text.trim() : "";
|
||||
if (!message) return;
|
||||
try {
|
||||
await fetch("/api/client-log", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "same-origin",
|
||||
body: JSON.stringify({
|
||||
session_id: frontendLogSessionID,
|
||||
text: message,
|
||||
frontend_version: FRONTEND_BUILD_VERSION,
|
||||
level,
|
||||
context: serializeFrontendLogContext(extra),
|
||||
}),
|
||||
keepalive: true,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn("[webterm] Failed to post client log:", error);
|
||||
}
|
||||
}
|
||||
|
||||
function logFrontend(level: FrontendLogLevel, text: string, extra: Record<string, unknown> = {}): void {
|
||||
void postFrontendLog(level, text, extra);
|
||||
}
|
||||
|
||||
function logFrontendDebug(text: string, extra: Record<string, unknown> = {}): void {
|
||||
logFrontend("debug", text, extra);
|
||||
}
|
||||
|
||||
function logFrontendInfo(text: string, extra: Record<string, unknown> = {}): void {
|
||||
logFrontend("info", text, extra);
|
||||
}
|
||||
|
||||
function logFrontendWarn(text: string, extra: Record<string, unknown> = {}): void {
|
||||
logFrontend("warn", text, extra);
|
||||
}
|
||||
|
||||
function logFrontendError(text: string, extra: Record<string, unknown> = {}): void {
|
||||
logFrontend("error", text, extra);
|
||||
}
|
||||
|
||||
export function createScopedFrontendLogger(scope: string): WebtermScopedLogger {
|
||||
const withScope = (extra: Record<string, unknown> = {}): Record<string, unknown> => ({
|
||||
scope,
|
||||
...extra,
|
||||
});
|
||||
return {
|
||||
debug: (text, extra = {}) => logFrontendDebug(text, withScope(extra)),
|
||||
info: (text, extra = {}) => logFrontendInfo(text, withScope(extra)),
|
||||
warn: (text, extra = {}) => logFrontendWarn(text, withScope(extra)),
|
||||
error: (text, extra = {}) => logFrontendError(text, withScope(extra)),
|
||||
};
|
||||
}
|
||||
|
||||
export const bugLog = createScopedFrontendLogger("bug");
|
||||
export const uiLog = createScopedFrontendLogger("ui");
|
||||
export const voiceLog = createScopedFrontendLogger("voice");
|
||||
export const wsLog = createScopedFrontendLogger("ws");
|
||||
|
||||
window.webtermClientLog = (text: string, extra: Record<string, unknown> = {}) =>
|
||||
postFrontendLog("info", text, extra);
|
||||
window.webtermClientLogSessionID = frontendLogSessionID;
|
||||
window.webtermClientBuildVersion = FRONTEND_BUILD_VERSION;
|
||||
window.webtermLogs = {
|
||||
bug: bugLog,
|
||||
ui: uiLog,
|
||||
voice: voiceLog,
|
||||
ws: wsLog,
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
const sharedScriptLoads = new Map<string, Promise<void>>();
|
||||
|
||||
/** Shared TextDecoder (stateless for UTF-8, safe to share) */
|
||||
export const sharedTextDecoder = new TextDecoder();
|
||||
|
||||
/** Get WASM path based on script location */
|
||||
export function getWasmPath(): string {
|
||||
const scripts = document.querySelectorAll('script[src*="terminal.js"]');
|
||||
if (scripts.length > 0) {
|
||||
const scriptSrc = (scripts[0] as HTMLScriptElement).src;
|
||||
const basePath = scriptSrc.substring(0, scriptSrc.lastIndexOf("/") + 1);
|
||||
return basePath + "ghostty-vt.wasm";
|
||||
}
|
||||
return "/static/js/ghostty-vt.wasm";
|
||||
}
|
||||
|
||||
export function getStaticJSBasePath(): string {
|
||||
const scripts = document.querySelectorAll('script[src*="terminal.js"]');
|
||||
if (scripts.length > 0) {
|
||||
const scriptSrc = (scripts[0] as HTMLScriptElement).src;
|
||||
return scriptSrc.substring(0, scriptSrc.lastIndexOf("/") + 1);
|
||||
}
|
||||
return "/static/js/";
|
||||
}
|
||||
|
||||
export function loadScriptOnce(src: string): Promise<void> {
|
||||
const existing = sharedScriptLoads.get(src);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const promise = new Promise<void>((resolve, reject) => {
|
||||
const script = document.createElement("script");
|
||||
script.src = src;
|
||||
script.async = true;
|
||||
script.onload = () => resolve();
|
||||
script.onerror = () => {
|
||||
sharedScriptLoads.delete(src);
|
||||
reject(new Error(`Failed to load script: ${src}`));
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
|
||||
sharedScriptLoads.set(src, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
export function formatSherpaStatus(status: string): string {
|
||||
if (!status) {
|
||||
return "";
|
||||
}
|
||||
if (status === "Running...") {
|
||||
return "Model ready";
|
||||
}
|
||||
const downloadMatch = status.match(/Downloading data... \((\d+)\/(\d+)\)/);
|
||||
if (!downloadMatch) {
|
||||
return status;
|
||||
}
|
||||
|
||||
const downloaded = BigInt(downloadMatch[1]);
|
||||
const total = BigInt(downloadMatch[2]);
|
||||
const percent =
|
||||
total === 0n ? 0 : Number((downloaded * 10_000n) / total) / 100;
|
||||
return `Downloading ${percent.toFixed(2)}%`;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { ITheme } from "ghostty-web";
|
||||
|
||||
import { uiLog } from "./frontend-log";
|
||||
import { DEFAULT_FONT_FAMILY, THEMES } from "./terminal-themes";
|
||||
|
||||
export interface TerminalConfig {
|
||||
fontFamily?: string;
|
||||
fontSize?: number;
|
||||
scrollback?: number;
|
||||
theme?: ITheme;
|
||||
}
|
||||
|
||||
/** Parse configuration from element data attributes */
|
||||
export function parseConfig(element: HTMLElement): TerminalConfig {
|
||||
const config: TerminalConfig = {};
|
||||
|
||||
if (element.dataset.fontFamily) {
|
||||
let fontFamily = element.dataset.fontFamily;
|
||||
// Resolve CSS variables - Canvas 2D context doesn't understand var(--name) syntax
|
||||
if (fontFamily.startsWith("var(")) {
|
||||
const varMatch = fontFamily.match(/var\(([^)]+)\)/);
|
||||
if (varMatch) {
|
||||
const varName = varMatch[1].trim();
|
||||
const resolved = getComputedStyle(document.documentElement).getPropertyValue(varName).trim();
|
||||
if (resolved) {
|
||||
fontFamily = resolved;
|
||||
} else {
|
||||
uiLog.warn("CSS variable not found; using default font", { varName });
|
||||
fontFamily = DEFAULT_FONT_FAMILY;
|
||||
}
|
||||
}
|
||||
}
|
||||
config.fontFamily = fontFamily;
|
||||
}
|
||||
if (element.dataset.fontSize) {
|
||||
config.fontSize = parseInt(element.dataset.fontSize, 10);
|
||||
}
|
||||
if (element.dataset.scrollback) {
|
||||
config.scrollback = parseInt(element.dataset.scrollback, 10);
|
||||
}
|
||||
if (element.dataset.theme) {
|
||||
const themeName = element.dataset.theme.toLowerCase();
|
||||
if (themeName in THEMES) {
|
||||
config.theme = THEMES[themeName];
|
||||
} else {
|
||||
try {
|
||||
config.theme = JSON.parse(element.dataset.theme) as ITheme;
|
||||
} catch (error) {
|
||||
uiLog.warn("Unknown theme configuration", { theme: element.dataset.theme, error });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
export function isMobileDevice(): boolean {
|
||||
const touchPoints = navigator.maxTouchPoints || 0;
|
||||
const coarsePointer =
|
||||
window.matchMedia?.("(pointer: coarse)").matches ||
|
||||
window.matchMedia?.("(any-pointer: coarse)").matches ||
|
||||
false;
|
||||
const isiPadDesktopMode =
|
||||
/Macintosh/i.test(navigator.userAgent) && touchPoints > 1;
|
||||
const hasTouchEvents = "ontouchstart" in window;
|
||||
|
||||
return (
|
||||
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
|
||||
navigator.userAgent
|
||||
) ||
|
||||
isiPadDesktopMode ||
|
||||
touchPoints > 0 ||
|
||||
hasTouchEvents ||
|
||||
coarsePointer
|
||||
);
|
||||
}
|
||||
|
||||
const SHIFT_KEY_MAP: Record<string, string> = {
|
||||
"`": "~",
|
||||
"1": "!",
|
||||
"2": "@",
|
||||
"3": "#",
|
||||
"4": "$",
|
||||
"5": "%",
|
||||
"6": "^",
|
||||
"7": "&",
|
||||
"8": "*",
|
||||
"9": "(",
|
||||
"0": ")",
|
||||
"-": "_",
|
||||
"=": "+",
|
||||
"[": "{",
|
||||
"]": "}",
|
||||
"\\": "|",
|
||||
";": ":",
|
||||
"'": "\"",
|
||||
",": "<",
|
||||
".": ">",
|
||||
"/": "?",
|
||||
};
|
||||
|
||||
const CTRL_KEY_MAP: Record<string, string> = {
|
||||
"2": "@",
|
||||
"3": "[",
|
||||
"4": "\\",
|
||||
"5": "]",
|
||||
"6": "^",
|
||||
"7": "_",
|
||||
"8": "?",
|
||||
};
|
||||
|
||||
export type VirtualKeyboardActionId =
|
||||
| "mode-alpha"
|
||||
| "mode-symbol"
|
||||
| "toggle-voice";
|
||||
|
||||
export type VirtualKeyboardKey = {
|
||||
kind: "char" | "modifier" | "action" | "arrow" | "space";
|
||||
label: string;
|
||||
modifier?: "ctrl" | "alt" | "shift" | "fn";
|
||||
shiftLabel?: string;
|
||||
shiftValue?: string;
|
||||
value?: string;
|
||||
seq?: string;
|
||||
width?: number;
|
||||
actionId?: VirtualKeyboardActionId;
|
||||
};
|
||||
|
||||
export const VIRTUAL_KEYBOARD_ALPHA_LAYOUT: VirtualKeyboardKey[][] = [
|
||||
[
|
||||
{ kind: "char", label: "q", shiftLabel: "Q" },
|
||||
{ kind: "char", label: "w", shiftLabel: "W" },
|
||||
{ kind: "char", label: "e", shiftLabel: "E" },
|
||||
{ kind: "char", label: "r", shiftLabel: "R" },
|
||||
{ kind: "char", label: "t", shiftLabel: "T" },
|
||||
{ kind: "char", label: "y", shiftLabel: "Y" },
|
||||
{ kind: "char", label: "u", shiftLabel: "U" },
|
||||
{ kind: "char", label: "i", shiftLabel: "I" },
|
||||
{ kind: "char", label: "o", shiftLabel: "O" },
|
||||
{ kind: "char", label: "p", shiftLabel: "P" },
|
||||
],
|
||||
[
|
||||
{ kind: "char", label: "a", shiftLabel: "A" },
|
||||
{ kind: "char", label: "s", shiftLabel: "S" },
|
||||
{ kind: "char", label: "d", shiftLabel: "D" },
|
||||
{ kind: "char", label: "f", shiftLabel: "F" },
|
||||
{ kind: "char", label: "g", shiftLabel: "G" },
|
||||
{ kind: "char", label: "h", shiftLabel: "H" },
|
||||
{ kind: "char", label: "j", shiftLabel: "J" },
|
||||
{ kind: "char", label: "k", shiftLabel: "K" },
|
||||
{ kind: "char", label: "l", shiftLabel: "L" },
|
||||
],
|
||||
[
|
||||
{ kind: "modifier", label: "⇧", modifier: "shift" },
|
||||
{ kind: "char", label: "z", shiftLabel: "Z" },
|
||||
{ kind: "char", label: "x", shiftLabel: "X" },
|
||||
{ kind: "char", label: "c", shiftLabel: "C" },
|
||||
{ kind: "char", label: "v", shiftLabel: "V" },
|
||||
{ kind: "char", label: "b", shiftLabel: "B" },
|
||||
{ kind: "char", label: "n", shiftLabel: "N" },
|
||||
{ kind: "char", label: "m", shiftLabel: "M" },
|
||||
{ kind: "action", label: "⌫", value: "Backspace", seq: "\x7f" },
|
||||
],
|
||||
[
|
||||
{ kind: "action", label: "123", actionId: "mode-symbol", width: 1.05 },
|
||||
{ kind: "modifier", label: "Ctrl", modifier: "ctrl", width: 1.05 },
|
||||
{ kind: "modifier", label: "Alt", modifier: "alt", width: 1.05 },
|
||||
{ kind: "space", label: "␣", value: " ", width: 3.1 },
|
||||
{ kind: "action", label: "Mic", actionId: "toggle-voice", width: 1.15 },
|
||||
{ kind: "action", label: "⏎", seq: "\r", width: 1.15 },
|
||||
],
|
||||
];
|
||||
|
||||
export const VIRTUAL_KEYBOARD_SYMBOL_LAYOUT: VirtualKeyboardKey[][] = [
|
||||
[
|
||||
{ kind: "char", label: "1", value: "1" },
|
||||
{ kind: "char", label: "2", value: "2" },
|
||||
{ kind: "char", label: "3", value: "3" },
|
||||
{ kind: "char", label: "4", value: "4" },
|
||||
{ kind: "char", label: "5", value: "5" },
|
||||
{ kind: "char", label: "6", value: "6" },
|
||||
{ kind: "char", label: "7", value: "7" },
|
||||
{ kind: "char", label: "8", value: "8" },
|
||||
{ kind: "char", label: "9", value: "9" },
|
||||
{ kind: "char", label: "0", value: "0" },
|
||||
],
|
||||
[
|
||||
{ kind: "char", label: "-", value: "-" },
|
||||
{ kind: "char", label: "/", value: "/" },
|
||||
{ kind: "char", label: ":", value: ":" },
|
||||
{ kind: "char", label: ";", value: ";" },
|
||||
{ kind: "char", label: "(", value: "(" },
|
||||
{ kind: "char", label: ")", value: ")" },
|
||||
{ kind: "char", label: "$", value: "$" },
|
||||
{ kind: "char", label: "&", value: "&" },
|
||||
{ kind: "char", label: "@", value: "@" },
|
||||
{ kind: "char", label: "\"", value: "\"" },
|
||||
],
|
||||
[
|
||||
{ kind: "action", label: "Esc", seq: "\x1b", width: 1.35 },
|
||||
{ kind: "char", label: ".", value: "." },
|
||||
{ kind: "char", label: ",", value: "," },
|
||||
{ kind: "char", label: "?", value: "?" },
|
||||
{ kind: "char", label: "!", value: "!" },
|
||||
{ kind: "char", label: "'", value: "'" },
|
||||
{ kind: "char", label: "[", value: "[" },
|
||||
{ kind: "char", label: "]", value: "]" },
|
||||
{ kind: "action", label: "⌫", value: "Backspace", seq: "\x7f", width: 1.35 },
|
||||
],
|
||||
[
|
||||
{ kind: "action", label: "ABC", actionId: "mode-alpha", width: 1.1 },
|
||||
{ kind: "modifier", label: "Ctrl", modifier: "ctrl", width: 1.1 },
|
||||
{ kind: "modifier", label: "Alt", modifier: "alt", width: 1.1 },
|
||||
{ kind: "space", label: "␣", value: " ", width: 4 },
|
||||
{ kind: "action", label: "⏎", seq: "\r", width: 1.35 },
|
||||
],
|
||||
];
|
||||
|
||||
export type VirtualKeyboardKeyBounds = {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
rowIndex: number;
|
||||
colIndex: number;
|
||||
key: VirtualKeyboardKey;
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type ActiveVirtualKeyboardPress = {
|
||||
pointerId: number;
|
||||
keyIndex: number;
|
||||
key: VirtualKeyboardKeyBounds | null;
|
||||
repeatTimeout?: number;
|
||||
};
|
||||
|
||||
const FN_NORMAL_KEYS = [
|
||||
"\x1bOP",
|
||||
"\x1bOQ",
|
||||
"\x1bOR",
|
||||
"\x1bOS",
|
||||
"\x1b[15~",
|
||||
"\x1b[17~",
|
||||
"\x1b[18~",
|
||||
"\x1b[19~",
|
||||
"\x1b[20~",
|
||||
"\x1b[21~",
|
||||
];
|
||||
|
||||
const FN_SHIFT_KEYS = [
|
||||
"\x1b[23~",
|
||||
"\x1b[24~",
|
||||
"\x1b[25~",
|
||||
"\x1b[26~",
|
||||
"\x1b[28~",
|
||||
"\x1b[29~",
|
||||
"\x1b[31~",
|
||||
"\x1b[32~",
|
||||
"\x1b[33~",
|
||||
"\x1b[34~",
|
||||
];
|
||||
|
||||
function applyShiftModifier(key: string): string {
|
||||
if (key.length !== 1) {
|
||||
return key;
|
||||
}
|
||||
if (key >= "a" && key <= "z") {
|
||||
return key.toUpperCase();
|
||||
}
|
||||
return SHIFT_KEY_MAP[key] ?? key;
|
||||
}
|
||||
|
||||
export function applyCtrlModifier(key: string): string {
|
||||
if (key.length !== 1) {
|
||||
return key;
|
||||
}
|
||||
const mapped = CTRL_KEY_MAP[key] ?? key;
|
||||
if (mapped === "?") {
|
||||
return "\x7f";
|
||||
}
|
||||
const code = mapped.toUpperCase().charCodeAt(0);
|
||||
if (code >= 64 && code <= 95) {
|
||||
return String.fromCharCode(code - 64);
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
export function applyFnModifier(key: string, useShift: boolean): string | null {
|
||||
if (key.length !== 1) {
|
||||
return null;
|
||||
}
|
||||
const index = "1234567890".indexOf(key);
|
||||
if (index < 0) {
|
||||
return null;
|
||||
}
|
||||
return useShift ? FN_SHIFT_KEYS[index] : FN_NORMAL_KEYS[index];
|
||||
}
|
||||
|
||||
export function applyAltModifier(text: string): string {
|
||||
if (!text || text.startsWith("\x1b")) {
|
||||
return text;
|
||||
}
|
||||
return `\x1b${text}`;
|
||||
}
|
||||
|
||||
export function applyModifiers(
|
||||
text: string,
|
||||
useShift: boolean,
|
||||
useCtrl: boolean,
|
||||
useAlt: boolean,
|
||||
useFn: boolean
|
||||
): string {
|
||||
if (text.length !== 1) {
|
||||
return text;
|
||||
}
|
||||
if (useFn) {
|
||||
const fnApplied = applyFnModifier(text, useShift);
|
||||
if (fnApplied) {
|
||||
return useAlt ? applyAltModifier(fnApplied) : fnApplied;
|
||||
}
|
||||
}
|
||||
if (useCtrl) {
|
||||
const ctrlApplied = applyCtrlModifier(text);
|
||||
if (ctrlApplied !== text) {
|
||||
return useAlt ? applyAltModifier(ctrlApplied) : ctrlApplied;
|
||||
}
|
||||
}
|
||||
if (useShift) {
|
||||
const shifted = applyShiftModifier(text);
|
||||
return useAlt ? applyAltModifier(shifted) : shifted;
|
||||
}
|
||||
return useAlt ? applyAltModifier(text) : text;
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
import type { ITheme } from "ghostty-web";
|
||||
|
||||
/** Default font stack - prefers system monospace, falls back through programming fonts */
|
||||
export const DEFAULT_FONT_FAMILY =
|
||||
'ui-monospace, "SFMono-Regular", "FiraCode Nerd Font", "FiraMono Nerd Font", ' +
|
||||
'"Fira Code", "Roboto Mono", Menlo, Monaco, Consolas, "Liberation Mono", ' +
|
||||
'"DejaVu Sans Mono", "Courier New", monospace';
|
||||
|
||||
/** Predefined terminal themes */
|
||||
export const THEMES: Record<string, ITheme> = {
|
||||
// Tango - default theme (GNOME/xterm.js colors)
|
||||
tango: {
|
||||
background: "#000000",
|
||||
foreground: "#d3d7cf",
|
||||
cursor: "#d3d7cf",
|
||||
cursorAccent: "#000000",
|
||||
selectionBackground: "#d3d7cf",
|
||||
selectionForeground: "#000000",
|
||||
black: "#2e3436",
|
||||
red: "#cc0000",
|
||||
green: "#4e9a06",
|
||||
yellow: "#c4a000",
|
||||
blue: "#3465a4",
|
||||
magenta: "#75507b",
|
||||
cyan: "#06989a",
|
||||
white: "#d3d7cf",
|
||||
brightBlack: "#555753",
|
||||
brightRed: "#ef2929",
|
||||
brightGreen: "#8ae234",
|
||||
brightYellow: "#fce94f",
|
||||
brightBlue: "#729fcf",
|
||||
brightMagenta: "#ad7fa8",
|
||||
brightCyan: "#34e2e2",
|
||||
brightWhite: "#eeeeec",
|
||||
},
|
||||
// Classic xterm (VGA colors, pure black background)
|
||||
xterm: {
|
||||
background: "#000000",
|
||||
foreground: "#e5e5e5",
|
||||
cursor: "#e5e5e5",
|
||||
cursorAccent: "#000000",
|
||||
selectionBackground: "#e5e5e5",
|
||||
selectionForeground: "#000000",
|
||||
black: "#000000",
|
||||
red: "#cd0000",
|
||||
green: "#00cd00",
|
||||
yellow: "#cdcd00",
|
||||
blue: "#0000cd",
|
||||
magenta: "#cd00cd",
|
||||
cyan: "#00cdcd",
|
||||
white: "#e5e5e5",
|
||||
brightBlack: "#4d4d4d",
|
||||
brightRed: "#ff0000",
|
||||
brightGreen: "#00ff00",
|
||||
brightYellow: "#ffff00",
|
||||
brightBlue: "#0000ff",
|
||||
brightMagenta: "#ff00ff",
|
||||
brightCyan: "#00ffff",
|
||||
brightWhite: "#ffffff",
|
||||
},
|
||||
// Monokai Classic
|
||||
monokai: {
|
||||
background: "#272822",
|
||||
foreground: "#f8f8f2",
|
||||
cursor: "#f8f8f2",
|
||||
cursorAccent: "#272822",
|
||||
selectionBackground: "#75715e",
|
||||
selectionForeground: "#f8f8f2",
|
||||
black: "#272822",
|
||||
red: "#f92672",
|
||||
green: "#a6e22e",
|
||||
yellow: "#f4bf75",
|
||||
blue: "#66d9ef",
|
||||
magenta: "#ae81ff",
|
||||
cyan: "#a1efe4",
|
||||
white: "#f8f8f2",
|
||||
brightBlack: "#75715e",
|
||||
brightRed: "#f92672",
|
||||
brightGreen: "#a6e22e",
|
||||
brightYellow: "#f4bf75",
|
||||
brightBlue: "#66d9ef",
|
||||
brightMagenta: "#ae81ff",
|
||||
brightCyan: "#a1efe4",
|
||||
brightWhite: "#f9f8f5",
|
||||
},
|
||||
"monokai-pro": {
|
||||
background: "#2d2a2e",
|
||||
foreground: "#fcfcfa",
|
||||
cursor: "#fcfcfa",
|
||||
cursorAccent: "#2d2a2e",
|
||||
selectionBackground: "#5b595c",
|
||||
selectionForeground: "#fcfcfa",
|
||||
black: "#403e41",
|
||||
red: "#ff6188",
|
||||
green: "#a9dc76",
|
||||
yellow: "#ffd866",
|
||||
blue: "#78dce8",
|
||||
magenta: "#ab9df2",
|
||||
cyan: "#78dce8",
|
||||
white: "#fcfcfa",
|
||||
brightBlack: "#727072",
|
||||
brightRed: "#ff6188",
|
||||
brightGreen: "#a9dc76",
|
||||
brightYellow: "#ffd866",
|
||||
brightBlue: "#78dce8",
|
||||
brightMagenta: "#ab9df2",
|
||||
brightCyan: "#78dce8",
|
||||
brightWhite: "#ffffff",
|
||||
},
|
||||
ristretto: {
|
||||
background: "#2c2c2c",
|
||||
foreground: "#eeeeee",
|
||||
cursor: "#eeeeee",
|
||||
cursorAccent: "#2c2c2c",
|
||||
selectionBackground: "#7f7f7f",
|
||||
selectionForeground: "#eeeeee",
|
||||
black: "#2c2c2c",
|
||||
red: "#cdaf95",
|
||||
green: "#a8ff60",
|
||||
yellow: "#bfbb1f",
|
||||
blue: "#75a5b0",
|
||||
magenta: "#ff73fd",
|
||||
cyan: "#5ffdff",
|
||||
white: "#b9b9b9",
|
||||
brightBlack: "#545454",
|
||||
brightRed: "#fcb08f",
|
||||
brightGreen: "#a8ff60",
|
||||
brightYellow: "#fffeb7",
|
||||
brightBlue: "#a5c7ff",
|
||||
brightMagenta: "#ff9cfe",
|
||||
brightCyan: "#d5ffff",
|
||||
brightWhite: "#ffffff",
|
||||
},
|
||||
dark: {
|
||||
background: "#1e1e1e",
|
||||
foreground: "#d4d4d4",
|
||||
cursor: "#d4d4d4",
|
||||
cursorAccent: "#1e1e1e",
|
||||
selectionBackground: "#264f78",
|
||||
selectionForeground: "#d4d4d4",
|
||||
black: "#000000",
|
||||
red: "#cd3131",
|
||||
green: "#0dbc79",
|
||||
yellow: "#e5e510",
|
||||
blue: "#2472c8",
|
||||
magenta: "#bc3fbc",
|
||||
cyan: "#11a8cd",
|
||||
white: "#e5e5e5",
|
||||
brightBlack: "#666666",
|
||||
brightRed: "#f14c4c",
|
||||
brightGreen: "#23d18b",
|
||||
brightYellow: "#f5f543",
|
||||
brightBlue: "#3b8eea",
|
||||
brightMagenta: "#d670d6",
|
||||
brightCyan: "#29b8db",
|
||||
brightWhite: "#e5e5e5",
|
||||
},
|
||||
light: {
|
||||
background: "#ffffff",
|
||||
foreground: "#000000",
|
||||
cursor: "#000000",
|
||||
cursorAccent: "#ffffff",
|
||||
selectionBackground: "#add6ff",
|
||||
selectionForeground: "#000000",
|
||||
black: "#000000",
|
||||
red: "#cd3131",
|
||||
green: "#00bc00",
|
||||
yellow: "#949800",
|
||||
blue: "#0451a5",
|
||||
magenta: "#bc05bc",
|
||||
cyan: "#0598bc",
|
||||
white: "#555555",
|
||||
brightBlack: "#666666",
|
||||
brightRed: "#cd3131",
|
||||
brightGreen: "#14ce14",
|
||||
brightYellow: "#b5ba00",
|
||||
brightBlue: "#0451a5",
|
||||
brightMagenta: "#bc05bc",
|
||||
brightCyan: "#0598bc",
|
||||
brightWhite: "#a5a5a5",
|
||||
},
|
||||
dracula: {
|
||||
background: "#282a36",
|
||||
foreground: "#f8f8f2",
|
||||
cursor: "#f8f8f2",
|
||||
cursorAccent: "#282a36",
|
||||
selectionBackground: "#44475a",
|
||||
selectionForeground: "#f8f8f2",
|
||||
black: "#21222c",
|
||||
red: "#ff5555",
|
||||
green: "#50fa7b",
|
||||
yellow: "#f1fa8c",
|
||||
blue: "#bd93f9",
|
||||
magenta: "#ff79c6",
|
||||
cyan: "#8be9fd",
|
||||
white: "#f8f8f2",
|
||||
brightBlack: "#6272a4",
|
||||
brightRed: "#ff6e6e",
|
||||
brightGreen: "#69ff94",
|
||||
brightYellow: "#ffffa5",
|
||||
brightBlue: "#d6acff",
|
||||
brightMagenta: "#ff92df",
|
||||
brightCyan: "#a4ffff",
|
||||
brightWhite: "#ffffff",
|
||||
},
|
||||
catppuccin: {
|
||||
background: "#1e1e2e",
|
||||
foreground: "#cdd6f4",
|
||||
cursor: "#f5e0dc",
|
||||
cursorAccent: "#1e1e2e",
|
||||
selectionBackground: "#585b70",
|
||||
selectionForeground: "#cdd6f4",
|
||||
black: "#45475a",
|
||||
red: "#f38ba8",
|
||||
green: "#a6e3a1",
|
||||
yellow: "#f9e2af",
|
||||
blue: "#89b4fa",
|
||||
magenta: "#f5c2e7",
|
||||
cyan: "#94e2d5",
|
||||
white: "#bac2de",
|
||||
brightBlack: "#585b70",
|
||||
brightRed: "#f38ba8",
|
||||
brightGreen: "#a6e3a1",
|
||||
brightYellow: "#f9e2af",
|
||||
brightBlue: "#89b4fa",
|
||||
brightMagenta: "#f5c2e7",
|
||||
brightCyan: "#94e2d5",
|
||||
brightWhite: "#a6adc8",
|
||||
},
|
||||
nord: {
|
||||
background: "#2e3440",
|
||||
foreground: "#d8dee9",
|
||||
cursor: "#d8dee9",
|
||||
cursorAccent: "#2e3440",
|
||||
selectionBackground: "#4c566a",
|
||||
selectionForeground: "#eceff4",
|
||||
black: "#3b4252",
|
||||
red: "#bf616a",
|
||||
green: "#a3be8c",
|
||||
yellow: "#ebcb8b",
|
||||
blue: "#81a1c1",
|
||||
magenta: "#b48ead",
|
||||
cyan: "#88c0d0",
|
||||
white: "#e5e9f0",
|
||||
brightBlack: "#4c566a",
|
||||
brightRed: "#bf616a",
|
||||
brightGreen: "#a3be8c",
|
||||
brightYellow: "#ebcb8b",
|
||||
brightBlue: "#81a1c1",
|
||||
brightMagenta: "#b48ead",
|
||||
brightCyan: "#8fbcbb",
|
||||
brightWhite: "#eceff4",
|
||||
},
|
||||
gruvbox: {
|
||||
background: "#282828",
|
||||
foreground: "#ebdbb2",
|
||||
cursor: "#ebdbb2",
|
||||
cursorAccent: "#282828",
|
||||
selectionBackground: "#504945",
|
||||
selectionForeground: "#fbf1c7",
|
||||
black: "#282828",
|
||||
red: "#cc241d",
|
||||
green: "#98971a",
|
||||
yellow: "#d79921",
|
||||
blue: "#458588",
|
||||
magenta: "#b16286",
|
||||
cyan: "#689d6a",
|
||||
white: "#a89984",
|
||||
brightBlack: "#928374",
|
||||
brightRed: "#fb4934",
|
||||
brightGreen: "#b8bb26",
|
||||
brightYellow: "#fabd2f",
|
||||
brightBlue: "#83a598",
|
||||
brightMagenta: "#d3869b",
|
||||
brightCyan: "#8ec07c",
|
||||
brightWhite: "#ebdbb2",
|
||||
},
|
||||
solarized: {
|
||||
background: "#002b36",
|
||||
foreground: "#839496",
|
||||
cursor: "#93a1a1",
|
||||
cursorAccent: "#002b36",
|
||||
selectionBackground: "#073642",
|
||||
selectionForeground: "#93a1a1",
|
||||
black: "#073642",
|
||||
red: "#dc322f",
|
||||
green: "#859900",
|
||||
yellow: "#b58900",
|
||||
blue: "#268bd2",
|
||||
magenta: "#d33682",
|
||||
cyan: "#2aa198",
|
||||
white: "#eee8d5",
|
||||
brightBlack: "#002b36",
|
||||
brightRed: "#cb4b16",
|
||||
brightGreen: "#586e75",
|
||||
brightYellow: "#657b83",
|
||||
brightBlue: "#839496",
|
||||
brightMagenta: "#6c71c4",
|
||||
brightCyan: "#93a1a1",
|
||||
brightWhite: "#fdf6e3",
|
||||
},
|
||||
tokyo: {
|
||||
background: "#1a1b26",
|
||||
foreground: "#c0caf5",
|
||||
cursor: "#c0caf5",
|
||||
cursorAccent: "#1a1b26",
|
||||
selectionBackground: "#33467c",
|
||||
selectionForeground: "#c0caf5",
|
||||
black: "#15161e",
|
||||
red: "#f7768e",
|
||||
green: "#9ece6a",
|
||||
yellow: "#e0af68",
|
||||
blue: "#7aa2f7",
|
||||
magenta: "#bb9af7",
|
||||
cyan: "#7dcfff",
|
||||
white: "#a9b1d6",
|
||||
brightBlack: "#414868",
|
||||
brightRed: "#f7768e",
|
||||
brightGreen: "#9ece6a",
|
||||
brightYellow: "#e0af68",
|
||||
brightBlue: "#7aa2f7",
|
||||
brightMagenta: "#bb9af7",
|
||||
brightCyan: "#7dcfff",
|
||||
brightWhite: "#c0caf5",
|
||||
},
|
||||
miasma: {
|
||||
background: "#222222",
|
||||
foreground: "#c2c2b0",
|
||||
cursor: "#c2c2b0",
|
||||
cursorAccent: "#222222",
|
||||
selectionBackground: "#5f5f5f",
|
||||
selectionForeground: "#ffffff",
|
||||
black: "#222222",
|
||||
red: "#685742",
|
||||
green: "#5f875f",
|
||||
yellow: "#b36d43",
|
||||
blue: "#78824b",
|
||||
magenta: "#bb7744",
|
||||
cyan: "#c9a554",
|
||||
white: "#c2c2b0",
|
||||
brightBlack: "#666666",
|
||||
brightRed: "#685742",
|
||||
brightGreen: "#5f875f",
|
||||
brightYellow: "#b36d43",
|
||||
brightBlue: "#78824b",
|
||||
brightMagenta: "#bb7744",
|
||||
brightCyan: "#c9a554",
|
||||
brightWhite: "#d7d7c7",
|
||||
},
|
||||
github: {
|
||||
background: "#0d1117",
|
||||
foreground: "#c9d1d9",
|
||||
cursor: "#c9d1d9",
|
||||
cursorAccent: "#0d1117",
|
||||
selectionBackground: "#264f78",
|
||||
selectionForeground: "#c9d1d9",
|
||||
black: "#484f58",
|
||||
red: "#ff7b72",
|
||||
green: "#7ee787",
|
||||
yellow: "#d29922",
|
||||
blue: "#58a6ff",
|
||||
magenta: "#bc8cff",
|
||||
cyan: "#39c5cf",
|
||||
white: "#b1bac4",
|
||||
brightBlack: "#6e7681",
|
||||
brightRed: "#ffa198",
|
||||
brightGreen: "#56d364",
|
||||
brightYellow: "#e3b341",
|
||||
brightBlue: "#79c0ff",
|
||||
brightMagenta: "#d2a8ff",
|
||||
brightCyan: "#56d4dd",
|
||||
brightWhite: "#f0f6fc",
|
||||
},
|
||||
gotham: {
|
||||
background: "#0a0f14",
|
||||
foreground: "#98d1ce",
|
||||
cursor: "#98d1ce",
|
||||
cursorAccent: "#0a0f14",
|
||||
selectionBackground: "#1f2233",
|
||||
selectionForeground: "#98d1ce",
|
||||
black: "#0a0f14",
|
||||
red: "#c33027",
|
||||
green: "#26a98b",
|
||||
yellow: "#edb54b",
|
||||
blue: "#195465",
|
||||
magenta: "#4e5165",
|
||||
cyan: "#33859e",
|
||||
white: "#98d1ce",
|
||||
brightBlack: "#10151b",
|
||||
brightRed: "#d26939",
|
||||
brightGreen: "#081f2d",
|
||||
brightYellow: "#245361",
|
||||
brightBlue: "#093748",
|
||||
brightMagenta: "#888ba5",
|
||||
brightCyan: "#599caa",
|
||||
brightWhite: "#d3ebe9",
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user