import { preloadPatchFile } from "@pierre/diffs/ssr"; import { useCallback, useEffect, useRef, useState } from "react"; import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "../ComposerPromptEditor"; import { terminalThemeFromApp } from "../ThreadTerminalDrawer"; import { useTheme } from "../../hooks/useTheme"; import { resolveDiffThemeName, type DiffThemeName } from "../../lib/diffRendering"; import { GhosttyTerminalSurface } from "~/terminal/ghostty/surface"; // The font previews are the real surfaces, not lookalikes: the composer's // Lexical editor, the diff panel's file diff, and the Ghostty canvas // renderer. Each already consumes the appearance font tokens (or, for the // terminal, the settings passed down as props), so what the row shows is // exactly what the app renders. const EMPTY_TERMINAL_CONTEXTS: ReadonlyArray = []; const EMPTY_SKILLS: ReadonlyArray = []; // Serialized the way the composer stores inline tokens: the $skill and the // markdown-style file links render as chips, so the preview shows prompt // text and pills exactly as the real composer draws them. const PROMPT_PREVIEW_TEXT = "Use $frontend-design to fix the flaky test in " + "[surface.test.ts](apps/web/src/terminal/ghostty/surface.test.ts) and align the header with " + "[SettingsPanels.tsx](apps/web/src/components/settings/SettingsPanels.tsx) before shipping."; function noop() {} /** A live composer editor: type in it to feel the family and size. */ export function PromptFontPreview() { const editorRef = useRef(null); const [prompt, setPrompt] = useState(PROMPT_PREVIEW_TEXT); const [cursor, setCursor] = useState(PROMPT_PREVIEW_TEXT.length); const onChange = useCallback((nextValue: string, nextCursor: number) => { setPrompt(nextValue); setCursor(nextCursor); }, []); return (
); } const DIFF_PREVIEW_PATCH = [ "diff --git a/src/formatUser.ts b/src/formatUser.ts", "--- a/src/formatUser.ts", "+++ b/src/formatUser.ts", "@@ -1,3 +1,3 @@", " export function formatUser(user: User) {", "- return user.name.toUpperCase();", "+ return `${user.name} <${user.email}>`; // 0O 1lI", " }", "", ].join("\n"); // Rendered once per theme through the SSR pipeline, which always awaits the // shared highlighter before producing HTML. The interactive FileDiff's mount // lifecycle can race that highlighter when the typography views remount it // (toggling Advanced) and lock in an unhighlighted frame; a static preview // needs none of that lifecycle, so it uses the deterministic renderer and // injects the finished HTML into a shadow root, exactly as FileDiff would. const diffPreviewHtmlByTheme = new Map>(); function loadDiffPreviewHtml(theme: DiffThemeName): Promise { let promise = diffPreviewHtmlByTheme.get(theme); if (promise === undefined) { promise = preloadPatchFile({ patch: DIFF_PREVIEW_PATCH, options: { diffStyle: "unified", theme }, }).then((results) => results.map((result) => result.prerenderedHTML)); diffPreviewHtmlByTheme.set(theme, promise); } return promise; } // Pierre's prerendered stylesheet bakes its own light/dark surface colors // into the shadow root's @layer rules. These unlayered rules win the cascade // without !important and re-point the surfaces at the app's code tokens // (custom properties inherit across the shadow boundary), so the preview // follows the active theme exactly like the real diff panel does. const DIFF_PREVIEW_THEME_BRIDGE = ` :host { color: var(--code-foreground); background-color: var(--code-background); --diffs-fg: var(--code-foreground); --diffs-bg: var(--code-background); --diffs-light-bg: var(--code-background); --diffs-dark-bg: var(--code-background); } [data-diffs-header] { background-color: var(--code-background); color: var(--code-foreground); } `; function StaticDiffHtml({ html }: { html: string }) { const hostRef = useRef(null); useEffect(() => { const host = hostRef.current; if (host === null) return; const shadow = host.shadowRoot ?? host.attachShadow({ mode: "open" }); shadow.innerHTML = html; const bridge = document.createElement("style"); bridge.textContent = DIFF_PREVIEW_THEME_BRIDGE; shadow.append(bridge); }, [html]); return
; } /** The diff panel's file diff, statically rendered by its real pipeline. */ export function CodeFontPreview() { const { resolvedTheme } = useTheme(); const themeName = resolveDiffThemeName(resolvedTheme); const [htmlByFile, setHtmlByFile] = useState(null); useEffect(() => { let cancelled = false; void loadDiffPreviewHtml(themeName).then((html) => { if (!cancelled) setHtmlByFile(html); }); return () => { cancelled = true; }; }, [themeName]); if (htmlByFile === null) return null; return (
{htmlByFile.map((html) => ( ))}
); } // A zsh-style prompt: green arrow, cyan project, blue/red git segment, // yellow dirty marker. It doubles as the echo loop's fresh-line prompt. const TERMINAL_PROMPT = "\x1b[1;32m→\x1b[0m \x1b[1;36mt3code\x1b[0m \x1b[1;34mgit:(\x1b[1;31mmain\x1b[1;34m)\x1b[0m \x1b[1;33m✗\x1b[0m "; // A dev-server startup: brand line, addresses, a test summary, and a READY // badge. Together the lines cover bold, dim, underline, the six accent // colors, and a background cell, so a font choice shows every SGR the // terminal actually renders. const TERMINAL_PREVIEW_TRANSCRIPT = `${TERMINAL_PROMPT}vpr dev\r\n` + "\r\n" + " \x1b[1;32mVITE\x1b[0m \x1b[32mv7.1.1\x1b[0m \x1b[2mready in\x1b[0m \x1b[1m1.24s\x1b[0m\r\n" + "\r\n" + " \x1b[32m→\x1b[0m \x1b[2mLocal:\x1b[0m \x1b[4;36mhttp://127.0.0.1:5173/\x1b[0m\r\n" + " \x1b[32m→\x1b[0m \x1b[2mNetwork:\x1b[0m \x1b[4;36mhttp://192.168.1.24:5173/\x1b[0m\r\n" + "\r\n" + " \x1b[32m✓ 85 passed\x1b[0m \x1b[33m△ 2 warnings\x1b[0m \x1b[31m✗ 0 failed\x1b[0m\r\n" + "\r\n" + " \x1b[42;30m READY \x1b[0m \x1b[2mwatching for changes — press\x1b[0m \x1b[1mq\x1b[0m \x1b[2mto quit\x1b[0m\r\n" + "\r\n" + TERMINAL_PROMPT; /** The surface treats an omitted family or size as "use the built-in default". */ function previewTerminalFont(family: string, size: number): { family?: string; size: number } { const trimmed = family.trim(); return trimmed.length > 0 ? { family: trimmed, size } : { size }; } /** * The real Ghostty canvas renderer against a local echo loop instead of a * PTY: keys print, Enter starts a new prompt line, Backspace erases. That * exercises the same glyph atlas, cell metrics, and monospace gate the * terminal drawer uses. */ export function TerminalFontPreview({ family, size }: { family: string; size: number }) { const mountRef = useRef(null); const surfaceRef = useRef(null); const fontRef = useRef({ family, size }); const { theme, resolvedTheme } = useTheme(); useEffect(() => { const current = fontRef.current; if (current.family === family && current.size === size) return; fontRef.current = { family, size }; void surfaceRef.current?.setFont(previewTerminalFont(family, size)); }, [family, size]); // Re-read the terminal tokens on any theme change — switching between two // palettes can leave resolvedTheme (light/dark) untouched. useEffect(() => { const mount = mountRef.current; const surface = surfaceRef.current; if (!mount || !surface) return; surface.setTheme(terminalThemeFromApp(mount)); }, [theme, resolvedTheme]); useEffect(() => { const mount = mountRef.current; if (!mount) return; let cancelled = false; // Column of the caret on the current input line, so Backspace stops at // the prompt instead of eating it. let lineLength = 0; const echo = (data: string) => { const surface = surfaceRef.current; if (!surface) return; if (data === "\r") { surface.write(`\r\n${TERMINAL_PROMPT}`); lineLength = 0; return; } if (data === "\x7f" || data === "\b") { if (lineLength > 0) { surface.write("\b \b"); lineLength -= 1; } return; } // Arrow keys and other escape reports have no cursor to move here. if (data.startsWith("\x1b")) return; const printable = [...data] .filter((character) => character >= " " && character !== "\x7f") .join(""); if (printable.length === 0) return; surface.write(printable); lineLength += printable.length; }; void GhosttyTerminalSurface.create(mount, { theme: terminalThemeFromApp(mount), font: previewTerminalFont(fontRef.current.family, fontRef.current.size), onData: echo, onResize: noop, onSelectionChange: noop, // Tab keeps walking the settings page instead of feeding the echo loop. beforeKey: (event) => event.key !== "Tab", onLinkActivate: noop, }).then((surface) => { if (cancelled) { surface.dispose(); return; } surfaceRef.current = surface; // The theme and font may both have changed while the WASM surface loaded. surface.setTheme(terminalThemeFromApp(mount)); const font = fontRef.current; void surface.setFont(previewTerminalFont(font.family, font.size)); surface.write(TERMINAL_PREVIEW_TRANSCRIPT); }); return () => { cancelled = true; surfaceRef.current?.dispose(); surfaceRef.current = null; }; }, []); return (
); }