@rigkit/provider-freestyle 0.2.4 → 0.2.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rigkit/provider-freestyle",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -17,9 +17,9 @@
17
17
  ],
18
18
  "dependencies": {
19
19
  "zod": "^4",
20
- "@rigkit/sdk": "0.2.4",
21
- "@rigkit/engine": "0.2.4",
22
- "@rigkit/provider-cmux": "0.2.4"
20
+ "@rigkit/sdk": "0.2.6",
21
+ "@rigkit/provider-cmux": "0.2.6",
22
+ "@rigkit/engine": "0.2.6"
23
23
  },
24
24
  "peerDependencies": {
25
25
  "freestyle": "^0.1.51"
@@ -160,6 +160,23 @@ describe("Freestyle provider host adapters", () => {
160
160
  expect(html).toContain("const canFinishWhileRunning = false;");
161
161
  });
162
162
 
163
+ test("sets remote browser open fallbacks for SSH terminal commands", () => {
164
+ const command = buildInteractiveSshCommand(
165
+ {
166
+ kind: "ssh",
167
+ host: "vm-ssh.freestyle.sh",
168
+ username: "vm-stream+root",
169
+ auth: { type: "token", token: "token" },
170
+ command: "ssh vm-stream+root:token@vm-ssh.freestyle.sh",
171
+ },
172
+ "gh auth login --hostname github.com --web",
173
+ );
174
+
175
+ expect(command).toContain('export BROWSER="${BROWSER:-true}"');
176
+ expect(command).toContain('export GH_BROWSER="${GH_BROWSER:-$BROWSER}"');
177
+ expect(command).toContain("gh auth login --hostname github.com --web");
178
+ });
179
+
163
180
  test("can keep an SSH terminal open after a successful remote command", () => {
164
181
  const command = buildInteractiveSshCommand(
165
182
  {
package/src/provider.ts CHANGED
@@ -88,6 +88,7 @@ export function createFreestyleTerminalController(): WorkflowProviderController<
88
88
  canFinishWhileRunning: options.keepOpenAfterCommand,
89
89
  instructions: options.instructions,
90
90
  nodePath: context.nodePath,
91
+ openExternalTarget: (target) => context.local.open(target),
91
92
  });
92
93
  return await context.interaction.present(session);
93
94
  },
@@ -234,10 +235,18 @@ export function buildInteractiveSshCommand(
234
235
  if (command) args.push("-tt", "-q");
235
236
  if (connection.port !== undefined) args.push("-p", String(connection.port));
236
237
  args.push(destination);
237
- if (command) args.push(command);
238
+ if (command) args.push(withBrowserOpenFallback(command));
238
239
  return args.map((arg) => arg === "ssh" || arg.startsWith("-") ? arg : shellQuote(arg)).join(" ");
239
240
  }
240
241
 
242
+ function withBrowserOpenFallback(command: string): string {
243
+ return [
244
+ 'export BROWSER="${BROWSER:-true}"',
245
+ 'export GH_BROWSER="${GH_BROWSER:-$BROWSER}"',
246
+ command,
247
+ ].join("\n");
248
+ }
249
+
241
250
  function keepOpenAfterCommand(command: string): string {
242
251
  return [
243
252
  command,
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test";
2
2
  import { createFreestyleTerminalSession } from "./terminal-session.ts";
3
3
 
4
4
  describe("Freestyle terminal session", () => {
5
- test("serves a wterm page and resolves after the user finishes", async () => {
5
+ test("serves an xterm page and resolves after the user finishes", async () => {
6
6
  const session = createFreestyleTerminalSession({
7
7
  nodePath: "login",
8
8
  title: "GitHub auth",
@@ -19,8 +19,9 @@ describe("Freestyle terminal session", () => {
19
19
  expect(html).toContain("GitHub auth");
20
20
  expect(html).toContain("Authenticate GitHub inside the VM.");
21
21
  expect(html).toContain("printf interactive-ready");
22
- expect(html).toContain("@wterm/dom");
23
- expect(html).toContain("@wterm/ghostty");
22
+ expect(html).toContain("@xterm/xterm");
23
+ expect(html).toContain("@xterm/addon-fit");
24
+ expect(html).toContain("@xterm/addon-web-links");
24
25
  expect(html).toContain("terminal-window");
25
26
  expect(html).toContain("freestyle.sh");
26
27
  expect(html).toContain("Complete task");
@@ -30,6 +31,18 @@ describe("Freestyle terminal session", () => {
30
31
  expect(html).toContain("user-select: text");
31
32
  expect(html).toContain("keyEventToTerminalInput");
32
33
  expect(html).toContain("sendTerminalInput(data)");
34
+ expect(html).toContain("terminalUrlFromEvent");
35
+ expect(html).toContain("expandKnownTerminalUrl");
36
+ expect(html).toContain("openPendingBrowserPrompt");
37
+ expect(html).toContain("window.open(normalized");
38
+ expect(html).toContain("document.addEventListener(\"paste\"");
39
+ expect(html).toContain("term-input-proxy");
40
+ expect(html).toContain("onPointerDownCapture");
41
+ expect(html).toContain("onBeforeInput");
42
+ expect(html).toContain("sendTextInputEventToTerminal");
43
+ expect(html).toContain("sendPasteEventToTerminal");
44
+ expect(html).not.toContain("term-send-form");
45
+ expect(html).not.toContain("Send to terminal");
33
46
  const startupInput = readStartupInput(html);
34
47
  expect(startupInput).toBe("printf interactive-ready\n");
35
48
 
@@ -173,16 +186,195 @@ describe("Freestyle terminal session", () => {
173
186
  session.stop();
174
187
  }
175
188
  });
189
+
190
+ test("forwards browser terminal input to process stdin", async () => {
191
+ const session = createFreestyleTerminalSession({
192
+ nodePath: "prompt",
193
+ title: "Input prompt",
194
+ command: stdinEchoProbe,
195
+ });
196
+
197
+ try {
198
+ const messages: unknown[] = [];
199
+ const socketUrl = new URL(session.url.replace("/?", "/terminal?"));
200
+ socketUrl.protocol = "ws:";
201
+ const socket = new WebSocket(socketUrl);
202
+ socket.addEventListener("message", (event) => {
203
+ messages.push(JSON.parse(String(event.data)));
204
+ });
205
+
206
+ await waitForSocketOpen(socket);
207
+ socket.send(JSON.stringify({ type: "input", data: "abc123\n" }));
208
+
209
+ await waitFor(() =>
210
+ messages.some((message) =>
211
+ isMessage(message, "output") && message.data.includes("STDIN:abc123")
212
+ ),
213
+ );
214
+
215
+ socket.send(JSON.stringify({ type: "finish" }));
216
+ await expect(session.completed).resolves.toEqual({ finished: true });
217
+ socket.close();
218
+ } finally {
219
+ session.stop();
220
+ }
221
+ });
222
+
223
+ test("drops terminal-generated string control responses from browser input", async () => {
224
+ const session = createFreestyleTerminalSession({
225
+ nodePath: "prompt",
226
+ title: "Input prompt",
227
+ command: stdinHexProbe,
228
+ });
229
+
230
+ try {
231
+ const messages: unknown[] = [];
232
+ const socketUrl = new URL(session.url.replace("/?", "/terminal?"));
233
+ socketUrl.protocol = "ws:";
234
+ const socket = new WebSocket(socketUrl);
235
+ socket.addEventListener("message", (event) => {
236
+ messages.push(JSON.parse(String(event.data)));
237
+ });
238
+
239
+ await waitForSocketOpen(socket);
240
+ socket.send(JSON.stringify({ type: "input", data: "\x1b]10;rgb:ffff/ffff/ffff\x07" }));
241
+ socket.send(JSON.stringify({ type: "input", data: "y\n" }));
242
+
243
+ await waitFor(() =>
244
+ messages.some((message) =>
245
+ isMessage(message, "output") && message.data.includes("STDINHEX:790a")
246
+ ),
247
+ );
248
+
249
+ socket.send(JSON.stringify({ type: "finish" }));
250
+ await expect(session.completed).resolves.toEqual({ finished: true });
251
+ socket.close();
252
+ } finally {
253
+ session.stop();
254
+ }
255
+ });
256
+
257
+ test("runs interactive commands with a TTY stdin", async () => {
258
+ const session = createFreestyleTerminalSession({
259
+ nodePath: "prompt",
260
+ title: "TTY probe",
261
+ command: ttyProbe,
262
+ });
263
+
264
+ try {
265
+ const messages: unknown[] = [];
266
+ const socketUrl = new URL(session.url.replace("/?", "/terminal?"));
267
+ socketUrl.protocol = "ws:";
268
+ const socket = new WebSocket(socketUrl);
269
+ socket.addEventListener("message", (event) => {
270
+ messages.push(JSON.parse(String(event.data)));
271
+ });
272
+
273
+ await waitForSocketOpen(socket);
274
+
275
+ await waitFor(() =>
276
+ messages.some((message) =>
277
+ isMessage(message, "output") && message.data.includes("TTY:true")
278
+ ),
279
+ );
280
+
281
+ socket.send(JSON.stringify({ type: "finish" }));
282
+ await expect(session.completed).resolves.toEqual({ finished: true });
283
+ socket.close();
284
+ } finally {
285
+ session.stop();
286
+ }
287
+ });
288
+
289
+ test("asks the host to open complete browser auth URLs printed by the terminal", async () => {
290
+ const opened: string[] = [];
291
+ const authUrl = "https://claude.com/cai/oauth/authorize?code=true&state=abc";
292
+ const session = createFreestyleTerminalSession({
293
+ nodePath: "login",
294
+ title: "Claude auth",
295
+ command: "printf %s " + JSON.stringify(`Opening browser to sign in...\nIf the browser didn't open, visit: ${authUrl}\nPaste code here if prompted > `),
296
+ openExternalTarget: (target) => opened.push(target),
297
+ });
298
+
299
+ try {
300
+ const socketUrl = new URL(session.url.replace("/?", "/terminal?"));
301
+ socketUrl.protocol = "ws:";
302
+ const socket = new WebSocket(socketUrl);
303
+ await waitForSocketOpen(socket);
304
+
305
+ await waitFor(() => opened.includes(authUrl));
306
+ socket.send(JSON.stringify({ type: "finish" }));
307
+ await expect(session.completed).resolves.toEqual({ finished: true });
308
+ socket.close();
309
+ } finally {
310
+ session.stop();
311
+ }
312
+ });
313
+
314
+ test("does not open partial browser auth URLs while output is still streaming", async () => {
315
+ const opened: string[] = [];
316
+ const authUrl = "https://claude.com/cai/oauth/authorize?code=true&state=abc";
317
+ const session = createFreestyleTerminalSession({
318
+ nodePath: "login",
319
+ title: "Claude auth",
320
+ command: streamedAuthUrlProbe,
321
+ openExternalTarget: (target) => opened.push(target),
322
+ });
323
+
324
+ try {
325
+ const socketUrl = new URL(session.url.replace("/?", "/terminal?"));
326
+ socketUrl.protocol = "ws:";
327
+ const socket = new WebSocket(socketUrl);
328
+ await waitForSocketOpen(socket);
329
+
330
+ await new Promise((resolve) => setTimeout(resolve, 750));
331
+ expect(opened).toEqual([]);
332
+ await waitFor(() => opened.includes(authUrl), 2_500);
333
+ socket.send(JSON.stringify({ type: "finish" }));
334
+ await expect(session.completed).resolves.toEqual({ finished: true });
335
+ socket.close();
336
+ } finally {
337
+ session.stop();
338
+ }
339
+ });
176
340
  });
177
341
 
178
342
  const localInteractiveShell = "bash --noprofile --norc -i";
179
343
  const cursorPositionProbe = "node -e " + JSON.stringify([
344
+ "process.stdin.setRawMode?.(true);",
180
345
  "process.stdout.write('\\x1b[6n');",
181
346
  "process.stdin.once('data', (chunk) => {",
182
347
  " process.stdout.write('CPR:' + Buffer.from(chunk).toString('hex') + '\\n');",
183
348
  " process.exit(0);",
184
349
  "});",
185
350
  ].join(""));
351
+ const stdinEchoProbe = "node -e " + JSON.stringify([
352
+ "process.stdin.once('data', (chunk) => {",
353
+ " process.stdout.write('STDIN:' + chunk.toString('utf8').trim() + '\\n');",
354
+ " process.exit(0);",
355
+ "});",
356
+ ].join(""));
357
+ const stdinHexProbe = "node -e " + JSON.stringify([
358
+ "process.stdin.setRawMode?.(true);",
359
+ "let data = Buffer.alloc(0);",
360
+ "process.stdin.on('data', (chunk) => {",
361
+ " data = Buffer.concat([data, chunk]);",
362
+ " if (data.includes(10)) {",
363
+ " process.stdout.write('STDINHEX:' + data.toString('hex') + '\\n');",
364
+ " process.exit(0);",
365
+ " }",
366
+ "});",
367
+ ].join(""));
368
+ const ttyProbe = "node -e " + JSON.stringify([
369
+ "process.stdout.write('TTY:' + Boolean(process.stdin.isTTY) + '\\n');",
370
+ "process.exit(0);",
371
+ ].join(""));
372
+ const streamedAuthUrlProbe = "node -e " + JSON.stringify([
373
+ "process.stdout.write(\"Opening browser to sign in...\\nIf the browser didn't open, visit: https://claude.com/cai/oauth/authorize?code=true\");",
374
+ "setTimeout(() => {",
375
+ " process.stdout.write('&state=abc\\nPaste code here if prompted > ');",
376
+ "}, 1000);",
377
+ ].join(""));
186
378
 
187
379
  function readStartupInput(html: string): string {
188
380
  const match = /const startupInput = (.*);/.exec(html);
@@ -213,10 +405,10 @@ function isMessage(
213
405
  return Boolean(value && typeof value === "object" && (value as { type?: unknown }).type === type);
214
406
  }
215
407
 
216
- async function waitFor(assertion: () => boolean): Promise<void> {
408
+ async function waitFor(assertion: () => boolean, timeoutMs = 1_000): Promise<void> {
217
409
  const started = Date.now();
218
410
  while (!assertion()) {
219
- if (Date.now() - started > 1_000) throw new Error("Timed out waiting for terminal event");
411
+ if (Date.now() - started > timeoutMs) throw new Error("Timed out waiting for terminal event");
220
412
  await new Promise((resolve) => setTimeout(resolve, 10));
221
413
  }
222
414
  }
@@ -10,6 +10,7 @@ export type FreestyleTerminalSessionRequest = {
10
10
  canFinishWhileRunning?: boolean;
11
11
  instructions?: string;
12
12
  nodePath?: string;
13
+ openExternalTarget?: (target: string) => unknown;
13
14
  };
14
15
 
15
16
  export type FreestyleTerminalSessionResult = {
@@ -41,12 +42,15 @@ export function createFreestyleTerminalSession(
41
42
  let terminalCols = 100;
42
43
  let terminalRows = 28;
43
44
  let terminalQueryBuffer = "";
45
+ let browserPromptOutputTail = "";
46
+ let browserPromptOpenTimer: ReturnType<typeof setTimeout> | undefined;
44
47
  let proc: Subprocess<"pipe", "pipe", "pipe"> | undefined;
45
48
  let stdin: { write(data: Uint8Array): unknown; flush?(): unknown } | undefined;
46
49
  let complete!: (result: FreestyleTerminalSessionResult) => void;
47
50
  let fail!: (error: Error) => void;
48
51
  const sockets = new Set<ServerWebSocket<SocketData>>();
49
52
  const outputBuffer: string[] = [];
53
+ const openedExternalTargets = new Set<string>();
50
54
  const startupCommand = request.startupInput ?? request.remoteCommand;
51
55
  const startupInput = startupCommand ? ensureTrailingNewline(startupCommand) : undefined;
52
56
  const displayCommand = request.displayCommand ?? request.remoteCommand ?? request.command;
@@ -122,6 +126,7 @@ export function createFreestyleTerminalSession(
122
126
  stop: () => {
123
127
  if (stopped) return;
124
128
  stopped = true;
129
+ clearTimeout(browserPromptOpenTimer);
125
130
  proc?.kill();
126
131
  server.stop(true);
127
132
  },
@@ -136,10 +141,11 @@ export function createFreestyleTerminalSession(
136
141
  canFinish: canFinishWhileRunning,
137
142
  });
138
143
 
139
- proc = Bun.spawn(["sh", "-lc", `exec ${request.command}`], {
144
+ proc = Bun.spawn(["sh", "-lc", terminalProcessShellCommand(request.command)], {
140
145
  stdin: "pipe",
141
146
  stdout: "pipe",
142
147
  stderr: "pipe",
148
+ env: terminalProcessEnv(terminalCols, terminalRows),
143
149
  });
144
150
  stdin = proc.stdin;
145
151
 
@@ -184,6 +190,7 @@ export function createFreestyleTerminalSession(
184
190
  function handleProcessOutput(data: string): void {
185
191
  appendOutput(data);
186
192
  respondToTerminalQueries(data);
193
+ openBrowserUrlsFromOutput(data);
187
194
  }
188
195
 
189
196
  function appendOutput(data: string): void {
@@ -193,9 +200,10 @@ export function createFreestyleTerminalSession(
193
200
  }
194
201
 
195
202
  function writeInput(data: string): void {
196
- if (isCursorPositionReport(data)) return;
203
+ const input = sanitizeBrowserTerminalInput(data);
204
+ if (!input) return;
197
205
 
198
- if (startupInput && data === startupInput) {
206
+ if (startupInput && input === startupInput) {
199
207
  if (remoteCommandStarted) return;
200
208
  remoteCommandStarted = true;
201
209
  broadcast({
@@ -205,7 +213,7 @@ export function createFreestyleTerminalSession(
205
213
  });
206
214
  }
207
215
 
208
- writeProcessInput(data);
216
+ writeProcessInput(input);
209
217
  }
210
218
 
211
219
  function writeProcessInput(data: string): void {
@@ -234,6 +242,26 @@ export function createFreestyleTerminalSession(
234
242
  }
235
243
  }
236
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
+
237
265
  function requestFinish(): void {
238
266
  if (settled) return;
239
267
  settled = true;
@@ -267,6 +295,192 @@ export function createFreestyleTerminalSession(
267
295
  }
268
296
  }
269
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
+
270
484
  function parseClientMessage(raw: string | Buffer): ClientMessage | undefined {
271
485
  if (typeof raw !== "string") return undefined;
272
486
  try {
@@ -292,6 +506,30 @@ function isCursorPositionReport(data: string): boolean {
292
506
  return /^\x1b\[\??\d+;\d+R$/.test(data);
293
507
  }
294
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
+
295
533
  function send(ws: ServerWebSocket<SocketData>, message: ServerMessage): void {
296
534
  ws.send(JSON.stringify(message));
297
535
  }
@@ -305,11 +543,11 @@ function htmlResponse(body: string): Response {
305
543
  headers: {
306
544
  "content-type": "text/html; charset=utf-8",
307
545
  "content-security-policy": [
308
- "default-src 'none'",
309
- "script-src 'unsafe-inline' 'unsafe-eval' 'wasm-unsafe-eval' https://esm.sh",
310
- "style-src 'unsafe-inline'",
311
- "connect-src 'self' ws: wss: https://esm.sh",
312
- "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'",
313
551
  ].join("; "),
314
552
  },
315
553
  });
@@ -342,6 +580,7 @@ function renderInteractionPage(
342
580
  <meta charset="utf-8">
343
581
  <meta name="viewport" content="width=device-width, initial-scale=1">
344
582
  <title>${escapedDocTitle}</title>
583
+ <link rel="stylesheet" href="https://esm.sh/@xterm/xterm@6.0.0/css/xterm.css">
345
584
  <style>
346
585
  :root {
347
586
  color-scheme: light;
@@ -590,10 +829,13 @@ function renderInteractionPage(
590
829
  background: var(--term-bg);
591
830
  overflow: hidden;
592
831
  user-select: text;
832
+ outline: none;
593
833
  }
594
834
  .term-host {
595
835
  position: absolute;
596
836
  inset: 0;
837
+ padding: 14px 16px;
838
+ box-sizing: border-box;
597
839
  user-select: text;
598
840
  --term-bg: #faf8f2;
599
841
  --term-fg: #1a1a1a;
@@ -618,7 +860,22 @@ function renderInteractionPage(
618
860
  --term-color-14: #06606a;
619
861
  --term-color-15: #0a0a0a;
620
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
+ }
621
874
  .term-host:not(.ready) { visibility: hidden; }
875
+ .term-host.link-hover,
876
+ .term-fallback.link-hover {
877
+ cursor: pointer;
878
+ }
622
879
  .term-fallback {
623
880
  position: absolute;
624
881
  inset: 0;
@@ -636,34 +893,18 @@ function renderInteractionPage(
636
893
  line-height: 1.4;
637
894
  }
638
895
  .term-fallback.hidden { display: none; }
639
- .wterm {
640
- position: relative;
641
- background: var(--term-bg);
642
- color: var(--term-fg);
643
- font-family: var(--term-font-family);
644
- font-size: var(--term-font-size);
645
- line-height: 1.2;
646
- padding: 14px 16px;
647
- outline: none;
648
- overflow: auto;
649
- user-select: text;
650
- }
651
- .term-grid { display: block; white-space: pre; contain: layout paint style; user-select: text; }
652
- .term-row {
653
- display: block;
654
- height: var(--term-row-height);
655
- line-height: var(--term-row-height);
656
- user-select: text;
657
- }
658
- .term-row > span {
659
- display: inline-block;
660
- height: var(--term-row-height);
661
- vertical-align: top;
662
- user-select: text;
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;
663
907
  }
664
- .term-block { width: 1ch; overflow: hidden; }
665
- .term-cursor { outline: 1px solid var(--term-cursor); outline-offset: -1px; }
666
- .wterm.focused .term-cursor { background: var(--term-cursor); color: #ffffff; outline: none; }
667
908
  .success-pane {
668
909
  position: absolute;
669
910
  inset: 0;
@@ -765,6 +1006,8 @@ function renderInteractionPage(
765
1006
  let startupSent = false;
766
1007
  let startupIdleTimer;
767
1008
  let startupMaxTimer;
1009
+ let terminalOutputTail = "";
1010
+ let pendingBrowserPromptUrl = null;
768
1011
  const outputBacklog = [];
769
1012
  const listeners = {
770
1013
  onStatus: null,
@@ -792,9 +1035,88 @@ function renderInteractionPage(
792
1035
  startupMaxTimer = startupMaxTimer || setTimeout(sendStartupInput, 1500);
793
1036
  }
794
1037
 
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;
1067
+ }
1068
+ }
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 });
1080
+ }
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;
1087
+ }
1088
+ return null;
1089
+ }
1090
+
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);
1115
+ }
1116
+
795
1117
  function isTextEditingTarget(target) {
796
1118
  if (!(target instanceof Element)) return false;
797
- if (terminalEl && terminalEl.contains(target)) return false;
1119
+ if (terminalEl && terminalEl.contains(target)) return true;
798
1120
  return Boolean(target.closest("textarea, input, select, button, [contenteditable=''], [contenteditable='true']"));
799
1121
  }
800
1122
 
@@ -869,9 +1191,20 @@ function renderInteractionPage(
869
1191
  event.preventDefault();
870
1192
  event.stopImmediatePropagation();
871
1193
  term?.focus();
1194
+ if (data === "\\r") openPendingBrowserPrompt();
872
1195
  sendTerminalInput(data);
873
1196
  }, { capture: true });
874
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
+
875
1208
  function setupSocket() {
876
1209
  const terminalUrl = new URL("/terminal", location.href);
877
1210
  terminalUrl.protocol = location.protocol === "https:" ? "wss:" : "ws:";
@@ -884,6 +1217,7 @@ function renderInteractionPage(
884
1217
  socket.addEventListener("message", (event) => {
885
1218
  const message = JSON.parse(event.data);
886
1219
  if (message.type === "output") {
1220
+ trackBrowserPrompt(message.data);
887
1221
  outputBacklog.push(message.data);
888
1222
  if (termReady) {
889
1223
  term.write(message.data);
@@ -987,14 +1321,18 @@ function renderInteractionPage(
987
1321
  }
988
1322
 
989
1323
  function TerminalChrome() {
1324
+ const shellRef = useRef(null);
990
1325
  const hostRef = useRef(null);
991
1326
  const fallbackRef = useRef(null);
1327
+ const inputProxyRef = useRef(null);
992
1328
 
993
1329
  useEffect(() => {
994
- terminalEl = hostRef.current;
995
- if (fallbackRef.current) {
1330
+ const host = hostRef.current;
1331
+ const fallback = fallbackRef.current;
1332
+ terminalEl = host;
1333
+ if (fallback) {
996
1334
  for (const chunk of outputBacklog) {
997
- fallbackRef.current.textContent += chunk;
1335
+ fallback.textContent += chunk;
998
1336
  }
999
1337
  }
1000
1338
  listeners.onOutput = (data) => {
@@ -1003,46 +1341,182 @@ function renderInteractionPage(
1003
1341
  fallbackRef.current.scrollTop = fallbackRef.current.scrollHeight;
1004
1342
  };
1005
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
+
1006
1374
  let cancelled = false;
1375
+ let resizeObserver = null;
1376
+ let currentTerm = null;
1007
1377
  (async () => {
1008
1378
  try {
1009
- const [{ WTerm }, { GhosttyCore }] = await Promise.all([
1010
- import("https://esm.sh/@wterm/dom@0.3.0?bundle"),
1011
- import("https://esm.sh/@wterm/ghostty@0.3.0?bundle"),
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"),
1012
1383
  ]);
1013
1384
  if (cancelled || !hostRef.current) return;
1014
- const core = await GhosttyCore.load({
1015
- wasmPath: "https://esm.sh/@wterm/ghostty@0.3.0/wasm/ghostty-vt.wasm",
1016
- });
1017
- if (cancelled || !hostRef.current) return;
1018
- term = new WTerm(hostRef.current, {
1019
- core,
1385
+ const xterm = new Terminal({
1020
1386
  cols: 100,
1021
1387
  rows: 28,
1022
- autoResize: true,
1023
1388
  cursorBlink: true,
1024
- onData(data) { sendTerminalInput(data); },
1025
- onResize(cols, rows) {
1026
- if (socket && socket.readyState === WebSocket.OPEN) {
1027
- socket.send(JSON.stringify({ type: "resize", cols, rows }));
1028
- }
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",
1029
1415
  },
1030
1416
  });
1031
- await term.init();
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;
1032
1448
  for (const chunk of outputBacklog) term.write(chunk);
1033
1449
  termReady = true;
1034
1450
  hostRef.current.classList.add("ready");
1035
1451
  fallbackRef.current && fallbackRef.current.classList.add("hidden");
1036
1452
  term.focus();
1453
+ focusTerminalInput();
1037
1454
  } catch (error) {
1038
1455
  console.error(error);
1039
1456
  if (fallbackRef.current) {
1040
- fallbackRef.current.textContent += "\\nUnable to load the libghostty renderer. Output will continue here.\\n";
1457
+ fallbackRef.current.textContent += "\\nUnable to load the xterm renderer. Output will continue here.\\n";
1041
1458
  }
1042
1459
  }
1043
1460
  })();
1044
1461
 
1045
- return () => { cancelled = true; };
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);
1046
1520
  }, []);
1047
1521
 
1048
1522
  return h(F, null,
@@ -1050,9 +1524,29 @@ function renderInteractionPage(
1050
1524
  h("span", { className: "term-titlebar-icon", "aria-hidden": "true" }, h(TerminalIcon, null)),
1051
1525
  h("span", { className: "term-titlebar-label" }, NODE_PATH + " · terminal"),
1052
1526
  ),
1053
- h("div", { className: "terminal-shell" },
1527
+ h("div", {
1528
+ ref: shellRef,
1529
+ className: "terminal-shell",
1530
+ tabIndex: 0,
1531
+ onPointerDownCapture: focusTerminalInput,
1532
+ onKeyDown: sendKeyboardEventToTerminal,
1533
+ onPaste: sendPasteEventToTerminal,
1534
+ },
1054
1535
  h("pre", { ref: fallbackRef, className: "term-fallback" }, "Starting terminal...\\n"),
1055
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
+ }),
1056
1550
  ),
1057
1551
  );
1058
1552
  }
@@ -1069,6 +1563,36 @@ function renderInteractionPage(
1069
1563
  );
1070
1564
  }
1071
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
+
1072
1596
  function App() {
1073
1597
  const [canFinish, setCanFinish] = useState(false);
1074
1598
  const [done, setDone] = useState(INITIAL_COMPLETED);
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const RIGKIT_PROVIDER_FREESTYLE_VERSION = "0.2.4";
1
+ export const RIGKIT_PROVIDER_FREESTYLE_VERSION = "0.2.6";