From 7084016d5dd770cafc3c9a3a0643196ea4590aa9 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Mon, 2 Nov 2026 16:32:37 +0800 Subject: [PATCH 1/2] feat(sdk): add native terminal lifecycle and reopen APIs --- guides/native-terminals.md | 123 ++++++++++++++++++ sdk/e2e/plugin_sdk/backend.rs | 55 ++++++-- sdk/java/README.md | 8 ++ .../io/github/litheidea/plugin/sdk/Host.java | 34 +++++ .../github/litheidea/plugin/sdk/Protocol.java | 6 + .../github/litheidea/plugin/sdk/HostTest.java | 23 ++++ sdk/node/README.md | 8 ++ sdk/node/src/host.mjs | 9 ++ sdk/node/src/index.d.ts | 12 ++ sdk/node/src/protocol.mjs | 6 + sdk/node/test/host.test.mjs | 34 +++++ sdk/node/test/types/host.mts | 17 +++ sdk/rust/README.md | 8 ++ sdk/rust/examples/sample-backend/src/main.rs | 37 ++++++ sdk/rust/plugin-sdk/src/host.rs | 79 +++++++++++ sdk/rust/plugin-sdk/src/lib.rs | 2 +- sdk/rust/plugin-sdk/src/protocol.rs | 6 + sdk/rust/plugin-sdk/tests/host_services.rs | 38 ++++++ 18 files changed, 492 insertions(+), 13 deletions(-) create mode 100644 guides/native-terminals.md diff --git a/guides/native-terminals.md b/guides/native-terminals.md new file mode 100644 index 0000000..65a4540 --- /dev/null +++ b/guides/native-terminals.md @@ -0,0 +1,123 @@ +# Open a native Lithe terminal from a plugin + +These SDK wrappers are an **unreleased addition** and require a host that +advertises the terminal methods. The checked-in contract mirror remains pinned +to its published upstream commit until the terminal host change merges; it must +then be refreshed with `scripts/contract.mjs sync`, never edited manually. + +Use the host's integrated terminal for an interactive client. Declare +`requires.backend: 2` and permission `terminal.create`; a web page invokes a +function in its own backend, which calls the host with the function's workspace +context. It does not need to implement a terminal emulator, stream keystrokes +through plugin functions, or become a Build/Run provider. + +Discover `lithe/host/terminal/create` and `reopen` with `host.supports` first. +An old host or Windows host can lack them. The first implementation supports +only a local workspace on macOS; it explicitly refuses a remote authority. +The host checks the caller, displays execution confirmation, and manages the +PTY, rendering, focus, resize and bounded process-tree shutdown. + +## Node.js + +```js +const host = context.host; +const workspace = context.workspaceContext; +if (!workspace || !await host.supports('lithe/host/terminal/reopen')) { + throw new Error('This host has no native terminal API.'); +} +const terminal = await host.terminal.create(workspace, { + title: 'Interactive client', + executable: 'example-client', + arguments: ['--interactive'], + retainedContextBytes: 16 * 1024, +}); +// Keep an exit subscription on a backend-lifetime host if it must outlive this call. +const events = await backend.host.terminal.onExit(workspace, terminal.terminalID, event => { + console.error(event.generation, event.exitCode, event.reason); +}); +await host.terminal.close(workspace, { terminalID: terminal.terminalID, retainedContextBytes: 4096 }); +await host.terminal.reopen(workspace, { terminalID: terminal.terminalID }); +await events.dispose(); +await host.terminal.dispose(workspace, { terminalID: terminal.terminalID }); +``` + +Pass only supported fields. In particular, use `{terminalID, +retainedContextBytes:4096}` for close rather than copying result metadata into +request params; the host refuses unknown fields. No SDK retries a launch after +cancellation automatically. + +## Rust + +```rust,ignore +let workspace = context.workspace_context().ok_or_else(|| + lithe_plugin_sdk::PluginError::new("example.noWorkspace", "Open a workspace first."))?; +let host = context.host(); +if !host.supports("lithe/host/terminal/reopen")? { + return Err(lithe_plugin_sdk::PluginError::new( + "example.unavailable", "This host has no native terminal API.").into()); +} +let terminal = host.terminal(); +let opened = terminal.create(workspace, serde_json::json!({ + "executable": "example-client", "arguments": ["--interactive"], + "retainedContextBytes": 4096 +}))?; +let id = opened["terminalID"].as_str().ok_or_else(|| + lithe_plugin_sdk::FunctionError::internal("Host returned no terminalID"))?; +terminal.close(workspace, id, Some(4096))?; +terminal.reopen(workspace, id)?; +terminal.dispose(workspace, id)?; +``` + +Rust also exposes `terminal.on_exit(workspace,id)` as an existing SDK +`Subscription`, with bounded `recv_timeout` and explicit `dispose`. + +## Java + +`host.terminal()` exposes `create(context,params)`, `show(context,params)`, +`close(context,params)`, `reopen(context,params)`, `dispose(context,params)` and +`onExit(context,params)`. Params are normal JSON maps. The first five return a +`CompletableFuture>` (null for show/dispose); `onExit` returns +`CompletableFuture`. Context, cancellation, host failures and +subscription routing use the same channel as the other protocol-3 methods. + +## Launch and reopen semantics + +`executable` is a bare PATH command or absolute path; `arguments` stays an argv +array. Optional `cwd` is a workspace `{rootID,path}` directory resource and +cannot escape the workspace. `environment` is a map of string overrides/null +removals, not shell assignments. Never put credentials in titles, arguments or +logs. Input and output live in the native terminal; there is no public +`sendText`, terminal-output read or user-terminal enumeration API. + +`close` removes the tab and stops the process, retaining the launch configuration +and a bounded plain-text suffix of the displayed output. `retainedContextBytes` +is a UTF-8 byte budget: default 16384, maximum 262144, 0 disables history. It may +be set on create or overridden at close. Actual retained bytes can be smaller +to keep Unicode intact. Displayed/echoed secrets can be in that history; choose +0 for such sessions. No raw escape sequence or stdin event is retained. + +`reopen` uses a **new process** with the same executable/argv/cwd/environment, +restoring that suffix to the display before spawning. It never replays input, +recovers process memory or resumes shell variables. It keeps `terminalID` and +increases `generation`; events include generation so old exits cannot be +mistaken for the new process. The exit subscription continues across reopen. +Natural exit and user tab close are reopenable too. + +`dispose` forgets the handle, launch settings and history, closing a live session +first. Eight handles per plugin (including closed ones) are retained, so dispose +handles that will not be reopened. A backend/session restart, plugin disable or +update, and closing/replacing the workspace also release them. Context and +history are in-memory only and never written to the installed package. + +The Rust sample's `terminal.cycle` function and Core end-to-end scenario exercise +the protocol roundtrip. That scenario supplies host responses; Lithe's own +`PluginTerminalNativeTests` verify actual native PTY input and output/history. + +Before the host change merges, run the opt-in terminal protocol scenario with: + +```sh +LITHE_PLUGIN_SDK_TERMINAL_E2E=1 node scripts/sdk-e2e.mjs --source /path/to/Lithe-worktree --working-tree +``` + +Without that flag the scenario continues to validate the published Core pin; +the ungranted terminal method remains discoverably unavailable on that host. diff --git a/sdk/e2e/plugin_sdk/backend.rs b/sdk/e2e/plugin_sdk/backend.rs index 47e36c4..0624f85 100644 --- a/sdk/e2e/plugin_sdk/backend.rs +++ b/sdk/e2e/plugin_sdk/backend.rs @@ -20,11 +20,15 @@ use std::time::Instant; /// Runs the scenario against the installed package at `package`, whose /// `plugin.json` declares the sample backend. pub fn run_backend_scenario(package: &Path) { - let manifest: Value = + let mut manifest: Value = serde_json::from_slice(&fs::read(package.join("plugin.json")).expect("sample manifest")) .expect("sample manifest JSON"); let plugin_id = manifest["id"].as_str().expect("plugin ID").to_string(); let package_before = listing(package); + // Opt in against the terminal host branch before its contract is published. + // Default CI must still work against the pinned older Core. + let terminals = std::env::var_os("LITHE_PLUGIN_SDK_TERMINAL_E2E").is_some(); + if terminals { manifest["permissions"].as_array_mut().unwrap().push(json!("terminal.create")); } let validated = core( "plugins.declarative.validate", json!({ "manifest": manifest }), @@ -34,17 +38,14 @@ pub fn run_backend_scenario(package: &Path) { let fixture = Fixture::new("backend"); let state = fixture.root.join("state"); let plan = launch_plan(&manifest, package, &state); - assert_eq!( - plan["backend"]["hostServices"], - json!([ - "credentials", - "lithe/host/window/showError", - "lithe/host/window/showInformation", - "lithe/host/window/showWarning", - "lithe/host/workspace/watch", - "workspace" - ]) - ); + let mut grants = vec!["credentials", "lithe/host/window/showError", "lithe/host/window/showInformation", + "lithe/host/window/showWarning", "lithe/host/workspace/watch", "workspace"]; + if terminals { + grants.extend(["lithe/host/terminal/close", "lithe/host/terminal/create", "lithe/host/terminal/dispose", + "lithe/host/terminal/onExit", "lithe/host/terminal/reopen", "lithe/host/terminal/show"]); + } + grants.sort(); + assert_eq!(plan["backend"]["hostServices"], json!(grants)); assert_eq!(plan["requiredDirectories"], json!(["state"])); fs::create_dir_all(&state).expect("host state directory"); @@ -59,6 +60,7 @@ pub fn run_backend_scenario(package: &Path) { "secret.check", "secret.forget", "secret.remember", + "terminal.cycle", "work.wait" ] ); @@ -171,6 +173,7 @@ pub fn run_backend_scenario(package: &Path) { wait_for_file(&mut session, &state.join("cancelled")); run_host_services(&mut session); + if terminals { run_terminal_services(&mut session); } // State survives a restart. Without credentials.store the host grants no // host service, and Core refuses the request before the host sees it. @@ -380,6 +383,34 @@ fn run_host_services(session: &mut Session) { ); } +/// This scenario plays the native host at the protocol boundary. Real PTY +/// rendering and history restoration are covered by Lithe's native tests. +fn run_terminal_services(session: &mut Session) { + invoke_in(session, "terminal-cycle", "terminal.cycle", &workspace()); + let create = take_host_request(session, "lithe/host/terminal/create"); + assert_eq!(create["result"]["params"]["retainedContextBytes"], 4096); + assert_eq!(create["result"]["params"]["arguments"], json!(["--interactive"])); + respond(session, &create, Ok(json!({ "terminalID":"terminal-1", "generation":1 }))); + let listen = take_host_request(session, "lithe/host/terminal/onExit"); + respond(session, &listen, Ok(json!({ "subscriptionID":"terminal-sub" }))); + let close = take_host_request(session, "lithe/host/terminal/close"); + respond(session, &close, Ok(json!({ "retainedContextBytes":1024 }))); + assert!(notify(session, "lithe/host/subscriptions/notify", json!({ + "subscriptionID":"terminal-sub", "sequence":1, + "event": { "type":"exit", "terminalID":"terminal-1", "generation":1, "exitCode":null, "reason":"closed" } + }))); + let reopen = take_host_request(session, "lithe/host/terminal/reopen"); + assert_eq!(reopen["result"]["params"]["terminalID"], "terminal-1"); + respond(session, &reopen, Ok(json!({ "terminalID":"terminal-1", "generation":2, "restoredContextBytes":1024 }))); + let dispose = take_host_request(session, "lithe/host/terminal/dispose"); + respond(session, &dispose, Ok(Value::Null)); + let subscription = take_host_request(session, "lithe/host/subscriptions/dispose"); + respond(session, &subscription, Ok(Value::Null)); + let result = session.completion("terminal-cycle"); + assert!(result.get("error").is_none(), "{result}"); + assert_eq!(result["result"]["value"]["reopened"]["generation"], 2); +} + /// Invokes `function` as a page of the window showing `context` does. fn invoke_in(session: &Session, operation: &str, function: &str, context: &Value) { core( diff --git a/sdk/java/README.md b/sdk/java/README.md index bc9635b..c214a7d 100644 --- a/sdk/java/README.md +++ b/sdk/java/README.md @@ -274,3 +274,11 @@ producer changes. Protocol 1–5 handlers reject requests containing model input The two native workflow samples demonstrate independent producer and consumer backends in the Core end-to-end tests. Their engine is fictional; this does not migrate Maven, JDT, LLDB or existing plugin business logic. + +## Native terminal API (unreleased) + +The SDK includes wrappers for plugin-owned native terminal create/show/close, +reopen with bounded display history, disposal and exit subscriptions. These +methods are discovered within protocol 3; they require a newer host and +`terminal.create`, not a new protocol version. See +[the native terminal guide](../../guides/native-terminals.md). diff --git a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Host.java b/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Host.java index 2ea91f4..5e67d63 100644 --- a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Host.java +++ b/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Host.java @@ -189,6 +189,10 @@ public Browser browser() { return browser; } + public Terminal terminal() { + return new Terminal(); + } + public Window window() { return window; } @@ -417,6 +421,36 @@ public CompletableFuture> openExternal(Map contex } } + /** Native terminals private to the backend session and workspace (protocol 3). */ + public final class Terminal { + private Terminal() {} + /** Params: executable, arguments?, title?, cwd?, environment?, show?. */ + public CompletableFuture> create(Map context, Map params) { + return scoped(Protocol.HOST_TERMINAL_CREATE, context, params); + } + public CompletableFuture> show(Map context, Map params) { + return scoped(Protocol.HOST_TERMINAL_SHOW, context, params); + } + public CompletableFuture> close(Map context, Map params) { + return scoped(Protocol.HOST_TERMINAL_CLOSE, context, params); + } + /** Restarts the saved argv, restoring only the chosen plain display suffix. */ + public CompletableFuture> reopen(Map context, Map params) { + return scoped(Protocol.HOST_TERMINAL_REOPEN, context, params); + } + /** Forgets the handle and its retained display history. */ + public CompletableFuture> dispose(Map context, Map params) { + return scoped(Protocol.HOST_TERMINAL_DISPOSE, context, params); + } + /** Params: terminalID; disposing the subscription does not close the terminal. */ + public CompletableFuture onExit(Map context, Map params) { + Map message; + try { message = withContext(context, params); } + catch (PluginSdkError error) { return failed(error); } + return channel.subscribe(Protocol.HOST_TERMINAL_ON_EXIT, message, null, scope); + } + } + /** * Messages in the window (protocol 3). {@code params}: {@code text, * actions?}; answers {@code { selectedIndex }}, null on dismissal. diff --git a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Protocol.java b/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Protocol.java index ce4a7ef..c4e2ff8 100644 --- a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Protocol.java +++ b/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Protocol.java @@ -45,6 +45,12 @@ public final class Protocol { * {@link Host#request} sends any {@code lithe/host/*} method, and * {@code getCapabilities} says which ones the session may call. */ + public static final String HOST_TERMINAL_CREATE = "lithe/host/terminal/create"; + public static final String HOST_TERMINAL_SHOW = "lithe/host/terminal/show"; + public static final String HOST_TERMINAL_CLOSE = "lithe/host/terminal/close"; + public static final String HOST_TERMINAL_REOPEN = "lithe/host/terminal/reopen"; + public static final String HOST_TERMINAL_DISPOSE = "lithe/host/terminal/dispose"; + public static final String HOST_TERMINAL_ON_EXIT = "lithe/host/terminal/onExit"; public static final String HOST_PREFIX = "lithe/host/"; public static final String HOST_CREDENTIALS_GET = "lithe/host/credentials/get"; public static final String HOST_CREDENTIALS_SET = "lithe/host/credentials/set"; diff --git a/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/HostTest.java b/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/HostTest.java index e9f62bf..278089a 100644 --- a/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/HostTest.java +++ b/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/HostTest.java @@ -108,6 +108,29 @@ private static List> sent(TestHost host, String method) { return host.messages().stream().filter(message -> method.equals(message.get("method"))).collect(Collectors.toList()); } + @Test + void nativeTerminalMethodsKeepContextAndLiteralArguments() { + Backend backend = Backend.create(null, null); + TestHost host = session(3, backend); + try { + CompletableFuture> creating = backend.host().terminal().create(CONTEXT, + map("executable", "example-client", "arguments", Arrays.asList("literal;$(value)", "space value"))); + Map request = nextRequest(host, Protocol.HOST_TERMINAL_CREATE); + assertEquals(CONTEXT, object(request.get("params")).get("context")); + assertEquals(Arrays.asList("literal;$(value)", "space value"), object(request.get("params")).get("arguments")); + host.send(map("id", request.get("id"), "result", map("terminalID", "owned"))); + assertEquals("owned", value(creating).get("terminalID")); + CompletableFuture> closing = backend.host().terminal().close(CONTEXT, map("terminalID", "owned")); + Map close = nextRequest(host, Protocol.HOST_TERMINAL_CLOSE); + host.send(map("id", close.get("id"), "result", map("retainedContextBytes", 4096L))); + assertEquals(4096L, value(closing).get("retainedContextBytes")); + CompletableFuture> reopening = backend.host().terminal().reopen(CONTEXT, map("terminalID", "owned")); + Map reopen = nextRequest(host, Protocol.HOST_TERMINAL_REOPEN); + host.send(map("id", reopen.get("id"), "result", map("terminalID", "owned", "generation", 2L, "restoredContextBytes", 4096L))); + assertEquals(2L, value(reopening).get("generation")); + } finally { host.close(); } + } + @Test void theSdkAnswersTheHighestPluginFunctionVersionTheHostOffers() { Object offer = object(FIXTURE.get("offer")).get("versions"); diff --git a/sdk/node/README.md b/sdk/node/README.md index add5711..2ddcf60 100644 --- a/sdk/node/README.md +++ b/sdk/node/README.md @@ -334,3 +334,11 @@ producer changes. Protocol 1–5 handlers reject requests containing model input The two native workflow samples demonstrate independent producer and consumer backends in the Core end-to-end tests. Their engine is fictional; this does not migrate Maven, JDT, LLDB or existing plugin business logic. + +## Native terminal API (unreleased) + +The SDK includes wrappers for plugin-owned native terminal create/show/close, +reopen with bounded display history, disposal and exit subscriptions. These +methods are discovered within protocol 3; they require a newer host and +`terminal.create`, not a new protocol version. See +[the native terminal guide](../../guides/native-terminals.md). diff --git a/sdk/node/src/host.mjs b/sdk/node/src/host.mjs index fc0e113..1aafa4c 100644 --- a/sdk/node/src/host.mjs +++ b/sdk/node/src/host.mjs @@ -327,6 +327,15 @@ export function createHost(channel, callSignal) { browser: Object.freeze({ openExternal: scoped(HOST_METHODS.browserOpenExternal), }), + terminal: Object.freeze({ + create: scoped(HOST_METHODS.terminalCreate), + show: scoped(HOST_METHODS.terminalShow), + close: scoped(HOST_METHODS.terminalClose), + reopen: scoped(HOST_METHODS.terminalReopen), + dispose: scoped(HOST_METHODS.terminalDispose), + onExit: async (context, terminalID, onEvent, options) => + subscribe(HOST_METHODS.terminalOnExit, { context: contextReference(context), terminalID }, onEvent, options), + }), window: Object.freeze({ showInformation: scoped(HOST_METHODS.windowShowInformation), showWarning: scoped(HOST_METHODS.windowShowWarning), diff --git a/sdk/node/src/index.d.ts b/sdk/node/src/index.d.ts index 8b7d813..a2cbc50 100644 --- a/sdk/node/src/index.d.ts +++ b/sdk/node/src/index.d.ts @@ -265,6 +265,18 @@ export interface Host { { opened: true; source: 'web' | 'disk'; hasUnsavedChanges?: boolean } >; }; + /** Permission `terminal.create`; only terminals owned by this backend session and context. */ + readonly terminal: { + create: Scoped<{ executable: string; arguments?: string[]; title?: string; cwd?: WorkspaceResource; + environment?: Record; show?: boolean; retainedContextBytes?: number }, { terminalID: string; generation: number }>; + show: Scoped<{ terminalID: string }, null>; + close: Scoped<{ terminalID: string; retainedContextBytes?: number }, { retainedContextBytes: number }>; + reopen: Scoped<{ terminalID: string; show?: boolean }, { terminalID: string; generation: number; restoredContextBytes: number }>; + dispose: Scoped<{ terminalID: string }, null>; + onExit(context: ContextReference, terminalID: string, + onEvent: (event: { type: 'exit'; terminalID: string; exitCode: number | null; reason: 'exited' | 'closed'; generation: number }, metadata: SubscriptionEventInfo) => void, + options?: HostRequestOptions): Promise; + }; /** Permission `window.messages`. `selectedIndex` is zero-based, or null on dismissal. */ readonly window: { showInformation: Scoped<{ text: string; actions?: string[] }, { selectedIndex: number | null }>; diff --git a/sdk/node/src/protocol.mjs b/sdk/node/src/protocol.mjs index b47894d..4cf8e2b 100644 --- a/sdk/node/src/protocol.mjs +++ b/sdk/node/src/protocol.mjs @@ -71,6 +71,12 @@ export const HOST_METHODS = Object.freeze({ windowShowInformation: 'lithe/host/window/showInformation', windowShowWarning: 'lithe/host/window/showWarning', windowShowError: 'lithe/host/window/showError', + terminalCreate: 'lithe/host/terminal/create', + terminalShow: 'lithe/host/terminal/show', + terminalClose: 'lithe/host/terminal/close', + terminalReopen: 'lithe/host/terminal/reopen', + terminalDispose: 'lithe/host/terminal/dispose', + terminalOnExit: 'lithe/host/terminal/onExit', }); /** Host-to-backend notifications of protocol 3. */ diff --git a/sdk/node/test/host.test.mjs b/sdk/node/test/host.test.mjs index d38b883..ba19d81 100644 --- a/sdk/node/test/host.test.mjs +++ b/sdk/node/test/host.test.mjs @@ -320,3 +320,37 @@ test('commands, context changes, and later host notifications reach their handle assert.deepEqual(seen, expected); await host.close(); }); + +test('native terminal wrappers keep argv and context, and subscribe to one owned exit', { timeout: 5000 }, async () => { + const { backend, host } = await session(3); + try { + const params = { executable: 'example-client', arguments: ['literal;$(value)', 'space value'], show: true }; + const creating = backend.host.terminal.create(CONTEXT, params); + const create = await nextRequest(host, 'lithe/host/terminal/create'); + assert.deepEqual(create.params, { ...params, context: CONTEXT }); + host.send({ id: create.id, result: { terminalID: 'owned', generation: 1 } }); + assert.deepEqual(await creating, { terminalID: 'owned', generation: 1 }); + const seen = gate(); + let event; + const listening = backend.host.terminal.onExit(CONTEXT, 'owned', value => { event = value; seen.open(); }); + const listen = await nextRequest(host, 'lithe/host/terminal/onExit'); + host.send({ id: listen.id, result: { subscriptionID: 'terminal-exit' } }); + const subscription = await listening; + host.notify('lithe/host/subscriptions/notify', { subscriptionID: 'terminal-exit', sequence: 1, + event: { type: 'exit', terminalID: 'owned', exitCode: 0, reason: 'exited' } }); + await withTimeout(seen.opened, 'terminal exit event'); + assert.equal(event.exitCode, 0); + const closing = backend.host.terminal.close(CONTEXT, { terminalID: 'owned' }); + const close = await nextRequest(host, 'lithe/host/terminal/close'); + host.send({ id: close.id, result: { retainedContextBytes: 0 } }); + assert.deepEqual(await closing, { retainedContextBytes: 0 }); + const reopening = backend.host.terminal.reopen(CONTEXT, { terminalID: 'owned' }); + const reopen = await nextRequest(host, 'lithe/host/terminal/reopen'); + host.send({ id: reopen.id, result: { terminalID: 'owned', generation: 2, restoredContextBytes: 0 } }); + assert.equal((await reopening).generation, 2); + const disposing = subscription.dispose(); + const dispose = await nextRequest(host, 'lithe/host/subscriptions/dispose'); + host.send({ id: dispose.id, result: null }); + await disposing; + } finally { await host.close(); } +}); diff --git a/sdk/node/test/types/host.mts b/sdk/node/test/types/host.mts index 034ac65..474c61b 100644 --- a/sdk/node/test/types/host.mts +++ b/sdk/node/test/types/host.mts @@ -65,3 +65,20 @@ async function edit(context: ContextReference) { await backend.host.credentials.set('key', 42); } void edit; + +async function nativeTerminal(context: ContextReference) { + const created = await backend.host.terminal.create(context, { + executable: 'example-client', arguments: ['--interactive'], environment: { MODE: 'dev', OLD_MODE: null }, + }); + await backend.host.terminal.show(context, { terminalID: created.terminalID }); + const subscription = await backend.host.terminal.onExit(context, created.terminalID, (event, info) => { + const code: number | null = event.exitCode; + const reason: 'exited' | 'closed' = event.reason; + void code; void reason; void info; + }); + await subscription.dispose(); + await backend.host.terminal.close(context, { terminalID: created.terminalID, retainedContextBytes: 4096 }); + await backend.host.terminal.reopen(context, { terminalID: created.terminalID }); + await backend.host.terminal.dispose(context, { terminalID: created.terminalID }); +} +void nativeTerminal; diff --git a/sdk/rust/README.md b/sdk/rust/README.md index ee51d82..334ea7b 100644 --- a/sdk/rust/README.md +++ b/sdk/rust/README.md @@ -282,3 +282,11 @@ The unit tests drive a session through in-process pipes with a scripted host; `tests/host_services.rs` checks the host services against the contract's fixture. The end-to-end check builds the sample, installs it as a package, and runs it through a real Core session. + +## Native terminal API (unreleased) + +The SDK includes wrappers for plugin-owned native terminal create/show/close, +reopen with bounded display history, disposal and exit subscriptions. These +methods are discovered within protocol 3; they require a newer host and +`terminal.create`, not a new protocol version. See +[the native terminal guide](../../guides/native-terminals.md). diff --git a/sdk/rust/examples/sample-backend/src/main.rs b/sdk/rust/examples/sample-backend/src/main.rs index 9f04baa..7b69d04 100644 --- a/sdk/rust/examples/sample-backend/src/main.rs +++ b/sdk/rust/examples/sample-backend/src/main.rs @@ -140,6 +140,43 @@ fn main() { .function("files.nextChange", |_, context| next_change(context)) .expect("valid function"); + // Terminal APIs are additive protocol-3 host methods. The lifecycle + // sample deliberately uses a harmless interactive client and display-only + // history; it never executes retained text as input. + backend + .function("terminal.cycle", |_, context| { + let workspace = context + .workspace_context() + .ok_or_else(|| PluginError::new("sample.noWorkspace", "Open a workspace first."))?; + let host = context.host(); + if !host.supports("lithe/host/terminal/reopen")? { + return Err(PluginError::new( + "sample.terminalUnavailable", + "The host has no native terminal API.", + ) + .into()); + } + let terminal = host.terminal(); + let created = terminal.create( + workspace, + json!({ "executable": "example-client", "arguments": ["--interactive"], + "retainedContextBytes": 4096, "show": true }), + )?; + let id = created["terminalID"] + .as_str() + .ok_or_else(|| FunctionError::internal("missing terminalID"))?; + let subscription = terminal.on_exit(workspace, id)?; + let closed = terminal.close(workspace, id, Some(4096))?; + let event = subscription + .recv_timeout(Duration::from_secs(5)) + .map_err(|_| FunctionError::internal("terminal exit did not arrive"))?; + let reopened = terminal.reopen(workspace, id)?; + terminal.dispose(workspace, id)?; + subscription.dispose()?; + Ok(json!({ "closed": closed, "reopened": reopened, "event": event.event() })) + }) + .expect("valid function"); + // Notifications are events: each handler runs on a thread of its own and // may ask the host for more, outside any call. let handle = backend.handle(); diff --git a/sdk/rust/plugin-sdk/src/host.rs b/sdk/rust/plugin-sdk/src/host.rs index ebb1679..03143d9 100644 --- a/sdk/rust/plugin-sdk/src/host.rs +++ b/sdk/rust/plugin-sdk/src/host.rs @@ -494,6 +494,10 @@ impl Host { Browser { host: self.clone() } } + pub fn terminal(&self) -> Terminal { + Terminal { host: self.clone() } + } + pub fn window(&self) -> Window { Window { host: self.clone() } } @@ -1015,6 +1019,81 @@ impl Window { } } +/// Integrated native terminals owned by this backend session and workspace. +/// Permission `terminal.create`; discover each method with `Host::supports`. +/// Input and output stay in the host terminal, not in the plugin's web page. +pub struct Terminal { + host: Host, +} + +impl Terminal { + /// Launches executable and arguments directly. Params: `executable`, optional + /// `arguments`, `title`, `cwd` resource, `environment`, and `show` (true). + pub fn create(&self, context: &WorkspaceContext, params: Value) -> Result { + self.host.scoped(methods::TERMINAL_CREATE, context, params) + } + + /// Shows an owned terminal in its original window without raising the window. + pub fn show(&self, context: &WorkspaceContext, terminal_id: &str) -> Result { + self.host.scoped( + methods::TERMINAL_SHOW, + context, + json!({ "terminalID": terminal_id }), + ) + } + + /// Stops the native process, keeping a bounded display suffix for reopen. + /// `retained_context_bytes` overrides the create-time choice (0..=262144). + pub fn close( + &self, + context: &WorkspaceContext, + terminal_id: &str, + retained_context_bytes: Option, + ) -> Result { + let mut params = json!({ "terminalID": terminal_id }); + if let Some(bytes) = retained_context_bytes { + params["retainedContextBytes"] = json!(bytes); + } + self.host.scoped(methods::TERMINAL_CLOSE, context, params) + } + + /// Starts the saved argv again and restores plain display history, never input. + pub fn reopen(&self, context: &WorkspaceContext, terminal_id: &str) -> Result { + self.host.scoped( + methods::TERMINAL_REOPEN, + context, + json!({ "terminalID": terminal_id }), + ) + } + + /// Permanently forgets an owned handle and its history, also closing it if live. + pub fn dispose( + &self, + context: &WorkspaceContext, + terminal_id: &str, + ) -> Result { + self.host.scoped( + methods::TERMINAL_DISPOSE, + context, + json!({ "terminalID": terminal_id }), + ) + } + + /// One exit event per process generation, including the latest recorded + /// outcome when subscribing after exit. + /// Disposing the subscription stops notifications, not the terminal itself. + pub fn on_exit( + &self, + context: &WorkspaceContext, + terminal_id: &str, + ) -> Result { + self.host.subscribe( + methods::TERMINAL_ON_EXIT, + with_context(context, json!({ "terminalID": terminal_id }))?, + ) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/sdk/rust/plugin-sdk/src/lib.rs b/sdk/rust/plugin-sdk/src/lib.rs index 070d68f..deb51f0 100644 --- a/sdk/rust/plugin-sdk/src/lib.rs +++ b/sdk/rust/plugin-sdk/src/lib.rs @@ -34,7 +34,7 @@ pub use backend::{Backend, BackendHandle, Cancellation, Context, Initialize}; pub use error::{sdk_codes, Cancelled, FunctionError, PluginError, SdkError, HOST_ERROR_CODES}; pub use host::{ Browser, Credentials, Documents, Editor, Event, Host, Preview, Subscription, Subscriptions, - Window, Workspace, WorkspaceContext, + Terminal, Window, Workspace, WorkspaceContext, }; pub use protocol::{is_command_id, is_credential_key, is_function_id, is_plugin_error_code}; pub use serde_json::Value; diff --git a/sdk/rust/plugin-sdk/src/protocol.rs b/sdk/rust/plugin-sdk/src/protocol.rs index d2bddec..1cf3655 100644 --- a/sdk/rust/plugin-sdk/src/protocol.rs +++ b/sdk/rust/plugin-sdk/src/protocol.rs @@ -24,6 +24,12 @@ pub(crate) mod methods { pub const FUNCTION_REGISTER: &str = "lithe/function/register"; pub const FUNCTION_UNREGISTER: &str = "lithe/function/unregister"; pub const FUNCTION_PROGRESS: &str = "lithe/function/progress"; + pub const TERMINAL_CREATE: &str = "lithe/host/terminal/create"; + pub const TERMINAL_SHOW: &str = "lithe/host/terminal/show"; + pub const TERMINAL_CLOSE: &str = "lithe/host/terminal/close"; + pub const TERMINAL_REOPEN: &str = "lithe/host/terminal/reopen"; + pub const TERMINAL_DISPOSE: &str = "lithe/host/terminal/dispose"; + pub const TERMINAL_ON_EXIT: &str = "lithe/host/terminal/onExit"; pub const HOST_PREFIX: &str = "lithe/host/"; pub const CREDENTIALS_GET: &str = "lithe/host/credentials/get"; pub const CREDENTIALS_SET: &str = "lithe/host/credentials/set"; diff --git a/sdk/rust/plugin-sdk/tests/host_services.rs b/sdk/rust/plugin-sdk/tests/host_services.rs index 2883fcd..b857c8a 100644 --- a/sdk/rust/plugin-sdk/tests/host_services.rs +++ b/sdk/rust/plugin-sdk/tests/host_services.rs @@ -724,3 +724,41 @@ fn commands_context_changes_and_later_host_notifications_reach_their_handlers() session.host.assert_quiet(); session.close(); } + +#[test] +fn native_terminal_methods_keep_context_and_literal_arguments() { + let backend = Backend::new("terminal-test"); + let handle = backend.handle(); + let mut session = Session::start(3, backend); + let host = handle.host(); + let creating = background(move || { + host.terminal().create( + &context(), + json!({ + "executable": "example-client", "arguments": ["literal;$(value)", "space value"] + }), + ) + }); + let create = session.take("lithe/host/terminal/create"); + assert_eq!(create["params"]["context"], context().to_value()); + assert_eq!( + create["params"]["arguments"], + json!(["literal;$(value)", "space value"]) + ); + session.reply(&create, json!({ "terminalID": "owned" })); + assert_eq!(done(&creating).unwrap()["terminalID"], "owned"); + for method in ["show", "close", "reopen", "dispose"] { + let host = handle.host(); + let operation = background(move || match method { + "show" => host.terminal().show(&context(), "owned"), + "close" => host.terminal().close(&context(), "owned", Some(4096)), + "reopen" => host.terminal().reopen(&context(), "owned"), + _ => host.terminal().dispose(&context(), "owned"), + }); + let request = session.take(&format!("lithe/host/terminal/{method}")); + assert_eq!(request["params"]["terminalID"], "owned"); + session.reply(&request, Value::Null); + assert_eq!(done(&operation).unwrap(), Value::Null); + } + session.close(); +} From c04a3104f066a75f8cd397a30b29620dd5f21036 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Mon, 2 Nov 2026 17:30:12 +0800 Subject: [PATCH 2/2] docs(contracts): sync merged terminal API and enable default e2e coverage --- contract.lock.json | 12 ++- contract/v1/README.md | 102 ++++++++++++++++++ contract/v1/fixtures/function-protocol.json | 10 +- .../v1/fixtures/terminal-backend.plugin.json | 35 ++++++ contract/v1/plugin-manifest.schema.json | 3 +- guides/native-terminals.md | 25 +++-- sdk/e2e/plugin_sdk/backend.rs | 68 ++++++++---- sdk/rust/examples/sample-backend/plugin.json | 1 + 8 files changed, 221 insertions(+), 35 deletions(-) create mode 100644 contract/v1/fixtures/terminal-backend.plugin.json diff --git a/contract.lock.json b/contract.lock.json index 932553e..d086f42 100644 --- a/contract.lock.json +++ b/contract.lock.json @@ -3,12 +3,12 @@ "source": { "repository": "https://github.com/1lck/Lithe-IDEA", "directory": "shared/contracts/plugins/v1", - "revision": "04b1fb848f8f593d51e998b0cc0f487bc8c56f40" + "revision": "f64d262c48f1ff5ad1778b301138596493a09ad5" }, "files": [ { "path": "README.md", - "sha256": "02f8480592dba210e6e5d78d2eb14d9cf6d4dbd4ee8eef0339982f67eecf9696" + "sha256": "4cfe0f5f6cb15980bf9db4b19fe5ed80082c417132ccad04dee5959d7efdef72" }, { "path": "fixtures/backend-workspace.plugin.json", @@ -124,7 +124,7 @@ }, { "path": "fixtures/function-protocol.json", - "sha256": "e880024b108b2d11c5c2b93c39ea642460989eaf530bc1f85254f404780dfc3b" + "sha256": "4341a2145d495431f4017ae2aed95214c512c14c5efb866c5a1178912c66006b" }, { "path": "fixtures/grammars/http.monarch.json", @@ -226,6 +226,10 @@ "path": "fixtures/settings-v2.plugin.json", "sha256": "83d4c7faf7bfbb9c474639bfc0ef42dae8cd7d370d388f8527589f2afed12981" }, + { + "path": "fixtures/terminal-backend.plugin.json", + "sha256": "664669aa36b8b3af464495048f76cb1154c38407c816e7997447f372c139fff1" + }, { "path": "fixtures/tool-definition.plugin.json", "sha256": "06b94a1886edcaa8ade3ae1462d01aa6e54e7ec3104ae28d0b796f2a303fd90a" @@ -252,7 +256,7 @@ }, { "path": "plugin-manifest.schema.json", - "sha256": "a308c44479fd2f1f3a51f54f75831f1052ec02aa18e7f294187946bd30ea3a6c" + "sha256": "fba04d8c2c053cec877ab0419e1236810a75368fb3536b271b6a6301ba750339" }, { "path": "proposals/toolchains/README.md", diff --git a/contract/v1/README.md b/contract/v1/README.md index 4b08601..12b4059 100644 --- a/contract/v1/README.md +++ b/contract/v1/README.md @@ -606,6 +606,7 @@ Rules for publishing across levels and capability versions: | `credentials.store` | the backend uses the [credentials host service](#host-services-protocol-version-2); refused without a `backend` | | `workspace.metadata` | the backend uses the [workspace host service](#workspace-contexts-and-capability-discovery-protocol-version-3); refused without `backend` 2 | | `editor.actions` | the backend registers independent editor actions; requires `backend` 2 and `documents.read`, grants only `editor/registerActions`, not document edits or domain execution | +| `terminal.create` | the backend creates and manages its own [native terminals](#plugin-owned-native-terminals-function-protocol-3), including bounded display history for reopen; requires `backend` 2 | | Activation event | Meaning | | --- | --- | @@ -2972,3 +2973,104 @@ Schema 1 loads the complete node set on refresh. Forms, lazy child data sources, multi-instance selectors, global function commands, native renderer factories and Windows rendering remain follow-up work under #1209; this subset does not claim completion of that whole issue. + +### Plugin-owned native terminals (function protocol 3) + +[`terminal-backend.plugin.json`](fixtures/terminal-backend.plugin.json) is the +minimal manifest. `terminal.create` grants `lithe/host/terminal/create`, `show`, `close`, `reopen`, +`dispose`, and `onExit`. It requires `backend:2`; discover the individual +methods through `getCapabilities` before using them. macOS implements this +service. Windows has no handler and does not advertise its methods. The +plugin's web page calls its backend through `functions.invoke`; the backend +uses these host requests with the invocation's workspace context. There is +no direct web `terminal.*` bridge and no new protocol version. + +| Method (prefix `lithe/host/terminal/`) | Params, in addition to `context` | Result | +| --- | --- | --- | +| `create` | `executable`, optional `arguments`, `title`, `cwd`, `environment`, `show`, `retainedContextBytes` | `{terminalID,generation:1}` | +| `show` | `terminalID` | `null` | +| `close` | `terminalID`, optional `retainedContextBytes` | `{retainedContextBytes}` after bounded native cleanup | +| `reopen` | `terminalID`, optional `show` | `{terminalID,generation,restoredContextBytes}` | +| `dispose` | `terminalID` | `null` after closing and forgetting the handle | +| `onExit` | `terminalID` | `{subscriptionID}` | + +`executable` is an absolute path or a bare command resolved by the native +terminal's PATH lookup; relative paths containing separators are refused. +`arguments` is an argv array, never shell text. `cwd` is a `{rootID,path}` +workspace directory resource (an empty path selects the folder root); without +it, the workspace root is used. The native adapter rejects a canonical cwd +outside that workspace. `environment` maps variable names to string overrides +or `null` removals. Process-ownership scope variables cannot be overridden. +`show` defaults to true; it selects the terminal in its own window without +raising a background window. With `show:false`, creation and reopening preserve +the current terminal selection and do not reveal the terminal panel or request +input focus; the native tab is mounted when the plugin explicitly calls `show`. +Unknown options fail before any side effect. + +Creation and reopening ask for execution consent with the program, argv and +working directory, all available in a scrollable, selectable native text view, +then revalidate context and cancellation before launching +through the existing PTY terminal module. The response waits for user choice +without a machine-operation deadline, but cancellation/session/context closure +still ends it. Native launch and process cleanup retain their own bounds. +The host never types a launch into an existing shell or reads other terminals. +There is no `sendText`, user-terminal enumeration, web emulator, or Docker +special case. First release is local authority only; a remote context fails +with `plugin.host.authorityUnsupported` instead of starting anything locally. + +Each handle belongs to one plugin backend session and one context generation. +A handle from another plugin, context or replacement backend is `notFound`. +At most eight handles per plugin are retained, including closed handles; +`dispose` releases one. Limits are 256 arguments, 16 KiB per argument or +environment value, 64 environment entries, 64 KiB combined launch data, and +128 UTF-8 bytes in a nonempty single-line title. `busy` refuses concurrent +close/reopen/dispose on the same handle. Reopening a running terminal is +`preconditionFailed`; showing a closed terminal asks the plugin to reopen it. + +#### Close, retained context and reopen + +`close` ends the process tree and removes the native tab, keeping the handle's +launch configuration and a plain-text suffix of its displayed terminal output. +`retainedContextBytes` is a nonnegative UTF-8 byte budget: default **16384**, +maximum **262144**, and **0** disables history. It can be chosen at create or +changed at close; over-limit values are refused, never silently expanded. +The result reports actual retained bytes, which can be smaller to avoid cutting +a Unicode scalar. The history is a rendering snapshot, not input events, raw +escape sequences, a resumable process image, or shell state. Secrets already +printed or echoed by a program can be part of that display; choose 0 when no +history should be retained. + +`reopen` uses the saved executable, argv, cwd and environment in a **new** PTY +process. It feeds retained plain text to the display before spawning, never to +stdin. Shell variables, unsubmitted input, process state and automatic command +replay are not restored. Natural exit and user tab close are also reopenable; +the snapshot is bounded by the same budget. It keeps `terminalID` and increments +`generation`; late exit callbacks of previous generations are ignored. These +handles/history exist only in memory, not across app/backend restart or +workspace close. No state is written to the app bundle or plugin package. + +Natural exit and user tab close run the same bounded native cleanup before +retaining the budgeted snapshot and releasing the complete native session and +render buffer. The handle is `busy` during cleanup; its exit notification is +sent after cleanup, so a subscriber can reopen immediately. A zero-byte budget +retains no display text on any close or exit path. + +`dispose` closes any live process and permanently forgets the launch settings +and history. Ending the backend session, disabling/updating the plugin or +closing/replacing its workspace does the same for only its owned terminals; +user-created terminals are unaffected. Closing a page alone does not close a +completed terminal creation. Cancellation before launch creates nothing; +cancellation or context loss during launch closes the newly created process. +Cancelled creation/reopen is not safe to retry automatically. + +`onExit` supports one live subscription per handle. Its event is +`{type:"exit",terminalID,generation,exitCode,reason}`, with an integer or null +exit code and `reason:"exited"|"closed"`. Each generation publishes one outcome. +Subscribing after exit delivers its recorded outcome once; the subscription +continues across reopen. Disposing it stops notifications and keeps the +terminal open. A disposed terminal has no further events. + +The executable and argv remain plugin-owned semantics. A container plugin can +launch the user's Docker CLI with an explicit engine and container ID; another +tool can launch its interactive client through exactly the same service. +Neither requires a fake Build/Run provider. diff --git a/contract/v1/fixtures/function-protocol.json b/contract/v1/fixtures/function-protocol.json index 6bd6b38..89cf0f6 100644 --- a/contract/v1/fixtures/function-protocol.json +++ b/contract/v1/fixtures/function-protocol.json @@ -535,7 +535,15 @@ }, {"name": "editor actions are a discoverable independent host service", "method": "lithe/host/editor/registerActions", "version": 3, "hostServices": ["lithe/host/editor/registerActions"], "params": {"context": {"contextID": "w1", "generation": 1}, "id": "actions", "function": "actions.collect", "selector": {"fileExtensions": ["http"]}}, "expected": {"forward": "editor"}}, {"name": "document reads do not grant editor action contribution", "method": "lithe/host/editor/registerActions", "version": 3, "hostServices": ["lithe/host/documents/getSnapshot"], "params": {}, "expected": {"code": "plugin.host.permissionDenied", "rpcCode": -32010}}, - {"name": "protocol 2 does not expose independent editor actions", "method": "lithe/host/editor/registerActions", "version": 2, "hostServices": ["lithe/host/editor/registerActions"], "params": {}, "expected": {"code": "plugin.host.unavailable", "rpcCode": -32601}} + {"name": "protocol 2 does not expose independent editor actions", "method": "lithe/host/editor/registerActions", "version": 2, "hostServices": ["lithe/host/editor/registerActions"], "params": {}, "expected": {"code": "plugin.host.unavailable", "rpcCode": -32601}}, + {"name": "terminal create is an individually granted protocol 3 method", "version": 3, "hostServices": ["lithe/host/terminal/create"], "method": "lithe/host/terminal/create", "params": {"context": {"contextID": "w-terminal", "generation": 1}, "executable": "example-tool"}, "expected": {"forward": "terminal"}}, + {"name": "terminal show is an individually granted protocol 3 method", "version": 3, "hostServices": ["lithe/host/terminal/show"], "method": "lithe/host/terminal/show", "params": {"context": {"contextID": "w-terminal", "generation": 1}, "terminalID": "owned"}, "expected": {"forward": "terminal"}}, + {"name": "terminal close is an individually granted protocol 3 method", "version": 3, "hostServices": ["lithe/host/terminal/close"], "method": "lithe/host/terminal/close", "params": {"context": {"contextID": "w-terminal", "generation": 1}, "terminalID": "owned"}, "expected": {"forward": "terminal"}}, + {"name": "terminal onExit is an individually granted protocol 3 method", "version": 3, "hostServices": ["lithe/host/terminal/onExit"], "method": "lithe/host/terminal/onExit", "params": {"context": {"contextID": "w-terminal", "generation": 1}, "terminalID": "owned"}, "expected": {"forward": "terminal"}}, + {"name": "terminal aggregate grant cannot authorize terminal creation", "version": 3, "hostServices": ["terminal"], "method": "lithe/host/terminal/create", "params": {}, "expected": {"code": "plugin.host.permissionDenied", "rpcCode": -32010}}, + {"name": "an older protocol cannot create a native terminal", "version": 2, "hostServices": ["lithe/host/terminal/create"], "method": "lithe/host/terminal/create", "params": {}, "expected": {"code": "plugin.host.unavailable", "rpcCode": -32601}}, + {"name": "terminal reopen is granted on protocol 3", "version": 3, "hostServices": ["lithe/host/terminal/reopen"], "method": "lithe/host/terminal/reopen", "params": {"context": {"contextID": "w-terminal", "generation": 1}, "terminalID": "owned"}, "expected": {"forward": "terminal"}}, + {"name": "terminal dispose is granted on protocol 3", "version": 3, "hostServices": ["lithe/host/terminal/dispose"], "method": "lithe/host/terminal/dispose", "params": {"context": {"contextID": "w-terminal", "generation": 1}, "terminalID": "owned"}, "expected": {"forward": "terminal"}} ], "invocationContexts": [ { diff --git a/contract/v1/fixtures/terminal-backend.plugin.json b/contract/v1/fixtures/terminal-backend.plugin.json new file mode 100644 index 0000000..18c20e0 --- /dev/null +++ b/contract/v1/fixtures/terminal-backend.plugin.json @@ -0,0 +1,35 @@ +{ + "schemaVersion": 2, + "id": "dev.lithe.fixture.terminalbackend", + "displayName": "Native Terminal Backend", + "version": "1.0.0", + "pluginAPIVersion": 3, + "requires": { + "backend": 2, + "serverProcess": 1 + }, + "vendor": { + "id": "org.example", + "displayName": "Example", + "signatureRequirement": "publisherPackage" + }, + "permissions": [ + "backend.launch", + "workspace.metadata", + "terminal.create" + ], + "activationEvents": [ + "onWorkspaceOpen" + ], + "languages": [], + "backend": { + "launch": { + "kind": "packaged", + "path": "bin/backend" + }, + "arguments": [ + "--state", + "${stateDir}" + ] + } +} diff --git a/contract/v1/plugin-manifest.schema.json b/contract/v1/plugin-manifest.schema.json index 3fa91c6..a344452 100644 --- a/contract/v1/plugin-manifest.schema.json +++ b/contract/v1/plugin-manifest.schema.json @@ -81,7 +81,8 @@ "preview.open", "toolchains.provide", "browser.open", - "window.messages"] } + "window.messages", + "terminal.create"] } }, "activationEvents": { "type": "array", diff --git a/guides/native-terminals.md b/guides/native-terminals.md index 65a4540..61bfa68 100644 --- a/guides/native-terminals.md +++ b/guides/native-terminals.md @@ -1,9 +1,11 @@ # Open a native Lithe terminal from a plugin These SDK wrappers are an **unreleased addition** and require a host that -advertises the terminal methods. The checked-in contract mirror remains pinned -to its published upstream commit until the terminal host change merges; it must -then be refreshed with `scripts/contract.mjs sync`, never edited manually. +advertises the terminal methods. The checked-in contract mirror is pinned to +the merged host change, [Lithe #1653](https://github.com/1lck/Lithe-IDEA/pull/1653), +at `f64d262c48f1ff5ad1778b301138596493a09ad5`. The mirror is generated by +`scripts/contract.mjs sync`, never edited manually. See the +[native terminal contract](../contract/v1/README.md#plugin-owned-native-terminals-function-protocol-3). Use the host's integrated terminal for an interactive client. Declare `requires.backend: 2` and permission `terminal.create`; a web page invokes a @@ -16,6 +18,9 @@ An old host or Windows host can lack them. The first implementation supports only a local workspace on macOS; it explicitly refuses a remote authority. The host checks the caller, displays execution confirmation, and manages the PTY, rendering, focus, resize and bounded process-tree shutdown. +With `show:false`, create/reopen preserve the current terminal selection and +do not reveal the terminal panel or request input focus. Call `show` when the +new tab should become visible. ## Node.js @@ -102,6 +107,10 @@ recovers process memory or resumes shell variables. It keeps `terminalID` and increases `generation`; events include generation so old exits cannot be mistaken for the new process. The exit subscription continues across reopen. Natural exit and user tab close are reopenable too. +Both paths finish bounded native cleanup, retain only the chosen snapshot, +and release the native session and render buffer before notifying subscribers. +The handle is busy during cleanup; an exit subscriber may reopen immediately +after receiving the event. A zero-byte budget keeps no history on any exit path. `dispose` forgets the handle, launch settings and history, closing a live session first. Eight handles per plugin (including closed ones) are retained, so dispose @@ -113,11 +122,13 @@ The Rust sample's `terminal.cycle` function and Core end-to-end scenario exercis the protocol roundtrip. That scenario supplies host responses; Lithe's own `PluginTerminalNativeTests` verify actual native PTY input and output/history. -Before the host change merges, run the opt-in terminal protocol scenario with: +The terminal lifecycle scenario now runs by default against the pinned host: ```sh -LITHE_PLUGIN_SDK_TERMINAL_E2E=1 node scripts/sdk-e2e.mjs --source /path/to/Lithe-worktree --working-tree +node scripts/sdk-e2e.mjs ``` -Without that flag the scenario continues to validate the published Core pin; -the ungranted terminal method remains discoverably unavailable on that host. +The sample declares `terminal.create` in its installed manifest, so the test +uses the same grants an installed plugin receives. Older hosts without this +permission refuse that sample manifest; other plugins that do not declare it +retain their existing compatibility. diff --git a/sdk/e2e/plugin_sdk/backend.rs b/sdk/e2e/plugin_sdk/backend.rs index 0624f85..250d537 100644 --- a/sdk/e2e/plugin_sdk/backend.rs +++ b/sdk/e2e/plugin_sdk/backend.rs @@ -20,15 +20,11 @@ use std::time::Instant; /// Runs the scenario against the installed package at `package`, whose /// `plugin.json` declares the sample backend. pub fn run_backend_scenario(package: &Path) { - let mut manifest: Value = + let manifest: Value = serde_json::from_slice(&fs::read(package.join("plugin.json")).expect("sample manifest")) .expect("sample manifest JSON"); let plugin_id = manifest["id"].as_str().expect("plugin ID").to_string(); let package_before = listing(package); - // Opt in against the terminal host branch before its contract is published. - // Default CI must still work against the pinned older Core. - let terminals = std::env::var_os("LITHE_PLUGIN_SDK_TERMINAL_E2E").is_some(); - if terminals { manifest["permissions"].as_array_mut().unwrap().push(json!("terminal.create")); } let validated = core( "plugins.declarative.validate", json!({ "manifest": manifest }), @@ -38,14 +34,23 @@ pub fn run_backend_scenario(package: &Path) { let fixture = Fixture::new("backend"); let state = fixture.root.join("state"); let plan = launch_plan(&manifest, package, &state); - let mut grants = vec!["credentials", "lithe/host/window/showError", "lithe/host/window/showInformation", - "lithe/host/window/showWarning", "lithe/host/workspace/watch", "workspace"]; - if terminals { - grants.extend(["lithe/host/terminal/close", "lithe/host/terminal/create", "lithe/host/terminal/dispose", - "lithe/host/terminal/onExit", "lithe/host/terminal/reopen", "lithe/host/terminal/show"]); - } - grants.sort(); - assert_eq!(plan["backend"]["hostServices"], json!(grants)); + assert_eq!( + plan["backend"]["hostServices"], + json!([ + "credentials", + "lithe/host/terminal/close", + "lithe/host/terminal/create", + "lithe/host/terminal/dispose", + "lithe/host/terminal/onExit", + "lithe/host/terminal/reopen", + "lithe/host/terminal/show", + "lithe/host/window/showError", + "lithe/host/window/showInformation", + "lithe/host/window/showWarning", + "lithe/host/workspace/watch", + "workspace" + ]) + ); assert_eq!(plan["requiredDirectories"], json!(["state"])); fs::create_dir_all(&state).expect("host state directory"); @@ -173,7 +178,7 @@ pub fn run_backend_scenario(package: &Path) { wait_for_file(&mut session, &state.join("cancelled")); run_host_services(&mut session); - if terminals { run_terminal_services(&mut session); } + run_terminal_services(&mut session); // State survives a restart. Without credentials.store the host grants no // host service, and Core refuses the request before the host sees it. @@ -389,19 +394,38 @@ fn run_terminal_services(session: &mut Session) { invoke_in(session, "terminal-cycle", "terminal.cycle", &workspace()); let create = take_host_request(session, "lithe/host/terminal/create"); assert_eq!(create["result"]["params"]["retainedContextBytes"], 4096); - assert_eq!(create["result"]["params"]["arguments"], json!(["--interactive"])); - respond(session, &create, Ok(json!({ "terminalID":"terminal-1", "generation":1 }))); + assert_eq!( + create["result"]["params"]["arguments"], + json!(["--interactive"]) + ); + respond( + session, + &create, + Ok(json!({ "terminalID":"terminal-1", "generation":1 })), + ); let listen = take_host_request(session, "lithe/host/terminal/onExit"); - respond(session, &listen, Ok(json!({ "subscriptionID":"terminal-sub" }))); + respond( + session, + &listen, + Ok(json!({ "subscriptionID":"terminal-sub" })), + ); let close = take_host_request(session, "lithe/host/terminal/close"); respond(session, &close, Ok(json!({ "retainedContextBytes":1024 }))); - assert!(notify(session, "lithe/host/subscriptions/notify", json!({ - "subscriptionID":"terminal-sub", "sequence":1, - "event": { "type":"exit", "terminalID":"terminal-1", "generation":1, "exitCode":null, "reason":"closed" } - }))); + assert!(notify( + session, + "lithe/host/subscriptions/notify", + json!({ + "subscriptionID":"terminal-sub", "sequence":1, + "event": { "type":"exit", "terminalID":"terminal-1", "generation":1, "exitCode":null, "reason":"closed" } + }) + )); let reopen = take_host_request(session, "lithe/host/terminal/reopen"); assert_eq!(reopen["result"]["params"]["terminalID"], "terminal-1"); - respond(session, &reopen, Ok(json!({ "terminalID":"terminal-1", "generation":2, "restoredContextBytes":1024 }))); + respond( + session, + &reopen, + Ok(json!({ "terminalID":"terminal-1", "generation":2, "restoredContextBytes":1024 })), + ); let dispose = take_host_request(session, "lithe/host/terminal/dispose"); respond(session, &dispose, Ok(Value::Null)); let subscription = take_host_request(session, "lithe/host/subscriptions/dispose"); diff --git a/sdk/rust/examples/sample-backend/plugin.json b/sdk/rust/examples/sample-backend/plugin.json index b5bad05..778d81a 100644 --- a/sdk/rust/examples/sample-backend/plugin.json +++ b/sdk/rust/examples/sample-backend/plugin.json @@ -12,6 +12,7 @@ }, "permissions": [ "backend.launch", + "terminal.create", "credentials.store", "ui.contribute", "ui.web",