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

tabs.test.ts8.2 KB
// Files open through the convergence/tabs service and follow its visible
// tab. Real activation with a canned tabs client: opening calls the
// service, the active event selects the file, and without a provider
// opening does nothing.
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));

function fakeApi(withTabs: boolean, activeTab: { kind: string; path: string } | null) {
  let state: AppState = { workspaceId: "w1", shared: { rightPanel: "files" } };
  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 calls: { 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 call = async (method: string, params: unknown) => {
    calls.push({ method, params });
    if (method === "host/fs.search") return { paths: ["README.md", "src/a.ts", "src/z.ts"] };
    throw new Error(`Unexpected host 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 () => activeTab, {
      result: async () => ({ ok: true as const, value: activeTab }),
    }),
    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: "files",
      id: "convergence/files",
      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, kernel: call },
      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: "files",
          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("files 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,
    tabsCalls,
    update,
    settle,
    invoke,
    trees: () => stable(trees),
    tabsActive: (tab: { kind: string; path: string } | null) => {
      for (const fn of tabsSubs.get("convergence/tabs|activeChanged") ?? []) fn(tab);
    },
  };
}

test("selecting a file calls the tabs service, and the visible tab selects it", async () => {
  const fake = fakeApi(true, { kind: "file", path: "README.md" });
  const dispose = activate(fake.api);
  try {
    await fake.settle();
    // The initial visible tab arrives through the service, not shared state.
    assert.ok(fake.trees().includes('"selected":"README.md"'));
    fake.invoke("files-w1", "onSelect", { id: "src/a.ts", folder: false });
    await fake.settle();
    assert.deepEqual(fake.tabsCalls, [
      { method: "open", params: { kind: "file", workspaceId: "w1", path: "src/a.ts" } },
    ]);
    fake.tabsActive({ kind: "file", path: "src/a.ts" });
    await fake.settle();
    assert.ok(fake.trees().includes('"selected":"src/a.ts"'));
    // A non-file tab clears the selection.
    fake.tabsActive({ kind: "terminal", path: "t1" });
    await fake.settle();
    assert.ok(fake.trees().includes('"selected":null'));
  } finally {
    await dispose();
  }
});

test("without the tabs plugin selecting a file does nothing", async () => {
  const fake = fakeApi(false, null);
  const dispose = activate(fake.api);
  try {
    await fake.settle();
    fake.invoke("files-w1", "onSelect", { id: "src/a.ts", folder: false });
    await fake.settle();
    assert.deepEqual(fake.tabsCalls, []);
    assert.ok(fake.trees().includes('"selected":null'));
  } finally {
    await dispose();
  }
});

Versions

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

Reviews and comments

0 threads · 0 reviews

No comments yet.