@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.
- package/README.md +9 -3
- package/package.json +8 -5
- package/src/host-auth.test.ts +375 -0
- package/src/host-auth.ts +591 -0
- package/src/index.ts +37 -59
- package/src/provider.test.ts +188 -113
- package/src/provider.ts +113 -378
- package/src/terminal-session.test.ts +239 -7
- package/src/terminal-session.ts +1181 -332
- package/src/version.ts +1 -1
package/src/terminal-session.ts
CHANGED
|
@@ -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
|
|
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:
|
|
141
|
+
canFinish: canFinishWhileRunning,
|
|
131
142
|
});
|
|
132
143
|
|
|
133
|
-
proc = Bun.spawn(["sh", "-lc",
|
|
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
|
-
|
|
203
|
+
const input = sanitizeBrowserTerminalInput(data);
|
|
204
|
+
if (!input) return;
|
|
191
205
|
|
|
192
|
-
if (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 ${
|
|
211
|
+
status: `Running ${displayCommand}`,
|
|
198
212
|
canFinish: true,
|
|
199
213
|
});
|
|
200
214
|
}
|
|
201
215
|
|
|
202
|
-
writeProcessInput(
|
|
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 ${
|
|
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:
|
|
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
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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
|
|
318
|
-
const
|
|
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
|
|
321
|
-
const
|
|
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>${
|
|
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:
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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
|
-
|
|
347
|
-
|
|
611
|
+
background: var(--bg);
|
|
612
|
+
-webkit-font-smoothing: antialiased;
|
|
613
|
+
-moz-osx-font-smoothing: grayscale;
|
|
348
614
|
}
|
|
349
|
-
|
|
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
|
|
355
|
-
|
|
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
|
-
.
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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
|
-
.
|
|
628
|
+
.app-header {
|
|
372
629
|
display: flex;
|
|
373
|
-
|
|
374
|
-
|
|
630
|
+
align-items: center;
|
|
631
|
+
padding: 18px 24px 14px;
|
|
375
632
|
}
|
|
376
|
-
.
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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
|
-
.
|
|
384
|
-
|
|
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
|
-
.
|
|
387
|
-
|
|
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
|
-
.
|
|
390
|
-
|
|
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
|
-
.
|
|
664
|
+
.instructions-pane {
|
|
665
|
+
display: flex;
|
|
666
|
+
flex-direction: column;
|
|
667
|
+
gap: 20px;
|
|
393
668
|
min-width: 0;
|
|
394
|
-
|
|
669
|
+
padding: 18px 22px 22px;
|
|
670
|
+
overflow: auto;
|
|
671
|
+
user-select: text;
|
|
395
672
|
}
|
|
396
|
-
.
|
|
397
|
-
margin: 0
|
|
398
|
-
|
|
399
|
-
|
|
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
|
-
|
|
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
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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
|
-
.
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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
|
-
.
|
|
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
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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
|
-
|
|
829
|
+
background: var(--term-bg);
|
|
432
830
|
overflow: hidden;
|
|
433
|
-
background: #0b0f14;
|
|
434
831
|
user-select: text;
|
|
832
|
+
outline: none;
|
|
435
833
|
}
|
|
436
|
-
|
|
834
|
+
.term-host {
|
|
437
835
|
position: absolute;
|
|
438
836
|
inset: 0;
|
|
439
|
-
|
|
440
|
-
box-
|
|
837
|
+
padding: 14px 16px;
|
|
838
|
+
box-sizing: border-box;
|
|
441
839
|
user-select: text;
|
|
442
|
-
--term-bg: #
|
|
443
|
-
--term-fg: #
|
|
444
|
-
--term-cursor: #
|
|
445
|
-
--term-font-family:
|
|
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: #
|
|
449
|
-
--term-color-1: #
|
|
450
|
-
--term-color-2: #
|
|
451
|
-
--term-color-3: #
|
|
452
|
-
--term-color-4: #
|
|
453
|
-
--term-color-5: #
|
|
454
|
-
--term-color-6: #
|
|
455
|
-
--term-color-7: #
|
|
456
|
-
--term-color-8: #
|
|
457
|
-
--term-color-9: #
|
|
458
|
-
--term-color-10: #
|
|
459
|
-
--term-color-11: #
|
|
460
|
-
--term-color-12: #
|
|
461
|
-
--term-color-13: #
|
|
462
|
-
--term-color-14: #
|
|
463
|
-
--term-color-15: #
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
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:
|
|
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(--
|
|
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-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
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
|
-
.
|
|
519
|
-
|
|
520
|
-
|
|
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
|
-
|
|
523
|
-
|
|
524
|
-
|
|
916
|
+
@keyframes fadeUp {
|
|
917
|
+
from { opacity: 0; transform: translateY(8px); }
|
|
918
|
+
to { opacity: 1; transform: translateY(0); }
|
|
525
919
|
}
|
|
526
|
-
.
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
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
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
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
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
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
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
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
|
-
|
|
558
|
-
|
|
954
|
+
.success-message {
|
|
955
|
+
margin: 0;
|
|
956
|
+
color: var(--muted);
|
|
957
|
+
font-size: 14px;
|
|
958
|
+
line-height: 1.55;
|
|
559
959
|
}
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
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:
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
}
|
|
568
|
-
.
|
|
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
|
-
<
|
|
582
|
-
<
|
|
583
|
-
<div class="
|
|
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
|
|
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
|
-
</
|
|
594
|
-
|
|
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
|
-
|
|
607
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
1034
|
+
startupIdleTimer = setTimeout(sendStartupInput, delay || 350);
|
|
1035
|
+
startupMaxTimer = startupMaxTimer || setTimeout(sendStartupInput, 1500);
|
|
650
1036
|
}
|
|
651
1037
|
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
}
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
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
|
-
|
|
670
|
-
|
|
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
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
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
|
-
|
|
683
|
-
|
|
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
|
-
|
|
696
|
-
const
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
]);
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
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
|
|
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("
|
|
811
|
-
.replaceAll("
|
|
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 {
|