diff --git a/design/design.ts b/design/design.ts index 10021af8..bb10822e 100644 --- a/design/design.ts +++ b/design/design.ts @@ -207,6 +207,14 @@ export function card(extra?: string): string { return cx('flex flex-col rounded-xl border border-base bg-base shadow-sm', extra) } +export function modalBackdrop(extra?: string): string { + return cx('fixed inset-0 z-modal-backdrop grid place-items-center p-4 bg-black/40 backdrop-blur-sm', extra) +} + +export function modalCard(extra?: string): string { + return cx('z-modal-content w-full max-w-sm flex flex-col gap-3 p-4 rounded-xl border border-base bg-base shadow-lg', extra) +} + export function panel(extra?: string): string { return cx('rounded-lg border border-base bg-base', extra) } diff --git a/docs/errors/DF8204.md b/docs/errors/DF8204.md new file mode 100644 index 00000000..e4f7e9bf --- /dev/null +++ b/docs/errors/DF8204.md @@ -0,0 +1,30 @@ +--- +outline: deep +--- + +# DF8204: Terminal Session Cannot Be Controlled + +## Message + +> Terminal session "`{id}`" cannot be controlled (no lifecycle handle) + +## Cause + +`hub:terminals:terminate` or `hub:terminals:restart` was called for a session +that carries no lifecycle handle. Sessions added with a bare +`ctx.terminals.register()` are just a descriptor plus an output stream — they +expose no `terminate()` / `restart()`. Only sessions spawned via +`ctx.terminals.startChildProcess()` or `ctx.terminals.startPtySession()` own +their process and can be terminated or restarted. + +## Fix + +- Spawn the session via `ctx.terminals.startChildProcess(executeOptions, terminal)` + or `ctx.terminals.startPtySession(executeOptions, terminal)` so it owns a + process the hub can control. +- For a bare `register()` session, drive its lifecycle from whatever owns the + underlying process; `hub:terminals:remove` still drops it from the registry. + +## Source + +- [`packages/hub/src/node/rpc-builtins.ts`](https://github.com/devframes/devframe/blob/main/packages/hub/src/node/rpc-builtins.ts) — the `hub:terminals:terminate` / `hub:terminals:restart` handlers throw this when the resolved session has no `terminate` handle. diff --git a/docs/errors/DF8205.md b/docs/errors/DF8205.md new file mode 100644 index 00000000..a56c699f --- /dev/null +++ b/docs/errors/DF8205.md @@ -0,0 +1,30 @@ +--- +outline: deep +--- + +# DF8205: Terminal Session Is Not Restartable + +## Message + +> Terminal session "`{id}`" is not restartable + +## Cause + +`hub:terminals:restart` was called for a session registered with +`restartable: false`. That flag marks a session whose lifecycle is owned +elsewhere — for example a one-shot build, or a server (like code-server) that +should be restarted through its own controls rather than by re-spawning the raw +process. A hub-aware terminal UI hides its restart affordance for these +sessions, and the RPC rejects a direct call. + +## Fix + +- Restart the session through its owner's controls (the plugin or dock that + started it), not the generic terminal restart. +- If in-place restarts are safe, spawn it with `restartable: true` (the default) + in the `terminal` descriptor passed to `startChildProcess()` / + `startPtySession()`. + +## Source + +- [`packages/hub/src/node/rpc-builtins.ts`](https://github.com/devframes/devframe/blob/main/packages/hub/src/node/rpc-builtins.ts) — the `hub:terminals:restart` handler throws this when the resolved session has `restartable: false`. diff --git a/examples/minimal-next-devframe-hub/package.json b/examples/minimal-next-devframe-hub/package.json index a43a13c0..b06e7ed3 100644 --- a/examples/minimal-next-devframe-hub/package.json +++ b/examples/minimal-next-devframe-hub/package.json @@ -6,7 +6,7 @@ "description": "Protocol-witness example — a tiny Next.js Devframe Hub built on @devframes/hub that exercises every hub subsystem end-to-end.", "homepage": "https://github.com/devframes/devframe/tree/main/examples/minimal-next-devframe-hub", "scripts": { - "dev": "next dev src/client -H 0.0.0.0", + "dev": "pnpm -C ../.. run build && next dev src/client", "build": "next build src/client", "test": "vitest run --config vitest.config.ts" }, diff --git a/examples/minimal-vite-devframe-hub/package.json b/examples/minimal-vite-devframe-hub/package.json index afbdfa85..1dcb410e 100644 --- a/examples/minimal-vite-devframe-hub/package.json +++ b/examples/minimal-vite-devframe-hub/package.json @@ -6,7 +6,7 @@ "description": "Protocol-witness example — a tiny Vite Devframe Hub built on @devframes/hub that exercises every hub subsystem end-to-end.", "homepage": "https://github.com/devframes/devframe/tree/main/examples/minimal-vite-devframe-hub", "scripts": { - "dev": "vite", + "dev": "pnpm -C ../.. run build && vite", "build": "vite build", "typecheck": "tsc --noEmit" }, diff --git a/packages/hub/src/node/__tests__/rpc-builtins.test.ts b/packages/hub/src/node/__tests__/rpc-builtins.test.ts index 4bdce1e0..5a9e2e0e 100644 --- a/packages/hub/src/node/__tests__/rpc-builtins.test.ts +++ b/packages/hub/src/node/__tests__/rpc-builtins.test.ts @@ -1,6 +1,13 @@ import type { DevframeHubContext } from '../context' import { describe, expect, it, vi } from 'vitest' -import { hubDocksActivate, hubTerminalsResize, hubTerminalsWrite } from '../rpc-builtins' +import { + hubDocksActivate, + hubTerminalsRemove, + hubTerminalsResize, + hubTerminalsRestart, + hubTerminalsTerminate, + hubTerminalsWrite, +} from '../rpc-builtins' function contextWithSessions(sessions: Map): DevframeHubContext { return { terminals: { sessions } } as unknown as DevframeHubContext @@ -38,6 +45,80 @@ describe('hub terminal write/resize RPC', () => { }) }) +describe('hub terminal terminate/restart/remove RPC', () => { + it('terminates a controllable (child-process or pty) session', async () => { + const terminate = vi.fn() + const ctx = contextWithSessions(new Map([ + ['log', { id: 'log', type: 'child-process', terminate }], + ])) + const fn = await hubTerminalsTerminate.setup!(ctx) + await fn.handler!('log') + expect(terminate).toHaveBeenCalledOnce() + }) + + it('restarts a restartable session', async () => { + const restart = vi.fn() + const ctx = contextWithSessions(new Map([ + ['log', { id: 'log', type: 'child-process', terminate: vi.fn(), restart }], + ])) + const fn = await hubTerminalsRestart.setup!(ctx) + await fn.handler!('log') + expect(restart).toHaveBeenCalledOnce() + }) + + it('rejects restarting a session marked restartable: false', async () => { + const restart = vi.fn() + const ctx = contextWithSessions(new Map([ + ['svc', { id: 'svc', type: 'child-process', terminate: vi.fn(), restart, restartable: false }], + ])) + const fn = await hubTerminalsRestart.setup!(ctx) + await expect(fn.handler!('svc')).rejects.toThrow(/not restartable/i) + expect(restart).not.toHaveBeenCalled() + }) + + it('rejects controlling a session with no lifecycle handle', async () => { + const ctx = contextWithSessions(new Map([ + ['bare', { id: 'bare' }], + ])) + const fn = await hubTerminalsTerminate.setup!(ctx) + await expect(fn.handler!('bare')).rejects.toThrow(/cannot be controlled/i) + }) + + it('kills then drops a session on remove', async () => { + const terminate = vi.fn() + const remove = vi.fn() + const session = { id: 'log', type: 'child-process', terminate } + const ctx = { + terminals: { sessions: new Map([['log', session]]), remove }, + } as unknown as DevframeHubContext + const fn = await hubTerminalsRemove.setup!(ctx) + await fn.handler!('log') + expect(terminate).toHaveBeenCalledOnce() + expect(remove).toHaveBeenCalledWith(session) + }) + + it('removes a bare registered session without a terminate handle', async () => { + const remove = vi.fn() + const session = { id: 'mirror' } + const ctx = { + terminals: { sessions: new Map([['mirror', session]]), remove }, + } as unknown as DevframeHubContext + const fn = await hubTerminalsRemove.setup!(ctx) + await fn.handler!('mirror') + expect(remove).toHaveBeenCalledWith(session) + }) + + it('rejects removing an unknown session', async () => { + const remove = vi.fn() + const ctx = { + terminals: { sessions: new Map(), remove }, + } as unknown as DevframeHubContext + const fn = await hubTerminalsRemove.setup!(ctx) + await expect(fn.handler!('nope')).rejects.toThrow(/not registered/i) + expect(remove).not.toHaveBeenCalled() + }) +}) + describe('hub docks activate RPC', () => { it('forwards dockId and params to docks.activate', async () => { const activate = vi.fn() diff --git a/packages/hub/src/node/diagnostics.ts b/packages/hub/src/node/diagnostics.ts index d144af21..e18fd931 100644 --- a/packages/hub/src/node/diagnostics.ts +++ b/packages/hub/src/node/diagnostics.ts @@ -59,6 +59,14 @@ export const diagnostics = defineDiagnostics({ DF8203: { why: (p: { command: string, reason: string }) => `Failed to spawn PTY session for "${p.command}": ${p.reason}`, }, + DF8204: { + why: (p: { id: string }) => `Terminal session "${p.id}" cannot be controlled (no lifecycle handle)`, + fix: 'Spawn it via ctx.terminals.startChildProcess() or startPtySession() — sessions added with a bare register() expose no terminate/restart handle.', + }, + DF8205: { + why: (p: { id: string }) => `Terminal session "${p.id}" is not restartable`, + fix: 'It was registered with `restartable: false`; restart it through its owner\'s controls, or spawn it with `restartable: true` (the default) to allow in-place restarts.', + }, DF8400: { why: (p: { id: string }) => `Command "${p.id}" is already registered`, }, diff --git a/packages/hub/src/node/rpc-builtins.ts b/packages/hub/src/node/rpc-builtins.ts index 00f582a5..3d1f1eb0 100644 --- a/packages/hub/src/node/rpc-builtins.ts +++ b/packages/hub/src/node/rpc-builtins.ts @@ -1,6 +1,9 @@ import type { RpcFunctionDefinitionAny } from 'devframe/rpc' import type { DevframeMessageEntry, DevframeMessageEntryInput } from '../types/messages' -import type { DevframePtyTerminalSession } from '../types/terminals' +import type { + DevframeChildProcessTerminalSession, + DevframePtyTerminalSession, +} from '../types/terminals' import { defineHubRpcFunction } from '../define' import { diagnostics } from './diagnostics' @@ -20,6 +23,26 @@ function resolveInteractiveSession( return session } +/** A session exposing lifecycle handles — spawned via startChildProcess/startPtySession. */ +type ControllableTerminalSession = DevframeChildProcessTerminalSession | DevframePtyTerminalSession + +/** + * Resolve a session that can be terminated/restarted (spawned via + * `startChildProcess` or `startPtySession`), or throw. Sessions added with a + * bare `register()` carry no lifecycle handle and are rejected. + */ +function resolveControllableSession( + sessions: Map, + id: string, +): ControllableTerminalSession { + const session = sessions.get(id) as ControllableTerminalSession | undefined + if (!session) + throw diagnostics.DF8201({ id }) + if (typeof session.terminate !== 'function') + throw diagnostics.DF8204({ id }) + return session +} + /** * `hub:commands:execute` — Invoke a registered server command by id. The * arguments after `id` are forwarded to the command's `handler(...)`. @@ -119,6 +142,61 @@ export const hubTerminalsResize = defineHubRpcFunction({ }), }) +/** + * `hub:terminals:terminate` — Kill a session's process while keeping the + * session registered (its output/scrollback stays). Works for both read-only + * child-process and interactive PTY sessions, letting a hub-aware terminal UI + * force-kill a session owned by another plugin. + */ +export const hubTerminalsTerminate = defineHubRpcFunction({ + name: 'hub:terminals:terminate', + type: 'action', + setup: context => ({ + async handler(id: string): Promise { + await resolveControllableSession(context.terminals.sessions, id).terminate() + }, + }), +}) + +/** + * `hub:terminals:restart` — Re-run a session's command in place. Rejected for + * sessions registered with `restartable: false`, whose lifecycle is owned + * elsewhere. + */ +export const hubTerminalsRestart = defineHubRpcFunction({ + name: 'hub:terminals:restart', + type: 'action', + setup: context => ({ + async handler(id: string): Promise { + const session = resolveControllableSession(context.terminals.sessions, id) + if (session.restartable === false) + throw diagnostics.DF8205({ id }) + await session.restart() + }, + }), +}) + +/** + * `hub:terminals:remove` — Kill a session's process (when it still owns one) + * and drop it from the registry, disposing its output stream. Lets a hub-aware + * terminal UI discard a stopped aggregated session. + */ +export const hubTerminalsRemove = defineHubRpcFunction({ + name: 'hub:terminals:remove', + type: 'action', + setup: context => ({ + async handler(id: string): Promise { + const session = context.terminals.sessions.get(id) + if (!session) + throw diagnostics.DF8201({ id }) + const controllable = session as Partial + if (typeof controllable.terminate === 'function') + await controllable.terminate() + context.terminals.remove(session) + }, + }), +}) + /** * `hub:docks:activate` — Ask the active viewer to switch its focused dock to * `dockId`, optionally carrying `params` for the target dock to interpret @@ -156,4 +234,7 @@ export const builtinHubRpcDeclarations: readonly RpcFunctionDefinitionAny[] = [ hubMessagesClear, hubTerminalsWrite, hubTerminalsResize, + hubTerminalsTerminate, + hubTerminalsRestart, + hubTerminalsRemove, ] diff --git a/packages/hub/src/types/terminals.ts b/packages/hub/src/types/terminals.ts index 2e21735a..55e9d575 100644 --- a/packages/hub/src/types/terminals.ts +++ b/packages/hub/src/types/terminals.ts @@ -10,6 +10,8 @@ export interface DevframeTerminalsHost { register: (session: DevframeTerminalSession) => DevframeTerminalSession update: (session: DevframeTerminalSession) => void + /** Drop a session from the registry, disposing its bound output stream. */ + remove: (session: DevframeTerminalSession) => void /** * Spawn a read-only child process (pipe-backed, output only). Use this for @@ -50,6 +52,15 @@ export interface DevframeTerminalSessionBase { * decide whether to enable stdin and wire resize. */ interactive?: boolean + /** + * Whether the session may be restarted in place (re-running its command). + * Defaults to `true`. Set `false` for sessions whose lifecycle is owned + * elsewhere — e.g. a one-shot build, or a server (like code-server) that + * should be restarted through its own controls rather than by re-spawning + * the raw process. A hub-aware terminal UI hides its restart affordance for + * these, and `hub:terminals:restart` rejects them. + */ + restartable?: boolean } export interface DevframeTerminalSession extends DevframeTerminalSessionBase { diff --git a/plugins/code-server/src/node/supervisor.ts b/plugins/code-server/src/node/supervisor.ts index 57a09b63..d7cd5cba 100644 --- a/plugins/code-server/src/node/supervisor.ts +++ b/plugins/code-server/src/node/supervisor.ts @@ -65,7 +65,7 @@ interface HubTerminalsBridge { remove?: (session: { id: string }) => void startChildProcess: ( executeOptions: { command: string, args: string[], cwd?: string, env?: Record }, - terminal: { id: string, title: string, description?: string, icon?: string }, + terminal: { id: string, title: string, description?: string, icon?: string, restartable?: boolean }, ) => Promise } @@ -557,6 +557,10 @@ export class CodeServerSupervisor { title: TERMINAL_SESSION_TITLE, description: folder, icon: TERMINAL_SESSION_ICON, + // Restarting the editor means re-running the supervisor's start + // flow (fresh port + secret), not re-spawning this raw process, so + // hide the terminal panel's generic restart for this session. + restartable: false, }, ) const child = session.getChildProcess() diff --git a/plugins/terminals/src/client/App.svelte b/plugins/terminals/src/client/App.svelte index 51a59452..f252af7a 100644 --- a/plugins/terminals/src/client/App.svelte +++ b/plugins/terminals/src/client/App.svelte @@ -11,6 +11,8 @@ connectionTitle, dot, iconButton, + modalBackdrop, + modalCard, nav, navBrand, navTab, @@ -41,8 +43,24 @@ let renamingId = $state(null) let presetsOpen = $state(false) + // Terminating a running process is destructive (its output stops, and for a + // full remove, its scrollback is discarded), so it goes through this modal. + interface ConfirmDialog { + title: string + body: string + confirmLabel: string + onConfirm: () => void + } + let confirm = $state(null) + const activeSession = $derived(sessions.find(s => s.id === activeId) ?? null) + // Only own sessions can be killed/removed; hub-aggregated ones are read-only. + // `exitedCount` drives the nav-level "Clear exited" sweep. + const exitedCount = $derived( + sessions.filter(s => !isExternal(s) && s.status !== 'running').length, + ) + // A focus request that arrived (via the hub's dock-activation slot) before // its session showed up in the list. Applied one-shot the moment a matching // session appears, then cleared so the user's own tab clicks stay honored. @@ -222,6 +240,80 @@ spawn({ presetId: id }) } + /** + * Route a lifecycle action to the right RPC: own sessions go through this + * plugin (object args); sessions aggregated from other devframes via the hub + * are driven through the hub's built-ins (positional args), the same seam + * `TerminalView` uses for write/resize. Force-killing and removing therefore + * work for read-only aggregated sessions too, not just the plugin's own. + */ + function control(s: TerminalSessionInfo, action: 'terminate' | 'restart' | 'remove'): void { + if (isExternal(s)) + rpc.call(`hub:terminals:${action}`, s.id).catch(() => {}) + else + rpc.call(`devframes:plugin:terminals:${action}`, { id: s.id }).catch(() => {}) + } + + /** A session offers a restart affordance unless it opted out (`restartable: false`). */ + function canRestart(s: TerminalSessionInfo): boolean { + return s.restartable !== false + } + + function restartSession(id: string): void { + const s = sessions.find(x => x.id === id) + if (s) + control(s, 'restart') + } + + /** + * Kill a session's running process (interactive or readonly, own or + * aggregated), keeping the stopped session and its scrollback. Always + * confirmed — a live process is being terminated. + */ + function killSession(id: string): void { + const s = sessions.find(x => x.id === id) + if (!s) + return + confirm = { + title: 'Kill process', + body: `Terminate “${displayName(s)}”? The process stops, but the session stays in the list so you can read its output or restart it.`, + confirmLabel: 'Kill process', + onConfirm: () => control(s, 'terminate'), + } + } + + /** + * Discard a session entirely — process, stream, and scrollback. Removing a + * session whose process is still running terminates it, so that case is + * confirmed; an already-stopped session is dropped immediately. + */ + function removeSession(id: string): void { + const s = sessions.find(x => x.id === id) + if (!s) + return + if (s.status === 'running') { + confirm = { + title: 'Remove terminal', + body: `“${displayName(s)}” is still running. Removing it terminates the process and discards its output.`, + confirmLabel: 'Kill & remove', + onConfirm: () => control(s, 'remove'), + } + return + } + control(s, 'remove') + } + + function resolveConfirm(): void { + const action = confirm?.onConfirm + confirm = null + action?.() + } + + /** Sweep every stopped session away in one go. */ + function clearExited(): void { + rpc.call('devframes:plugin:terminals:clear-exited').catch(() => {}) + } + function commitRename(id: string, title: string): void { renamingId = null rpc.call('devframes:plugin:terminals:rename', { id, title: title.trim() }).catch(() => {}) @@ -232,6 +324,24 @@ node.select() } + function autofocus(node: HTMLElement) { + node.focus() + } + + // While the confirmation modal is open, Escape cancels and Enter confirms. + function onGlobalKey(e: KeyboardEvent): void { + if (!confirm) + return + if (e.key === 'Escape') { + e.preventDefault() + confirm = null + } + else if (e.key === 'Enter') { + e.preventDefault() + resolveConfirm() + } + } + function statusDot(status: string): DotState { if (status === 'running') return 'running' @@ -239,6 +349,8 @@ } + + {#if connCopy}
@@ -290,16 +402,14 @@
{/if} {displayName(s)} - {#if !isExternal(s)} - { e.stopPropagation(); rpc.call('devframes:plugin:terminals:remove', { id: s.id }).catch(() => {}) }} - onkeydown={() => {}} - > - {/if} + { e.stopPropagation(); removeSession(s.id) }} + onkeydown={() => {}} + > {/if} {/each} @@ -314,6 +424,18 @@
+ {#if exitedCount > 0} + + {/if} + {#if presets.length}
- {#if !isExternal(s)} - - + {:else} + {/if} @@ -410,5 +538,36 @@ {/each} + + {#if confirm} + + + {/if} {/if} diff --git a/plugins/terminals/src/client/TerminalView.svelte b/plugins/terminals/src/client/TerminalView.svelte index de28b0a8..f80abed5 100644 --- a/plugins/terminals/src/client/TerminalView.svelte +++ b/plugins/terminals/src/client/TerminalView.svelte @@ -59,6 +59,14 @@ scrollback: 10000, theme: isDark ? DARK_THEME : LIGHT_THEME, disableStdin: info.mode !== 'interactive', + // Readonly sessions are line-oriented log streams. Own pipe sessions get + // their bare `\n` normalized to `\r\n` server-side, but sessions + // aggregated from other devframes via the hub stream their raw output + // here unfiltered — a lone `\n` would then leave the cursor's column + // untouched and render a staircase. `convertEol` makes xterm treat `\n` + // as `\r\n`, fixing both (a no-op where CRLF already arrived). Interactive + // PTY sessions keep it off so full-screen TUIs control the cursor exactly. + convertEol: info.mode !== 'interactive', allowProposedApi: false, }) diff --git a/plugins/terminals/src/node/manager.ts b/plugins/terminals/src/node/manager.ts index dcd5ab7a..d7d1a670 100644 --- a/plugins/terminals/src/node/manager.ts +++ b/plugins/terminals/src/node/manager.ts @@ -70,6 +70,12 @@ interface HubTerminalEntry { * the hub instead of treating it as read-only. */ interactive?: boolean + /** + * Whether the aggregated hub session may be restarted in place. `false` when + * its owner reserves restarts for its own controls; surfaced so this plugin's + * UI can hide the restart affordance for it. + */ + restartable?: boolean } interface HubTerminalsBridge { sessions: Map @@ -207,6 +213,7 @@ export class TerminalManager { createdAt: 0, icon: toIconClass(session.icon), channel: HUB_TERMINAL_STREAM_CHANNEL, + restartable: session.restartable, }) } return [...own, ...foreign] @@ -483,6 +490,30 @@ export class TerminalManager { const session = this.sessions.get(id) if (!session) throw diagnostics.DP_TERMINALS_0001({ id }) + this.disposeSession(id, session) + this.publish() + } + + /** + * Drop every stopped (non-running) session this manager owns, closing their + * streams and forgetting their scrollback. Sessions still running are left + * untouched; aggregated hub sessions aren't owned here so they never appear + * in {@link sessions}. Publishes once for the whole sweep. + */ + clearExited(): void { + for (const [id, session] of this.sessions) { + if (session.info.status !== 'running') + this.disposeSession(id, session) + } + this.publish() + } + + /** + * Tear a single session down — kill its process, stop polling, dispose the + * OSC inspector, close the sink, and drop it from the store — without + * publishing. Callers publish once they've finished mutating the store. + */ + private disposeSession(id: string, session: ManagedSession): void { const proc = session.proc session.proc = undefined this.stopProcessPoll(session) @@ -492,7 +523,6 @@ export class TerminalManager { if (!session.sink.closed) session.sink.close() this.sessions.delete(id) - this.publish() } /** Tear everything down — used on server shutdown and in tests. */ diff --git a/plugins/terminals/src/rpc/functions/clear-exited.ts b/plugins/terminals/src/rpc/functions/clear-exited.ts new file mode 100644 index 00000000..83c302d4 --- /dev/null +++ b/plugins/terminals/src/rpc/functions/clear-exited.ts @@ -0,0 +1,20 @@ +import { defineRpcFunction } from 'devframe' +import * as v from 'valibot' +import { getTerminalManager } from '../../node/context' + +export const clearExited = defineRpcFunction({ + name: 'devframes:plugin:terminals:clear-exited', + type: 'action', + jsonSerializable: true, + args: [], + returns: v.void(), + agent: { + description: 'Discard every stopped (exited or errored) terminal session at once. Running sessions are left untouched.', + safety: 'destructive', + }, + setup: ctx => ({ + handler: () => { + getTerminalManager(ctx).clearExited() + }, + }), +}) diff --git a/plugins/terminals/src/rpc/index.ts b/plugins/terminals/src/rpc/index.ts index 520c916a..9409bf18 100644 --- a/plugins/terminals/src/rpc/index.ts +++ b/plugins/terminals/src/rpc/index.ts @@ -1,5 +1,6 @@ import type { RpcDefinitionsToFunctions } from 'devframe/rpc' import type { TerminalPreset, TerminalsSharedState } from '../types' +import { clearExited } from './functions/clear-exited' import { list } from './functions/list' import { presets } from './functions/presets' import { remove } from './functions/remove' @@ -20,6 +21,7 @@ export const serverFunctions = [ restart, rename, remove, + clearExited, ] as const declare module 'devframe' { diff --git a/plugins/terminals/src/types.ts b/plugins/terminals/src/types.ts index fbb62ec2..47a230c7 100644 --- a/plugins/terminals/src/types.ts +++ b/plugins/terminals/src/types.ts @@ -55,6 +55,13 @@ export interface TerminalSessionInfo { exitCode?: number icon?: string channel?: string + /** + * Whether the session may be restarted in place. `false` hides the restart + * control and makes the restart RPC reject it — used for sessions whose + * lifecycle is owned elsewhere (surfaced from the hub's `restartable` flag). + * Own sessions leave this unset (always restartable). + */ + restartable?: boolean /** Preset this session was spawned from, if any. */ presetId?: string createdAt: number diff --git a/plugins/terminals/test/_utils.ts b/plugins/terminals/test/_utils.ts index dcb39f76..eb6d1cb1 100644 --- a/plugins/terminals/test/_utils.ts +++ b/plugins/terminals/test/_utils.ts @@ -27,6 +27,8 @@ interface FakeHubEntry { description?: string status: 'running' | 'stopped' | 'error' icon?: string | { light: string, dark: string } + interactive?: boolean + restartable?: boolean } export interface FakeHubTerminals { diff --git a/plugins/terminals/test/terminals.test.ts b/plugins/terminals/test/terminals.test.ts index afe81429..96784d73 100644 --- a/plugins/terminals/test/terminals.test.ts +++ b/plugins/terminals/test/terminals.test.ts @@ -247,6 +247,65 @@ describe('@devframes/plugin-terminals', () => { expect(list.some(s => s.id === info.id)).toBe(false) }) + it('kills a running process but keeps the session as stopped', 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 vi.waitFor(async () => { + const list = await sessions(server) + expect(list.find(s => s.id === info.id)?.status).toBe('running') + }) + + // terminate stops the process, but the session (and its scrollback) survive. + await call(client, 'devframes:plugin:terminals:terminate', { id: info.id }) + + await vi.waitFor(async () => { + const list = await sessions(server) + const s = list.find(x => x.id === info.id) + expect(s).toBeDefined() + expect(s?.status).not.toBe('running') + }) + + await call(client, 'devframes:plugin:terminals:remove', { id: info.id }) + }) + + it('clears stopped sessions, leaving running ones untouched', async () => { + const client = bootClient(server.port) + await new Promise(r => setTimeout(r, 50)) + + // One session exits on its own; another stays alive. + const done = await call(client, 'devframes:plugin:terminals:spawn', { + command: NODE, + args: ['-e', 'process.stdout.write("bye")'], + mode: 'readonly', + }) + const alive = await call(client, 'devframes:plugin:terminals:spawn', { + command: NODE, + args: ['-e', 'setInterval(() => {}, 1000)'], + mode: 'readonly', + }) + + await vi.waitFor(async () => { + const list = await sessions(server) + expect(list.find(s => s.id === done.id)?.status).toBe('exited') + expect(list.find(s => s.id === alive.id)?.status).toBe('running') + }) + + await call(client, 'devframes:plugin:terminals:clear-exited') + + const list = await sessions(server) + expect(list.some(s => s.id === done.id)).toBe(false) + expect(list.some(s => s.id === alive.id)).toBe(true) + + await call(client, 'devframes:plugin:terminals:remove', { id: alive.id }) + }) + it('exposes presets and spawns from them', async () => { await server.close() server = await startTerminalsServer({ @@ -316,6 +375,28 @@ describe('@devframes/plugin-terminals', () => { expect(afterRemove.some(s => s.id === 'devframes_plugin_code-server')).toBe(false) }) + it('surfaces a foreign session\'s restartable flag so the UI can respect it', async () => { + await server.close() + const hub = createFakeHubTerminals() + server = await startTerminalsServer({}, { hub }) + const client = bootClient(server.port) + await new Promise(r => setTimeout(r, 50)) + + // A session whose owner reserves restarts for its own controls. + hub.register({ + id: 'devframes_plugin_code-server', + title: 'Code Server', + status: 'running', + restartable: false, + }) + // A plain aggregated session leaves it unset (restartable by default). + hub.register({ id: 'other', title: 'Other', status: 'running' }) + + const list = await call(client, 'devframes:plugin:terminals:list') + expect(list.find(s => s.id === 'devframes_plugin_code-server')?.restartable).toBe(false) + expect(list.find(s => s.id === 'other')?.restartable).toBeUndefined() + }) + it('mirrors its own sessions into the hub without looping', async () => { await server.close() const hub = createFakeHubTerminals() diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.d.ts index ced3f90c..b1e12286 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.d.ts @@ -196,6 +196,21 @@ export declare const hubMessagesUpdate: { __cache?: WeakMap], Promise>>> | undefined; __promise?: import("devframe/rpc").Thenable], Promise>> | undefined; }; +export declare const hubTerminalsRemove: { + name: "hub:terminals:remove"; + type?: "action" | undefined; + cacheable?: boolean; + args?: undefined; + returns?: undefined; + jsonSerializable?: boolean; + agent?: import("devframe").RpcFunctionAgentOptions; + setup?: ((context: DevframeHubContext) => import("devframe/rpc").Thenable>>) | undefined; + handler?: ((id: string) => Promise) | undefined; + dump?: import("devframe/rpc").RpcDump<[id: string], Promise, DevframeHubContext> | undefined; + snapshot?: boolean; + __cache?: WeakMap>>> | undefined; + __promise?: import("devframe/rpc").Thenable>> | undefined; +}; export declare const hubTerminalsResize: { name: "hub:terminals:resize"; type?: "action" | undefined; @@ -211,6 +226,36 @@ export declare const hubTerminalsResize: { __cache?: WeakMap>>> | undefined; __promise?: import("devframe/rpc").Thenable>> | undefined; }; +export declare const hubTerminalsRestart: { + name: "hub:terminals:restart"; + type?: "action" | undefined; + cacheable?: boolean; + args?: undefined; + returns?: undefined; + jsonSerializable?: boolean; + agent?: import("devframe").RpcFunctionAgentOptions; + setup?: ((context: DevframeHubContext) => import("devframe/rpc").Thenable>>) | undefined; + handler?: ((id: string) => Promise) | undefined; + dump?: import("devframe/rpc").RpcDump<[id: string], Promise, DevframeHubContext> | undefined; + snapshot?: boolean; + __cache?: WeakMap>>> | undefined; + __promise?: import("devframe/rpc").Thenable>> | undefined; +}; +export declare const hubTerminalsTerminate: { + name: "hub:terminals:terminate"; + type?: "action" | undefined; + cacheable?: boolean; + args?: undefined; + returns?: undefined; + jsonSerializable?: boolean; + agent?: import("devframe").RpcFunctionAgentOptions; + setup?: ((context: DevframeHubContext) => import("devframe/rpc").Thenable>>) | undefined; + handler?: ((id: string) => Promise) | undefined; + dump?: import("devframe/rpc").RpcDump<[id: string], Promise, DevframeHubContext> | undefined; + snapshot?: boolean; + __cache?: WeakMap>>> | undefined; + __promise?: import("devframe/rpc").Thenable>> | undefined; +}; export declare const hubTerminalsWrite: { name: "hub:terminals:write"; type?: "action" | undefined; diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.js b/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.js index e6a95010..649f7eee 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.js +++ b/tests/__snapshots__/tsnapi/@devframes/hub/node.snapshot.js @@ -86,6 +86,9 @@ export var hubMessagesAdd /* const */ export var hubMessagesClear /* const */ export var hubMessagesRemove /* const */ export var hubMessagesUpdate /* const */ +export var hubTerminalsRemove /* const */ export var hubTerminalsResize /* const */ +export var hubTerminalsRestart /* const */ +export var hubTerminalsTerminate /* const */ export var hubTerminalsWrite /* const */ // #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 index c9e4c964..3732906a 100644 --- a/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/node.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/node.snapshot.d.ts @@ -33,6 +33,8 @@ export declare class TerminalManager { rename(_: string, _: string): void; restart(_: string): TerminalSessionInfo; remove(_: string): void; + clearExited(): void; + private disposeSession; dispose(): void; private publish; private refreshSessionsState; diff --git a/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/node.snapshot.js b/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/node.snapshot.js index 10d47b65..71f29528 100644 --- a/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/node.snapshot.js +++ b/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/node.snapshot.js @@ -33,6 +33,8 @@ export class TerminalManager { rename(_, _) {} restart(_) {} remove(_) {} + clearExited() {} + disposeSession(_, _) {} dispose() {} publish() {} refreshSessionsState() {} 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 5465556d..ef57bad9 100644 --- a/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/rpc.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/rpc.snapshot.d.ts @@ -678,5 +678,19 @@ export declare const serverFunctions: readonly [{ __promise?: import("devframe/rpc").Thenable> | undefined; +}, { + name: "devframes:plugin:terminals:clear-exited"; + type?: "action" | undefined; + cacheable?: boolean; + args: readonly []; + returns: import("valibot").VoidSchema; + jsonSerializable?: boolean; + agent?: import("devframe").RpcFunctionAgentOptions; + setup?: ((context: import("devframe").DevframeNodeContext) => import("devframe/rpc").Thenable>) | undefined; + handler?: (() => void) | undefined; + dump?: import("devframe/rpc").RpcDump<[], void, import("devframe").DevframeNodeContext> | undefined; + snapshot?: boolean; + __cache?: WeakMap>> | undefined; + __promise?: import("devframe/rpc").Thenable> | undefined; }]; // #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 index a1de6878..3d1af5e0 100644 --- a/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/types.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/types.snapshot.d.ts @@ -42,6 +42,7 @@ export interface TerminalSessionInfo { exitCode?: number; icon?: string; channel?: string; + restartable?: boolean; presetId?: string; createdAt: number; }