| 1 | import { preloadPatchFile } from "@pierre/diffs/ssr"; |
| 2 | import { useCallback, useEffect, useRef, useState } from "react"; |
| 3 | import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "../ComposerPromptEditor"; |
| 4 | import { EMPTY_COMPOSER_CONTEXT_RECORDS } from "../composerContextPresentation"; |
| 5 | import { terminalThemeFromApp } from "../ThreadTerminalDrawer"; |
| 6 | import { useTheme } from "../../hooks/useTheme"; |
| 7 | import { DISCONNECTED_COMPOSER_PLACEHOLDER } from "../../composerPlaceholder"; |
| 8 | import { resolveDiffThemeName, type DiffThemeName } from "../../lib/diffRendering"; |
| 9 | import { PREFERRED_HIGHLIGHTER } from "../../lib/syntaxHighlighting"; |
| 10 | import { TerminalSurface } from "~/terminal/TerminalSurface"; |
| 11 | |
| 12 | const EMPTY_SKILLS: ReadonlyArray<never> = []; |
| 13 | |
| 14 | // Serialized the way the composer stores inline tokens: the $skill and the |
| 15 | // markdown-style file links render as chips, so the preview shows prompt |
| 16 | // text and pills exactly as the real composer draws them. |
| 17 | const PROMPT_PREVIEW_TEXT = |
| 18 | "Use $frontend-design to fix the flaky test in " + |
| 19 | "[TerminalSurface.test.ts](apps/web/src/terminal/TerminalSurface.test.ts) and align the header with " + |
| 20 | "[SettingsPanels.tsx](apps/web/src/components/settings/SettingsPanels.tsx) before shipping."; |
| 21 | |
| 22 | function noop() {} |
| 23 | |
| 24 | /** A live composer editor: type in it to feel the family and size. */ |
| 25 | export function PromptFontPreview() { |
| 26 | const editorRef = useRef<ComposerPromptEditorHandle>(null); |
| 27 | const [prompt, setPrompt] = useState(PROMPT_PREVIEW_TEXT); |
| 28 | const [cursor, setCursor] = useState(PROMPT_PREVIEW_TEXT.length); |
| 29 | const onChange = useCallback((nextValue: string, nextCursor: number) => { |
| 30 | setPrompt(nextValue); |
| 31 | setCursor(nextCursor); |
| 32 | }, []); |
| 33 | return ( |
| 34 | <div className="mt-1 mb-2 rounded-lg border border-border bg-background px-3 py-2"> |
| 35 | <ComposerPromptEditor |
| 36 | ariaLabel="Prompt font preview" |
| 37 | editorRef={editorRef} |
| 38 | value={prompt} |
| 39 | cursor={cursor} |
| 40 | contextRecords={EMPTY_COMPOSER_CONTEXT_RECORDS} |
| 41 | skills={EMPTY_SKILLS} |
| 42 | disabled={false} |
| 43 | placeholder={DISCONNECTED_COMPOSER_PLACEHOLDER} |
| 44 | className="max-h-42 min-h-14" |
| 45 | onChange={onChange} |
| 46 | onPaste={noop} |
| 47 | /> |
| 48 | </div> |
| 49 | ); |
| 50 | } |
| 51 | |
| 52 | const DIFF_PREVIEW_PATCH = [ |
| 53 | "diff --git a/src/formatUser.ts b/src/formatUser.ts", |
| 54 | "--- a/src/formatUser.ts", |
| 55 | "+++ b/src/formatUser.ts", |
| 56 | "@@ -1,3 +1,3 @@", |
| 57 | " export function formatUser(user: User) {", |
| 58 | "- return user.name.toUpperCase();", |
| 59 | "+ return `${user.name} <${user.email}>`; // 0O 1lI", |
| 60 | " }", |
| 61 | "", |
| 62 | ].join("\n"); |
| 63 | |
| 64 | // Rendered once per theme through the SSR pipeline, which always awaits the |
| 65 | // shared highlighter before producing HTML. The interactive FileDiff's mount |
| 66 | // lifecycle can race that highlighter when the typography views remount it |
| 67 | // (toggling Advanced) and lock in an unhighlighted frame; a static preview |
| 68 | // needs none of that lifecycle, so it uses the deterministic renderer and |
| 69 | // injects the finished HTML into a shadow root, exactly as FileDiff would. |
| 70 | const diffPreviewHtmlByTheme = new Map<DiffThemeName, Promise<readonly string[]>>(); |
| 71 | |
| 72 | function loadDiffPreviewHtml(theme: DiffThemeName): Promise<readonly string[]> { |
| 73 | let promise = diffPreviewHtmlByTheme.get(theme); |
| 74 | if (promise === undefined) { |
| 75 | promise = preloadPatchFile({ |
| 76 | patch: DIFF_PREVIEW_PATCH, |
| 77 | options: { diffStyle: "unified", theme, preferredHighlighter: PREFERRED_HIGHLIGHTER }, |
| 78 | }).then((results) => results.map((result) => result.prerenderedHTML)); |
| 79 | diffPreviewHtmlByTheme.set(theme, promise); |
| 80 | } |
| 81 | return promise; |
| 82 | } |
| 83 | |
| 84 | // Pierre's prerendered stylesheet bakes its own light/dark surface colors |
| 85 | // into the shadow root's @layer rules. These unlayered rules win the cascade |
| 86 | // without !important and re-point the surfaces at the app's code tokens |
| 87 | // (custom properties inherit across the shadow boundary), so the preview |
| 88 | // follows the active theme exactly like the real diff panel does. |
| 89 | const DIFF_PREVIEW_THEME_BRIDGE = ` |
| 90 | :host { |
| 91 | color: var(--code-foreground); |
| 92 | background-color: var(--code-background); |
| 93 | --diffs-fg: var(--code-foreground); |
| 94 | --diffs-bg: var(--code-background); |
| 95 | --diffs-light-bg: var(--code-background); |
| 96 | --diffs-dark-bg: var(--code-background); |
| 97 | } |
| 98 | [data-diffs-header] { |
| 99 | background-color: var(--code-background); |
| 100 | color: var(--code-foreground); |
| 101 | } |
| 102 | `; |
| 103 | |
| 104 | function StaticDiffHtml({ html }: { html: string }) { |
| 105 | const hostRef = useRef<HTMLDivElement | null>(null); |
| 106 | useEffect(() => { |
| 107 | const host = hostRef.current; |
| 108 | if (host === null) return; |
| 109 | const shadow = host.shadowRoot ?? host.attachShadow({ mode: "open" }); |
| 110 | shadow.innerHTML = html; |
| 111 | const bridge = document.createElement("style"); |
| 112 | bridge.textContent = DIFF_PREVIEW_THEME_BRIDGE; |
| 113 | shadow.append(bridge); |
| 114 | }, [html]); |
| 115 | return <div ref={hostRef} />; |
| 116 | } |
| 117 | |
| 118 | /** The diff panel's file diff, statically rendered by its real pipeline. */ |
| 119 | export function CodeFontPreview() { |
| 120 | const { resolvedTheme } = useTheme(); |
| 121 | const themeName = resolveDiffThemeName(resolvedTheme); |
| 122 | const [htmlByFile, setHtmlByFile] = useState<readonly string[] | null>(null); |
| 123 | useEffect(() => { |
| 124 | let cancelled = false; |
| 125 | void loadDiffPreviewHtml(themeName).then((html) => { |
| 126 | if (!cancelled) setHtmlByFile(html); |
| 127 | }); |
| 128 | return () => { |
| 129 | cancelled = true; |
| 130 | }; |
| 131 | }, [themeName]); |
| 132 | if (htmlByFile === null) return null; |
| 133 | return ( |
| 134 | <div className="mt-1 mb-2 space-y-2"> |
| 135 | {htmlByFile.map((html) => ( |
| 136 | <StaticDiffHtml key={html} html={html} /> |
| 137 | ))} |
| 138 | </div> |
| 139 | ); |
| 140 | } |
| 141 | |
| 142 | // A zsh-style prompt: green arrow, cyan project, blue/red git segment, |
| 143 | // yellow dirty marker. It doubles as the echo loop's fresh-line prompt. |
| 144 | const TERMINAL_PROMPT = |
| 145 | "\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 "; |
| 146 | // A dev-server startup: brand line, addresses, a test summary, and a READY |
| 147 | // badge. Together the lines cover bold, dim, underline, the six accent |
| 148 | // colors, and a background cell, so a font choice shows every SGR the |
| 149 | // terminal actually renders. |
| 150 | const TERMINAL_PREVIEW_TRANSCRIPT = |
| 151 | `${TERMINAL_PROMPT}vpr dev\r\n` + |
| 152 | "\r\n" + |
| 153 | " \x1b[1;32mVITE\x1b[0m \x1b[32mv7.1.1\x1b[0m \x1b[2mready in\x1b[0m \x1b[1m1.24s\x1b[0m\r\n" + |
| 154 | "\r\n" + |
| 155 | " \x1b[32m→\x1b[0m \x1b[2mLocal:\x1b[0m \x1b[4;36mhttp://127.0.0.1:5173/\x1b[0m\r\n" + |
| 156 | " \x1b[32m→\x1b[0m \x1b[2mNetwork:\x1b[0m \x1b[4;36mhttp://192.168.1.24:5173/\x1b[0m\r\n" + |
| 157 | "\r\n" + |
| 158 | " \x1b[32m✓ 85 passed\x1b[0m \x1b[33m△ 2 warnings\x1b[0m \x1b[31m✗ 0 failed\x1b[0m\r\n" + |
| 159 | "\r\n" + |
| 160 | " \x1b[42;30m READY \x1b[0m \x1b[2mwatching for changes — press\x1b[0m \x1b[1mq\x1b[0m \x1b[2mto quit\x1b[0m\r\n" + |
| 161 | "\r\n" + |
| 162 | TERMINAL_PROMPT; |
| 163 | |
| 164 | /** The surface treats an omitted family or size as "use the built-in default". */ |
| 165 | function previewTerminalFont(family: string, size: number): { family?: string; size: number } { |
| 166 | const trimmed = family.trim(); |
| 167 | return trimmed.length > 0 ? { family: trimmed, size } : { size }; |
| 168 | } |
| 169 | |
| 170 | /** |
| 171 | * The DOM terminal against a local echo loop: keys print, Enter starts a new |
| 172 | * prompt line, and Backspace erases. |
| 173 | */ |
| 174 | export function TerminalFontPreview({ family, size }: { family: string; size: number }) { |
| 175 | const mountRef = useRef<HTMLDivElement>(null); |
| 176 | const surfaceRef = useRef<TerminalSurface | null>(null); |
| 177 | const fontRef = useRef({ family, size }); |
| 178 | const { theme, resolvedTheme } = useTheme(); |
| 179 | |
| 180 | useEffect(() => { |
| 181 | const current = fontRef.current; |
| 182 | if (current.family === family && current.size === size) return; |
| 183 | fontRef.current = { family, size }; |
| 184 | void surfaceRef.current?.setFont(previewTerminalFont(family, size)); |
| 185 | }, [family, size]); |
| 186 | |
| 187 | // Re-read the terminal tokens on any theme change — switching between two |
| 188 | // palettes can leave resolvedTheme (light/dark) untouched. |
| 189 | useEffect(() => { |
| 190 | const mount = mountRef.current; |
| 191 | const surface = surfaceRef.current; |
| 192 | if (!mount || !surface) return; |
| 193 | surface.setTheme(terminalThemeFromApp(mount)); |
| 194 | }, [theme, resolvedTheme]); |
| 195 | |
| 196 | useEffect(() => { |
| 197 | const mount = mountRef.current; |
| 198 | if (!mount) return; |
| 199 | let cancelled = false; |
| 200 | // Column of the caret on the current input line, so Backspace stops at |
| 201 | // the prompt instead of eating it. |
| 202 | let lineLength = 0; |
| 203 | |
| 204 | const echo = (data: string) => { |
| 205 | const surface = surfaceRef.current; |
| 206 | if (!surface) return; |
| 207 | if (data === "\r") { |
| 208 | surface.write(`\r\n${TERMINAL_PROMPT}`); |
| 209 | lineLength = 0; |
| 210 | return; |
| 211 | } |
| 212 | if (data === "\x7f" || data === "\b") { |
| 213 | if (lineLength > 0) { |
| 214 | surface.write("\b \b"); |
| 215 | lineLength -= 1; |
| 216 | } |
| 217 | return; |
| 218 | } |
| 219 | // Arrow keys and other escape reports have no cursor to move here. |
| 220 | if (data.startsWith("\x1b")) return; |
| 221 | const printable = [...data] |
| 222 | .filter((character) => character >= " " && character !== "\x7f") |
| 223 | .join(""); |
| 224 | if (printable.length === 0) return; |
| 225 | surface.write(printable); |
| 226 | lineLength += printable.length; |
| 227 | }; |
| 228 | |
| 229 | void TerminalSurface.create(mount, { |
| 230 | theme: terminalThemeFromApp(mount), |
| 231 | font: previewTerminalFont(fontRef.current.family, fontRef.current.size), |
| 232 | onData: echo, |
| 233 | onResize: noop, |
| 234 | // Tab keeps walking the settings page instead of feeding the echo loop. |
| 235 | beforeKey: (event) => event.key !== "Tab", |
| 236 | onLinkActivate: noop, |
| 237 | }).then((surface) => { |
| 238 | if (cancelled) { |
| 239 | surface.dispose(); |
| 240 | return; |
| 241 | } |
| 242 | surfaceRef.current = surface; |
| 243 | // The theme and font may both have changed while the WASM surface loaded. |
| 244 | surface.setTheme(terminalThemeFromApp(mount)); |
| 245 | const font = fontRef.current; |
| 246 | void surface.setFont(previewTerminalFont(font.family, font.size)); |
| 247 | surface.write(TERMINAL_PREVIEW_TRANSCRIPT); |
| 248 | }); |
| 249 | |
| 250 | return () => { |
| 251 | cancelled = true; |
| 252 | surfaceRef.current?.dispose(); |
| 253 | surfaceRef.current = null; |
| 254 | }; |
| 255 | }, []); |
| 256 | |
| 257 | return ( |
| 258 | <div |
| 259 | ref={mountRef} |
| 260 | className="relative mt-1 mb-2 h-52 overflow-hidden rounded-lg border border-border" |
| 261 | aria-label="Terminal font preview" |
| 262 | /> |
| 263 | ); |
| 264 | } |