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.
  • Terminals terminalDangerousCreates and controls terminals, which run a shell.Run shells in the integrated terminal
  • Read chats chats.readMediumReads your transcripts and chat lists.Start shells in the current workspace
  • Show panels ui.slotsLowShows views in the listed parts of the window.Show the terminal button and its windowSlots: railright

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

VersionPublishedPlugin APISizePermissionsStatus
0.2.0latestOct 5, 2026>=2 <38.7 KB3 permissionsListed

Reviews and comments

0 threads · 0 reviews

No comments yet.