@particle-academy/fancy-term 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
@@ -16,10 +16,13 @@ Like every Fancy UI component it serves two surfaces at once:
16
16
  Sexy by default via a Fancy dark theme drawn from the react-fancy Tailwind v4
17
17
  tokens.
18
18
 
19
- > **Status:** 0.1.0. `<Terminal>` + `useTerminal` / `useTerminalFit` /
20
- > `useTerminalSession` are in place. The `registerTerminalBridge` MCP bridge
21
- > (`terminal_read` / `terminal_write` / `terminal_run`) and the trust‑but‑verify
22
- > staged-command affordance ship next, in `@particle-academy/agent-integrations`.
19
+ > **Status:** 0.2.0. `<Terminal>` + `useTerminal` / `useTerminalFit` /
20
+ > `useTerminalSession` are in place, plus **shell / profile switching** (the
21
+ > `<ShellSwitcher>` component, controlled `shells` / `activeShell` props, and the
22
+ > session hook's `switchShell`). The `registerTerminalBridge` MCP bridge
23
+ > (`terminal_read` / `terminal_write` / `terminal_run` / `terminal_set_shell`) and
24
+ > the trust‑but‑verify staged-command affordance ship in
25
+ > `@particle-academy/agent-integrations`.
23
26
 
24
27
  ## Install
25
28
 
@@ -70,6 +73,10 @@ fancy-query's `useFancyStream`.
70
73
  | `cursorBlink` / `cursorStyle` | | `"block" \| "underline" \| "bar"` |
71
74
  | `fontFamily` / `fontSize` / `scrollback` | | |
72
75
  | `initialOutput` | `string` | written once on mount (uncontrolled use) |
76
+ | `shells` | `ShellProfile[]` | the shells / profiles the host offers (see [Switching shells](#switching-shells)) |
77
+ | `activeShell` | `string` | controlled selected shell id (omit for uncontrolled) |
78
+ | `onShellChange` | `(id, profile) => void` | fired when the user / `setShell` switches |
79
+ | `showShellBar` | `boolean` | render the `<ShellSwitcher>` toolbar above the surface (default `false`) |
73
80
 
74
81
  The ref exposes a `TerminalHandle`:
75
82
 
@@ -78,9 +85,105 @@ const term = useRef<TerminalHandle>(null);
78
85
  // term.current.write / writeln / clear / reset / fit / focus
79
86
  // term.current.getBuffer() → the visible buffer as text (what an agent "sees")
80
87
  // term.current.getSelection() → current selection
88
+ // term.current.setShell("pwsh") → switch the active shell (fires onShellChange)
89
+ // term.current.getShell() → the active shell id
81
90
  // term.current.xterm → the raw xterm.js instance (escape hatch)
82
91
  ```
83
92
 
93
+ ## Switching shells
94
+
95
+ fancy-term is a **frontend wrapper** — it never spawns a shell. It owns the
96
+ *selected-shell state, the UI, and the change events*; the **host reacts** by
97
+ reconnecting its PTY / command backend to the chosen profile. That separation
98
+ keeps the wrapper portable across any backend.
99
+
100
+ A `ShellProfile` is fully JSON-friendly (an agent can emit one):
101
+
102
+ ```ts
103
+ interface ShellProfile {
104
+ id: string; // stable key, e.g. "powershell"
105
+ label: string; // display, e.g. "PowerShell"
106
+ icon?: string; // optional short glyph / emoji / single char
107
+ command?: string; // host hint, e.g. "pwsh" / "cmd.exe"
108
+ args?: string[]; // host hint
109
+ cwd?: string; // host hint
110
+ }
111
+ ```
112
+
113
+ `command` / `args` / `cwd` are **host hints only** — fancy-term passes the choice
114
+ along; your backend decides what to launch. Spread the built-in presets and
115
+ filter to what your host actually offers:
116
+
117
+ ```ts
118
+ import { BUILTIN_SHELLS } from "@particle-academy/fancy-term";
119
+ // cmd · powershell · pwsh · git-bash · bash · zsh
120
+ const shells = BUILTIN_SHELLS.filter((s) => ["pwsh", "git-bash"].includes(s.id));
121
+ ```
122
+
123
+ ### On the `<Terminal>` (controlled)
124
+
125
+ ```tsx
126
+ const [shell, setShell] = useState("pwsh");
127
+
128
+ <div style={{ height: 360 }}>
129
+ <Terminal
130
+ output={out}
131
+ onData={(d) => backend.send(d)}
132
+ shells={shells}
133
+ activeShell={shell}
134
+ onShellChange={(id) => {
135
+ setShell(id);
136
+ backend.reconnect(id); // host reconnects its PTY to the chosen shell
137
+ }}
138
+ showShellBar // opt-in: render the built-in switcher toolbar
139
+ />
140
+ </div>
141
+ ```
142
+
143
+ The root carries `data-fancy-terminal-shell="<id>"` so an agent (or an MCP bridge)
144
+ can read the active shell without DOM-guessing. Omit `activeShell` for an
145
+ uncontrolled terminal that tracks the selection internally.
146
+
147
+ ### Standalone `<ShellSwitcher>`
148
+
149
+ Place the picker anywhere — it's a self-contained, accessible (Arrow / Enter /
150
+ Esc), Fancy-dark-themed dropdown with zero third-party deps:
151
+
152
+ ```tsx
153
+ import { ShellSwitcher } from "@particle-academy/fancy-term";
154
+
155
+ <ShellSwitcher
156
+ shells={shells}
157
+ value={shell}
158
+ onChange={(id, profile) => backend.reconnect(profile)}
159
+ />
160
+ ```
161
+
162
+ Stable handles: the root carries `data-fancy-shell-switcher`, each option carries
163
+ `data-shell-id`.
164
+
165
+ ### In the session hook
166
+
167
+ `useTerminalSession` accepts a `shell` and exposes `switchShell(id)`. Switching
168
+ **resets the buffer** and calls the transport's optional `connect(shell)` so the
169
+ host can (re)wire its backend. Transports that don't care about shells just omit
170
+ `connect` — fully backward compatible.
171
+
172
+ ```tsx
173
+ const session = useTerminalSession({
174
+ shell: "pwsh",
175
+ transport: {
176
+ send: (d) => backend.send(d),
177
+ subscribe: (onChunk) => backend.onOutput(onChunk),
178
+ connect: (shell) => backend.connect(shell), // (re)connect to the chosen shell
179
+ },
180
+ });
181
+
182
+ // later — reset + reconnect to a new shell:
183
+ session.switchShell("git-bash");
184
+ <Terminal output={session.output} onData={session.sendData} />
185
+ ```
186
+
84
187
  ## Hooks
85
188
 
86
189
  ```tsx
@@ -105,10 +208,12 @@ const session = useTerminalSession({
105
208
 
106
209
  ## Human+ contract
107
210
 
108
- `<Terminal>` is **controlled** (`value`/`onData`), carries a **stable handle**
109
- (`data-fancy-terminal` + the ref API), takes **JSON-friendly** props, and is
110
- **bridgeable** — `registerTerminalBridge` (in `agent-integrations`, shipping next)
111
- maps `terminal_read` / `terminal_write` / `terminal_run` onto the handle, wraps
211
+ `<Terminal>` is **controlled** (`value`/`onData`, `activeShell`/`onShellChange`),
212
+ carries **stable handles** (`data-fancy-terminal` + `data-fancy-terminal-shell`,
213
+ `data-fancy-shell-switcher` / `data-shell-id`, plus the ref API), takes
214
+ **JSON-friendly** props (including `ShellProfile[]`), and is **bridgeable** —
215
+ `registerTerminalBridge` (in `agent-integrations`) maps `terminal_read` /
216
+ `terminal_write` / `terminal_run` / `terminal_set_shell` onto the handle, wraps
112
217
  mutations so every write broadcasts `AgentActivity`, and supports a staged
113
218
  "agent proposes → human confirms" mode for destructive commands.
114
219
 
package/dist/index.cjs CHANGED
@@ -87,7 +87,13 @@ function useTerminal(containerRef, options = {}) {
87
87
  },
88
88
  focus: () => xtermRef.current?.focus(),
89
89
  getBuffer: () => readBuffer(xtermRef.current),
90
- getSelection: () => xtermRef.current?.getSelection() ?? ""
90
+ getSelection: () => xtermRef.current?.getSelection() ?? "",
91
+ // Shell selection is owned by the <Terminal> component layer (it needs the
92
+ // `shells` list + onShellChange). The headless engine is shell-agnostic, so
93
+ // these are no-ops here and get overridden when <Terminal> composes them.
94
+ setShell: () => {
95
+ },
96
+ getShell: () => void 0
91
97
  };
92
98
  }
93
99
  const handle = handleRef.current;
@@ -145,12 +151,231 @@ function useTerminal(containerRef, options = {}) {
145
151
  return handle;
146
152
  }
147
153
 
154
+ // src/types.ts
155
+ function resolveShell(shells, id) {
156
+ if (!shells || id === void 0) return void 0;
157
+ return shells.find((s) => s.id === id);
158
+ }
159
+ var BUILTIN_SHELLS = [
160
+ { id: "cmd", label: "Command Prompt", icon: ">_", command: "cmd.exe" },
161
+ { id: "powershell", label: "Windows PowerShell", icon: "PS", command: "powershell.exe" },
162
+ { id: "pwsh", label: "PowerShell", icon: "PS", command: "pwsh" },
163
+ {
164
+ id: "git-bash",
165
+ label: "Git Bash",
166
+ icon: "",
167
+ command: "C:\\Program Files\\Git\\bin\\bash.exe",
168
+ args: ["--login", "-i"]
169
+ },
170
+ { id: "bash", label: "Bash", icon: "$", command: "bash" },
171
+ { id: "zsh", label: "Zsh", icon: "%", command: "zsh" }
172
+ ];
173
+ var PANEL_BG = "#18181b";
174
+ var BORDER = "#3f3f46";
175
+ var FG = "#e4e4e7";
176
+ var MUTED = "#a1a1aa";
177
+ var ACCENT = "#8b5cf6";
178
+ var HOVER_BG = "#27272a";
179
+ function ShellSwitcher({
180
+ shells,
181
+ value,
182
+ onChange,
183
+ className,
184
+ style,
185
+ disabled
186
+ }) {
187
+ const [open, setOpen] = react.useState(false);
188
+ const [activeIndex, setActiveIndex] = react.useState(0);
189
+ const rootRef = react.useRef(null);
190
+ const listId = react.useId();
191
+ const selected = resolveShell(shells, value);
192
+ const selectedIndex = selected ? shells.indexOf(selected) : -1;
193
+ react.useEffect(() => {
194
+ if (!open) return;
195
+ const onDocClick = (e) => {
196
+ if (rootRef.current && !rootRef.current.contains(e.target)) setOpen(false);
197
+ };
198
+ document.addEventListener("mousedown", onDocClick);
199
+ return () => document.removeEventListener("mousedown", onDocClick);
200
+ }, [open]);
201
+ const openMenu = react.useCallback(() => {
202
+ if (disabled) return;
203
+ setActiveIndex(selectedIndex >= 0 ? selectedIndex : 0);
204
+ setOpen(true);
205
+ }, [disabled, selectedIndex]);
206
+ const pick = react.useCallback(
207
+ (i) => {
208
+ const profile = shells[i];
209
+ if (!profile) return;
210
+ onChange(profile.id, profile);
211
+ setOpen(false);
212
+ },
213
+ [shells, onChange]
214
+ );
215
+ const onButtonKeyDown = (e) => {
216
+ if (disabled) return;
217
+ if (e.key === "ArrowDown" || e.key === "ArrowUp" || e.key === "Enter" || e.key === " ") {
218
+ e.preventDefault();
219
+ openMenu();
220
+ }
221
+ };
222
+ const onListKeyDown = (e) => {
223
+ if (e.key === "Escape") {
224
+ e.preventDefault();
225
+ setOpen(false);
226
+ return;
227
+ }
228
+ if (e.key === "ArrowDown") {
229
+ e.preventDefault();
230
+ setActiveIndex((i) => Math.min(shells.length - 1, i + 1));
231
+ } else if (e.key === "ArrowUp") {
232
+ e.preventDefault();
233
+ setActiveIndex((i) => Math.max(0, i - 1));
234
+ } else if (e.key === "Home") {
235
+ e.preventDefault();
236
+ setActiveIndex(0);
237
+ } else if (e.key === "End") {
238
+ e.preventDefault();
239
+ setActiveIndex(shells.length - 1);
240
+ } else if (e.key === "Enter" || e.key === " ") {
241
+ e.preventDefault();
242
+ pick(activeIndex);
243
+ }
244
+ };
245
+ const buttonStyle = {
246
+ display: "inline-flex",
247
+ alignItems: "center",
248
+ gap: 6,
249
+ height: 26,
250
+ padding: "0 8px",
251
+ fontSize: 12,
252
+ lineHeight: 1,
253
+ fontFamily: "inherit",
254
+ color: FG,
255
+ background: PANEL_BG,
256
+ border: `1px solid ${BORDER}`,
257
+ borderRadius: 6,
258
+ cursor: disabled ? "default" : "pointer",
259
+ opacity: disabled ? 0.6 : 1,
260
+ userSelect: "none"
261
+ };
262
+ return /* @__PURE__ */ jsxRuntime.jsxs(
263
+ "div",
264
+ {
265
+ ref: rootRef,
266
+ "data-fancy-shell-switcher": "",
267
+ "data-active-shell": value ?? void 0,
268
+ className,
269
+ style: { position: "relative", display: "inline-block", ...style },
270
+ children: [
271
+ /* @__PURE__ */ jsxRuntime.jsxs(
272
+ "button",
273
+ {
274
+ type: "button",
275
+ "aria-haspopup": "listbox",
276
+ "aria-expanded": open,
277
+ "aria-label": "Select shell",
278
+ disabled,
279
+ style: buttonStyle,
280
+ onClick: () => open ? setOpen(false) : openMenu(),
281
+ onKeyDown: onButtonKeyDown,
282
+ children: [
283
+ selected?.icon ? /* @__PURE__ */ jsxRuntime.jsx("span", { "aria-hidden": "true", style: { color: ACCENT }, children: selected.icon }) : null,
284
+ /* @__PURE__ */ jsxRuntime.jsx("span", { children: selected?.label ?? "Select shell" }),
285
+ /* @__PURE__ */ jsxRuntime.jsx("span", { "aria-hidden": "true", style: { color: MUTED, fontSize: 9 }, children: "\u25BC" })
286
+ ]
287
+ }
288
+ ),
289
+ open ? /* @__PURE__ */ jsxRuntime.jsx(
290
+ "ul",
291
+ {
292
+ role: "listbox",
293
+ id: listId,
294
+ "aria-label": "Shells",
295
+ tabIndex: -1,
296
+ autoFocus: true,
297
+ onKeyDown: onListKeyDown,
298
+ ref: (el) => el?.focus(),
299
+ style: {
300
+ position: "absolute",
301
+ zIndex: 50,
302
+ top: "calc(100% + 4px)",
303
+ left: 0,
304
+ minWidth: "100%",
305
+ margin: 0,
306
+ padding: 4,
307
+ listStyle: "none",
308
+ background: PANEL_BG,
309
+ border: `1px solid ${BORDER}`,
310
+ borderRadius: 8,
311
+ boxShadow: "0 8px 24px rgba(0,0,0,0.45)",
312
+ outline: "none"
313
+ },
314
+ children: shells.map((s, i) => {
315
+ const isSelected = s.id === value;
316
+ const isActive = i === activeIndex;
317
+ return /* @__PURE__ */ jsxRuntime.jsxs(
318
+ "li",
319
+ {
320
+ role: "option",
321
+ "aria-selected": isSelected,
322
+ "data-shell-id": s.id,
323
+ onMouseEnter: () => setActiveIndex(i),
324
+ onClick: () => pick(i),
325
+ style: {
326
+ display: "flex",
327
+ alignItems: "center",
328
+ gap: 8,
329
+ padding: "6px 8px",
330
+ fontSize: 12,
331
+ borderRadius: 5,
332
+ color: FG,
333
+ cursor: "pointer",
334
+ background: isActive ? HOVER_BG : "transparent"
335
+ },
336
+ children: [
337
+ /* @__PURE__ */ jsxRuntime.jsx(
338
+ "span",
339
+ {
340
+ "aria-hidden": "true",
341
+ style: {
342
+ width: 18,
343
+ textAlign: "center",
344
+ color: isSelected ? ACCENT : MUTED
345
+ },
346
+ children: s.icon ?? ""
347
+ }
348
+ ),
349
+ /* @__PURE__ */ jsxRuntime.jsx("span", { style: { flex: 1 }, children: s.label }),
350
+ isSelected ? /* @__PURE__ */ jsxRuntime.jsx("span", { "aria-hidden": "true", style: { color: ACCENT, fontSize: 11 }, children: "\u2713" }) : null
351
+ ]
352
+ },
353
+ s.id
354
+ );
355
+ })
356
+ }
357
+ ) : null
358
+ ]
359
+ }
360
+ );
361
+ }
362
+
148
363
  // src/output-diff.ts
149
364
  function diffOutput(written, next) {
150
365
  if (next === written) return null;
151
366
  if (next.startsWith(written)) return { reset: false, write: next.slice(written.length) };
152
367
  return { reset: true, write: next };
153
368
  }
369
+
370
+ // src/shell-select.ts
371
+ function decideShellSelect(shells, current, id) {
372
+ const profile = resolveShell(shells, id);
373
+ if (!profile) return null;
374
+ return { profile, changed: current !== id };
375
+ }
376
+ function decideShellSwitch(current, next) {
377
+ return { changed: current !== next };
378
+ }
154
379
  var Terminal = react.forwardRef(function Terminal2({
155
380
  output,
156
381
  theme,
@@ -166,11 +391,27 @@ var Terminal = react.forwardRef(function Terminal2({
166
391
  initialOutput,
167
392
  onData,
168
393
  onResize,
394
+ shells,
395
+ activeShell,
396
+ onShellChange,
397
+ showShellBar = false,
169
398
  className,
170
399
  style,
171
400
  ...rest
172
401
  }, ref) {
173
402
  const containerRef = react.useRef(null);
403
+ const isShellControlled = activeShell !== void 0;
404
+ const [internalShell, setInternalShell] = react.useState(void 0);
405
+ const shellId = isShellControlled ? activeShell : internalShell;
406
+ const shellStateRef = react.useRef({ shells, shellId, isShellControlled, onShellChange });
407
+ shellStateRef.current = { shells, shellId, isShellControlled, onShellChange };
408
+ const selectShell = react.useCallback((id) => {
409
+ const s = shellStateRef.current;
410
+ const decision = decideShellSelect(s.shells, s.shellId, id);
411
+ if (!decision) return;
412
+ if (!s.isShellControlled) setInternalShell(id);
413
+ s.onShellChange?.(id, decision.profile);
414
+ }, []);
174
415
  const handle = useTerminal(containerRef, {
175
416
  theme,
176
417
  rows,
@@ -188,7 +429,15 @@ var Terminal = react.forwardRef(function Terminal2({
188
429
  onData,
189
430
  onResize
190
431
  });
191
- react.useImperativeHandle(ref, () => handle, [handle]);
432
+ react.useImperativeHandle(
433
+ ref,
434
+ () => ({
435
+ ...handle,
436
+ setShell: selectShell,
437
+ getShell: () => shellStateRef.current.shellId
438
+ }),
439
+ [handle, selectShell]
440
+ );
192
441
  const written = react.useRef("");
193
442
  react.useEffect(() => {
194
443
  if (output === void 0) return;
@@ -198,38 +447,102 @@ var Terminal = react.forwardRef(function Terminal2({
198
447
  handle.write(change.write);
199
448
  written.current = output;
200
449
  }, [output, handle]);
201
- return /* @__PURE__ */ jsxRuntime.jsx(
450
+ const surface = /* @__PURE__ */ jsxRuntime.jsx(
202
451
  "div",
203
452
  {
204
453
  ref: containerRef,
205
454
  "data-fancy-terminal": "",
206
455
  "data-readonly": readOnly ? "" : void 0,
456
+ "data-fancy-terminal-shell": shellId ?? void 0,
457
+ style: showShellBar && shells ? { width: "100%", flex: 1, minHeight: 0 } : { width: "100%", height: "100%", ...style },
458
+ ...showShellBar && shells ? {} : rest
459
+ }
460
+ );
461
+ if (!showShellBar || !shells) return surface;
462
+ return /* @__PURE__ */ jsxRuntime.jsxs(
463
+ "div",
464
+ {
465
+ "data-fancy-terminal-shell": shellId ?? void 0,
207
466
  className,
208
- style: { width: "100%", height: "100%", ...style },
209
- ...rest
467
+ style: { display: "flex", flexDirection: "column", width: "100%", height: "100%", ...style },
468
+ ...rest,
469
+ children: [
470
+ /* @__PURE__ */ jsxRuntime.jsx(
471
+ "div",
472
+ {
473
+ style: {
474
+ display: "flex",
475
+ alignItems: "center",
476
+ gap: 8,
477
+ padding: "4px 6px",
478
+ background: "#09090b",
479
+ borderBottom: "1px solid #27272a"
480
+ },
481
+ children: /* @__PURE__ */ jsxRuntime.jsx(
482
+ ShellSwitcher,
483
+ {
484
+ shells,
485
+ value: shellId,
486
+ onChange: (id) => selectShell(id),
487
+ disabled: readOnly
488
+ }
489
+ )
490
+ }
491
+ ),
492
+ surface
493
+ ]
210
494
  }
211
495
  );
212
496
  });
213
497
  function useTerminalSession(options) {
214
- const [output, setOutput] = react.useState(options.initial ?? "");
498
+ const initial = options.initial ?? "";
499
+ const [output, setOutput] = react.useState(initial);
500
+ const [internalShell, setInternalShell] = react.useState(options.shell);
501
+ const shell = options.shell !== void 0 ? options.shell : internalShell;
215
502
  const transportRef = react.useRef(options.transport);
216
503
  transportRef.current = options.transport;
504
+ const initialRef = react.useRef(initial);
505
+ initialRef.current = initial;
217
506
  react.useEffect(() => {
218
507
  const unsub = transportRef.current.subscribe((chunk) => setOutput((o) => o + chunk));
219
508
  return () => {
220
509
  if (typeof unsub === "function") unsub();
221
510
  };
222
511
  }, []);
512
+ const connectedShell = react.useRef(void 0);
513
+ const mounted = react.useRef(false);
514
+ react.useEffect(() => {
515
+ if (!mounted.current) {
516
+ mounted.current = true;
517
+ connectedShell.current = shell;
518
+ void transportRef.current.connect?.(shell);
519
+ return;
520
+ }
521
+ if (!decideShellSwitch(connectedShell.current, shell).changed) return;
522
+ connectedShell.current = shell;
523
+ setOutput(initialRef.current);
524
+ void transportRef.current.connect?.(shell);
525
+ }, [shell]);
223
526
  const sendData = react.useCallback((data) => {
224
527
  void transportRef.current.send(data);
225
528
  }, []);
226
529
  const append = react.useCallback((chunk) => setOutput((o) => o + chunk), []);
227
530
  const clear = react.useCallback(() => setOutput(""), []);
228
- return { output, sendData, append, clear };
531
+ const switchShell = react.useCallback((id) => {
532
+ setInternalShell((cur) => cur === id ? cur : id);
533
+ if (!decideShellSwitch(connectedShell.current, id).changed) return;
534
+ connectedShell.current = id;
535
+ setOutput(initialRef.current);
536
+ void transportRef.current.connect?.(id);
537
+ }, []);
538
+ return { output, sendData, append, clear, shell, switchShell };
229
539
  }
230
540
 
541
+ exports.BUILTIN_SHELLS = BUILTIN_SHELLS;
542
+ exports.ShellSwitcher = ShellSwitcher;
231
543
  exports.Terminal = Terminal;
232
544
  exports.fancyDarkTheme = fancyDarkTheme;
545
+ exports.resolveShell = resolveShell;
233
546
  exports.useTerminal = useTerminal;
234
547
  exports.useTerminalFit = useTerminalFit;
235
548
  exports.useTerminalSession = useTerminalSession;