Official
terminal
Built-in integrated terminal (JavaScript UI plugin).
The app opens the listing; nothing installs until an agent in your Plugins workspace has read the files and you enable the plugin. In a terminal: cvg install convergence/terminal@0.2.0
Permissions in 0.2.0
Take care. This plugin asks for permissions that can do anything your account can. The app asks you to hold Enable for two seconds or to type the plugin's name before it turns on.
Files
tabs.test.ts9.3 KB
// Terminals pop out and close through the convergence/tabs service and
// follow its visible tab. Real activation with a canned tabs client:
// popping out calls open, closing calls close, the active event marks the
// pane session, and without a provider the kernel calls still run.
import assert from "node:assert/strict";
import { test } from "node:test";
import * as z from "zod";
import type {
Api,
AppState,
AppStatePatch,
HostEvent,
RenderResult,
SlotOptions,
ViewContext,
} from "convergence";
import { ui } from "convergence";
import { testLayer } from "convergence/effect/testing";
import { activate } from "./main.ts";
const tick = () => new Promise<void>((resolve) => setImmediate(resolve));
const stable = (value: unknown) =>
JSON.stringify(value, (_key, item: unknown) => (typeof item === "function" ? "@handler" : item));
const session = (id: string) => ({
id,
title: id,
userTitle: null,
cwd: "/fixture",
workspaceId: "w1",
running: true,
exitCode: null,
});
function fakeApi(withTabs: boolean) {
let state: AppState = { workspaceId: "w1", shared: { rightPanelPreview: "terminal" } };
const appListeners = new Set<(state: AppState) => void>();
const views: { render: (ctx: ViewContext<object>) => RenderResult; active: boolean }[] = [];
const listeners = new Map<string, Set<(event: HostEvent) => unknown>>();
const kernelCalls: { method: string; params: unknown }[] = [];
const hostCalls: { method: string; params: unknown }[] = [];
const tabsCalls: { method: string; params: unknown }[] = [];
const tabsSubs = new Map<string, Set<(payload: unknown) => void>>();
let dirty = true;
const update = (patch: AppStatePatch) => {
state = {
...state,
...patch,
workspaceId: patch.workspaceId === null ? undefined : (patch.workspaceId ?? state.workspaceId),
chatId: patch.chatId === null ? undefined : (patch.chatId ?? state.chatId),
route: patch.route ?? state.route,
shared: { ...state.shared, ...patch.shared },
};
dirty = true;
for (const listener of appListeners) listener(state);
};
const hostCall = async (method: string, params: unknown) => {
hostCalls.push({ method, params });
if (method === "host/workspaces.list")
return { workspaces: [{ id: "w1", name: "Workspace", path: "/fixture", position: 0 }] };
throw new Error(`Unexpected host method ${method}`);
};
const kernelCall = async (method: string, params: unknown) => {
kernelCalls.push({ method, params });
switch (method) {
case "terminal.list":
return { terminals: [session("t1")] };
case "terminal.create":
return session("t2");
case "terminal.close":
case "terminal.focus":
case "terminal.rename":
return {};
default:
throw new Error(`Unexpected kernel method ${method}`);
}
};
const tabsClient = {
open: Object.assign(async (params: unknown) => ({ id: "tab" }), {
// The Effect adapter calls through `result`, never the direct call.
result: async (params: unknown) => {
tabsCalls.push({ method: "open", params });
return { ok: true as const, value: { id: "tab" } };
},
}),
close: Object.assign(async () => null, {
result: async (params: unknown) => {
tabsCalls.push({ method: "close", params });
return { ok: true as const, value: null };
},
}),
active: Object.assign(async () => null, {
result: async () => ({ ok: true as const, value: null }),
}),
on: (event: string, fn: (payload: unknown) => void) => {
const key = `convergence/tabs|${event}`;
const set = tabsSubs.get(key) ?? new Set<(payload: unknown) => void>();
tabsSubs.set(key, set);
set.add(fn);
return () => {
set.delete(fn);
};
},
};
const broker = testLayer();
// This assertion describes a local fake; unused API methods fail rather than impersonating external replies.
const api = new Proxy(
{
...broker.api,
plugin: "terminal",
id: "convergence/terminal",
ui,
services: {
provide: async () => ({ emit: () => {}, remove: () => {} }),
get: (name: string) => {
if (name === "convergence/tabs" && withTabs) return tabsClient;
throw new Error(`no provider for ${name}`);
},
available: (name: string) => name === "convergence/tabs" && withTabs,
when: (name: string, fn: (client: unknown) => void) => {
fn(name === "convergence/tabs" && withTabs ? tabsClient : undefined);
return () => {};
},
},
host: { ...broker.api.host, call: hostCall, kernel: kernelCall },
app: {
state: () => state,
update,
share: (key: string, value: unknown) => update({ shared: { [key]: value } }),
onChange: (fn: (state: AppState) => void) => {
appListeners.add(fn);
return () => {
appListeners.delete(fn);
};
},
},
slot: (slot: string, render: (ctx: ViewContext<object>) => RenderResult, _options?: SlotOptions) => {
const view = { render, active: true };
views.push(view);
dirty = true;
return {
id: views.length,
update: () => {
dirty = true;
},
remove: () => {
view.active = false;
},
};
},
on: (name: string, fn: (event: HostEvent) => unknown) => {
const subscribers = listeners.get(name) ?? new Set();
listeners.set(name, subscribers);
subscribers.add(fn);
return () => {
subscribers.delete(fn);
};
},
command: () => ({ remove() {} }),
notify: () => {},
focus: () => {},
},
{
get(target, key, receiver) {
if (Reflect.has(target, key)) return Reflect.get(target, key, receiver);
throw new Error(`Unexpected API ${String(key)}`);
},
},
) as unknown as Api;
const render = () =>
views
.filter((view) => view.active)
.map((view) =>
view.render({
state: {},
app: state,
plugin: "terminal",
update: () => {
dirty = true;
},
}),
);
let trees: RenderResult[] = [];
const settle = async () => {
for (let round = 0; round < 50; round++) {
await tick();
if (!dirty) {
await tick();
if (!dirty) return;
}
dirty = false;
trees = render();
}
throw new Error("terminal did not settle");
};
const nodeSchema = z.object({ id: z.string().optional(), children: z.array(z.unknown()).optional() }).passthrough();
function found(tree: unknown): Record<string, unknown>[] {
if (Array.isArray(tree)) return tree.flatMap(found);
const parsed = nodeSchema.safeParse(tree);
if (!parsed.success) return [];
return [
parsed.data,
...(parsed.data.children ?? []).flatMap(found),
...(Array.isArray(parsed.data.items) ? parsed.data.items.flatMap(found) : []),
];
}
const invoke = (id: string, event = "onClick", payload: unknown = {}) => {
const node = trees.flatMap((view) => found(view)).find((node) => node.id === id);
assert.ok(node, `Missing ${id}`);
const handler = z.custom<(payload: unknown) => unknown>((value) => typeof value === "function").parse(node[event]);
handler(payload);
};
return {
api,
kernelCalls,
tabsCalls,
update,
settle,
invoke,
trees: () => stable(trees),
state: () => state,
tabsActive: (tab: { kind: string; path: string } | null) => {
for (const fn of tabsSubs.get("convergence/tabs|activeChanged") ?? []) fn(tab);
},
};
}
test("popping out opens a tab and closing closes it through the tabs service", async () => {
const fake = fakeApi(true);
const dispose = activate(fake.api);
try {
await fake.settle();
assert.ok(fake.kernelCalls.some((call) => call.method === "terminal.list"));
// While the fly-out is open the visible tab marks the pane session.
fake.tabsActive({ kind: "terminal", path: "t1" });
await fake.settle();
assert.ok(fake.trees().includes("This terminal is open in the tabbed pane."));
fake.tabsActive({ kind: "file", path: "a.ts" });
await fake.settle();
assert.ok(!fake.trees().includes("This terminal is open in the tabbed pane."));
fake.invoke("terminal-close-t1");
await fake.settle();
assert.deepEqual(fake.tabsCalls.at(-1), { method: "close", params: { kind: "terminal", path: "t1" } });
assert.ok(fake.kernelCalls.some((call) => call.method === "terminal.close"));
fake.invoke("terminal-popout");
await fake.settle();
assert.deepEqual(fake.tabsCalls.at(-1), {
method: "open",
params: { kind: "terminal", workspaceId: "w1", path: "t1", title: "t1" },
});
assert.ok(fake.kernelCalls.some((call) => call.method === "terminal.focus"));
assert.equal(fake.state().shared?.openTab, undefined);
assert.equal(fake.state().shared?.closeTab, undefined);
} finally {
await dispose();
}
});
test("without the tabs plugin popping out still focuses the session", async () => {
const fake = fakeApi(false);
const dispose = activate(fake.api);
try {
await fake.settle();
fake.invoke("terminal-popout");
await fake.settle();
assert.deepEqual(fake.tabsCalls, []);
assert.ok(fake.kernelCalls.some((call) => call.method === "terminal.focus"));
} finally {
await dispose();
}
});Versions
| Version | Published | Plugin API | Size | Permissions | Status |
|---|---|---|---|---|---|
| 0.2.0latest | Oct 5, 2026 | >=2 <3 | 8.7 KB | 3 permissions | Listed |
No comments yet.