@opengeni/react 0.12.0 → 0.13.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 (85) hide show
  1. package/README.md +1 -1
  2. package/dist/{chunk-5TIXRCSI.js → chunk-NFYVQWIB.js} +150 -64
  3. package/dist/chunk-NFYVQWIB.js.map +1 -0
  4. package/dist/index.d.ts +760 -142
  5. package/dist/index.js +5345 -2146
  6. package/dist/index.js.map +1 -1
  7. package/dist/{machines-BeZ3bD3t.d.ts → machines-CnlMb7E-.d.ts} +5 -5
  8. package/dist/machines.d.ts +1 -1
  9. package/dist/machines.js +1 -1
  10. package/package.json +17 -12
  11. package/src/approvals.ts +16 -4
  12. package/src/client.ts +32 -5
  13. package/src/commands/index.ts +1 -7
  14. package/src/commands/registry.ts +46 -12
  15. package/src/components/chat-composer.tsx +135 -39
  16. package/src/components/code-editor.tsx +28 -36
  17. package/src/components/command-palette.tsx +26 -5
  18. package/src/components/desktop-viewer.tsx +29 -16
  19. package/src/components/diff-view.tsx +27 -189
  20. package/src/components/enrollment-consent.tsx +28 -10
  21. package/src/components/enrollment-device-flow.tsx +8 -5
  22. package/src/components/file-browser.tsx +190 -56
  23. package/src/components/fleet-tile.tsx +13 -4
  24. package/src/components/machine-card.tsx +16 -4
  25. package/src/components/machine-dock-bar.tsx +20 -8
  26. package/src/components/machine-metrics.tsx +31 -8
  27. package/src/components/machine-status-pill.tsx +26 -5
  28. package/src/components/machines-dashboard.tsx +8 -2
  29. package/src/components/markdown.tsx +39 -9
  30. package/src/components/message-timeline.tsx +633 -109
  31. package/src/components/pierre-diff.tsx +83 -15
  32. package/src/components/pierre-file.tsx +10 -9
  33. package/src/components/sandbox-files.tsx +110 -227
  34. package/src/components/sandbox-terminal.tsx +256 -88
  35. package/src/components/sandbox-workspace.tsx +823 -0
  36. package/src/components/session-status.tsx +28 -2
  37. package/src/components/workbench-changes.tsx +541 -0
  38. package/src/components/workspace-dock.tsx +85 -22
  39. package/src/hooks/internal.ts +5 -2
  40. package/src/hooks/use-available-models.ts +7 -2
  41. package/src/hooks/use-billing-usage.ts +4 -1
  42. package/src/hooks/use-codex-accounts.ts +74 -44
  43. package/src/hooks/use-composer.ts +57 -36
  44. package/src/hooks/use-environments.ts +92 -47
  45. package/src/hooks/use-file-attachments.ts +101 -55
  46. package/src/hooks/use-goal.ts +52 -16
  47. package/src/hooks/use-machine-chip.ts +134 -0
  48. package/src/hooks/use-machines.ts +34 -16
  49. package/src/hooks/use-packs.ts +24 -19
  50. package/src/hooks/use-relay-frame-stream.ts +5 -2
  51. package/src/hooks/use-rigs.ts +335 -0
  52. package/src/hooks/use-sandbox-files.ts +213 -39
  53. package/src/hooks/use-sandbox-git.ts +188 -11
  54. package/src/hooks/use-sandbox-terminal.ts +18 -14
  55. package/src/hooks/use-scheduled-tasks.ts +10 -2
  56. package/src/hooks/use-session-capabilities.ts +77 -20
  57. package/src/hooks/use-session-control.ts +49 -21
  58. package/src/hooks/use-session-events.ts +48 -20
  59. package/src/hooks/use-session-lineage.ts +119 -0
  60. package/src/hooks/use-session.ts +43 -28
  61. package/src/hooks/use-slash-commands.ts +6 -4
  62. package/src/hooks/use-turn-queue.ts +74 -147
  63. package/src/hooks/use-windowed-sections.ts +204 -0
  64. package/src/hooks/use-workspace-capture.ts +233 -0
  65. package/src/hooks/use-workspace-edit.ts +231 -0
  66. package/src/hooks/use-workspace-sessions.ts +48 -5
  67. package/src/hooks/use-workspaces.ts +18 -15
  68. package/src/index.ts +127 -23
  69. package/src/lib/format.ts +3 -3
  70. package/src/lib/xterm-renderer.ts +65 -0
  71. package/src/lib/xterm-theme.ts +224 -18
  72. package/src/machines.ts +13 -3
  73. package/src/provider.tsx +3 -1
  74. package/src/timeline/activity-rail.tsx +213 -54
  75. package/src/timeline/disclosure-context.tsx +12 -2
  76. package/src/timeline/index.ts +21 -2
  77. package/src/timeline/parsers.ts +44 -10
  78. package/src/timeline/projection.ts +388 -85
  79. package/src/timeline/shared.tsx +111 -24
  80. package/src/timeline/tool-diff.tsx +24 -20
  81. package/src/timeline/tool-renderers.tsx +98 -21
  82. package/src/timeline/turn-summary.tsx +76 -23
  83. package/src/timeline/types.ts +96 -12
  84. package/styles/tokens.css +2 -2
  85. package/dist/chunk-5TIXRCSI.js.map +0 -1
@@ -3,14 +3,38 @@ import { type ReactNode, useEffect, useRef, useState } from "react";
3
3
  import { cn } from "../lib/cn";
4
4
  import { useTerminalStream } from "../hooks/use-terminal-stream";
5
5
  import type { UseSandboxTerminalResult } from "../hooks/use-sandbox-terminal";
6
+ import { resolveTerminalFont, xtermThemeFromTokens } from "../lib/xterm-theme";
7
+ import { attachRenderer, type RendererLoaders, type RendererTier } from "../lib/xterm-renderer";
6
8
 
7
- /** A subset of xterm.js's ITheme — the tokens worth themeing from a host app. */
9
+ /**
10
+ * A COMPLETE xterm `ITheme` (subset by intent, but every field xterm colors a
11
+ * cell with is present so it never falls back to its stock VGA palette). Built
12
+ * from the og tokens via `xtermThemeFromTokens`.
13
+ */
8
14
  export type XtermTheme = {
9
15
  background?: string;
10
16
  foreground?: string;
11
17
  cursor?: string;
12
18
  cursorAccent?: string;
13
19
  selectionBackground?: string;
20
+ selectionForeground?: string;
21
+ selectionInactiveBackground?: string;
22
+ black?: string;
23
+ red?: string;
24
+ green?: string;
25
+ yellow?: string;
26
+ blue?: string;
27
+ magenta?: string;
28
+ cyan?: string;
29
+ white?: string;
30
+ brightBlack?: string;
31
+ brightRed?: string;
32
+ brightGreen?: string;
33
+ brightYellow?: string;
34
+ brightBlue?: string;
35
+ brightMagenta?: string;
36
+ brightCyan?: string;
37
+ brightWhite?: string;
14
38
  };
15
39
 
16
40
  export type SandboxTerminalProps = {
@@ -26,9 +50,21 @@ export type SandboxTerminalProps = {
26
50
  * (`result.chunks`). Omit to force the legacy firehose-only behavior.
27
51
  */
28
52
  terminalCapability?: TerminalCapability | null | undefined;
53
+ /**
54
+ * Full xterm theme. When omitted the terminal self-derives it from the `--og-*`
55
+ * tokens of its own container (correct even inside a nested `data-og-theme`
56
+ * subtree). Passing it lets the host drive re-theme on a theme flip.
57
+ */
29
58
  theme?: XtermTheme | undefined;
30
59
  fontFamily?: string | undefined;
31
60
  fontSize?: number | undefined;
61
+ /**
62
+ * Lease liveness (`cold` | `warming` | `warm` | `draining`). Drives the
63
+ * boot-in-terminal status lines: once the user has focused the terminal and
64
+ * the box is not yet warm, a styled "● waking machine — Ns…" line is rendered
65
+ * INSIDE xterm (no overlay/spinner) until the live PTY connects.
66
+ */
67
+ liveness?: string | undefined;
32
68
  /**
33
69
  * Force read-only even when the PTY accepts stdin. Default: interactive
34
70
  * whenever a live pty-ws stream is connected OR `result.write !== null` (the box
@@ -64,21 +100,69 @@ type XtermLike = {
64
100
  dispose: () => void;
65
101
  cols: number;
66
102
  rows: number;
67
- options: { theme?: XtermTheme; disableStdin?: boolean };
103
+ options: {
104
+ theme?: XtermTheme;
105
+ disableStdin?: boolean;
106
+ cursorBlink?: boolean;
107
+ fontFamily?: string;
108
+ fontSize?: number;
109
+ };
68
110
  };
69
111
  type FitAddonLike = { fit: () => void };
70
112
 
113
+ /** Debug seam (dev/evidence only, never a typed public API): a global hook the
114
+ * screenshot harness reads to probe the settled renderer tier + resolved font
115
+ * without mounting a real WebGL context in unit tests. */
116
+ type TerminalDebugInfo = {
117
+ renderer: RendererTier | null;
118
+ fontFamily: string | undefined;
119
+ fontSize: number | undefined;
120
+ hasVarInFont: boolean;
121
+ theme: XtermTheme | undefined;
122
+ /** The live xterm instance (opaque) — the evidence harness reads its buffer to
123
+ * prove screen preservation (E5) and drive the burst-responsiveness probe. */
124
+ term: unknown;
125
+ };
126
+ declare global {
127
+ // eslint-disable-next-line no-var
128
+ var __OG_TERMINAL_DEBUG__: ((info: TerminalDebugInfo) => void) | undefined;
129
+ // A space/comma list of renderer tiers to force-fail, so the demo can prove the
130
+ // WebGL→canvas→DOM fallback ladder without a real GPU context loss.
131
+ // eslint-disable-next-line no-var
132
+ var __OG_FORCE_RENDERER_FAIL__: string | undefined;
133
+ }
134
+
135
+ /** Which renderer tiers are force-failed (demo/evidence only). */
136
+ function forcedRendererFailures(): Set<RendererTier> {
137
+ const raw = typeof globalThis !== "undefined" ? globalThis.__OG_FORCE_RENDERER_FAIL__ : undefined;
138
+ const set = new Set<RendererTier>();
139
+ if (typeof raw === "string") {
140
+ for (const tier of raw.split(/[\s,]+/)) {
141
+ if (tier === "webgl" || tier === "canvas") set.add(tier);
142
+ }
143
+ }
144
+ return set;
145
+ }
146
+
71
147
  /**
72
148
  * An xterm.js terminal fed by the Channel-A event projection
73
- * (`useSandboxTerminal`). xterm + the fit + web-links addons are lazy-imported
149
+ * (`useSandboxTerminal`) or a live ttyd PTY. xterm + its addons are lazy-imported
74
150
  * inside an effect, so SSR renders the placeholder and the terminal mounts on
75
- * hydration. Output chunks are written incrementally (tracking a written-cursor
76
- * by chunk id so a re-render never re-writes). When `result.write` is non-null
77
- * and not forced read-only, keystrokes pipe back through the PTY.
151
+ * hydration.
152
+ *
153
+ * Renderer: WebGL (GPU-composited cells) with a canvas DOM fallback ladder
154
+ * (`attachRenderer`), so bursty output stays smooth but a lost/blocklisted
155
+ * context degrades gracefully. Font + theme are resolved to CONCRETE values
156
+ * from the og tokens BEFORE construction (xterm measures glyphs in a canvas
157
+ * where `var()` never resolves, and colors every cell from a full ANSI palette).
158
+ *
159
+ * The terminal is mounted ONCE and its data source is swapped in place: the
160
+ * read-only firehose (`result.chunks`) → the live PTY. Stdin is toggled via
161
+ * `term.options.disableStdin` at runtime (not a remount), so scrollback and the
162
+ * visible screen survive the projection→PTY handoff.
78
163
  *
79
164
  * Resizes are tracked with a `ResizeObserver` on the container (not just
80
- * `window.resize`) so dragging the dock handle refits the grid instead of
81
- * leaving the terminal mis-sized.
165
+ * `window.resize`) so dragging the dock handle refits the grid.
82
166
  */
83
167
  export function SandboxTerminal({
84
168
  result,
@@ -86,6 +170,7 @@ export function SandboxTerminal({
86
170
  theme,
87
171
  fontFamily,
88
172
  fontSize,
173
+ liveness,
89
174
  readOnly,
90
175
  placeholder,
91
176
  showHeader,
@@ -96,20 +181,23 @@ export function SandboxTerminal({
96
181
  const containerRef = useRef<HTMLDivElement | null>(null);
97
182
  const termRef = useRef<XtermLike | null>(null);
98
183
  const fitRef = useRef<FitAddonLike | null>(null);
184
+ const rendererRef = useRef<RendererTier | null>(null);
99
185
  const writtenRef = useRef<Set<string>>(new Set());
186
+ const wroteFirehoseRef = useRef(false);
187
+ const bootActiveRef = useRef(false);
100
188
  const [ready, setReady] = useState(false);
189
+ const [activated, setActivated] = useState(false);
101
190
 
102
191
  // ── Interactive transport switch ─────────────────────────────────────────────
103
- // The interactive terminal is a REAL bidirectional PTY (ttyd over the Modal
104
- // tunnel) WHENEVER the Terminal cell advertises `transport: "pty-ws"` + a live
105
- // `url`; the ttyd socket then OWNS the screen (input + output both ride it). On
106
- // a cold box / sse-events cell, `useTerminalStream` stays idle and we fall back
107
- // to the read-only Channel-A command-output firehose (`result.chunks`) — with
108
- // the legacy `result.write` (ptyWrite-over-HTTP) as the only stdin path there.
109
192
  const ptyWs = terminalCapability?.transport === "pty-ws" && Boolean(terminalCapability?.url);
110
193
  // Output frames buffer here until xterm has mounted; the write effect drains it.
111
194
  const ptyOutputQueueRef = useRef<string[]>([]);
112
- const ptyStream = useTerminalStream({
195
+ const {
196
+ status: ptyStatus,
197
+ write: ptyWrite,
198
+ resize: resizePty,
199
+ connected: ptyConnected,
200
+ } = useTerminalStream({
113
201
  capability: ptyWs
114
202
  ? {
115
203
  transport: terminalCapability!.transport,
@@ -124,48 +212,49 @@ export function SandboxTerminal({
124
212
  },
125
213
  });
126
214
 
127
- // PTY mode = a live ttyd socket drives the screen. In PTY mode the screen is
128
- // owned by the websocket (we must NOT replay the SSE firehose into it — that
129
- // double-writes the agent's command output on top of the live PTY). Firehose
130
- // mode = the read-only projection (or the legacy HTTP-write fallback).
131
- const ptyMode = ptyWs && ptyStream.status !== "closed";
132
- // Interactive (stdin enabled) when a live PTY socket is connected, OR (legacy
133
- // fallback) the projection exposed an HTTP write fn. `readOnly` forces off.
215
+ // PTY mode = a live ttyd socket drives the screen. Firehose mode = the
216
+ // read-only projection (or the legacy HTTP-write fallback).
217
+ const ptyMode = ptyWs && ptyStatus !== "closed";
134
218
  const interactive = !readOnly && (ptyMode || result.write !== null);
135
219
 
136
- // Mount xterm once (client-only). Re-mounts only when the interactive flag
137
- // flips (stdin enable/disable is a construction param on xterm).
220
+ // Boot-in-terminal: after the user focuses a not-yet-warm box, show styled
221
+ // status lines INSIDE xterm instead of an overlay — but only when there is no
222
+ // firehose transcript to show yet (otherwise the projected output is shown).
223
+ const warm = liveness === "warm" || liveness === "draining";
224
+ const booting = activated && !ptyMode && !warm && liveness != null && result.chunks.length === 0;
225
+
226
+ function handleActivate() {
227
+ if (!activated) setActivated(true);
228
+ onActivate?.();
229
+ }
230
+
231
+ // Mount xterm once (client-only). Never re-mounts on interactive/theme flips —
232
+ // stdin + theme are runtime options synced by the effects below, so the visible
233
+ // screen survives the projection→PTY handoff (E5).
138
234
  useEffect(() => {
139
235
  if (typeof window === "undefined") return;
140
236
  const el = containerRef.current;
141
237
  if (!el) return;
142
238
  let disposed = false;
143
239
 
144
- // Wait for the container to have a real measured size before opening xterm.
145
- // If `term.open()` runs while the panel is 0-wide (tab still mounting, dock
146
- // mid-resize), xterm renders at its 80×24 fallback and the first frame's
147
- // grid is rasterized into the canvas; a later fit reflows the text but the
148
- // stale first paint lingers as a `]]]bbb` smear on the top row. Gating the
149
- // open on a non-zero size removes that first wrong-width render entirely.
150
- const waitForSize = (el: HTMLElement) =>
240
+ // Wait for a real measured size before opening xterm so the first fit is at
241
+ // the true width (belt-and-braces with the visibility gate below).
242
+ const waitForSize = (node: HTMLElement) =>
151
243
  new Promise<void>((resolve) => {
152
- if (el.clientWidth > 0 && el.clientHeight > 0) return resolve();
244
+ if (node.clientWidth > 0 && node.clientHeight > 0) return resolve();
153
245
  const ro = new ResizeObserver(() => {
154
- if (el.clientWidth > 0 && el.clientHeight > 0) {
246
+ if (node.clientWidth > 0 && node.clientHeight > 0) {
155
247
  ro.disconnect();
156
248
  resolve();
157
249
  }
158
250
  });
159
- ro.observe(el);
160
- // Safety: never hang if the observer somehow never fires.
251
+ ro.observe(node);
161
252
  setTimeout(() => {
162
253
  ro.disconnect();
163
254
  resolve();
164
255
  }, 1000);
165
256
  });
166
257
 
167
- // Inject xterm's structural CSS before the first open so the helper-textarea
168
- // ghost is clipped from frame one (no top-left input box flash).
169
258
  ensureXtermBaseCss();
170
259
 
171
260
  void (async () => {
@@ -177,32 +266,68 @@ export function SandboxTerminal({
177
266
  if (disposed) return;
178
267
  await waitForSize(el);
179
268
  if (disposed) return;
269
+
270
+ // Resolve font + theme to CONCRETE values BEFORE construction. `var()` in a
271
+ // font family never resolves in xterm's canvas measurement, so we read the
272
+ // computed `--og-font-mono` here; the theme is a full ANSI palette.
273
+ const font = resolveTerminalFont(el, { fontFamily, fontSize });
274
+ const initialTheme = theme ?? xtermThemeFromTokens(el);
275
+
180
276
  const term = new Terminal({
181
277
  convertEol: true,
182
278
  disableStdin: !interactive,
183
279
  cursorBlink: interactive,
184
- fontFamily: fontFamily ?? "var(--og-font-mono)",
185
- fontSize: fontSize ?? 13,
186
- lineHeight: 1.25,
187
- // A little breathing room from the panel edge so output isn't flush to
188
- // the border (matches a real terminal app's inner gutter).
189
- ...({ padding: 8 } as Record<string, unknown>),
190
- ...(theme ? { theme } : {}),
280
+ fontFamily: font.fontFamily,
281
+ fontSize: font.fontSize,
282
+ lineHeight: font.lineHeight,
283
+ ...(initialTheme ? { theme: initialTheme } : {}),
191
284
  }) as unknown as XtermLike;
192
285
  const fit = new FitAddon() as unknown as FitAddonLike;
193
286
  term.loadAddon(fit);
194
- // Clickable URLs in agent output (open in a new tab).
195
287
  term.loadAddon(
196
- new WebLinksAddon((_e: MouseEvent, uri: string) => window.open(uri, "_blank", "noopener,noreferrer")) as unknown,
288
+ new WebLinksAddon((_e: MouseEvent, uri: string) =>
289
+ window.open(uri, "_blank", "noopener,noreferrer"),
290
+ ) as unknown,
197
291
  );
198
292
  term.open(el);
199
293
  termRef.current = term;
200
294
  fitRef.current = fit;
201
- // Critical: the column count must be settled BEFORE any output is written,
202
- // or seeded transcript lines reflow/garble against the default 80×24 grid
203
- // (the trailing prompt's escape codes smear into `]]]bbb` artifacts). Fit
204
- // once now, then again on the next frame after layout has measured the
205
- // real container width, and only then unblock the write effect.
295
+
296
+ // Attach the fastest available renderer. For xterm-6 the supported ladder
297
+ // is WebGL DOM (the standalone 2D-canvas addon targets xterm-5 internals,
298
+ // so it is intentionally NOT in the shipped path). Init failure / context
299
+ // loss steps down to xterm's built-in DOM renderer; the settled tier is
300
+ // exposed for evidence.
301
+ const failSet = forcedRendererFailures();
302
+ const loaders: RendererLoaders = {
303
+ webgl: async (onLoss) => {
304
+ if (failSet.has("webgl")) throw new Error("forced webgl failure");
305
+ const { WebglAddon } = await import("@xterm/addon-webgl");
306
+ const addon = new WebglAddon() as unknown as {
307
+ dispose: () => void;
308
+ onContextLoss?: (cb: () => void) => void;
309
+ };
310
+ addon.onContextLoss?.(() => {
311
+ try {
312
+ addon.dispose();
313
+ } catch {
314
+ // already disposed
315
+ }
316
+ onLoss();
317
+ });
318
+ term.loadAddon(addon);
319
+ return addon;
320
+ },
321
+ };
322
+ await attachRenderer("webgl", loaders, (tier) => {
323
+ rendererRef.current = tier;
324
+ el.setAttribute("data-og-term-renderer", tier);
325
+ });
326
+ if (disposed) return;
327
+
328
+ // Fit BEFORE reveal so the first visible frame is already correctly sized
329
+ // (no 80×24 rasterize-then-reflow flash — the container stays hidden until
330
+ // now via the `ready` visibility gate).
206
331
  const settle = () => {
207
332
  try {
208
333
  fit.fit();
@@ -214,11 +339,21 @@ export function SandboxTerminal({
214
339
  requestAnimationFrame(() => {
215
340
  if (disposed) return;
216
341
  settle();
217
- // Drain any ttyd OUTPUT that arrived before xterm finished mounting.
218
342
  if (ptyOutputQueueRef.current.length > 0) {
219
343
  for (const data of ptyOutputQueueRef.current) term.write(data);
220
344
  ptyOutputQueueRef.current = [];
221
345
  }
346
+ el.setAttribute("data-og-term-ready", "true");
347
+ if (typeof globalThis.__OG_TERMINAL_DEBUG__ === "function") {
348
+ globalThis.__OG_TERMINAL_DEBUG__({
349
+ renderer: rendererRef.current,
350
+ fontFamily: term.options.fontFamily,
351
+ fontSize: term.options.fontSize,
352
+ hasVarInFont: String(term.options.fontFamily ?? "").includes("var("),
353
+ theme: term.options.theme,
354
+ term,
355
+ });
356
+ }
222
357
  setReady(true);
223
358
  });
224
359
  })();
@@ -228,71 +363,97 @@ export function SandboxTerminal({
228
363
  termRef.current?.dispose();
229
364
  termRef.current = null;
230
365
  fitRef.current = null;
366
+ rendererRef.current = null;
231
367
  writtenRef.current = new Set();
368
+ wroteFirehoseRef.current = false;
369
+ bootActiveRef.current = false;
232
370
  setReady(false);
233
371
  };
372
+ // Mount ONCE — see the effects below for the runtime option syncs.
234
373
  // eslint-disable-next-line react-hooks/exhaustive-deps
235
- }, [interactive]);
374
+ }, []);
236
375
 
237
- // Re-theme live (e.g. dark↔light flip) without re-mounting / losing scrollback.
376
+ // Sync stdin/cursor at runtime (NOT a remount) when interactivity flips, so the
377
+ // projection→PTY handoff keeps the single Terminal instance + its scrollback.
238
378
  useEffect(() => {
239
379
  const term = termRef.current;
240
- if (!ready || !term || !theme) return;
241
- term.options.theme = theme;
380
+ if (!ready || !term) return;
381
+ term.options.disableStdin = !interactive;
382
+ term.options.cursorBlink = interactive;
383
+ }, [ready, interactive]);
384
+
385
+ // Re-theme live (dark↔light flip) without re-mounting / losing scrollback. Uses
386
+ // the passed theme, or self-derives from the container's tokens.
387
+ useEffect(() => {
388
+ const term = termRef.current;
389
+ if (!ready || !term) return;
390
+ const next = theme ?? xtermThemeFromTokens(containerRef.current);
391
+ if (next) term.options.theme = next;
242
392
  }, [ready, theme]);
243
393
 
244
- // On the firehose PTY transition (the box warmed and the ttyd socket came
245
- // up), clear the stale read-only transcript so the live shell starts on a clean
246
- // screen instead of below the agent's projected command output.
247
- const enteredPtyRef = useRef(false);
394
+ // Boot-in-terminal status lines. While `booting`, paint a styled "● waking
395
+ // machine Ns…" line and tick it. On exit, if those lines were the ONLY
396
+ // content (no firehose transcript), clear them for the incoming live screen
397
+ // never clear a real transcript (E5 preservation).
248
398
  useEffect(() => {
249
399
  const term = termRef.current;
250
400
  if (!ready || !term) return;
251
- if (ptyMode && !enteredPtyRef.current) {
252
- enteredPtyRef.current = true;
253
- term.clear();
254
- } else if (!ptyMode) {
255
- enteredPtyRef.current = false;
401
+ if (!booting) {
402
+ if (bootActiveRef.current) {
403
+ bootActiveRef.current = false;
404
+ if (!wroteFirehoseRef.current) term.clear();
405
+ }
406
+ return;
256
407
  }
257
- }, [ready, ptyMode]);
408
+ bootActiveRef.current = true;
409
+ const start = Date.now();
410
+ const paint = () => {
411
+ const s = Math.max(0, Math.round((Date.now() - start) / 1000));
412
+ // Bright-blue dot (accent) + dim label; `\r` + clear-line keeps it on one
413
+ // line so the counter updates in place.
414
+ term.write(`\r\x1b[2K\x1b[94m●\x1b[0m \x1b[2mwaking machine — ${s}s…\x1b[0m`);
415
+ };
416
+ term.write("\r\n");
417
+ paint();
418
+ const id = setInterval(paint, 1000);
419
+ return () => clearInterval(id);
420
+ }, [ready, booting]);
258
421
 
259
- // Write new firehose chunks incrementally — but ONLY in firehose mode. In PTY
260
- // mode the ttyd socket owns the screen; replaying the SSE command-output
261
- // projection on top of it would double-render the agent's output.
422
+ // Write new firehose chunks incrementally — ONLY in firehose mode. In PTY mode
423
+ // the ttyd socket owns the screen. Records that a real transcript exists so the
424
+ // boot cleanup never wipes it.
262
425
  useEffect(() => {
263
426
  const term = termRef.current;
264
427
  if (!ready || !term || ptyMode) return;
265
428
  for (const chunk of result.chunks) {
266
429
  if (writtenRef.current.has(chunk.id)) continue;
267
430
  writtenRef.current.add(chunk.id);
431
+ wroteFirehoseRef.current = true;
268
432
  term.write(chunk.text);
269
433
  }
270
434
  }, [ready, result.chunks, ptyMode]);
271
435
 
272
436
  // Wire interactive input when allowed. PTY mode pipes keystrokes to the ttyd
273
- // socket (the real PTY); firehose mode uses the legacy ptyWrite-over-HTTP fn.
437
+ // socket; firehose mode uses the legacy ptyWrite-over-HTTP fn.
274
438
  useEffect(() => {
275
439
  const term = termRef.current;
276
440
  if (!ready || !term || !interactive) return;
277
- const sink = ptyMode ? ptyStream.write : result.write;
441
+ const sink = ptyMode ? ptyWrite : result.write;
278
442
  if (!sink) return;
279
443
  const sub = term.onData((data) => sink(data));
280
444
  return () => sub.dispose();
281
- }, [ready, interactive, ptyMode, ptyStream.write, result.write]);
445
+ }, [ready, interactive, ptyMode, ptyWrite, result.write]);
282
446
 
283
- // In PTY mode, tell ttyd the window size on the xterm resize event (the fit
284
- // addon reflows the grid; ttyd resizes the remote PTY to match), and push the
285
- // initial geometry once the socket is open.
447
+ // In PTY mode, tell ttyd the window size on the xterm resize event.
286
448
  useEffect(() => {
287
449
  const term = termRef.current;
288
450
  if (!ready || !term || !ptyMode) return;
289
- ptyStream.resize(term.cols, term.rows);
290
- const sub = term.onResize(({ cols, rows }) => ptyStream.resize(cols, rows));
451
+ resizePty(term.cols, term.rows);
452
+ const sub = term.onResize(({ cols, rows }) => resizePty(cols, rows));
291
453
  return () => sub.dispose();
292
- }, [ready, ptyMode, ptyStream.connected, ptyStream.resize]);
454
+ }, [ready, ptyMode, ptyConnected, resizePty]);
293
455
 
294
- // Refit on container resize (dock drag) AND window resize. The fit addon
295
- // mutates xterm's cols/rows → the onResize handler above forwards to ttyd.
456
+ // Refit on container resize (dock drag) AND window resize.
296
457
  useEffect(() => {
297
458
  if (typeof window === "undefined") return;
298
459
  const refit = () => {
@@ -326,9 +487,7 @@ export function SandboxTerminal({
326
487
  result.running ? "bg-og-status-running" : "bg-og-fg-subtle",
327
488
  )}
328
489
  />
329
- <span className="truncate font-og-mono">
330
- pty: {shell ?? "shell"}
331
- </span>
490
+ <span className="truncate font-og-mono">pty: {shell ?? "shell"}</span>
332
491
  </span>
333
492
  <span className="flex shrink-0 items-center gap-2">
334
493
  {!interactive && (
@@ -341,6 +500,7 @@ export function SandboxTerminal({
341
500
  onClick={() => {
342
501
  termRef.current?.clear();
343
502
  writtenRef.current = new Set();
503
+ wroteFirehoseRef.current = false;
344
504
  }}
345
505
  className="rounded-og-sm px-1.5 py-0.5 text-og-xs hover:text-og-fg pointer-coarse:min-h-10"
346
506
  >
@@ -351,14 +511,22 @@ export function SandboxTerminal({
351
511
  )}
352
512
  {/* `onPointerDownCapture`/`onFocusCapture` fire on the FIRST user engagement
353
513
  with the terminal surface (capture so they win even though xterm's own
354
- listeners stop propagation). The host warms the box for pty-ws here. */}
514
+ listeners stop propagation). The host warms the box for pty-ws here. The
515
+ 12px inset + `--og-color-bg` ground match the dock surface. */}
355
516
  <div
356
- className="relative min-h-0 flex-1 bg-og-bg px-2 py-1.5"
357
- onPointerDownCapture={onActivate}
358
- onFocusCapture={onActivate}
517
+ className="relative min-h-0 flex-1 bg-og-bg p-3"
518
+ onPointerDownCapture={handleActivate}
519
+ onFocusCapture={handleActivate}
359
520
  >
360
521
  {!ready && (placeholder ?? <TerminalPlaceholder />)}
361
- <div ref={containerRef} className="h-full w-full" data-opengeni-terminal />
522
+ {/* Kept `visibility:hidden` until the first successful fit so the first
523
+ VISIBLE frame is already correctly sized (no 80×24 flash — E4). */}
524
+ <div
525
+ ref={containerRef}
526
+ className="h-full w-full"
527
+ style={{ visibility: ready ? "visible" : "hidden" }}
528
+ data-opengeni-terminal
529
+ />
362
530
  </div>
363
531
  </div>
364
532
  );