Official

tabs

The tabbed detail pane beside the chat: opened files and diffs (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/tabs@0.2.0

Permissions in 0.2.0

  • Read files fs.readMediumReads files in the listed places.Open workspace files in tabsPlaces: the open workspace
  • Read chats chats.readMediumReads your transcripts and chat lists.Reload the open file when an agent finishes a turn
  • Show panels ui.slotsLowShows views in the listed parts of the window.Draw the tabbed pane beside the chatSlots: detail

Files

controller.ts4.8 KB
// Tabs' scoped effects serve the convergence/tabs service and consume legacy shared requests.
// The service emits which tab is visible; third-party plugins still open and
// close through the shared openTab/closeTab keys those writes once were.
// File reads run in the layer scope so closing/unloading never leaves a detached task.
import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import { App, Host, Notify, parse } from "convergence/effect";
import type { AppState, ViewHandle } from "convergence";
import { closeRequest, closeTab, fileResponse, initialState, openRequest, openTab, readContent } from "./tabs.ts";
import type { Tab, TabInput } from "./tabs.ts";

// What the convergence/tabs events carry: which tab, or none visible.
export interface TabRef {
  readonly kind: Tab["kind"];
  readonly path: string;
}

const makeTabs = Effect.fn("Tabs.make")(function* () {
  const host = yield* Host;
  const app = yield* App;
  const notify = yield* Notify;
  const scope = yield* Effect.scope;
  const state = initialState();
  const views: ViewHandle[] = [];
  const redraw = () => {
    for (const view of views) view.update();
  };
  // The service handle is set after the views register; until a provider
  // handle exists there is nobody to tell.
  let emitActive: ((tab: TabRef | null) => void) | undefined;
  const announce = (tab: Tab | undefined) => {
    emitActive?.(tab ? { kind: tab.kind, path: tab.path } : null);
  };
  const readFile = Effect.fn("Tabs.readFile")(function* (tab: TabInput, id: string) {
    yield* host.call("host/fs.read", { workspaceId: tab.workspaceId, path: tab.path }).pipe(
      Effect.flatMap((result) => parse("file contents", fileResponse, result)),
      Effect.tap((result) =>
        Effect.sync(() => {
          state.contents[id] = readContent(result);
        }),
      ),
      Effect.catchTag(["HostCallFailed", "NeedsReview", "PermissionNotGranted", "ParseFailed"], (error) =>
        Effect.sync(() => {
          state.contents[id] = {
            error: error._tag === "ParseFailed" ? `${error.what}: ${error.issues.join("; ")}` : error.message,
          };
        }),
      ),
    );
    redraw();
  });
  const open = Effect.fn("Tabs.open")(function* (request: TabInput) {
    const tab = openTab(state, request);
    announce(tab);
    redraw();
    if (request.kind === "file") yield* Effect.forkIn(readFile(request, tab.id), scope, { startImmediately: true });
    return tab;
  });
  const close = Effect.fn("Tabs.close")(function* (id: string) {
    announce(closeTab(state, id));
    redraw();
  });
  const select = Effect.fn("Tabs.select")(function* (tab: Tab) {
    state.active = tab.id;
    announce(tab);
    redraw();
  });
  // The convergence/tabs service methods: open and close by reference, and
  // which tab is visible. They share the open path above, without consuming
  // a legacy request.
  const closeByRef = Effect.fn("Tabs.closeByRef")(function* (ref: TabRef) {
    const tab = state.tabs.find((tab) => tab.kind === ref.kind && tab.path === ref.path);
    if (tab) yield* close(tab.id);
    return null;
  });
  const activeRef = Effect.fn("Tabs.activeRef")(function* (): Effect.fn.Return<TabRef | null> {
    const tab = state.tabs.find((tab) => tab.id === state.active) ?? state.tabs[0];
    return tab ? { kind: tab.kind, path: tab.path } : null;
  });
  const followApp = Effect.fn("Tabs.followApp")(
    function* (next: AppState) {
      const request = next.shared?.openTab;
      if (request) {
        const parsed = yield* parse("openTab", openRequest, request);
        if (parsed.nonce !== state.handled) {
          state.handled = parsed.nonce;
          yield* open(parsed);
          // Consumed requests must not reopen closed tabs after the app restarts.
          yield* app.update({ shared: { openTab: null } });
        }
      }
      const gone = next.shared?.closeTab;
      if (gone) {
        const parsed = yield* parse("closeTab", closeRequest, gone);
        if (parsed.nonce !== state.handledClose) {
          state.handledClose = parsed.nonce;
          yield* app.update({ shared: { closeTab: null } });
          const tab = state.tabs.find((tab) => tab.kind === parsed.kind && tab.path === parsed.path);
          if (tab) yield* close(tab.id);
        }
      }
    },
    // Invalid shared data is reported without ending the request subscription.
    Effect.catchTag("ParseFailed", (error) => notify.error(`${error.what}: ${error.issues.join("; ")}`)),
  );
  return {
    state,
    views,
    open,
    close,
    closeByRef,
    activeRef,
    select,
    followApp,
    emitActive: (emit: typeof emitActive) => {
      emitActive = emit;
    },
  };
});

export class Tabs extends Context.Service<Tabs, Effect.Success<ReturnType<typeof makeTabs>>>()("tabs/Tabs") {
  static readonly layer = Layer.effect(Tabs, makeTabs());
}

Versions

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

Reviews and comments

0 threads · 0 reviews

No comments yet.