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
controller.ts7.2 KB
// Terminal effects manage kernel sessions and the convergence/tabs client
// for the detail pane. The view boundary triggers creation at the same point
// as the JavaScript baseline.
import * as Effect from "effect/Effect";
import * as Fiber from "effect/Fiber";
import * as Option from "effect/Option";
import * as Scope from "effect/Scope";
import * as Stream from "effect/Stream";
import type { Terminal } from "convergence";
import { App, Host, Kernel, Notify, Services, Views } from "convergence/effect";
import type { EffectClient, HostError, KernelCallFailed } from "convergence/effect";
import type { Tabs } from "../tabs/contract.ts";
import { activeTerminal, nextTerminal, pathsByWorkspace, TerminalState } from "./state.ts";
type Failure = HostError | KernelCallFailed;
export const controller = Effect.fn("Terminal.controller")(function* () {
const state = yield* TerminalState;
const kernel = yield* Kernel;
const host = yield* Host;
const app = yield* App;
const notify = yield* Notify;
const views = yield* Views;
const services = yield* Services;
const scope = yield* Scope.Scope;
const update = () => {
state.flyOut?.update();
state.rail?.update();
};
const workspacePath = Effect.fn("Terminal.workspacePath")(function* (workspaceId: string) {
if (state.paths[workspaceId]) return state.paths[workspaceId];
const { workspaces } = yield* host.call("host/workspaces.list", {});
Object.assign(state.paths, pathsByWorkspace(workspaces));
return state.paths[workspaceId];
});
const refresh = Effect.fn("Terminal.refresh")(function* (): Effect.fn.Return<void, Failure> {
state.terminals = (yield* kernel.call("terminal.list", {})).terminals;
update();
});
const create = Effect.fn("Terminal.create")(function* (
workspaceId: string | null | undefined,
): Effect.fn.Return<Terminal | null> {
if (!workspaceId || state.creating.has(workspaceId)) return null;
state.creating.add(workspaceId);
return yield* Effect.gen(function* () {
const cwd = yield* workspacePath(workspaceId);
const terminal = yield* kernel.call("terminal.create", { cwd, workspaceId });
state.activeByWorkspace[workspaceId] = terminal.id;
yield* refresh();
return terminal;
}).pipe(
Effect.catchTag(
["HostCallFailed", "KernelCallFailed", "PermissionNotGranted", "NeedsReview"],
Effect.fn("Terminal.createFailed")(function* (error) {
yield* notify.error(`Cannot open a terminal: ${error.message}`);
return null;
}),
),
Effect.ensuring(
Effect.sync(() => {
state.creating.delete(workspaceId);
}),
),
);
});
const ensureVisible = Effect.fn("Terminal.ensureVisible")(function* () {
const current = yield* app.state;
if (
state.ready &&
current.shared?.rightPanelPreview === "terminal" &&
current.workspaceId &&
!activeTerminal(state, current.workspaceId)
) {
yield* Effect.forkIn(create(current.workspaceId), scope, { startImmediately: true });
}
});
const close = Effect.fn("Terminal.close")(function* (terminal: Terminal) {
const next = nextTerminal(state.terminals, terminal);
state.menu = null;
const client = state.tabs;
// Without the tabs plugin there is no detail pane to close; the shared
// request the baseline wrote would have had no reader either.
if (client)
yield* client.close({ kind: "terminal", path: terminal.id }).pipe(
// A call that loses its provider on the way behaves like no reader.
Effect.ignore,
);
yield* kernel.call("terminal.close", { id: terminal.id });
state.activeByWorkspace[String(terminal.workspaceId)] = next?.id ?? null;
yield* refresh();
});
const rename = Effect.fn("Terminal.rename")(function* (terminal: Terminal, title: string) {
state.renaming = null;
yield* kernel.call("terminal.rename", { id: terminal.id, title });
yield* refresh();
});
const popOut = Effect.fn("Terminal.popOut")(function* (terminal: Terminal) {
state.menu = null;
yield* app.update({ shared: { rightPanelPreview: null } });
const client = state.tabs;
// The tab opens beside the focus, not before it: the pane can draw the
// session as soon as the kernel has it. A call that loses its provider
// on the way behaves like no reader.
if (client && terminal.workspaceId)
yield* Effect.forkIn(
client
.open({ kind: "terminal", workspaceId: terminal.workspaceId, path: terminal.id, title: terminal.title })
.pipe(Effect.ignore),
scope,
{ startImmediately: true },
);
// Focus is best effort in the baseline: the detail pane can still display a session if it fails.
yield* Effect.forkIn(
kernel
.call("terminal.focus", { id: terminal.id })
.pipe(Effect.catchTag(["KernelCallFailed", "PermissionNotGranted"], () => Effect.void)),
scope,
{ startImmediately: true },
);
update();
});
const openInPane = Effect.fn("Terminal.openInPane")(function* (fresh = false) {
const { workspaceId } = yield* app.state;
if (!workspaceId) return yield* notify.warning("Open a workspace to use the terminal.");
const terminal = (fresh ? null : activeTerminal(state, workspaceId)) ?? (yield* create(workspaceId));
if (terminal) yield* popOut(terminal);
});
const startRename = Effect.fn("Terminal.startRename")(function* (terminal: Terminal) {
state.menu = null;
state.renaming = terminal.id;
update();
yield* views.focus(`terminal-rename-${terminal.id}`);
});
// Whether the tabbed pane shows a session follows the visible tab while
// the tabs plugin runs. One episode per provider: the previous follow ends
// when the provider goes.
const followTabs = Effect.fn("Terminal.followTabs")(function* () {
yield* services.when<typeof Tabs>("convergence/tabs").pipe(
Stream.runForEach((option) =>
Effect.gen(function* () {
if (state.follow) {
yield* Fiber.interrupt(state.follow);
state.follow = undefined;
}
if (Option.isNone(option)) {
state.tabs = undefined;
return;
}
const client: EffectClient<typeof Tabs> = option.value;
state.tabs = client;
state.follow = yield* Effect.forkIn(
client.on("activeChanged").pipe(
Stream.runForEach((tab) =>
Effect.sync(() => {
state.activeTab = tab;
update();
}),
),
),
scope,
{ startImmediately: true },
);
yield* client.active({}).pipe(
Effect.tap((tab) =>
Effect.sync(() => {
state.activeTab = tab;
update();
}),
),
// A provider that goes away mid-read leaves the last tab.
Effect.ignore,
);
}),
),
Effect.forkIn(scope, { startImmediately: true }),
);
});
yield* followTabs();
return { state, update, refresh, create, ensureVisible, close, rename, popOut, openInPane, startRename, scope };
});
export type Controller = Effect.Success<ReturnType<typeof controller>>;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.