From 3d19d1eb880d99d5a57dd6dfbf4e80535c40e6ec Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Thu, 18 Jun 2026 02:59:09 +0000 Subject: [PATCH 01/11] 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 | 7 + 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 | 266 ++++++++ pnpm-workspace.yaml | 4 + .../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, 3286 insertions(+) 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 281fa8e..cb28bf8 100644 --- a/alias.ts +++ b/alias.ts @@ -53,6 +53,13 @@ export const alias = { '@devframes/plugin-code-server/cli': p('code-server/src/cli.ts'), '@devframes/plugin-code-server/vite': p('code-server/src/vite.ts'), '@devframes/plugin-code-server': p('code-server/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 65e4d1e..bfe1463 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 @@ -572,6 +581,52 @@ importers: specifier: catalog:deps version: 8.21.0 + 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: 1.1.4 + 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(oxc-resolver@11.21.3)(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: '@adobe/css-tools@4.5.0': @@ -1278,6 +1333,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'} @@ -3619,6 +3678,12 @@ packages: '@webcontainer/env@1.1.1': resolution: {integrity: sha512-6aN99yL695Hi9SuIk1oC88l9o0gmxL1nGWWQ/kNy81HigJ0FoaoTXpytCj6ItzgyCEwA9kF1wixsTuv5cjsgng==} + '@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} @@ -3832,6 +3897,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'} @@ -3862,6 +3930,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==} @@ -3943,6 +4014,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'} @@ -4339,10 +4413,18 @@ 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-eql@5.0.2: resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} engines: {node: '>=6'} + 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==} @@ -4473,6 +4555,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'} @@ -4774,6 +4859,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'} @@ -4894,6 +4983,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} @@ -4952,6 +5044,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==} @@ -5125,6 +5220,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} @@ -5673,6 +5771,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'} + min-indent@1.0.1: resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} engines: {node: '>=4'} @@ -5689,6 +5791,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'} @@ -5700,6 +5805,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==} @@ -5732,6 +5840,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==} @@ -5774,6 +5885,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==} @@ -6246,6 +6361,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'} @@ -6275,6 +6396,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'} @@ -6306,6 +6430,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: @@ -6321,6 +6449,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} @@ -6573,6 +6705,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 @@ -6714,6 +6852,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==} @@ -6770,6 +6912,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==} @@ -6913,6 +7062,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 @@ -8160,6 +8312,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 @@ -10250,6 +10407,10 @@ snapshots: '@webcontainer/env@1.1.1': {} + '@xterm/addon-fit@0.11.0': {} + + '@xterm/xterm@6.0.0': {} + abbrev@3.0.1: {} abort-controller@3.0.0: @@ -10439,6 +10600,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 @@ -10479,6 +10646,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 @@ -10570,6 +10742,8 @@ snapshots: dependencies: readdirp: 5.0.0 + chownr@1.1.4: {} + chownr@3.0.0: {} ci-info@4.4.0: {} @@ -10959,8 +11133,14 @@ snapshots: dependencies: character-entities: 2.0.2 + decompress-response@6.0.0: + dependencies: + mimic-response: 3.1.0 + deep-eql@5.0.2: {} + deep-extend@0.6.0: {} + deep-is@0.1.4: {} deepmerge@4.3.1: {} @@ -11060,6 +11240,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 @@ -11462,6 +11646,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): @@ -11602,6 +11788,8 @@ snapshots: fresh@2.0.0: {} + fs-constants@1.0.0: {} + fsevents@2.3.2: optional: true @@ -11652,6 +11840,8 @@ snapshots: giget@3.2.0: {} + github-from-package@0.0.0: {} + github-slugger@2.0.0: {} glob-parent@5.1.2: @@ -11827,6 +12017,8 @@ snapshots: inherits@2.0.4: {} + ini@1.3.8: {} + ini@4.1.1: {} internmap@1.0.1: {} @@ -12530,6 +12722,8 @@ snapshots: mimic-fn@4.0.0: {} + mimic-response@3.1.0: {} + min-indent@1.0.1: {} minimatch@10.2.5: @@ -12544,6 +12738,8 @@ snapshots: dependencies: brace-expansion: 2.1.0 + minimist@1.2.8: {} + minipass@7.1.3: {} minisearch@7.2.0: {} @@ -12552,6 +12748,8 @@ snapshots: dependencies: minipass: 7.1.3 + mkdirp-classic@0.5.3: {} + mlly@1.8.2: dependencies: acorn: 8.16.0 @@ -12575,6 +12773,8 @@ snapshots: nanotar@0.3.0: {} + napi-build-utils@2.0.0: {} + natural-compare@1.4.0: {} natural-orderby@5.0.0: {} @@ -12711,6 +12911,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: {} @@ -13384,6 +13588,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: {} @@ -13411,6 +13630,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: @@ -13439,6 +13663,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 @@ -13458,6 +13689,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 @@ -13812,6 +14049,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: @@ -13967,6 +14212,8 @@ snapshots: strip-indent@4.1.1: {} + strip-json-comments@2.0.1: {} + strip-literal@3.1.0: dependencies: js-tokens: 9.0.1 @@ -14010,6 +14257,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 @@ -14143,6 +14405,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 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index ceb1423..5ce93e3 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 @@ -35,6 +36,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 @@ -62,6 +64,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 104cc85..c5a9ee2 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -145,6 +145,27 @@ "@devframes/plugin-code-server": [ "./plugins/code-server/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 c456d30..41ddc9c 100644 --- a/turbo.json +++ b/turbo.json @@ -23,6 +23,11 @@ "dependsOn": ["devframe#build"], "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 97c753b..cc7dec0 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -10,6 +10,7 @@ export default defineConfig({ 'packages/devframe', 'packages/hub', 'plugins/code-server', + 'plugins/terminals', 'examples/files-inspector', 'examples/streaming-chat', 'examples/next-runtime-snapshot', From f0f39b06527ad74884d577cff018a69270e35614 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Fri, 19 Jun 2026 03:26:49 +0000 Subject: [PATCH 02/11] 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 18a22399d5fca250d1d2306fbfc850046beb40d0 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Fri, 19 Jun 2026 03:46:58 +0000 Subject: [PATCH 03/11] 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 +- .../plugin-terminals/index.snapshot.js | 8 +- .../plugin-terminals/node.snapshot.d.ts | 73 ++++++++++--------- 5 files changed, 50 insertions(+), 42 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/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 879d0c3ff752fab331f900b5e6d0dee23f6b448c Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Fri, 19 Jun 2026 05:07:28 +0000 Subject: [PATCH 04/11] 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 fa35bba41e1cc9dea630c3e8282b682928b97f6b Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Fri, 19 Jun 2026 05:07:36 +0000 Subject: [PATCH 05/11] 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 edb723a4d772d7b6a98f95128ef8f8f5e2b6d2ac Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Fri, 19 Jun 2026 05:18:26 +0000 Subject: [PATCH 06/11] 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 bfe1463..a0172b5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -583,9 +583,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 @@ -626,6 +623,10 @@ importers: ws: specifier: catalog:deps version: 8.21.0 + optionalDependencies: + '@homebridge/node-pty-prebuilt-multiarch': + specifier: catalog:deps + version: 0.13.1 packages: @@ -8316,6 +8317,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: @@ -10605,6 +10607,7 @@ snapshots: buffer: 5.7.1 inherits: 2.0.4 readable-stream: 3.6.2 + optional: true body-parser@2.2.2: dependencies: @@ -10650,6 +10653,7 @@ snapshots: dependencies: base64-js: 1.5.1 ieee754: 1.2.1 + optional: true buffer@6.0.3: dependencies: @@ -10742,7 +10746,8 @@ snapshots: dependencies: readdirp: 5.0.0 - chownr@1.1.4: {} + chownr@1.1.4: + optional: true chownr@3.0.0: {} @@ -11136,10 +11141,12 @@ snapshots: decompress-response@6.0.0: dependencies: mimic-response: 3.1.0 + optional: true deep-eql@5.0.2: {} - deep-extend@0.6.0: {} + deep-extend@0.6.0: + optional: true deep-is@0.1.4: {} @@ -11243,6 +11250,7 @@ snapshots: end-of-stream@1.4.5: dependencies: once: 1.4.0 + optional: true enhanced-resolve@5.21.2: dependencies: @@ -11646,7 +11654,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: {} @@ -11788,7 +11797,8 @@ snapshots: fresh@2.0.0: {} - fs-constants@1.0.0: {} + fs-constants@1.0.0: + optional: true fsevents@2.3.2: optional: true @@ -11840,7 +11850,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: {} @@ -12017,7 +12028,8 @@ snapshots: inherits@2.0.4: {} - ini@1.3.8: {} + ini@1.3.8: + optional: true ini@4.1.1: {} @@ -12722,7 +12734,8 @@ snapshots: mimic-fn@4.0.0: {} - mimic-response@3.1.0: {} + mimic-response@3.1.0: + optional: true min-indent@1.0.1: {} @@ -12738,7 +12751,8 @@ snapshots: dependencies: brace-expansion: 2.1.0 - minimist@1.2.8: {} + minimist@1.2.8: + optional: true minipass@7.1.3: {} @@ -12748,7 +12762,8 @@ snapshots: dependencies: minipass: 7.1.3 - mkdirp-classic@0.5.3: {} + mkdirp-classic@0.5.3: + optional: true mlly@1.8.2: dependencies: @@ -12773,7 +12788,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: {} @@ -12914,6 +12930,7 @@ snapshots: node-abi@3.92.0: dependencies: semver: 7.8.1 + optional: true node-addon-api@7.1.1: {} @@ -13602,6 +13619,7 @@ snapshots: simple-get: 4.0.1 tar-fs: 2.1.4 tunnel-agent: 0.6.0 + optional: true prelude-ls@1.2.1: {} @@ -13634,6 +13652,7 @@ snapshots: dependencies: end-of-stream: 1.4.5 once: 1.4.0 + optional: true punycode@2.3.1: {} @@ -13669,6 +13688,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: @@ -13694,6 +13714,7 @@ snapshots: inherits: 2.0.4 string_decoder: 1.3.0 util-deprecate: 1.0.2 + optional: true readable-stream@4.7.0: dependencies: @@ -14049,13 +14070,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: {} @@ -14212,7 +14235,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: @@ -14263,6 +14287,7 @@ snapshots: mkdirp-classic: 0.5.3 pump: 3.0.4 tar-stream: 2.2.0 + optional: true tar-stream@2.2.0: dependencies: @@ -14271,6 +14296,7 @@ snapshots: fs-constants: 1.0.0 inherits: 2.0.4 readable-stream: 3.6.2 + optional: true tar-stream@3.2.0: dependencies: @@ -14408,6 +14434,7 @@ snapshots: tunnel-agent@0.6.0: dependencies: safe-buffer: 5.2.1 + optional: true turbo@2.9.15: optionalDependencies: From cfe10d086a6888b6431dc2e477c6b47cb029baf3 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Fri, 19 Jun 2026 08:00:32 +0000 Subject: [PATCH 07/11] 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' From 2c6e7ad8f9efeab1b66f617aaa5d0eb88ebb021c Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Mon, 22 Jun 2026 01:34:48 +0000 Subject: [PATCH 08/11] feat(plugin-terminals): migrate UI to Svelte 5 and UnoCSS --- plugins/terminals/package.json | 10 +- plugins/terminals/src/client/App.svelte | 242 ++++++++ .../terminals/src/client/TerminalView.svelte | 124 ++++ plugins/terminals/src/client/index.ts | 564 +----------------- plugins/terminals/src/client/vite.config.ts | 29 + plugins/terminals/src/client/xterm-css.ts | 76 --- plugins/terminals/src/env.d.ts | 5 + plugins/terminals/src/spa/vite.config.ts | 6 + plugins/terminals/tsdown.config.ts | 17 +- plugins/terminals/uno.config.ts | 10 + pnpm-lock.yaml | 466 ++++++++++++++- pnpm-workspace.yaml | 8 + 12 files changed, 911 insertions(+), 646 deletions(-) create mode 100644 plugins/terminals/src/client/App.svelte create mode 100644 plugins/terminals/src/client/TerminalView.svelte create mode 100644 plugins/terminals/src/client/vite.config.ts delete mode 100644 plugins/terminals/src/client/xterm-css.ts create mode 100644 plugins/terminals/src/env.d.ts create mode 100644 plugins/terminals/uno.config.ts diff --git a/plugins/terminals/package.json b/plugins/terminals/package.json index e9cdbe9..477986c 100644 --- a/plugins/terminals/package.json +++ b/plugins/terminals/package.json @@ -40,7 +40,7 @@ "dist" ], "scripts": { - "build": "tsdown && vite build --config src/spa/vite.config.ts", + "build": "tsdown && vite build --config src/client/vite.config.ts && vite build --config src/spa/vite.config.ts", "watch": "tsdown --watch", "dev": "node bin.mjs", "test": "vitest run", @@ -67,12 +67,20 @@ "@homebridge/node-pty-prebuilt-multiarch": "catalog:deps" }, "devDependencies": { + "@iconify-json/ph": "catalog:frontend", + "@sveltejs/vite-plugin-svelte": "catalog:frontend", "@types/node": "catalog:types", + "@unocss/preset-icons": "catalog:frontend", + "@unocss/preset-uno": "catalog:frontend", + "@unocss/vite": "catalog:frontend", "devframe": "workspace:*", "get-port-please": "catalog:deps", "h3": "catalog:deps", + "svelte": "catalog:frontend", "tsdown": "catalog:build", + "unocss": "catalog:frontend", "vite": "catalog:build", + "vite-plugin-css-injected-by-js": "catalog:frontend", "vitest": "catalog:testing", "ws": "catalog:deps" } diff --git a/plugins/terminals/src/client/App.svelte b/plugins/terminals/src/client/App.svelte new file mode 100644 index 0000000..883dc28 --- /dev/null +++ b/plugins/terminals/src/client/App.svelte @@ -0,0 +1,242 @@ + + +
+
+
+ {#each sessions as s (s.id)} + {#if renamingId === s.id} + { + if (e.key === 'Enter') { e.preventDefault(); handleRenameSubmit(s.id, e.currentTarget.value) } + else if (e.key === 'Escape') { e.preventDefault(); renamingId = null } + }} + onblur={(e) => handleRenameSubmit(s.id, e.currentTarget.value)} + onclick={(e) => e.stopPropagation()} + use:focusAndSelect + /> + {:else} + + {/if} + {/each} + +
+ +
+ +
+
+ +
+ {#if activeId} + {@const activeSession = sessions.find(s => s.id === activeId)} + {#if activeSession} + + {activeSession.mode} + + + {activeSession.command}{activeSession.args.length ? ` ${activeSession.args.join(' ')}` : ''} + + + {activeSession.status === 'running' + ? `running · ${activeSession.backend}${activeSession.pid ? ` · pid ${activeSession.pid}` : ''}` + : `${activeSession.status}${activeSession.exitCode != null ? ` (${activeSession.exitCode})` : ''}`} + +
+ + + {/if} + {/if} +
+ +
+ {#if sessions.length === 0} +
+ No terminal sessions — click + to start one. +
+ {/if} + {#each sessions as s (s.id)} + + {/each} +
+
diff --git a/plugins/terminals/src/client/TerminalView.svelte b/plugins/terminals/src/client/TerminalView.svelte new file mode 100644 index 0000000..a6f15f6 --- /dev/null +++ b/plugins/terminals/src/client/TerminalView.svelte @@ -0,0 +1,124 @@ + + +
diff --git a/plugins/terminals/src/client/index.ts b/plugins/terminals/src/client/index.ts index 4dc68ae..074014d 100644 --- a/plugins/terminals/src/client/index.ts +++ b/plugins/terminals/src/client/index.ts @@ -1,20 +1,12 @@ -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' -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' +import { mount, unmount } from 'svelte' +import App from './App.svelte' + +import 'virtual:uno.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 } @@ -23,558 +15,24 @@ export interface TerminalsHandle { 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: 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 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: 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-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; } -.dft-actions { display: flex; gap: 6px; align-items: center; } -.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 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 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 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: 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: 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: var(--dft-fg); } -` - -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') - 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') - 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 — click + to start one.' - 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 - 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. - 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 - } - - // 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') - } - - 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) - void spawnAndSelect({ 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, 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 (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() - } - - 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.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}` : ''}` - : `${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: activeTheme(), - 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) - tab.ondblclick = (e) => { - e.preventDefault() - e.stopPropagation() - const view = views.get(info.id) - if (view) - startRename(view) - } - - 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()) { - 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 = displayName(view.info) - 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. */ - 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 - 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 (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) - 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 ?? []) + const app = mount(App, { + target: container, + props: { + rpc, + autostart: 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' - ? new ResizeObserver(() => fitActive()) - : undefined - resizeObserver?.observe(body) - return { rpc, dispose() { - disposed = true - offSessions?.() - offPresets?.() - colorScheme?.removeEventListener('change', onColorScheme) - if (typeof window !== 'undefined') - window.removeEventListener('hashchange', onHashChange) - resizeObserver?.disconnect() - for (const view of views.values()) - disposeView(view) - views.clear() - root.remove() + unmount(app) }, } } diff --git a/plugins/terminals/src/client/vite.config.ts b/plugins/terminals/src/client/vite.config.ts new file mode 100644 index 0000000..1a359cf --- /dev/null +++ b/plugins/terminals/src/client/vite.config.ts @@ -0,0 +1,29 @@ +import { fileURLToPath } from 'node:url' +import { svelte } from '@sveltejs/vite-plugin-svelte' +import UnoCSS from 'unocss/vite' +import { defineConfig } from 'vite' +import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js' +import { alias } from '../../../../alias' + +export default defineConfig({ + resolve: { alias }, + plugins: [ + svelte(), + UnoCSS(), + cssInjectedByJsPlugin(), + ], + build: { + outDir: fileURLToPath(new URL('../../dist/client', import.meta.url)), + emptyOutDir: false, + lib: { + entry: fileURLToPath(new URL('./index.ts', import.meta.url)), + formats: ['es'], + fileName: () => 'index.mjs', + }, + rollupOptions: { + // Don't externalize xterm/xterm-addon-fit so it works out of the box in custom-render, + // but do externalize devframe/client since the host provides it. + external: ['devframe/client'], + }, + }, +}) diff --git a/plugins/terminals/src/client/xterm-css.ts b/plugins/terminals/src/client/xterm-css.ts deleted file mode 100644 index 8ca1167..0000000 --- a/plugins/terminals/src/client/xterm-css.ts +++ /dev/null @@ -1,76 +0,0 @@ -/** - * 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/env.d.ts b/plugins/terminals/src/env.d.ts new file mode 100644 index 0000000..b300efd --- /dev/null +++ b/plugins/terminals/src/env.d.ts @@ -0,0 +1,5 @@ +declare module '*.svelte' { + import type { Component } from 'svelte' + const component: Component + export default component +} diff --git a/plugins/terminals/src/spa/vite.config.ts b/plugins/terminals/src/spa/vite.config.ts index ba415b4..0dbd722 100644 --- a/plugins/terminals/src/spa/vite.config.ts +++ b/plugins/terminals/src/spa/vite.config.ts @@ -1,4 +1,6 @@ import { fileURLToPath } from 'node:url' +import { svelte } from '@sveltejs/vite-plugin-svelte' +import UnoCSS from 'unocss/vite' import { defineConfig } from 'vite' import { alias } from '../../../../alias' @@ -6,6 +8,10 @@ export default defineConfig({ base: './', root: fileURLToPath(new URL('.', import.meta.url)), resolve: { alias }, + plugins: [ + svelte(), + UnoCSS() + ], build: { outDir: fileURLToPath(new URL('../../dist/spa', import.meta.url)), emptyOutDir: true, diff --git a/plugins/terminals/tsdown.config.ts b/plugins/terminals/tsdown.config.ts index cc1c22e..322e846 100644 --- a/plugins/terminals/tsdown.config.ts +++ b/plugins/terminals/tsdown.config.ts @@ -29,23 +29,12 @@ const serverEntries = { '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. +// Three configs: +// 1. node server build (clean: true, outputs dist/node, dist/rpc, etc.) +// 2. combined dts so augmentations resolve export default defineConfig([ { clean: true, - platform: 'browser', - tsconfig, - deps, - dts: false, - outExtensions: () => ({ js: '.mjs' }), - entry: clientEntries, - }, - { - clean: false, platform: 'node', tsconfig, deps, diff --git a/plugins/terminals/uno.config.ts b/plugins/terminals/uno.config.ts new file mode 100644 index 0000000..73899bd --- /dev/null +++ b/plugins/terminals/uno.config.ts @@ -0,0 +1,10 @@ +import { defineConfig, presetIcons, presetUno } from 'unocss' + +export default defineConfig({ + presets: [ + presetUno(), + presetIcons({ + scale: 1.2, + }), + ], +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a0172b5..3dbb4d4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -114,6 +114,21 @@ catalogs: specifier: ^2.0.17 version: 2.0.17 frontend: + '@iconify-json/ph': + specifier: ^1.2.0 + version: 1.2.2 + '@sveltejs/vite-plugin-svelte': + specifier: ^5.0.0 + version: 5.1.1 + '@unocss/preset-icons': + specifier: ^66.0.0 + version: 66.7.2 + '@unocss/preset-uno': + specifier: ^66.0.0 + version: 66.7.2 + '@unocss/vite': + specifier: ^66.0.0 + version: 66.7.2 '@xterm/addon-fit': specifier: ^0.11.0 version: 0.11.0 @@ -132,6 +147,15 @@ catalogs: react-dom: specifier: ^19.2.6 version: 19.2.6 + svelte: + specifier: ^5.0.0 + version: 5.56.3 + unocss: + specifier: ^66.0.0 + version: 66.7.2 + vite-plugin-css-injected-by-js: + specifier: ^3.5.0 + version: 3.5.2 inlined: '@antfu/utils': specifier: ^9.3.0 @@ -599,9 +623,24 @@ importers: specifier: catalog:deps version: 1.4.1(typescript@6.0.3) devDependencies: + '@iconify-json/ph': + specifier: catalog:frontend + version: 1.2.2 + '@sveltejs/vite-plugin-svelte': + specifier: catalog:frontend + version: 5.1.1(svelte@5.56.3(@typescript-eslint/types@8.59.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)) '@types/node': specifier: catalog:types version: 25.9.1 + '@unocss/preset-icons': + specifier: catalog:frontend + version: 66.7.2 + '@unocss/preset-uno': + specifier: catalog:frontend + version: 66.7.2 + '@unocss/vite': + specifier: catalog:frontend + version: 66.7.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)) devframe: specifier: workspace:* version: link:../../packages/devframe @@ -611,12 +650,21 @@ importers: h3: specifier: catalog:deps version: 2.0.1-rc.22(crossws@0.4.5(srvx@0.11.15)) + svelte: + specifier: catalog:frontend + version: 5.56.3(@typescript-eslint/types@8.59.2) tsdown: specifier: catalog:build version: 0.22.0(oxc-resolver@11.21.3)(tsx@4.22.3)(typescript@6.0.3) + unocss: + specifier: catalog:frontend + version: 66.7.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: 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) + vite-plugin-css-injected-by-js: + specifier: catalog:frontend + version: 3.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)) 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)) @@ -1364,6 +1412,9 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} + '@iconify-json/ph@1.2.2': + resolution: {integrity: sha512-PgkEZNtqa8hBGjHXQa4pMwZa93hmfu8FUSjs/nv4oUU6yLsgv+gh9nu28Kqi8Fz9CCVu4hj1MZs9/60J57IzFw==} + '@iconify-json/simple-icons@1.2.81': resolution: {integrity: sha512-Utjw4sPtoVdbpAQAkC4O0cYpt4ehQZYr6aFHhmvdeW8mQwkINyAe0ogTPqNptSSKogZ2lfgXM8zpuhO961Wnng==} @@ -3159,6 +3210,26 @@ packages: peerDependencies: eslint: ^9.0.0 || ^10.0.0 + '@sveltejs/acorn-typescript@1.0.10': + resolution: {integrity: sha512-4WfKk68eTih+MiJD4fSbxN7E8kVBmTMPWHUPYjvl2N0rMs53YLTT8/YjKU5Dtnz5LqDjl7LEw4U7lXR2W3J5WA==} + peerDependencies: + acorn: ^8.9.0 + + '@sveltejs/vite-plugin-svelte-inspector@4.0.1': + resolution: {integrity: sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22} + peerDependencies: + '@sveltejs/vite-plugin-svelte': ^5.0.0 + svelte: ^5.0.0 + vite: ^6.0.0 + + '@sveltejs/vite-plugin-svelte@5.1.1': + resolution: {integrity: sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22} + peerDependencies: + svelte: ^5.0.0 + vite: ^6.0.0 + '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} @@ -3456,6 +3527,72 @@ packages: peerDependencies: vue: '>=3.5.18' + '@unocss/cli@66.7.2': + resolution: {integrity: sha512-50vBptZyiyYzm5CBNSVs1WYIFX+7IKYFwLNrm6pVCOjHfrBmmpfvyCznPMzUcGEFKvP2VsyB2hf3k57GBCSS9Q==} + hasBin: true + + '@unocss/config@66.7.2': + resolution: {integrity: sha512-m8LZUZOFHBesViFOnC1MzMMQ1ovYbZ/F2ntkKSIWzLO/VvEYo2/HK8qhBhtI/FyL27+gvePL4sZ6a5ZChyl0Ug==} + + '@unocss/core@66.7.2': + resolution: {integrity: sha512-NNnhm9IVPEZ34drwztREP+mq1rio0L4Tp0u247qBKxJJWYec1+I+FTRsw7EvtukZKvr56YAxFA1qbBV+LjyV+Q==} + + '@unocss/extractor-arbitrary-variants@66.7.2': + resolution: {integrity: sha512-1R+ntws4zhi9gCsyovYeNCiAYGSceN6Rsy/4kyaw3npr1UBWhBJdQZtacxvqOssPfbbqq2vpatcuQ4rfZZYWFQ==} + + '@unocss/inspector@66.7.2': + resolution: {integrity: sha512-fvZ8w9dTnu61ZwbXjVMQopxxrQOnFOBN2I6KVPJtoUSMsatrpEYyJHDA9pfLes1a3C4eEJZaSADURHKkON09CA==} + + '@unocss/preset-attributify@66.7.2': + resolution: {integrity: sha512-JnnoRUgOL4O565+jNi8BfzTQDElEny1reaMhrdYQR4P4I7cfdRV89R5DsmND0H2mGtwJjP/gL4cWd1FSX9ShrA==} + + '@unocss/preset-icons@66.7.2': + resolution: {integrity: sha512-C05oM7j8jFuxjbPaRFUbcwxHPXvBtmJOhaE2M3YstVR/L9IBsQ6Ts/PT1vxVAVDmX19MudRRTnQ0x5XzUM3P7A==} + + '@unocss/preset-mini@66.7.2': + resolution: {integrity: sha512-2DLS20vj+eoZI/r7U8eTxd+HTfMYamhx03mJyeadNu+efVJZrfEEMwjILgFywVAthYINmdeB4sYpc8Qfef4Vww==} + + '@unocss/preset-tagify@66.7.2': + resolution: {integrity: sha512-46V3ibNqKEyeSNnplsOKiYiBdNZ348JgjhnGDWHigVYIfZkEqn6WJdGAX9tVZpjI/rS9px8jQA0lm6ubKoc8iQ==} + + '@unocss/preset-typography@66.7.2': + resolution: {integrity: sha512-6nTRoZiHTkDV87omRlEn8RZhakMYrIJtzazfj0rdF8msvjM1LnTT6K6qDlmxqb6NBIakljNb0bryubr6bWzK9A==} + + '@unocss/preset-uno@66.7.2': + resolution: {integrity: sha512-XiSGtnh04sGHCRUnlxhePBhRF8zfOQlCfYkKByQcp/pvhmFMIWwzVL68R5LwxBmBwBVY4hfQAIx0Sz/FbA3Ojg==} + + '@unocss/preset-web-fonts@66.7.2': + resolution: {integrity: sha512-Tx6YJWxD29NoG6t8hpbnectdL8KkBVEzEYwBcJlEa200O6/KXNpGJ8tk4l5+EK1dwTxWkUqsG+60fzXzTpe5Gg==} + + '@unocss/preset-wind3@66.7.2': + resolution: {integrity: sha512-3WUmNZ3ibNotel6PAm1AgdK8BP2RqThRvEYU+svZgxsCX8E/RtVM68BFPOwzsEtuMD/R3Up6rHXqZsJvUQsg+A==} + + '@unocss/preset-wind4@66.7.2': + resolution: {integrity: sha512-yP1Np15QKm+zh945lBmpNC2FnD4oyd0eq9qA6j8r15uvhz7AF98t9dGqqzV5WrjX+IZpwg08nP3son1IjAerLw==} + + '@unocss/preset-wind@66.7.2': + resolution: {integrity: sha512-porNxph4xfr67e0LpFis813rKk9+psUJMw0nPL4sZoHovhmR/hA5XlWb6fLCl7t4Lls20I/n8F8KKgkqkxnk8Q==} + + '@unocss/rule-utils@66.7.2': + resolution: {integrity: sha512-EGi2m9I87hluz2zgjVpXM4PWFn996RInNcx4PGF6Qw9Z0W78ROXEto0SM1IltpJ4R7+at4EhssU0IbHiT0snEw==} + + '@unocss/transformer-attributify-jsx@66.7.2': + resolution: {integrity: sha512-lb5y4lwHCjZm+9L3k6c/fq9O75+mQcxBp2Dq+a3DS+vOQpPce+hOyLFFkiHVRNKp9chEHS9gnq58SBVxJbhR2A==} + + '@unocss/transformer-compile-class@66.7.2': + resolution: {integrity: sha512-/vdgxgUI9vp7NOGfuOCY44/Ja2YbR0m+sWCGHq8K8/53a3Dk+4AvXPePv/EI/Oo6z8bhSGZHCSes8pBEY8Mr8A==} + + '@unocss/transformer-directives@66.7.2': + resolution: {integrity: sha512-9xr5Tiy+urutRBcyKJUAOOpW3LSuSM59sKowdTStzZdBUMs/L9cmjfsjNFt8rm5tptUR25wGZ8xLR5hhVDAiBQ==} + + '@unocss/transformer-variant-group@66.7.2': + resolution: {integrity: sha512-CO0CoYRn96wLm+cIICuNrv96cfzEkBHc/OmTYcHzlheyZRfGWPWlPpazMr2rlm5bve986akAxe2HSBqdaRf04w==} + + '@unocss/vite@66.7.2': + resolution: {integrity: sha512-KZL8LFNcoOjAaF8AKSUJznxrjcmuQKPSAmwvndL5RjEWtbyunV66YvOuoBPN3F0tR7MhY5NWKJjwmaYyetcM1Q==} + peerDependencies: + vite: ^5.0.0-0 || ^6.0.0-0 || ^7.0.0-0 || ^8.0.0-0 + '@upsetjs/venn.js@2.0.0': resolution: {integrity: sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==} @@ -3783,6 +3920,10 @@ packages: aria-query@5.3.0: resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + aria-query@5.3.1: + resolution: {integrity: sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==} + engines: {node: '>= 0.4'} + aria-query@5.3.2: resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} engines: {node: '>= 0.4'} @@ -3820,6 +3961,10 @@ packages: peerDependencies: postcss: ^8.1.0 + axobject-query@4.1.0: + resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} + engines: {node: '>= 0.4'} + b4a@1.8.1: resolution: {integrity: sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==} peerDependencies: @@ -4043,6 +4188,10 @@ packages: resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==} engines: {node: '>=20'} + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + cluster-key-slot@1.1.2: resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} engines: {node: '>=0.10.0'} @@ -4054,6 +4203,9 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + colorette@2.0.20: + resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + comma-separated-tokens@2.0.3: resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} @@ -4798,6 +4950,9 @@ packages: jiti: optional: true + esm-env@1.2.2: + resolution: {integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==} + espree@10.4.0: resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -4815,6 +4970,14 @@ packages: resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} engines: {node: '>=0.10'} + esrap@2.2.11: + resolution: {integrity: sha512-gPdx+I+BjYEinNMQaBXFjbaJVyoPMU4ZODg5mE+M4DqVG9VusAVHHjcBX+zqyITlI0DIARwDMMzZwAWj36dRoQ==} + peerDependencies: + '@typescript-eslint/types': ^8.2.0 + peerDependenciesMeta: + '@typescript-eslint/types': + optional: true + esrecurse@4.3.0: resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} engines: {node: '>=4.0'} @@ -5098,6 +5261,10 @@ packages: resolution: {integrity: sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==} engines: {node: '>=6.0'} + gzip-size@6.0.0: + resolution: {integrity: sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==} + engines: {node: '>=10'} + gzip-size@7.0.0: resolution: {integrity: sha512-O1Ld7Dr+nqPnmGpdhzLmMTQ4vAsD+rHwMm1NLUmoUFFymBOMKxCCrtDxqdBRYXdeEPEi3SyoR4TizJLQrnKBNA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -5309,6 +5476,9 @@ packages: is-reference@1.2.1: resolution: {integrity: sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==} + is-reference@3.0.3: + resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==} + is-stream@2.0.1: resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} engines: {node: '>=8'} @@ -5522,6 +5692,9 @@ packages: resolution: {integrity: sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==} engines: {node: '>=14'} + locate-character@3.0.0: + resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==} + locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -5567,6 +5740,9 @@ packages: resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} hasBin: true + magic-regexp@0.10.0: + resolution: {integrity: sha512-Uly1Bu4lO1hwHUW0CQeSWuRtzCMNO00CmXtS8N6fyvB3B979GOEEeAkiTUDsmbYLAbvpUS/Kt5c4ibosAzVyVg==} + magic-regexp@0.11.0: resolution: {integrity: sha512-LG77Z/gVnwz7oaDpD4heX6ryl+lcr4l1B2gnP4MMvt2pGhGC1Dfj7dl1pXpP4ih+VQFLuAadeKVa+lARAzfW+Q==} @@ -6045,6 +6221,11 @@ packages: resolution: {integrity: sha512-ml0/elXPNnDnuHo3VHmEMN2fnybmKx7YL+0E+gMQ0fuHRZHXYJzF6YJ01KsCWg6FXY6pbZcjm7DC3xwGHnB/BA==} engines: {node: ^20.19.0 || >=22.12.0} + oxc-walker@0.7.0: + resolution: {integrity: sha512-54B4KUhrzbzc4sKvKwVYm7E2PgeROpGba0/2nlNZMqfDyca+yOor5IMb4WLGBatGDT0nkzYdYuzylg7n3YfB7A==} + peerDependencies: + oxc-parser: '>=0.98.0' + oxc-walker@1.0.0: resolution: {integrity: sha512-eMsHflAGfOskpWxtp9xP/f5b96XLEU8ifTd2gOOCkdux9HMxKGy5S1ru0Gh1B3aPu+YbfmWUUVkcb7MrZz3XyQ==} peerDependencies: @@ -6893,6 +7074,10 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + svelte@5.56.3: + resolution: {integrity: sha512-w7JvrM5IFl5cmfbY0TLik9o7mjRUJmRMhOR51tBPu708Gr/MjbGs7VnJnr/B0CaXeI4vtnOh7RKxDr0cwhMdDA==} + engines: {node: '>=18'} + svgo@4.0.1: resolution: {integrity: sha512-XDpWUOPC6FEibaLzjfe0ucaV0YrOjYotGJO1WpF0Zd+n6ZGEQUsSugaoLq9QkEZtAfQIxT42UChcssDVPP3+/w==} engines: {node: '>=16'} @@ -7171,6 +7356,20 @@ packages: unist-util-visit@5.1.0: resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + unocss@66.7.2: + resolution: {integrity: sha512-yB0yOpJTtlyGH/HAe4QdnjgjSP6z9ItTdrObvagc8ZEwRY1D2GbfUABwDKyZzXs19gXebqThMG9f+W0hPhDIPA==} + peerDependencies: + '@unocss/astro': 66.7.2 + '@unocss/postcss': 66.7.2 + '@unocss/webpack': 66.7.2 + peerDependenciesMeta: + '@unocss/astro': + optional: true + '@unocss/postcss': + optional: true + '@unocss/webpack': + optional: true + unpipe@1.0.0: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} @@ -7357,6 +7556,11 @@ packages: vue-tsc: optional: true + vite-plugin-css-injected-by-js@3.5.2: + resolution: {integrity: sha512-2MpU/Y+SCZyWUB6ua3HbJCrgnF0KACAsmzOQt1UvRVJCGF6S8xdA3ZUhWcWdM9ivG4I5az8PnQmwwrkC2CAQrQ==} + peerDependencies: + vite: '>2.0.0-0' + vite-plugin-inspect@11.3.3: resolution: {integrity: sha512-u2eV5La99oHoYPHE6UvbwgEqKKOQGz86wMg40CCosP6q8BkB6e5xPneZfYagK4ojPJSj5anHCrnvC20DpwVdRA==} engines: {node: '>=14'} @@ -7461,6 +7665,14 @@ packages: yaml: optional: true + vitefu@1.1.3: + resolution: {integrity: sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==} + peerDependencies: + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + vite: + optional: true + vitepress-plugin-mermaid@2.0.17: resolution: {integrity: sha512-IUzYpwf61GC6k0XzfmAmNrLvMi9TRrVRMsUyCA8KNXhg/mQ1VqWnO0/tBVPiX5UoKF1mDUwqn5QV4qAJl6JnUg==} peerDependencies: @@ -8339,6 +8551,10 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} + '@iconify-json/ph@1.2.2': + dependencies: + '@iconify/types': 2.0.0 + '@iconify-json/simple-icons@1.2.81': dependencies: '@iconify/types': 2.0.0 @@ -9779,6 +9995,32 @@ snapshots: estraverse: 5.3.0 picomatch: 4.0.4 + '@sveltejs/acorn-typescript@1.0.10(acorn@8.16.0)': + dependencies: + acorn: 8.16.0 + + '@sveltejs/vite-plugin-svelte-inspector@4.0.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.56.3(@typescript-eslint/types@8.59.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)))(svelte@5.56.3(@typescript-eslint/types@8.59.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))': + dependencies: + '@sveltejs/vite-plugin-svelte': 5.1.1(svelte@5.56.3(@typescript-eslint/types@8.59.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)) + debug: 4.4.3 + svelte: 5.56.3(@typescript-eslint/types@8.59.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) + transitivePeerDependencies: + - supports-color + + '@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.56.3(@typescript-eslint/types@8.59.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))': + dependencies: + '@sveltejs/vite-plugin-svelte-inspector': 4.0.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.56.3(@typescript-eslint/types@8.59.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)))(svelte@5.56.3(@typescript-eslint/types@8.59.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)) + debug: 4.4.3 + deepmerge: 4.3.1 + kleur: 4.1.5 + magic-string: 0.30.21 + svelte: 5.56.3(@typescript-eslint/types@8.59.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) + vitefu: 1.1.3(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)) + transitivePeerDependencies: + - supports-color + '@swc/helpers@0.5.15': dependencies: tslib: 2.8.1 @@ -10011,8 +10253,7 @@ snapshots: '@types/resolve@1.20.2': {} - '@types/trusted-types@2.0.7': - optional: true + '@types/trusted-types@2.0.7': {} '@types/unist@3.0.3': {} @@ -10135,6 +10376,135 @@ snapshots: unhead: 2.1.15 vue: 3.5.34(typescript@6.0.3) + '@unocss/cli@66.7.2': + dependencies: + '@jridgewell/remapping': 2.3.5 + '@unocss/config': 66.7.2 + '@unocss/core': 66.7.2 + '@unocss/preset-wind3': 66.7.2 + '@unocss/preset-wind4': 66.7.2 + '@unocss/transformer-directives': 66.7.2 + cac: 7.0.0 + chokidar: 5.0.0 + colorette: 2.0.20 + consola: 3.4.2 + magic-string: 0.30.21 + pathe: 2.0.3 + perfect-debounce: 2.1.0 + tinyglobby: 0.2.16 + unplugin-utils: 0.3.1 + + '@unocss/config@66.7.2': + dependencies: + '@unocss/core': 66.7.2 + colorette: 2.0.20 + consola: 3.4.2 + unconfig: 7.5.0 + + '@unocss/core@66.7.2': {} + + '@unocss/extractor-arbitrary-variants@66.7.2': + dependencies: + '@unocss/core': 66.7.2 + + '@unocss/inspector@66.7.2': + dependencies: + '@unocss/core': 66.7.2 + '@unocss/rule-utils': 66.7.2 + colorette: 2.0.20 + gzip-size: 6.0.0 + sirv: 3.0.2 + + '@unocss/preset-attributify@66.7.2': + dependencies: + '@unocss/core': 66.7.2 + + '@unocss/preset-icons@66.7.2': + dependencies: + '@iconify/utils': 3.1.3 + '@unocss/core': 66.7.2 + ofetch: 1.5.1 + + '@unocss/preset-mini@66.7.2': + dependencies: + '@unocss/core': 66.7.2 + '@unocss/extractor-arbitrary-variants': 66.7.2 + '@unocss/rule-utils': 66.7.2 + + '@unocss/preset-tagify@66.7.2': + dependencies: + '@unocss/core': 66.7.2 + + '@unocss/preset-typography@66.7.2': + dependencies: + '@unocss/core': 66.7.2 + '@unocss/rule-utils': 66.7.2 + + '@unocss/preset-uno@66.7.2': + dependencies: + '@unocss/core': 66.7.2 + '@unocss/preset-wind3': 66.7.2 + + '@unocss/preset-web-fonts@66.7.2': + dependencies: + '@unocss/core': 66.7.2 + ofetch: 1.5.1 + + '@unocss/preset-wind3@66.7.2': + dependencies: + '@unocss/core': 66.7.2 + '@unocss/preset-mini': 66.7.2 + '@unocss/rule-utils': 66.7.2 + + '@unocss/preset-wind4@66.7.2': + dependencies: + '@unocss/core': 66.7.2 + '@unocss/extractor-arbitrary-variants': 66.7.2 + '@unocss/rule-utils': 66.7.2 + + '@unocss/preset-wind@66.7.2': + dependencies: + '@unocss/core': 66.7.2 + '@unocss/preset-wind3': 66.7.2 + + '@unocss/rule-utils@66.7.2': + dependencies: + '@unocss/core': 66.7.2 + magic-string: 0.30.21 + + '@unocss/transformer-attributify-jsx@66.7.2': + dependencies: + '@unocss/core': 66.7.2 + oxc-parser: 0.131.0 + oxc-walker: 0.7.0(oxc-parser@0.131.0) + + '@unocss/transformer-compile-class@66.7.2': + dependencies: + '@unocss/core': 66.7.2 + + '@unocss/transformer-directives@66.7.2': + dependencies: + '@unocss/core': 66.7.2 + '@unocss/rule-utils': 66.7.2 + css-tree: 3.2.1 + + '@unocss/transformer-variant-group@66.7.2': + dependencies: + '@unocss/core': 66.7.2 + + '@unocss/vite@66.7.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))': + dependencies: + '@jridgewell/remapping': 2.3.5 + '@unocss/config': 66.7.2 + '@unocss/core': 66.7.2 + '@unocss/inspector': 66.7.2 + chokidar: 5.0.0 + magic-string: 0.30.21 + pathe: 2.0.3 + tinyglobby: 0.2.16 + unplugin-utils: 0.3.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) + '@upsetjs/venn.js@2.0.0': optionalDependencies: d3-selection: 3.0.0 @@ -10511,6 +10881,8 @@ snapshots: dependencies: dequal: 2.0.3 + aria-query@5.3.1: {} + aria-query@5.3.2: {} assertion-error@2.0.1: {} @@ -10548,6 +10920,8 @@ snapshots: postcss: 8.5.14 postcss-value-parser: 4.2.0 + axobject-query@4.1.0: {} + b4a@1.8.1: {} babel-plugin-transform-hook-names@1.0.2(@babel/core@7.29.0): @@ -10771,6 +11145,8 @@ snapshots: strip-ansi: 7.2.0 wrap-ansi: 9.0.2 + clsx@2.1.1: {} + cluster-key-slot@1.1.2: {} color-convert@2.0.1: @@ -10779,6 +11155,8 @@ snapshots: color-name@1.1.4: {} + colorette@2.0.20: {} + comma-separated-tokens@2.0.3: {} commander@11.1.0: {} @@ -11592,6 +11970,8 @@ snapshots: transitivePeerDependencies: - supports-color + esm-env@1.2.2: {} + espree@10.4.0: dependencies: acorn: 8.16.0 @@ -11610,6 +11990,12 @@ snapshots: dependencies: estraverse: 5.3.0 + esrap@2.2.11(@typescript-eslint/types@8.59.2): + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + optionalDependencies: + '@typescript-eslint/types': 8.59.2 + esrecurse@4.3.0: dependencies: estraverse: 5.3.0 @@ -11908,6 +12294,10 @@ snapshots: section-matter: 1.0.0 strip-bom-string: 1.0.0 + gzip-size@6.0.0: + dependencies: + duplexer: 0.1.2 + gzip-size@7.0.0: dependencies: duplexer: 0.1.2 @@ -12100,6 +12490,10 @@ snapshots: dependencies: '@types/estree': 1.0.9 + is-reference@3.0.3: + dependencies: + '@types/estree': 1.0.9 + is-stream@2.0.1: {} is-stream@3.0.0: {} @@ -12277,6 +12671,8 @@ snapshots: pkg-types: 2.3.1 quansync: 0.2.11 + locate-character@3.0.0: {} + locate-path@6.0.0: dependencies: p-locate: 5.0.0 @@ -12309,6 +12705,16 @@ snapshots: lz-string@1.5.0: {} + magic-regexp@0.10.0: + dependencies: + estree-walker: 3.0.3 + magic-string: 0.30.21 + mlly: 1.8.2 + regexp-tree: 0.1.27 + type-level-regexp: 0.1.17 + ufo: 1.6.4 + unplugin: 2.3.11 + magic-regexp@0.11.0: dependencies: magic-string: 0.30.21 @@ -13321,6 +13727,11 @@ snapshots: '@oxc-transform/binding-win32-ia32-msvc': 0.131.0 '@oxc-transform/binding-win32-x64-msvc': 0.131.0 + oxc-walker@0.7.0(oxc-parser@0.131.0): + dependencies: + magic-regexp: 0.10.0 + oxc-parser: 0.131.0 + oxc-walker@1.0.0(oxc-parser@0.131.0)(rolldown@1.0.2): dependencies: magic-regexp: 0.11.0 @@ -14261,6 +14672,27 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + svelte@5.56.3(@typescript-eslint/types@8.59.2): + dependencies: + '@jridgewell/remapping': 2.3.5 + '@jridgewell/sourcemap-codec': 1.5.5 + '@sveltejs/acorn-typescript': 1.0.10(acorn@8.16.0) + '@types/estree': 1.0.9 + '@types/trusted-types': 2.0.7 + acorn: 8.16.0 + aria-query: 5.3.1 + axobject-query: 4.1.0 + clsx: 2.1.1 + devalue: 5.8.1 + esm-env: 1.2.2 + esrap: 2.2.11(@typescript-eslint/types@8.59.2) + is-reference: 3.0.3 + locate-character: 3.0.0 + magic-string: 0.30.21 + zimmerframe: 1.1.4 + transitivePeerDependencies: + - '@typescript-eslint/types' + svgo@4.0.1: dependencies: commander: 11.1.0 @@ -14574,6 +15006,28 @@ snapshots: unist-util-is: 6.0.1 unist-util-visit-parents: 6.0.2 + unocss@66.7.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)): + dependencies: + '@unocss/cli': 66.7.2 + '@unocss/core': 66.7.2 + '@unocss/preset-attributify': 66.7.2 + '@unocss/preset-icons': 66.7.2 + '@unocss/preset-mini': 66.7.2 + '@unocss/preset-tagify': 66.7.2 + '@unocss/preset-typography': 66.7.2 + '@unocss/preset-uno': 66.7.2 + '@unocss/preset-web-fonts': 66.7.2 + '@unocss/preset-wind': 66.7.2 + '@unocss/preset-wind3': 66.7.2 + '@unocss/preset-wind4': 66.7.2 + '@unocss/transformer-attributify-jsx': 66.7.2 + '@unocss/transformer-compile-class': 66.7.2 + '@unocss/transformer-directives': 66.7.2 + '@unocss/transformer-variant-group': 66.7.2 + '@unocss/vite': 66.7.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)) + transitivePeerDependencies: + - vite + unpipe@1.0.0: {} unplugin-utils@0.3.1: @@ -14719,6 +15173,10 @@ snapshots: optionator: 0.9.4 typescript: 6.0.3 + vite-plugin-css-injected-by-js@3.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)): + dependencies: + 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-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)): dependencies: ansis: 4.3.0 @@ -14789,6 +15247,10 @@ snapshots: tsx: 4.22.3 yaml: 2.8.4 + vitefu@1.1.3(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)): + optionalDependencies: + 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) + vitepress-plugin-mermaid@2.0.17(mermaid@11.15.0)(vitepress@2.0.0-alpha.17(@types/node@25.9.1)(change-case@5.4.4)(fuse.js@7.3.0)(jiti@2.7.0)(lightningcss@1.32.0)(oxc-minify@0.131.0)(postcss@8.5.15)(terser@5.47.1)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.8.4)): dependencies: mermaid: 11.15.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 5ce93e3..784a577 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -70,6 +70,14 @@ catalogs: preact: ^10.29.2 react: ^19.2.6 react-dom: ^19.2.6 + svelte: ^5.0.0 + unocss: ^66.0.0 + '@sveltejs/vite-plugin-svelte': ^5.0.0 + '@unocss/vite': ^66.0.0 + '@unocss/preset-icons': ^66.0.0 + '@unocss/preset-uno': ^66.0.0 + '@iconify-json/ph': ^1.2.0 + 'vite-plugin-css-injected-by-js': ^3.5.0 inlined: '@antfu/utils': ^9.3.0 ua-parser-modern: ^0.1.1 From 22abcf3e0dd1bb05d0ad28f8c03cc71c3fc55373 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Mon, 22 Jun 2026 02:04:56 +0000 Subject: [PATCH 09/11] fix(plugin-terminals): add missing declarations and update snapshot --- plugins/terminals/src/env.d.ts | 2 ++ .../tsnapi/@devframes/plugin-terminals/client.snapshot.js | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/plugins/terminals/src/env.d.ts b/plugins/terminals/src/env.d.ts index b300efd..2f470ce 100644 --- a/plugins/terminals/src/env.d.ts +++ b/plugins/terminals/src/env.d.ts @@ -3,3 +3,5 @@ declare module '*.svelte' { const component: Component export default component } + +declare module 'virtual:uno.css' {} diff --git a/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/client.snapshot.js b/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/client.snapshot.js index 754ad67..5e51997 100644 --- a/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/client.snapshot.js +++ b/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/client.snapshot.js @@ -6,5 +6,5 @@ export async function mountTerminals(_, _) {} // #endregion // #region Variables -export var TERMINAL_STREAM_CHANNEL /* const */ +export var TERMINAL_STREAM_CHANNEL // #endregion \ No newline at end of file From f8adb491efe52e995046d6b0f9ca7805b5234370 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Mon, 22 Jun 2026 04:05:39 +0000 Subject: [PATCH 10/11] fix(plugin-terminals): satisfy lint and formatting --- plugins/terminals/src/client/App.svelte | 42 +++++++++++------------- plugins/terminals/src/env.d.ts | 1 + plugins/terminals/src/spa/vite.config.ts | 2 +- plugins/terminals/uno.config.ts | 36 ++++++++++++++++++++ pnpm-workspace.yaml | 12 +++---- 5 files changed, 64 insertions(+), 29 deletions(-) diff --git a/plugins/terminals/src/client/App.svelte b/plugins/terminals/src/client/App.svelte index 883dc28..65d4d2a 100644 --- a/plugins/terminals/src/client/App.svelte +++ b/plugins/terminals/src/client/App.svelte @@ -34,7 +34,11 @@ onMount(() => { const mq = window.matchMedia('(prefers-color-scheme: dark)') isDark = mq.matches - const onMq = (e: MediaQueryListEvent) => isDark = e.matches + document.documentElement.classList.toggle('dark', isDark) + const onMq = (e: MediaQueryListEvent) => { + isDark = e.matches + document.documentElement.classList.toggle('dark', isDark) + } mq.addEventListener('change', onMq) const onHashChange = () => { @@ -138,13 +142,13 @@ } -
-
+
+
{#each sessions as s (s.id)} {#if renamingId === s.id} { @@ -157,24 +161,20 @@ /> {:else} {/if} {/each} {/if} {/each} -
- -
- -
-
-
- {#if activeId} - {@const activeSession = sessions.find(s => s.id === activeId)} - {#if activeSession} - - {activeSession.mode} - - - {activeSession.command}{activeSession.args.length ? ` ${activeSession.args.join(' ')}` : ''} - - - {activeSession.status === 'running' - ? `running · ${activeSession.backend}${activeSession.pid ? ` · pid ${activeSession.pid}` : ''}` - : `${activeSession.status}${activeSession.exitCode != null ? ` (${activeSession.exitCode})` : ''}`} - -
- - - {/if} + {#if presetsOpen} + +
(presetsOpen = false)} + onkeydown={() => {}} + >
+
+ {#each presets as p (p.id)} + + {/each} +
+ {/if} +
{/if} -
+ -
+ + {#if activeSession} + {@const s = activeSession} +
+ + {s.mode === 'interactive' ? 'interactive' : 'readonly'} + + + {s.command}{s.args.length ? ` ${s.args.join(' ')}` : ''} + + + {#if s.status === 'running'} + + {s.backend}{s.pid ? ` · ${s.pid}` : ''} + {:else} + {s.status}{s.exitCode != null ? ` (${s.exitCode})` : ''} + {/if} + + +
+ + + +
+ {/if} + + +
{#if sessions.length === 0} -
- No terminal sessions — click + to start one. +
+
+
+ No sessions. + +
{/if} {#each sessions as s (s.id)} - + {/each}
diff --git a/plugins/terminals/src/client/TerminalView.svelte b/plugins/terminals/src/client/TerminalView.svelte index a6f15f6..f425fe7 100644 --- a/plugins/terminals/src/client/TerminalView.svelte +++ b/plugins/terminals/src/client/TerminalView.svelte @@ -15,19 +15,19 @@ }>() const DARK_THEME: ITheme = { - background: '#000000', + background: '#111111', foreground: '#c9d1d9', - cursor: '#58a6ff', - cursorAccent: '#000000', - selectionBackground: '#234876', + cursor: '#7cbc71', + cursorAccent: '#111111', + selectionBackground: '#ffffff20', } const LIGHT_THEME: ITheme = { background: '#ffffff', foreground: '#1f2328', - cursor: '#0969da', + cursor: '#396831', cursorAccent: '#ffffff', - selectionBackground: '#b6d7ff', + selectionBackground: '#00000018', black: '#24292f', red: '#cf222e', green: '#116329', @@ -76,13 +76,14 @@ rpc.call('devframes-plugin-terminals:resize', { id: info.id, cols, rows }).catch(() => {}) }) - reader = rpc.streaming.subscribe(TERMINAL_STREAM_CHANNEL, info.id) + reader = rpc.streaming.subscribe(TERMINAL_STREAM_CHANNEL, info.id) ;(async () => { try { for await (const chunk of reader) { - term.write(chunk) + term.write(chunk as string) } - } catch {} + } + catch {} })() requestAnimationFrame(() => { @@ -121,4 +122,4 @@ }) -
+
diff --git a/plugins/terminals/src/client/index.ts b/plugins/terminals/src/client/index.ts index 074014d..737fc82 100644 --- a/plugins/terminals/src/client/index.ts +++ b/plugins/terminals/src/client/index.ts @@ -4,6 +4,7 @@ import { mount, unmount } from 'svelte' import App from './App.svelte' import 'virtual:uno.css' +import './styles.css' export interface MountTerminalsOptions { rpc?: DevframeRpcClient diff --git a/plugins/terminals/src/client/styles.css b/plugins/terminals/src/client/styles.css new file mode 100644 index 0000000..2005121 --- /dev/null +++ b/plugins/terminals/src/client/styles.css @@ -0,0 +1,49 @@ +html, +body, +#app { + height: 100vh; + margin: 0; + padding: 0; +} + +html { + --uno: bg-base color-base font-sans; + color-scheme: light; +} + +html.dark { + color-scheme: dark; +} + +:root { + --term-scrollbar: #8884; + --term-scrollbar-hover: #8887; +} + +::-webkit-scrollbar { + width: 7px; + height: 7px; +} + +::-webkit-scrollbar-thumb { + background: var(--term-scrollbar); + border-radius: 3px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--term-scrollbar-hover); +} + +::-webkit-scrollbar-track, +::-webkit-scrollbar-corner { + background: transparent; +} + +.xterm { + height: 100%; + padding: 0 0.25rem; +} + +.xterm .xterm-viewport { + background: transparent !important; +} diff --git a/plugins/terminals/src/env.d.ts b/plugins/terminals/src/env.d.ts index 812596c..1a6a903 100644 --- a/plugins/terminals/src/env.d.ts +++ b/plugins/terminals/src/env.d.ts @@ -6,3 +6,4 @@ declare module '*.svelte' { } declare module 'virtual:uno.css' {} +declare module '*.css' {} diff --git a/plugins/terminals/uno.config.ts b/plugins/terminals/uno.config.ts index ceaa653..68a570b 100644 --- a/plugins/terminals/uno.config.ts +++ b/plugins/terminals/uno.config.ts @@ -1,24 +1,12 @@ -import { defineConfig, presetIcons, presetUno } from 'unocss' +import { + defineConfig, + presetIcons, + presetWind4, + transformerDirectives, + transformerVariantGroup, +} from 'unocss' export default defineConfig({ - shortcuts: [ - { - 'color-base': 'text-gray-800 dark:text-gray-300', - 'bg-base': 'bg-white dark:bg-[#111]', - 'bg-secondary': 'bg-gray-100 dark:bg-[#222]', - 'border-base': 'border-gray-200 dark:border-[#333]', - - 'color-active': 'text-primary-600 dark:text-primary-400', - 'border-active': 'border-primary-600/25 dark:border-primary-400/25', - 'bg-active': 'bg-primary-500/10 dark:bg-primary-400/10', - - 'btn-action': 'border border-base rounded flex gap-1 items-center px-2.5 py-1 text-xs transition-colors hover:bg-active disabled:pointer-events-none disabled:opacity-50 cursor-pointer', - 'btn-action-active': 'color-active border-active bg-active', - - 'tab-btn': 'inline-flex items-center gap-1.5 whitespace-nowrap px-2.5 py-1 rounded-md border border-transparent text-xs cursor-pointer transition-colors bg-secondary text-gray-500 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200', - 'tab-btn-active': 'bg-base text-gray-800 dark:text-gray-200 border-base', - }, - ], theme: { colors: { primary: { @@ -37,10 +25,50 @@ export default defineConfig({ }, }, }, + shortcuts: [ + { + // Neutral foundations (light + dark together). + 'color-base': 'color-neutral-800 dark:color-neutral-200', + 'bg-base': 'bg-white dark:bg-#111', + 'bg-secondary': 'bg-#f6f6f7 dark:bg-#191919', + 'border-base': 'border-#8882', + + // Accents layered on top of neutrals. + 'bg-active': 'bg-#8881', + 'color-active': 'color-primary-600 dark:color-primary-300', + 'border-active': 'border-primary-600/25 dark:border-primary-400/25', + + 'op-fade': 'op65 dark:op55', + 'op-mute': 'op40 dark:op30', + + // Reusable controls. + 'btn-action': 'inline-flex gap-1.5 items-center border border-base rounded px2 py1 op75 transition-colors hover:(op100 bg-active) disabled:(pointer-events-none op30)', + 'btn-action-sm': 'btn-action text-sm', + 'btn-action-active': 'color-active border-active! bg-active op100!', + 'btn-icon': 'inline-flex h-7 w-7 items-center justify-center rounded op50 transition-colors hover:(op100 bg-active) disabled:(pointer-events-none op30)', + + // Tabs. + 'tab-item': 'relative inline-flex gap-1.5 items-center max-w-52 px2 py1 rounded border border-transparent text-sm op-fade transition-colors hover:(op100 bg-active) cursor-pointer select-none', + 'tab-item-active': 'op100! bg-active border-base! color-base', + + // Status dots (real semantic state only). + 'dot-running': 'bg-primary-500 dark:bg-primary-400', + 'dot-exited': 'bg-neutral-400 dark:bg-neutral-500', + 'dot-error': 'bg-red-500', + + // Named depth layers. + 'z-nav': 'z-30', + 'z-toolbar': 'z-20', + }, + // mode badges: badge-mode- + [/^badge-(\w+)$/, ([, color]) => `bg-${color}-400/15 text-${color}-700 dark:text-${color}-300 border border-${color}-500/20`], + ], presets: [ - presetUno(), - presetIcons({ - scale: 1.2, - }), + presetWind4(), + presetIcons({ scale: 1.1 }), + ], + transformers: [ + transformerDirectives(), + transformerVariantGroup(), ], })