paseo-terminal-integration 0.1.0 → 0.2.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 CHANGED
@@ -6,13 +6,25 @@ Two-way bridge between agent chats and Paseo terminals.
6
6
  (`bash`, `sh`, `shell`, `console`, `zsh`, `fish`, `python`, `js`/`node`) get a **Run in terminal**
7
7
  card under the reply:
8
8
 
9
- - **Run & send to agent** runs the block in the workspace terminal "Agent commands" and, when it
10
- finishes, sends the command, exit code, and output to the agent as a new message. If the agent is
11
- busy, delivery waits until its turn ends instead of interrupting it.
12
- - **Run only** runs it without sending; **Send output** sends it later.
9
+ - **Run in side terminal** runs the block in the workspace terminal "Agent commands" and shows it in
10
+ the side panel; **Run silently** runs it without showing anything.
11
+ - With **Send to agent** checked (the default), the command, exit code, and output go to the agent
12
+ as a new message when the block finishes. If the agent is busy, delivery waits until its turn
13
+ ends instead of interrupting it. Unchecked, **Send output** sends it later.
13
14
  - **Stop** sends Ctrl-C; **Send output so far** reports a still-running command.
14
15
 
15
- The terminal is a real PTY, so prompts (`sudo`, `y/n`) can be answered in the terminal tab. Runs in
16
+ **Terminal panel.** The side panel (Explorer) shows "Agent commands" as a full terminal: the same
17
+ xterm.js renderer and live stream as Paseo's terminal tabs, with colors, the cursor, full-screen
18
+ programs, and keyboard input. Click it and type to answer `sudo` passwords, `y/n` questions, or
19
+ npm's "Press ENTER" without leaving the chat. Ctrl-C copies while text is selected and otherwise
20
+ goes to the shell; Ctrl-Shift-C and Ctrl-Shift-V always copy and paste. Web links open in the
21
+ browser when clicked. The card's **Terminal** button opens the panel without running anything. On
22
+ phones the panel is a plain-text copy with an input line and Enter, Ctrl-C, Ctrl-D, and Tab keys,
23
+ in a tab that opens only when you ask. **Settings → Plugins → Terminal integration** switches the
24
+ panel to a workspace tab and sets the checkbox's default.
25
+
26
+ Paseo gives every workspace terminal its own tab, so "Agent commands" also appears in the tab bar
27
+ (in the background). Closing that tab ends the terminal; the next run starts a new one. Runs in
16
28
  one workspace are queued so a command never types into another one's stdin. Each block runs as a
17
29
  script in the agent's working directory, so `cd` and `export` do not persist between runs.
18
30
 
@@ -27,10 +39,10 @@ Requires Paseo 0.10.1 or later and a macOS or Linux daemon host with `bash` (plu
27
39
 
28
40
  ```bash
29
41
  paseo plugin install npm:paseo-terminal-integration
30
- # or from GitHub
31
- paseo plugin install github:keefeere/paseo-terminal-integration
32
42
  ```
33
43
 
44
+ Install from npm: GitHub installs skip npm dependencies, which the terminal panel needs.
45
+
34
46
  Plugins are trusted, unsandboxed code: this one types commands into terminals on the daemon host.
35
47
  Nothing runs until you press a button or submit `/run`.
36
48
 
@@ -41,12 +53,17 @@ Nothing runs until you press a button or submit `/run`.
41
53
  | `/run <command>` | Run a shell command and send its output to the agent |
42
54
  | `/blocks` | Add a run card for the latest reply (e.g. older chats) |
43
55
  | ⌘K / Ctrl+K → "Terminal: add run buttons to …" | Same as `/blocks` |
56
+ | ⌘K / Ctrl+K → "Terminal: show … in the side panel" | Open the terminal panel in the Explorer |
44
57
 
45
58
  ## Limitations
46
59
 
47
60
  - Output is read from the terminal screen, so very long lines arrive wrapped and only the last
48
61
  3,000 rows are visible. The agent receives at most 300 lines or 24,000 characters.
49
62
  - Run state lives in the plugin process; reloading the plugin forgets it (the cards stay).
63
+ - The live panel opens its own connection to the daemon on the plugin host, using the address in
64
+ `$PASEO_HOME/paseo.pid` and the local credential, as the `paseo` CLI does. A daemon listening on
65
+ a Unix socket gets the plain-text panel instead.
66
+ - Opening or resizing the panel resizes the PTY to fit it, as focusing a terminal tab does.
50
67
  - A command that has not started within 20 seconds fails, for example when something else is
51
68
  running in "Agent commands".
52
69
 
@@ -0,0 +1,29 @@
1
+ import type { PluginClientContext } from "@getpaseo/plugin/client";
2
+ import { TERMINAL_PANEL_ID, type PanelLocation } from "../shared/settings";
3
+
4
+ type OpenPanel = PluginClientContext["openPanel"];
5
+
6
+ // Timeline renderers receive no navigation, so the entry hands its openPanel capability here.
7
+ let openPanel: OpenPanel | null = null;
8
+
9
+ export function setPanelOpener(next: OpenPanel | null): void {
10
+ openPanel = next;
11
+ }
12
+
13
+ /**
14
+ * Shows the terminal panel. Compact layouts have no Explorer, so they use a workspace tab and
15
+ * only when asked explicitly: an automatic tab switch would hide the chat on a phone.
16
+ */
17
+ export function openTerminalPanel(
18
+ workspaceId: string,
19
+ where: PanelLocation,
20
+ { compact, explicit }: { compact: boolean; explicit: boolean },
21
+ ): void {
22
+ if (!openPanel || (compact && !explicit)) return;
23
+ const location = compact ? "workspace" : where;
24
+ try {
25
+ openPanel(TERMINAL_PANEL_ID, { workspaceId, location });
26
+ } catch {
27
+ if (location === "explorer") openPanel(TERMINAL_PANEL_ID, { workspaceId, location: "workspace" });
28
+ }
29
+ }
@@ -1,4 +1,4 @@
1
- import { useRpc, type PluginTimelineItemProps } from "@getpaseo/plugin/client";
1
+ import { useAgent, useRpc, useSettings, type PluginTimelineItemProps } from "@getpaseo/plugin/client";
2
2
  import { copyText, Icon, useToast } from "@getpaseo/plugin/client/react-native";
3
3
  import { useQuery, useQueryClient } from "@tanstack/react-query";
4
4
  import { useMemo, useState } from "react";
@@ -13,6 +13,8 @@ import {
13
13
  type RunCardData,
14
14
  type RunSnapshot,
15
15
  } from "../shared/contracts";
16
+ import { preferences } from "../shared/settings";
17
+ import { openTerminalPanel } from "./panel-opener";
16
18
 
17
19
  type Theme = PluginTimelineItemProps["theme"];
18
20
  type Styles = ReturnType<typeof createStyles>;
@@ -42,6 +44,26 @@ export function RunCard({ item, agentId, theme, layout }: PluginTimelineItemProp
42
44
  const cancel = useRpc(cancelRun);
43
45
  const send = useRpc(sendRunOutput);
44
46
 
47
+ const workspaceId = useAgent(agentId, (agent) => agent.workspaceId);
48
+ const settings = useSettings(preferences);
49
+ const saved = settings.status === "ready" ? settings.values : null;
50
+ const panelLocation = saved?.panelLocation ?? "explorer";
51
+ const [sendChoice, setSendChoice] = useState<boolean | null>(null);
52
+ const sendToAgent = sendChoice ?? saved?.sendToAgent ?? true;
53
+ const runVisibleLabel =
54
+ layout.compact || panelLocation === "workspace" ? "Run in terminal tab" : "Run in side terminal";
55
+
56
+ function showTerminal() {
57
+ if (!workspaceId) return;
58
+ openTerminalPanel(workspaceId, panelLocation, { compact: layout.compact, explicit: true });
59
+ }
60
+ function runBlock(key: string, block: RunCardData["blocks"][number], visible: boolean) {
61
+ return act(key, async () => {
62
+ await start({ agentId, key, lang: block.lang, code: block.code, send: sendToAgent });
63
+ if (visible) showTerminal();
64
+ });
65
+ }
66
+
45
67
  const queryKey = useMemo(() => ["terminal-run-status", cardId], [cardId]);
46
68
  const status = useQuery({
47
69
  queryKey,
@@ -73,6 +95,23 @@ export function RunCard({ item, agentId, theme, layout }: PluginTimelineItemProp
73
95
  <Text style={styles.muted} numberOfLines={1}>
74
96
  · {TERMINAL_NAME}
75
97
  </Text>
98
+ <View style={styles.spacer} />
99
+ <Checkbox
100
+ styles={styles}
101
+ theme={theme}
102
+ label="Send to agent"
103
+ checked={sendToAgent}
104
+ onChange={setSendChoice}
105
+ />
106
+ {workspaceId ? (
107
+ <ActionButton
108
+ styles={styles}
109
+ theme={theme}
110
+ icon="PanelRight"
111
+ label="Terminal"
112
+ onPress={showTerminal}
113
+ />
114
+ ) : null}
76
115
  </View>
77
116
  {blocks.map((block, index) => {
78
117
  const key = keys[index];
@@ -115,23 +154,19 @@ export function RunCard({ item, agentId, theme, layout }: PluginTimelineItemProp
115
154
  <ActionButton
116
155
  styles={styles}
117
156
  theme={theme}
118
- icon="Play"
119
- label={run ? "Run again & send" : "Run & send to agent"}
157
+ icon="PanelRight"
158
+ label={runVisibleLabel}
120
159
  tone="primary"
121
160
  disabled={busy || active}
122
- onPress={() =>
123
- act(key, () => start({ agentId, key, lang: block.lang, code: block.code, send: true }))
124
- }
161
+ onPress={() => runBlock(key, block, true)}
125
162
  />
126
163
  <ActionButton
127
164
  styles={styles}
128
165
  theme={theme}
129
- icon="SquareTerminal"
130
- label="Run only"
166
+ icon="Play"
167
+ label="Run silently"
131
168
  disabled={busy || active}
132
- onPress={() =>
133
- act(key, () => start({ agentId, key, lang: block.lang, code: block.code, send: false }))
134
- }
169
+ onPress={() => runBlock(key, block, false)}
135
170
  />
136
171
  {run && !run.sent && !active ? (
137
172
  <ActionButton
@@ -203,6 +238,37 @@ function RunStatus({ run, styles }: { run: RunSnapshot; styles: Styles }) {
203
238
  );
204
239
  }
205
240
 
241
+ function Checkbox({
242
+ styles,
243
+ theme,
244
+ label,
245
+ checked,
246
+ onChange,
247
+ }: {
248
+ styles: Styles;
249
+ theme: Theme;
250
+ label: string;
251
+ checked: boolean;
252
+ onChange(checked: boolean): void;
253
+ }) {
254
+ return (
255
+ <Pressable
256
+ accessibilityRole="checkbox"
257
+ accessibilityLabel={label}
258
+ accessibilityState={{ checked }}
259
+ onPress={() => onChange(!checked)}
260
+ style={({ pressed }) => [styles.checkbox, { opacity: pressed ? 0.75 : 1 }]}
261
+ >
262
+ <Icon
263
+ name={checked ? "SquareCheck" : "Square"}
264
+ size={16}
265
+ color={checked ? theme.colors.accent : theme.colors.foregroundMuted}
266
+ />
267
+ <Text style={[styles.buttonText, { color: theme.colors.foreground }]}>{label}</Text>
268
+ </Pressable>
269
+ );
270
+ }
271
+
206
272
  function ActionButton({
207
273
  styles,
208
274
  theme,
@@ -260,9 +326,22 @@ function createStyles(theme: Theme, compact: boolean) {
260
326
  padding: compact ? 10 : 12,
261
327
  backgroundColor: theme.colors.surface1,
262
328
  },
263
- header: { flexDirection: "row" as const, alignItems: "center" as const, gap: 6 },
329
+ header: {
330
+ flexDirection: "row" as const,
331
+ flexWrap: "wrap" as const,
332
+ alignItems: "center" as const,
333
+ gap: 6,
334
+ },
335
+ checkbox: {
336
+ flexDirection: "row" as const,
337
+ alignItems: "center" as const,
338
+ gap: 6,
339
+ paddingHorizontal: 4,
340
+ paddingVertical: compact ? 7 : 5,
341
+ },
264
342
  title: { color: theme.colors.foreground, fontWeight: "600" as const },
265
343
  muted: { color: theme.colors.foregroundMuted, flexShrink: 1 },
344
+ spacer: { flex: 1 },
266
345
  success: { color: theme.colors.statusSuccess },
267
346
  danger: { color: theme.colors.statusDanger },
268
347
  block: { gap: 8 },
@@ -0,0 +1,53 @@
1
+ import { useSettings, type PluginSurfaceProps } from "@getpaseo/plugin/client";
2
+ import {
3
+ SettingsCard,
4
+ SettingsRow,
5
+ SettingsSection,
6
+ SettingsSelect,
7
+ SettingsSwitch,
8
+ } from "@getpaseo/plugin/client/ui";
9
+ import { Text } from "react-native";
10
+ import { preferences, type PanelLocation } from "../shared/settings";
11
+
12
+ const panelLocationChoices: { label: string; value: PanelLocation }[] = [
13
+ { label: "Side panel (Explorer)", value: "explorer" },
14
+ { label: "Workspace tab", value: "workspace" },
15
+ ];
16
+
17
+ export function SettingsScreen({ theme }: PluginSurfaceProps) {
18
+ const settings = useSettings(preferences);
19
+
20
+ if (settings.status !== "ready") {
21
+ const message =
22
+ settings.status === "loading" ? "Loading…" : `Settings are unavailable: ${settings.error}`;
23
+ return <Text style={{ color: theme.colors.foregroundMuted }}>{message}</Text>;
24
+ }
25
+ const { values, revision } = settings;
26
+
27
+ return (
28
+ <SettingsSection title="Run cards">
29
+ <SettingsCard>
30
+ <SettingsSelect
31
+ label="Show the terminal in"
32
+ hint={`Used by "Run in side terminal", the Terminal button, and /run. Phones always use a tab and never switch to it automatically.`}
33
+ value={values.panelLocation}
34
+ options={panelLocationChoices}
35
+ disabled={settings.saving}
36
+ error={settings.saveError}
37
+ onValueChange={(panelLocation) => void settings.save({ ...values, panelLocation }, revision)}
38
+ />
39
+ <SettingsSwitch
40
+ label="Send output to the agent by default"
41
+ hint={`Initial state of the "Send to agent" checkbox on each run card.`}
42
+ value={values.sendToAgent}
43
+ disabled={settings.saving}
44
+ onValueChange={(sendToAgent) => void settings.save({ ...values, sendToAgent }, revision)}
45
+ />
46
+ <SettingsRow
47
+ label="What the panel shows"
48
+ hint={`The live "Agent commands" terminal with colors and keyboard input, for prompts such as sudo passwords, y/n questions, or 2FA codes. Ctrl-C copies while text is selected; Ctrl-Shift-C and Ctrl-Shift-V copy and paste. Phones show a plain-text copy with an input line.`}
49
+ />
50
+ </SettingsCard>
51
+ </SettingsSection>
52
+ );
53
+ }
@@ -0,0 +1,273 @@
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
+ }
@@ -0,0 +1,13 @@
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
+ }
@@ -0,0 +1,222 @@
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
+ }
package/index.client.tsx CHANGED
@@ -1,15 +1,22 @@
1
1
  import type { PluginClientContext } from "@getpaseo/plugin/client";
2
+ import { setPanelOpener } from "./client/panel-opener";
2
3
  import { RunCard } from "./client/run-card";
4
+ import { SettingsScreen } from "./client/settings-screen";
5
+ import { TerminalPanel } from "./client/terminal-panel";
3
6
  import {
4
7
  RUN_CARD_KIND,
5
8
  RUN_CARD_VERSION,
6
9
  runAdhoc,
7
10
  runCardSchema,
8
11
  scanBlocks,
12
+ TERMINAL_NAME,
9
13
  terminalOutputSource,
10
14
  } from "./shared/contracts";
15
+ import { TERMINAL_PANEL_ID } from "./shared/settings";
11
16
 
12
17
  export default function contribute(client: PluginClientContext) {
18
+ setPanelOpener((id, options) => client.openPanel(id, options));
19
+
13
20
  client.addTimelineRenderer({
14
21
  kind: RUN_CARD_KIND,
15
22
  version: RUN_CARD_VERSION,
@@ -17,6 +24,22 @@ export default function contribute(client: PluginClientContext) {
17
24
  Component: RunCard,
18
25
  });
19
26
 
27
+ client.addWorkspacePanel({
28
+ id: TERMINAL_PANEL_ID,
29
+ title: TERMINAL_NAME,
30
+ icon: "SquareTerminal",
31
+ context: "workspace",
32
+ locations: ["explorer", "workspace"],
33
+ Component: TerminalPanel,
34
+ });
35
+
36
+ client.addSettingsScreen({
37
+ id: "preferences",
38
+ title: "Terminal integration",
39
+ icon: "SquareTerminal",
40
+ Component: SettingsScreen,
41
+ });
42
+
20
43
  client.addAttachmentSource(terminalOutputSource);
21
44
 
22
45
  client.addSlashCommand({
@@ -24,9 +47,14 @@ export default function contribute(client: PluginClientContext) {
24
47
  description: "Run a shell command in the workspace terminal and send its output to the agent",
25
48
  argumentHint: "<command>",
26
49
  context: "agent",
27
- async onSubmit({ args, agent, rpc }) {
50
+ async onSubmit({ args, agent, rpc, openPanel }) {
28
51
  if (!args) throw new Error("Usage: /run <command>");
29
- await rpc(runAdhoc, { agentId: agent.id, command: args });
52
+ const { panelLocation } = await rpc(runAdhoc, { agentId: agent.id, command: args });
53
+ try {
54
+ openPanel(TERMINAL_PANEL_ID, { location: panelLocation });
55
+ } catch {
56
+ // Compact layouts have no Explorer; the card's Terminal button still opens a tab.
57
+ }
30
58
  },
31
59
  });
32
60
 
@@ -52,5 +80,20 @@ export default function contribute(client: PluginClientContext) {
52
80
  onSelect: ({ agent, rpc }) => addRunButtons(agent.id, rpc),
53
81
  });
54
82
 
55
- return () => {};
83
+ client.addCommandCenterItem({
84
+ id: "open-terminal-panel",
85
+ title: `Terminal: show "${TERMINAL_NAME}" in the side panel`,
86
+ icon: "PanelRight",
87
+ keywords: ["terminal", "explorer", "sidebar", "agent commands"],
88
+ context: "workspace",
89
+ onSelect({ openPanel }) {
90
+ try {
91
+ openPanel(TERMINAL_PANEL_ID, { location: "explorer" });
92
+ } catch {
93
+ openPanel(TERMINAL_PANEL_ID, { location: "workspace" });
94
+ }
95
+ },
96
+ });
97
+
98
+ return () => setPanelOpener(null);
56
99
  }
package/index.server.ts CHANGED
@@ -2,8 +2,14 @@ import type { PluginServerContext } from "@getpaseo/plugin/server";
2
2
  import { searchTerminals } from "./server/attachments";
3
3
  import { appendCardForTurn, appendRunCard, scanLatestReply } from "./server/cards";
4
4
  import { cleanupScripts, TerminalRunner } from "./server/runner";
5
+ import { TerminalStreams } from "./server/streams";
6
+ import { preferences } from "./shared/settings";
5
7
  import {
6
8
  cancelRun,
9
+ closeTerminalView,
10
+ openTerminalView,
11
+ readTerminalView,
12
+ resizeTerminalView,
7
13
  runAdhoc,
8
14
  runKey,
9
15
  runStatus,
@@ -14,7 +20,9 @@ import {
14
20
  } from "./shared/contracts";
15
21
 
16
22
  export default function contribute(server: PluginServerContext) {
23
+ const settings = server.registerSettings(preferences);
17
24
  let runner: TerminalRunner | null = null;
25
+ const streams = new TerminalStreams();
18
26
  const runnerFor = (paseo: ConstructorParameters<typeof TerminalRunner>[0]) =>
19
27
  (runner ??= new TerminalRunner(paseo));
20
28
 
@@ -30,7 +38,8 @@ export default function contribute(server: PluginServerContext) {
30
38
  const cardId = await appendRunCard(paseo, agentId, [block]);
31
39
  if (!cardId) throw new Error("Command is too long");
32
40
  await runnerFor(paseo).start({ agentId, key: runKey(cardId, 0), ...block, send: true });
33
- return { cardId };
41
+ const saved = await settings.read();
42
+ return { cardId, panelLocation: saved.status === "ready" ? saved.values.panelLocation : "explorer" };
34
43
  });
35
44
 
36
45
  server.handle(scanBlocks, async ({ agentId }, { paseo }) => ({
@@ -39,8 +48,14 @@ export default function contribute(server: PluginServerContext) {
39
48
 
40
49
  server.handle(searchTerminalOutput, searchTerminals);
41
50
 
51
+ server.handle(openTerminalView, (input) => streams.open(input));
52
+ server.handle(readTerminalView, (input) => streams.read(input));
53
+ server.handle(resizeTerminalView, (input) => streams.resize(input));
54
+ server.handle(closeTerminalView, (input) => streams.close(input));
55
+
42
56
  return async () => {
43
57
  runner?.stop();
58
+ await streams.stop();
44
59
  await cleanupScripts();
45
60
  };
46
61
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "paseo-terminal-integration",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "files": [
5
5
  "paseo-plugin.json",
6
6
  "index.client.tsx",
@@ -38,5 +38,11 @@
38
38
  "paseo-plugin",
39
39
  "terminal",
40
40
  "agents"
41
- ]
41
+ ],
42
+ "dependencies": {
43
+ "@getpaseo/client": "0.10.1",
44
+ "@xterm/addon-fit": "^0.11.0",
45
+ "@xterm/addon-web-links": "^0.12.0",
46
+ "@xterm/xterm": "^6.0.0"
47
+ }
42
48
  }
@@ -2,7 +2,7 @@ import { pathToFileURL } from "node:url";
2
2
  import type { RpcInput, RpcOutput } from "@getpaseo/plugin";
3
3
  import type { PluginHandlerContext } from "@getpaseo/plugin/server";
4
4
  import type { searchTerminalOutput } from "../shared/contracts";
5
- import { fenced, trimBlankEdges, withoutRunMarkers } from "./text";
5
+ import { fenced, trimBlankEdges, withoutRunMarkers } from "../shared/text";
6
6
 
7
7
  const DEFAULT_LINES = 200;
8
8
  const MAX_LINES = 2_000;
package/server/runner.ts CHANGED
@@ -5,7 +5,7 @@ import path from "node:path";
5
5
  import type { PluginHandlerContext } from "@getpaseo/plugin/server";
6
6
  import { interpreterFor, type Interpreter } from "../shared/code-blocks";
7
7
  import { TERMINAL_NAME, type RunSnapshot } from "../shared/contracts";
8
- import { fenced, trimBlankEdges } from "./text";
8
+ import { fenced, trimBlankEdges } from "../shared/text";
9
9
 
10
10
  type Paseo = PluginHandlerContext["paseo"];
11
11
  type TerminalHandle = ReturnType<Paseo["terminals"]["ref"]>;
@@ -0,0 +1,228 @@
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
+ }
@@ -1,5 +1,6 @@
1
1
  import { defineAttachmentSource, defineRpc } from "@getpaseo/plugin";
2
2
  import { z } from "zod";
3
+ import { panelLocations } from "./settings";
3
4
 
4
5
  export const TERMINAL_NAME = "Agent commands";
5
6
 
@@ -65,7 +66,34 @@ export const sendRunOutput = defineRpc({
65
66
  export const runAdhoc = defineRpc({
66
67
  name: "run.adhoc",
67
68
  input: z.object({ agentId: z.string(), command: z.string().min(1) }),
68
- output: z.object({ cardId: z.string() }),
69
+ output: z.object({ cardId: z.string(), panelLocation: z.enum(panelLocations) }),
70
+ });
71
+
72
+ const terminalSize = { rows: z.number().int().min(2).max(500), cols: z.number().int().min(10).max(1000) };
73
+
74
+ export const openTerminalView = defineRpc({
75
+ name: "view.open",
76
+ input: z.object({ terminalId: z.string(), ...terminalSize }),
77
+ output: z.object({ viewId: z.string() }),
78
+ });
79
+
80
+ /** Long-polls for terminal output after `cursor`, which acknowledges everything before it. */
81
+ export const readTerminalView = defineRpc({
82
+ name: "view.read",
83
+ input: z.object({ viewId: z.string(), cursor: z.number().int().nonnegative() }),
84
+ output: z.object({ data: z.string(), cursor: z.number().int(), closed: z.string().nullable() }),
85
+ });
86
+
87
+ export const resizeTerminalView = defineRpc({
88
+ name: "view.resize",
89
+ input: z.object({ viewId: z.string(), ...terminalSize }),
90
+ output: z.object({}),
91
+ });
92
+
93
+ export const closeTerminalView = defineRpc({
94
+ name: "view.close",
95
+ input: z.object({ viewId: z.string() }),
96
+ output: z.object({}),
69
97
  });
70
98
 
71
99
  export const scanBlocks = defineRpc({
@@ -0,0 +1,19 @@
1
+ import { defineSettings } from "@getpaseo/plugin";
2
+ import { z } from "zod";
3
+
4
+ export const TERMINAL_PANEL_ID = "terminal";
5
+
6
+ export const panelLocations = ["explorer", "workspace"] as const;
7
+ export type PanelLocation = (typeof panelLocations)[number];
8
+
9
+ export const preferences = defineSettings({
10
+ id: "preferences",
11
+ scope: "host",
12
+ version: 1,
13
+ schema: z.object({
14
+ /** Where "Run in side terminal", the Terminal button, and /run show the terminal panel. */
15
+ panelLocation: z.enum(panelLocations).default("explorer"),
16
+ /** Initial state of a run card's "Send to agent" checkbox. */
17
+ sendToAgent: z.boolean().default(true),
18
+ }),
19
+ });
@@ -5,11 +5,14 @@ export function fenced(content: string, lang = ""): string {
5
5
  return `${fence}${lang}\n${content}\n${fence}`;
6
6
  }
7
7
 
8
- /** Removes the run markers the runner prints around command output. */
8
+ /** Matches the run markers the runner prints, concealed, around command output. */
9
+ export const RUN_MARKER = /__PTI_(?:BEGIN_[0-9a-f]+|END_[0-9a-f]+_\d+)__/g;
10
+
11
+ /** Removes the run markers from captured lines. */
9
12
  export function withoutRunMarkers(lines: readonly string[]): string[] {
10
13
  return lines
11
14
  .filter((line) => !/^\s*__PTI_BEGIN_[0-9a-f]+__\s*$/.test(line))
12
- .map((line) => line.replace(/__PTI_(?:BEGIN_[0-9a-f]+|END_[0-9a-f]+_\d+)__/g, ""));
15
+ .map((line) => line.replace(RUN_MARKER, ""));
13
16
  }
14
17
 
15
18
  export function trimBlankEdges(lines: readonly string[]): string[] {