@opengeni/react 0.3.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/dist/index.d.ts +1055 -27
  2. package/dist/index.js +6993 -1954
  3. package/dist/index.js.map +1 -1
  4. package/package.json +65 -2
  5. package/src/client.ts +22 -0
  6. package/src/components/code-editor.tsx +398 -0
  7. package/src/components/desktop-viewer.tsx +647 -0
  8. package/src/components/diff-view.tsx +230 -0
  9. package/src/components/file-browser.tsx +838 -0
  10. package/src/components/fleet-tile.tsx +5 -0
  11. package/src/components/message-timeline.tsx +70 -196
  12. package/src/components/pierre-diff.tsx +140 -0
  13. package/src/components/pierre-file.tsx +142 -0
  14. package/src/components/sandbox-files.tsx +509 -0
  15. package/src/components/sandbox-terminal.tsx +425 -0
  16. package/src/components/workspace-dock.tsx +247 -0
  17. package/src/hooks/use-desktop-stream.ts +214 -0
  18. package/src/hooks/use-sandbox-files.ts +670 -0
  19. package/src/hooks/use-sandbox-git.ts +105 -0
  20. package/src/hooks/use-sandbox-terminal.ts +226 -0
  21. package/src/hooks/use-session-capabilities.ts +415 -0
  22. package/src/hooks/use-session.ts +80 -12
  23. package/src/hooks/use-terminal-stream.ts +207 -0
  24. package/src/index.ts +112 -3
  25. package/src/lib/cn.ts +20 -1
  26. package/src/lib/git-patch.ts +43 -0
  27. package/src/lib/use-theme-type.ts +40 -0
  28. package/src/lib/xterm-theme.ts +34 -0
  29. package/src/timeline/activity-rail.tsx +207 -0
  30. package/src/timeline/disclosure-context.tsx +34 -0
  31. package/src/timeline/index.ts +85 -0
  32. package/src/timeline/parsers.ts +253 -0
  33. package/src/{timeline.ts → timeline/projection.ts} +59 -134
  34. package/src/timeline/registry.ts +96 -0
  35. package/src/timeline/screenshot-lightbox.tsx +152 -0
  36. package/src/timeline/shared.tsx +481 -0
  37. package/src/timeline/tool-diff.tsx +91 -0
  38. package/src/timeline/tool-renderers.tsx +882 -0
  39. package/src/timeline/turn-summary.tsx +125 -0
  40. package/src/timeline/types.ts +131 -0
  41. package/src/types/external.d.ts +7 -0
  42. package/styles/index.css +72 -0
@@ -0,0 +1,207 @@
1
+ import {
2
+ TTYD_SUBPROTOCOL,
3
+ TerminalCapability,
4
+ terminalSocketUrl,
5
+ ttydAuthFrame,
6
+ ttydInputFrame,
7
+ ttydResizeFrame,
8
+ TtydServerCommand,
9
+ } from "@opengeni/sdk";
10
+ import { useEffect, useMemo, useRef, useState } from "react";
11
+
12
+ /** The ttyd connection lifecycle as surfaced to the component. */
13
+ export type TerminalStreamStatus = "connecting" | "open" | "closed" | "error";
14
+
15
+ export type UseTerminalStreamOptions = {
16
+ /** The Terminal cell of the negotiated capabilities (`capabilities.Terminal`).
17
+ * The stream connects ONLY when `transport === "pty-ws"` and `url` is set; on a
18
+ * cold box (`transport === "sse-events"` / no url) it stays idle and the caller
19
+ * falls back to the Channel-A read-only firehose. */
20
+ capability: Pick<TerminalCapability, "transport" | "url" | "token"> | null;
21
+ /** Called for each OUTPUT payload from ttyd (write verbatim into xterm). */
22
+ onOutput?: ((data: string) => void) | undefined;
23
+ /** Called when ttyd sends a SET_WINDOW_TITLE frame. */
24
+ onTitle?: ((title: string) => void) | undefined;
25
+ /** Initial PTY size to seed the ttyd auth frame + first resize. */
26
+ initialCols?: number | undefined;
27
+ initialRows?: number | undefined;
28
+ };
29
+
30
+ export type UseTerminalStreamResult = {
31
+ /** True once the ttyd socket is open (and the auth frame has been sent). */
32
+ connected: boolean;
33
+ status: TerminalStreamStatus;
34
+ /** Pipe a keystroke/paste to the PTY stdin. No-op until the socket is open. */
35
+ write: (data: string) => void;
36
+ /** Tell ttyd the PTY window changed size (on xterm fit/resize). */
37
+ resize: (cols: number, rows: number) => void;
38
+ /** Tear the socket down (the effect also tears down on unmount / url change). */
39
+ disconnect: () => void;
40
+ };
41
+
42
+ /** Decode an inbound ttyd frame's payload (everything after the 1-char command).
43
+ * ttyd may send either a text frame (string) or a binary frame (ArrayBuffer);
44
+ * for binary we slice off the first byte (the command) and utf-8 decode the rest. */
45
+ function decodeFrame(data: string | ArrayBuffer): { command: string; payload: string } {
46
+ if (typeof data === "string") {
47
+ return { command: data.charAt(0), payload: data.slice(1) };
48
+ }
49
+ const bytes = new Uint8Array(data);
50
+ const command = bytes.length > 0 ? String.fromCharCode(bytes[0]!) : "";
51
+ const payload = bytes.length > 1 ? new TextDecoder().decode(bytes.subarray(1)) : "";
52
+ return { command, payload };
53
+ }
54
+
55
+ /**
56
+ * Drive a ttyd PTY-over-websocket connection from a `pty-ws` Terminal capability,
57
+ * symmetric with `use-desktop-stream` (the noVNC-over-tunnel hook). The scoped
58
+ * stream token is already embedded in the minted tunnel `url`; the WebSocket is
59
+ * opened with the REQUIRED ttyd subprotocol "tty".
60
+ *
61
+ * ttyd wire protocol (see `@opengeni/sdk/terminal`):
62
+ * - first frame: `JSON.stringify({ AuthToken: "" })` (+ optional columns/rows).
63
+ * - client→server: INPUT = "0"+data ; RESIZE = "1"+JSON({columns,rows}).
64
+ * - server→client: "0" = OUTPUT (→ xterm) ; "1" = SET_WINDOW_TITLE ;
65
+ * "2" = SET_PREFERENCES (ignored). Binary frames are decoded the same way.
66
+ *
67
+ * On a `url`/`token` rotation (a box rollover folds a fresh address into the cell)
68
+ * the effect re-runs: the old socket closes and a fresh one connects — a brief
69
+ * terminal blink, acceptable on rollover (mirrors the desktop's RFB hot-swap).
70
+ * SSR-safe: the socket open lives in `useEffect`, so a server render is a no-op.
71
+ */
72
+ export function useTerminalStream(options: UseTerminalStreamOptions): UseTerminalStreamResult {
73
+ const { capability, onOutput, onTitle, initialCols, initialRows } = options;
74
+ const [status, setStatus] = useState<TerminalStreamStatus>("closed");
75
+ const wsRef = useRef<WebSocket | null>(null);
76
+ // Latest size, so a resize() before the socket opens is replayed on open, and a
77
+ // reconnect seeds the right geometry.
78
+ const sizeRef = useRef<{ cols: number; rows: number }>({
79
+ cols: initialCols ?? 80,
80
+ rows: initialRows ?? 24,
81
+ });
82
+ // Keep the callbacks current without re-running the connect effect on every
83
+ // render (the parent passes fresh closures each time).
84
+ const onOutputRef = useRef(onOutput);
85
+ const onTitleRef = useRef(onTitle);
86
+ onOutputRef.current = onOutput;
87
+ onTitleRef.current = onTitle;
88
+
89
+ const transport = capability?.transport ?? null;
90
+ const url = capability?.url ?? null;
91
+ const token = capability?.token ?? null;
92
+
93
+ useEffect(() => {
94
+ // SSR / no WebSocket / not a live pty-ws cell: stay closed; the caller falls
95
+ // back to the Channel-A read-only firehose.
96
+ if (typeof window === "undefined" || typeof WebSocket === "undefined") return;
97
+ if (transport !== "pty-ws" || !url) {
98
+ setStatus("closed");
99
+ return;
100
+ }
101
+
102
+ let disposed = false;
103
+ let socket: WebSocket;
104
+ setStatus("connecting");
105
+ try {
106
+ // The ttyd "tty" subprotocol is REQUIRED — ttyd rejects a handshake without
107
+ // it. The scoped token is already in the tunnel `url`.
108
+ socket = new WebSocket(terminalSocketUrl({ url }), TTYD_SUBPROTOCOL);
109
+ } catch {
110
+ setStatus("error");
111
+ return;
112
+ }
113
+ socket.binaryType = "arraybuffer";
114
+ wsRef.current = socket;
115
+
116
+ socket.onopen = () => {
117
+ if (disposed) return;
118
+ // ttyd's required first frame: the auth message (empty token — the gate is
119
+ // the tunnel url + scoped stream token, not a ttyd -c credential), seeded
120
+ // with the current PTY geometry. Then an explicit resize to be safe.
121
+ try {
122
+ socket.send(ttydAuthFrame({ columns: sizeRef.current.cols, rows: sizeRef.current.rows }));
123
+ socket.send(ttydResizeFrame(sizeRef.current.cols, sizeRef.current.rows));
124
+ } catch {
125
+ // a closed socket between open and send — onclose handles state.
126
+ }
127
+ setStatus("open");
128
+ };
129
+
130
+ socket.onmessage = (ev: MessageEvent) => {
131
+ if (disposed) return;
132
+ const { command, payload } = decodeFrame(ev.data as string | ArrayBuffer);
133
+ switch (command) {
134
+ case TtydServerCommand.OUTPUT:
135
+ onOutputRef.current?.(payload);
136
+ break;
137
+ case TtydServerCommand.SET_WINDOW_TITLE:
138
+ onTitleRef.current?.(payload);
139
+ break;
140
+ // SET_PREFERENCES ("2") and anything else: ignored.
141
+ default:
142
+ break;
143
+ }
144
+ };
145
+
146
+ socket.onerror = () => {
147
+ if (!disposed) setStatus("error");
148
+ };
149
+ socket.onclose = () => {
150
+ if (!disposed) setStatus("closed");
151
+ };
152
+
153
+ return () => {
154
+ disposed = true;
155
+ wsRef.current = null;
156
+ // Drop handlers so an in-flight close/error doesn't mutate state post-unmount.
157
+ socket.onopen = null;
158
+ socket.onmessage = null;
159
+ socket.onerror = null;
160
+ socket.onclose = null;
161
+ try {
162
+ socket.close();
163
+ } catch {
164
+ // ignore teardown errors
165
+ }
166
+ };
167
+ // A url/token change (rotation) re-runs this effect → close old, open new.
168
+ // eslint-disable-next-line react-hooks/exhaustive-deps
169
+ }, [transport, url, token]);
170
+
171
+ return useMemo<UseTerminalStreamResult>(() => {
172
+ const write = (data: string) => {
173
+ const ws = wsRef.current;
174
+ if (ws && ws.readyState === WebSocket.OPEN) {
175
+ try {
176
+ ws.send(ttydInputFrame(data));
177
+ } catch {
178
+ // socket raced closed — the reconnect effect will re-establish.
179
+ }
180
+ }
181
+ };
182
+ const resize = (cols: number, rows: number) => {
183
+ if (cols <= 0 || rows <= 0) return;
184
+ sizeRef.current = { cols, rows };
185
+ const ws = wsRef.current;
186
+ if (ws && ws.readyState === WebSocket.OPEN) {
187
+ try {
188
+ ws.send(ttydResizeFrame(cols, rows));
189
+ } catch {
190
+ // socket raced closed — geometry is replayed on the next open.
191
+ }
192
+ }
193
+ };
194
+ const disconnect = () => {
195
+ const ws = wsRef.current;
196
+ wsRef.current = null;
197
+ if (ws) {
198
+ try {
199
+ ws.close();
200
+ } catch {
201
+ // ignore
202
+ }
203
+ }
204
+ };
205
+ return { connected: status === "open", status, write, resize, disconnect };
206
+ }, [status]);
207
+ }
package/src/index.ts CHANGED
@@ -9,7 +9,7 @@ export { OpenGeniProvider, useOpenGeni, useOpenGeniClient } from "./provider";
9
9
  export type { ClientOverride, OpenGeniContextValue, OpenGeniProviderProps } from "./provider";
10
10
 
11
11
  // Hooks
12
- export { useSession } from "./hooks/use-session";
12
+ export { useSession, isTitleEvent } from "./hooks/use-session";
13
13
  export type { UseSessionOptions, UseSessionResult } from "./hooks/use-session";
14
14
  export { useSessionEvents } from "./hooks/use-session-events";
15
15
  export type { SessionEventsConnectionState, UseSessionEventsOptions, UseSessionEventsResult } from "./hooks/use-session-events";
@@ -46,6 +46,37 @@ export type { UseBillingUsageOptions, UseBillingUsageResult } from "./hooks/use-
46
46
  export { useAvailableModels } from "./hooks/use-available-models";
47
47
  export type { UseAvailableModelsOptions, UseAvailableModelsResult } from "./hooks/use-available-models";
48
48
 
49
+ // Sandbox surfacing (Phase 5): capability negotiation + terminal/files/diff/desktop
50
+ export { useSessionCapabilities } from "./hooks/use-session-capabilities";
51
+ export type {
52
+ SessionCapabilitiesState,
53
+ UseSessionCapabilitiesOptions,
54
+ UseSessionCapabilitiesResult,
55
+ } from "./hooks/use-session-capabilities";
56
+ export { useDesktopStream } from "./hooks/use-desktop-stream";
57
+ export type { UseDesktopStreamOptions, UseDesktopStreamResult } from "./hooks/use-desktop-stream";
58
+ export { useTerminalStream } from "./hooks/use-terminal-stream";
59
+ export type {
60
+ TerminalStreamStatus,
61
+ UseTerminalStreamOptions,
62
+ UseTerminalStreamResult,
63
+ } from "./hooks/use-terminal-stream";
64
+ export { useSandboxTerminal } from "./hooks/use-sandbox-terminal";
65
+ export type {
66
+ TerminalChunk,
67
+ UseSandboxTerminalOptions,
68
+ UseSandboxTerminalResult,
69
+ } from "./hooks/use-sandbox-terminal";
70
+ export { useSandboxFiles } from "./hooks/use-sandbox-files";
71
+ export type {
72
+ FileTreeNode,
73
+ FileTreeStatus,
74
+ UseSandboxFilesOptions,
75
+ UseSandboxFilesResult,
76
+ } from "./hooks/use-sandbox-files";
77
+ export { useSandboxGit } from "./hooks/use-sandbox-git";
78
+ export type { UseSandboxGitOptions, UseSandboxGitResult } from "./hooks/use-sandbox-git";
79
+
49
80
  // Pending-approvals projection
50
81
  export { approvalsFromRequiresAction, projectPendingApprovals } from "./approvals";
51
82
  export type { PendingApproval } from "./approvals";
@@ -53,13 +84,13 @@ export type { PendingApproval } from "./approvals";
53
84
  // Timeline projection
54
85
  export {
55
86
  buildTimeline,
56
- compactPayloadPreview,
57
87
  extractSessionRef,
58
88
  groupTimeline,
59
89
  sessionStatusFromEvents,
60
90
  toolDisplayName,
61
91
  } from "./timeline";
62
92
  export type {
93
+ ActivityItem,
63
94
  AgentMessageItem,
64
95
  GoalItem,
65
96
  NoticeItem,
@@ -73,6 +104,60 @@ export type {
73
104
  WorkerItem,
74
105
  } from "./timeline";
75
106
 
107
+ // Tool-renderer registry + the per-tool renderers (the timeline's extension API)
108
+ export { createDefaultToolRegistry, createToolRegistry, defaultToolRegistry, rawTypeOf } from "./timeline";
109
+ export type {
110
+ CreateToolRegistryOptions,
111
+ ToolRegistry,
112
+ ToolRegistryEntry,
113
+ ToolRenderer,
114
+ ToolRendererProps,
115
+ } from "./timeline";
116
+
117
+ // Timeline rendering primitives + the screenshot lightbox (compose custom renderers)
118
+ export {
119
+ ActivityDisclosure,
120
+ ActivityRail,
121
+ BodyNote,
122
+ DisclosureDefaultsProvider,
123
+ LightboxProvider,
124
+ MediaEmpty,
125
+ MediaSkeleton,
126
+ PayloadBlock,
127
+ ScreenshotFigure,
128
+ TermBlock,
129
+ Thumbnail,
130
+ TurnSummary,
131
+ useLightbox,
132
+ useLightboxOptional,
133
+ } from "./timeline";
134
+ export type {
135
+ ActivityDisclosureProps,
136
+ ActivityRailProps,
137
+ DisclosureChip,
138
+ TurnOutcome,
139
+ TurnSummaryProps,
140
+ } from "./timeline";
141
+
142
+ // Pure provider-shape parsers (exec banner, V4A diff, secret redaction, …)
143
+ export {
144
+ applyPatchOps,
145
+ controlCaret,
146
+ execTruncated,
147
+ isApplyPatch,
148
+ isExecSessionLostBanner,
149
+ looksBinary,
150
+ parseExecBannerSessionId,
151
+ parseToolArgs,
152
+ redactSecrets,
153
+ sandboxCommandExitCode,
154
+ stripExecBanner,
155
+ tailPeek,
156
+ unwrapMcpOutput,
157
+ v4aToGitFileDiff,
158
+ } from "./timeline";
159
+ export type { ApplyPatchOperation } from "./timeline";
160
+
76
161
  // Slash-command palette (registry + UI + hook)
77
162
  export {
78
163
  argHint,
@@ -107,7 +192,7 @@ export { ChatComposer } from "./components/chat-composer";
107
192
  export type { ChatComposerProps } from "./components/chat-composer";
108
193
  export { ModelPicker } from "./components/model-picker";
109
194
  export type { ModelPickerProps } from "./components/model-picker";
110
- export { MessageTimeline } from "./components/message-timeline";
195
+ export { MessageTimeline, TimelineRow } from "./components/message-timeline";
111
196
  export type { MessageTimelineProps } from "./components/message-timeline";
112
197
  export { Markdown } from "./components/markdown";
113
198
  export type { MarkdownProps } from "./components/markdown";
@@ -116,6 +201,30 @@ export type { SessionStatusProps, StatusDotProps, SessionStatusMeta } from "./co
116
201
  export { FleetTile, sessionDisplayTitle } from "./components/fleet-tile";
117
202
  export type { FleetTileProps } from "./components/fleet-tile";
118
203
 
204
+ // Sandbox surfacing components (Phase 5)
205
+ export { SandboxTerminal } from "./components/sandbox-terminal";
206
+ export type { SandboxTerminalProps, XtermTheme } from "./components/sandbox-terminal";
207
+ export { FileBrowser } from "./components/file-browser";
208
+ export type { FileBrowserProps } from "./components/file-browser";
209
+ export { DiffView } from "./components/diff-view";
210
+ export type { DiffViewProps, DiffTheme } from "./components/diff-view";
211
+ export { PierreDiff } from "./components/pierre-diff";
212
+ export type { PierreDiffProps } from "./components/pierre-diff";
213
+ export { PierreFile } from "./components/pierre-file";
214
+ export type { PierreFileProps } from "./components/pierre-file";
215
+ export { CodeEditor, languageForPath } from "./components/code-editor";
216
+ export type { CodeEditorProps } from "./components/code-editor";
217
+ export { SandboxFiles } from "./components/sandbox-files";
218
+ export type { SandboxFilesProps } from "./components/sandbox-files";
219
+ export { DesktopViewer } from "./components/desktop-viewer";
220
+ export type { DesktopViewerProps } from "./components/desktop-viewer";
221
+ export { WorkspaceDock } from "./components/workspace-dock";
222
+ export type { WorkspaceDockProps, WorkspaceTab } from "./components/workspace-dock";
223
+
224
+ // Sandbox helpers
225
+ export { gitFileDiffToPatch } from "./lib/git-patch";
226
+ export { xtermThemeFromTokens } from "./lib/xterm-theme";
227
+
119
228
  // Utilities
120
229
  export { cn } from "./lib/cn";
121
230
  export { formatBytes, formatRelativeTime, stringifyPayload, truncate, tryParseJson } from "./lib/format";
package/src/lib/cn.ts CHANGED
@@ -1,5 +1,24 @@
1
1
  import { clsx, type ClassValue } from "clsx";
2
- import { twMerge } from "tailwind-merge";
2
+ import { extendTailwindMerge } from "tailwind-merge";
3
+
4
+ /* ----------------------------------------------------------------------------
5
+ tailwind-merge, taught our design tokens.
6
+
7
+ Our type scale and color ramp share the `og-` prefix: `text-og-base` is a
8
+ FONT SIZE, `text-og-fg-muted` is a COLOR. Stock tailwind-merge can't tell
9
+ them apart — it lumps every `text-og-*` into one `font-size` group and, when
10
+ two land in the same class list, drops the earlier one. That silently ate the
11
+ size off rows like `text-og-base text-og-fg-muted`, leaving titles at the
12
+ browser-default 16px. Register the custom font-size scale so the size and the
13
+ color live in different conflict groups and both survive a merge.
14
+ -------------------------------------------------------------------------- */
15
+ const twMerge = extendTailwindMerge({
16
+ extend: {
17
+ classGroups: {
18
+ "font-size": [{ text: ["og-xs", "og-sm", "og-base", "og-md"] }],
19
+ },
20
+ },
21
+ });
3
22
 
4
23
  /** Merge class names with Tailwind-aware conflict resolution. */
5
24
  export function cn(...inputs: ClassValue[]): string {
@@ -0,0 +1,43 @@
1
+ import type { GitFileDiff } from "@opengeni/sdk";
2
+
3
+ /**
4
+ * Reconstruct a unified-diff patch string for a single `GitFileDiff` so it can
5
+ * be fed to a generic patch renderer (e.g. Pierre's `PatchDiff`). The hook
6
+ * already carries per-line old/new numbers and a hunk header, so we emit a
7
+ * conventional `--- / +++ / @@` patch the parser understands.
8
+ */
9
+ export function gitFileDiffToPatch(file: GitFileDiff): string {
10
+ const oldPath = file.oldPath ?? file.path;
11
+ const newPath = file.path;
12
+ const lines: string[] = [];
13
+ lines.push(`diff --git a/${oldPath} b/${newPath}`);
14
+ if (file.status === "deleted") {
15
+ lines.push(`--- a/${oldPath}`);
16
+ lines.push(`+++ /dev/null`);
17
+ } else if (file.status === "added" || file.status === "untracked") {
18
+ lines.push(`--- /dev/null`);
19
+ lines.push(`+++ b/${newPath}`);
20
+ } else {
21
+ lines.push(`--- a/${oldPath}`);
22
+ lines.push(`+++ b/${newPath}`);
23
+ }
24
+ for (const hunk of file.hunks) {
25
+ // Only trust a pre-parsed header if it carries the full unified range form
26
+ // `@@ -<o>[,<n>] +<o>[,<n>] @@`. A synthesized create_file hunk (parsers.ts)
27
+ // can carry a degenerate `@@ +1 @@` with no `-`/`+` ranges; a generic patch
28
+ // parser (Pierre) renders zero lines from it, so the expanded diff comes up
29
+ // empty while the collapsed chip still shows the (correct) addition count.
30
+ // In that case regenerate a valid header from the hunk's range fields.
31
+ const headerIsValid = /^@@ -\d+(?:,\d+)? \+\d+(?:,\d+)? @@/.test(hunk.header ?? "");
32
+ const header = headerIsValid
33
+ ? hunk.header
34
+ : `@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`;
35
+ lines.push(header);
36
+ for (const line of hunk.lines) {
37
+ if (line.type === "meta") continue;
38
+ const prefix = line.type === "add" ? "+" : line.type === "del" ? "-" : " ";
39
+ lines.push(`${prefix}${line.text}`);
40
+ }
41
+ }
42
+ return lines.join("\n") + "\n";
43
+ }
@@ -0,0 +1,40 @@
1
+ import { useEffect, useState } from "react";
2
+
3
+ /* ----------------------------------------------------------------------------
4
+ useThemeType
5
+
6
+ Resolve the effective dark/light theme for surfaces (the Pierre/Shiki diff)
7
+ that render outside the reach of host CSS — they need the theme as a value,
8
+ not a cascade. An explicit prop always wins; otherwise read the host's
9
+ `data-og-theme` attribute (set on `<html>` or any ancestor by the same opt-in
10
+ the tokens use) and default to dark, the first-class theme. A MutationObserver
11
+ keeps it live across runtime theme flips.
12
+
13
+ One detector, shared by every diff surface (the Files tab and the timeline),
14
+ so the two can never drift onto different themes.
15
+ -------------------------------------------------------------------------- */
16
+
17
+ /**
18
+ * Resolve the diff theme. An explicit `forced` value wins; otherwise auto-detect
19
+ * from the host `data-og-theme` (defaulting to dark) and track live flips.
20
+ */
21
+ export function useThemeType(forced: "dark" | "light" | undefined): "dark" | "light" {
22
+ const [detected, setDetected] = useState<"dark" | "light">("dark");
23
+ useEffect(() => {
24
+ if (forced || typeof document === "undefined") return;
25
+ const read = () => {
26
+ const el = document.querySelector("[data-og-theme]");
27
+ const value = el?.getAttribute("data-og-theme");
28
+ setDetected(value === "light" ? "light" : "dark");
29
+ };
30
+ read();
31
+ const observer = new MutationObserver(read);
32
+ observer.observe(document.documentElement, {
33
+ attributes: true,
34
+ attributeFilter: ["data-og-theme"],
35
+ subtree: true,
36
+ });
37
+ return () => observer.disconnect();
38
+ }, [forced]);
39
+ return forced ?? detected;
40
+ }
@@ -0,0 +1,34 @@
1
+ import type { XtermTheme } from "../components/sandbox-terminal";
2
+
3
+ /**
4
+ * Derive an xterm `ITheme` subset from the live OKLCH `--og-*` token system (or
5
+ * the app's `--color-*` aliases). Reads the COMPUTED values so xterm — which
6
+ * paints into a canvas and can't consume CSS vars — gets concrete colors. Call
7
+ * on mount and re-derive on a `data-og-theme` flip.
8
+ *
9
+ * SSR-safe: returns undefined off the DOM (the caller keeps xterm's defaults).
10
+ */
11
+ export function xtermThemeFromTokens(root?: HTMLElement | null): XtermTheme | undefined {
12
+ if (typeof window === "undefined" || typeof getComputedStyle === "undefined") return undefined;
13
+ const el = root ?? document.documentElement;
14
+ const style = getComputedStyle(el);
15
+ const read = (names: string[]): string | undefined => {
16
+ for (const name of names) {
17
+ const value = style.getPropertyValue(name).trim();
18
+ if (value) return value;
19
+ }
20
+ return undefined;
21
+ };
22
+ const bg = read(["--og-color-bg", "--color-bg"]);
23
+ const fg = read(["--og-color-fg", "--color-fg"]);
24
+ const accent = read(["--og-color-accent", "--color-brand", "--color-accent"]);
25
+ const theme: XtermTheme = {};
26
+ if (bg) theme.background = bg;
27
+ if (fg) theme.foreground = fg;
28
+ if (accent) {
29
+ theme.cursor = accent;
30
+ theme.selectionBackground = accent;
31
+ }
32
+ if (bg) theme.cursorAccent = bg;
33
+ return Object.keys(theme).length > 0 ? theme : undefined;
34
+ }