paseo-terminal-integration 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -27
- package/client/run-card.tsx +18 -34
- package/client/settings-screen.tsx +6 -19
- package/index.client.tsx +6 -39
- package/index.server.ts +9 -16
- package/package.json +2 -8
- package/server/runner.ts +174 -16
- package/shared/contracts.ts +7 -29
- package/shared/settings.ts +1 -8
- package/shared/text.ts +1 -1
- package/client/panel-opener.ts +0 -29
- package/client/terminal-panel.tsx +0 -273
- package/client/xterm-modules.d.ts +0 -13
- package/client/xterm-view.tsx +0 -222
- package/server/streams.ts +0 -228
package/server/streams.ts
DELETED
|
@@ -1,228 +0,0 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { readFile } from "node:fs/promises";
|
|
3
|
-
import { homedir } from "node:os";
|
|
4
|
-
import path from "node:path";
|
|
5
|
-
import { DaemonClient, type TerminalStreamEvent } from "@getpaseo/client/internal/daemon-client";
|
|
6
|
-
import { RUN_MARKER } from "../shared/text";
|
|
7
|
-
|
|
8
|
-
// The plugin SDK exposes terminal capture without colors or cursor state, so live views use
|
|
9
|
-
// their own daemon connection and the same terminal stream the app's terminal tabs render.
|
|
10
|
-
|
|
11
|
-
const SCROLLBACK_LINES = 5_000;
|
|
12
|
-
const READ_WAIT_MS = 15_000;
|
|
13
|
-
const IDLE_VIEW_MS = 60_000;
|
|
14
|
-
const SWEEP_MS = 30_000;
|
|
15
|
-
const MAX_PENDING_CHARS = 4_000_000;
|
|
16
|
-
const CONNECT_TIMEOUT_MS = 10_000;
|
|
17
|
-
// Full reset, so a restore after a reconnect replaces the screen instead of appending to it.
|
|
18
|
-
const RESET = "\x1bc";
|
|
19
|
-
|
|
20
|
-
interface Size {
|
|
21
|
-
rows: number;
|
|
22
|
-
cols: number;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
interface View {
|
|
26
|
-
id: string;
|
|
27
|
-
terminalId: string;
|
|
28
|
-
chunks: string[];
|
|
29
|
-
/** Cursor value at the start of chunks[0]. */
|
|
30
|
-
base: number;
|
|
31
|
-
/** Cursor value after the last chunk. */
|
|
32
|
-
end: number;
|
|
33
|
-
closed: string | null;
|
|
34
|
-
seenAt: number;
|
|
35
|
-
decoder: TextDecoder;
|
|
36
|
-
wake: (() => void) | null;
|
|
37
|
-
release(): void;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
function errorMessage(error: unknown): string {
|
|
41
|
-
return error instanceof Error ? error.message : String(error);
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
async function daemonTarget(): Promise<{ url: string; token: string | undefined }> {
|
|
45
|
-
const home = process.env.PASEO_HOME?.trim() || path.join(homedir(), ".paseo");
|
|
46
|
-
let listen = "";
|
|
47
|
-
try {
|
|
48
|
-
const lock = JSON.parse(await readFile(path.join(home, "paseo.pid"), "utf8")) as { listen?: unknown };
|
|
49
|
-
if (typeof lock.listen === "string") listen = lock.listen.trim();
|
|
50
|
-
} catch {
|
|
51
|
-
throw new Error("The Paseo daemon lock file is missing");
|
|
52
|
-
}
|
|
53
|
-
if (/^\d+$/.test(listen)) listen = `127.0.0.1:${listen}`;
|
|
54
|
-
listen = listen.replace(/^(0\.0\.0\.0|\[::\]):/, "127.0.0.1:");
|
|
55
|
-
if (!/^(\[[\da-f:]+\]|[\w.-]+):\d+$/i.test(listen)) {
|
|
56
|
-
throw new Error(`Live terminals need a TCP daemon address; this daemon listens on ${listen || "an unknown address"}`);
|
|
57
|
-
}
|
|
58
|
-
const token = await readFile(path.join(home, "local-credential"), "utf8").then(
|
|
59
|
-
(text) => text.trim(),
|
|
60
|
-
() => "",
|
|
61
|
-
);
|
|
62
|
-
return { url: `ws://${listen}/ws`, token: /^[\w-]{43}$/.test(token) ? token : undefined };
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
export class TerminalStreams {
|
|
66
|
-
private client: Promise<DaemonClient> | null = null;
|
|
67
|
-
private readonly views = new Map<string, View>();
|
|
68
|
-
private readonly sweeper = setInterval(() => this.sweep(), SWEEP_MS);
|
|
69
|
-
|
|
70
|
-
async open({ terminalId, rows, cols }: Size & { terminalId: string }): Promise<{ viewId: string }> {
|
|
71
|
-
const client = await this.connect();
|
|
72
|
-
const view: View = {
|
|
73
|
-
id: randomUUID(),
|
|
74
|
-
terminalId,
|
|
75
|
-
chunks: [],
|
|
76
|
-
base: 0,
|
|
77
|
-
end: 0,
|
|
78
|
-
closed: null,
|
|
79
|
-
seenAt: Date.now(),
|
|
80
|
-
decoder: new TextDecoder(),
|
|
81
|
-
wake: null,
|
|
82
|
-
release: () => {},
|
|
83
|
-
};
|
|
84
|
-
this.views.set(view.id, view);
|
|
85
|
-
|
|
86
|
-
// Like a terminal tab, take over the PTY size before asking for the snapshot.
|
|
87
|
-
client.sendTerminalInput(terminalId, { type: "resize", rows, cols, intent: "claim" });
|
|
88
|
-
const subscription = client.observeTerminal(terminalId, (event) => this.receive(view, event), {
|
|
89
|
-
restore: { mode: "full-snapshot", scrollbackLines: SCROLLBACK_LINES, size: { rows, cols } },
|
|
90
|
-
});
|
|
91
|
-
view.release = () => void subscription.release().catch(() => {});
|
|
92
|
-
subscription.subscribe({
|
|
93
|
-
snapshot: () => {},
|
|
94
|
-
update: (message) => {
|
|
95
|
-
if (message.type === "terminal_stream_exit") {
|
|
96
|
-
this.finish(view, message.payload.error ?? "The terminal exited");
|
|
97
|
-
}
|
|
98
|
-
},
|
|
99
|
-
error: (error) => this.finish(view, errorMessage(error)),
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
try {
|
|
103
|
-
const ready = await subscription.ready;
|
|
104
|
-
if (ready.error) throw new Error(ready.error);
|
|
105
|
-
} catch (error) {
|
|
106
|
-
this.drop(view);
|
|
107
|
-
throw error;
|
|
108
|
-
}
|
|
109
|
-
return { viewId: view.id };
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
async read({ viewId, cursor }: { viewId: string; cursor: number }) {
|
|
113
|
-
const view = this.views.get(viewId);
|
|
114
|
-
if (!view) return { data: "", cursor, closed: "The terminal view expired" };
|
|
115
|
-
view.seenAt = Date.now();
|
|
116
|
-
while (view.chunks.length > 0 && view.base + view.chunks[0].length <= cursor) {
|
|
117
|
-
view.base += view.chunks.shift()!.length;
|
|
118
|
-
}
|
|
119
|
-
if (view.end <= cursor && !view.closed) await this.waitForOutput(view);
|
|
120
|
-
view.seenAt = Date.now();
|
|
121
|
-
|
|
122
|
-
const data = view.chunks.join("").slice(Math.max(0, cursor - view.base));
|
|
123
|
-
if (view.closed && !data) this.drop(view);
|
|
124
|
-
return { data, cursor: view.end, closed: data ? null : view.closed };
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
async resize({ viewId, rows, cols }: Size & { viewId: string }) {
|
|
128
|
-
const view = this.views.get(viewId);
|
|
129
|
-
if (view && !view.closed) {
|
|
130
|
-
const client = await this.connect();
|
|
131
|
-
client.sendTerminalInput(view.terminalId, { type: "resize", rows, cols, intent: "claim" });
|
|
132
|
-
}
|
|
133
|
-
return {};
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
close({ viewId }: { viewId: string }) {
|
|
137
|
-
const view = this.views.get(viewId);
|
|
138
|
-
if (view) this.drop(view);
|
|
139
|
-
return {};
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
async stop(): Promise<void> {
|
|
143
|
-
clearInterval(this.sweeper);
|
|
144
|
-
for (const view of [...this.views.values()]) this.drop(view);
|
|
145
|
-
const client = this.client;
|
|
146
|
-
this.client = null;
|
|
147
|
-
await client?.then((connected) => connected.close()).catch(() => {});
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
private connect(): Promise<DaemonClient> {
|
|
151
|
-
this.client ??= (async () => {
|
|
152
|
-
const { url, token } = await daemonTarget();
|
|
153
|
-
const client = new DaemonClient({
|
|
154
|
-
url,
|
|
155
|
-
clientId: `terminal-integration-${randomUUID()}`,
|
|
156
|
-
clientType: "cli",
|
|
157
|
-
...(token ? { localCredential: () => token } : {}),
|
|
158
|
-
connectTimeoutMs: CONNECT_TIMEOUT_MS,
|
|
159
|
-
suppressSendErrors: true,
|
|
160
|
-
reconnect: { enabled: true },
|
|
161
|
-
});
|
|
162
|
-
try {
|
|
163
|
-
await client.connect();
|
|
164
|
-
} catch (error) {
|
|
165
|
-
await client.close().catch(() => {});
|
|
166
|
-
throw new Error(`Could not connect to the Paseo daemon at ${url}: ${errorMessage(error)}`);
|
|
167
|
-
}
|
|
168
|
-
return client;
|
|
169
|
-
})().catch((error: unknown) => {
|
|
170
|
-
this.client = null;
|
|
171
|
-
throw error;
|
|
172
|
-
});
|
|
173
|
-
return this.client;
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
private receive(view: View, event: TerminalStreamEvent) {
|
|
177
|
-
// Cell snapshots only arrive for visible-snapshot subscriptions.
|
|
178
|
-
if (event.type === "snapshot" || view.closed) return;
|
|
179
|
-
if (event.type === "restore") view.decoder = new TextDecoder();
|
|
180
|
-
// Snapshots drop the concealed attribute, so markers would show; blanking them keeps
|
|
181
|
-
// every line's cursor positions intact.
|
|
182
|
-
const text = view.decoder.decode(event.data, { stream: true }).replace(RUN_MARKER, "");
|
|
183
|
-
this.push(view, event.type === "restore" ? RESET + text : text);
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
private push(view: View, text: string) {
|
|
187
|
-
if (!text) return;
|
|
188
|
-
view.chunks.push(text);
|
|
189
|
-
view.end += text.length;
|
|
190
|
-
if (view.end - view.base > MAX_PENDING_CHARS) {
|
|
191
|
-
this.finish(view, "The terminal view fell behind");
|
|
192
|
-
return;
|
|
193
|
-
}
|
|
194
|
-
view.wake?.();
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
private waitForOutput(view: View): Promise<void> {
|
|
198
|
-
view.wake?.();
|
|
199
|
-
return new Promise((resolve) => {
|
|
200
|
-
const done = () => {
|
|
201
|
-
clearTimeout(timer);
|
|
202
|
-
if (view.wake === done) view.wake = null;
|
|
203
|
-
resolve();
|
|
204
|
-
};
|
|
205
|
-
const timer = setTimeout(done, READ_WAIT_MS);
|
|
206
|
-
view.wake = done;
|
|
207
|
-
});
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
private finish(view: View, reason: string) {
|
|
211
|
-
if (view.closed) return;
|
|
212
|
-
view.closed = reason;
|
|
213
|
-
view.release();
|
|
214
|
-
view.wake?.();
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
private drop(view: View) {
|
|
218
|
-
this.finish(view, "The terminal view closed");
|
|
219
|
-
this.views.delete(view.id);
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
private sweep() {
|
|
223
|
-
const cutoff = Date.now() - IDLE_VIEW_MS;
|
|
224
|
-
for (const view of [...this.views.values()]) {
|
|
225
|
-
if (!view.wake && view.seenAt < cutoff) this.drop(view);
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
}
|