From 90486f1c5d434e612b90008b3e19c14813053637 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Thu, 18 Jun 2026 02:59:09 +0000 Subject: [PATCH 1/7] feat(plugin-terminals): add terminals plugin with readonly + interactive PTY modes Introduce @devframes/plugin-terminals, a portable hub-native terminal panel built on the core devframe RPC + streaming surface (no hard hub dependency). It runs standalone via the CLI, mounts into a Vite host, and docks inside a hub. Two interaction modes: - readonly: a piped child process whose merged output is streamed to viewers; input is rejected. Ideal for dev servers / logs. - interactive: a real PTY (node-pty prebuilt) that accepts keystrokes and resize, so full-screen TUIs (vim, htop, Claude Code) render correctly. Falls back to a piped child process with a diagnostic when no PTY backend is present. Output streams over a per-session channel (one stream kept open for the session's life so restart reuses the same id), session metadata syncs via shared state, and spawning is allow-list gated (presets + opt-in arbitrary commands). Ships node + client + cli + vite entries plus a self-contained xterm SPA, structured DP_TERMINALS diagnostics, and an e2e test suite covering streaming, stdin, TTY detection, and SIGWINCH. --- alias.ts | 8 + plugins/terminals/bin.mjs | 13 + plugins/terminals/package.json | 76 +++ plugins/terminals/src/cli.ts | 16 + plugins/terminals/src/client/index.ts | 375 ++++++++++++ plugins/terminals/src/client/xterm-css.ts | 76 +++ plugins/terminals/src/constants.ts | 25 + plugins/terminals/src/index.ts | 65 ++ plugins/terminals/src/node/backend.ts | 207 +++++++ plugins/terminals/src/node/context.ts | 16 + plugins/terminals/src/node/diagnostics.ts | 51 ++ plugins/terminals/src/node/index.ts | 33 + plugins/terminals/src/node/manager.ts | 344 +++++++++++ plugins/terminals/src/rpc/functions/list.ts | 20 + .../terminals/src/rpc/functions/presets.ts | 23 + plugins/terminals/src/rpc/functions/remove.ts | 20 + plugins/terminals/src/rpc/functions/resize.ts | 20 + .../terminals/src/rpc/functions/restart.ts | 15 + plugins/terminals/src/rpc/functions/spawn.ts | 18 + .../terminals/src/rpc/functions/terminate.ts | 20 + plugins/terminals/src/rpc/functions/write.ts | 16 + plugins/terminals/src/rpc/index.ts | 30 + plugins/terminals/src/rpc/schemas.ts | 41 ++ plugins/terminals/src/spa/index.html | 16 + plugins/terminals/src/spa/main.ts | 9 + plugins/terminals/src/spa/vite.config.ts | 13 + plugins/terminals/src/types.ts | 111 ++++ plugins/terminals/src/vite.ts | 25 + plugins/terminals/test/_utils.ts | 121 ++++ plugins/terminals/test/terminals.test.ts | 185 ++++++ plugins/terminals/tsconfig.json | 7 + plugins/terminals/tsdown.config.ts | 64 ++ pnpm-lock.yaml | 282 ++++++++- pnpm-workspace.yaml | 5 + .../plugin-terminals/cli.snapshot.d.ts | 6 + .../plugin-terminals/cli.snapshot.js | 6 + .../plugin-terminals/client.snapshot.d.ts | 23 + .../plugin-terminals/client.snapshot.js | 10 + .../plugin-terminals/constants.snapshot.d.ts | 13 + .../plugin-terminals/constants.snapshot.js | 13 + .../plugin-terminals/index.snapshot.d.ts | 27 + .../plugin-terminals/index.snapshot.js | 19 + .../plugin-terminals/node.snapshot.d.ts | 78 +++ .../plugin-terminals/node.snapshot.js | 45 ++ .../plugin-terminals/rpc.snapshot.d.ts | 578 ++++++++++++++++++ .../plugin-terminals/rpc.snapshot.js | 6 + .../plugin-terminals/types.snapshot.d.ts | 65 ++ .../plugin-terminals/types.snapshot.js | 4 + .../plugin-terminals/vite.snapshot.d.ts | 12 + .../plugin-terminals/vite.snapshot.js | 6 + tsconfig.base.json | 21 + turbo.json | 5 + vitest.config.ts | 1 + 53 files changed, 3289 insertions(+), 15 deletions(-) create mode 100755 plugins/terminals/bin.mjs create mode 100644 plugins/terminals/package.json create mode 100644 plugins/terminals/src/cli.ts create mode 100644 plugins/terminals/src/client/index.ts create mode 100644 plugins/terminals/src/client/xterm-css.ts create mode 100644 plugins/terminals/src/constants.ts create mode 100644 plugins/terminals/src/index.ts create mode 100644 plugins/terminals/src/node/backend.ts create mode 100644 plugins/terminals/src/node/context.ts create mode 100644 plugins/terminals/src/node/diagnostics.ts create mode 100644 plugins/terminals/src/node/index.ts create mode 100644 plugins/terminals/src/node/manager.ts create mode 100644 plugins/terminals/src/rpc/functions/list.ts create mode 100644 plugins/terminals/src/rpc/functions/presets.ts create mode 100644 plugins/terminals/src/rpc/functions/remove.ts create mode 100644 plugins/terminals/src/rpc/functions/resize.ts create mode 100644 plugins/terminals/src/rpc/functions/restart.ts create mode 100644 plugins/terminals/src/rpc/functions/spawn.ts create mode 100644 plugins/terminals/src/rpc/functions/terminate.ts create mode 100644 plugins/terminals/src/rpc/functions/write.ts create mode 100644 plugins/terminals/src/rpc/index.ts create mode 100644 plugins/terminals/src/rpc/schemas.ts create mode 100644 plugins/terminals/src/spa/index.html create mode 100644 plugins/terminals/src/spa/main.ts create mode 100644 plugins/terminals/src/spa/vite.config.ts create mode 100644 plugins/terminals/src/types.ts create mode 100644 plugins/terminals/src/vite.ts create mode 100644 plugins/terminals/test/_utils.ts create mode 100644 plugins/terminals/test/terminals.test.ts create mode 100644 plugins/terminals/tsconfig.json create mode 100644 plugins/terminals/tsdown.config.ts create mode 100644 tests/__snapshots__/tsnapi/@devframes/plugin-terminals/cli.snapshot.d.ts create mode 100644 tests/__snapshots__/tsnapi/@devframes/plugin-terminals/cli.snapshot.js create mode 100644 tests/__snapshots__/tsnapi/@devframes/plugin-terminals/client.snapshot.d.ts create mode 100644 tests/__snapshots__/tsnapi/@devframes/plugin-terminals/client.snapshot.js create mode 100644 tests/__snapshots__/tsnapi/@devframes/plugin-terminals/constants.snapshot.d.ts create mode 100644 tests/__snapshots__/tsnapi/@devframes/plugin-terminals/constants.snapshot.js create mode 100644 tests/__snapshots__/tsnapi/@devframes/plugin-terminals/index.snapshot.d.ts create mode 100644 tests/__snapshots__/tsnapi/@devframes/plugin-terminals/index.snapshot.js create mode 100644 tests/__snapshots__/tsnapi/@devframes/plugin-terminals/node.snapshot.d.ts create mode 100644 tests/__snapshots__/tsnapi/@devframes/plugin-terminals/node.snapshot.js create mode 100644 tests/__snapshots__/tsnapi/@devframes/plugin-terminals/rpc.snapshot.d.ts create mode 100644 tests/__snapshots__/tsnapi/@devframes/plugin-terminals/rpc.snapshot.js create mode 100644 tests/__snapshots__/tsnapi/@devframes/plugin-terminals/types.snapshot.d.ts create mode 100644 tests/__snapshots__/tsnapi/@devframes/plugin-terminals/types.snapshot.js create mode 100644 tests/__snapshots__/tsnapi/@devframes/plugin-terminals/vite.snapshot.d.ts create mode 100644 tests/__snapshots__/tsnapi/@devframes/plugin-terminals/vite.snapshot.js diff --git a/alias.ts b/alias.ts index e1f0e42..b8897e1 100644 --- a/alias.ts +++ b/alias.ts @@ -4,6 +4,7 @@ import { join, relative } from 'pathe' const root = fileURLToPath(new URL('.', import.meta.url)) const r = (path: string) => fileURLToPath(new URL(`./packages/${path}`, import.meta.url)) +const p = (path: string) => fileURLToPath(new URL(`./plugins/${path}`, import.meta.url)) export const alias = { 'devframe/rpc/transports/ws-server': r('devframe/src/rpc/transports/ws-server.ts'), @@ -43,6 +44,13 @@ export const alias = { '@devframes/hub': r('hub/src/index.ts'), '@devframes/nuxt/runtime/plugin.client': r('nuxt/src/runtime/plugin.client.ts'), '@devframes/nuxt': r('nuxt/src/index.ts'), + '@devframes/plugin-terminals/client': p('terminals/src/client/index.ts'), + '@devframes/plugin-terminals/node': p('terminals/src/node/index.ts'), + '@devframes/plugin-terminals/constants': p('terminals/src/constants.ts'), + '@devframes/plugin-terminals/types': p('terminals/src/types.ts'), + '@devframes/plugin-terminals/cli': p('terminals/src/cli.ts'), + '@devframes/plugin-terminals/vite': p('terminals/src/vite.ts'), + '@devframes/plugin-terminals': p('terminals/src/index.ts'), 'devframe/recipes/open-helpers': r('devframe/src/recipes/open-helpers.ts'), 'devframe/client': r('devframe/src/client/index.ts'), 'devframe': r('devframe/src'), diff --git a/plugins/terminals/bin.mjs b/plugins/terminals/bin.mjs new file mode 100755 index 0000000..47ee97d --- /dev/null +++ b/plugins/terminals/bin.mjs @@ -0,0 +1,13 @@ +#!/usr/bin/env node +import process from 'node:process' +import { createTerminalsCli } from './dist/cli.mjs' + +async function main() { + const cli = createTerminalsCli() + await cli.parse() +} + +main().catch((error) => { + console.error(error) + process.exit(1) +}) diff --git a/plugins/terminals/package.json b/plugins/terminals/package.json new file mode 100644 index 0000000..f26f9b1 --- /dev/null +++ b/plugins/terminals/package.json @@ -0,0 +1,76 @@ +{ + "name": "@devframes/plugin-terminals", + "type": "module", + "version": "0.5.2", + "description": "Portable, hub-native terminal panel for devframe — readonly output streaming and fully interactive PTY shells (TUI-capable).", + "author": "Anthony Fu ", + "license": "MIT", + "homepage": "https://github.com/devframes/devframe#readme", + "repository": { + "directory": "plugins/terminals", + "type": "git", + "url": "git+https://github.com/devframes/devframe.git" + }, + "bugs": "https://github.com/devframes/devframe/issues", + "keywords": [ + "devframe", + "devtools", + "terminal", + "pty", + "xterm" + ], + "sideEffects": false, + "exports": { + ".": "./dist/index.mjs", + "./cli": "./dist/cli.mjs", + "./client": "./dist/client/index.mjs", + "./constants": "./dist/constants.mjs", + "./node": "./dist/node/index.mjs", + "./rpc": "./dist/rpc/index.mjs", + "./types": "./dist/types.mjs", + "./vite": "./dist/vite.mjs", + "./package.json": "./package.json" + }, + "types": "./dist/index.d.mts", + "bin": { + "devframe-terminals": "./bin.mjs" + }, + "files": [ + "bin.mjs", + "dist" + ], + "scripts": { + "build": "tsdown && vite build --config src/spa/vite.config.ts", + "watch": "tsdown --watch", + "dev": "node bin.mjs", + "test": "vitest run", + "prepack": "pnpm run build" + }, + "peerDependencies": { + "devframe": "workspace:*", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + }, + "dependencies": { + "@homebridge/node-pty-prebuilt-multiarch": "catalog:deps", + "@xterm/addon-fit": "catalog:frontend", + "@xterm/xterm": "catalog:frontend", + "nostics": "catalog:deps", + "pathe": "catalog:deps", + "valibot": "catalog:deps" + }, + "devDependencies": { + "@types/node": "catalog:types", + "devframe": "workspace:*", + "get-port-please": "catalog:deps", + "h3": "catalog:deps", + "tsdown": "catalog:build", + "vite": "catalog:build", + "vitest": "catalog:testing", + "ws": "catalog:deps" + } +} diff --git a/plugins/terminals/src/cli.ts b/plugins/terminals/src/cli.ts new file mode 100644 index 0000000..07b08fa --- /dev/null +++ b/plugins/terminals/src/cli.ts @@ -0,0 +1,16 @@ +import type { CliHandle, CreateCliOptions } from 'devframe/adapters/cli' +import type { TerminalsOptions } from './types' +import { createCli } from 'devframe/adapters/cli' +import { createTerminalsDevframe } from './index' + +/** + * Build a standalone CLI for the terminals panel — `dev` / `build` / `mcp` + * subcommands, backed by {@link createTerminalsDevframe}. Used by the + * package `bin`. + */ +export function createTerminalsCli( + options: TerminalsOptions = {}, + cliOptions: CreateCliOptions = {}, +): CliHandle { + return createCli(createTerminalsDevframe(options), cliOptions) +} diff --git a/plugins/terminals/src/client/index.ts b/plugins/terminals/src/client/index.ts new file mode 100644 index 0000000..a7fd41c --- /dev/null +++ b/plugins/terminals/src/client/index.ts @@ -0,0 +1,375 @@ +import type { DevframeRpcClient } from 'devframe/client' +import type { StreamReader } from 'devframe/utils/streaming-channel' +import type { TerminalPreset, TerminalSessionInfo, TerminalsSharedState } from '../types' +import { FitAddon } from '@xterm/addon-fit' +import { Terminal } from '@xterm/xterm' +import { connectDevframe } from 'devframe/client' +import { PRESETS_STATE_KEY, SESSIONS_STATE_KEY, TERMINAL_STREAM_CHANNEL } from '../constants' +import { XTERM_CSS } from './xterm-css' + +export interface MountTerminalsOptions { + /** Pre-connected client. When omitted, `connectDevframe()` is awaited. */ + rpc?: DevframeRpcClient + /** + * Auto-create an interactive shell when no session exists yet. + * @default true + */ + autostart?: boolean +} + +export interface TerminalsHandle { + rpc: DevframeRpcClient + dispose: () => void +} + +interface SessionView { + info: TerminalSessionInfo + term: Terminal + fit: FitAddon + reader: StreamReader + el: HTMLDivElement + tab: HTMLButtonElement +} + +const UI_CSS = ` +.dft-root { position: absolute; inset: 0; display: flex; flex-direction: column; + font-family: system-ui, sans-serif; background: #0b0e14; color: #c9d1d9; } +.dft-header { display: flex; align-items: stretch; gap: 4px; padding: 6px 8px; + border-bottom: 1px solid #1c2128; background: #0d1117; } +.dft-tabs { display: flex; gap: 4px; overflow-x: auto; flex: 1; align-items: center; } +.dft-tab { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; + padding: 4px 10px; border-radius: 6px; border: 1px solid transparent; background: #161b22; + color: #8b949e; font-size: 12px; cursor: pointer; } +.dft-tab:hover { color: #c9d1d9; } +.dft-tab.active { background: #21262d; color: #fff; border-color: #30363d; } +.dft-dot { width: 7px; height: 7px; border-radius: 50%; background: #3fb950; flex: none; } +.dft-dot.exited { background: #6e7681; } +.dft-dot.error { background: #f85149; } +.dft-actions { display: flex; gap: 6px; align-items: center; } +.dft-btn { padding: 4px 10px; border-radius: 6px; border: 1px solid #30363d; + background: #21262d; color: #c9d1d9; font-size: 12px; cursor: pointer; } +.dft-btn:hover { background: #30363d; } +.dft-btn:disabled { opacity: 0.45; cursor: default; } +.dft-select { padding: 4px 8px; border-radius: 6px; border: 1px solid #30363d; + background: #21262d; color: #c9d1d9; font-size: 12px; } +.dft-toolbar { display: flex; align-items: center; gap: 8px; padding: 4px 10px; + border-bottom: 1px solid #1c2128; font-size: 12px; color: #8b949e; min-height: 20px; } +.dft-badge { padding: 1px 7px; border-radius: 10px; font-size: 10px; text-transform: uppercase; + letter-spacing: 0.03em; border: 1px solid #30363d; } +.dft-badge.interactive { color: #58a6ff; border-color: #1f6feb55; } +.dft-badge.readonly { color: #d29922; border-color: #9e6a0355; } +.dft-spacer { flex: 1; } +.dft-body { position: relative; flex: 1; overflow: hidden; background: #000; } +.dft-view { position: absolute; inset: 0; padding: 4px; display: none; } +.dft-view.active { display: block; } +.dft-empty { position: absolute; inset: 0; display: flex; align-items: center; + justify-content: center; color: #6e7681; font-size: 13px; pointer-events: none; } +.dft-view .xterm, .dft-view .xterm-viewport, .dft-view .xterm-screen { height: 100%; } +.dft-mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; color: #c9d1d9; } +` + +const THEME = { + background: '#000000', + foreground: '#c9d1d9', + cursor: '#58a6ff', + selectionBackground: '#234876', +} + +let stylesInjected = false +function injectStyles(): void { + if (stylesInjected || typeof document === 'undefined') + return + stylesInjected = true + const style = document.createElement('style') + style.textContent = XTERM_CSS + UI_CSS + document.head.appendChild(style) +} + +function el( + tag: K, + className?: string, +): HTMLElementTagNameMap[K] { + const node = document.createElement(tag) + if (className) + node.className = className + return node +} + +/** + * Mount the xterm-powered terminals UI into `container`. Renders one tab + + * xterm instance per session, streams output from the + * `devframes-plugin-terminals:output` channel, forwards keystrokes/resize for + * interactive sessions, and disables input for readonly ones. + * + * Usable both by the standalone SPA and as a hub `custom-render` renderer. + */ +export async function mountTerminals( + container: HTMLElement, + options: MountTerminalsOptions = {}, +): Promise { + injectStyles() + const rpc = options.rpc ?? (await connectDevframe()) as unknown as DevframeRpcClient + + const root = el('div', 'dft-root') + const header = el('div', 'dft-header') + const tabs = el('div', 'dft-tabs') + const actions = el('div', 'dft-actions') + const presetSelect = el('select', 'dft-select') + const newShellBtn = el('button', 'dft-btn') + newShellBtn.textContent = '+ Shell' + actions.append(presetSelect, newShellBtn) + header.append(tabs, actions) + + const toolbar = el('div', 'dft-toolbar') + const body = el('div', 'dft-body') + const empty = el('div', 'dft-empty') + empty.textContent = 'No terminal sessions — start one above.' + body.append(empty) + + root.append(header, toolbar, body) + container.append(root) + + const views = new Map() + let activeId: string | null = null + let presets: TerminalPreset[] = [] + let disposed = false + + function spawn(req: Parameters[1]): void { + rpc.call('devframes-plugin-terminals:spawn', req as any).catch(() => {}) + } + + newShellBtn.onclick = () => spawn({ mode: 'interactive' }) + + presetSelect.onchange = () => { + const id = presetSelect.value + presetSelect.value = '' + if (id) + spawn({ presetId: id }) + } + + function renderPresets(): void { + presetSelect.replaceChildren() + const placeholder = el('option') + placeholder.value = '' + placeholder.textContent = presets.length ? 'Run preset…' : 'No presets' + presetSelect.append(placeholder) + presetSelect.disabled = presets.length === 0 + for (const preset of presets) { + const opt = el('option') + opt.value = preset.id + opt.textContent = preset.title + presetSelect.append(opt) + } + } + + function fitActive(): void { + if (!activeId) + return + const view = views.get(activeId) + if (!view) + return + try { + view.fit.fit() + } + catch { + // Container not measurable yet. + } + } + + function setActive(id: string | null): void { + activeId = id + for (const [vid, view] of views) { + const active = vid === id + view.el.classList.toggle('active', active) + view.tab.classList.toggle('active', active) + if (active) { + requestAnimationFrame(() => { + fitActive() + view.term.focus() + }) + } + } + renderToolbar() + } + + function renderToolbar(): void { + toolbar.replaceChildren() + const view = activeId ? views.get(activeId) : undefined + if (!view) + return + const { info } = view + + const badge = el('span', `dft-badge ${info.mode}`) + badge.textContent = info.mode + const label = el('span', 'dft-mono') + label.textContent = `${info.command}${info.args.length ? ` ${info.args.join(' ')}` : ''}` + const status = el('span') + status.textContent = info.status === 'running' + ? `running · ${info.backend}${info.pid ? ` · pid ${info.pid}` : ''}` + : `${info.status}${info.exitCode != null ? ` (${info.exitCode})` : ''}` + + const spacer = el('div', 'dft-spacer') + + const restartBtn = el('button', 'dft-btn') + restartBtn.textContent = 'Restart' + restartBtn.onclick = () => rpc.call('devframes-plugin-terminals:restart', { id: info.id }).catch(() => {}) + + const clearBtn = el('button', 'dft-btn') + clearBtn.textContent = 'Clear' + clearBtn.onclick = () => view.term.clear() + + const killBtn = el('button', 'dft-btn') + killBtn.textContent = 'Kill' + killBtn.onclick = () => rpc.call('devframes-plugin-terminals:remove', { id: info.id }).catch(() => {}) + + toolbar.append(badge, label, status, spacer, restartBtn, clearBtn, killBtn) + } + + function createView(info: TerminalSessionInfo): SessionView { + const viewEl = el('div', 'dft-view') + body.append(viewEl) + + const term = new Terminal({ + cursorBlink: true, + fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', + fontSize: 13, + scrollback: 10000, + theme: THEME, + disableStdin: info.mode !== 'interactive', + allowProposedApi: false, + }) + const fit = new FitAddon() + term.loadAddon(fit) + term.open(viewEl) + + if (info.mode === 'interactive') { + term.onData((data) => { + rpc.call('devframes-plugin-terminals:write', { id: info.id, data }).catch(() => {}) + }) + } + term.onResize(({ cols, rows }) => { + rpc.call('devframes-plugin-terminals:resize', { id: info.id, cols, rows }).catch(() => {}) + }) + + const reader = rpc.streaming.subscribe(TERMINAL_STREAM_CHANNEL, info.id) + ;(async () => { + try { + for await (const chunk of reader) + term.write(chunk) + } + catch { + // Stream ended/errored; the session view stays for scrollback. + } + })() + + const tab = el('button', 'dft-tab') + tab.onclick = () => setActive(info.id) + + requestAnimationFrame(() => { + try { + fit.fit() + } + catch {} + }) + + return { info, term, fit, reader, el: viewEl, tab } + } + + function disposeView(view: SessionView): void { + view.reader.cancel() + view.term.dispose() + view.el.remove() + view.tab.remove() + } + + function renderTabs(): void { + for (const view of views.values()) { + view.tab.replaceChildren() + const dot = el('span', `dft-dot ${view.info.status === 'running' ? '' : view.info.status}`) + const label = el('span') + label.textContent = view.info.title + view.tab.append(dot, label) + if (view.tab.parentElement !== tabs) + tabs.append(view.tab) + } + } + + function syncSessions(sessions: TerminalSessionInfo[]): void { + if (disposed) + return + const seen = new Set() + for (const info of sessions) { + seen.add(info.id) + const existing = views.get(info.id) + if (existing) { + existing.info = info + } + else { + views.set(info.id, createView(info)) + } + } + for (const [id, view] of views) { + if (!seen.has(id)) { + disposeView(view) + views.delete(id) + } + } + + empty.style.display = views.size ? 'none' : 'flex' + + if (activeId && !views.has(activeId)) + activeId = null + if (!activeId && views.size) + activeId = sessions[sessions.length - 1]?.id ?? views.keys().next().value ?? null + + renderTabs() + setActive(activeId) + renderToolbar() + } + + // Bind shared state for sessions + presets. + const sessionsState = await rpc.sharedState.get(SESSIONS_STATE_KEY, { + initialValue: { sessions: [] } as TerminalsSharedState, + }) + const presetsState = await rpc.sharedState.get(PRESETS_STATE_KEY, { + initialValue: { presets: [] } as { presets: TerminalPreset[] }, + }) + + presets = (presetsState.value() as { presets: TerminalPreset[] }).presets ?? [] + renderPresets() + const offPresets = presetsState.on('updated', (full: { presets: TerminalPreset[] }) => { + presets = full.presets ?? [] + renderPresets() + }) + + syncSessions((sessionsState.value() as TerminalsSharedState).sessions ?? []) + const offSessions = sessionsState.on('updated', (full: TerminalsSharedState) => { + syncSessions(full.sessions ?? []) + }) + + // Auto-create an interactive shell when nothing is running yet. + if (options.autostart !== false && views.size === 0) + spawn({ mode: 'interactive' }) + + const resizeObserver = typeof ResizeObserver !== 'undefined' + ? new ResizeObserver(() => fitActive()) + : undefined + resizeObserver?.observe(body) + + return { + rpc, + dispose() { + disposed = true + offSessions?.() + offPresets?.() + resizeObserver?.disconnect() + for (const view of views.values()) + disposeView(view) + views.clear() + root.remove() + }, + } +} + +export { TERMINAL_STREAM_CHANNEL } from '../constants' +export type { TerminalPreset, TerminalSessionInfo } from '../types' diff --git a/plugins/terminals/src/client/xterm-css.ts b/plugins/terminals/src/client/xterm-css.ts new file mode 100644 index 0000000..8ca1167 --- /dev/null +++ b/plugins/terminals/src/client/xterm-css.ts @@ -0,0 +1,76 @@ +/** + * xterm.js base stylesheet, inlined so the renderer is self-contained and + * needs no build-time CSS import. Sourced from `@xterm/xterm/css/xterm.css` + * (MIT, (c) the xterm.js authors). + */ +export const XTERM_CSS = ` +.xterm { + cursor: text; + position: relative; + user-select: none; + -ms-user-select: none; + -webkit-user-select: none; +} +.xterm.focus, +.xterm:focus { outline: none; } +.xterm .xterm-helpers { position: absolute; top: 0; z-index: 5; } +.xterm .xterm-helper-textarea { + padding: 0; border: 0; margin: 0; position: absolute; opacity: 0; + left: -9999em; top: 0; width: 0; height: 0; z-index: -5; + white-space: nowrap; overflow: hidden; resize: none; +} +.xterm .composition-view { + background: #000; color: #FFF; display: none; position: absolute; + white-space: nowrap; z-index: 1; +} +.xterm .composition-view.active { display: block; } +.xterm .xterm-viewport { + background-color: #000; overflow-y: scroll; cursor: default; + position: absolute; right: 0; left: 0; top: 0; bottom: 0; +} +.xterm .xterm-screen { position: relative; } +.xterm .xterm-screen canvas { position: absolute; left: 0; top: 0; } +.xterm-char-measure-element { + display: inline-block; visibility: hidden; position: absolute; + top: 0; left: -9999em; line-height: normal; +} +.xterm.enable-mouse-events { cursor: default; } +.xterm.xterm-cursor-pointer, +.xterm .xterm-cursor-pointer { cursor: pointer; } +.xterm.column-select.focus { cursor: crosshair; } +.xterm .xterm-accessibility:not(.debug), +.xterm .xterm-message { + position: absolute; left: 0; top: 0; bottom: 0; right: 0; + z-index: 10; color: transparent; pointer-events: none; +} +.xterm .xterm-accessibility-tree:not(.debug) *::selection { color: transparent; } +.xterm .xterm-accessibility-tree { font-family: monospace; user-select: text; white-space: pre; } +.xterm .xterm-accessibility-tree > div { transform-origin: left; width: fit-content; } +.xterm .live-region { + position: absolute; left: -9999px; width: 1px; height: 1px; overflow: hidden; +} +.xterm-dim { opacity: 1 !important; } +.xterm-underline-1 { text-decoration: underline; } +.xterm-underline-2 { text-decoration: double underline; } +.xterm-underline-3 { text-decoration: wavy underline; } +.xterm-underline-4 { text-decoration: dotted underline; } +.xterm-underline-5 { text-decoration: dashed underline; } +.xterm-overline { text-decoration: overline; } +.xterm-overline.xterm-underline-1 { text-decoration: overline underline; } +.xterm-overline.xterm-underline-2 { text-decoration: overline double underline; } +.xterm-overline.xterm-underline-3 { text-decoration: overline wavy underline; } +.xterm-overline.xterm-underline-4 { text-decoration: overline dotted underline; } +.xterm-overline.xterm-underline-5 { text-decoration: overline dashed underline; } +.xterm-strikethrough { text-decoration: line-through; } +.xterm-screen .xterm-decoration-container .xterm-decoration { z-index: 6; position: absolute; } +.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer { z-index: 7; } +.xterm-decoration-overview-ruler { z-index: 8; position: absolute; top: 0; right: 0; pointer-events: none; } +.xterm-decoration-top { z-index: 2; position: relative; } +.xterm .xterm-scrollable-element > .scrollbar { cursor: default; } +.xterm .xterm-scrollable-element > .scrollbar > .scra { cursor: pointer; font-size: 11px !important; } +.xterm .xterm-scrollable-element > .visible { + opacity: 1; background: rgba(0,0,0,0); transition: opacity 100ms linear; z-index: 11; +} +.xterm .xterm-scrollable-element > .invisible { opacity: 0; pointer-events: none; } +.xterm .xterm-scrollable-element > .invisible.fade { transition: opacity 800ms linear; } +` diff --git a/plugins/terminals/src/constants.ts b/plugins/terminals/src/constants.ts new file mode 100644 index 0000000..26ff847 --- /dev/null +++ b/plugins/terminals/src/constants.ts @@ -0,0 +1,25 @@ +/** Stable devframe id for the terminals plugin. */ +export const PLUGIN_ID = 'devframes-plugin-terminals' + +/** + * Streaming channel carrying terminal output. Each session is a stream + * keyed by the session id, so clients subscribe by id the moment they + * see a session in the shared-state list. + */ +export const TERMINAL_STREAM_CHANNEL = 'devframes-plugin-terminals:output' + +/** Shared-state key holding the serializable session list. */ +export const SESSIONS_STATE_KEY = 'devframes-plugin-terminals:sessions' + +/** Shared-state key holding the spawnable command presets. */ +export const PRESETS_STATE_KEY = 'devframes-plugin-terminals:presets' + +/** Default dev-server port for the standalone CLI. */ +export const DEFAULT_PORT = 9011 + +/** Default number of output chunks retained for replay on reconnect. */ +export const DEFAULT_SCROLLBACK = 5000 + +/** Default terminal geometry before the client reports its real size. */ +export const DEFAULT_COLS = 80 +export const DEFAULT_ROWS = 24 diff --git a/plugins/terminals/src/index.ts b/plugins/terminals/src/index.ts new file mode 100644 index 0000000..9f48892 --- /dev/null +++ b/plugins/terminals/src/index.ts @@ -0,0 +1,65 @@ +import type { DevframeDefinition } from 'devframe/types' +import type { TerminalsOptions } from './types' +import { fileURLToPath } from 'node:url' +import { defineDevframe } from 'devframe/types' +import { + DEFAULT_PORT, + PLUGIN_ID, + PRESETS_STATE_KEY, + SESSIONS_STATE_KEY, + TERMINAL_STREAM_CHANNEL, +} from './constants' + +export type * from './types' +export { + DEFAULT_PORT, + PLUGIN_ID, + PRESETS_STATE_KEY, + SESSIONS_STATE_KEY, + TERMINAL_STREAM_CHANNEL, +} + +/** + * Build a {@link DevframeDefinition} for the terminals panel. The same + * definition runs standalone (`createCli`), mounts into a Vite host + * (`/vite`), or docks inside a hub — its `setup` only relies on the core + * devframe RPC surface. + * + * @example + * ```ts + * import { createTerminalsDevframe } from '@devframes/plugin-terminals' + * + * export default createTerminalsDevframe({ + * presets: [{ id: 'dev', title: 'pnpm dev', command: 'pnpm', args: ['dev'] }], + * }) + * ``` + */ +export function createTerminalsDevframe(options: TerminalsOptions = {}): DevframeDefinition { + const distDir = options.distDir ?? fileURLToPath(new URL('../dist/spa', import.meta.url)) + + return defineDevframe({ + id: PLUGIN_ID, + name: 'Terminals', + icon: 'ph:terminal-window-duotone', + // Leave undefined so `resolveBasePath` picks `/` standalone and + // `/__/` when hosted. Authors override via `options.basePath`. + basePath: options.basePath, + cli: { + command: options.command ?? 'devframe-terminals', + port: options.port ?? DEFAULT_PORT, + distDir, + // Single-user localhost tool: auto-trust the connection so streaming + // and shared-state sync work without an auth round-trip. + auth: false, + }, + spa: { loader: 'none' }, + async setup(ctx) { + const { setupTerminals } = await import('./node/index') + await setupTerminals(ctx, options) + }, + }) +} + +/** Default-configured terminals devframe (interactive shell, no presets). */ +const terminals: DevframeDefinition = createTerminalsDevframe() +export default terminals diff --git a/plugins/terminals/src/node/backend.ts b/plugins/terminals/src/node/backend.ts new file mode 100644 index 0000000..ce32720 --- /dev/null +++ b/plugins/terminals/src/node/backend.ts @@ -0,0 +1,207 @@ +import type { Buffer } from 'node:buffer' +import type { TerminalBackend } from '../types' +import { spawn as spawnChild } from 'node:child_process' +import { diagnostics } from './diagnostics' + +/** + * Minimal surface the manager needs from a running terminal process, + * abstracting over a real PTY and a piped child process. Kept local so the + * plugin's public types never hard-depend on the optional native module. + */ +export interface TerminalProcess { + readonly pid: number | undefined + readonly backend: TerminalBackend + write: (data: string) => void + resize: (cols: number, rows: number) => void + kill: (signal?: string) => void + onData: (cb: (data: string) => void) => void + onExit: (cb: (exitCode: number) => void) => void +} + +export interface SpawnBackendOptions { + command: string + args: string[] + cwd: string + env: Record + cols: number + rows: number + /** When true, stdin is wired (interactive). Readonly sessions leave it closed. */ + input: boolean +} + +interface PtyModule { + spawn: (file: string, args: string[], options: { + name?: string + cols?: number + rows?: number + cwd?: string + env?: Record + }) => PtyProcess +} + +interface PtyProcess { + pid: number + onData: (cb: (data: string) => void) => void + onExit: (cb: (e: { exitCode: number, signal?: number }) => void) => void + write: (data: string) => void + resize: (cols: number, rows: number) => void + kill: (signal?: string) => void +} + +let ptyModulePromise: Promise | undefined + +/** + * Lazily load the optional PTY backend. Resolves to `undefined` when the + * native module is missing or fails to load, letting interactive sessions + * degrade to a piped child process. + */ +async function loadPty(): Promise { + ptyModulePromise ??= (async () => { + try { + const mod = await import('@homebridge/node-pty-prebuilt-multiarch') + const candidate = ((mod as any).default ?? mod) as PtyModule + return typeof candidate?.spawn === 'function' ? candidate : undefined + } + catch { + return undefined + } + })() + return ptyModulePromise +} + +/** Whether the PTY backend is available in this runtime. */ +export async function isPtyAvailable(): Promise { + return (await loadPty()) !== undefined +} + +/** Spawn a real PTY. Returns `undefined` when the backend is unavailable. */ +export async function spawnPty(options: SpawnBackendOptions): Promise { + const pty = await loadPty() + if (!pty) + return undefined + + let proc: PtyProcess + try { + proc = pty.spawn(options.command, options.args, { + name: 'xterm-256color', + cols: options.cols, + rows: options.rows, + cwd: options.cwd, + env: options.env, + }) + } + catch (error) { + diagnostics.DP_TERMINALS_0004( + { command: options.command, reason: error instanceof Error ? error.message : String(error) }, + { method: 'warn' }, + ) + return undefined + } + + return { + backend: 'pty', + get pid() { return proc.pid }, + write: (data) => { + try { + proc.write(data) + } + catch { + // Process already gone. + } + }, + resize: (cols, rows) => { + try { + proc.resize(Math.max(1, cols), Math.max(1, rows)) + } + catch { + // Resize after exit is a no-op. + } + }, + kill: (signal) => { + try { + proc.kill(signal) + } + catch { + // Already dead. + } + }, + onData: cb => proc.onData(cb), + onExit: cb => proc.onExit(e => cb(e.exitCode ?? 0)), + } +} + +/** + * Spawn a piped child process. Used for readonly sessions and as the + * interactive fallback when no PTY backend is present. stdout/stderr are + * merged into a single ordered text stream. + */ +export function spawnPipe(options: SpawnBackendOptions): TerminalProcess { + const dataCbs: ((data: string) => void)[] = [] + const exitCbs: ((code: number) => void)[] = [] + let exited = false + + const emitData = (data: string): void => { + for (const cb of dataCbs) cb(data) + } + const emitExit = (code: number): void => { + if (exited) + return + exited = true + for (const cb of exitCbs) cb(code) + } + + const child = spawnChild(options.command, options.args, { + cwd: options.cwd, + env: options.env, + stdio: [options.input ? 'pipe' : 'ignore', 'pipe', 'pipe'], + windowsHide: true, + }) + + child.stdout?.on('data', (chunk: Buffer) => emitData(chunk.toString('utf8'))) + child.stderr?.on('data', (chunk: Buffer) => emitData(chunk.toString('utf8'))) + child.on('error', (error) => { + emitData(`\r\n[failed to start: ${error.message}]\r\n`) + emitExit(1) + }) + child.on('exit', code => emitExit(code ?? 0)) + + return { + backend: 'pipe', + get pid() { return child.pid }, + write: (data) => { + if (options.input && child.stdin && !child.stdin.destroyed) + child.stdin.write(data) + }, + resize: () => { + // A piped child has no controlling TTY to resize. + }, + kill: (signal) => { + try { + child.kill((signal as NodeJS.Signals) ?? 'SIGTERM') + } + catch { + // Already dead. + } + }, + onData: cb => dataCbs.push(cb), + onExit: cb => exitCbs.push(cb), + } +} + +/** + * Spawn the most capable backend for the requested interaction. Interactive + * sessions prefer a real PTY (for TUIs); readonly sessions and the no-PTY + * fallback use a piped child process. + */ +export async function spawnBackend( + options: SpawnBackendOptions, + preferPty: boolean, +): Promise { + if (preferPty) { + const pty = await spawnPty(options) + if (pty) + return pty + diagnostics.DP_TERMINALS_0005({}, { method: 'warn' }) + } + return spawnPipe(options) +} diff --git a/plugins/terminals/src/node/context.ts b/plugins/terminals/src/node/context.ts new file mode 100644 index 0000000..0a5a157 --- /dev/null +++ b/plugins/terminals/src/node/context.ts @@ -0,0 +1,16 @@ +import type { DevframeNodeContext } from 'devframe/types' +import type { TerminalManager } from './manager' +import { diagnostics } from './diagnostics' + +const managers = new WeakMap() + +export function setTerminalManager(ctx: DevframeNodeContext, manager: TerminalManager): void { + managers.set(ctx, manager) +} + +export function getTerminalManager(ctx: DevframeNodeContext): TerminalManager { + const manager = managers.get(ctx) + if (!manager) + throw diagnostics.DP_TERMINALS_0007({}) + return manager +} diff --git a/plugins/terminals/src/node/diagnostics.ts b/plugins/terminals/src/node/diagnostics.ts new file mode 100644 index 0000000..b16cd79 --- /dev/null +++ b/plugins/terminals/src/node/diagnostics.ts @@ -0,0 +1,51 @@ +import type { Diagnostic } from 'nostics' +import { colors as c } from 'devframe/utils/colors' +import { defineDiagnostics } from 'nostics' +import { ansiFormatter } from 'nostics/formatters/ansi' + +const formatAnsi = ansiFormatter(c) + +interface ReporterOptions { method?: 'log' | 'warn' | 'error' } + +function reporter(d: Diagnostic, { method = 'warn' }: ReporterOptions = {}): void { + // eslint-disable-next-line no-console + console[method](formatAnsi(d)) +} + +/** + * Structured diagnostics for the terminals plugin. Uses the plugin's own + * `DP_TERMINALS_` prefix per the built-in plugin convention, keeping it + * collision-free with devframe core (`DF`) and the hub (`DF8xxx`). + */ +export const diagnostics = defineDiagnostics({ + docsBase: 'https://devfra.me/errors', + reporters: [reporter], + codes: { + DP_TERMINALS_0001: { + why: (p: { id: string }) => `Terminal session "${p.id}" does not exist`, + fix: 'Spawn a session first, or refresh the session list.', + }, + DP_TERMINALS_0002: { + why: (p: { command: string }) => `Spawning the arbitrary command "${p.command}" is not allowed`, + fix: 'Add it to `presets`, or pass `allowArbitraryCommands: true` to createTerminalsDevframe().', + }, + DP_TERMINALS_0003: { + why: (p: { id: string }) => `Cannot write to read-only terminal session "${p.id}"`, + fix: 'Spawn the session with `mode: "interactive"` to accept input.', + }, + DP_TERMINALS_0004: { + why: (p: { command: string, reason: string }) => `Failed to spawn "${p.command}": ${p.reason}`, + }, + DP_TERMINALS_0005: { + why: 'PTY backend (@homebridge/node-pty-prebuilt-multiarch) is unavailable; interactive sessions fall back to a piped child process. Full-screen TUIs may not render correctly.', + fix: 'Install @homebridge/node-pty-prebuilt-multiarch to enable real pseudo-terminals.', + }, + DP_TERMINALS_0006: { + why: (p: { id: string }) => `Unknown terminal preset "${p.id}"`, + }, + DP_TERMINALS_0007: { + why: 'Terminals manager is not initialised on this context', + fix: 'Call setupTerminals(ctx) (or use createTerminalsDevframe) before invoking terminal RPCs.', + }, + }, +}) diff --git a/plugins/terminals/src/node/index.ts b/plugins/terminals/src/node/index.ts new file mode 100644 index 0000000..2dfce74 --- /dev/null +++ b/plugins/terminals/src/node/index.ts @@ -0,0 +1,33 @@ +import type { DevframeNodeContext } from 'devframe/types' +import type { TerminalsOptions } from '../types' +import { serverFunctions } from '../rpc/index' +import { setTerminalManager } from './context' +import { TerminalManager } from './manager' + +export { isPtyAvailable } from './backend' +export * from './context' +export { diagnostics } from './diagnostics' +export { TerminalManager } from './manager' + +/** + * Wire the terminals subsystem onto a devframe node context: create the + * {@link TerminalManager}, publish presets + the session list into shared + * state, and register the control RPC functions. Returns the manager so + * callers can spawn sessions or dispose it on shutdown. + * + * Works in any devframe runtime (CLI, Vite, build) — it only depends on the + * core `ctx.rpc` streaming + shared-state surface, not on the hub. + */ +export async function setupTerminals( + ctx: DevframeNodeContext, + options: TerminalsOptions = {}, +): Promise { + const manager = new TerminalManager(ctx, options) + setTerminalManager(ctx, manager) + await manager.init() + + for (const fn of serverFunctions) + ctx.rpc.register(fn) + + return manager +} diff --git a/plugins/terminals/src/node/manager.ts b/plugins/terminals/src/node/manager.ts new file mode 100644 index 0000000..0db654c --- /dev/null +++ b/plugins/terminals/src/node/manager.ts @@ -0,0 +1,344 @@ +import type { DevframeNodeContext, RpcStreamingChannel } from 'devframe/types' +import type { SharedState } from 'devframe/utils/shared-state' +import type { StreamSink } from 'devframe/utils/streaming-channel' +import type { + SpawnRequest, + TerminalMode, + TerminalPreset, + TerminalSessionInfo, + TerminalsOptions, + TerminalsSharedState, +} from '../types' +import type { TerminalProcess } from './backend' +import process from 'node:process' +import { nanoid } from 'devframe/utils/nanoid' +import { + DEFAULT_COLS, + DEFAULT_ROWS, + DEFAULT_SCROLLBACK, + PRESETS_STATE_KEY, + SESSIONS_STATE_KEY, + TERMINAL_STREAM_CHANNEL, +} from '../constants' +import { isPtyAvailable, spawnBackend } from './backend' +import { diagnostics } from './diagnostics' + +interface ResolvedSpawn { + command: string + args: string[] + cwd: string + mode: TerminalMode + env: Record + title: string + cols: number + rows: number + presetId?: string +} + +interface ManagedSession { + info: TerminalSessionInfo + sink: StreamSink + spawn: ResolvedSpawn + proc?: TerminalProcess +} + +function defaultShell(): string { + if (process.platform === 'win32') + return process.env.COMSPEC || 'powershell.exe' + return process.env.SHELL || 'bash' +} + +/** + * Owns terminal session lifecycle: spawns PTY / piped backends, streams + * their output over the `devframes-plugin-terminals:output` channel (one + * stream per session, stable for the session's whole life so restarts reuse + * the same id), and mirrors a serializable session list into shared state. + */ +export class TerminalManager { + readonly shell: string + readonly shellArgs: string[] + readonly defaultCwd: string + readonly defaultMode: TerminalMode + readonly allowArbitraryCommands: boolean + readonly presets: TerminalPreset[] + + private channel: RpcStreamingChannel + private sessionsState?: SharedState + private sessions = new Map() + private ptyAvailable = false + + constructor( + private ctx: DevframeNodeContext, + private options: TerminalsOptions = {}, + ) { + this.shell = options.shell ?? defaultShell() + this.shellArgs = options.shellArgs ?? [] + this.defaultCwd = options.cwd ?? ctx.cwd + this.defaultMode = options.defaultMode ?? 'interactive' + this.allowArbitraryCommands = options.allowArbitraryCommands ?? false + this.presets = options.presets ?? [] + this.channel = ctx.rpc.streaming.create(TERMINAL_STREAM_CHANNEL, { + replayWindow: options.scrollback ?? DEFAULT_SCROLLBACK, + }) + } + + /** Resolve shared state, probe the PTY backend, publish the preset catalog. */ + async init(): Promise { + if (this.sessionsState) + return + this.ptyAvailable = await isPtyAvailable() + this.sessionsState = await this.ctx.rpc.sharedState.get(SESSIONS_STATE_KEY, { + initialValue: { sessions: [] }, + }) + const presetsState = await this.ctx.rpc.sharedState.get(PRESETS_STATE_KEY, { + initialValue: { presets: [] }, + }) + presetsState.mutate((draft: any) => { + draft.presets = this.presets.map(p => ({ + id: p.id, + title: p.title, + command: p.command, + args: p.args ?? [], + mode: p.mode ?? 'readonly', + icon: p.icon, + })) + }) + } + + list(): TerminalSessionInfo[] { + return Array.from(this.sessions.values()).map(s => ({ ...s.info })) + } + + getPresets(): TerminalPreset[] { + return this.presets.map(p => ({ ...p })) + } + + private buildEnv(extra?: Record): Record { + const base: Record = {} + for (const [k, v] of Object.entries(process.env)) { + if (v !== undefined) + base[k] = v + } + return { + ...base, + TERM: 'xterm-256color', + COLORTERM: 'truecolor', + FORCE_COLOR: '1', + ...this.options.env, + ...extra, + } + } + + private resolveSpawn(req: SpawnRequest): ResolvedSpawn { + const cols = req.cols ?? DEFAULT_COLS + const rows = req.rows ?? DEFAULT_ROWS + + if (req.presetId) { + const preset = this.presets.find(p => p.id === req.presetId) + if (!preset) + throw diagnostics.DP_TERMINALS_0006({ id: req.presetId }) + return { + command: preset.command, + args: req.args ?? preset.args ?? [], + cwd: req.cwd ?? preset.cwd ?? this.defaultCwd, + mode: req.mode ?? preset.mode ?? 'readonly', + env: this.buildEnv({ ...preset.env, ...req.env }), + title: req.title ?? preset.title, + cols, + rows, + presetId: preset.id, + } + } + + if (req.command) { + if (!this.allowArbitraryCommands && req.command !== this.shell) + throw diagnostics.DP_TERMINALS_0002({ command: req.command }) + return { + command: req.command, + args: req.args ?? [], + cwd: req.cwd ?? this.defaultCwd, + mode: req.mode ?? 'interactive', + env: this.buildEnv(req.env), + title: req.title ?? req.command, + cols, + rows, + } + } + + // Default: an interactive shell. + return { + command: this.shell, + args: this.shellArgs, + cwd: req.cwd ?? this.defaultCwd, + mode: req.mode ?? this.defaultMode, + env: this.buildEnv(req.env), + title: req.title ?? 'Shell', + cols, + rows, + } + } + + /** + * Spawn a session and return its descriptor immediately. The OS process + * is launched in the background and streams into the session's stream as + * soon as it produces output; clients can subscribe by id right away. + */ + spawn(req: SpawnRequest = {}): TerminalSessionInfo { + const spawn = this.resolveSpawn(req) + const id = nanoid() + const usePty = spawn.mode === 'interactive' && this.ptyAvailable + + const sink = this.channel.start({ id }) + const info: TerminalSessionInfo = { + id, + title: spawn.title, + mode: spawn.mode, + status: 'running', + backend: usePty ? 'pty' : 'pipe', + command: spawn.command, + args: spawn.args, + cwd: spawn.cwd, + cols: spawn.cols, + rows: spawn.rows, + presetId: spawn.presetId, + createdAt: Date.now(), + } + const session: ManagedSession = { info, sink, spawn } + this.sessions.set(id, session) + + void this.launch(session) + this.publish() + return { ...info } + } + + private async launch(session: ManagedSession): Promise { + const { spawn, sink } = session + let proc + try { + proc = await spawnBackend( + { + command: spawn.command, + args: spawn.args, + cwd: spawn.cwd, + env: spawn.env, + cols: spawn.cols, + rows: spawn.rows, + input: spawn.mode === 'interactive', + }, + spawn.mode === 'interactive', + ) + } + catch (error) { + const reason = error instanceof Error ? error.message : String(error) + diagnostics.DP_TERMINALS_0004({ command: spawn.command, reason }, { method: 'warn' }) + session.info.status = 'error' + session.info.pid = undefined + if (!sink.closed) + sink.write(`\r\n\x1B[31m[failed to start: ${reason}]\x1B[0m\r\n`) + this.publish() + return + } + + session.proc = proc + session.info.status = 'running' + session.info.exitCode = undefined + session.info.backend = proc.backend + session.info.pid = proc.pid + + proc.onData((data) => { + if (!sink.closed) + sink.write(data) + }) + proc.onExit((code) => { + // Ignore the exit of a process replaced by restart(). + if (session.proc !== proc) + return + session.info.status = code === 0 ? 'exited' : 'error' + session.info.exitCode = code + session.info.pid = undefined + if (!sink.closed) + sink.write(`\r\n\x1B[2m[process exited with code ${code}]\x1B[0m\r\n`) + this.publish() + }) + + this.publish() + } + + write(id: string, data: string): void { + const session = this.sessions.get(id) + if (!session) + throw diagnostics.DP_TERMINALS_0001({ id }) + if (session.info.mode !== 'interactive') + throw diagnostics.DP_TERMINALS_0003({ id }) + if (session.info.status !== 'running') + return + session.proc?.write(data) + } + + resize(id: string, cols: number, rows: number): void { + const session = this.sessions.get(id) + if (!session) + throw diagnostics.DP_TERMINALS_0001({ id }) + session.info.cols = cols + session.info.rows = rows + session.spawn.cols = cols + session.spawn.rows = rows + session.proc?.resize(cols, rows) + } + + /** Stop the process but keep the session (and its stream) around. */ + terminate(id: string): void { + const session = this.sessions.get(id) + if (!session) + throw diagnostics.DP_TERMINALS_0001({ id }) + session.proc?.kill() + } + + /** Restart the session's command in place, reusing the same stream id. */ + restart(id: string): TerminalSessionInfo { + const session = this.sessions.get(id) + if (!session) + throw diagnostics.DP_TERMINALS_0001({ id }) + const previous = session.proc + session.proc = undefined + previous?.kill() + if (!session.sink.closed) + session.sink.write('\r\n\x1B[2m[restarting…]\x1B[0m\r\n') + session.info.status = 'running' + session.info.exitCode = undefined + void this.launch(session) + this.publish() + return { ...session.info } + } + + /** Kill the process, close the stream, and drop the session. */ + remove(id: string): void { + const session = this.sessions.get(id) + if (!session) + throw diagnostics.DP_TERMINALS_0001({ id }) + const proc = session.proc + session.proc = undefined + proc?.kill() + if (!session.sink.closed) + session.sink.close() + this.sessions.delete(id) + this.publish() + } + + /** Tear everything down — used on server shutdown and in tests. */ + dispose(): void { + for (const session of this.sessions.values()) { + session.proc?.kill() + if (!session.sink.closed) + session.sink.close() + } + this.sessions.clear() + this.publish() + } + + private publish(): void { + this.sessionsState?.mutate((draft) => { + draft.sessions = this.list() + }) + } +} diff --git a/plugins/terminals/src/rpc/functions/list.ts b/plugins/terminals/src/rpc/functions/list.ts new file mode 100644 index 0000000..b4f338b --- /dev/null +++ b/plugins/terminals/src/rpc/functions/list.ts @@ -0,0 +1,20 @@ +import { defineRpcFunction } from 'devframe' +import * as v from 'valibot' +import { getTerminalManager } from '../../node/context' +import { sessionInfoSchema } from '../schemas' + +export const list = defineRpcFunction({ + name: 'devframes-plugin-terminals:list', + type: 'query', + jsonSerializable: true, + snapshot: true, + args: [], + returns: v.array(sessionInfoSchema), + agent: { + description: 'List the current terminal sessions with their status, mode, and command.', + safety: 'read', + }, + setup: ctx => ({ + handler: () => getTerminalManager(ctx).list(), + }), +}) diff --git a/plugins/terminals/src/rpc/functions/presets.ts b/plugins/terminals/src/rpc/functions/presets.ts new file mode 100644 index 0000000..6d536f4 --- /dev/null +++ b/plugins/terminals/src/rpc/functions/presets.ts @@ -0,0 +1,23 @@ +import { defineRpcFunction } from 'devframe' +import * as v from 'valibot' +import { getTerminalManager } from '../../node/context' +import { presetSchema } from '../schemas' + +export const presets = defineRpcFunction({ + name: 'devframes-plugin-terminals:presets', + type: 'query', + jsonSerializable: true, + snapshot: true, + args: [], + returns: v.array(presetSchema), + setup: ctx => ({ + handler: () => getTerminalManager(ctx).getPresets().map(p => ({ + id: p.id, + title: p.title, + command: p.command, + args: p.args ?? [], + mode: p.mode ?? 'readonly', + icon: p.icon, + })), + }), +}) diff --git a/plugins/terminals/src/rpc/functions/remove.ts b/plugins/terminals/src/rpc/functions/remove.ts new file mode 100644 index 0000000..a629ce1 --- /dev/null +++ b/plugins/terminals/src/rpc/functions/remove.ts @@ -0,0 +1,20 @@ +import { defineRpcFunction } from 'devframe' +import * as v from 'valibot' +import { getTerminalManager } from '../../node/context' + +export const remove = defineRpcFunction({ + name: 'devframes-plugin-terminals:remove', + type: 'action', + jsonSerializable: true, + args: [v.object({ id: v.string() })], + returns: v.void(), + agent: { + description: 'Kill a terminal session and discard it (process, stream, and scrollback).', + safety: 'destructive', + }, + setup: ctx => ({ + handler: ({ id }) => { + getTerminalManager(ctx).remove(id) + }, + }), +}) diff --git a/plugins/terminals/src/rpc/functions/resize.ts b/plugins/terminals/src/rpc/functions/resize.ts new file mode 100644 index 0000000..142639c --- /dev/null +++ b/plugins/terminals/src/rpc/functions/resize.ts @@ -0,0 +1,20 @@ +import { defineRpcFunction } from 'devframe' +import * as v from 'valibot' +import { getTerminalManager } from '../../node/context' + +export const resize = defineRpcFunction({ + name: 'devframes-plugin-terminals:resize', + type: 'action', + jsonSerializable: true, + args: [v.object({ + id: v.string(), + cols: v.pipe(v.number(), v.integer(), v.minValue(1)), + rows: v.pipe(v.number(), v.integer(), v.minValue(1)), + })], + returns: v.void(), + setup: ctx => ({ + handler: ({ id, cols, rows }) => { + getTerminalManager(ctx).resize(id, cols, rows) + }, + }), +}) diff --git a/plugins/terminals/src/rpc/functions/restart.ts b/plugins/terminals/src/rpc/functions/restart.ts new file mode 100644 index 0000000..f6ccc4f --- /dev/null +++ b/plugins/terminals/src/rpc/functions/restart.ts @@ -0,0 +1,15 @@ +import { defineRpcFunction } from 'devframe' +import * as v from 'valibot' +import { getTerminalManager } from '../../node/context' +import { sessionInfoSchema } from '../schemas' + +export const restart = defineRpcFunction({ + name: 'devframes-plugin-terminals:restart', + type: 'action', + jsonSerializable: true, + args: [v.object({ id: v.string() })], + returns: sessionInfoSchema, + setup: ctx => ({ + handler: ({ id }) => getTerminalManager(ctx).restart(id), + }), +}) diff --git a/plugins/terminals/src/rpc/functions/spawn.ts b/plugins/terminals/src/rpc/functions/spawn.ts new file mode 100644 index 0000000..187b8cf --- /dev/null +++ b/plugins/terminals/src/rpc/functions/spawn.ts @@ -0,0 +1,18 @@ +import { defineRpcFunction } from 'devframe' +import { getTerminalManager } from '../../node/context' +import { sessionInfoSchema, spawnRequestSchema } from '../schemas' + +export const spawn = defineRpcFunction({ + name: 'devframes-plugin-terminals:spawn', + type: 'action', + jsonSerializable: true, + args: [spawnRequestSchema], + returns: sessionInfoSchema, + agent: { + description: 'Spawn a new terminal session. Pass a preset id, or a command + mode. Interactive sessions accept input; readonly sessions only stream output.', + safety: 'action', + }, + setup: ctx => ({ + handler: req => getTerminalManager(ctx).spawn(req ?? {}), + }), +}) diff --git a/plugins/terminals/src/rpc/functions/terminate.ts b/plugins/terminals/src/rpc/functions/terminate.ts new file mode 100644 index 0000000..b1da4af --- /dev/null +++ b/plugins/terminals/src/rpc/functions/terminate.ts @@ -0,0 +1,20 @@ +import { defineRpcFunction } from 'devframe' +import * as v from 'valibot' +import { getTerminalManager } from '../../node/context' + +export const terminate = defineRpcFunction({ + name: 'devframes-plugin-terminals:terminate', + type: 'action', + jsonSerializable: true, + args: [v.object({ id: v.string() })], + returns: v.void(), + agent: { + description: 'Terminate a terminal session\'s running process. The session and its scrollback are kept; use restart to run it again.', + safety: 'destructive', + }, + setup: ctx => ({ + handler: ({ id }) => { + getTerminalManager(ctx).terminate(id) + }, + }), +}) diff --git a/plugins/terminals/src/rpc/functions/write.ts b/plugins/terminals/src/rpc/functions/write.ts new file mode 100644 index 0000000..b59461b --- /dev/null +++ b/plugins/terminals/src/rpc/functions/write.ts @@ -0,0 +1,16 @@ +import { defineRpcFunction } from 'devframe' +import * as v from 'valibot' +import { getTerminalManager } from '../../node/context' + +export const write = defineRpcFunction({ + name: 'devframes-plugin-terminals:write', + type: 'action', + jsonSerializable: true, + args: [v.object({ id: v.string(), data: v.string() })], + returns: v.void(), + setup: ctx => ({ + handler: ({ id, data }) => { + getTerminalManager(ctx).write(id, data) + }, + }), +}) diff --git a/plugins/terminals/src/rpc/index.ts b/plugins/terminals/src/rpc/index.ts new file mode 100644 index 0000000..8ff3179 --- /dev/null +++ b/plugins/terminals/src/rpc/index.ts @@ -0,0 +1,30 @@ +import type { RpcDefinitionsToFunctions } from 'devframe/rpc' +import type { TerminalPreset, TerminalsSharedState } from '../types' +import { list } from './functions/list' +import { presets } from './functions/presets' +import { remove } from './functions/remove' +import { resize } from './functions/resize' +import { restart } from './functions/restart' +import { spawn } from './functions/spawn' +import { terminate } from './functions/terminate' +import { write } from './functions/write' + +export const serverFunctions = [ + list, + presets, + spawn, + write, + resize, + terminate, + restart, + remove, +] as const + +declare module 'devframe' { + interface DevframeRpcServerFunctions extends RpcDefinitionsToFunctions {} + + interface DevframeRpcSharedStates { + 'devframes-plugin-terminals:sessions': TerminalsSharedState + 'devframes-plugin-terminals:presets': { presets: TerminalPreset[] } + } +} diff --git a/plugins/terminals/src/rpc/schemas.ts b/plugins/terminals/src/rpc/schemas.ts new file mode 100644 index 0000000..f96b200 --- /dev/null +++ b/plugins/terminals/src/rpc/schemas.ts @@ -0,0 +1,41 @@ +import * as v from 'valibot' + +export const terminalModeSchema = v.picklist(['interactive', 'readonly']) + +export const spawnRequestSchema = v.object({ + presetId: v.optional(v.string()), + command: v.optional(v.string()), + args: v.optional(v.array(v.string())), + cwd: v.optional(v.string()), + mode: v.optional(terminalModeSchema), + title: v.optional(v.string()), + cols: v.optional(v.number()), + rows: v.optional(v.number()), + env: v.optional(v.record(v.string(), v.string())), +}) + +export const sessionInfoSchema = v.object({ + id: v.string(), + title: v.string(), + mode: terminalModeSchema, + status: v.picklist(['running', 'exited', 'error']), + backend: v.picklist(['pty', 'pipe']), + command: v.string(), + args: v.array(v.string()), + cwd: v.string(), + cols: v.number(), + rows: v.number(), + pid: v.optional(v.number()), + exitCode: v.optional(v.number()), + presetId: v.optional(v.string()), + createdAt: v.number(), +}) + +export const presetSchema = v.object({ + id: v.string(), + title: v.string(), + command: v.string(), + args: v.array(v.string()), + mode: terminalModeSchema, + icon: v.optional(v.string()), +}) diff --git a/plugins/terminals/src/spa/index.html b/plugins/terminals/src/spa/index.html new file mode 100644 index 0000000..3fef113 --- /dev/null +++ b/plugins/terminals/src/spa/index.html @@ -0,0 +1,16 @@ + + + + + + Terminals + + + +
+ + + diff --git a/plugins/terminals/src/spa/main.ts b/plugins/terminals/src/spa/main.ts new file mode 100644 index 0000000..cb9257a --- /dev/null +++ b/plugins/terminals/src/spa/main.ts @@ -0,0 +1,9 @@ +import { mountTerminals } from '../client/index' + +const app = document.getElementById('app') +if (!app) + throw new Error('#app mount node missing from index.html') + +mountTerminals(app).catch((error) => { + app.textContent = `Failed to connect: ${error instanceof Error ? error.message : String(error)}` +}) diff --git a/plugins/terminals/src/spa/vite.config.ts b/plugins/terminals/src/spa/vite.config.ts new file mode 100644 index 0000000..ba415b4 --- /dev/null +++ b/plugins/terminals/src/spa/vite.config.ts @@ -0,0 +1,13 @@ +import { fileURLToPath } from 'node:url' +import { defineConfig } from 'vite' +import { alias } from '../../../../alias' + +export default defineConfig({ + base: './', + root: fileURLToPath(new URL('.', import.meta.url)), + resolve: { alias }, + build: { + outDir: fileURLToPath(new URL('../../dist/spa', import.meta.url)), + emptyOutDir: true, + }, +}) diff --git a/plugins/terminals/src/types.ts b/plugins/terminals/src/types.ts new file mode 100644 index 0000000..b20271a --- /dev/null +++ b/plugins/terminals/src/types.ts @@ -0,0 +1,111 @@ +/** + * How a session is driven. + * + * - `interactive` — a PTY-backed session that accepts keystrokes, resize, + * and renders full-screen TUIs (vim, htop, Claude Code, …). Falls back to + * a piped child process when no PTY backend is available. + * - `readonly` — a piped child process whose combined output is streamed to + * viewers; stdin is rejected. Ideal for long-running logs / dev servers. + */ +export type TerminalMode = 'interactive' | 'readonly' + +/** Lifecycle status of a session. */ +export type TerminalStatus = 'running' | 'exited' | 'error' + +/** Which OS-level mechanism backs a session. */ +export type TerminalBackend = 'pty' | 'pipe' + +/** + * Serializable descriptor for a single terminal session. Lives in the + * `devframes-plugin-terminals:sessions` shared state and is returned by the + * `list` RPC. + */ +export interface TerminalSessionInfo { + id: string + title: string + mode: TerminalMode + status: TerminalStatus + backend: TerminalBackend + command: string + args: string[] + cwd: string + cols: number + rows: number + pid?: number + exitCode?: number + /** Preset this session was spawned from, if any. */ + presetId?: string + createdAt: number +} + +export interface TerminalsSharedState { + sessions: TerminalSessionInfo[] +} + +/** + * A pre-configured command the UI offers and that clients may spawn by id + * without `allowArbitraryCommands`. + */ +export interface TerminalPreset { + id: string + title: string + command: string + args?: string[] + cwd?: string + /** @default 'readonly' for presets, 'interactive' for the shell */ + mode?: TerminalMode + env?: Record + icon?: string +} + +/** Wire payload for the `spawn` RPC. */ +export interface SpawnRequest { + /** Spawn a configured preset by id. */ + presetId?: string + /** + * Explicit command. Requires `allowArbitraryCommands` unless it resolves + * to the configured shell. Omit to spawn the default shell. + */ + command?: string + args?: string[] + cwd?: string + mode?: TerminalMode + title?: string + cols?: number + rows?: number + env?: Record +} + +/** Options accepted by {@link createTerminalsDevframe}. */ +export interface TerminalsOptions { + /** Shell used for interactive sessions. Defaults to `$SHELL` / platform default. */ + shell?: string + /** Extra args passed to the shell. Defaults to an interactive-login flag set. */ + shellArgs?: string[] + /** Default working directory for new sessions. Defaults to `ctx.cwd`. */ + cwd?: string + /** Environment variables merged into every spawned session. */ + env?: Record + /** Spawnable command presets surfaced in the UI. */ + presets?: TerminalPreset[] + /** + * Allow clients to spawn arbitrary command strings beyond presets and the + * configured shell. Default `false` (deny) for safety. + */ + allowArbitraryCommands?: boolean + /** + * Default mode for the shell session created on demand. + * @default 'interactive' + */ + defaultMode?: TerminalMode + /** Output chunks retained per session for replay on reconnect. */ + scrollback?: number + /** Mount path override. */ + basePath?: string + /** SPA dist dir override. Defaults to the bundled SPA. */ + distDir?: string + /** CLI binary name. */ + command?: string + /** Preferred dev-server port. */ + port?: number +} diff --git a/plugins/terminals/src/vite.ts b/plugins/terminals/src/vite.ts new file mode 100644 index 0000000..76664c8 --- /dev/null +++ b/plugins/terminals/src/vite.ts @@ -0,0 +1,25 @@ +import type { DevframeVitePlugin, ViteDevBridgeOptions } from 'devframe/helpers/vite' +import type { TerminalsOptions } from './types' +import { viteDevBridge } from 'devframe/helpers/vite' +import { createTerminalsDevframe } from './index' + +export interface TerminalsViteOptions extends TerminalsOptions { + /** Forwarded to the underlying `viteDevBridge` (mount base, etc.). */ + vite?: ViteDevBridgeOptions +} + +/** + * Mount the terminals panel into an existing Vite dev server. Returns two + * plugins: a bridge that starts the devframe RPC + WebSocket server (so the + * panel can stream terminal output), and a static mount that serves the + * bundled SPA at the mount base. The bridge is listed first so its + * `__connection.json` route is matched ahead of the SPA fallback. + */ +export function terminalsVite(options: TerminalsViteOptions = {}): DevframeVitePlugin[] { + const { vite, ...terminalsOptions } = options + const definition = createTerminalsDevframe(terminalsOptions) + return [ + viteDevBridge(definition, { ...vite, devMiddleware: true }), + viteDevBridge(definition, vite), + ] +} diff --git a/plugins/terminals/test/_utils.ts b/plugins/terminals/test/_utils.ts new file mode 100644 index 0000000..12d0ea8 --- /dev/null +++ b/plugins/terminals/test/_utils.ts @@ -0,0 +1,121 @@ +import type { StartedServer } from 'devframe/node' +import type { DevframeNodeContext } from 'devframe/types' +import type { TerminalsOptions } from '../src/types' +import process from 'node:process' +import { createRpcStreamingClientHost } from 'devframe/client' +import { + createH3DevframeHost, + createHostContext, + startHttpAndWs, +} from 'devframe/node' +import { createRpcClient } from 'devframe/rpc/client' +import { createWsRpcChannel } from 'devframe/rpc/transports/ws-client' +import { getPort } from 'get-port-please' +import { H3 } from 'h3' +import { createTerminalsDevframe } from '../src/index' + +export type TerminalsServer = StartedServer & { + ctx: DevframeNodeContext + port: number +} + +/** + * Boot the terminals devframe in-process over real HTTP + WebSocket so the + * full RPC + streaming path is exercised end to end. + */ +export async function startTerminalsServer(options: TerminalsOptions = {}): Promise { + const definition = createTerminalsDevframe({ allowArbitraryCommands: true, ...options }) + const host = '127.0.0.1' + const port = await getPort({ host, random: true }) + + const app = new H3() + const origin = `http://${host}:${port}` + const h3Host = createH3DevframeHost({ + origin, + appName: definition.id, + mount: () => {}, + }) + + const ctx = await createHostContext({ cwd: process.cwd(), mode: 'dev', host: h3Host }) + await definition.setup(ctx) + + const server = await startHttpAndWs({ context: ctx, host, port, app, auth: false }) + return Object.assign(server, { ctx, port }) +} + +export interface TestClient { + rpc: ReturnType + streaming: ReturnType +} + +/** + * Minimal RPC + streaming client over the WS transport — mirrors the + * streaming-chat example harness. `connectDevframe` is skipped because it + * needs a browser-like environment for connection-meta lookup. + */ +export function bootClient(port: number): TestClient { + const listeners = new Set<(trusted: boolean) => void>() + const fakeEvents = { + on(name: string, fn: (trusted: boolean) => void) { + if (name === 'rpc:is-trusted:updated') + listeners.add(fn) + return () => listeners.delete(fn) + }, + } + const clientFns: any = {} + const clientRpcStub = { + register(def: { name: string, handler: (...args: any[]) => any }) { + clientFns[def.name] = def.handler + }, + } + + const rpc = createRpcClient(clientFns, { + channel: createWsRpcChannel({ url: `ws://127.0.0.1:${port}` }), + }) + + const fakeRpcClient = { + isTrusted: true, + events: fakeEvents, + client: clientRpcStub, + callEvent: (name: any, ...args: any[]) => (rpc as any).$callEvent(name, ...args), + } as any + + const streaming = createRpcStreamingClientHost(fakeRpcClient) + return { rpc, streaming } +} + +export function call(client: TestClient, method: string, ...args: any[]): Promise { + return (client.rpc as any).$call(method, ...args) as Promise +} + +/** + * Terminal streams stay open for the session's whole life, so we can't drain + * to completion. Collect output until `predicate(accumulated)` is satisfied + * or the timeout elapses, then cancel. + */ +export async function collectUntil( + reader: AsyncIterable & { cancel: () => void }, + predicate: (acc: string) => boolean, + timeoutMs = 4000, +): Promise { + let acc = '' + const deadline = Date.now() + timeoutMs + const iterator = (reader as any)[Symbol.asyncIterator]() as AsyncIterator + + while (Date.now() < deadline) { + const next = iterator.next() + const timer = new Promise<{ timeout: true }>(resolve => + setTimeout(resolve, Math.max(0, deadline - Date.now()), { timeout: true })) + const result = await Promise.race([next, timer]) + if ((result as any).timeout) + break + const { value, done } = result as IteratorResult + if (done) + break + acc += value + if (predicate(acc)) + break + } + reader.cancel() + return acc +} diff --git a/plugins/terminals/test/terminals.test.ts b/plugins/terminals/test/terminals.test.ts new file mode 100644 index 0000000..fbd2e07 --- /dev/null +++ b/plugins/terminals/test/terminals.test.ts @@ -0,0 +1,185 @@ +import type { TerminalSessionInfo, TerminalsSharedState } from '../src/types' +import type { TerminalsServer, TestClient } from './_utils' +import process from 'node:process' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { WebSocket } from 'ws' +import { SESSIONS_STATE_KEY, TERMINAL_STREAM_CHANNEL } from '../src/constants' +import { bootClient, call, collectUntil, startTerminalsServer } from './_utils' + +vi.stubGlobal('WebSocket', WebSocket) + +const NODE = process.execPath + +function subscribe(client: TestClient, id: string) { + return client.streaming.subscribe(TERMINAL_STREAM_CHANNEL, id) +} + +async function sessions(server: TerminalsServer): Promise { + const state = await server.ctx.rpc.sharedState.get(SESSIONS_STATE_KEY) + return (state.value() as TerminalsSharedState).sessions +} + +describe('@devframes/plugin-terminals', () => { + let server: TerminalsServer + + beforeEach(async () => { + server = await startTerminalsServer() + }) + + afterEach(async () => { + await server?.close() + }) + + it('streams output from a readonly session and marks it exited', async () => { + const client = bootClient(server.port) + await new Promise(r => setTimeout(r, 50)) + + const info = await call(client, 'devframes-plugin-terminals:spawn', { + command: NODE, + args: ['-e', 'process.stdout.write("hello-readonly")'], + mode: 'readonly', + }) + expect(info.mode).toBe('readonly') + + const reader = subscribe(client, info.id) + const output = await collectUntil(reader, acc => acc.includes('hello-readonly')) + expect(output).toContain('hello-readonly') + + await vi.waitFor(async () => { + const list = await sessions(server) + expect(list.find(s => s.id === info.id)?.status).toBe('exited') + }) + }) + + it('rejects writes to a readonly session', async () => { + const client = bootClient(server.port) + await new Promise(r => setTimeout(r, 50)) + + const info = await call(client, 'devframes-plugin-terminals:spawn', { + command: NODE, + args: ['-e', 'setInterval(() => {}, 1000)'], + mode: 'readonly', + }) + + await expect( + call(client, 'devframes-plugin-terminals:write', { id: info.id, data: 'x' }), + ).rejects.toThrow(/read-only/i) + }) + + it('runs an interactive PTY session that accepts input', async () => { + const client = bootClient(server.port) + await new Promise(r => setTimeout(r, 50)) + + const info = await call(client, 'devframes-plugin-terminals:spawn', { + command: NODE, + args: ['-e', 'process.stdin.on("data", d => process.stdout.write("echo:" + d)); setTimeout(() => {}, 4000)'], + mode: 'interactive', + }) + expect(info.backend).toBe('pty') + + const reader = subscribe(client, info.id) + await new Promise(r => setTimeout(r, 200)) + await call(client, 'devframes-plugin-terminals:write', { id: info.id, data: 'ping\n' }) + + const output = await collectUntil(reader, acc => acc.includes('echo:ping')) + expect(output).toContain('echo:ping') + }) + + it('gives interactive sessions a real TTY (TUI support)', async () => { + const client = bootClient(server.port) + await new Promise(r => setTimeout(r, 50)) + + const info = await call(client, 'devframes-plugin-terminals:spawn', { + command: NODE, + args: ['-e', 'process.stdout.write("isTTY=" + process.stdout.isTTY)'], + mode: 'interactive', + }) + + const reader = subscribe(client, info.id) + const output = await collectUntil(reader, acc => acc.includes('isTTY=')) + expect(output).toContain('isTTY=true') + }) + + it('propagates resize to the PTY (SIGWINCH) for TUI layout', async () => { + const client = bootClient(server.port) + await new Promise(r => setTimeout(r, 50)) + + const info = await call(client, 'devframes-plugin-terminals:spawn', { + command: NODE, + args: ['-e', 'process.stdout.write("cols=" + process.stdout.columns); process.on("SIGWINCH", () => process.stdout.write(" winch=" + process.stdout.columns)); setInterval(() => {}, 4000)'], + mode: 'interactive', + cols: 80, + rows: 24, + }) + + const reader = subscribe(client, info.id) + await new Promise(r => setTimeout(r, 200)) + await call(client, 'devframes-plugin-terminals:resize', { id: info.id, cols: 120, rows: 40 }) + + const output = await collectUntil(reader, acc => acc.includes('winch=')) + expect(output).toContain('winch=120') + }) + + it('restarts a session in place, reusing the same id', async () => { + const client = bootClient(server.port) + await new Promise(r => setTimeout(r, 50)) + + const info = await call(client, 'devframes-plugin-terminals:spawn', { + command: NODE, + args: ['-e', 'process.stdout.write("run")'], + mode: 'readonly', + }) + + const restarted = await call(client, 'devframes-plugin-terminals:restart', { id: info.id }) + expect(restarted.id).toBe(info.id) + expect(restarted.status).toBe('running') + }) + + it('lists sessions and removes them', async () => { + const client = bootClient(server.port) + await new Promise(r => setTimeout(r, 50)) + + const info = await call(client, 'devframes-plugin-terminals:spawn', { + command: NODE, + args: ['-e', 'setInterval(() => {}, 1000)'], + mode: 'readonly', + }) + + let list = await call(client, 'devframes-plugin-terminals:list') + expect(list.some(s => s.id === info.id)).toBe(true) + + await call(client, 'devframes-plugin-terminals:remove', { id: info.id }) + list = await call(client, 'devframes-plugin-terminals:list') + expect(list.some(s => s.id === info.id)).toBe(false) + }) + + it('exposes presets and spawns from them', async () => { + await server.close() + server = await startTerminalsServer({ + presets: [{ id: 'greet', title: 'Greet', command: NODE, args: ['-e', 'process.stdout.write("from-preset")'], mode: 'readonly' }], + }) + const client = bootClient(server.port) + await new Promise(r => setTimeout(r, 50)) + + const presets = await call(client, 'devframes-plugin-terminals:presets') + expect(presets.map(p => p.id)).toContain('greet') + + const info = await call(client, 'devframes-plugin-terminals:spawn', { presetId: 'greet' }) + expect(info.presetId).toBe('greet') + + const reader = subscribe(client, info.id) + const output = await collectUntil(reader, acc => acc.includes('from-preset')) + expect(output).toContain('from-preset') + }) + + it('rejects arbitrary commands unless explicitly allowed', async () => { + await server.close() + server = await startTerminalsServer({ allowArbitraryCommands: false }) + const client = bootClient(server.port) + await new Promise(r => setTimeout(r, 50)) + + await expect( + call(client, 'devframes-plugin-terminals:spawn', { command: 'definitely-not-allowed', mode: 'readonly' }), + ).rejects.toThrow() + }) +}) diff --git a/plugins/terminals/tsconfig.json b/plugins/terminals/tsconfig.json new file mode 100644 index 0000000..8a6d5a7 --- /dev/null +++ b/plugins/terminals/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "lib": ["esnext", "dom"] + } +} diff --git a/plugins/terminals/tsdown.config.ts b/plugins/terminals/tsdown.config.ts new file mode 100644 index 0000000..cc1c22e --- /dev/null +++ b/plugins/terminals/tsdown.config.ts @@ -0,0 +1,64 @@ +import { defineConfig } from 'tsdown' + +const tsconfig = '../../tsconfig.base.json' + +const deps = { + neverBundle: [ + 'vite', + 'esbuild', + 'postcss', + 'rolldown', + ], +} + +// Browser-loaded modules — the xterm-powered renderer. Kept in its own +// rolldown graph so node-only imports never leak into the client bundle. +const clientEntries = { + 'client/index': 'src/client/index.ts', +} + +// Node + neutral modules — the devframe definition/factory, RPC functions, +// the PTY/child-process manager, and the host adapters. +const serverEntries = { + 'index': 'src/index.ts', + 'node/index': 'src/node/index.ts', + 'rpc/index': 'src/rpc/index.ts', + 'cli': 'src/cli.ts', + 'vite': 'src/vite.ts', + 'constants': 'src/constants.ts', + 'types': 'src/types.ts', +} + +// Three configs, mirroring `packages/devframe/tsdown.config.ts`: +// 1. browser client build (independent graph, `.mjs`), +// 2. node server build (appends to the same dist/), +// 3. combined dts so `declare module 'devframe'` augmentations resolve +// across every entry. +export default defineConfig([ + { + clean: true, + platform: 'browser', + tsconfig, + deps, + dts: false, + outExtensions: () => ({ js: '.mjs' }), + entry: clientEntries, + }, + { + clean: false, + platform: 'node', + tsconfig, + deps, + dts: false, + entry: serverEntries, + }, + { + clean: false, + platform: 'neutral', + tsconfig, + deps, + dts: { emitDtsOnly: true }, + outExtensions: () => ({ dts: '.d.mts' }), + entry: { ...clientEntries, ...serverEntries }, + }, +]) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c5fe3e5..6c04006 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -34,6 +34,9 @@ catalogs: specifier: ^8.0.14 version: 8.0.14 deps: + '@homebridge/node-pty-prebuilt-multiarch': + specifier: ^0.13.1 + version: 0.13.1 '@modelcontextprotocol/sdk': specifier: ^1.29.0 version: 1.29.0 @@ -111,6 +114,12 @@ catalogs: specifier: ^2.0.17 version: 2.0.17 frontend: + '@xterm/addon-fit': + specifier: ^0.11.0 + version: 0.11.0 + '@xterm/xterm': + specifier: ^6.0.0 + version: 6.0.0 next: specifier: ^16.2.6 version: 16.2.6 @@ -528,6 +537,52 @@ importers: specifier: catalog:build version: 0.22.0(tsx@4.22.3)(typescript@6.0.3) + plugins/terminals: + dependencies: + '@homebridge/node-pty-prebuilt-multiarch': + specifier: catalog:deps + version: 0.13.1 + '@xterm/addon-fit': + specifier: catalog:frontend + version: 0.11.0 + '@xterm/xterm': + specifier: catalog:frontend + version: 6.0.0 + nostics: + specifier: catalog:deps + version: 0.2.0 + pathe: + specifier: catalog:deps + version: 2.0.3 + valibot: + specifier: catalog:deps + version: 1.4.1(typescript@6.0.3) + devDependencies: + '@types/node': + specifier: catalog:types + version: 25.9.1 + devframe: + specifier: workspace:* + version: link:../../packages/devframe + get-port-please: + specifier: catalog:deps + version: 3.2.0 + h3: + specifier: catalog:deps + version: 2.0.1-rc.22(crossws@0.4.5(srvx@0.11.15)) + tsdown: + specifier: catalog:build + version: 0.22.0(tsx@4.22.3)(typescript@6.0.3) + vite: + specifier: catalog:build + version: 8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.8.4) + vitest: + specifier: catalog:testing + version: 4.1.7(@types/node@25.9.1)(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.8.4)) + ws: + specifier: catalog:deps + version: 8.21.0 + packages: '@antfu/eslint-config@9.0.0': @@ -1212,6 +1267,10 @@ packages: resolution: {integrity: sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} + '@homebridge/node-pty-prebuilt-multiarch@0.13.1': + resolution: {integrity: sha512-ccQ60nMcbEGrQh0U9E6x0ajW9qJNeazpcM/9CH6J8leyNtJgb+gu24WTBAfBUVeO486ZhscnaxLEITI2HXwhow==} + engines: {node: '>=18.0.0 <25.0.0'} + '@hono/node-server@1.19.14': resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} engines: {node: '>=18.14.1'} @@ -3365,6 +3424,12 @@ packages: peerDependencies: vue: ^3.5.0 + '@xterm/addon-fit@0.11.0': + resolution: {integrity: sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==} + + '@xterm/xterm@6.0.0': + resolution: {integrity: sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==} + abbrev@3.0.1: resolution: {integrity: sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==} engines: {node: ^18.17.0 || >=20.5.0} @@ -3563,6 +3628,9 @@ packages: birpc@4.0.0: resolution: {integrity: sha512-LShSxJP0KTmd101b6DRyGBj57LZxSDYWKitQNW/mi8GRMvZb078Uf9+pveax1DrVL89vm7mWe+TovdI/UDOuPw==} + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + body-parser@2.2.2: resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} engines: {node: '>=18'} @@ -3593,6 +3661,9 @@ packages: buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + buffer@6.0.3: resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} @@ -3666,6 +3737,9 @@ packages: resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} engines: {node: '>= 20.19.0'} + chownr@1.1.4: + resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + chownr@3.0.0: resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} engines: {node: '>=18'} @@ -4059,6 +4133,14 @@ packages: decode-named-character-reference@1.3.0: resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} + decompress-response@6.0.0: + resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} + engines: {node: '>=10'} + + deep-extend@0.6.0: + resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} + engines: {node: '>=4.0.0'} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -4183,6 +4265,9 @@ packages: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + enhanced-resolve@5.21.2: resolution: {integrity: sha512-xe9vQb5kReirPUxgQrXA3ihgbCqssmTiM7cOZ+Gzu+VeGWgpV98lLZvp0dl4yriyAePcewxGUs9UpKD8PET9KQ==} engines: {node: '>=10.13.0'} @@ -4484,6 +4569,10 @@ packages: resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==} engines: {node: '>=16.17'} + expand-template@2.0.3: + resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} + engines: {node: '>=6'} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} @@ -4604,6 +4693,9 @@ packages: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} + fs-constants@1.0.0: + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + fsevents@2.3.2: resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -4662,6 +4754,9 @@ packages: resolution: {integrity: sha512-GvHTWcykIR/fP8cj8dMpuMMkvaeJfPvYnhq0oW+chSeIr+ldX21ifU2Ms6KBoyKZQZmVaUAAhQ2EZ68KJF8a7A==} hasBin: true + github-from-package@0.0.0: + resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + github-slugger@2.0.0: resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==} @@ -4831,6 +4926,9 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + ini@4.1.1: resolution: {integrity: sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} @@ -5372,6 +5470,10 @@ packages: resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} engines: {node: '>=12'} + mimic-response@3.1.0: + resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} + engines: {node: '>=10'} + minimatch@10.2.5: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} @@ -5384,6 +5486,9 @@ packages: resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} engines: {node: '>=16 || 14 >=14.17'} + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + minipass@7.1.3: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} @@ -5395,6 +5500,9 @@ packages: resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} engines: {node: '>= 18'} + mkdirp-classic@0.5.3: + resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + mlly@1.8.2: resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} @@ -5427,6 +5535,9 @@ packages: nanotar@0.3.0: resolution: {integrity: sha512-Kv2JYYiCzt16Kt5QwAc9BFG89xfPNBx+oQL4GQXD9nLqPkZBiNaqaCWtwnbk/q7UVsTYevvM1b0UF8zmEI4pCg==} + napi-build-utils@2.0.0: + resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} + natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} @@ -5469,6 +5580,10 @@ packages: xml2js: optional: true + node-abi@3.92.0: + resolution: {integrity: sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==} + engines: {node: '>=10'} + node-addon-api@7.1.1: resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} @@ -5934,6 +6049,12 @@ packages: preact@10.29.2: resolution: {integrity: sha512-7tNmwg/7mzzAoB/8kSg6Hl37JraAZw3Z3A0JSY7VXlZwo82Xn0G7wKbNNs2qoF4ZEEsQGTwDAroNdqKs1ofJxQ==} + prebuild-install@7.1.3: + resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} + engines: {node: '>=10'} + deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. + hasBin: true + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -5959,6 +6080,9 @@ packages: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} + pump@3.0.4: + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -5990,6 +6114,10 @@ packages: rc9@3.0.1: resolution: {integrity: sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==} + rc@1.2.8: + resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} + hasBin: true + react-dom@19.2.6: resolution: {integrity: sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==} peerDependencies: @@ -6002,6 +6130,10 @@ packages: readable-stream@2.3.8: resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + readable-stream@4.7.0: resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -6246,6 +6378,12 @@ packages: simple-code-frame@1.3.0: resolution: {integrity: sha512-MB4pQmETUBlNs62BBeRjIFGeuy/x6gGKh7+eRUemn1rCFhqo7K+4slPqsyizCbcbYLnaYqaoZ2FWsZ/jN06D8w==} + simple-concat@1.0.1: + resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} + + simple-get@4.0.1: + resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + simple-git-hooks@2.13.1: resolution: {integrity: sha512-WszCLXwT4h2k1ufIXAgsbiTOazqqevFCIncOuUBZJ91DdvWcC5+OFkluWRQPrcuSYd8fjq+o2y1QfWqYMoAToQ==} hasBin: true @@ -6368,6 +6506,10 @@ packages: resolution: {integrity: sha512-SlyRoSkdh1dYP0PzclLE7r0M9sgbFKKMFXpFRUMNuKhQSbC6VQIGzq3E0qsfvGJaUFJPGv6Ws1NZ/haTAjfbMA==} engines: {node: '>=12'} + strip-json-comments@2.0.1: + resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} + engines: {node: '>=0.10.0'} + strip-literal@3.1.0: resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} @@ -6424,6 +6566,13 @@ packages: resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} engines: {node: '>=6'} + tar-fs@2.1.4: + resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==} + + tar-stream@2.2.0: + resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} + engines: {node: '>=6'} + tar-stream@3.2.0: resolution: {integrity: sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==} @@ -6559,6 +6708,9 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + turbo@2.9.15: resolution: {integrity: sha512-VpKvD9Z0Hu/xrGUAYX1wnhfpqv835wIwGqeKfulvBPTOcDap0E3nFwyzCAVV85fB1sBcBDEfTP+7FSW7GzwWSQ==} hasBin: true @@ -7095,18 +7247,6 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - ws@8.20.0: - resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==} - engines: {node: '>=10.0.0'} - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: '>=5.0.2' - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - ws@8.21.0: resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} engines: {node: '>=10.0.0'} @@ -7782,6 +7922,11 @@ snapshots: '@eslint/core': 1.2.1 levn: 0.4.1 + '@homebridge/node-pty-prebuilt-multiarch@0.13.1': + dependencies: + node-addon-api: 7.1.1 + prebuild-install: 7.1.3 + '@hono/node-server@1.19.14(hono@4.12.18)': dependencies: hono: 4.12.18 @@ -8146,7 +8291,7 @@ snapshots: vite-plugin-inspect: 11.3.3(@nuxt/kit@4.4.5(magicast@0.5.2))(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.8.4)) vite-plugin-vue-tracer: 1.3.0(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.8.4))(vue@3.5.34(typescript@6.0.3)) which: 6.0.1 - ws: 8.20.0 + ws: 8.21.0 transitivePeerDependencies: - bufferutil - supports-color @@ -9701,6 +9846,10 @@ snapshots: dependencies: vue: 3.5.34(typescript@6.0.3) + '@xterm/addon-fit@0.11.0': {} + + '@xterm/xterm@6.0.0': {} + abbrev@3.0.1: {} abort-controller@3.0.0: @@ -9878,6 +10027,12 @@ snapshots: birpc@4.0.0: {} + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + body-parser@2.2.2: dependencies: bytes: 3.1.2 @@ -9918,6 +10073,11 @@ snapshots: buffer-from@1.1.2: {} + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + buffer@6.0.3: dependencies: base64-js: 1.5.1 @@ -9999,6 +10159,8 @@ snapshots: dependencies: readdirp: 5.0.0 + chownr@1.1.4: {} + chownr@3.0.0: {} ci-info@4.4.0: {} @@ -10386,6 +10548,12 @@ snapshots: dependencies: character-entities: 2.0.2 + decompress-response@6.0.0: + dependencies: + mimic-response: 3.1.0 + + deep-extend@0.6.0: {} + deep-is@0.1.4: {} deepmerge@4.3.1: {} @@ -10479,6 +10647,10 @@ snapshots: encodeurl@2.0.0: {} + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + enhanced-resolve@5.21.2: dependencies: graceful-fs: 4.2.11 @@ -10881,6 +11053,8 @@ snapshots: signal-exit: 4.1.0 strip-final-newline: 3.0.0 + expand-template@2.0.3: {} + expect-type@1.3.0: {} express-rate-limit@8.5.1(express@5.2.1): @@ -11021,6 +11195,8 @@ snapshots: fresh@2.0.0: {} + fs-constants@1.0.0: {} + fsevents@2.3.2: optional: true @@ -11071,6 +11247,8 @@ snapshots: giget@3.2.0: {} + github-from-package@0.0.0: {} + github-slugger@2.0.0: {} glob-parent@5.1.2: @@ -11244,6 +11422,8 @@ snapshots: inherits@2.0.4: {} + ini@1.3.8: {} + ini@4.1.1: {} internmap@1.0.1: {} @@ -11943,6 +12123,8 @@ snapshots: mimic-fn@4.0.0: {} + mimic-response@3.1.0: {} + minimatch@10.2.5: dependencies: brace-expansion: 5.0.6 @@ -11955,6 +12137,8 @@ snapshots: dependencies: brace-expansion: 2.1.0 + minimist@1.2.8: {} + minipass@7.1.3: {} minisearch@7.2.0: {} @@ -11963,6 +12147,8 @@ snapshots: dependencies: minipass: 7.1.3 + mkdirp-classic@0.5.3: {} + mlly@1.8.2: dependencies: acorn: 8.16.0 @@ -11986,6 +12172,8 @@ snapshots: nanotar@0.3.0: {} + napi-build-utils@2.0.0: {} + natural-compare@1.4.0: {} natural-orderby@5.0.0: {} @@ -12122,6 +12310,10 @@ snapshots: - supports-color - uploadthing + node-abi@3.92.0: + dependencies: + semver: 7.8.1 + node-addon-api@7.1.1: {} node-fetch-native@1.6.7: {} @@ -12775,6 +12967,21 @@ snapshots: preact@10.29.2: {} + prebuild-install@7.1.3: + dependencies: + detect-libc: 2.1.2 + expand-template: 2.0.3 + github-from-package: 0.0.0 + minimist: 1.2.8 + mkdirp-classic: 0.5.3 + napi-build-utils: 2.0.0 + node-abi: 3.92.0 + pump: 3.0.4 + rc: 1.2.8 + simple-get: 4.0.1 + tar-fs: 2.1.4 + tunnel-agent: 0.6.0 + prelude-ls@1.2.1: {} pretty-bytes@7.1.0: {} @@ -12796,6 +13003,11 @@ snapshots: forwarded: 0.2.0 ipaddr.js: 1.9.1 + pump@3.0.4: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + punycode@2.3.1: {} qs@6.15.1: @@ -12824,6 +13036,13 @@ snapshots: defu: 6.1.7 destr: 2.0.5 + rc@1.2.8: + dependencies: + deep-extend: 0.6.0 + ini: 1.3.8 + minimist: 1.2.8 + strip-json-comments: 2.0.1 + react-dom@19.2.6(react@19.2.6): dependencies: react: 19.2.6 @@ -12841,6 +13060,12 @@ snapshots: string_decoder: 1.1.1 util-deprecate: 1.0.2 + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + readable-stream@4.7.0: dependencies: abort-controller: 3.0.0 @@ -13182,6 +13407,14 @@ snapshots: dependencies: kolorist: 1.8.0 + simple-concat@1.0.1: {} + + simple-get@4.0.1: + dependencies: + decompress-response: 6.0.0 + once: 1.4.0 + simple-concat: 1.0.1 + simple-git-hooks@2.13.1: {} simple-git@3.36.0: @@ -13307,6 +13540,8 @@ snapshots: strip-indent@4.1.1: {} + strip-json-comments@2.0.1: {} + strip-literal@3.1.0: dependencies: js-tokens: 9.0.1 @@ -13350,6 +13585,21 @@ snapshots: tapable@2.3.3: {} + tar-fs@2.1.4: + dependencies: + chownr: 1.1.4 + mkdirp-classic: 0.5.3 + pump: 3.0.4 + tar-stream: 2.2.0 + + tar-stream@2.2.0: + dependencies: + bl: 4.1.0 + end-of-stream: 1.4.5 + fs-constants: 1.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + tar-stream@3.2.0: dependencies: b4a: 1.8.1 @@ -13479,6 +13729,10 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + tunnel-agent@0.6.0: + dependencies: + safe-buffer: 5.2.1 + turbo@2.9.15: optionalDependencies: '@turbo/darwin-64': 2.9.15 @@ -14010,8 +14264,6 @@ snapshots: wrappy@1.0.2: {} - ws@8.20.0: {} - ws@8.21.0: {} wsl-utils@0.1.0: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 23d77b0..857c279 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,4 +1,5 @@ allowBuilds: + '@homebridge/node-pty-prebuilt-multiarch': true '@parcel/watcher': false esbuild: true sharp: false @@ -15,6 +16,7 @@ trustPolicyExclude: - tinyexec@1.2.2 packages: - packages/* + - plugins/* - examples/* - docs overrides: @@ -32,6 +34,7 @@ catalogs: turbo: ^2.9.15 vite: ^8.0.14 deps: + '@homebridge/node-pty-prebuilt-multiarch': ^0.13.1 '@modelcontextprotocol/sdk': ^1.29.0 '@valibot/to-json-schema': ^1.7.0 birpc: ^4.0.0 @@ -59,6 +62,8 @@ catalogs: vitepress: ^2.0.0-alpha.17 vitepress-plugin-mermaid: ^2.0.17 frontend: + '@xterm/addon-fit': ^0.11.0 + '@xterm/xterm': ^6.0.0 next: ^16.2.6 preact: ^10.29.2 react: ^19.2.6 diff --git a/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/cli.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/cli.snapshot.d.ts new file mode 100644 index 0000000..4715a40 --- /dev/null +++ b/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/cli.snapshot.d.ts @@ -0,0 +1,6 @@ +/** + * Generated by tsnapi — public API snapshot of `@devframes/plugin-terminals/cli` + */ +// #region Functions +export declare function createTerminalsCli(_?: TerminalsOptions, _?: CreateCliOptions): CliHandle; +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/cli.snapshot.js b/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/cli.snapshot.js new file mode 100644 index 0000000..3fd3f78 --- /dev/null +++ b/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/cli.snapshot.js @@ -0,0 +1,6 @@ +/** + * Generated by tsnapi — public API snapshot of `@devframes/plugin-terminals/cli` + */ +// #region Functions +export function createTerminalsCli(_, _) {} +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/client.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/client.snapshot.d.ts new file mode 100644 index 0000000..b3f140b --- /dev/null +++ b/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/client.snapshot.d.ts @@ -0,0 +1,23 @@ +/** + * Generated by tsnapi — public API snapshot of `@devframes/plugin-terminals/client` + */ +// #region Interfaces +export interface MountTerminalsOptions { + rpc?: DevframeRpcClient; + autostart?: boolean; +} +export interface TerminalsHandle { + rpc: DevframeRpcClient; + dispose: () => void; +} +// #endregion + +// #region Functions +export declare function mountTerminals(_: HTMLElement, _?: MountTerminalsOptions): Promise; +// #endregion + +// #region Other +export { TERMINAL_STREAM_CHANNEL } +export { TerminalPreset } +export { TerminalSessionInfo } +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/client.snapshot.js b/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/client.snapshot.js new file mode 100644 index 0000000..754ad67 --- /dev/null +++ b/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/client.snapshot.js @@ -0,0 +1,10 @@ +/** + * Generated by tsnapi — public API snapshot of `@devframes/plugin-terminals/client` + */ +// #region Functions +export async function mountTerminals(_, _) {} +// #endregion + +// #region Variables +export var TERMINAL_STREAM_CHANNEL /* const */ +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/constants.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/constants.snapshot.d.ts new file mode 100644 index 0000000..95c43d6 --- /dev/null +++ b/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/constants.snapshot.d.ts @@ -0,0 +1,13 @@ +/** + * Generated by tsnapi — public API snapshot of `@devframes/plugin-terminals/constants` + */ +// #region Variables +export declare const DEFAULT_COLS: number; +export declare const DEFAULT_PORT: number; +export declare const DEFAULT_ROWS: number; +export declare const DEFAULT_SCROLLBACK: number; +export declare const PLUGIN_ID: string; +export declare const PRESETS_STATE_KEY: string; +export declare const SESSIONS_STATE_KEY: string; +export declare const TERMINAL_STREAM_CHANNEL: string; +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/constants.snapshot.js b/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/constants.snapshot.js new file mode 100644 index 0000000..f38be69 --- /dev/null +++ b/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/constants.snapshot.js @@ -0,0 +1,13 @@ +/** + * Generated by tsnapi — public API snapshot of `@devframes/plugin-terminals/constants` + */ +// #region Variables +export var DEFAULT_COLS /* const */ +export var DEFAULT_PORT /* const */ +export var DEFAULT_ROWS /* const */ +export var DEFAULT_SCROLLBACK /* const */ +export var PLUGIN_ID /* const */ +export var PRESETS_STATE_KEY /* const */ +export var SESSIONS_STATE_KEY /* const */ +export var TERMINAL_STREAM_CHANNEL /* const */ +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/index.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/index.snapshot.d.ts new file mode 100644 index 0000000..02fd1f7 --- /dev/null +++ b/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/index.snapshot.d.ts @@ -0,0 +1,27 @@ +/** + * Generated by tsnapi — public API snapshot of `@devframes/plugin-terminals` + */ +// #region Functions +export declare function createTerminalsDevframe(_?: TerminalsOptions): DevframeDefinition; +// #endregion + +// #region Default Export +declare const _default: DevframeDefinition; +export default _default +// #endregion + +// #region Other +export { DEFAULT_PORT } +export { PLUGIN_ID } +export { PRESETS_STATE_KEY } +export { SESSIONS_STATE_KEY } +export { SpawnRequest } +export { TERMINAL_STREAM_CHANNEL } +export { TerminalBackend } +export { TerminalMode } +export { TerminalPreset } +export { TerminalSessionInfo } +export { TerminalsOptions } +export { TerminalsSharedState } +export { TerminalStatus } +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/index.snapshot.js b/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/index.snapshot.js new file mode 100644 index 0000000..7e75410 --- /dev/null +++ b/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/index.snapshot.js @@ -0,0 +1,19 @@ +/** + * Generated by tsnapi — public API snapshot of `@devframes/plugin-terminals` + */ +// #region Functions +export function createTerminalsDevframe(_) {} +// #endregion + +// #region Default Export +var _default /* const */ +export default _default +// #endregion + +// #region Other +export { DEFAULT_PORT } +export { PLUGIN_ID } +export { PRESETS_STATE_KEY } +export { SESSIONS_STATE_KEY } +export { TERMINAL_STREAM_CHANNEL } +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/node.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/node.snapshot.d.ts new file mode 100644 index 0000000..1bd85d3 --- /dev/null +++ b/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/node.snapshot.d.ts @@ -0,0 +1,78 @@ +/** + * Generated by tsnapi — public API snapshot of `@devframes/plugin-terminals/node` + */ +// #region Classes +export declare class TerminalManager { + private ctx; + private options; + readonly shell: string; + readonly shellArgs: string[]; + readonly defaultCwd: string; + readonly defaultMode: TerminalMode; + readonly allowArbitraryCommands: boolean; + readonly presets: TerminalPreset[]; + private channel; + private sessionsState?; + private sessions; + private ptyAvailable; + constructor(_: DevframeNodeContext, _?: TerminalsOptions); + init(): Promise; + list(): TerminalSessionInfo[]; + getPresets(): TerminalPreset[]; + private buildEnv; + private resolveSpawn; + spawn(_?: SpawnRequest): TerminalSessionInfo; + private launch; + write(_: string, _: string): void; + resize(_: string, _: number, _: number): void; + terminate(_: string): void; + restart(_: string): TerminalSessionInfo; + remove(_: string): void; + dispose(): void; + private publish; +} +// #endregion + +// #region Functions +export declare function getTerminalManager(_: DevframeNodeContext): TerminalManager; +export declare function isPtyAvailable(): Promise; +export declare function setTerminalManager(_: DevframeNodeContext, _: TerminalManager): void; +export declare function setupTerminals(_: DevframeNodeContext, _?: TerminalsOptions): Promise; +// #endregion + +// #region Variables +export declare const diagnostics: { + readonly DP_TERMINALS_0001: _$nostics.DiagnosticHandle<{ + id: string; + }, { + method?: "log" | "warn" | "error" | undefined; + }>; + readonly DP_TERMINALS_0002: _$nostics.DiagnosticHandle<{ + command: string; + }, { + method?: "log" | "warn" | "error" | undefined; + }>; + readonly DP_TERMINALS_0003: _$nostics.DiagnosticHandle<{ + id: string; + }, { + method?: "log" | "warn" | "error" | undefined; + }>; + readonly DP_TERMINALS_0004: _$nostics.DiagnosticHandle<{ + command: string; + reason: string; + }, { + method?: "log" | "warn" | "error" | undefined; + }>; + readonly DP_TERMINALS_0005: _$nostics.DiagnosticHandle<{}, { + method?: "log" | "warn" | "error" | undefined; + }>; + readonly DP_TERMINALS_0006: _$nostics.DiagnosticHandle<{ + id: string; + }, { + method?: "log" | "warn" | "error" | undefined; + }>; + readonly DP_TERMINALS_0007: _$nostics.DiagnosticHandle<{}, { + method?: "log" | "warn" | "error" | undefined; + }>; +}; +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/node.snapshot.js b/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/node.snapshot.js new file mode 100644 index 0000000..efe9c13 --- /dev/null +++ b/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/node.snapshot.js @@ -0,0 +1,45 @@ +/** + * Generated by tsnapi — public API snapshot of `@devframes/plugin-terminals/node` + */ +// #region Classes +export class TerminalManager { + ctx + options + shell + shellArgs + defaultCwd + defaultMode + allowArbitraryCommands + presets + channel + sessionsState + sessions + ptyAvailable + constructor(_, _) {} + async init() {} + list() {} + getPresets() {} + buildEnv(_) {} + resolveSpawn(_) {} + spawn(_) {} + async launch(_) {} + write(_, _) {} + resize(_, _, _) {} + terminate(_) {} + restart(_) {} + remove(_) {} + dispose() {} + publish() {} +} +// #endregion + +// #region Functions +export async function isPtyAvailable() {} +export async function setupTerminals(_, _) {} +// #endregion + +// #region Other +export { diagnostics } +export { getTerminalManager } +export { setTerminalManager } +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/rpc.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/rpc.snapshot.d.ts new file mode 100644 index 0000000..d3ee335 --- /dev/null +++ b/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/rpc.snapshot.d.ts @@ -0,0 +1,578 @@ +/** + * Generated by tsnapi — public API snapshot of `@devframes/plugin-terminals/rpc` + */ +// #region Variables +export declare const serverFunctions: readonly [{ + name: "devframes-plugin-terminals:list"; + type?: "query" | undefined; + cacheable?: boolean; + args: readonly []; + returns: _$valibot.ArraySchema<_$valibot.ObjectSchema<{ + readonly id: _$valibot.StringSchema; + readonly title: _$valibot.StringSchema; + readonly mode: _$valibot.PicklistSchema<["interactive", "readonly"], undefined>; + readonly status: _$valibot.PicklistSchema<["running", "exited", "error"], undefined>; + readonly backend: _$valibot.PicklistSchema<["pty", "pipe"], undefined>; + readonly command: _$valibot.StringSchema; + readonly args: _$valibot.ArraySchema<_$valibot.StringSchema, undefined>; + readonly cwd: _$valibot.StringSchema; + readonly cols: _$valibot.NumberSchema; + readonly rows: _$valibot.NumberSchema; + readonly pid: _$valibot.OptionalSchema<_$valibot.NumberSchema, undefined>; + readonly exitCode: _$valibot.OptionalSchema<_$valibot.NumberSchema, undefined>; + readonly presetId: _$valibot.OptionalSchema<_$valibot.StringSchema, undefined>; + readonly createdAt: _$valibot.NumberSchema; + }, undefined>, undefined>; + jsonSerializable?: boolean; + agent?: _$devframe.RpcFunctionAgentOptions; + setup?: ((context: _$devframe.DevframeNodeContext) => _$devframe_rpc0.Thenable<_$devframe_rpc0.RpcFunctionSetupResult<[], { + id: string; + title: string; + mode: "interactive" | "readonly"; + status: "running" | "exited" | "error"; + backend: "pty" | "pipe"; + command: string; + args: string[]; + cwd: string; + cols: number; + rows: number; + pid?: number | undefined; + exitCode?: number | undefined; + presetId?: string | undefined; + createdAt: number; + }[]>>) | undefined; + handler?: (() => { + id: string; + title: string; + mode: "interactive" | "readonly"; + status: "running" | "exited" | "error"; + backend: "pty" | "pipe"; + command: string; + args: string[]; + cwd: string; + cols: number; + rows: number; + pid?: number | undefined; + exitCode?: number | undefined; + presetId?: string | undefined; + createdAt: number; + }[]) | undefined; + dump?: _$devframe_rpc0.RpcDump<[], { + id: string; + title: string; + mode: "interactive" | "readonly"; + status: "running" | "exited" | "error"; + backend: "pty" | "pipe"; + command: string; + args: string[]; + cwd: string; + cols: number; + rows: number; + pid?: number | undefined; + exitCode?: number | undefined; + presetId?: string | undefined; + createdAt: number; + }[], _$devframe.DevframeNodeContext> | undefined; + snapshot?: boolean; + __cache?: WeakMap>> | undefined; + __promise?: _$devframe_rpc0.Thenable<_$devframe_rpc0.RpcFunctionSetupResult<[], { + id: string; + title: string; + mode: "interactive" | "readonly"; + status: "running" | "exited" | "error"; + backend: "pty" | "pipe"; + command: string; + args: string[]; + cwd: string; + cols: number; + rows: number; + pid?: number | undefined; + exitCode?: number | undefined; + presetId?: string | undefined; + createdAt: number; + }[]>> | undefined; +}, { + name: "devframes-plugin-terminals:presets"; + type?: "query" | undefined; + cacheable?: boolean; + args: readonly []; + returns: _$valibot.ArraySchema<_$valibot.ObjectSchema<{ + readonly id: _$valibot.StringSchema; + readonly title: _$valibot.StringSchema; + readonly command: _$valibot.StringSchema; + readonly args: _$valibot.ArraySchema<_$valibot.StringSchema, undefined>; + readonly mode: _$valibot.PicklistSchema<["interactive", "readonly"], undefined>; + readonly icon: _$valibot.OptionalSchema<_$valibot.StringSchema, undefined>; + }, undefined>, undefined>; + jsonSerializable?: boolean; + agent?: _$devframe.RpcFunctionAgentOptions; + setup?: ((context: _$devframe.DevframeNodeContext) => _$devframe_rpc0.Thenable<_$devframe_rpc0.RpcFunctionSetupResult<[], { + id: string; + title: string; + command: string; + args: string[]; + mode: "interactive" | "readonly"; + icon?: string | undefined; + }[]>>) | undefined; + handler?: (() => { + id: string; + title: string; + command: string; + args: string[]; + mode: "interactive" | "readonly"; + icon?: string | undefined; + }[]) | undefined; + dump?: _$devframe_rpc0.RpcDump<[], { + id: string; + title: string; + command: string; + args: string[]; + mode: "interactive" | "readonly"; + icon?: string | undefined; + }[], _$devframe.DevframeNodeContext> | undefined; + snapshot?: boolean; + __cache?: WeakMap>> | undefined; + __promise?: _$devframe_rpc0.Thenable<_$devframe_rpc0.RpcFunctionSetupResult<[], { + id: string; + title: string; + command: string; + args: string[]; + mode: "interactive" | "readonly"; + icon?: string | undefined; + }[]>> | undefined; +}, { + name: "devframes-plugin-terminals:spawn"; + type?: "action" | undefined; + cacheable?: boolean; + args: readonly [_$valibot.ObjectSchema<{ + readonly presetId: _$valibot.OptionalSchema<_$valibot.StringSchema, undefined>; + readonly command: _$valibot.OptionalSchema<_$valibot.StringSchema, undefined>; + readonly args: _$valibot.OptionalSchema<_$valibot.ArraySchema<_$valibot.StringSchema, undefined>, undefined>; + readonly cwd: _$valibot.OptionalSchema<_$valibot.StringSchema, undefined>; + readonly mode: _$valibot.OptionalSchema<_$valibot.PicklistSchema<["interactive", "readonly"], undefined>, undefined>; + readonly title: _$valibot.OptionalSchema<_$valibot.StringSchema, undefined>; + readonly cols: _$valibot.OptionalSchema<_$valibot.NumberSchema, undefined>; + readonly rows: _$valibot.OptionalSchema<_$valibot.NumberSchema, undefined>; + readonly env: _$valibot.OptionalSchema<_$valibot.RecordSchema<_$valibot.StringSchema, _$valibot.StringSchema, undefined>, undefined>; + }, undefined>]; + returns: _$valibot.ObjectSchema<{ + readonly id: _$valibot.StringSchema; + readonly title: _$valibot.StringSchema; + readonly mode: _$valibot.PicklistSchema<["interactive", "readonly"], undefined>; + readonly status: _$valibot.PicklistSchema<["running", "exited", "error"], undefined>; + readonly backend: _$valibot.PicklistSchema<["pty", "pipe"], undefined>; + readonly command: _$valibot.StringSchema; + readonly args: _$valibot.ArraySchema<_$valibot.StringSchema, undefined>; + readonly cwd: _$valibot.StringSchema; + readonly cols: _$valibot.NumberSchema; + readonly rows: _$valibot.NumberSchema; + readonly pid: _$valibot.OptionalSchema<_$valibot.NumberSchema, undefined>; + readonly exitCode: _$valibot.OptionalSchema<_$valibot.NumberSchema, undefined>; + readonly presetId: _$valibot.OptionalSchema<_$valibot.StringSchema, undefined>; + readonly createdAt: _$valibot.NumberSchema; + }, undefined>; + jsonSerializable?: boolean; + agent?: _$devframe.RpcFunctionAgentOptions; + setup?: ((context: _$devframe.DevframeNodeContext) => _$devframe_rpc0.Thenable<_$devframe_rpc0.RpcFunctionSetupResult<[{ + presetId?: string | undefined; + command?: string | undefined; + args?: string[] | undefined; + cwd?: string | undefined; + mode?: "interactive" | "readonly" | undefined; + title?: string | undefined; + cols?: number | undefined; + rows?: number | undefined; + env?: { + [x: string]: string; + } | undefined; + }], { + id: string; + title: string; + mode: "interactive" | "readonly"; + status: "running" | "exited" | "error"; + backend: "pty" | "pipe"; + command: string; + args: string[]; + cwd: string; + cols: number; + rows: number; + pid?: number | undefined; + exitCode?: number | undefined; + presetId?: string | undefined; + createdAt: number; + }>>) | undefined; + handler?: ((args_0: { + presetId?: string | undefined; + command?: string | undefined; + args?: string[] | undefined; + cwd?: string | undefined; + mode?: "interactive" | "readonly" | undefined; + title?: string | undefined; + cols?: number | undefined; + rows?: number | undefined; + env?: { + [x: string]: string; + } | undefined; + }) => { + id: string; + title: string; + mode: "interactive" | "readonly"; + status: "running" | "exited" | "error"; + backend: "pty" | "pipe"; + command: string; + args: string[]; + cwd: string; + cols: number; + rows: number; + pid?: number | undefined; + exitCode?: number | undefined; + presetId?: string | undefined; + createdAt: number; + }) | undefined; + dump?: _$devframe_rpc0.RpcDump<[{ + presetId?: string | undefined; + command?: string | undefined; + args?: string[] | undefined; + cwd?: string | undefined; + mode?: "interactive" | "readonly" | undefined; + title?: string | undefined; + cols?: number | undefined; + rows?: number | undefined; + env?: { + [x: string]: string; + } | undefined; + }], { + id: string; + title: string; + mode: "interactive" | "readonly"; + status: "running" | "exited" | "error"; + backend: "pty" | "pipe"; + command: string; + args: string[]; + cwd: string; + cols: number; + rows: number; + pid?: number | undefined; + exitCode?: number | undefined; + presetId?: string | undefined; + createdAt: number; + }, _$devframe.DevframeNodeContext> | undefined; + snapshot?: boolean; + __cache?: WeakMap>> | undefined; + __promise?: _$devframe_rpc0.Thenable<_$devframe_rpc0.RpcFunctionSetupResult<[{ + presetId?: string | undefined; + command?: string | undefined; + args?: string[] | undefined; + cwd?: string | undefined; + mode?: "interactive" | "readonly" | undefined; + title?: string | undefined; + cols?: number | undefined; + rows?: number | undefined; + env?: { + [x: string]: string; + } | undefined; + }], { + id: string; + title: string; + mode: "interactive" | "readonly"; + status: "running" | "exited" | "error"; + backend: "pty" | "pipe"; + command: string; + args: string[]; + cwd: string; + cols: number; + rows: number; + pid?: number | undefined; + exitCode?: number | undefined; + presetId?: string | undefined; + createdAt: number; + }>> | undefined; +}, { + name: "devframes-plugin-terminals:write"; + type?: "action" | undefined; + cacheable?: boolean; + args: readonly [_$valibot.ObjectSchema<{ + readonly id: _$valibot.StringSchema; + readonly data: _$valibot.StringSchema; + }, undefined>]; + returns: _$valibot.VoidSchema; + jsonSerializable?: boolean; + agent?: _$devframe.RpcFunctionAgentOptions; + setup?: ((context: _$devframe.DevframeNodeContext) => _$devframe_rpc0.Thenable<_$devframe_rpc0.RpcFunctionSetupResult<[{ + id: string; + data: string; + }], void>>) | undefined; + handler?: ((args_0: { + id: string; + data: string; + }) => void) | undefined; + dump?: _$devframe_rpc0.RpcDump<[{ + id: string; + data: string; + }], void, _$devframe.DevframeNodeContext> | undefined; + snapshot?: boolean; + __cache?: WeakMap>> | undefined; + __promise?: _$devframe_rpc0.Thenable<_$devframe_rpc0.RpcFunctionSetupResult<[{ + id: string; + data: string; + }], void>> | undefined; +}, { + name: "devframes-plugin-terminals:resize"; + type?: "action" | undefined; + cacheable?: boolean; + args: readonly [_$valibot.ObjectSchema<{ + readonly id: _$valibot.StringSchema; + readonly cols: _$valibot.SchemaWithPipe, _$valibot.IntegerAction, _$valibot.MinValueAction]>; + readonly rows: _$valibot.SchemaWithPipe, _$valibot.IntegerAction, _$valibot.MinValueAction]>; + }, undefined>]; + returns: _$valibot.VoidSchema; + jsonSerializable?: boolean; + agent?: _$devframe.RpcFunctionAgentOptions; + setup?: ((context: _$devframe.DevframeNodeContext) => _$devframe_rpc0.Thenable<_$devframe_rpc0.RpcFunctionSetupResult<[{ + id: string; + cols: number; + rows: number; + }], void>>) | undefined; + handler?: ((args_0: { + id: string; + cols: number; + rows: number; + }) => void) | undefined; + dump?: _$devframe_rpc0.RpcDump<[{ + id: string; + cols: number; + rows: number; + }], void, _$devframe.DevframeNodeContext> | undefined; + snapshot?: boolean; + __cache?: WeakMap>> | undefined; + __promise?: _$devframe_rpc0.Thenable<_$devframe_rpc0.RpcFunctionSetupResult<[{ + id: string; + cols: number; + rows: number; + }], void>> | undefined; +}, { + name: "devframes-plugin-terminals:terminate"; + type?: "action" | undefined; + cacheable?: boolean; + args: readonly [_$valibot.ObjectSchema<{ + readonly id: _$valibot.StringSchema; + }, undefined>]; + returns: _$valibot.VoidSchema; + jsonSerializable?: boolean; + agent?: _$devframe.RpcFunctionAgentOptions; + setup?: ((context: _$devframe.DevframeNodeContext) => _$devframe_rpc0.Thenable<_$devframe_rpc0.RpcFunctionSetupResult<[{ + id: string; + }], void>>) | undefined; + handler?: ((args_0: { + id: string; + }) => void) | undefined; + dump?: _$devframe_rpc0.RpcDump<[{ + id: string; + }], void, _$devframe.DevframeNodeContext> | undefined; + snapshot?: boolean; + __cache?: WeakMap>> | undefined; + __promise?: _$devframe_rpc0.Thenable<_$devframe_rpc0.RpcFunctionSetupResult<[{ + id: string; + }], void>> | undefined; +}, { + name: "devframes-plugin-terminals:restart"; + type?: "action" | undefined; + cacheable?: boolean; + args: readonly [_$valibot.ObjectSchema<{ + readonly id: _$valibot.StringSchema; + }, undefined>]; + returns: _$valibot.ObjectSchema<{ + readonly id: _$valibot.StringSchema; + readonly title: _$valibot.StringSchema; + readonly mode: _$valibot.PicklistSchema<["interactive", "readonly"], undefined>; + readonly status: _$valibot.PicklistSchema<["running", "exited", "error"], undefined>; + readonly backend: _$valibot.PicklistSchema<["pty", "pipe"], undefined>; + readonly command: _$valibot.StringSchema; + readonly args: _$valibot.ArraySchema<_$valibot.StringSchema, undefined>; + readonly cwd: _$valibot.StringSchema; + readonly cols: _$valibot.NumberSchema; + readonly rows: _$valibot.NumberSchema; + readonly pid: _$valibot.OptionalSchema<_$valibot.NumberSchema, undefined>; + readonly exitCode: _$valibot.OptionalSchema<_$valibot.NumberSchema, undefined>; + readonly presetId: _$valibot.OptionalSchema<_$valibot.StringSchema, undefined>; + readonly createdAt: _$valibot.NumberSchema; + }, undefined>; + jsonSerializable?: boolean; + agent?: _$devframe.RpcFunctionAgentOptions; + setup?: ((context: _$devframe.DevframeNodeContext) => _$devframe_rpc0.Thenable<_$devframe_rpc0.RpcFunctionSetupResult<[{ + id: string; + }], { + id: string; + title: string; + mode: "interactive" | "readonly"; + status: "running" | "exited" | "error"; + backend: "pty" | "pipe"; + command: string; + args: string[]; + cwd: string; + cols: number; + rows: number; + pid?: number | undefined; + exitCode?: number | undefined; + presetId?: string | undefined; + createdAt: number; + }>>) | undefined; + handler?: ((args_0: { + id: string; + }) => { + id: string; + title: string; + mode: "interactive" | "readonly"; + status: "running" | "exited" | "error"; + backend: "pty" | "pipe"; + command: string; + args: string[]; + cwd: string; + cols: number; + rows: number; + pid?: number | undefined; + exitCode?: number | undefined; + presetId?: string | undefined; + createdAt: number; + }) | undefined; + dump?: _$devframe_rpc0.RpcDump<[{ + id: string; + }], { + id: string; + title: string; + mode: "interactive" | "readonly"; + status: "running" | "exited" | "error"; + backend: "pty" | "pipe"; + command: string; + args: string[]; + cwd: string; + cols: number; + rows: number; + pid?: number | undefined; + exitCode?: number | undefined; + presetId?: string | undefined; + createdAt: number; + }, _$devframe.DevframeNodeContext> | undefined; + snapshot?: boolean; + __cache?: WeakMap>> | undefined; + __promise?: _$devframe_rpc0.Thenable<_$devframe_rpc0.RpcFunctionSetupResult<[{ + id: string; + }], { + id: string; + title: string; + mode: "interactive" | "readonly"; + status: "running" | "exited" | "error"; + backend: "pty" | "pipe"; + command: string; + args: string[]; + cwd: string; + cols: number; + rows: number; + pid?: number | undefined; + exitCode?: number | undefined; + presetId?: string | undefined; + createdAt: number; + }>> | undefined; +}, { + name: "devframes-plugin-terminals:remove"; + type?: "action" | undefined; + cacheable?: boolean; + args: readonly [_$valibot.ObjectSchema<{ + readonly id: _$valibot.StringSchema; + }, undefined>]; + returns: _$valibot.VoidSchema; + jsonSerializable?: boolean; + agent?: _$devframe.RpcFunctionAgentOptions; + setup?: ((context: _$devframe.DevframeNodeContext) => _$devframe_rpc0.Thenable<_$devframe_rpc0.RpcFunctionSetupResult<[{ + id: string; + }], void>>) | undefined; + handler?: ((args_0: { + id: string; + }) => void) | undefined; + dump?: _$devframe_rpc0.RpcDump<[{ + id: string; + }], void, _$devframe.DevframeNodeContext> | undefined; + snapshot?: boolean; + __cache?: WeakMap>> | undefined; + __promise?: _$devframe_rpc0.Thenable<_$devframe_rpc0.RpcFunctionSetupResult<[{ + id: string; + }], void>> | undefined; +}]; +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/rpc.snapshot.js b/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/rpc.snapshot.js new file mode 100644 index 0000000..509a3e3 --- /dev/null +++ b/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/rpc.snapshot.js @@ -0,0 +1,6 @@ +/** + * Generated by tsnapi — public API snapshot of `@devframes/plugin-terminals/rpc` + */ +// #region Other +export { serverFunctions } +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/types.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/types.snapshot.d.ts new file mode 100644 index 0000000..ed9a731 --- /dev/null +++ b/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/types.snapshot.d.ts @@ -0,0 +1,65 @@ +/** + * Generated by tsnapi — public API snapshot of `@devframes/plugin-terminals/types` + */ +// #region Interfaces +export interface SpawnRequest { + presetId?: string; + command?: string; + args?: string[]; + cwd?: string; + mode?: TerminalMode; + title?: string; + cols?: number; + rows?: number; + env?: Record; +} +export interface TerminalPreset { + id: string; + title: string; + command: string; + args?: string[]; + cwd?: string; + mode?: TerminalMode; + env?: Record; + icon?: string; +} +export interface TerminalSessionInfo { + id: string; + title: string; + mode: TerminalMode; + status: TerminalStatus; + backend: TerminalBackend; + command: string; + args: string[]; + cwd: string; + cols: number; + rows: number; + pid?: number; + exitCode?: number; + presetId?: string; + createdAt: number; +} +export interface TerminalsOptions { + shell?: string; + shellArgs?: string[]; + cwd?: string; + env?: Record; + presets?: TerminalPreset[]; + allowArbitraryCommands?: boolean; + defaultMode?: TerminalMode; + scrollback?: number; + basePath?: string; + distDir?: string; + command?: string; + port?: number; +} +export interface TerminalsSharedState { + sessions: TerminalSessionInfo[]; +} +// #endregion + +// #region Types +export type TerminalBackend = 'pty' | 'pipe'; +export type TerminalMode = 'interactive' | 'readonly'; +export type TerminalStatus = 'running' | 'exited' | 'error'; +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/types.snapshot.js b/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/types.snapshot.js new file mode 100644 index 0000000..d0fcc92 --- /dev/null +++ b/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/types.snapshot.js @@ -0,0 +1,4 @@ +/** + * Generated by tsnapi — public API snapshot of `@devframes/plugin-terminals/types` + */ +/* no exports */ \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/vite.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/vite.snapshot.d.ts new file mode 100644 index 0000000..35a27ba --- /dev/null +++ b/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/vite.snapshot.d.ts @@ -0,0 +1,12 @@ +/** + * Generated by tsnapi — public API snapshot of `@devframes/plugin-terminals/vite` + */ +// #region Interfaces +export interface TerminalsViteOptions extends TerminalsOptions { + vite?: ViteDevBridgeOptions; +} +// #endregion + +// #region Functions +export declare function terminalsVite(_?: TerminalsViteOptions): DevframeVitePlugin[]; +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/vite.snapshot.js b/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/vite.snapshot.js new file mode 100644 index 0000000..ccd36ac --- /dev/null +++ b/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/vite.snapshot.js @@ -0,0 +1,6 @@ +/** + * Generated by tsnapi — public API snapshot of `@devframes/plugin-terminals/vite` + */ +// #region Functions +export function terminalsVite(_) {} +// #endregion \ No newline at end of file diff --git a/tsconfig.base.json b/tsconfig.base.json index 1fac0a9..99e9681 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -120,6 +120,27 @@ "@devframes/nuxt": [ "./packages/nuxt/src/index.ts" ], + "@devframes/plugin-terminals/client": [ + "./plugins/terminals/src/client/index.ts" + ], + "@devframes/plugin-terminals/node": [ + "./plugins/terminals/src/node/index.ts" + ], + "@devframes/plugin-terminals/constants": [ + "./plugins/terminals/src/constants.ts" + ], + "@devframes/plugin-terminals/types": [ + "./plugins/terminals/src/types.ts" + ], + "@devframes/plugin-terminals/cli": [ + "./plugins/terminals/src/cli.ts" + ], + "@devframes/plugin-terminals/vite": [ + "./plugins/terminals/src/vite.ts" + ], + "@devframes/plugin-terminals": [ + "./plugins/terminals/src/index.ts" + ], "devframe/recipes/open-helpers": [ "./packages/devframe/src/recipes/open-helpers.ts" ], diff --git a/turbo.json b/turbo.json index 5eb83cf..d338bd5 100644 --- a/turbo.json +++ b/turbo.json @@ -15,6 +15,11 @@ "outputLogs": "new-only", "outputs": ["dist/**"] }, + "@devframes/plugin-terminals#build": { + "outputLogs": "new-only", + "dependsOn": ["devframe#build"], + "outputs": ["dist/**"] + }, "minimal-vite-devframe-hub#build": { "outputLogs": "new-only", "dependsOn": ["@devframes/hub#build", "devframe#build"], diff --git a/vitest.config.ts b/vitest.config.ts index 79b7cdc..468eaf2 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -9,6 +9,7 @@ export default defineConfig({ projects: [ 'packages/devframe', 'packages/hub', + 'plugins/terminals', 'examples/files-inspector', 'examples/streaming-chat', 'examples/next-runtime-snapshot', From 4ac814629318d698a8ffcbc4a3c392c0ccedbd38 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Fri, 19 Jun 2026 03:26:49 +0000 Subject: [PATCH 2/7] feat(plugin-terminals): light/dark theming + live process-name tabs with rename MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Follow the system color mode and react to changes at runtime: the UI chrome (CSS variables) and every xterm instance switch between dark and a GitHub-light palette without reload, driven by prefers-color-scheme. - Tab labels show the live foreground process of the controlling TTY (e.g. bash → vim → bash), polled from node-pty for PTY sessions. - Custom renaming: double-click a tab to edit inline; backed by a new `devframes-plugin-terminals:rename` RPC. Display precedence is customTitle > processName > base title. Adds processName/customTitle to the session descriptor, a getProcessName hook on the PTY backend, an unref'd poll timer (cleared on exit/restart/ remove/dispose), and tests for process-name tracking and renaming. --- plugins/terminals/src/client/index.ts | 170 +++++++++++++++--- plugins/terminals/src/node/backend.ts | 15 ++ plugins/terminals/src/node/manager.ts | 48 +++++ plugins/terminals/src/rpc/functions/rename.ts | 16 ++ plugins/terminals/src/rpc/index.ts | 2 + plugins/terminals/src/rpc/schemas.ts | 2 + plugins/terminals/src/spa/index.html | 4 +- plugins/terminals/src/types.ts | 9 + plugins/terminals/test/terminals.test.ts | 41 +++++ .../plugin-terminals/node.snapshot.d.ts | 3 + .../plugin-terminals/node.snapshot.js | 3 + .../plugin-terminals/rpc.snapshot.d.ts | 68 +++++++ .../plugin-terminals/types.snapshot.d.ts | 2 + 13 files changed, 358 insertions(+), 25 deletions(-) create mode 100644 plugins/terminals/src/rpc/functions/rename.ts diff --git a/plugins/terminals/src/client/index.ts b/plugins/terminals/src/client/index.ts index a7fd41c..8e28a9d 100644 --- a/plugins/terminals/src/client/index.ts +++ b/plugins/terminals/src/client/index.ts @@ -1,3 +1,4 @@ +import type { ITheme } from '@xterm/xterm' import type { DevframeRpcClient } from 'devframe/client' import type { StreamReader } from 'devframe/utils/streaming-channel' import type { TerminalPreset, TerminalSessionInfo, TerminalsSharedState } from '../types' @@ -33,48 +34,87 @@ interface SessionView { const UI_CSS = ` .dft-root { position: absolute; inset: 0; display: flex; flex-direction: column; - font-family: system-ui, sans-serif; background: #0b0e14; color: #c9d1d9; } + font-family: system-ui, sans-serif; background: var(--dft-bg); color: var(--dft-fg); } +.dft-root.dft-dark { + --dft-bg: #0d1117; --dft-fg: #c9d1d9; --dft-muted: #8b949e; + --dft-border: #1c2128; --dft-surface: #161b22; --dft-surface-hover: #30363d; + --dft-surface-active: #21262d; --dft-term-bg: #000000; --dft-accent: #58a6ff; +} +.dft-root.dft-light { + --dft-bg: #f6f8fa; --dft-fg: #1f2328; --dft-muted: #59636e; + --dft-border: #d0d7de; --dft-surface: #ffffff; --dft-surface-hover: #eaeef2; + --dft-surface-active: #ffffff; --dft-term-bg: #ffffff; --dft-accent: #0969da; +} .dft-header { display: flex; align-items: stretch; gap: 4px; padding: 6px 8px; - border-bottom: 1px solid #1c2128; background: #0d1117; } + border-bottom: 1px solid var(--dft-border); background: var(--dft-bg); } .dft-tabs { display: flex; gap: 4px; overflow-x: auto; flex: 1; align-items: center; } .dft-tab { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; - padding: 4px 10px; border-radius: 6px; border: 1px solid transparent; background: #161b22; - color: #8b949e; font-size: 12px; cursor: pointer; } -.dft-tab:hover { color: #c9d1d9; } -.dft-tab.active { background: #21262d; color: #fff; border-color: #30363d; } + padding: 4px 10px; border-radius: 6px; border: 1px solid transparent; background: var(--dft-surface); + color: var(--dft-muted); font-size: 12px; cursor: pointer; } +.dft-tab:hover { color: var(--dft-fg); } +.dft-tab.active { background: var(--dft-surface-active); color: var(--dft-fg); border-color: var(--dft-border); } .dft-dot { width: 7px; height: 7px; border-radius: 50%; background: #3fb950; flex: none; } .dft-dot.exited { background: #6e7681; } .dft-dot.error { background: #f85149; } .dft-actions { display: flex; gap: 6px; align-items: center; } -.dft-btn { padding: 4px 10px; border-radius: 6px; border: 1px solid #30363d; - background: #21262d; color: #c9d1d9; font-size: 12px; cursor: pointer; } -.dft-btn:hover { background: #30363d; } +.dft-btn { padding: 4px 10px; border-radius: 6px; border: 1px solid var(--dft-border); + background: var(--dft-surface); color: var(--dft-fg); font-size: 12px; cursor: pointer; } +.dft-btn:hover { background: var(--dft-surface-hover); } .dft-btn:disabled { opacity: 0.45; cursor: default; } -.dft-select { padding: 4px 8px; border-radius: 6px; border: 1px solid #30363d; - background: #21262d; color: #c9d1d9; font-size: 12px; } +.dft-select { padding: 4px 8px; border-radius: 6px; border: 1px solid var(--dft-border); + background: var(--dft-surface); color: var(--dft-fg); font-size: 12px; } +.dft-rename { font: inherit; font-size: 12px; width: 10ch; min-width: 64px; padding: 1px 5px; + border: 1px solid var(--dft-accent); border-radius: 4px; background: var(--dft-bg); + color: var(--dft-fg); outline: none; } .dft-toolbar { display: flex; align-items: center; gap: 8px; padding: 4px 10px; - border-bottom: 1px solid #1c2128; font-size: 12px; color: #8b949e; min-height: 20px; } + border-bottom: 1px solid var(--dft-border); font-size: 12px; color: var(--dft-muted); min-height: 20px; } .dft-badge { padding: 1px 7px; border-radius: 10px; font-size: 10px; text-transform: uppercase; - letter-spacing: 0.03em; border: 1px solid #30363d; } -.dft-badge.interactive { color: #58a6ff; border-color: #1f6feb55; } -.dft-badge.readonly { color: #d29922; border-color: #9e6a0355; } + letter-spacing: 0.03em; border: 1px solid var(--dft-border); } +.dft-badge.interactive { color: var(--dft-accent); border-color: #1f6feb55; } +.dft-badge.readonly { color: #bb8009; border-color: #9e6a0355; } .dft-spacer { flex: 1; } -.dft-body { position: relative; flex: 1; overflow: hidden; background: #000; } +.dft-body { position: relative; flex: 1; overflow: hidden; background: var(--dft-term-bg); } .dft-view { position: absolute; inset: 0; padding: 4px; display: none; } .dft-view.active { display: block; } .dft-empty { position: absolute; inset: 0; display: flex; align-items: center; - justify-content: center; color: #6e7681; font-size: 13px; pointer-events: none; } + justify-content: center; color: var(--dft-muted); font-size: 13px; pointer-events: none; } .dft-view .xterm, .dft-view .xterm-viewport, .dft-view .xterm-screen { height: 100%; } -.dft-mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; color: #c9d1d9; } +.dft-mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--dft-fg); } ` -const THEME = { +const DARK_THEME: ITheme = { background: '#000000', foreground: '#c9d1d9', cursor: '#58a6ff', + cursorAccent: '#000000', selectionBackground: '#234876', } +// GitHub-light palette so the default-bright ANSI colors stay legible on white. +const LIGHT_THEME: ITheme = { + background: '#ffffff', + foreground: '#1f2328', + cursor: '#0969da', + cursorAccent: '#ffffff', + selectionBackground: '#b6d7ff', + black: '#24292f', + red: '#cf222e', + green: '#116329', + yellow: '#7d4e00', + blue: '#0969da', + magenta: '#8250df', + cyan: '#1b7c83', + white: '#6e7781', + brightBlack: '#57606a', + brightRed: '#a40e26', + brightGreen: '#1a7f37', + brightYellow: '#633c01', + brightBlue: '#218bff', + brightMagenta: '#a475f9', + brightCyan: '#3192aa', + brightWhite: '#8c959f', +} + let stylesInjected = false function injectStyles(): void { if (stylesInjected || typeof document === 'undefined') @@ -133,6 +173,37 @@ export async function mountTerminals( let activeId: string | null = null let presets: TerminalPreset[] = [] let disposed = false + let renamingId: string | null = null + + // Follow the system color mode and react to changes at runtime, switching + // both the UI chrome (via CSS classes) and every xterm instance's theme. + const colorScheme = typeof window !== 'undefined' && window.matchMedia + ? window.matchMedia('(prefers-color-scheme: dark)') + : null + let isDark = colorScheme ? colorScheme.matches : true + + function activeTheme(): ITheme { + return isDark ? DARK_THEME : LIGHT_THEME + } + + function applyColorScheme(): void { + root.classList.toggle('dft-dark', isDark) + root.classList.toggle('dft-light', !isDark) + for (const view of views.values()) + view.term.options.theme = activeTheme() + } + + const onColorScheme = (e: MediaQueryListEvent): void => { + isDark = e.matches + applyColorScheme() + } + colorScheme?.addEventListener('change', onColorScheme) + applyColorScheme() + + /** Tab/toolbar label: custom name wins, then the live process, then the base title. */ + function displayName(info: TerminalSessionInfo): string { + return info.customTitle || info.processName || info.title + } function spawn(req: Parameters[1]): void { rpc.call('devframes-plugin-terminals:spawn', req as any).catch(() => {}) @@ -202,7 +273,9 @@ export async function mountTerminals( const badge = el('span', `dft-badge ${info.mode}`) badge.textContent = info.mode const label = el('span', 'dft-mono') - label.textContent = `${info.command}${info.args.length ? ` ${info.args.join(' ')}` : ''}` + label.textContent = info.processName && info.processName !== info.command + ? `${info.processName} · ${info.command}` + : `${info.command}${info.args.length ? ` ${info.args.join(' ')}` : ''}` const status = el('span') status.textContent = info.status === 'running' ? `running · ${info.backend}${info.pid ? ` · pid ${info.pid}` : ''}` @@ -234,7 +307,7 @@ export async function mountTerminals( fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', fontSize: 13, scrollback: 10000, - theme: THEME, + theme: activeTheme(), disableStdin: info.mode !== 'interactive', allowProposedApi: false, }) @@ -264,6 +337,13 @@ export async function mountTerminals( const tab = el('button', 'dft-tab') tab.onclick = () => setActive(info.id) + tab.ondblclick = (e) => { + e.preventDefault() + e.stopPropagation() + const view = views.get(info.id) + if (view) + startRename(view) + } requestAnimationFrame(() => { try { @@ -284,16 +364,57 @@ export async function mountTerminals( function renderTabs(): void { for (const view of views.values()) { + if (view.tab.parentElement !== tabs) + tabs.append(view.tab) + // Leave the tab being renamed untouched so its input survives + // concurrent shared-state updates. + if (view.info.id === renamingId) + continue view.tab.replaceChildren() const dot = el('span', `dft-dot ${view.info.status === 'running' ? '' : view.info.status}`) const label = el('span') - label.textContent = view.info.title + label.textContent = displayName(view.info) + view.tab.title = 'Double-click to rename' view.tab.append(dot, label) - if (view.tab.parentElement !== tabs) - tabs.append(view.tab) } } + /** Inline-edit a tab name; commits via the rename RPC on Enter/blur. */ + function startRename(view: SessionView): void { + renamingId = view.info.id + const input = el('input', 'dft-rename') + input.value = displayName(view.info) + input.spellcheck = false + view.tab.replaceChildren(input) + input.focus() + input.select() + + let settled = false + const finish = (commit: boolean): void => { + if (settled) + return + settled = true + renamingId = null + if (commit) { + rpc.call('devframes-plugin-terminals:rename', { id: view.info.id, title: input.value.trim() }) + .catch(() => {}) + } + renderTabs() + } + input.onclick = e => e.stopPropagation() + input.onkeydown = (e) => { + if (e.key === 'Enter') { + e.preventDefault() + finish(true) + } + else if (e.key === 'Escape') { + e.preventDefault() + finish(false) + } + } + input.onblur = () => finish(true) + } + function syncSessions(sessions: TerminalSessionInfo[]): void { if (disposed) return @@ -362,6 +483,7 @@ export async function mountTerminals( disposed = true offSessions?.() offPresets?.() + colorScheme?.removeEventListener('change', onColorScheme) resizeObserver?.disconnect() for (const view of views.values()) disposeView(view) diff --git a/plugins/terminals/src/node/backend.ts b/plugins/terminals/src/node/backend.ts index ce32720..75351e7 100644 --- a/plugins/terminals/src/node/backend.ts +++ b/plugins/terminals/src/node/backend.ts @@ -16,6 +16,11 @@ export interface TerminalProcess { kill: (signal?: string) => void onData: (cb: (data: string) => void) => void onExit: (cb: (exitCode: number) => void) => void + /** + * Current foreground process name of the controlling TTY, when the + * backend can resolve it (PTY only). Polled by the manager. + */ + getProcessName?: () => string | undefined } export interface SpawnBackendOptions { @@ -41,6 +46,8 @@ interface PtyModule { interface PtyProcess { pid: number + /** Foreground process title; updated by node-pty as the foreground changes. */ + readonly process?: string onData: (cb: (data: string) => void) => void onExit: (cb: (e: { exitCode: number, signal?: number }) => void) => void write: (data: string) => void @@ -127,6 +134,14 @@ export async function spawnPty(options: SpawnBackendOptions): Promise proc.onData(cb), onExit: cb => proc.onExit(e => cb(e.exitCode ?? 0)), + getProcessName: () => { + try { + return proc.process || undefined + } + catch { + return undefined + } + }, } } diff --git a/plugins/terminals/src/node/manager.ts b/plugins/terminals/src/node/manager.ts index 0db654c..c693d8f 100644 --- a/plugins/terminals/src/node/manager.ts +++ b/plugins/terminals/src/node/manager.ts @@ -40,8 +40,12 @@ interface ManagedSession { sink: StreamSink spawn: ResolvedSpawn proc?: TerminalProcess + pollTimer?: ReturnType } +/** How often a PTY session's foreground process name is polled. */ +const PROCESS_POLL_INTERVAL = 1000 + function defaultShell(): string { if (process.platform === 'win32') return process.env.COMSPEC || 'powershell.exe' @@ -253,17 +257,48 @@ export class TerminalManager { // Ignore the exit of a process replaced by restart(). if (session.proc !== proc) return + this.stopProcessPoll(session) session.info.status = code === 0 ? 'exited' : 'error' session.info.exitCode = code session.info.pid = undefined + session.info.processName = undefined if (!sink.closed) sink.write(`\r\n\x1B[2m[process exited with code ${code}]\x1B[0m\r\n`) this.publish() }) + this.startProcessPoll(session) this.publish() } + /** + * Poll the PTY foreground process name and reflect changes (e.g. `bash` + * → `vim` → `bash`) into the session info. The interval is `unref`'d so + * it never keeps the process alive on its own. + */ + private startProcessPoll(session: ManagedSession): void { + const read = session.proc?.getProcessName + if (!read) + return + const poll = (): void => { + const name = session.proc?.getProcessName?.() + if (name && name !== session.info.processName) { + session.info.processName = name + this.publish() + } + } + poll() + session.pollTimer = setInterval(poll, PROCESS_POLL_INTERVAL) + session.pollTimer.unref?.() + } + + private stopProcessPoll(session: ManagedSession): void { + if (session.pollTimer) { + clearInterval(session.pollTimer) + session.pollTimer = undefined + } + } + write(id: string, data: string): void { const session = this.sessions.get(id) if (!session) @@ -294,6 +329,16 @@ export class TerminalManager { session.proc?.kill() } + /** Set a custom display name. Pass an empty string to clear it. */ + rename(id: string, title: string): void { + const session = this.sessions.get(id) + if (!session) + throw diagnostics.DP_TERMINALS_0001({ id }) + const trimmed = title.trim() + session.info.customTitle = trimmed.length ? trimmed : undefined + this.publish() + } + /** Restart the session's command in place, reusing the same stream id. */ restart(id: string): TerminalSessionInfo { const session = this.sessions.get(id) @@ -301,6 +346,7 @@ export class TerminalManager { throw diagnostics.DP_TERMINALS_0001({ id }) const previous = session.proc session.proc = undefined + this.stopProcessPoll(session) previous?.kill() if (!session.sink.closed) session.sink.write('\r\n\x1B[2m[restarting…]\x1B[0m\r\n') @@ -318,6 +364,7 @@ export class TerminalManager { throw diagnostics.DP_TERMINALS_0001({ id }) const proc = session.proc session.proc = undefined + this.stopProcessPoll(session) proc?.kill() if (!session.sink.closed) session.sink.close() @@ -328,6 +375,7 @@ export class TerminalManager { /** Tear everything down — used on server shutdown and in tests. */ dispose(): void { for (const session of this.sessions.values()) { + this.stopProcessPoll(session) session.proc?.kill() if (!session.sink.closed) session.sink.close() diff --git a/plugins/terminals/src/rpc/functions/rename.ts b/plugins/terminals/src/rpc/functions/rename.ts new file mode 100644 index 0000000..88b39f9 --- /dev/null +++ b/plugins/terminals/src/rpc/functions/rename.ts @@ -0,0 +1,16 @@ +import { defineRpcFunction } from 'devframe' +import * as v from 'valibot' +import { getTerminalManager } from '../../node/context' + +export const rename = defineRpcFunction({ + name: 'devframes-plugin-terminals:rename', + type: 'action', + jsonSerializable: true, + args: [v.object({ id: v.string(), title: v.string() })], + returns: v.void(), + setup: ctx => ({ + handler: ({ id, title }) => { + getTerminalManager(ctx).rename(id, title) + }, + }), +}) diff --git a/plugins/terminals/src/rpc/index.ts b/plugins/terminals/src/rpc/index.ts index 8ff3179..d5d1d17 100644 --- a/plugins/terminals/src/rpc/index.ts +++ b/plugins/terminals/src/rpc/index.ts @@ -3,6 +3,7 @@ import type { TerminalPreset, TerminalsSharedState } from '../types' import { list } from './functions/list' import { presets } from './functions/presets' import { remove } from './functions/remove' +import { rename } from './functions/rename' import { resize } from './functions/resize' import { restart } from './functions/restart' import { spawn } from './functions/spawn' @@ -17,6 +18,7 @@ export const serverFunctions = [ resize, terminate, restart, + rename, remove, ] as const diff --git a/plugins/terminals/src/rpc/schemas.ts b/plugins/terminals/src/rpc/schemas.ts index f96b200..52d12ab 100644 --- a/plugins/terminals/src/rpc/schemas.ts +++ b/plugins/terminals/src/rpc/schemas.ts @@ -17,6 +17,8 @@ export const spawnRequestSchema = v.object({ export const sessionInfoSchema = v.object({ id: v.string(), title: v.string(), + processName: v.optional(v.string()), + customTitle: v.optional(v.string()), mode: terminalModeSchema, status: v.picklist(['running', 'exited', 'error']), backend: v.picklist(['pty', 'pipe']), diff --git a/plugins/terminals/src/spa/index.html b/plugins/terminals/src/spa/index.html index 3fef113..fc3dc91 100644 --- a/plugins/terminals/src/spa/index.html +++ b/plugins/terminals/src/spa/index.html @@ -5,8 +5,10 @@ Terminals diff --git a/plugins/terminals/src/types.ts b/plugins/terminals/src/types.ts index b20271a..3b984ad 100644 --- a/plugins/terminals/src/types.ts +++ b/plugins/terminals/src/types.ts @@ -22,7 +22,16 @@ export type TerminalBackend = 'pty' | 'pipe' */ export interface TerminalSessionInfo { id: string + /** Base label derived from the spawn request (command / preset / "Shell"). */ title: string + /** + * Live foreground process name of the controlling TTY (e.g. `vim`, + * `node`), tracked for PTY-backed sessions. Undefined for piped sessions + * and once the process has exited. + */ + processName?: string + /** User-assigned name; takes precedence over `processName`/`title` in the UI. */ + customTitle?: string mode: TerminalMode status: TerminalStatus backend: TerminalBackend diff --git a/plugins/terminals/test/terminals.test.ts b/plugins/terminals/test/terminals.test.ts index fbd2e07..c97adfb 100644 --- a/plugins/terminals/test/terminals.test.ts +++ b/plugins/terminals/test/terminals.test.ts @@ -135,6 +135,47 @@ describe('@devframes/plugin-terminals', () => { expect(restarted.status).toBe('running') }) + it('tracks the foreground process name for PTY sessions', async () => { + const client = bootClient(server.port) + await new Promise(r => setTimeout(r, 50)) + + const info = await call(client, 'devframes-plugin-terminals:spawn', { + command: NODE, + args: ['-e', 'setInterval(() => {}, 4000)'], + mode: 'interactive', + }) + + await vi.waitFor(async () => { + const list = await sessions(server) + const s = list.find(x => x.id === info.id) + expect(s?.processName?.toLowerCase()).toContain('node') + }, { timeout: 4000 }) + + await call(client, 'devframes-plugin-terminals:remove', { id: info.id }) + }) + + it('supports custom renaming via the rename RPC', async () => { + const client = bootClient(server.port) + await new Promise(r => setTimeout(r, 50)) + + const info = await call(client, 'devframes-plugin-terminals:spawn', { + command: NODE, + args: ['-e', 'setInterval(() => {}, 4000)'], + mode: 'readonly', + }) + + await call(client, 'devframes-plugin-terminals:rename', { id: info.id, title: 'My Build' }) + let list = await call(client, 'devframes-plugin-terminals:list') + expect(list.find(s => s.id === info.id)?.customTitle).toBe('My Build') + + // Empty string clears the custom name. + await call(client, 'devframes-plugin-terminals:rename', { id: info.id, title: ' ' }) + list = await call(client, 'devframes-plugin-terminals:list') + expect(list.find(s => s.id === info.id)?.customTitle).toBeUndefined() + + await call(client, 'devframes-plugin-terminals:remove', { id: info.id }) + }) + it('lists sessions and removes them', async () => { const client = bootClient(server.port) await new Promise(r => setTimeout(r, 50)) diff --git a/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/node.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/node.snapshot.d.ts index 1bd85d3..682430e 100644 --- a/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/node.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/node.snapshot.d.ts @@ -23,9 +23,12 @@ export declare class TerminalManager { private resolveSpawn; spawn(_?: SpawnRequest): TerminalSessionInfo; private launch; + private startProcessPoll; + private stopProcessPoll; write(_: string, _: string): void; resize(_: string, _: number, _: number): void; terminate(_: string): void; + rename(_: string, _: string): void; restart(_: string): TerminalSessionInfo; remove(_: string): void; dispose(): void; diff --git a/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/node.snapshot.js b/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/node.snapshot.js index efe9c13..c009b9c 100644 --- a/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/node.snapshot.js +++ b/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/node.snapshot.js @@ -23,9 +23,12 @@ export class TerminalManager { resolveSpawn(_) {} spawn(_) {} async launch(_) {} + startProcessPoll(_) {} + stopProcessPoll(_) {} write(_, _) {} resize(_, _, _) {} terminate(_) {} + rename(_, _) {} restart(_) {} remove(_) {} dispose() {} diff --git a/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/rpc.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/rpc.snapshot.d.ts index d3ee335..d4a76a7 100644 --- a/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/rpc.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/rpc.snapshot.d.ts @@ -10,6 +10,8 @@ export declare const serverFunctions: readonly [{ returns: _$valibot.ArraySchema<_$valibot.ObjectSchema<{ readonly id: _$valibot.StringSchema; readonly title: _$valibot.StringSchema; + readonly processName: _$valibot.OptionalSchema<_$valibot.StringSchema, undefined>; + readonly customTitle: _$valibot.OptionalSchema<_$valibot.StringSchema, undefined>; readonly mode: _$valibot.PicklistSchema<["interactive", "readonly"], undefined>; readonly status: _$valibot.PicklistSchema<["running", "exited", "error"], undefined>; readonly backend: _$valibot.PicklistSchema<["pty", "pipe"], undefined>; @@ -28,6 +30,8 @@ export declare const serverFunctions: readonly [{ setup?: ((context: _$devframe.DevframeNodeContext) => _$devframe_rpc0.Thenable<_$devframe_rpc0.RpcFunctionSetupResult<[], { id: string; title: string; + processName?: string | undefined; + customTitle?: string | undefined; mode: "interactive" | "readonly"; status: "running" | "exited" | "error"; backend: "pty" | "pipe"; @@ -44,6 +48,8 @@ export declare const serverFunctions: readonly [{ handler?: (() => { id: string; title: string; + processName?: string | undefined; + customTitle?: string | undefined; mode: "interactive" | "readonly"; status: "running" | "exited" | "error"; backend: "pty" | "pipe"; @@ -60,6 +66,8 @@ export declare const serverFunctions: readonly [{ dump?: _$devframe_rpc0.RpcDump<[], { id: string; title: string; + processName?: string | undefined; + customTitle?: string | undefined; mode: "interactive" | "readonly"; status: "running" | "exited" | "error"; backend: "pty" | "pipe"; @@ -77,6 +85,8 @@ export declare const serverFunctions: readonly [{ __cache?: WeakMap; readonly title: _$valibot.StringSchema; + readonly processName: _$valibot.OptionalSchema<_$valibot.StringSchema, undefined>; + readonly customTitle: _$valibot.OptionalSchema<_$valibot.StringSchema, undefined>; readonly mode: _$valibot.PicklistSchema<["interactive", "readonly"], undefined>; readonly status: _$valibot.PicklistSchema<["running", "exited", "error"], undefined>; readonly backend: _$valibot.PicklistSchema<["pty", "pipe"], undefined>; @@ -210,6 +224,8 @@ export declare const serverFunctions: readonly [{ }], { id: string; title: string; + processName?: string | undefined; + customTitle?: string | undefined; mode: "interactive" | "readonly"; status: "running" | "exited" | "error"; backend: "pty" | "pipe"; @@ -238,6 +254,8 @@ export declare const serverFunctions: readonly [{ }) => { id: string; title: string; + processName?: string | undefined; + customTitle?: string | undefined; mode: "interactive" | "readonly"; status: "running" | "exited" | "error"; backend: "pty" | "pipe"; @@ -266,6 +284,8 @@ export declare const serverFunctions: readonly [{ }], { id: string; title: string; + processName?: string | undefined; + customTitle?: string | undefined; mode: "interactive" | "readonly"; status: "running" | "exited" | "error"; backend: "pty" | "pipe"; @@ -295,6 +315,8 @@ export declare const serverFunctions: readonly [{ }], { id: string; title: string; + processName?: string | undefined; + customTitle?: string | undefined; mode: "interactive" | "readonly"; status: "running" | "exited" | "error"; backend: "pty" | "pipe"; @@ -323,6 +345,8 @@ export declare const serverFunctions: readonly [{ }], { id: string; title: string; + processName?: string | undefined; + customTitle?: string | undefined; mode: "interactive" | "readonly"; status: "running" | "exited" | "error"; backend: "pty" | "pipe"; @@ -442,6 +466,8 @@ export declare const serverFunctions: readonly [{ returns: _$valibot.ObjectSchema<{ readonly id: _$valibot.StringSchema; readonly title: _$valibot.StringSchema; + readonly processName: _$valibot.OptionalSchema<_$valibot.StringSchema, undefined>; + readonly customTitle: _$valibot.OptionalSchema<_$valibot.StringSchema, undefined>; readonly mode: _$valibot.PicklistSchema<["interactive", "readonly"], undefined>; readonly status: _$valibot.PicklistSchema<["running", "exited", "error"], undefined>; readonly backend: _$valibot.PicklistSchema<["pty", "pipe"], undefined>; @@ -462,6 +488,8 @@ export declare const serverFunctions: readonly [{ }], { id: string; title: string; + processName?: string | undefined; + customTitle?: string | undefined; mode: "interactive" | "readonly"; status: "running" | "exited" | "error"; backend: "pty" | "pipe"; @@ -480,6 +508,8 @@ export declare const serverFunctions: readonly [{ }) => { id: string; title: string; + processName?: string | undefined; + customTitle?: string | undefined; mode: "interactive" | "readonly"; status: "running" | "exited" | "error"; backend: "pty" | "pipe"; @@ -498,6 +528,8 @@ export declare const serverFunctions: readonly [{ }], { id: string; title: string; + processName?: string | undefined; + customTitle?: string | undefined; mode: "interactive" | "readonly"; status: "running" | "exited" | "error"; backend: "pty" | "pipe"; @@ -517,6 +549,8 @@ export declare const serverFunctions: readonly [{ }], { id: string; title: string; + processName?: string | undefined; + customTitle?: string | undefined; mode: "interactive" | "readonly"; status: "running" | "exited" | "error"; backend: "pty" | "pipe"; @@ -535,6 +569,8 @@ export declare const serverFunctions: readonly [{ }], { id: string; title: string; + processName?: string | undefined; + customTitle?: string | undefined; mode: "interactive" | "readonly"; status: "running" | "exited" | "error"; backend: "pty" | "pipe"; @@ -548,6 +584,38 @@ export declare const serverFunctions: readonly [{ presetId?: string | undefined; createdAt: number; }>> | undefined; +}, { + name: "devframes-plugin-terminals:rename"; + type?: "action" | undefined; + cacheable?: boolean; + args: readonly [_$valibot.ObjectSchema<{ + readonly id: _$valibot.StringSchema; + readonly title: _$valibot.StringSchema; + }, undefined>]; + returns: _$valibot.VoidSchema; + jsonSerializable?: boolean; + agent?: _$devframe.RpcFunctionAgentOptions; + setup?: ((context: _$devframe.DevframeNodeContext) => _$devframe_rpc0.Thenable<_$devframe_rpc0.RpcFunctionSetupResult<[{ + id: string; + title: string; + }], void>>) | undefined; + handler?: ((args_0: { + id: string; + title: string; + }) => void) | undefined; + dump?: _$devframe_rpc0.RpcDump<[{ + id: string; + title: string; + }], void, _$devframe.DevframeNodeContext> | undefined; + snapshot?: boolean; + __cache?: WeakMap>> | undefined; + __promise?: _$devframe_rpc0.Thenable<_$devframe_rpc0.RpcFunctionSetupResult<[{ + id: string; + title: string; + }], void>> | undefined; }, { name: "devframes-plugin-terminals:remove"; type?: "action" | undefined; diff --git a/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/types.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/types.snapshot.d.ts index ed9a731..de1b931 100644 --- a/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/types.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/types.snapshot.d.ts @@ -26,6 +26,8 @@ export interface TerminalPreset { export interface TerminalSessionInfo { id: string; title: string; + processName?: string; + customTitle?: string; mode: TerminalMode; status: TerminalStatus; backend: TerminalBackend; From 4768007c561a610f0840d0e27ced78b3b04bcb52 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Fri, 19 Jun 2026 03:46:58 +0000 Subject: [PATCH 3/7] fix(plugin-terminals): adapt to upstream main (nostics v1, required metadata, per-package typecheck) Merge upstream/main and reconcile the plugin with its newer baseline: - resolve to nostics ^1.1.4 via the catalog and regenerate the lockfile (fixes the broken merged lockfile that failed frozen CI installs). - supply the now-required DevframeDefinition metadata (version, packageName, homepage, description) from package.json. - add a `typecheck` script and switch tsconfig to explicit include/exclude (drop composite) so `turbo run typecheck` passes for the package. - refresh tsnapi snapshots for the nostics v1 diagnostics handle shape. --- plugins/terminals/package.json | 1 + plugins/terminals/src/index.ts | 5 ++ plugins/terminals/tsconfig.json | 5 +- pnpm-lock.yaml | 2 +- .../plugin-terminals/index.snapshot.js | 8 +- .../plugin-terminals/node.snapshot.d.ts | 73 ++++++++++--------- 6 files changed, 51 insertions(+), 43 deletions(-) diff --git a/plugins/terminals/package.json b/plugins/terminals/package.json index f26f9b1..55e5bb5 100644 --- a/plugins/terminals/package.json +++ b/plugins/terminals/package.json @@ -44,6 +44,7 @@ "watch": "tsdown --watch", "dev": "node bin.mjs", "test": "vitest run", + "typecheck": "tsc --noEmit", "prepack": "pnpm run build" }, "peerDependencies": { diff --git a/plugins/terminals/src/index.ts b/plugins/terminals/src/index.ts index 9f48892..fc9e1dd 100644 --- a/plugins/terminals/src/index.ts +++ b/plugins/terminals/src/index.ts @@ -2,6 +2,7 @@ import type { DevframeDefinition } from 'devframe/types' import type { TerminalsOptions } from './types' import { fileURLToPath } from 'node:url' import { defineDevframe } from 'devframe/types' +import pkg from '../package.json' with { type: 'json' } import { DEFAULT_PORT, PLUGIN_ID, @@ -40,6 +41,10 @@ export function createTerminalsDevframe(options: TerminalsOptions = {}): Devfram return defineDevframe({ id: PLUGIN_ID, name: 'Terminals', + version: pkg.version, + packageName: pkg.name, + homepage: pkg.homepage, + description: pkg.description, icon: 'ph:terminal-window-duotone', // Leave undefined so `resolveBasePath` picks `/` standalone and // `/__/` when hosted. Authors override via `options.basePath`. diff --git a/plugins/terminals/tsconfig.json b/plugins/terminals/tsconfig.json index 8a6d5a7..f5c76a6 100644 --- a/plugins/terminals/tsconfig.json +++ b/plugins/terminals/tsconfig.json @@ -1,7 +1,8 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { - "composite": true, "lib": ["esnext", "dom"] - } + }, + "include": ["src", "test", "tsdown.config.ts"], + "exclude": ["dist", "node_modules"] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1df9819..ec37194 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -550,7 +550,7 @@ importers: version: 6.0.0 nostics: specifier: catalog:deps - version: 0.2.0 + version: 1.1.4 pathe: specifier: catalog:deps version: 2.0.3 diff --git a/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/index.snapshot.js b/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/index.snapshot.js index 7e75410..8b4af6b 100644 --- a/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/index.snapshot.js +++ b/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/index.snapshot.js @@ -1,16 +1,12 @@ /** * Generated by tsnapi — public API snapshot of `@devframes/plugin-terminals` */ -// #region Functions -export function createTerminalsDevframe(_) {} -// #endregion - // #region Default Export -var _default /* const */ -export default _default +export default terminals // #endregion // #region Other +export { createTerminalsDevframe } export { DEFAULT_PORT } export { PLUGIN_ID } export { PRESETS_STATE_KEY } diff --git a/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/node.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/node.snapshot.d.ts index 682430e..abf2913 100644 --- a/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/node.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/node.snapshot.d.ts @@ -44,38 +44,43 @@ export declare function setupTerminals(_: DevframeNodeContext, _?: TerminalsOpti // #endregion // #region Variables -export declare const diagnostics: { - readonly DP_TERMINALS_0001: _$nostics.DiagnosticHandle<{ - id: string; - }, { - method?: "log" | "warn" | "error" | undefined; - }>; - readonly DP_TERMINALS_0002: _$nostics.DiagnosticHandle<{ - command: string; - }, { - method?: "log" | "warn" | "error" | undefined; - }>; - readonly DP_TERMINALS_0003: _$nostics.DiagnosticHandle<{ - id: string; - }, { - method?: "log" | "warn" | "error" | undefined; - }>; - readonly DP_TERMINALS_0004: _$nostics.DiagnosticHandle<{ - command: string; - reason: string; - }, { - method?: "log" | "warn" | "error" | undefined; - }>; - readonly DP_TERMINALS_0005: _$nostics.DiagnosticHandle<{}, { - method?: "log" | "warn" | "error" | undefined; - }>; - readonly DP_TERMINALS_0006: _$nostics.DiagnosticHandle<{ - id: string; - }, { - method?: "log" | "warn" | "error" | undefined; - }>; - readonly DP_TERMINALS_0007: _$nostics.DiagnosticHandle<{}, { - method?: "log" | "warn" | "error" | undefined; - }>; -}; +export declare const diagnostics: _$nostics.Diagnostics<{ + readonly DP_TERMINALS_0001: { + readonly why: (p: { + id: string; + }) => string; + readonly fix: "Spawn a session first, or refresh the session list."; + }; + readonly DP_TERMINALS_0002: { + readonly why: (p: { + command: string; + }) => string; + readonly fix: "Add it to `presets`, or pass `allowArbitraryCommands: true` to createTerminalsDevframe()."; + }; + readonly DP_TERMINALS_0003: { + readonly why: (p: { + id: string; + }) => string; + readonly fix: "Spawn the session with `mode: \"interactive\"` to accept input."; + }; + readonly DP_TERMINALS_0004: { + readonly why: (p: { + command: string; + reason: string; + }) => string; + }; + readonly DP_TERMINALS_0005: { + readonly why: "PTY backend (@homebridge/node-pty-prebuilt-multiarch) is unavailable; interactive sessions fall back to a piped child process. Full-screen TUIs may not render correctly."; + readonly fix: "Install @homebridge/node-pty-prebuilt-multiarch to enable real pseudo-terminals."; + }; + readonly DP_TERMINALS_0006: { + readonly why: (p: { + id: string; + }) => string; + }; + readonly DP_TERMINALS_0007: { + readonly why: "Terminals manager is not initialised on this context"; + readonly fix: "Call setupTerminals(ctx) (or use createTerminalsDevframe) before invoking terminal RPCs."; + }; +}, readonly [typeof reporter]>; // #endregion \ No newline at end of file From a721f7a270609f802a5fba2fb9035797befeb739 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Fri, 19 Jun 2026 05:07:28 +0000 Subject: [PATCH 4/7] fix(plugin-terminals): make PTY tests Windows-safe and harness leak-free - Gate the PTY-semantics tests (stdin echo, SIGWINCH resize, foreground process name) to POSIX; Windows keeps the isTTY interactive coverage. These rely on behaviours conpty doesn't provide (no SIGWINCH; `.process` returns the TERM name). - Ignore the Windows `xterm-256color` TERM-name fallback so it never surfaces as a session label. - Dispose the terminal manager on test server close so spawned PTY/piped child processes don't leak across runs. --- plugins/terminals/src/node/backend.ts | 10 ++++++++-- plugins/terminals/test/_utils.ts | 15 +++++++++++++++ plugins/terminals/test/terminals.test.ts | 11 ++++++++--- 3 files changed, 31 insertions(+), 5 deletions(-) diff --git a/plugins/terminals/src/node/backend.ts b/plugins/terminals/src/node/backend.ts index 75351e7..8f64be2 100644 --- a/plugins/terminals/src/node/backend.ts +++ b/plugins/terminals/src/node/backend.ts @@ -55,6 +55,9 @@ interface PtyProcess { kill: (signal?: string) => void } +/** TERM name handed to the PTY; also used to reject the Windows fallback. */ +const PTY_TERM_NAME = 'xterm-256color' + let ptyModulePromise: Promise | undefined /** @@ -90,7 +93,7 @@ export async function spawnPty(options: SpawnBackendOptions): Promise proc.onExit(e => cb(e.exitCode ?? 0)), getProcessName: () => { try { - return proc.process || undefined + const name = proc.process + // On Windows node-pty falls back to the TERM name rather than the + // foreground process — don't surface that as a session label. + return name && name !== PTY_TERM_NAME ? name : undefined } catch { return undefined diff --git a/plugins/terminals/test/_utils.ts b/plugins/terminals/test/_utils.ts index 12d0ea8..a7eabed 100644 --- a/plugins/terminals/test/_utils.ts +++ b/plugins/terminals/test/_utils.ts @@ -13,6 +13,7 @@ import { createWsRpcChannel } from 'devframe/rpc/transports/ws-client' import { getPort } from 'get-port-please' import { H3 } from 'h3' import { createTerminalsDevframe } from '../src/index' +import { getTerminalManager } from '../src/node/index' export type TerminalsServer = StartedServer & { ctx: DevframeNodeContext @@ -40,6 +41,20 @@ export async function startTerminalsServer(options: TerminalsOptions = {}): Prom await definition.setup(ctx) const server = await startHttpAndWs({ context: ctx, host, port, app, auth: false }) + + // Tear down spawned terminal processes (PTYs / piped children) alongside + // the HTTP+WS server so tests don't leak `node`/shell processes. + const closeServer = server.close.bind(server) + server.close = async () => { + try { + getTerminalManager(ctx).dispose() + } + catch { + // Manager may not be initialised if setup failed. + } + await closeServer() + } + return Object.assign(server, { ctx, port }) } diff --git a/plugins/terminals/test/terminals.test.ts b/plugins/terminals/test/terminals.test.ts index c97adfb..fb56b5a 100644 --- a/plugins/terminals/test/terminals.test.ts +++ b/plugins/terminals/test/terminals.test.ts @@ -9,6 +9,11 @@ import { bootClient, call, collectUntil, startTerminalsServer } from './_utils' vi.stubGlobal('WebSocket', WebSocket) const NODE = process.execPath +// PTY semantics differ on Windows (conpty): no SIGWINCH, the foreground +// process name resolves to the TERM name, and stdin round-trips are slow to +// render. These behaviours are exercised on POSIX; Windows keeps the +// `isTTY` interactive coverage below. +const itPosix = process.platform === 'win32' ? it.skip : it function subscribe(client: TestClient, id: string) { return client.streaming.subscribe(TERMINAL_STREAM_CHANNEL, id) @@ -66,7 +71,7 @@ describe('@devframes/plugin-terminals', () => { ).rejects.toThrow(/read-only/i) }) - it('runs an interactive PTY session that accepts input', async () => { + itPosix('runs an interactive PTY session that accepts input', async () => { const client = bootClient(server.port) await new Promise(r => setTimeout(r, 50)) @@ -100,7 +105,7 @@ describe('@devframes/plugin-terminals', () => { expect(output).toContain('isTTY=true') }) - it('propagates resize to the PTY (SIGWINCH) for TUI layout', async () => { + itPosix('propagates resize to the PTY (SIGWINCH) for TUI layout', async () => { const client = bootClient(server.port) await new Promise(r => setTimeout(r, 50)) @@ -135,7 +140,7 @@ describe('@devframes/plugin-terminals', () => { expect(restarted.status).toBe('running') }) - it('tracks the foreground process name for PTY sessions', async () => { + itPosix('tracks the foreground process name for PTY sessions', async () => { const client = bootClient(server.port) await new Promise(r => setTimeout(r, 50)) From b43d19b5c0b98fabb90120ce9aaaa3faef717140 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Fri, 19 Jun 2026 05:07:36 +0000 Subject: [PATCH 5/7] feat(plugin-terminals): hash-synced selection, fresh session per load, + tab - Mirror the active terminal into the URL hash (`#id=`) and react to external hash changes (links, back/forward, manual edits). - Spawn and select a fresh interactive session on every page load. - Move the "new terminal" affordance to a compact "+" pinned at the end of the tab strip. Avoids refocusing the active terminal on background shared-state updates by only fitting/focusing when the selection actually changes. --- plugins/terminals/src/client/index.ts | 108 +++++++++++++++++++++----- 1 file changed, 89 insertions(+), 19 deletions(-) diff --git a/plugins/terminals/src/client/index.ts b/plugins/terminals/src/client/index.ts index 8e28a9d..ba1fc44 100644 --- a/plugins/terminals/src/client/index.ts +++ b/plugins/terminals/src/client/index.ts @@ -53,6 +53,7 @@ const UI_CSS = ` color: var(--dft-muted); font-size: 12px; cursor: pointer; } .dft-tab:hover { color: var(--dft-fg); } .dft-tab.active { background: var(--dft-surface-active); color: var(--dft-fg); border-color: var(--dft-border); } +.dft-newtab { min-width: 28px; justify-content: center; font-weight: 600; font-size: 14px; flex: none; } .dft-dot { width: 7px; height: 7px; border-radius: 50%; background: #3fb950; flex: none; } .dft-dot.exited { background: #6e7681; } .dft-dot.error { background: #f85149; } @@ -155,15 +156,18 @@ export async function mountTerminals( const tabs = el('div', 'dft-tabs') const actions = el('div', 'dft-actions') const presetSelect = el('select', 'dft-select') - const newShellBtn = el('button', 'dft-btn') - newShellBtn.textContent = '+ Shell' - actions.append(presetSelect, newShellBtn) + actions.append(presetSelect) header.append(tabs, actions) + // The "new terminal" affordance sits at the end of the tab strip. + const newTabBtn = el('button', 'dft-tab dft-newtab') + newTabBtn.textContent = '+' + newTabBtn.title = 'New terminal' + const toolbar = el('div', 'dft-toolbar') const body = el('div', 'dft-body') const empty = el('div', 'dft-empty') - empty.textContent = 'No terminal sessions — start one above.' + empty.textContent = 'No terminal sessions — click + to start one.' body.append(empty) root.append(header, toolbar, body) @@ -174,6 +178,9 @@ export async function mountTerminals( let presets: TerminalPreset[] = [] let disposed = false let renamingId: string | null = null + // Session to select once it shows up in the shared-state list (set when + // we spawn one and want to focus it on arrival). + let pendingSelectId: string | null = null // Follow the system color mode and react to changes at runtime, switching // both the UI chrome (via CSS classes) and every xterm instance's theme. @@ -205,17 +212,54 @@ export async function mountTerminals( return info.customTitle || info.processName || info.title } - function spawn(req: Parameters[1]): void { - rpc.call('devframes-plugin-terminals:spawn', req as any).catch(() => {}) + // Selection is mirrored to the URL hash (e.g. `#id=`) so it + // survives links and reacts to back/forward + manual edits. + function readHashId(): string | null { + if (typeof location === 'undefined') + return null + return new URLSearchParams(location.hash.replace(/^#/, '')).get('id') } - newShellBtn.onclick = () => spawn({ mode: 'interactive' }) + function writeHashId(id: string): void { + if (typeof location === 'undefined' || typeof history === 'undefined') + return + const target = `#id=${id}` + if (location.hash !== target) + history.replaceState(history.state, '', target) + } + + const onHashChange = (): void => { + const id = readHashId() + if (id && views.has(id) && id !== activeId) + setActive(id, { updateHash: false }) + } + if (typeof window !== 'undefined') + window.addEventListener('hashchange', onHashChange) + + /** Spawn a session and select it as soon as it appears in the list. */ + async function spawnAndSelect(req: Parameters[1]): Promise { + try { + const info = await rpc.call('devframes-plugin-terminals:spawn', req as any) as { id?: string } + if (info?.id) { + pendingSelectId = info.id + if (views.has(info.id)) { + pendingSelectId = null + setActive(info.id) + } + } + } + catch { + // Spawn failures surface via server-side diagnostics. + } + } + + newTabBtn.onclick = () => void spawnAndSelect({ mode: 'interactive' }) presetSelect.onchange = () => { const id = presetSelect.value presetSelect.value = '' if (id) - spawn({ presetId: id }) + void spawnAndSelect({ presetId: id }) } function renderPresets(): void { @@ -247,17 +291,28 @@ export async function mountTerminals( } } - function setActive(id: string | null): void { + function setActive(id: string | null, opts: { updateHash?: boolean } = {}): void { + const changed = activeId !== id activeId = id for (const [vid, view] of views) { const active = vid === id view.el.classList.toggle('active', active) view.tab.classList.toggle('active', active) - if (active) { - requestAnimationFrame(() => { - fitActive() - view.term.focus() - }) + } + if (id) { + if (opts.updateHash !== false) + writeHashId(id) + // Only fit + steal focus when the active session actually changed, + // so background shared-state updates (e.g. process-name polling) + // don't refocus the terminal every tick. + if (changed) { + const view = views.get(id) + if (view) { + requestAnimationFrame(() => { + fitActive() + view.term.focus() + }) + } } } renderToolbar() @@ -377,6 +432,8 @@ export async function mountTerminals( view.tab.title = 'Double-click to rename' view.tab.append(dot, label) } + // Keep the "+" affordance pinned to the end of the strip. + tabs.append(newTabBtn) } /** Inline-edit a tab name; commits via the rename RPC on Enter/blur. */ @@ -440,8 +497,19 @@ export async function mountTerminals( if (activeId && !views.has(activeId)) activeId = null - if (!activeId && views.size) - activeId = sessions[sessions.length - 1]?.id ?? views.keys().next().value ?? null + + if (pendingSelectId && views.has(pendingSelectId)) { + // A freshly spawned session has arrived — select it. + activeId = pendingSelectId + pendingSelectId = null + } + else if (!activeId && views.size) { + // Otherwise honour the URL hash, else fall back to the newest session. + const hashId = readHashId() + activeId = (hashId && views.has(hashId)) + ? hashId + : sessions[sessions.length - 1]?.id ?? views.keys().next().value ?? null + } renderTabs() setActive(activeId) @@ -468,9 +536,9 @@ export async function mountTerminals( syncSessions(full.sessions ?? []) }) - // Auto-create an interactive shell when nothing is running yet. - if (options.autostart !== false && views.size === 0) - spawn({ mode: 'interactive' }) + // Each page load spawns a fresh interactive session and selects it. + if (options.autostart !== false) + void spawnAndSelect({ mode: 'interactive' }) const resizeObserver = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(() => fitActive()) @@ -484,6 +552,8 @@ export async function mountTerminals( offSessions?.() offPresets?.() colorScheme?.removeEventListener('change', onColorScheme) + if (typeof window !== 'undefined') + window.removeEventListener('hashchange', onHashChange) resizeObserver?.disconnect() for (const view of views.values()) disposeView(view) From 5ceb97fb45282d6151cd9a24403280ff971373ff Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Fri, 19 Jun 2026 05:18:26 +0000 Subject: [PATCH 6/7] fix(plugin-terminals): make node-pty an optional dependency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prebuilt PTY binary isn't published for every Node ABI on every OS (e.g. Node 26 on Windows 404s and the source-build fallback crashes), which failed `pnpm install`. node-pty is already lazily imported with a piped-child fallback, so move it to optionalDependencies — a missing prebuild is now non-fatal. Gate the PTY tests on actual backend availability: the real-TTY check runs wherever a PTY exists (incl. Windows conpty), while the POSIX-only semantics (SIGWINCH, foreground process name, stdin echo) stay POSIX-gated. --- plugins/terminals/package.json | 4 +- plugins/terminals/test/terminals.test.ts | 24 ++++++---- pnpm-lock.yaml | 57 +++++++++++++++++------- 3 files changed, 60 insertions(+), 25 deletions(-) diff --git a/plugins/terminals/package.json b/plugins/terminals/package.json index 55e5bb5..e9cdbe9 100644 --- a/plugins/terminals/package.json +++ b/plugins/terminals/package.json @@ -57,13 +57,15 @@ } }, "dependencies": { - "@homebridge/node-pty-prebuilt-multiarch": "catalog:deps", "@xterm/addon-fit": "catalog:frontend", "@xterm/xterm": "catalog:frontend", "nostics": "catalog:deps", "pathe": "catalog:deps", "valibot": "catalog:deps" }, + "optionalDependencies": { + "@homebridge/node-pty-prebuilt-multiarch": "catalog:deps" + }, "devDependencies": { "@types/node": "catalog:types", "devframe": "workspace:*", diff --git a/plugins/terminals/test/terminals.test.ts b/plugins/terminals/test/terminals.test.ts index fb56b5a..a1a5863 100644 --- a/plugins/terminals/test/terminals.test.ts +++ b/plugins/terminals/test/terminals.test.ts @@ -4,16 +4,22 @@ import process from 'node:process' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { WebSocket } from 'ws' import { SESSIONS_STATE_KEY, TERMINAL_STREAM_CHANNEL } from '../src/constants' +import { isPtyAvailable } from '../src/node/index' import { bootClient, call, collectUntil, startTerminalsServer } from './_utils' vi.stubGlobal('WebSocket', WebSocket) const NODE = process.execPath -// PTY semantics differ on Windows (conpty): no SIGWINCH, the foreground -// process name resolves to the TERM name, and stdin round-trips are slow to -// render. These behaviours are exercised on POSIX; Windows keeps the -// `isTTY` interactive coverage below. -const itPosix = process.platform === 'win32' ? it.skip : it +const ptyAvailable = await isPtyAvailable() +const isWindows = process.platform === 'win32' + +// A real pseudo-terminal works wherever the PTY backend is installed +// (including Windows conpty); skip when the optional native module is absent. +const itPty = ptyAvailable ? it : it.skip + +// These rely on POSIX PTY semantics that conpty doesn't provide: SIGWINCH, +// foreground-process-name resolution, and prompt stdin echo timing. +const itPosixPty = (!isWindows && ptyAvailable) ? it : it.skip function subscribe(client: TestClient, id: string) { return client.streaming.subscribe(TERMINAL_STREAM_CHANNEL, id) @@ -71,7 +77,7 @@ describe('@devframes/plugin-terminals', () => { ).rejects.toThrow(/read-only/i) }) - itPosix('runs an interactive PTY session that accepts input', async () => { + itPosixPty('runs an interactive PTY session that accepts input', async () => { const client = bootClient(server.port) await new Promise(r => setTimeout(r, 50)) @@ -90,7 +96,7 @@ describe('@devframes/plugin-terminals', () => { expect(output).toContain('echo:ping') }) - it('gives interactive sessions a real TTY (TUI support)', async () => { + itPty('gives interactive sessions a real TTY (TUI support)', async () => { const client = bootClient(server.port) await new Promise(r => setTimeout(r, 50)) @@ -105,7 +111,7 @@ describe('@devframes/plugin-terminals', () => { expect(output).toContain('isTTY=true') }) - itPosix('propagates resize to the PTY (SIGWINCH) for TUI layout', async () => { + itPosixPty('propagates resize to the PTY (SIGWINCH) for TUI layout', async () => { const client = bootClient(server.port) await new Promise(r => setTimeout(r, 50)) @@ -140,7 +146,7 @@ describe('@devframes/plugin-terminals', () => { expect(restarted.status).toBe('running') }) - itPosix('tracks the foreground process name for PTY sessions', async () => { + itPosixPty('tracks the foreground process name for PTY sessions', async () => { const client = bootClient(server.port) await new Promise(r => setTimeout(r, 50)) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ec37194..2a65da1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -539,9 +539,6 @@ importers: plugins/terminals: dependencies: - '@homebridge/node-pty-prebuilt-multiarch': - specifier: catalog:deps - version: 0.13.1 '@xterm/addon-fit': specifier: catalog:frontend version: 0.11.0 @@ -582,6 +579,10 @@ importers: ws: specifier: catalog:deps version: 8.21.0 + optionalDependencies: + '@homebridge/node-pty-prebuilt-multiarch': + specifier: catalog:deps + version: 0.13.1 packages: @@ -7795,6 +7796,7 @@ snapshots: dependencies: node-addon-api: 7.1.1 prebuild-install: 7.1.3 + optional: true '@hono/node-server@1.19.14(hono@4.12.18)': dependencies: @@ -9837,6 +9839,7 @@ snapshots: buffer: 5.7.1 inherits: 2.0.4 readable-stream: 3.6.2 + optional: true body-parser@2.2.2: dependencies: @@ -9882,6 +9885,7 @@ snapshots: dependencies: base64-js: 1.5.1 ieee754: 1.2.1 + optional: true buffer@6.0.3: dependencies: @@ -9964,7 +9968,8 @@ snapshots: dependencies: readdirp: 5.0.0 - chownr@1.1.4: {} + chownr@1.1.4: + optional: true chownr@3.0.0: {} @@ -10356,8 +10361,10 @@ snapshots: decompress-response@6.0.0: dependencies: mimic-response: 3.1.0 + optional: true - deep-extend@0.6.0: {} + deep-extend@0.6.0: + optional: true deep-is@0.1.4: {} @@ -10455,6 +10462,7 @@ snapshots: end-of-stream@1.4.5: dependencies: once: 1.4.0 + optional: true enhanced-resolve@5.21.2: dependencies: @@ -10858,7 +10866,8 @@ snapshots: signal-exit: 4.1.0 strip-final-newline: 3.0.0 - expand-template@2.0.3: {} + expand-template@2.0.3: + optional: true expect-type@1.3.0: {} @@ -11000,7 +11009,8 @@ snapshots: fresh@2.0.0: {} - fs-constants@1.0.0: {} + fs-constants@1.0.0: + optional: true fsevents@2.3.2: optional: true @@ -11052,7 +11062,8 @@ snapshots: giget@3.2.0: {} - github-from-package@0.0.0: {} + github-from-package@0.0.0: + optional: true github-slugger@2.0.0: {} @@ -11227,7 +11238,8 @@ snapshots: inherits@2.0.4: {} - ini@1.3.8: {} + ini@1.3.8: + optional: true ini@4.1.1: {} @@ -11928,7 +11940,8 @@ snapshots: mimic-fn@4.0.0: {} - mimic-response@3.1.0: {} + mimic-response@3.1.0: + optional: true minimatch@10.2.5: dependencies: @@ -11942,7 +11955,8 @@ snapshots: dependencies: brace-expansion: 2.1.0 - minimist@1.2.8: {} + minimist@1.2.8: + optional: true minipass@7.1.3: {} @@ -11952,7 +11966,8 @@ snapshots: dependencies: minipass: 7.1.3 - mkdirp-classic@0.5.3: {} + mkdirp-classic@0.5.3: + optional: true mlly@1.8.2: dependencies: @@ -11977,7 +11992,8 @@ snapshots: nanotar@0.3.0: {} - napi-build-utils@2.0.0: {} + napi-build-utils@2.0.0: + optional: true natural-compare@1.4.0: {} @@ -12118,6 +12134,7 @@ snapshots: node-abi@3.92.0: dependencies: semver: 7.8.1 + optional: true node-addon-api@7.1.1: {} @@ -12757,6 +12774,7 @@ snapshots: simple-get: 4.0.1 tar-fs: 2.1.4 tunnel-agent: 0.6.0 + optional: true prelude-ls@1.2.1: {} @@ -12783,6 +12801,7 @@ snapshots: dependencies: end-of-stream: 1.4.5 once: 1.4.0 + optional: true punycode@2.3.1: {} @@ -12818,6 +12837,7 @@ snapshots: ini: 1.3.8 minimist: 1.2.8 strip-json-comments: 2.0.1 + optional: true react-dom@19.2.6(react@19.2.6): dependencies: @@ -12841,6 +12861,7 @@ snapshots: inherits: 2.0.4 string_decoder: 1.3.0 util-deprecate: 1.0.2 + optional: true readable-stream@4.7.0: dependencies: @@ -13183,13 +13204,15 @@ snapshots: dependencies: kolorist: 1.8.0 - simple-concat@1.0.1: {} + simple-concat@1.0.1: + optional: true simple-get@4.0.1: dependencies: decompress-response: 6.0.0 once: 1.4.0 simple-concat: 1.0.1 + optional: true simple-git-hooks@2.13.1: {} @@ -13316,7 +13339,8 @@ snapshots: strip-indent@4.1.1: {} - strip-json-comments@2.0.1: {} + strip-json-comments@2.0.1: + optional: true strip-literal@3.1.0: dependencies: @@ -13367,6 +13391,7 @@ snapshots: mkdirp-classic: 0.5.3 pump: 3.0.4 tar-stream: 2.2.0 + optional: true tar-stream@2.2.0: dependencies: @@ -13375,6 +13400,7 @@ snapshots: fs-constants: 1.0.0 inherits: 2.0.4 readable-stream: 3.6.2 + optional: true tar-stream@3.2.0: dependencies: @@ -13508,6 +13534,7 @@ snapshots: tunnel-agent@0.6.0: dependencies: safe-buffer: 5.2.1 + optional: true turbo@2.9.15: optionalDependencies: From c58b6d93f47cdb9faab1f95120c4a16ce849de01 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Fri, 19 Jun 2026 08:00:32 +0000 Subject: [PATCH 7/7] fix(plugin-terminals): reattach to existing session on refresh instead of spawning A reload was always starting a new shell: the sessions shared state resolves with its empty initial value and backfills the server's sessions asynchronously, so the autostart check always saw an empty list. Decide autostart from the authoritative `list` RPC and seed the initial render from it, so a refresh restores the persisted sessions (reselecting the URL-hashed one) and only spawns a shell when none exist. --- plugins/terminals/src/client/index.ts | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/plugins/terminals/src/client/index.ts b/plugins/terminals/src/client/index.ts index ba1fc44..4dc68ae 100644 --- a/plugins/terminals/src/client/index.ts +++ b/plugins/terminals/src/client/index.ts @@ -536,8 +536,24 @@ export async function mountTerminals( syncSessions(full.sessions ?? []) }) - // Each page load spawns a fresh interactive session and selects it. - if (options.autostart !== false) + // Reconcile from the authoritative `list` RPC. The shared state resolves + // with its (empty) initial value and backfills the server's sessions + // asynchronously, so reading it synchronously here can both miss existing + // sessions (leaving the panel blank on refresh) and make every reload look + // empty enough to spawn another shell. Seeding from `list` renders the + // restored sessions immediately; syncSessions then reselects the URL-hashed + // one. A new session is started only when none exist. + let existing: TerminalSessionInfo[] | null = null + try { + existing = await rpc.call('devframes-plugin-terminals:list') as TerminalSessionInfo[] + } + catch { + existing = null + } + if (existing) + syncSessions(existing) + const hasSessions = existing ? existing.length > 0 : views.size > 0 + if (options.autostart !== false && !hasSessions) void spawnAndSelect({ mode: 'interactive' }) const resizeObserver = typeof ResizeObserver !== 'undefined'