@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 +4 -4
- package/src/provider.test.ts +17 -0
- package/src/provider.ts +10 -1
- package/src/terminal-session.test.ts +197 -5
- package/src/terminal-session.ts +583 -59
- package/src/version.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rigkit/provider-freestyle",
|
|
3
|
-
"version": "0.2.
|
|
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.
|
|
21
|
-
"@rigkit/
|
|
22
|
-
"@rigkit/
|
|
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"
|
package/src/provider.test.ts
CHANGED
|
@@ -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
|
|
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("@
|
|
23
|
-
expect(html).toContain("@
|
|
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 >
|
|
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
|
}
|
package/src/terminal-session.ts
CHANGED
|
@@ -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",
|
|
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
|
-
|
|
203
|
+
const input = sanitizeBrowserTerminalInput(data);
|
|
204
|
+
if (!input) return;
|
|
197
205
|
|
|
198
|
-
if (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(
|
|
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
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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
|
-
.
|
|
640
|
-
position:
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
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
|
|
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
|
-
|
|
995
|
-
|
|
1330
|
+
const host = hostRef.current;
|
|
1331
|
+
const fallback = fallbackRef.current;
|
|
1332
|
+
terminalEl = host;
|
|
1333
|
+
if (fallback) {
|
|
996
1334
|
for (const chunk of outputBacklog) {
|
|
997
|
-
|
|
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 [{
|
|
1010
|
-
import("https://esm.sh/@
|
|
1011
|
-
import("https://esm.sh/@
|
|
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
|
|
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
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
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
|
-
|
|
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
|
|
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 () => {
|
|
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", {
|
|
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.
|
|
1
|
+
export const RIGKIT_PROVIDER_FREESTYLE_VERSION = "0.2.6";
|