1import { preloadPatchFile } from "@pierre/diffs/ssr";
2import { useCallback, useEffect, useRef, useState } from "react";
3import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "../ComposerPromptEditor";
4import { EMPTY_COMPOSER_CONTEXT_RECORDS } from "../composerContextPresentation";
5import { terminalThemeFromApp } from "../ThreadTerminalDrawer";
6import { useTheme } from "../../hooks/useTheme";
7import { DISCONNECTED_COMPOSER_PLACEHOLDER } from "../../composerPlaceholder";
8import { resolveDiffThemeName, type DiffThemeName } from "../../lib/diffRendering";
9import { PREFERRED_HIGHLIGHTER } from "../../lib/syntaxHighlighting";
10import { TerminalSurface } from "~/terminal/TerminalSurface";
11
12const 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.
17const 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
22function noop() {}
23
24/** A live composer editor: type in it to feel the family and size. */
25export 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
52const 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.
70const diffPreviewHtmlByTheme = new Map<DiffThemeName, Promise<readonly string[]>>();
71
72function 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.
89const 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
104function 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. */
119export 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.
144const 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.
150const 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". */
165function 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 */
174export 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}