@rigkit/provider-freestyle 0.2.3 → 0.2.5

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.
@@ -4,9 +4,13 @@ import type { ProviderInteractionSession } from "@rigkit/engine";
4
4
  export type FreestyleTerminalSessionRequest = {
5
5
  title: string;
6
6
  command: string;
7
+ displayCommand?: string;
8
+ startupInput?: string;
7
9
  remoteCommand?: string;
10
+ canFinishWhileRunning?: boolean;
8
11
  instructions?: string;
9
12
  nodePath?: string;
13
+ openExternalTarget?: (target: string) => unknown;
10
14
  };
11
15
 
12
16
  export type FreestyleTerminalSessionResult = {
@@ -38,13 +42,19 @@ export function createFreestyleTerminalSession(
38
42
  let terminalCols = 100;
39
43
  let terminalRows = 28;
40
44
  let terminalQueryBuffer = "";
45
+ let browserPromptOutputTail = "";
46
+ let browserPromptOpenTimer: ReturnType<typeof setTimeout> | undefined;
41
47
  let proc: Subprocess<"pipe", "pipe", "pipe"> | undefined;
42
48
  let stdin: { write(data: Uint8Array): unknown; flush?(): unknown } | undefined;
43
49
  let complete!: (result: FreestyleTerminalSessionResult) => void;
44
50
  let fail!: (error: Error) => void;
45
51
  const sockets = new Set<ServerWebSocket<SocketData>>();
46
52
  const outputBuffer: string[] = [];
47
- const startupInput = request.remoteCommand ? ensureTrailingNewline(request.remoteCommand) : undefined;
53
+ const openedExternalTargets = new Set<string>();
54
+ const startupCommand = request.startupInput ?? request.remoteCommand;
55
+ const startupInput = startupCommand ? ensureTrailingNewline(startupCommand) : undefined;
56
+ const displayCommand = request.displayCommand ?? request.remoteCommand ?? request.command;
57
+ const canFinishWhileRunning = canFinishWhileProcessRuns(request, startupInput);
48
58
 
49
59
  const completed = new Promise<FreestyleTerminalSessionResult>((resolve, reject) => {
50
60
  complete = resolve;
@@ -116,6 +126,7 @@ export function createFreestyleTerminalSession(
116
126
  stop: () => {
117
127
  if (stopped) return;
118
128
  stopped = true;
129
+ clearTimeout(browserPromptOpenTimer);
119
130
  proc?.kill();
120
131
  server.stop(true);
121
132
  },
@@ -127,13 +138,14 @@ export function createFreestyleTerminalSession(
127
138
  broadcast({
128
139
  type: "status",
129
140
  status: "Connected",
130
- canFinish: false,
141
+ canFinish: canFinishWhileRunning,
131
142
  });
132
143
 
133
- proc = Bun.spawn(["sh", "-lc", `exec ${request.command}`], {
144
+ proc = Bun.spawn(["sh", "-lc", terminalProcessShellCommand(request.command)], {
134
145
  stdin: "pipe",
135
146
  stdout: "pipe",
136
147
  stderr: "pipe",
148
+ env: terminalProcessEnv(terminalCols, terminalRows),
137
149
  });
138
150
  stdin = proc.stdin;
139
151
 
@@ -178,6 +190,7 @@ export function createFreestyleTerminalSession(
178
190
  function handleProcessOutput(data: string): void {
179
191
  appendOutput(data);
180
192
  respondToTerminalQueries(data);
193
+ openBrowserUrlsFromOutput(data);
181
194
  }
182
195
 
183
196
  function appendOutput(data: string): void {
@@ -187,19 +200,20 @@ export function createFreestyleTerminalSession(
187
200
  }
188
201
 
189
202
  function writeInput(data: string): void {
190
- if (isCursorPositionReport(data)) return;
203
+ const input = sanitizeBrowserTerminalInput(data);
204
+ if (!input) return;
191
205
 
192
- if (startupInput && data === startupInput) {
206
+ if (startupInput && input === startupInput) {
193
207
  if (remoteCommandStarted) return;
194
208
  remoteCommandStarted = true;
195
209
  broadcast({
196
210
  type: "status",
197
- status: `Running ${request.remoteCommand}`,
211
+ status: `Running ${displayCommand}`,
198
212
  canFinish: true,
199
213
  });
200
214
  }
201
215
 
202
- writeProcessInput(data);
216
+ writeProcessInput(input);
203
217
  }
204
218
 
205
219
  function writeProcessInput(data: string): void {
@@ -228,6 +242,26 @@ export function createFreestyleTerminalSession(
228
242
  }
229
243
  }
230
244
 
245
+ function openBrowserUrlsFromOutput(data: string): void {
246
+ if (!request.openExternalTarget) return;
247
+ browserPromptOutputTail = (browserPromptOutputTail + data).slice(-12_000);
248
+ clearTimeout(browserPromptOpenTimer);
249
+ browserPromptOpenTimer = setTimeout(openBufferedBrowserPromptUrls, 600);
250
+ }
251
+
252
+ function openBufferedBrowserPromptUrls(): void {
253
+ browserPromptOpenTimer = undefined;
254
+ const openExternalTarget = request.openExternalTarget;
255
+ if (!openExternalTarget) return;
256
+ for (const url of browserPromptUrlsInText(browserPromptOutputTail)) {
257
+ if (openedExternalTargets.has(url)) continue;
258
+ openedExternalTargets.add(url);
259
+ Promise.resolve(openExternalTarget(url)).catch(() => {
260
+ // The URL remains visible/clickable in the terminal if the host cannot open it.
261
+ });
262
+ }
263
+ }
264
+
231
265
  function requestFinish(): void {
232
266
  if (settled) return;
233
267
  settled = true;
@@ -250,10 +284,10 @@ export function createFreestyleTerminalSession(
250
284
  return;
251
285
  }
252
286
  if (remoteCommandStarted) {
253
- send(ws, { type: "status", status: `Running ${request.remoteCommand}`, canFinish: true });
287
+ send(ws, { type: "status", status: `Running ${displayCommand}`, canFinish: true });
254
288
  return;
255
289
  }
256
- send(ws, { type: "status", status: proc ? "Connected" : "Starting", canFinish: !request.remoteCommand });
290
+ send(ws, { type: "status", status: proc ? "Connected" : "Starting", canFinish: canFinishWhileRunning });
257
291
  }
258
292
 
259
293
  function broadcast(message: ServerMessage): void {
@@ -261,6 +295,192 @@ export function createFreestyleTerminalSession(
261
295
  }
262
296
  }
263
297
 
298
+ function browserPromptUrlsInText(text: string): string[] {
299
+ const cleaned = stripAnsi(text).replace(/\r(?!\n)/g, "\n");
300
+ const urls: string[] = [];
301
+ const matcher = /https?:\/\/[^\s<>"'\\]+/ig;
302
+ let match: RegExpExecArray | null;
303
+ while ((match = matcher.exec(cleaned)) !== null) {
304
+ const url = normalizeHttpUrl(match[0]);
305
+ if (!url) continue;
306
+ const context = cleaned.slice(Math.max(0, match.index - 260), match.index);
307
+ const trailing = cleaned.slice(match.index + match[0].length, match.index + match[0].length + 260);
308
+ if (looksLikeBrowserAuthPrompt(context) && looksLikeCompleteBrowserPrompt(trailing)) urls.push(url);
309
+ }
310
+ return urls;
311
+ }
312
+
313
+ function looksLikeBrowserAuthPrompt(context: string): boolean {
314
+ const lower = context.toLowerCase();
315
+ return Boolean(
316
+ lower.includes("browser") ||
317
+ lower.includes("sign in") ||
318
+ lower.includes("login") ||
319
+ lower.includes("oauth") ||
320
+ lower.includes("authorize") ||
321
+ lower.includes("visit:"),
322
+ );
323
+ }
324
+
325
+ function looksLikeCompleteBrowserPrompt(trailing: string): boolean {
326
+ const lower = trailing.toLowerCase();
327
+ return Boolean(
328
+ lower.includes("paste code here") ||
329
+ lower.includes("in your browser"),
330
+ );
331
+ }
332
+
333
+ function normalizeHttpUrl(value: string): string | undefined {
334
+ let url = value.trim();
335
+ while (/[),.;:!?\]}]+$/.test(url)) url = url.slice(0, -1);
336
+ try {
337
+ const parsed = new URL(url);
338
+ if (parsed.protocol === "http:" || parsed.protocol === "https:") return parsed.href;
339
+ } catch {
340
+ return undefined;
341
+ }
342
+ return undefined;
343
+ }
344
+
345
+ function stripAnsi(text: string): string {
346
+ return text.replace(/[\u001b\u009b][[\]()#;?]*(?:(?:(?:[a-zA-Z\d]*(?:;[a-zA-Z\d]*)*)?\u0007)|(?:(?:\d{1,4}(?:;\d{0,4})*)?[\dA-PR-TZcf-nq-uy=><~]))/g, "");
347
+ }
348
+
349
+ function terminalProcessShellCommand(command: string): string {
350
+ return [
351
+ "if command -v python3 >/dev/null 2>&1; then",
352
+ ` exec python3 -c ${shellQuote(ptyBridgePythonScript)} ${shellQuote(command)}`,
353
+ "fi",
354
+ `exec ${command}`,
355
+ ].join("\n");
356
+ }
357
+
358
+ function terminalProcessEnv(cols: number, rows: number): Record<string, string> {
359
+ const env: Record<string, string> = {};
360
+ for (const [key, value] of Object.entries(process.env)) {
361
+ if (typeof value === "string") env[key] = value;
362
+ }
363
+ env.TERM = env.TERM || "xterm-256color";
364
+ env.COLORTERM = env.COLORTERM || "truecolor";
365
+ env.COLUMNS = String(cols);
366
+ env.LINES = String(rows);
367
+ return env;
368
+ }
369
+
370
+ const ptyBridgePythonScript = String.raw`
371
+ import errno
372
+ import fcntl
373
+ import os
374
+ import pty
375
+ import select
376
+ import signal
377
+ import struct
378
+ import sys
379
+ import termios
380
+
381
+ command = sys.argv[1]
382
+ rows = int(os.environ.get("LINES") or "28")
383
+ cols = int(os.environ.get("COLUMNS") or "100")
384
+
385
+ pid, fd = pty.fork()
386
+ if pid == 0:
387
+ os.environ.setdefault("TERM", "xterm-256color")
388
+ os.environ.setdefault("COLORTERM", "truecolor")
389
+ os.execlp("sh", "sh", "-lc", "exec " + command)
390
+
391
+ def forward_signal(signum, _frame):
392
+ try:
393
+ os.kill(pid, signum)
394
+ finally:
395
+ sys.exit(128 + signum)
396
+
397
+ signal.signal(signal.SIGTERM, forward_signal)
398
+ signal.signal(signal.SIGINT, forward_signal)
399
+
400
+ try:
401
+ fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0))
402
+ except Exception:
403
+ pass
404
+
405
+ for target in (fd, sys.stdin.fileno()):
406
+ try:
407
+ os.set_blocking(target, False)
408
+ except Exception:
409
+ pass
410
+
411
+ stdout_fd = sys.stdout.fileno()
412
+ stdin_open = True
413
+ child_exited = False
414
+ exit_code = 0
415
+
416
+ while True:
417
+ reads = [fd]
418
+ if stdin_open:
419
+ reads.append(sys.stdin.fileno())
420
+ try:
421
+ ready, _, _ = select.select(reads, [], [], 0.1)
422
+ except InterruptedError:
423
+ continue
424
+
425
+ if fd in ready:
426
+ try:
427
+ data = os.read(fd, 65536)
428
+ except OSError as exc:
429
+ if exc.errno not in (errno.EIO, errno.EBADF):
430
+ raise
431
+ data = b""
432
+ if data:
433
+ os.write(stdout_fd, data)
434
+ else:
435
+ child_exited = True
436
+
437
+ if stdin_open and sys.stdin.fileno() in ready:
438
+ try:
439
+ data = os.read(sys.stdin.fileno(), 65536)
440
+ except BlockingIOError:
441
+ data = None
442
+ if data:
443
+ os.write(fd, data)
444
+ elif data == b"":
445
+ stdin_open = False
446
+
447
+ try:
448
+ done_pid, status = os.waitpid(pid, os.WNOHANG)
449
+ except ChildProcessError:
450
+ done_pid = pid
451
+ status = 0
452
+
453
+ if done_pid == pid:
454
+ child_exited = True
455
+ if os.WIFEXITED(status):
456
+ exit_code = os.WEXITSTATUS(status)
457
+ elif os.WIFSIGNALED(status):
458
+ exit_code = 128 + os.WTERMSIG(status)
459
+ else:
460
+ exit_code = 1
461
+
462
+ if child_exited:
463
+ while True:
464
+ try:
465
+ data = os.read(fd, 65536)
466
+ except OSError:
467
+ break
468
+ if not data:
469
+ break
470
+ os.write(stdout_fd, data)
471
+ break
472
+
473
+ try:
474
+ os.close(fd)
475
+ except Exception:
476
+ pass
477
+ sys.exit(exit_code)
478
+ `;
479
+
480
+ function shellQuote(value: string): string {
481
+ return `'${value.replaceAll("'", `'\\''`)}'`;
482
+ }
483
+
264
484
  function parseClientMessage(raw: string | Buffer): ClientMessage | undefined {
265
485
  if (typeof raw !== "string") return undefined;
266
486
  try {
@@ -286,6 +506,30 @@ function isCursorPositionReport(data: string): boolean {
286
506
  return /^\x1b\[\??\d+;\d+R$/.test(data);
287
507
  }
288
508
 
509
+ function isDeviceAttributesReport(data: string): boolean {
510
+ return /^\x1b\[(?:[>?]\d+)?(?:;\d+)*c$/.test(data);
511
+ }
512
+
513
+ function isDeviceStatusReport(data: string): boolean {
514
+ return /^\x1b\[\??\d+n$/.test(data);
515
+ }
516
+
517
+ function isStringControlResponse(data: string): boolean {
518
+ return /^(?:\x1b[\]\^_P]|[\x90\x9d\x9e\x9f])/.test(data);
519
+ }
520
+
521
+ function sanitizeBrowserTerminalInput(data: string): string {
522
+ if (
523
+ isCursorPositionReport(data) ||
524
+ isDeviceAttributesReport(data) ||
525
+ isDeviceStatusReport(data) ||
526
+ isStringControlResponse(data)
527
+ ) {
528
+ return "";
529
+ }
530
+ return data;
531
+ }
532
+
289
533
  function send(ws: ServerWebSocket<SocketData>, message: ServerMessage): void {
290
534
  ws.send(JSON.stringify(message));
291
535
  }
@@ -299,11 +543,11 @@ function htmlResponse(body: string): Response {
299
543
  headers: {
300
544
  "content-type": "text/html; charset=utf-8",
301
545
  "content-security-policy": [
302
- "default-src 'none'",
303
- "script-src 'unsafe-inline' 'unsafe-eval' 'wasm-unsafe-eval' https://esm.sh",
304
- "style-src 'unsafe-inline'",
305
- "connect-src 'self' ws: wss: https://esm.sh",
306
- "form-action 'self'",
546
+ "default-src 'none'",
547
+ "script-src 'unsafe-inline' 'unsafe-eval' 'wasm-unsafe-eval' https://esm.sh",
548
+ "style-src 'unsafe-inline' https://esm.sh",
549
+ "connect-src 'self' ws: wss: https://esm.sh",
550
+ "form-action 'self'",
307
551
  ].join("; "),
308
552
  },
309
553
  });
@@ -314,423 +558,565 @@ function renderInteractionPage(
314
558
  options: { completed?: boolean; startupInput?: string } = {},
315
559
  ): string {
316
560
  const completed = options.completed ?? false;
317
- const escapedTitle = escapeHtml(completed ? "Interactive task completed" : request.title);
318
- const escapedNode = escapeHtml(request.nodePath ?? "provider");
561
+ const command = request.displayCommand ?? request.remoteCommand ?? request.command;
562
+ const node = request.nodePath ?? "provider";
563
+ const instructions = request.instructions ?? "";
564
+
565
+ const escapedDocTitle = escapeHtml(completed ? "Interactive task completed" : request.title);
319
566
  const escapedLabel = escapeHtml(request.title);
320
- const escapedInstructions = request.instructions ? escapeHtml(request.instructions) : "";
321
- const escapedCommand = escapeHtml(request.remoteCommand ?? request.command);
567
+ const escapedCommand = escapeHtml(command);
568
+ const escapedInstructions = instructions ? escapeHtml(instructions) : "";
569
+
570
+ const titleLit = javaScriptLiteral(request.title);
571
+ const instructionsLit = javaScriptLiteral(instructions);
572
+ const nodeLit = javaScriptLiteral(node);
322
573
  const startupInputLiteral = javaScriptLiteral(options.startupInput ?? null);
574
+ const canFinishWhileRunningLiteral = javaScriptLiteral(canFinishWhileProcessRuns(request, options.startupInput));
575
+ const initialCompletedLiteral = completed ? "true" : "false";
323
576
 
324
577
  return `<!doctype html>
325
578
  <html lang="en">
326
579
  <head>
327
580
  <meta charset="utf-8">
328
581
  <meta name="viewport" content="width=device-width, initial-scale=1">
329
- <title>${escapedTitle}</title>
582
+ <title>${escapedDocTitle}</title>
583
+ <link rel="stylesheet" href="https://esm.sh/@xterm/xterm@6.0.0/css/xterm.css">
330
584
  <style>
331
585
  :root {
332
- color-scheme: dark;
333
- font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
334
- background: #0a0a0a;
335
- color: #f5f5f5;
586
+ color-scheme: light;
587
+ --bg: #efece5;
588
+ --surface: #ffffff;
589
+ --fg: #0a0a0a;
590
+ --muted: #5a5a5a;
591
+ --dim: #8e8a80;
592
+ --border: #d8d2c5;
593
+ --border-strong: #b8b0a0;
594
+ --accent: #2d4df5;
595
+ --accent-soft: #e8ecff;
596
+ --ok: #0f9d58;
597
+ --err: #d93025;
598
+ --term-bg: #faf8f2;
599
+ --term-fg: #1a1a1a;
600
+ --mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
601
+ --sans: "Inter", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
602
+ font-family: var(--sans);
603
+ color: var(--fg);
604
+ background: var(--bg);
336
605
  }
606
+ * { box-sizing: border-box; }
337
607
  body {
338
608
  margin: 0;
339
- height: 100vh;
340
- padding: 24px;
341
- display: grid;
342
- place-items: center;
343
- box-sizing: border-box;
609
+ min-height: 100vh;
344
610
  overflow: hidden;
345
- background:
346
- radial-gradient(circle at 18% 0%, rgba(82, 82, 91, 0.16), transparent 28%),
347
- #0a0a0a;
611
+ background: var(--bg);
612
+ -webkit-font-smoothing: antialiased;
613
+ -moz-osx-font-smoothing: grayscale;
348
614
  }
349
- .terminal-window {
350
- width: min(1120px, 100%);
351
- height: min(760px, calc(100vh - 48px));
352
- min-height: 420px;
615
+ #app {
353
616
  display: grid;
354
- grid-template-rows: auto auto minmax(0, 1fr) auto;
355
- overflow: hidden;
356
- border: 1px solid #2b2b2f;
357
- border-radius: 8px;
358
- background: #0b0f14;
359
- box-shadow: 0 24px 70px rgba(0, 0, 0, 0.42);
617
+ grid-template-rows: auto minmax(0, 1fr);
618
+ height: 100vh;
360
619
  }
361
- .titlebar {
362
- min-height: 50px;
363
- display: flex;
364
- align-items: center;
365
- gap: 14px;
366
- padding: 11px 14px;
367
- border-bottom: 1px solid #27272a;
368
- background: linear-gradient(#1c1c20, #17171a);
369
- box-sizing: border-box;
620
+ .noscript-fallback {
621
+ padding: 32px;
622
+ color: var(--muted);
623
+ font-size: 14px;
624
+ line-height: 1.6;
625
+ max-width: 640px;
626
+ margin: 0 auto;
370
627
  }
371
- .lights {
628
+ .app-header {
372
629
  display: flex;
373
- gap: 7px;
374
- flex: 0 0 auto;
630
+ align-items: center;
631
+ padding: 18px 24px 14px;
375
632
  }
376
- .light {
377
- width: 11px;
378
- height: 11px;
379
- border-radius: 999px;
380
- background: #3f3f46;
381
- box-shadow: inset 0 0 0 1px rgba(255,255,255,0.08);
633
+ .brand {
634
+ display: inline-flex;
635
+ align-items: center;
636
+ gap: 10px;
637
+ color: var(--fg);
382
638
  }
383
- .light.red {
384
- background: #ff5f57;
639
+ .brand-mark { width: 22px; height: 22px; color: var(--fg); flex: 0 0 auto; }
640
+ .brand-mark svg { width: 100%; height: 100%; display: block; }
641
+ .brand-wordmark {
642
+ font-family: var(--mono);
643
+ font-size: 15px;
644
+ font-weight: 500;
645
+ letter-spacing: -0.01em;
646
+ color: var(--fg);
385
647
  }
386
- .light.yellow {
387
- background: #febc2e;
648
+ .brand-node {
649
+ margin-left: 14px;
650
+ padding-left: 14px;
651
+ border-left: 1px solid var(--border);
652
+ color: var(--muted);
653
+ font-family: var(--mono);
654
+ font-size: 13px;
388
655
  }
389
- .light.green {
390
- background: #28c840;
656
+ .workspace {
657
+ display: grid;
658
+ grid-template-columns: minmax(360px, 480px) minmax(0, 1fr);
659
+ gap: 22px;
660
+ padding: 0 24px 24px;
661
+ min-height: 0;
662
+ height: 100%;
391
663
  }
392
- .title-copy {
664
+ .instructions-pane {
665
+ display: flex;
666
+ flex-direction: column;
667
+ gap: 20px;
393
668
  min-width: 0;
394
- flex: 1;
669
+ padding: 18px 22px 22px;
670
+ overflow: auto;
671
+ user-select: text;
395
672
  }
396
- .meta {
397
- margin: 0 0 3px;
398
- color: #a1a1aa;
399
- font-size: 12px;
673
+ .eyebrow {
674
+ margin: 0;
675
+ align-self: flex-start;
676
+ display: inline-flex;
677
+ align-items: center;
678
+ padding: 6px 12px;
679
+ border: 1.5px solid var(--accent);
680
+ border-radius: 8px;
681
+ color: var(--accent);
682
+ font-size: 11px;
683
+ font-weight: 600;
684
+ letter-spacing: 0.12em;
685
+ text-transform: uppercase;
686
+ }
687
+ .task-title {
688
+ margin: 0;
689
+ font-size: 40px;
690
+ font-weight: 800;
691
+ letter-spacing: -0.035em;
692
+ line-height: 1.02;
693
+ color: var(--fg);
400
694
  }
401
- h1 {
695
+ .instruction-text {
402
696
  margin: 0;
697
+ white-space: pre-wrap;
698
+ color: #2a2a2a;
699
+ font-size: 15px;
700
+ line-height: 1.55;
701
+ }
702
+ .instruction-steps {
703
+ margin: 0;
704
+ padding: 0;
705
+ list-style: none;
706
+ counter-reset: step;
707
+ display: flex;
708
+ flex-direction: column;
709
+ gap: 10px;
710
+ }
711
+ .instruction-steps li {
712
+ counter-increment: step;
713
+ position: relative;
714
+ padding: 2px 0 2px 34px;
715
+ color: #2a2a2a;
716
+ font-size: 15px;
717
+ line-height: 1.5;
718
+ }
719
+ .instruction-steps li::before {
720
+ content: counter(step);
721
+ position: absolute;
722
+ left: 0;
723
+ top: 1px;
724
+ width: 22px;
725
+ height: 22px;
726
+ display: grid;
727
+ place-items: center;
728
+ border-radius: 999px;
729
+ border: 1.5px solid var(--accent);
730
+ color: var(--accent);
731
+ font-family: var(--mono);
732
+ font-size: 11px;
733
+ font-weight: 600;
734
+ line-height: 1;
735
+ }
736
+ .instructions-cta {
737
+ margin-top: auto;
738
+ padding-top: 12px;
739
+ display: flex;
740
+ flex-direction: column;
741
+ gap: 12px;
742
+ }
743
+ .primary-button {
744
+ display: inline-flex;
745
+ align-items: center;
746
+ justify-content: center;
747
+ gap: 10px;
748
+ width: 100%;
749
+ border: 0;
750
+ border-radius: 10px;
751
+ padding: 14px 18px;
752
+ font: inherit;
403
753
  font-size: 14px;
404
- line-height: 1.25;
405
754
  font-weight: 600;
406
- letter-spacing: 0;
407
- white-space: nowrap;
408
- overflow: hidden;
409
- text-overflow: ellipsis;
755
+ letter-spacing: -0.005em;
756
+ cursor: pointer;
757
+ color: #ffffff;
758
+ background: var(--fg);
759
+ transition: transform 0.12s ease, background 0.12s ease, opacity 0.12s ease;
410
760
  }
411
- .instructions {
412
- margin: 4px 0 0;
413
- white-space: pre-wrap;
414
- color: #a1a1aa;
415
- line-height: 1.35;
416
- font-size: 12px;
761
+ .primary-button:hover:not(:disabled) {
762
+ transform: translateY(-1px);
763
+ background: #1f1f1f;
764
+ }
765
+ .primary-button:active:not(:disabled) {
766
+ transform: translateY(0);
767
+ }
768
+ .primary-button:disabled {
769
+ cursor: not-allowed;
770
+ color: var(--dim);
771
+ background: var(--border);
417
772
  }
418
- .command {
773
+ .primary-button .check { width: 16px; height: 16px; display: inline-grid; place-items: center; }
774
+ .primary-button .check svg {
775
+ width: 16px;
776
+ height: 16px;
777
+ fill: none;
778
+ stroke: currentColor;
779
+ stroke-width: 2.4;
780
+ stroke-linecap: round;
781
+ stroke-linejoin: round;
782
+ }
783
+ .cta-hint {
419
784
  margin: 0;
420
- padding: 8px 12px;
421
- border-bottom: 1px solid #1f2937;
422
- background: #0f1720;
423
- color: #7dd3fc;
424
- font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
785
+ color: var(--muted);
786
+ font-size: 12.5px;
787
+ line-height: 1.5;
788
+ text-align: center;
789
+ }
790
+ .right-pane {
791
+ position: relative;
792
+ min-width: 0;
793
+ min-height: 0;
794
+ }
795
+ .terminal-window {
796
+ position: absolute;
797
+ inset: 0;
798
+ display: grid;
799
+ grid-template-rows: auto minmax(0, 1fr);
800
+ overflow: hidden;
801
+ border: 1px solid var(--border);
802
+ border-radius: 10px;
803
+ background: var(--term-bg);
804
+ }
805
+ .term-titlebar {
806
+ display: flex;
807
+ align-items: center;
808
+ gap: 10px;
809
+ padding: 10px 14px;
810
+ border-bottom: 1px solid var(--border);
811
+ background: #f2efe7;
812
+ }
813
+ .term-titlebar-icon {
814
+ width: 12px;
815
+ height: 12px;
816
+ color: var(--muted);
817
+ flex: 0 0 auto;
818
+ }
819
+ .term-titlebar-icon svg { width: 100%; height: 100%; display: block; }
820
+ .term-titlebar-label {
821
+ color: var(--muted);
822
+ font-family: var(--mono);
425
823
  font-size: 12px;
426
- overflow-wrap: anywhere;
427
824
  }
428
825
  .terminal-shell {
826
+ position: relative;
429
827
  min-height: 0;
430
828
  height: 100%;
431
- position: relative;
829
+ background: var(--term-bg);
432
830
  overflow: hidden;
433
- background: #0b0f14;
434
831
  user-select: text;
832
+ outline: none;
435
833
  }
436
- #terminal {
834
+ .term-host {
437
835
  position: absolute;
438
836
  inset: 0;
439
- border-radius: 0;
440
- box-shadow: none;
837
+ padding: 14px 16px;
838
+ box-sizing: border-box;
441
839
  user-select: text;
442
- --term-bg: #0b0f14;
443
- --term-fg: #e5e7eb;
444
- --term-cursor: #f8fafc;
445
- --term-font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
840
+ --term-bg: #faf8f2;
841
+ --term-fg: #1a1a1a;
842
+ --term-cursor: #2d4df5;
843
+ --term-font-family: var(--mono);
446
844
  --term-font-size: 13px;
447
845
  --term-row-height: 17px;
448
- --term-color-0: #1f2937;
449
- --term-color-1: #ef4444;
450
- --term-color-2: #22c55e;
451
- --term-color-3: #eab308;
452
- --term-color-4: #38bdf8;
453
- --term-color-5: #a78bfa;
454
- --term-color-6: #2dd4bf;
455
- --term-color-7: #e5e7eb;
456
- --term-color-8: #6b7280;
457
- --term-color-9: #f87171;
458
- --term-color-10: #4ade80;
459
- --term-color-11: #facc15;
460
- --term-color-12: #7dd3fc;
461
- --term-color-13: #c4b5fd;
462
- --term-color-14: #5eead4;
463
- --term-color-15: #ffffff;
464
- }
465
- #terminal:not(.ready) {
466
- visibility: hidden;
467
- }
468
- #fallback {
846
+ --term-color-0: #0a0a0a;
847
+ --term-color-1: #c93250;
848
+ --term-color-2: #1f8b4c;
849
+ --term-color-3: #a17500;
850
+ --term-color-4: #2d4df5;
851
+ --term-color-5: #8e3eff;
852
+ --term-color-6: #0a7783;
853
+ --term-color-7: #5a5a5a;
854
+ --term-color-8: #6a6a6a;
855
+ --term-color-9: #b81e3a;
856
+ --term-color-10: #176a3a;
857
+ --term-color-11: #7a5800;
858
+ --term-color-12: #1a3ad9;
859
+ --term-color-13: #7128df;
860
+ --term-color-14: #06606a;
861
+ --term-color-15: #0a0a0a;
862
+ }
863
+ .term-host .xterm {
864
+ width: 100%;
865
+ height: 100%;
866
+ padding: 0;
867
+ }
868
+ .term-host .xterm-viewport {
869
+ background: transparent !important;
870
+ }
871
+ .term-host .xterm-screen {
872
+ user-select: text;
873
+ }
874
+ .term-host:not(.ready) { visibility: hidden; }
875
+ .term-host.link-hover,
876
+ .term-fallback.link-hover {
877
+ cursor: pointer;
878
+ }
879
+ .term-fallback {
469
880
  position: absolute;
470
881
  inset: 0;
471
882
  z-index: 1;
472
- box-sizing: border-box;
473
883
  margin: 0;
474
- padding: 14px;
884
+ padding: 16px;
475
885
  overflow: auto;
476
886
  white-space: pre-wrap;
477
887
  overflow-wrap: anywhere;
478
- background: #0b0f14;
479
- color: #e5e7eb;
480
- user-select: text;
481
- font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
482
- font-size: 13px;
483
- line-height: 1.35;
484
- }
485
- #fallback.hidden {
486
- display: none;
487
- }
488
- .wterm {
489
- position: relative;
490
888
  background: var(--term-bg);
491
- color: var(--term-fg);
492
- font-family: var(--term-font-family);
493
- font-size: var(--term-font-size);
494
- line-height: 1.2;
495
- padding: 12px;
496
- outline: none;
497
- overflow: auto;
498
- user-select: text;
499
- }
500
- .term-grid {
501
- display: block;
502
- white-space: pre;
503
- contain: layout paint style;
504
- user-select: text;
505
- }
506
- .term-row {
507
- display: block;
508
- height: var(--term-row-height);
509
- line-height: var(--term-row-height);
889
+ color: var(--fg);
510
890
  user-select: text;
891
+ font-family: var(--mono);
892
+ font-size: 13px;
893
+ line-height: 1.4;
511
894
  }
512
- .term-row > span {
513
- display: inline-block;
514
- height: var(--term-row-height);
515
- vertical-align: top;
516
- user-select: text;
895
+ .term-fallback.hidden { display: none; }
896
+ .term-input-proxy {
897
+ position: absolute;
898
+ left: 0;
899
+ top: 0;
900
+ width: 1px;
901
+ height: 1px;
902
+ padding: 0;
903
+ border: 0;
904
+ opacity: 0;
905
+ pointer-events: none;
906
+ resize: none;
517
907
  }
518
- .term-block {
519
- width: 1ch;
520
- overflow: hidden;
908
+ .success-pane {
909
+ position: absolute;
910
+ inset: 0;
911
+ display: grid;
912
+ place-items: center;
913
+ padding: 24px;
914
+ animation: fadeUp 0.4s cubic-bezier(0.22, 1, 0.36, 1) both;
521
915
  }
522
- .term-cursor {
523
- outline: 1px solid var(--term-cursor);
524
- outline-offset: -1px;
916
+ @keyframes fadeUp {
917
+ from { opacity: 0; transform: translateY(8px); }
918
+ to { opacity: 1; transform: translateY(0); }
525
919
  }
526
- .wterm.focused .term-cursor {
527
- background: var(--term-cursor);
528
- color: var(--term-bg);
529
- outline: none;
920
+ .success-card {
921
+ width: min(420px, 100%);
922
+ padding: 32px 28px 28px;
923
+ border-radius: 12px;
924
+ border: 1px solid var(--border);
925
+ background: var(--surface);
926
+ text-align: center;
530
927
  }
531
- footer {
532
- display: flex;
533
- align-items: center;
534
- gap: 14px;
535
- padding: 11px 14px;
536
- border-top: 1px solid #27272a;
537
- background: #17171a;
928
+ .success-icon {
929
+ width: 52px;
930
+ height: 52px;
931
+ margin: 0 auto 18px;
932
+ display: grid;
933
+ place-items: center;
934
+ border-radius: 999px;
935
+ border: 2px solid var(--accent);
936
+ color: var(--accent);
538
937
  }
539
- #status {
540
- flex: 1;
541
- min-width: 0;
542
- color: #cbd5e1;
543
- font-size: 12px;
938
+ .success-icon svg {
939
+ width: 24px;
940
+ height: 24px;
941
+ fill: none;
942
+ stroke: currentColor;
943
+ stroke-width: 2.6;
944
+ stroke-linecap: round;
945
+ stroke-linejoin: round;
544
946
  }
545
- button {
546
- border: 0;
547
- border-radius: 6px;
548
- background: #f5f5f5;
549
- color: #111111;
550
- min-width: 82px;
551
- cursor: pointer;
552
- font: inherit;
553
- font-size: 12px;
554
- font-weight: 600;
555
- padding: 8px 12px;
947
+ .success-title {
948
+ margin: 0 0 8px;
949
+ font-size: 26px;
950
+ font-weight: 800;
951
+ letter-spacing: -0.025em;
952
+ color: var(--fg);
556
953
  }
557
- button:hover:not(:disabled) {
558
- background: #ffffff;
954
+ .success-message {
955
+ margin: 0;
956
+ color: var(--muted);
957
+ font-size: 14px;
958
+ line-height: 1.55;
559
959
  }
560
- button:disabled {
561
- cursor: not-allowed;
562
- opacity: 0.45;
960
+ @media (max-width: 880px) {
961
+ body { overflow: auto; }
962
+ #app { height: auto; min-height: 100vh; }
963
+ .workspace { grid-template-columns: 1fr; padding: 0 16px 16px; gap: 16px; }
964
+ .right-pane { height: min(640px, 70vh); }
965
+ .task-title { font-size: 32px; }
563
966
  }
564
- @media (max-width: 720px) {
565
- body {
566
- padding: 0;
567
- }
568
- .terminal-window {
569
- height: 100vh;
570
- min-height: 100vh;
571
- border: 0;
572
- border-radius: 0;
573
- }
574
- .instructions {
575
- display: none;
576
- }
967
+ @media (max-width: 540px) {
968
+ .app-header { padding: 14px 16px 10px; }
969
+ .brand-node { display: none; }
970
+ .task-title { font-size: 28px; }
971
+ .instructions-pane { padding: 12px 16px 16px; }
577
972
  }
578
973
  </style>
579
974
  </head>
580
975
  <body>
581
- <section class="terminal-window">
582
- <header class="titlebar">
583
- <div class="lights" aria-hidden="true">
584
- <span class="light red"></span>
585
- <span class="light yellow"></span>
586
- <span class="light green"></span>
587
- </div>
588
- <div class="title-copy">
589
- <p class="meta">rigkit node ${escapedNode}</p>
976
+ <div id="app">
977
+ <noscript>
978
+ <div class="noscript-fallback">
590
979
  <h1>${escapedLabel}</h1>
591
- ${escapedInstructions ? `<p class="instructions">${escapedInstructions}</p>` : ""}
980
+ ${escapedInstructions ? `<p>${escapedInstructions}</p>` : ""}
981
+ <pre>$ ${escapedCommand}</pre>
982
+ <p>This interactive task requires JavaScript to run a terminal in your browser.</p>
592
983
  </div>
593
- </header>
594
- <p class="command">$ ${escapedCommand}</p>
595
- <main class="terminal-shell" aria-label="Interactive terminal">
596
- <pre id="fallback">Starting terminal...\n</pre>
597
- <div id="terminal"></div>
598
- </main>
599
- <footer>
600
- <span id="status">${completed ? "Done. You can close this page now." : "Starting terminal"}</span>
601
- <button id="finish" type="button" disabled>Finished</button>
602
- </footer>
603
- </section>
984
+ </noscript>
985
+ </div>
604
986
  <script type="module">
987
+ import * as React from "https://esm.sh/react@18.3.1";
988
+ import { createRoot } from "https://esm.sh/react-dom@18.3.1/client";
989
+
990
+ const h = React.createElement;
991
+ const F = React.Fragment;
992
+ const { useState, useEffect, useRef, useCallback, useMemo } = React;
993
+
994
+ const TASK_TITLE = ${titleLit};
995
+ const TASK_INSTRUCTIONS = ${instructionsLit};
996
+ const NODE_PATH = ${nodeLit};
997
+ const startupInput = ${startupInputLiteral};
998
+ const canFinishWhileRunning = ${canFinishWhileRunningLiteral};
999
+ const INITIAL_COMPLETED = ${initialCompletedLiteral};
605
1000
  const token = new URLSearchParams(location.search).get("token") || "";
606
- const terminalUrl = new URL("/terminal", location.href);
607
- terminalUrl.protocol = location.protocol === "https:" ? "wss:" : "ws:";
608
- terminalUrl.searchParams.set("token", token);
609
- const statusEl = document.getElementById("status");
610
- const finishEl = document.getElementById("finish");
611
- const terminalEl = document.getElementById("terminal");
612
- const fallbackEl = document.getElementById("fallback");
613
- const outputBacklog = [];
614
- let socket;
1001
+
1002
+ let terminalEl = null;
615
1003
  let term;
616
1004
  let termReady = false;
617
- const startupInput = ${startupInputLiteral};
1005
+ let socket;
618
1006
  let startupSent = false;
619
1007
  let startupIdleTimer;
620
1008
  let startupMaxTimer;
1009
+ let terminalOutputTail = "";
1010
+ let pendingBrowserPromptUrl = null;
1011
+ const outputBacklog = [];
1012
+ const listeners = {
1013
+ onStatus: null,
1014
+ onOutput: null,
1015
+ onClose: null,
1016
+ };
621
1017
 
622
1018
  function sendTerminalInput(data) {
623
- if (!data || socket?.readyState !== WebSocket.OPEN) return;
1019
+ if (!data || !socket || socket.readyState !== WebSocket.OPEN) return;
624
1020
  socket.send(JSON.stringify({ type: "input", data }));
625
1021
  }
626
1022
 
627
- function setStatus(text, canFinish = false) {
628
- statusEl.textContent = text;
629
- finishEl.disabled = !canFinish;
630
- }
631
-
632
- function appendFallback(data) {
633
- fallbackEl.textContent += data;
634
- fallbackEl.scrollTop = fallbackEl.scrollHeight;
635
- }
636
-
637
1023
  function sendStartupInput() {
638
- if (!startupInput || startupSent || socket.readyState !== WebSocket.OPEN) return;
1024
+ if (!startupInput || startupSent || !socket || socket.readyState !== WebSocket.OPEN) return;
639
1025
  startupSent = true;
640
1026
  clearTimeout(startupIdleTimer);
641
1027
  clearTimeout(startupMaxTimer);
642
1028
  sendTerminalInput(startupInput);
643
1029
  }
644
1030
 
645
- function scheduleStartupInput(delay = 350) {
646
- if (!startupInput || startupSent || socket.readyState !== WebSocket.OPEN) return;
1031
+ function scheduleStartupInput(delay) {
1032
+ if (!startupInput || startupSent || !socket || socket.readyState !== WebSocket.OPEN) return;
647
1033
  clearTimeout(startupIdleTimer);
648
- startupIdleTimer = setTimeout(sendStartupInput, delay);
649
- startupMaxTimer ??= setTimeout(sendStartupInput, 1500);
1034
+ startupIdleTimer = setTimeout(sendStartupInput, delay || 350);
1035
+ startupMaxTimer = startupMaxTimer || setTimeout(sendStartupInput, 1500);
650
1036
  }
651
1037
 
652
- socket = new WebSocket(terminalUrl);
653
- socket.addEventListener("open", () => {
654
- setStatus("Connected");
655
- scheduleStartupInput(700);
656
- });
657
- socket.addEventListener("message", (event) => {
658
- const message = JSON.parse(event.data);
659
- if (message.type === "output") {
660
- outputBacklog.push(message.data);
661
- if (termReady) {
662
- term.write(message.data);
663
- } else {
664
- appendFallback(message.data);
1038
+ const URL_MATCH_PATTERN = "https?:\\\\/\\\\/[^\\\\s<>\\\"'\\\\\\\\]+";
1039
+
1040
+ function stripAnsi(text) {
1041
+ return text.replace(/[\\u001b\\u009b][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[a-zA-Z\\d]*)*)?\\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]))/g, "");
1042
+ }
1043
+
1044
+ function normalizeHttpUrlLiteral(value) {
1045
+ let url = String(value || "").trim();
1046
+ while (/[),.;:!?\\]}]+$/.test(url)) url = url.slice(0, -1);
1047
+ try {
1048
+ const parsed = new URL(url);
1049
+ if (parsed.protocol === "http:" || parsed.protocol === "https:") return parsed.href;
1050
+ } catch {
1051
+ return null;
1052
+ }
1053
+ return null;
1054
+ }
1055
+
1056
+ function normalizeHttpUrl(value) {
1057
+ const normalized = normalizeHttpUrlLiteral(value);
1058
+ return normalized ? expandKnownTerminalUrl(normalized) : null;
1059
+ }
1060
+
1061
+ function expandKnownTerminalUrl(url) {
1062
+ const cleaned = stripAnsi(terminalOutputTail).replace(/\\r(?!\\n)/g, "\\n");
1063
+ let best = url;
1064
+ for (const candidate of urlsInText(cleaned)) {
1065
+ if (candidate.url.startsWith(url) && candidate.url.length > best.length) {
1066
+ best = candidate.url;
665
1067
  }
666
- scheduleStartupInput();
667
- return;
668
1068
  }
669
- if (message.type === "status") {
670
- setStatus(message.status, Boolean(message.canFinish));
1069
+ return best;
1070
+ }
1071
+
1072
+ function urlsInText(text) {
1073
+ const urls = [];
1074
+ const matcher = new RegExp(URL_MATCH_PATTERN, "ig");
1075
+ let match;
1076
+ while ((match = matcher.exec(text)) !== null) {
1077
+ const raw = match[0];
1078
+ const url = normalizeHttpUrlLiteral(raw);
1079
+ if (url) urls.push({ url, start: match.index, end: match.index + raw.length });
671
1080
  }
672
- });
673
- socket.addEventListener("close", () => {
674
- if (finishEl.disabled) setStatus("Terminal connection closed");
675
- });
676
- finishEl.addEventListener("click", () => {
677
- if (socket?.readyState === WebSocket.OPEN) {
678
- socket.send(JSON.stringify({ type: "finish" }));
679
- } else {
680
- fetch("/complete?token=" + encodeURIComponent(token), { method: "POST" }).catch(() => {});
1081
+ return urls;
1082
+ }
1083
+
1084
+ function urlAtTextIndex(text, index) {
1085
+ for (const candidate of urlsInText(text)) {
1086
+ if (index >= candidate.start - 1 && index <= candidate.end) return candidate.url;
681
1087
  }
682
- setStatus("Finishing");
683
- finishEl.disabled = true;
684
- });
685
- document.addEventListener("keydown", (event) => {
686
- if (event.defaultPrevented || isTextEditingTarget(event.target)) return;
687
- const data = keyEventToTerminalInput(event);
688
- if (!data) return;
689
- event.preventDefault();
690
- event.stopImmediatePropagation();
691
- term?.focus();
692
- sendTerminalInput(data);
693
- }, { capture: true });
1088
+ return null;
1089
+ }
694
1090
 
695
- try {
696
- const [{ WTerm }, { GhosttyCore }] = await Promise.all([
697
- import("https://esm.sh/@wterm/dom@0.3.0?bundle"),
698
- import("https://esm.sh/@wterm/ghostty@0.3.0?bundle"),
699
- ]);
700
- const core = await GhosttyCore.load({
701
- wasmPath: "https://esm.sh/@wterm/ghostty@0.3.0/wasm/ghostty-vt.wasm",
702
- });
703
- term = new WTerm(terminalEl, {
704
- core,
705
- cols: 100,
706
- rows: 28,
707
- autoResize: true,
708
- cursorBlink: true,
709
- onData(data) {
710
- sendTerminalInput(data);
711
- },
712
- onResize(cols, rows) {
713
- if (socket.readyState === WebSocket.OPEN) {
714
- socket.send(JSON.stringify({ type: "resize", cols, rows }));
715
- }
716
- },
717
- });
718
- await term.init();
719
- for (const chunk of outputBacklog) term.write(chunk);
720
- termReady = true;
721
- terminalEl.classList.add("ready");
722
- fallbackEl.classList.add("hidden");
723
- term.focus();
724
- } catch (error) {
725
- console.error(error);
726
- appendFallback("\\nUnable to load the libghostty renderer. Output will continue here.\\n");
727
- setStatus("Renderer unavailable. Command output is shown in fallback mode.", !startupInput || startupSent);
1091
+ function findPendingBrowserPromptUrl(text) {
1092
+ const cleaned = stripAnsi(text).replace(/\\r(?!\\n)/g, "\\n");
1093
+ const currentLine = cleaned.split(/\\n/).pop() || "";
1094
+ const match = /Press\\s+Enter\\s+to\\s+open\\s+(https?:\\/\\/\\S+)\\s+in\\s+your\\s+browser/i.exec(currentLine);
1095
+ return match ? normalizeHttpUrl(match[1]) : null;
1096
+ }
1097
+
1098
+ function trackBrowserPrompt(data) {
1099
+ terminalOutputTail = (terminalOutputTail + data).slice(-8000);
1100
+ pendingBrowserPromptUrl = findPendingBrowserPromptUrl(terminalOutputTail);
1101
+ }
1102
+
1103
+ function openExternalUrl(url) {
1104
+ const normalized = normalizeHttpUrl(url);
1105
+ if (!normalized) return false;
1106
+ const opened = window.open(normalized, "_blank", "noopener,noreferrer");
1107
+ return Boolean(opened);
1108
+ }
1109
+
1110
+ function openPendingBrowserPrompt() {
1111
+ if (!pendingBrowserPromptUrl) return false;
1112
+ const url = pendingBrowserPromptUrl;
1113
+ pendingBrowserPromptUrl = null;
1114
+ return openExternalUrl(url);
728
1115
  }
729
1116
 
730
1117
  function isTextEditingTarget(target) {
731
1118
  if (!(target instanceof Element)) return false;
732
- if (terminalEl.contains(target)) return false;
733
- if (target === finishEl) return true;
1119
+ if (terminalEl && terminalEl.contains(target)) return true;
734
1120
  return Boolean(target.closest("textarea, input, select, button, [contenteditable=''], [contenteditable='true']"));
735
1121
  }
736
1122
 
@@ -797,18 +1183,481 @@ function renderInteractionPage(
797
1183
 
798
1184
  return null;
799
1185
  }
1186
+
1187
+ document.addEventListener("keydown", (event) => {
1188
+ if (event.defaultPrevented || isTextEditingTarget(event.target)) return;
1189
+ const data = keyEventToTerminalInput(event);
1190
+ if (!data) return;
1191
+ event.preventDefault();
1192
+ event.stopImmediatePropagation();
1193
+ term?.focus();
1194
+ if (data === "\\r") openPendingBrowserPrompt();
1195
+ sendTerminalInput(data);
1196
+ }, { capture: true });
1197
+
1198
+ document.addEventListener("paste", (event) => {
1199
+ if (event.defaultPrevented || isTextEditingTarget(event.target)) return;
1200
+ const text = event.clipboardData && event.clipboardData.getData("text/plain");
1201
+ if (!text) return;
1202
+ event.preventDefault();
1203
+ event.stopImmediatePropagation();
1204
+ term?.focus();
1205
+ sendTerminalInput(text);
1206
+ }, { capture: true });
1207
+
1208
+ function setupSocket() {
1209
+ const terminalUrl = new URL("/terminal", location.href);
1210
+ terminalUrl.protocol = location.protocol === "https:" ? "wss:" : "ws:";
1211
+ terminalUrl.searchParams.set("token", token);
1212
+ socket = new WebSocket(terminalUrl);
1213
+ socket.addEventListener("open", () => {
1214
+ listeners.onStatus && listeners.onStatus("Connected", canFinishWhileRunning);
1215
+ scheduleStartupInput(700);
1216
+ });
1217
+ socket.addEventListener("message", (event) => {
1218
+ const message = JSON.parse(event.data);
1219
+ if (message.type === "output") {
1220
+ trackBrowserPrompt(message.data);
1221
+ outputBacklog.push(message.data);
1222
+ if (termReady) {
1223
+ term.write(message.data);
1224
+ } else if (listeners.onOutput) {
1225
+ listeners.onOutput(message.data);
1226
+ }
1227
+ scheduleStartupInput();
1228
+ return;
1229
+ }
1230
+ if (message.type === "status") {
1231
+ listeners.onStatus && listeners.onStatus(message.status, Boolean(message.canFinish));
1232
+ }
1233
+ });
1234
+ socket.addEventListener("close", () => {
1235
+ listeners.onClose && listeners.onClose();
1236
+ });
1237
+ }
1238
+
1239
+ function classifyStatus(text, canFinish) {
1240
+ const lower = text.toLowerCase();
1241
+ if (lower.includes("done.") || lower.startsWith("task complete")) return "done";
1242
+ if (lower.includes("error") || lower.includes("unavailable") || /exited [^0]/.test(lower)) return "error";
1243
+ if (canFinish) return "ready";
1244
+ return "working";
1245
+ }
1246
+
1247
+ function parseSteps(text) {
1248
+ if (!text) return [];
1249
+ const trimmed = text.trim();
1250
+ if (!trimmed) return [];
1251
+ const lines = trimmed.split(/\\r?\\n/).map((l) => l.trim()).filter(Boolean);
1252
+ if (lines.length <= 1) return [];
1253
+ return lines.map((line) => line.replace(/^([0-9]+[.)]\\s+|[-*•]\\s+)/, ""));
1254
+ }
1255
+
1256
+ function CheckIcon() {
1257
+ return h("svg", { viewBox: "0 0 24 24", "aria-hidden": "true" },
1258
+ h("polyline", { points: "4 12 10 18 20 6", fill: "none", stroke: "currentColor", strokeWidth: "2.6", strokeLinecap: "round", strokeLinejoin: "round" })
1259
+ );
1260
+ }
1261
+
1262
+ function CloudIcon() {
1263
+ return h("svg", { viewBox: "0 0 32 32", fill: "none", stroke: "currentColor", strokeWidth: "1.6", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true" },
1264
+ h("path", { d: "M22 21H10.5a4.5 4.5 0 0 1-.45-8.97A6.5 6.5 0 0 1 22.86 13H23a4 4 0 0 1 0 8h-1" }),
1265
+ h("path", { d: "M11.5 24v3" }),
1266
+ h("path", { d: "M16 25v3" }),
1267
+ h("path", { d: "M20.5 24v3" })
1268
+ );
1269
+ }
1270
+
1271
+ function TerminalIcon() {
1272
+ return h("svg", { viewBox: "0 0 16 16", fill: "none", stroke: "currentColor", strokeWidth: "1.5", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true" },
1273
+ h("polyline", { points: "4 5 7 8 4 11" }),
1274
+ h("line", { x1: "8.5", y1: "11", x2: "12", y2: "11" })
1275
+ );
1276
+ }
1277
+
1278
+ function Header(props) {
1279
+ return h("header", { className: "app-header" },
1280
+ h("div", { className: "brand" },
1281
+ h("span", { className: "brand-mark", "aria-hidden": "true" }, h(CloudIcon, null)),
1282
+ h("span", { className: "brand-wordmark" }, "freestyle.sh"),
1283
+ h("span", { className: "brand-node" }, props.node),
1284
+ ),
1285
+ );
1286
+ }
1287
+
1288
+ function InstructionsPane(props) {
1289
+ const steps = useMemo(() => parseSteps(props.instructions), [props.instructions]);
1290
+ const showSteps = steps.length > 1;
1291
+ const buttonDisabled = !props.canFinish || props.done;
1292
+ return h("section", { className: "instructions-pane", "aria-label": "Task instructions" },
1293
+ h("p", { className: "eyebrow" }, "Interactive task"),
1294
+ h("h1", { className: "task-title" }, props.title),
1295
+ props.instructions
1296
+ ? (showSteps
1297
+ ? h("ol", { className: "instruction-steps" },
1298
+ steps.map((step, i) => h("li", { key: i }, step))
1299
+ )
1300
+ : h("p", { className: "instruction-text" }, props.instructions))
1301
+ : null,
1302
+ h("div", { className: "instructions-cta" },
1303
+ h("button", {
1304
+ type: "button",
1305
+ className: "primary-button",
1306
+ disabled: buttonDisabled,
1307
+ onClick: props.onFinish,
1308
+ },
1309
+ h("span", { className: "check" }, h(CheckIcon, null)),
1310
+ h("span", null, "Complete task"),
1311
+ ),
1312
+ h("p", { className: "cta-hint" },
1313
+ props.done
1314
+ ? "Task complete — you can close this tab."
1315
+ : (props.canFinish
1316
+ ? "When the command above has finished in the terminal, click here to continue."
1317
+ : "Run the command in the terminal — this button will activate when the task is ready to finish.")
1318
+ ),
1319
+ ),
1320
+ );
1321
+ }
1322
+
1323
+ function TerminalChrome() {
1324
+ const shellRef = useRef(null);
1325
+ const hostRef = useRef(null);
1326
+ const fallbackRef = useRef(null);
1327
+ const inputProxyRef = useRef(null);
1328
+
1329
+ useEffect(() => {
1330
+ const host = hostRef.current;
1331
+ const fallback = fallbackRef.current;
1332
+ terminalEl = host;
1333
+ if (fallback) {
1334
+ for (const chunk of outputBacklog) {
1335
+ fallback.textContent += chunk;
1336
+ }
1337
+ }
1338
+ listeners.onOutput = (data) => {
1339
+ if (!fallbackRef.current) return;
1340
+ fallbackRef.current.textContent += data;
1341
+ fallbackRef.current.scrollTop = fallbackRef.current.scrollHeight;
1342
+ };
1343
+
1344
+ const handleTerminalClick = (event) => {
1345
+ if (event.defaultPrevented || event.button !== 0) return;
1346
+ const selection = window.getSelection();
1347
+ if (selection && selection.toString()) return;
1348
+ const url = terminalUrlFromEvent(event);
1349
+ if (!url) {
1350
+ focusTerminalInput();
1351
+ return;
1352
+ }
1353
+ event.preventDefault();
1354
+ event.stopPropagation();
1355
+ openExternalUrl(url);
1356
+ };
1357
+ const handleTerminalPointerMove = (event) => {
1358
+ const target = event.currentTarget;
1359
+ if (!(target instanceof HTMLElement)) return;
1360
+ target.classList.toggle("link-hover", Boolean(terminalUrlFromEvent(event)));
1361
+ };
1362
+ const handleTerminalPointerLeave = (event) => {
1363
+ const target = event.currentTarget;
1364
+ if (target instanceof HTMLElement) target.classList.remove("link-hover");
1365
+ };
1366
+
1367
+ host?.addEventListener("click", handleTerminalClick);
1368
+ host?.addEventListener("pointermove", handleTerminalPointerMove);
1369
+ host?.addEventListener("pointerleave", handleTerminalPointerLeave);
1370
+ fallback?.addEventListener("click", handleTerminalClick);
1371
+ fallback?.addEventListener("pointermove", handleTerminalPointerMove);
1372
+ fallback?.addEventListener("pointerleave", handleTerminalPointerLeave);
1373
+
1374
+ let cancelled = false;
1375
+ let resizeObserver = null;
1376
+ let currentTerm = null;
1377
+ (async () => {
1378
+ try {
1379
+ const [{ Terminal }, { FitAddon }, { WebLinksAddon }] = await Promise.all([
1380
+ import("https://esm.sh/@xterm/xterm@6.0.0?bundle"),
1381
+ import("https://esm.sh/@xterm/addon-fit@0.11.0?bundle"),
1382
+ import("https://esm.sh/@xterm/addon-web-links@0.12.0?bundle"),
1383
+ ]);
1384
+ if (cancelled || !hostRef.current) return;
1385
+ const xterm = new Terminal({
1386
+ cols: 100,
1387
+ rows: 28,
1388
+ cursorBlink: true,
1389
+ convertEol: true,
1390
+ scrollback: 10000,
1391
+ fontFamily: 'ui-monospace, "SF Mono", SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace',
1392
+ fontSize: 13,
1393
+ lineHeight: 1.3,
1394
+ theme: {
1395
+ background: "#faf8f2",
1396
+ foreground: "#1a1a1a",
1397
+ cursor: "#2d4df5",
1398
+ selectionBackground: "#d8d2c5",
1399
+ black: "#0a0a0a",
1400
+ red: "#c93250",
1401
+ green: "#1f8b4c",
1402
+ yellow: "#a17500",
1403
+ blue: "#2d4df5",
1404
+ magenta: "#8e3eff",
1405
+ cyan: "#0a7783",
1406
+ white: "#5a5a5a",
1407
+ brightBlack: "#6a6a6a",
1408
+ brightRed: "#b81e3a",
1409
+ brightGreen: "#176a3a",
1410
+ brightYellow: "#7a5800",
1411
+ brightBlue: "#1a3ad9",
1412
+ brightMagenta: "#7128df",
1413
+ brightCyan: "#06606a",
1414
+ brightWhite: "#0a0a0a",
1415
+ },
1416
+ });
1417
+ const fitAddon = new FitAddon();
1418
+ xterm.loadAddon(fitAddon);
1419
+ xterm.loadAddon(new WebLinksAddon((event, url) => {
1420
+ event.preventDefault();
1421
+ openExternalUrl(url);
1422
+ }));
1423
+ xterm.onData((data) => {
1424
+ if (data === "\\r") openPendingBrowserPrompt();
1425
+ sendTerminalInput(data);
1426
+ });
1427
+ xterm.onResize(({ cols, rows }) => {
1428
+ if (socket && socket.readyState === WebSocket.OPEN) {
1429
+ socket.send(JSON.stringify({ type: "resize", cols, rows }));
1430
+ }
1431
+ });
1432
+ xterm.open(hostRef.current);
1433
+ const fitTerminal = () => {
1434
+ try {
1435
+ fitAddon.fit();
1436
+ if (socket && socket.readyState === WebSocket.OPEN) {
1437
+ socket.send(JSON.stringify({ type: "resize", cols: xterm.cols, rows: xterm.rows }));
1438
+ }
1439
+ } catch {
1440
+ // The fit addon can throw while the element is detached during teardown.
1441
+ }
1442
+ };
1443
+ fitTerminal();
1444
+ resizeObserver = new ResizeObserver(fitTerminal);
1445
+ resizeObserver.observe(hostRef.current);
1446
+ currentTerm = xterm;
1447
+ term = xterm;
1448
+ for (const chunk of outputBacklog) term.write(chunk);
1449
+ termReady = true;
1450
+ hostRef.current.classList.add("ready");
1451
+ fallbackRef.current && fallbackRef.current.classList.add("hidden");
1452
+ term.focus();
1453
+ focusTerminalInput();
1454
+ } catch (error) {
1455
+ console.error(error);
1456
+ if (fallbackRef.current) {
1457
+ fallbackRef.current.textContent += "\\nUnable to load the xterm renderer. Output will continue here.\\n";
1458
+ }
1459
+ }
1460
+ })();
1461
+
1462
+ return () => {
1463
+ cancelled = true;
1464
+ resizeObserver?.disconnect();
1465
+ currentTerm?.dispose();
1466
+ if (term === currentTerm) term = null;
1467
+ terminalEl = null;
1468
+ host?.removeEventListener("click", handleTerminalClick);
1469
+ host?.removeEventListener("pointermove", handleTerminalPointerMove);
1470
+ host?.removeEventListener("pointerleave", handleTerminalPointerLeave);
1471
+ fallback?.removeEventListener("click", handleTerminalClick);
1472
+ fallback?.removeEventListener("pointermove", handleTerminalPointerMove);
1473
+ fallback?.removeEventListener("pointerleave", handleTerminalPointerLeave);
1474
+ };
1475
+ }, []);
1476
+
1477
+ const focusTerminalInput = useCallback(() => {
1478
+ shellRef.current?.focus({ preventScroll: true });
1479
+ if (term?.focus) {
1480
+ term.focus();
1481
+ } else {
1482
+ inputProxyRef.current?.focus({ preventScroll: true });
1483
+ }
1484
+ }, []);
1485
+
1486
+ const sendKeyboardEventToTerminal = useCallback((event) => {
1487
+ const data = keyEventToTerminalInput(event);
1488
+ if (!data) return;
1489
+ event.preventDefault();
1490
+ event.stopPropagation();
1491
+ if (inputProxyRef.current) inputProxyRef.current.value = "";
1492
+ if (data === "\\r") openPendingBrowserPrompt();
1493
+ sendTerminalInput(data);
1494
+ }, []);
1495
+
1496
+ const sendTextInputEventToTerminal = useCallback((event) => {
1497
+ const data = event.data || "";
1498
+ if (!data) return;
1499
+ event.preventDefault();
1500
+ event.stopPropagation();
1501
+ if (inputProxyRef.current) inputProxyRef.current.value = "";
1502
+ sendTerminalInput(data);
1503
+ }, []);
1504
+
1505
+ const sendPasteEventToTerminal = useCallback((event) => {
1506
+ const text = event.clipboardData && event.clipboardData.getData("text/plain");
1507
+ if (!text) return;
1508
+ event.preventDefault();
1509
+ event.stopPropagation();
1510
+ if (inputProxyRef.current) inputProxyRef.current.value = "";
1511
+ sendTerminalInput(text);
1512
+ }, []);
1513
+
1514
+ const sendInputValueToTerminal = useCallback((event) => {
1515
+ const target = event.currentTarget;
1516
+ const value = target.value || "";
1517
+ if (!value) return;
1518
+ target.value = "";
1519
+ sendTerminalInput(value);
1520
+ }, []);
1521
+
1522
+ return h(F, null,
1523
+ h("div", { className: "term-titlebar" },
1524
+ h("span", { className: "term-titlebar-icon", "aria-hidden": "true" }, h(TerminalIcon, null)),
1525
+ h("span", { className: "term-titlebar-label" }, NODE_PATH + " · terminal"),
1526
+ ),
1527
+ h("div", {
1528
+ ref: shellRef,
1529
+ className: "terminal-shell",
1530
+ tabIndex: 0,
1531
+ onPointerDownCapture: focusTerminalInput,
1532
+ onKeyDown: sendKeyboardEventToTerminal,
1533
+ onPaste: sendPasteEventToTerminal,
1534
+ },
1535
+ h("pre", { ref: fallbackRef, className: "term-fallback" }, "Starting terminal...\\n"),
1536
+ h("div", { ref: hostRef, className: "term-host" }),
1537
+ h("textarea", {
1538
+ ref: inputProxyRef,
1539
+ className: "term-input-proxy",
1540
+ tabIndex: 0,
1541
+ autoCapitalize: "off",
1542
+ autoComplete: "off",
1543
+ autoCorrect: "off",
1544
+ spellCheck: false,
1545
+ onKeyDown: sendKeyboardEventToTerminal,
1546
+ onBeforeInput: sendTextInputEventToTerminal,
1547
+ onInput: sendInputValueToTerminal,
1548
+ onPaste: sendPasteEventToTerminal,
1549
+ }),
1550
+ ),
1551
+ );
1552
+ }
1553
+
1554
+ function SuccessPane() {
1555
+ return h("div", { className: "success-pane" },
1556
+ h("div", { className: "success-card", role: "status", "aria-live": "polite" },
1557
+ h("div", { className: "success-icon" },
1558
+ h(CheckIcon, null)
1559
+ ),
1560
+ h("h2", { className: "success-title" }, "Task complete"),
1561
+ h("p", { className: "success-message" }, "You can close this tab — Rigkit will pick up from here."),
1562
+ ),
1563
+ );
1564
+ }
1565
+
1566
+ function terminalUrlFromEvent(event) {
1567
+ const target = event.target;
1568
+ if (!(target instanceof Element)) return null;
1569
+
1570
+ const fallback = target.closest(".term-fallback");
1571
+ if (fallback) {
1572
+ return terminalUrlFromPreEvent(fallback, event);
1573
+ }
1574
+
1575
+ return null;
1576
+ }
1577
+
1578
+ function terminalUrlFromPreEvent(pre, event) {
1579
+ const text = pre.textContent || "";
1580
+ const rect = pre.getBoundingClientRect();
1581
+ const style = window.getComputedStyle(pre);
1582
+ const lineHeight = parseFloat(style.lineHeight) || 18;
1583
+ const fontSize = parseFloat(style.fontSize) || 13;
1584
+ const charWidth = fontSize * 0.62;
1585
+ const paddingTop = parseFloat(style.paddingTop) || 0;
1586
+ const paddingLeft = parseFloat(style.paddingLeft) || 0;
1587
+ const lineIndex = Math.floor((event.clientY - rect.top + pre.scrollTop - paddingTop) / lineHeight);
1588
+ const colIndex = Math.floor((event.clientX - rect.left + pre.scrollLeft - paddingLeft) / charWidth);
1589
+ const line = text.split("\\n")[lineIndex] || "";
1590
+ const exactUrl = urlAtTextIndex(line, Math.max(0, colIndex));
1591
+ if (exactUrl) return exactUrl;
1592
+ const urls = urlsInText(line);
1593
+ return urls.length === 1 ? urls[0].url : null;
1594
+ }
1595
+
1596
+ function App() {
1597
+ const [canFinish, setCanFinish] = useState(false);
1598
+ const [done, setDone] = useState(INITIAL_COMPLETED);
1599
+
1600
+ useEffect(() => {
1601
+ listeners.onStatus = (text, canFinishVal) => {
1602
+ setCanFinish(canFinishVal);
1603
+ if (classifyStatus(text, canFinishVal) === "done") setDone(true);
1604
+ };
1605
+ if (!INITIAL_COMPLETED) setupSocket();
1606
+ return () => {
1607
+ listeners.onStatus = null;
1608
+ listeners.onClose = null;
1609
+ };
1610
+ }, []);
1611
+
1612
+ const handleFinish = useCallback(() => {
1613
+ if (socket && socket.readyState === WebSocket.OPEN) {
1614
+ socket.send(JSON.stringify({ type: "finish" }));
1615
+ } else {
1616
+ fetch("/complete?token=" + encodeURIComponent(token), { method: "POST" }).catch(() => {});
1617
+ }
1618
+ setCanFinish(false);
1619
+ setDone(true);
1620
+ }, []);
1621
+
1622
+ return h(F, null,
1623
+ h(Header, { node: NODE_PATH }),
1624
+ h("main", { className: "workspace" },
1625
+ h(InstructionsPane, {
1626
+ title: TASK_TITLE,
1627
+ instructions: TASK_INSTRUCTIONS,
1628
+ canFinish: canFinish,
1629
+ done: done,
1630
+ onFinish: handleFinish,
1631
+ }),
1632
+ h("div", { className: "right-pane" },
1633
+ !done
1634
+ ? h("div", { className: "terminal-window" }, h(TerminalChrome, null))
1635
+ : h(SuccessPane, null)
1636
+ )
1637
+ )
1638
+ );
1639
+ }
1640
+
1641
+ createRoot(document.getElementById("app")).render(h(App));
800
1642
  </script>
801
1643
  </body>
802
1644
  </html>`;
803
1645
  }
804
1646
 
805
- function javaScriptLiteral(value: string | null): string {
1647
+ function javaScriptLiteral(value: string | boolean | null): string {
806
1648
  return JSON.stringify(value)
807
1649
  .replaceAll("<", "\\u003c")
808
1650
  .replaceAll(">", "\\u003e")
809
1651
  .replaceAll("&", "\\u0026")
810
- .replaceAll("\u2028", "\\u2028")
811
- .replaceAll("\u2029", "\\u2029");
1652
+ .replaceAll("", "\\u2028")
1653
+ .replaceAll("", "\\u2029");
1654
+ }
1655
+
1656
+ function canFinishWhileProcessRuns(
1657
+ request: FreestyleTerminalSessionRequest,
1658
+ startupInput: string | undefined,
1659
+ ): boolean {
1660
+ return request.canFinishWhileRunning ?? (!request.displayCommand && !startupInput);
812
1661
  }
813
1662
 
814
1663
  function escapeHtml(value: string): string {