Official

files

Built-in files UI (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/files@0.2.0

Permissions in 0.2.0

  • Read files fs.readMediumReads files in the listed places.List the files of the workspacePlaces: the open workspace
  • Read chats chats.readMediumReads your transcripts and chat lists.Refresh the file list when an agent finishes a turn
  • Show panels ui.slotsLowShows views in the listed parts of the window.Show the files button and panelSlots: railright

Files

controller.ts4.2 KB
// Scoped file-panel effects: host reads and the convergence/tabs client.
// A layer owns one state instance for both registered views.
import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
import * as Fiber from "effect/Fiber";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Stream from "effect/Stream";
import { App, Host, Notify, Services } from "convergence/effect";
import type { EffectClient } from "convergence/effect";
import type { AppState, ViewHandle } from "convergence";
import type { Tabs } from "../tabs/contract.ts";
import { initialState, isOpen } from "./panel.ts";

const makeFiles = Effect.fn("Files.make")(function* () {
  const host = yield* Host;
  const app = yield* App;
  const notify = yield* Notify;
  const services = yield* Services;
  const scope = yield* Effect.scope;
  const state = initialState();
  const views: ViewHandle[] = [];
  const refresh = Effect.fn("Files.refresh")(function* (workspaceId: string) {
    state.workspaceId = workspaceId;
    yield* host.call("host/fs.search", { workspaceId, limit: 100000 }).pipe(
      Effect.tap((result) =>
        Effect.sync(() => {
          state.paths = result.paths;
        }),
      ),
      Effect.catchTag(["HostCallFailed", "NeedsReview", "PermissionNotGranted"], (error) =>
        notify.warning(`Files unavailable: ${error.message}`),
      ),
    );
    state.loaded = true;
    for (const view of views) view.update();
  });
  const followApp = Effect.fn("Files.followApp")(function* (next: AppState) {
    if (isOpen(next) && next.workspaceId && next.workspaceId !== state.workspaceId) yield* refresh(next.workspaceId);
  });
  const openFile = Effect.fn("Files.openFile")(function* (workspaceId: string, path: string) {
    const client = state.tabs;
    // Without the tabs plugin there is no detail pane; the shared request
    // the baseline wrote would have had no reader either.
    if (!client) return;
    yield* client.open({ kind: "file", workspaceId, path }).pipe(
      // A call that loses its provider on the way behaves like no reader.
      Effect.ignore,
    );
  });
  const toggle = Effect.fn("Files.toggle")((next: AppState) =>
    app.update({
      shared: {
        rightPanel: next.shared?.rightPanel === "files" ? null : "files",
      },
    }),
  );
  // The open file 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("Files.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;
          const shown = (tab: { kind: string; path: string } | null) => {
            state.activePath = tab?.kind === "file" ? tab.path : null;
          };
          state.follow = yield* Effect.forkIn(
            client.on("activeChanged").pipe(
              Stream.runForEach((tab) =>
                Effect.sync(() => {
                  shown(tab);
                  for (const view of views) view.update();
                }),
              ),
            ),
            scope,
            { startImmediately: true },
          );
          yield* client.active({}).pipe(
            Effect.tap((tab) =>
              Effect.sync(() => {
                shown(tab);
                for (const view of views) view.update();
              }),
            ),
            // A provider that goes away mid-read leaves the last selection.
            Effect.ignore,
          );
        }),
      ),
      Effect.forkIn(scope, { startImmediately: true }),
    );
  });
  yield* followTabs();
  return { state, views, refresh, followApp, openFile, toggle, close: app.update({ shared: { rightPanel: null } }) };
});

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

Versions

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

Reviews and comments

0 threads · 0 reviews

No comments yet.