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.
@@ -1,273 +0,0 @@
1
- import { usePaseo, type PluginWorkspacePanelProps } from "@getpaseo/plugin/client";
2
- import { Icon, ScrollView, TextInput, useToast } from "@getpaseo/plugin/client/react-native";
3
- import { useQuery } from "@tanstack/react-query";
4
- import { useMemo, useRef, useState } from "react";
5
- import {
6
- Platform,
7
- Pressable,
8
- Text,
9
- View,
10
- type NativeScrollEvent,
11
- type NativeSyntheticEvent,
12
- type ScrollView as NativeScrollView,
13
- } from "react-native";
14
- import { TERMINAL_NAME } from "../shared/contracts";
15
- import { trimBlankEdges, withoutRunMarkers } from "../shared/text";
16
- import { XtermView } from "./xterm-view";
17
-
18
- type Theme = PluginWorkspacePanelProps["theme"];
19
- type Styles = ReturnType<typeof createStyles>;
20
- // Derived from the SDK so the bundler's import checks never walk @getpaseo/client typings.
21
- type PaseoTerminal = Awaited<
22
- ReturnType<ReturnType<typeof usePaseo>["terminals"]["list"]>
23
- >["entries"][number];
24
-
25
- const SCREEN_LINES = 400;
26
- const SCREEN_POLL_MS = 600;
27
- const LOOKUP_POLL_MS = 3_000;
28
- const PIN_SLACK = 24;
29
- const MONOSPACE = Platform.select({
30
- ios: "Menlo",
31
- android: "monospace",
32
- default: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace",
33
- });
34
-
35
- const KEYS = [
36
- { label: "Enter", data: "\r" },
37
- { label: "Ctrl-C", data: "\x03" },
38
- { label: "Ctrl-D", data: "\x04" },
39
- { label: "Tab", data: "\t" },
40
- ] as const;
41
-
42
- /** The "Agent commands" terminal: live xterm.js on web, a plain-text mirror elsewhere. */
43
- export function TerminalPanel({ theme, layout, workspaceId }: PluginWorkspacePanelProps) {
44
- const paseo = usePaseo();
45
- const styles = useMemo(() => createStyles(theme, layout.compact), [theme, layout.compact]);
46
- const [liveError, setLiveError] = useState<{ terminalId: string; reason: string } | null>(null);
47
-
48
- const lookup = useQuery({
49
- queryKey: ["agent-terminal", workspaceId],
50
- queryFn: async () => {
51
- const { entries } = await paseo.terminals.list({ workspaceId });
52
- return entries.find((entry) => entry.name === TERMINAL_NAME) ?? null;
53
- },
54
- refetchInterval: LOOKUP_POLL_MS,
55
- });
56
- const terminal = lookup.data ?? null;
57
-
58
- if (!terminal) {
59
- const message = lookup.isLoading
60
- ? "Looking for the terminal…"
61
- : `No "${TERMINAL_NAME}" terminal yet. It opens with the first run.`;
62
- return (
63
- <View style={styles.screen}>
64
- <Text style={styles.muted}>{message}</Text>
65
- </View>
66
- );
67
- }
68
-
69
- const fallback = liveError?.terminalId === terminal.id ? liveError.reason : null;
70
- if (Platform.OS === "web" && !fallback) {
71
- return (
72
- <XtermView
73
- key={terminal.id}
74
- terminalId={terminal.id}
75
- theme={theme}
76
- onUnavailable={(reason) => setLiveError({ terminalId: terminal.id, reason })}
77
- />
78
- );
79
- }
80
- return (
81
- <TextMirror
82
- terminal={terminal}
83
- theme={theme}
84
- styles={styles}
85
- note={fallback ? `Live terminal unavailable: ${fallback}` : null}
86
- />
87
- );
88
- }
89
-
90
- /** A plain-text copy of the terminal with a line of input, for platforms without a DOM. */
91
- function TextMirror({
92
- terminal,
93
- theme,
94
- styles,
95
- note,
96
- }: {
97
- terminal: PaseoTerminal;
98
- theme: Theme;
99
- styles: Styles;
100
- note: string | null;
101
- }) {
102
- const paseo = usePaseo();
103
- const toast = useToast();
104
- const [draft, setDraft] = useState("");
105
- const [hidden, setHidden] = useState(false);
106
- const scroller = useRef<NativeScrollView>(null);
107
- const pinned = useRef(true);
108
-
109
- const screen = useQuery({
110
- queryKey: ["agent-terminal-screen", terminal.id],
111
- queryFn: async () => {
112
- const { lines } = await paseo.terminals
113
- .ref(terminal)
114
- .capture({ start: -SCREEN_LINES, stripAnsi: true });
115
- return trimBlankEdges(withoutRunMarkers(lines)).join("\n");
116
- },
117
- refetchInterval: SCREEN_POLL_MS,
118
- });
119
-
120
- function write(data: string) {
121
- try {
122
- paseo.terminals.ref(terminal).write(data);
123
- pinned.current = true;
124
- } catch (error) {
125
- toast.error(error instanceof Error ? error.message : String(error));
126
- }
127
- }
128
-
129
- function submit() {
130
- write(`${draft}\r`);
131
- setDraft("");
132
- }
133
-
134
- function trackScroll(event: NativeSyntheticEvent<NativeScrollEvent>) {
135
- const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent;
136
- pinned.current = contentOffset.y + layoutMeasurement.height >= contentSize.height - PIN_SLACK;
137
- }
138
-
139
- return (
140
- <View style={styles.screen}>
141
- {note ? <Text style={styles.muted}>{note}</Text> : null}
142
- <ScrollView
143
- ref={scroller}
144
- style={styles.output}
145
- contentContainerStyle={styles.outputContent}
146
- onScroll={trackScroll}
147
- scrollEventThrottle={100}
148
- onContentSizeChange={() => {
149
- if (pinned.current) scroller.current?.scrollToEnd({ animated: false });
150
- }}
151
- >
152
- <Text style={styles.text} selectable>
153
- {screen.data ?? ""}
154
- </Text>
155
- </ScrollView>
156
-
157
- <View style={styles.inputRow}>
158
- <TextInput
159
- style={styles.input}
160
- value={draft}
161
- onChangeText={setDraft}
162
- onSubmitEditing={submit}
163
- submitBehavior="submit"
164
- secureTextEntry={hidden}
165
- autoCapitalize="none"
166
- autoCorrect={false}
167
- spellCheck={false}
168
- placeholder="Input for the terminal; Enter sends it"
169
- placeholderTextColor={theme.colors.foregroundMuted}
170
- accessibilityLabel="Terminal input"
171
- />
172
- <KeyButton
173
- styles={styles}
174
- theme={theme}
175
- icon="SendHorizontal"
176
- label="Send"
177
- onPress={submit}
178
- />
179
- </View>
180
- <View style={styles.keys}>
181
- {KEYS.map((key) => (
182
- <KeyButton
183
- key={key.label}
184
- styles={styles}
185
- theme={theme}
186
- label={key.label}
187
- onPress={() => write(key.data)}
188
- />
189
- ))}
190
- <KeyButton
191
- styles={styles}
192
- theme={theme}
193
- icon={hidden ? "EyeOff" : "Eye"}
194
- label={hidden ? "Input hidden" : "Hide input"}
195
- onPress={() => setHidden((value) => !value)}
196
- />
197
- </View>
198
- </View>
199
- );
200
- }
201
-
202
- function KeyButton({
203
- styles,
204
- theme,
205
- icon,
206
- label,
207
- disabled = false,
208
- onPress,
209
- }: {
210
- styles: Styles;
211
- theme: Theme;
212
- icon?: string;
213
- label: string;
214
- disabled?: boolean;
215
- onPress(): void;
216
- }) {
217
- return (
218
- <Pressable
219
- accessibilityRole="button"
220
- accessibilityLabel={label}
221
- accessibilityState={{ disabled }}
222
- disabled={disabled}
223
- onPress={onPress}
224
- style={({ pressed }) => [styles.key, { opacity: disabled ? 0.5 : pressed ? 0.75 : 1 }]}
225
- >
226
- {icon ? <Icon name={icon} size={14} color={theme.colors.foreground} /> : null}
227
- <Text style={styles.keyText}>{label}</Text>
228
- </Pressable>
229
- );
230
- }
231
-
232
- function createStyles(theme: Theme, compact: boolean) {
233
- const gap = compact ? 6 : 8;
234
- return {
235
- screen: { flex: 1, gap, padding: gap, backgroundColor: theme.colors.surface0 },
236
- output: {
237
- flex: 1,
238
- borderRadius: 6,
239
- borderWidth: 1,
240
- borderColor: theme.colors.border,
241
- backgroundColor: theme.colors.surface1,
242
- },
243
- outputContent: { padding: compact ? 8 : 10 },
244
- text: { color: theme.colors.foreground, fontFamily: MONOSPACE, fontSize: 12, lineHeight: 17 },
245
- muted: { color: theme.colors.foregroundMuted },
246
- inputRow: { flexDirection: "row" as const, gap, alignItems: "center" as const },
247
- input: {
248
- flex: 1,
249
- color: theme.colors.foreground,
250
- fontFamily: MONOSPACE,
251
- fontSize: 13,
252
- paddingHorizontal: 10,
253
- paddingVertical: compact ? 8 : 6,
254
- borderRadius: 6,
255
- borderWidth: 1,
256
- borderColor: theme.colors.border,
257
- backgroundColor: theme.colors.surface1,
258
- },
259
- keys: { flexDirection: "row" as const, flexWrap: "wrap" as const, gap },
260
- key: {
261
- flexDirection: "row" as const,
262
- alignItems: "center" as const,
263
- gap: 6,
264
- paddingHorizontal: 10,
265
- paddingVertical: compact ? 7 : 5,
266
- borderRadius: 6,
267
- borderWidth: 1,
268
- borderColor: theme.colors.border,
269
- backgroundColor: theme.colors.surface2,
270
- },
271
- keyText: { color: theme.colors.foreground, fontSize: 13 },
272
- };
273
- }
@@ -1,13 +0,0 @@
1
- // Plugin client bundles build for a neutral platform, which ignores package "main" fields,
2
- // so xterm is imported by file path. These map the paths back to the published typings.
3
- declare module "@xterm/xterm/lib/xterm.mjs" {
4
- export * from "@xterm/xterm";
5
- }
6
-
7
- declare module "@xterm/addon-fit/lib/addon-fit.mjs" {
8
- export * from "@xterm/addon-fit";
9
- }
10
-
11
- declare module "@xterm/addon-web-links/lib/addon-web-links.mjs" {
12
- export * from "@xterm/addon-web-links";
13
- }
@@ -1,222 +0,0 @@
1
- import { usePaseo, useRpc, type PluginWorkspacePanelProps } from "@getpaseo/plugin/client";
2
- import type { ITheme, Terminal } from "@xterm/xterm";
3
- import { useEffect, useRef, useState } from "react";
4
- import { Linking, Text, View } from "react-native";
5
- import {
6
- closeTerminalView,
7
- openTerminalView,
8
- readTerminalView,
9
- resizeTerminalView,
10
- } from "../shared/contracts";
11
-
12
- type Theme = PluginWorkspacePanelProps["theme"];
13
-
14
- // Same defaults as the app's terminal tabs.
15
- const FONT_FAMILY = [
16
- "JetBrains Mono",
17
- "JetBrainsMono Nerd Font",
18
- "JetBrainsMono NF",
19
- "MesloLGM Nerd Font",
20
- "MesloLGM NF",
21
- "Hack Nerd Font",
22
- "FiraCode Nerd Font",
23
- "Symbols Nerd Font",
24
- "SF Mono",
25
- "Menlo",
26
- "Monaco",
27
- "Consolas",
28
- "'Liberation Mono'",
29
- "monospace",
30
- ].join(", ");
31
- const FONT_SIZE = 13;
32
- const SCROLLBACK_LINES = 5_000;
33
- const RETRY_MS = 1_000;
34
- const FIT_DEBOUNCE_MS = 100;
35
-
36
- function errorMessage(error: unknown): string {
37
- return error instanceof Error ? error.message : String(error);
38
- }
39
-
40
- function translucent(color: string, alpha: number): string | undefined {
41
- const hex = /^#([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i.exec(color);
42
- if (!hex) return undefined;
43
- const [red, green, blue] = hex.slice(1).map((part) => parseInt(part, 16));
44
- return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
45
- }
46
-
47
- function terminalTheme(theme: Theme): ITheme {
48
- return {
49
- background: theme.colors.surface0,
50
- foreground: theme.colors.foreground,
51
- cursor: theme.colors.foreground,
52
- cursorAccent: theme.colors.surface0,
53
- selectionBackground: translucent(theme.colors.accent, 0.35),
54
- };
55
- }
56
-
57
- const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
58
-
59
- function openWebLink(uri: string) {
60
- if (/^https?:\/\//i.test(uri)) void Linking.openURL(uri).catch(() => {});
61
- }
62
-
63
- /**
64
- * A live xterm.js view of a daemon terminal: the same stream and renderer as a terminal
65
- * tab. Web only; `onUnavailable` reports why the live stream could not start.
66
- */
67
- export function XtermView({
68
- terminalId,
69
- theme,
70
- onUnavailable,
71
- }: {
72
- terminalId: string;
73
- theme: Theme;
74
- onUnavailable(reason: string): void;
75
- }) {
76
- const host = useRef<View>(null);
77
- const terminal = useRef<Terminal | null>(null);
78
- const [status, setStatus] = useState<string | null>("Connecting…");
79
-
80
- const paseo = usePaseo();
81
- const open = useRpc(openTerminalView);
82
- const read = useRpc(readTerminalView);
83
- const resize = useRpc(resizeTerminalView);
84
- const close = useRpc(closeTerminalView);
85
- const latest = useRef({ paseo, open, read, resize, close, theme, onUnavailable });
86
- latest.current = { paseo, open, read, resize, close, theme, onUnavailable };
87
-
88
- useEffect(() => {
89
- // react-native-web refs are the underlying DOM elements.
90
- const element = host.current as unknown as HTMLElement | null;
91
- if (!element) return;
92
- let disposed = false;
93
- let viewId: string | null = null;
94
- let observer: ResizeObserver | null = null;
95
- let fitTimer: ReturnType<typeof setTimeout> | undefined;
96
-
97
- async function stream(term: Terminal) {
98
- let connected = false;
99
- while (!disposed) {
100
- try {
101
- const opened = await latest.current.open({ terminalId, rows: term.rows, cols: term.cols });
102
- if (disposed) {
103
- void latest.current.close(opened).catch(() => {});
104
- return;
105
- }
106
- viewId = opened.viewId;
107
- connected = true;
108
- setStatus(null);
109
- let cursor = 0;
110
- for (;;) {
111
- const chunk = await latest.current.read({ viewId, cursor });
112
- if (disposed) return;
113
- if (chunk.data) term.write(chunk.data);
114
- cursor = chunk.cursor;
115
- if (chunk.closed) break;
116
- }
117
- } catch (error) {
118
- if (disposed) return;
119
- if (!connected) {
120
- latest.current.onUnavailable(errorMessage(error));
121
- return;
122
- }
123
- setStatus("Reconnecting…");
124
- }
125
- viewId = null;
126
- await sleep(RETRY_MS);
127
- }
128
- }
129
-
130
- void (async () => {
131
- const [{ Terminal }, { FitAddon }, { WebLinksAddon }] = await Promise.all([
132
- import("@xterm/xterm/lib/xterm.mjs"),
133
- import("@xterm/addon-fit/lib/addon-fit.mjs"),
134
- import("@xterm/addon-web-links/lib/addon-web-links.mjs"),
135
- ]);
136
- if (disposed) return;
137
- const term = new Terminal({
138
- allowProposedApi: true,
139
- cursorBlink: true,
140
- cursorStyle: "bar",
141
- fontFamily: FONT_FAMILY,
142
- fontSize: FONT_SIZE,
143
- lineHeight: 1,
144
- // OSC 8 hyperlinks; plain URLs in the output go through WebLinksAddon.
145
- linkHandler: { activate: (_event, text) => openWebLink(text) },
146
- macOptionIsMeta: true,
147
- scrollback: SCROLLBACK_LINES,
148
- theme: terminalTheme(latest.current.theme),
149
- });
150
- terminal.current = term;
151
- const fit = new FitAddon();
152
- term.loadAddon(fit);
153
- term.loadAddon(new WebLinksAddon((_event, uri) => openWebLink(uri)));
154
- term.open(element);
155
- fit.fit();
156
-
157
- term.onData((data) => latest.current.paseo.terminals.ref(terminalId).write(data));
158
- term.onResize(({ rows, cols }) => {
159
- if (viewId) void latest.current.resize({ viewId, rows, cols }).catch(() => {});
160
- });
161
- // Copy and paste use Ctrl-Shift like Linux terminals. Ctrl-C also copies while text is
162
- // selected, as in VS Code, so copying a URL cannot interrupt the prompt that printed it.
163
- term.attachCustomKeyEventHandler((event) => {
164
- if (event.type !== "keydown" || !event.ctrlKey || event.altKey || event.metaKey) return true;
165
- const key = event.key.toLowerCase();
166
- if (key === "c" && (event.shiftKey || term.hasSelection())) {
167
- if (term.hasSelection()) {
168
- void navigator.clipboard?.writeText(term.getSelection());
169
- if (!event.shiftKey) term.clearSelection();
170
- }
171
- return false;
172
- }
173
- // Returning false leaves the browser's paste to xterm's textarea.
174
- return !(key === "v" && event.shiftKey);
175
- });
176
-
177
- observer = new ResizeObserver(() => {
178
- clearTimeout(fitTimer);
179
- fitTimer = setTimeout(() => {
180
- if (!disposed && element.clientWidth > 0 && element.clientHeight > 0) fit.fit();
181
- }, FIT_DEBOUNCE_MS);
182
- });
183
- observer.observe(element);
184
-
185
- await stream(term);
186
- })().catch((error: unknown) => {
187
- if (!disposed) latest.current.onUnavailable(errorMessage(error));
188
- });
189
-
190
- return () => {
191
- disposed = true;
192
- observer?.disconnect();
193
- clearTimeout(fitTimer);
194
- if (viewId) void latest.current.close({ viewId }).catch(() => {});
195
- terminal.current?.dispose();
196
- terminal.current = null;
197
- };
198
- }, [terminalId]);
199
-
200
- useEffect(() => {
201
- if (terminal.current) terminal.current.options.theme = terminalTheme(theme);
202
- }, [theme]);
203
-
204
- return (
205
- <View style={{ flex: 1, padding: 6, backgroundColor: theme.colors.surface0 }}>
206
- <View ref={host} style={{ flex: 1, overflow: "hidden" }} />
207
- {status ? (
208
- <Text
209
- style={{
210
- position: "absolute",
211
- top: 8,
212
- right: 12,
213
- color: theme.colors.foregroundMuted,
214
- fontSize: 12,
215
- }}
216
- >
217
- {status}
218
- </Text>
219
- ) : null}
220
- </View>
221
- );
222
- }