返回 CodeWhale
terminal-player.tsx
根目录 / web / components / terminal-player.tsx
1 "use client";
2
3 /**
4 * <TerminalPlayer> — a terminal chrome around the real reasoning traces in
5 * thinking-trace.tsx. Lines type in progressively with a blinking caret;
6 * scene tabs switch between the excerpts.
7 *
8 * Pure React + CSS, no media assets. SSG-safe: the server render (and any
9 * no-JS render) shows the complete static trace; the typing animation only
10 * starts inside a client effect. Users with prefers-reduced-motion get the
11 * full static text with no animation.
12 */
13
14 import { useEffect, useMemo, useState } from "react";
15 import { SCENES } from "./thinking-trace";
16
17 const TICK_MS = 24;
18 const CHARS_PER_TICK = 2;
19
20 function Caret() {
21 return <span className="tp-caret" aria-hidden="true" />;
22 }
23
24 export function TerminalPlayer({
25 locale = "en",
26 traceLabel,
27 tabsAria,
28 }: {
29 locale?: string;
30 /** Chrome copy from the locale dictionary (getChrome(locale)). */
31 traceLabel: string;
32 tabsAria: string;
33 }) {
34 // Scene bodies are faithful excerpts of a real session and live in
35 // components/thinking-trace.tsx as {en, zh} content pairs — the same
36 // shared-content pattern as web/lib/content/. Locales beyond zh fall back
37 // to the English excerpt until that content module gains more pairs
38 // (FINISH-0.9.4 §0A Phase 2); the surrounding chrome is dictionary-driven.
39 const isZh = locale === "zh";
40 const [active, setActive] = useState(0);
41 const scene = SCENES[active];
42
43 const text = useMemo(
44 () => ({
45 context: `# ${isZh ? scene.context.zh : scene.context.en}`,
46 trace: scene.trace,
47 decision: isZh ? scene.decision.zh : scene.decision.en,
48 }),
49 [scene, isZh]
50 );
51
52 // Character offsets across the four "lines" of a scene. Cites reveal as
53 // whole pills once the animation reaches their offset.
54 const contextStart = 0;
55 const traceStart = text.context.length;
56 const citesStart = traceStart + text.trace.length;
57 const citeStarts: number[] = [];
58 let acc = citesStart;
59 for (const c of scene.cites) {
60 citeStarts.push(acc);
61 acc += c.length;
62 }
63 const decisionStart = acc;
64 const total = decisionStart + text.decision.length;
65
66 // Server render shows the full trace; the effect rewinds and types it in
67 // when motion is allowed. No Date.now in render paths.
68 const [shown, setShown] = useState(Number.MAX_SAFE_INTEGER);
69
70 useEffect(() => {
71 if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
72 setShown(Number.MAX_SAFE_INTEGER);
73 return;
74 }
75 setShown(0);
76 const id = window.setInterval(() => {
77 setShown((n) => {
78 if (n + CHARS_PER_TICK >= total) {
79 window.clearInterval(id);
80 return total;
81 }
82 return n + CHARS_PER_TICK;
83 });
84 }, TICK_MS);
85 return () => window.clearInterval(id);
86 }, [active, total, isZh]);
87
88 const slice = (t: string, start: number) => t.slice(0, Math.max(0, shown - start));
89 const typing = (start: number, len: number) => shown > start && shown < start + len;
90 const done = shown >= total;
91
92 return (
93 <div className="hairline-t hairline-b hairline-l hairline-r bg-ink overflow-hidden">
94 {/* title bar */}
95 <div className="px-4 py-2.5 flex items-center justify-between border-b border-white/10">
96 <div className="flex items-center gap-1.5">
97 <span className="w-2.5 h-2.5 rounded-full bg-jade inline-block" />
98 <span className="w-2.5 h-2.5 rounded-full bg-ochre inline-block" />
99 <span className="w-2.5 h-2.5 rounded-full bg-indigo inline-block" />
100 <span className="ml-2.5 font-mono text-[0.66rem] uppercase tracking-widest text-paper-deep">
101 codewhale — thinking
102 </span>
103 </div>
104 <span className="font-cjk text-[0.6rem] text-paper-deep/70">{traceLabel}</span>
105 </div>
106
107 {/* scene tabs */}
108 <div
109 className="flex border-b border-white/10 overflow-x-auto"
110 role="tablist"
111 aria-label={tabsAria}
112 >
113 {SCENES.map((s, i) => (
114 <button
115 key={i}
116 type="button"
117 role="tab"
118 aria-selected={i === active}
119 onClick={() => setActive(i)}
120 className={`shrink-0 px-3 py-2 font-mono text-[0.62rem] uppercase tracking-widest transition-colors ${
121 i === active
122 ? "text-paper bg-white/10 border-b border-indigo"
123 : "text-paper-deep/60 hover:text-paper"
124 }`}
125 >
126 {String(i + 1).padStart(2, "0")} · {isZh ? s.tab.zh : s.tab.en}
127 </button>
128 ))}
129 </div>
130
131 {/* body */}
132 <div className="px-4 py-4 min-h-[15rem] font-mono text-[0.8rem] leading-relaxed">
133 {/* context */}
134 <div className="text-white/45">
135 {slice(text.context, contextStart)}
136 {typing(contextStart, text.context.length) && <Caret />}
137 </div>
138
139 {/* the trace */}
140 {shown > traceStart && (
141 <div className="mt-3 whitespace-pre-wrap">
142 <span className="text-indigo">›</span>{" "}
143 <span className="text-white/85">{slice(text.trace, traceStart)}</span>
144 {typing(traceStart, text.trace.length) && <Caret />}
145 </div>
146 )}
147
148 {/* cited authority */}
149 {shown > citesStart && (
150 <div className="mt-3 flex flex-wrap gap-1.5">
151 {scene.cites.map(
152 (c, i) =>
153 shown > citeStarts[i] && (
154 <span
155 key={c}
156 className="px-1.5 py-0.5 border border-white/25 text-white/75 text-[0.6rem] uppercase tracking-wider"
157 >
158 {c}
159 </span>
160 )
161 )}
162 </div>
163 )}
164
165 {/* the decision it produced */}
166 {shown > decisionStart && (
167 <div className="mt-3">
168 <span className="text-indigo font-semibold">→</span>{" "}
169 <span className="text-white/90">{slice(text.decision, decisionStart)}</span>
170 {typing(decisionStart, text.decision.length) && <Caret />}
171 </div>
172 )}
173
174 {/* resting prompt */}
175 {done && (
176 <div className="mt-3 text-indigo">
177 › <Caret />
178 </div>
179 )}
180 </div>
181 </div>
182 );
183 }
184
184 lines Plain Text